Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
Pre-PR codex review found a gap in the R1.1 annotation grammar handling: * scanTagAfterOpener treated the first '_' after an opener as the closing delimiter regardless of paren nesting, so a legitimate tag like `_Requirements: R1.1 (foo_bar)_` would incorrectly close at the underscore inside the annotation. * parseTagBody split on every ', ' regardless of paren context, so `_Requirements: R1.1 (note, with comma), R2.1_` split into three bogus refs. Both are per-spec: PRD R1.1 defines annotation body as 'arbitrary non-) characters', which includes _ and ,. The state machine and the ref-splitter are now paren-depth-aware; regression tests added for both cases.
792 lines
27 KiB
Go
792 lines
27 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/yuin/goldmark/ast"
|
||
east "github.com/yuin/goldmark/extension/ast"
|
||
)
|
||
|
||
// Indexer types — see design §3.3.
|
||
//
|
||
// BuildIndex (task 3.8) walks the Plan's ScanResults and populates Index. The
|
||
// types below are the data shapes only; extraction helpers (tag state machine,
|
||
// R-id / D§ / task-line walkers) land in subsequent sub-tasks.
|
||
//
|
||
// Diagnostic, Severity, and Code live here for now so Index.MalformedTags has
|
||
// something to name. Task 4.1 moves them into lint.go alongside the rule-check
|
||
// functions and the remaining Code constants.
|
||
|
||
// spec:planctl/D§3.3
|
||
// Plan is the in-memory representation of a plan directory: the three primary
|
||
// markdown files (each optional by convention — design.md is skipped on
|
||
// trivial features per R2.8; tasks.md may not exist during early scaffolding)
|
||
// plus the booleans for the close-out file-presence checks (R3.5 / R3.6).
|
||
type Plan struct {
|
||
Dir string // absolute path to the plan directory
|
||
PRD *ScanResult // nil if prd.md missing (triggers R3.2 fatal)
|
||
Design *ScanResult // nil if design.md missing (R2.8 skip path)
|
||
Tasks *ScanResult // nil if tasks.md missing
|
||
CodexLog bool // codex-sessions.md present?
|
||
HandoffFound bool // any handoff*.md present?
|
||
}
|
||
|
||
// spec:planctl/R2.3+D§3.3
|
||
// TagRef is one well-formed traceability reference extracted from a tasks.md
|
||
// `_Requirements:_` / `_Design:_` tag. Malformed tokens become Diagnostics
|
||
// on Index.MalformedTags instead of TagRefs — only well-formed tags flow
|
||
// into cross-reference checks.
|
||
type TagRef struct {
|
||
Kind Kind // Requirements or Design
|
||
ID string // canonical id ("R3.2", "D§3.4", or the literal "infra" sentinel)
|
||
File string // relative to the plan directory
|
||
Line int // 1-indexed
|
||
Col int // 0-indexed column of the opening `_`, for diagnostic positioning
|
||
}
|
||
|
||
// spec:planctl/D§3.3
|
||
// Kind discriminates TagRef between the two tag families (Requirements vs
|
||
// Design). The zero value is KindRequirements so a default-constructed TagRef
|
||
// is interpretable.
|
||
type Kind int
|
||
|
||
const (
|
||
KindRequirements Kind = iota
|
||
KindDesign
|
||
)
|
||
|
||
// spec:planctl/R3.3+D§3.3
|
||
// TaskLine records one tasks.md checkbox bullet. Definition matches PRD R3.3:
|
||
// a line matching `^\s*- \[[ x]\] ` outside any fenced code block. Indent is
|
||
// captured so nested sub-tasks are distinguishable from parent tasks when
|
||
// downstream consumers care (v1 lint rules do not).
|
||
type TaskLine struct {
|
||
File string // relative to the plan directory
|
||
Line int // 1-indexed
|
||
Indent int // leading spaces on the line
|
||
Checked bool // true for `[x]`, false for `[ ]`
|
||
}
|
||
|
||
// spec:planctl/R2.1+R2.2+R2.3+D§3.3
|
||
// Index is the extraction output consumed by the lint rules.
|
||
//
|
||
// PRDRequirements and DesignSections are maps (id → Position) so cross-ref
|
||
// membership checks are O(1). TaskTags and TaskLines are slices because the
|
||
// rule pipeline iterates over them; duplicates are legal (a requirement can
|
||
// be cited by multiple tasks).
|
||
//
|
||
// MalformedTags holds tag-syntax / tag-unclosed diagnostics produced during
|
||
// extraction. checkTagSyntax (task 4.2) simply returns this slice — keeping
|
||
// diagnostics alongside the well-formed TagRefs means only valid tags flow
|
||
// into the cross-reference checks.
|
||
type Index struct {
|
||
PRDRequirements map[string]Position // R-id → prd.md declaration site
|
||
DesignSections map[string]Position // D§-id → design.md declaration site
|
||
TaskTags []TagRef // every well-formed tag in tasks.md
|
||
TaskLines []TaskLine // every checkbox bullet in tasks.md
|
||
MalformedTags []Diagnostic // tag-syntax + tag-unclosed diagnostics
|
||
}
|
||
|
||
// spec:planctl/D§3.3
|
||
// Position is a (file, line, text) triple identifying where an id was
|
||
// declared. Used as the diagnostic site for uncovered-requirement /
|
||
// uncovered-design codes (which point at the declaration rather than a
|
||
// task tag) and as the EARS-check input for checkEars (which inspects
|
||
// the declaration line's raw text to classify the acceptance criterion).
|
||
//
|
||
// Text carries the declaration line verbatim (post-newline-strip, as in
|
||
// ScanResult.Lines) so rule checkers can reason about the line without
|
||
// re-opening the file. Callers may safely leave it empty when not needed.
|
||
type Position struct {
|
||
File string // relative to the plan directory
|
||
Line int // 1-indexed
|
||
Text string // declaration line text, verbatim from ScanResult.Lines
|
||
}
|
||
|
||
// spec:planctl/R1.1+D§3.3
|
||
// reqIDPattern matches a Requirements ref id: `R` followed by a numeric dot-path
|
||
// with at least one sub-segment, optionally suffixed with a lowercase letter
|
||
// (e.g. `R1.2`, `R3.2a`, `R10.15`). A bare `R3` is NOT valid — PRD R1.1 requires
|
||
// at least one sub-number.
|
||
var reqIDPattern = regexp.MustCompile(`^R\d+(?:\.\d+)+[a-z]?$`)
|
||
|
||
// spec:planctl/R1.1+D§3.3
|
||
// desIDPattern matches a Design ref id: `D§` followed by a numeric dot-path.
|
||
// Unlike R-ids, zero sub-segments are allowed (`D§3` alone is valid).
|
||
var desIDPattern = regexp.MustCompile(`^D§\d+(?:\.\d+)*$`)
|
||
|
||
// spec:planctl/R1.7+R2.9
|
||
// infraSentinel is the literal string accepted as a valid Requirements ref
|
||
// for branch-creation / scaffold tasks that do not correspond to any PRD R-id.
|
||
const infraSentinel = "infra"
|
||
|
||
// spec:planctl/R2.9
|
||
// isInfraRef reports whether ref is the `infra` sentinel. Cross-reference
|
||
// checks (task 4.3) use this to exempt `infra` from orphan-requirement.
|
||
func isInfraRef(ref string) bool {
|
||
return ref == infraSentinel
|
||
}
|
||
|
||
// spec:planctl/R1.1+R1.7+D§3.3
|
||
// isValidReqID reports whether ref is a well-formed Requirements tag body —
|
||
// either the `infra` sentinel (R1.7) or a string matching the R-id grammar
|
||
// from PRD R1.1.
|
||
func isValidReqID(ref string) bool {
|
||
if isInfraRef(ref) {
|
||
return true
|
||
}
|
||
return reqIDPattern.MatchString(ref)
|
||
}
|
||
|
||
// spec:planctl/R1.1+D§3.3
|
||
// isValidDesID reports whether ref is a well-formed Design tag body per the
|
||
// D§-id grammar from PRD R1.1.
|
||
func isValidDesID(ref string) bool {
|
||
return desIDPattern.MatchString(ref)
|
||
}
|
||
|
||
// spec:planctl/R1.2+R1.4+D§3.3
|
||
// Tag-extraction state machine — see design §3.3 ("Tag-unclosed recovery").
|
||
//
|
||
// These opener literals are the two substrings that begin a traceability tag.
|
||
// Keeping them as consts (rather than a regex) makes the state-machine
|
||
// transitions explicit and keeps byte-offset arithmetic trivial.
|
||
const (
|
||
reqOpener = "_Requirements:"
|
||
desOpener = "_Design:"
|
||
)
|
||
|
||
// spec:planctl/R1.2+R1.6+D§3.3
|
||
// rawTag is the well-formed output of extractTagsFromLine: a tag kind plus
|
||
// the verbatim body text between `:` and the closing `_`. Task 3.4 splits
|
||
// Body on `, `, strips trailing annotations, and validates each ref against
|
||
// the grammar helpers from task 3.2.
|
||
type rawTag struct {
|
||
Kind Kind
|
||
Body string // verbatim between `:` and closing `_` (leading space kept)
|
||
Line int // 1-indexed
|
||
Col int // 0-indexed column of the opening `_`
|
||
}
|
||
|
||
// spec:planctl/R1.2+R1.4+R1.6+D§3.3 ("Tag-unclosed recovery")
|
||
// extractTagsFromLine scans one line left-to-right for `_Requirements:` /
|
||
// `_Design:` tags. Each opener triggers a forward scan for a closing `_`:
|
||
//
|
||
// - If the forward scan reaches another `_Requirements:` / `_Design:`
|
||
// opener before any `_`, the current tag is "unclosed" (the user
|
||
// almost certainly forgot a `_` before starting the next tag) — emit
|
||
// ONE CodeTagUnclosed diagnostic at the original opener's position,
|
||
// then RESUME scanning at the inner opener so the second tag still
|
||
// extracts. This is the recovery path called out in design §3.3 and
|
||
// exercised by the `_Requirements: R3 _Design: D§2_` fixture.
|
||
// - If the forward scan hits EOL with no `_` at all, emit ONE
|
||
// CodeTagUnclosed diagnostic and advance past the opening `_`.
|
||
// - Otherwise the first plain `_` is the closer; record a rawTag whose
|
||
// body is line[i+tokenLen:closer] and resume past the closer.
|
||
//
|
||
// `file` and `lineNum` are copied into the returned Position fields only.
|
||
// Callers (task 3.4) are responsible for passing a mask-applied line so
|
||
// inline-code spans (R1.5) never trigger extraction.
|
||
func extractTagsFromLine(file string, lineNum int, line string) ([]rawTag, []Diagnostic) {
|
||
var (
|
||
tags []rawTag
|
||
diags []Diagnostic
|
||
)
|
||
for i := 0; i < len(line); {
|
||
if line[i] != '_' {
|
||
i++
|
||
continue
|
||
}
|
||
kind, tokenLen, isOpener := openerAt(line, i)
|
||
if !isOpener {
|
||
i++
|
||
continue
|
||
}
|
||
newI, tag, diag, ok := scanTagAfterOpener(file, lineNum, line, i, kind, tokenLen)
|
||
if ok {
|
||
tags = append(tags, tag)
|
||
}
|
||
if diag != nil {
|
||
diags = append(diags, *diag)
|
||
}
|
||
i = newI
|
||
}
|
||
return tags, diags
|
||
}
|
||
|
||
// spec:planctl/R1.1+R1.2+R1.4+R1.6+D§3.3
|
||
// scanTagAfterOpener runs the forward scan for the closing `_`. It
|
||
// returns the new scan cursor position, the extracted tag (if the scan
|
||
// succeeded), an optional tag-unclosed diagnostic, and a flag indicating
|
||
// success. The diagnostic and the tag are mutually exclusive.
|
||
//
|
||
// Paren-nesting awareness (R1.1): the grammar allows a trailing
|
||
// ` (<free-text>)` annotation whose body is "arbitrary non-`)`
|
||
// characters", so the annotation may legitimately contain `_` or `,`.
|
||
// While paren depth > 0 we skip `_` as a closer candidate; the closing
|
||
// `_` must appear at paren depth 0.
|
||
func scanTagAfterOpener(file string, lineNum int, line string, openCol int, kind Kind, tokenLen int) (newI int, tag rawTag, diag *Diagnostic, ok bool) {
|
||
start := openCol + tokenLen
|
||
parenDepth := 0
|
||
for j := start; j < len(line); j++ {
|
||
switch line[j] {
|
||
case '(':
|
||
parenDepth++
|
||
continue
|
||
case ')':
|
||
if parenDepth > 0 {
|
||
parenDepth--
|
||
}
|
||
continue
|
||
case '_':
|
||
if parenDepth > 0 {
|
||
continue
|
||
}
|
||
if _, _, inner := openerAt(line, j); inner {
|
||
d := tagUnclosedDiag(file, lineNum, line, openCol, tokenLen)
|
||
return j, rawTag{}, &d, false
|
||
}
|
||
return j + 1, rawTag{
|
||
Kind: kind,
|
||
Body: line[start:j],
|
||
Line: lineNum,
|
||
Col: openCol,
|
||
}, nil, true
|
||
}
|
||
}
|
||
d := tagUnclosedDiag(file, lineNum, line, openCol, tokenLen)
|
||
return openCol + 1, rawTag{}, &d, false
|
||
}
|
||
|
||
// spec:planctl/R1.2+D§3.3
|
||
// openerAt reports whether an opener literal starts at line[i]. Returns the
|
||
// tag kind and opener length in bytes on success.
|
||
func openerAt(line string, i int) (Kind, int, bool) {
|
||
if strings.HasPrefix(line[i:], reqOpener) {
|
||
return KindRequirements, len(reqOpener), true
|
||
}
|
||
if strings.HasPrefix(line[i:], desOpener) {
|
||
return KindDesign, len(desOpener), true
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
// spec:planctl/R1.4+D§3.1
|
||
// tagUnclosedDiag renders a CodeTagUnclosed diagnostic positioned at the
|
||
// opener's line (1-indexed). The message quotes the offending literal so the
|
||
// reader can find it in the source.
|
||
func tagUnclosedDiag(file string, lineNum int, line string, openCol, tokenLen int) Diagnostic {
|
||
opener := line[openCol : openCol+tokenLen]
|
||
return Diagnostic{
|
||
Path: file,
|
||
Line: lineNum,
|
||
Severity: SevError,
|
||
Code: CodeTagUnclosed,
|
||
Message: fmt.Sprintf("%s has no closing `_` on the same line", opener),
|
||
}
|
||
}
|
||
|
||
// spec:planctl/R1.1+D§3.3
|
||
// annotationPattern matches the optional trailing ` (note)` on a ref per
|
||
// PRD R1.1: a space, an open paren, any non-`)` characters, a close paren,
|
||
// anchored at end of string. Annotations are ignored for cross-reference
|
||
// purposes (only the `<id>` is compared).
|
||
var annotationPattern = regexp.MustCompile(` \([^)]*\)$`)
|
||
|
||
// spec:planctl/R1.1+R1.6+D§3.3
|
||
// splitRefs splits a tag body on the `, ` comma-space separator but
|
||
// treats commas inside `(<annotation>)` as annotation content rather
|
||
// than ref separators. PRD R1.1 allows annotations to contain any
|
||
// non-`)` characters — including `,` — so the naive `strings.Split`
|
||
// breaks on legitimate inputs like `R1.1 (note, with comma), R2.1`.
|
||
func splitRefs(body string) []string {
|
||
var (
|
||
out []string
|
||
buf strings.Builder
|
||
parenDepth int
|
||
)
|
||
for i := 0; i < len(body); i++ {
|
||
c := body[i]
|
||
switch c {
|
||
case '(':
|
||
parenDepth++
|
||
buf.WriteByte(c)
|
||
case ')':
|
||
if parenDepth > 0 {
|
||
parenDepth--
|
||
}
|
||
buf.WriteByte(c)
|
||
case ',':
|
||
// Only treat `, ` at depth 0 as a separator.
|
||
if parenDepth == 0 && i+1 < len(body) && body[i+1] == ' ' {
|
||
out = append(out, buf.String())
|
||
buf.Reset()
|
||
i++ // skip the following space
|
||
continue
|
||
}
|
||
buf.WriteByte(c)
|
||
default:
|
||
buf.WriteByte(c)
|
||
}
|
||
}
|
||
if buf.Len() > 0 {
|
||
out = append(out, buf.String())
|
||
}
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/R1.5+D§3.3
|
||
// applyLineMask returns a copy of `line` where every byte range listed in
|
||
// `mask` is replaced with ASCII space. Offsets are preserved so the
|
||
// returned string has the same length and columns as the original — the
|
||
// state machine (and any later diagnostic positions) therefore stay aligned
|
||
// with the source file.
|
||
//
|
||
// Called by extractTags before the state machine runs so tag-like tokens
|
||
// inside inline-code spans (R1.5) do not produce extractions.
|
||
func applyLineMask(line string, mask []MaskRun) string {
|
||
if len(mask) == 0 {
|
||
return line
|
||
}
|
||
buf := []byte(line)
|
||
for _, m := range mask {
|
||
start, end := m.Start, m.End
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
if end > len(buf) {
|
||
end = len(buf)
|
||
}
|
||
for k := start; k < end; k++ {
|
||
buf[k] = ' '
|
||
}
|
||
}
|
||
return string(buf)
|
||
}
|
||
|
||
// spec:planctl/R1.1+R1.3+R1.6+D§3.3
|
||
// parseTagBody turns one rawTag (the verbatim text between `:` and the
|
||
// closing `_`) into a slice of TagRefs plus any CodeTagSyntax diagnostics
|
||
// generated by malformed refs.
|
||
//
|
||
// Splitting rule: `, ` (comma-space) per R1.1, but paren-aware — a comma
|
||
// inside a ` (<free-text>)` annotation is part of the annotation, not a
|
||
// ref separator. Each ref is trimmed of surrounding whitespace; a
|
||
// trailing ` (…)` annotation is stripped before grammar validation. An
|
||
// empty body produces one tag-syntax diagnostic.
|
||
func parseTagBody(file string, r rawTag) ([]TagRef, []Diagnostic) {
|
||
var (
|
||
refs []TagRef
|
||
diags []Diagnostic
|
||
)
|
||
body := strings.TrimSpace(r.Body)
|
||
if body == "" {
|
||
diags = append(diags, Diagnostic{
|
||
Path: file,
|
||
Line: r.Line,
|
||
Severity: SevError,
|
||
Code: CodeTagSyntax,
|
||
Message: fmt.Sprintf("empty %s tag body", kindLabel(r.Kind)),
|
||
})
|
||
return refs, diags
|
||
}
|
||
for _, part := range splitRefs(body) {
|
||
raw := strings.TrimSpace(part)
|
||
id := annotationPattern.ReplaceAllString(raw, "")
|
||
valid := false
|
||
switch r.Kind {
|
||
case KindRequirements:
|
||
valid = isValidReqID(id)
|
||
case KindDesign:
|
||
valid = isValidDesID(id)
|
||
}
|
||
if !valid {
|
||
diags = append(diags, Diagnostic{
|
||
Path: file,
|
||
Line: r.Line,
|
||
Severity: SevError,
|
||
Code: CodeTagSyntax,
|
||
Message: fmt.Sprintf("malformed %s ref %q", kindLabel(r.Kind), raw),
|
||
})
|
||
continue
|
||
}
|
||
refs = append(refs, TagRef{
|
||
Kind: r.Kind,
|
||
ID: id,
|
||
File: file,
|
||
Line: r.Line,
|
||
Col: r.Col,
|
||
})
|
||
}
|
||
return refs, diags
|
||
}
|
||
|
||
// spec:planctl/R1.5+R1.6+D§3.3
|
||
// extractTags is the full per-line tag pipeline: apply the inline-code mask
|
||
// (R1.5) so masked bytes can't open or close tags, run the state-machine
|
||
// extraction (task 3.3), then body-parse each well-formed rawTag (task 3.4).
|
||
// Returns every well-formed TagRef and every diagnostic (tag-unclosed +
|
||
// tag-syntax) produced.
|
||
//
|
||
// Callers (BuildIndex in task 3.8) pass one line at a time, skipping lines
|
||
// where the scanner's InCode[] marked the line as inside a fenced / indented
|
||
// / HTML code block (R1.5 — block-level exclusion).
|
||
func extractTags(file string, lineNum int, line string, mask []MaskRun) ([]TagRef, []Diagnostic) {
|
||
masked := applyLineMask(line, mask)
|
||
raws, diags := extractTagsFromLine(file, lineNum, masked)
|
||
var refs []TagRef
|
||
for _, rt := range raws {
|
||
r, d := parseTagBody(file, rt)
|
||
refs = append(refs, r...)
|
||
diags = append(diags, d...)
|
||
}
|
||
return refs, diags
|
||
}
|
||
|
||
// spec:planctl/R1.3+D§3.1
|
||
// kindLabel renders a Kind as the literal tag label used in the source
|
||
// (`Requirements` / `Design`). Used for diagnostic messages.
|
||
func kindLabel(k Kind) string {
|
||
switch k {
|
||
case KindRequirements:
|
||
return "Requirements"
|
||
case KindDesign:
|
||
return "Design"
|
||
}
|
||
return "?"
|
||
}
|
||
|
||
// spec:planctl/R2.1+D§3.3
|
||
// prdReqPrefix matches a Requirements-id at the start of a list-item's
|
||
// first-line content. The trailing space is load-bearing — it rejects
|
||
// `R1ABC` from accidentally being treated as `R1` and distinguishes an
|
||
// R-id prefix from a non-declarative text line that happens to start with
|
||
// `R…`.
|
||
var prdReqPrefix = regexp.MustCompile(`^(R\d+(?:\.\d+)+[a-z]?) `)
|
||
|
||
// spec:planctl/R2.1+D§3.3
|
||
// prdReqFallbackPattern is the whole-line regex used when the AST walk
|
||
// yields zero R-id declarations. Anchored at line start; the `- ` bullet
|
||
// prefix is required so non-list text cannot masquerade as a declaration.
|
||
var prdReqFallbackPattern = regexp.MustCompile(`^\s*- (R\d+(?:\.\d+)+[a-z]?) `)
|
||
|
||
// spec:planctl/R2.1+D§3.3
|
||
// extractPRDReqs walks the PRD's AST for ast.ListItem nodes whose first
|
||
// line begins with an R-id (e.g. `R3.2 WHEN ...`) and records each
|
||
// declaration as a Position in the returned map. First declaration wins on
|
||
// duplicates — a well-formed PRD won't have any, but defensively ignoring
|
||
// later duplicates keeps the first declaration line stable for diagnostics.
|
||
//
|
||
// Fallback: if the AST walk produces no R-ids (e.g. the file isn't actually
|
||
// parsed as a markdown list due to missing blank lines or unusual
|
||
// indentation), a line-by-line regex sweeps non-code lines. The fallback
|
||
// exists so partially-malformed PRDs still surface some R-ids rather than
|
||
// silently reporting "no requirements declared".
|
||
func extractPRDReqs(s *ScanResult) map[string]Position {
|
||
out := map[string]Position{}
|
||
if s == nil || s.Root == nil {
|
||
return out
|
||
}
|
||
newlineOffsets := buildNewlineIndex(s.Source)
|
||
_ = ast.Walk(s.Root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||
if !entering {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
li, ok := n.(*ast.ListItem)
|
||
if !ok {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
segs := li.Lines()
|
||
if segs == nil || segs.Len() == 0 {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
seg := segs.At(0)
|
||
text := string(s.Source[seg.Start:seg.Stop])
|
||
m := prdReqPrefix.FindStringSubmatch(text)
|
||
if m == nil {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
line, _ := offsetToLineCol(seg.Start, newlineOffsets)
|
||
id := m[1]
|
||
if _, dup := out[id]; !dup {
|
||
out[id] = Position{File: s.Path, Line: line, Text: lineText(s, line)}
|
||
}
|
||
return ast.WalkContinue, nil
|
||
})
|
||
if len(out) == 0 {
|
||
fallbackScanPRDReqs(s, out)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/D§3.3
|
||
// lineText returns the 1-indexed line from s.Lines, or "" if line is out
|
||
// of range. Used to populate Position.Text during extraction so downstream
|
||
// rule checkers (notably checkEars) can inspect declaration line text
|
||
// without re-opening the source file.
|
||
func lineText(s *ScanResult, line int) string {
|
||
if s == nil || line < 1 || line > len(s.Lines) {
|
||
return ""
|
||
}
|
||
return s.Lines[line-1]
|
||
}
|
||
|
||
// spec:planctl/R2.1+D§3.3
|
||
// fallbackScanPRDReqs implements the AST-empty recovery path documented on
|
||
// extractPRDReqs. Walks Lines() in order so the first declaration line wins
|
||
// on duplicates — matching the AST path's semantics.
|
||
func fallbackScanPRDReqs(s *ScanResult, out map[string]Position) {
|
||
for i, line := range s.Lines {
|
||
if i < len(s.InCode) && s.InCode[i] {
|
||
continue
|
||
}
|
||
m := prdReqFallbackPattern.FindStringSubmatch(line)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
id := m[1]
|
||
if _, dup := out[id]; !dup {
|
||
out[id] = Position{File: s.Path, Line: i + 1, Text: line}
|
||
}
|
||
}
|
||
}
|
||
|
||
// spec:planctl/R2.2+D§3.3
|
||
// designHeadingPrefix matches a numeric section prefix at the start of a
|
||
// heading's first-line content.
|
||
//
|
||
// Design §3.3 specifies `\d+(\.\d+)*` followed by a space. The `\.?` tolerance
|
||
// here accepts both `3.4 Foo` (matches the spec example) and `0. Scope` (the
|
||
// convention actually used by level-2 headings in the in-repo design.md —
|
||
// `N.` period-space numbering). The captured id is always the numeric prefix
|
||
// without the trailing period, so `## 0. Scope` declares D§0.
|
||
var designHeadingPrefix = regexp.MustCompile(`^(\d+(?:\.\d+)*)\.? `)
|
||
|
||
// spec:planctl/R3.3+D§3.3
|
||
// taskCheckboxPattern is the AST-empty fallback for task-line extraction.
|
||
// The scanner enables goldmark's TaskList extension (see scan.go), so in
|
||
// practice every `[ ]` / `[x]` bullet surfaces as a TaskCheckBox AST node.
|
||
// This regex exists for the defensive case where the AST path fails.
|
||
var taskCheckboxPattern = regexp.MustCompile(`^(\s*)- \[([ x])\] `)
|
||
|
||
// spec:planctl/R2.2+D§3.3
|
||
// extractDesignSections walks the design AST for ast.Heading nodes at
|
||
// levels 2–4 whose first-line text starts with a numeric section prefix.
|
||
// Each heading declares a D§<prefix> id; the map value is the Position of
|
||
// the heading line in design.md.
|
||
//
|
||
// First declaration wins on duplicate ids; a well-formed design won't
|
||
// double-declare, but guarding against it keeps the earliest line stable
|
||
// for uncovered-design diagnostics.
|
||
func extractDesignSections(s *ScanResult) map[string]Position {
|
||
out := map[string]Position{}
|
||
if s == nil || s.Root == nil {
|
||
return out
|
||
}
|
||
newlineOffsets := buildNewlineIndex(s.Source)
|
||
_ = ast.Walk(s.Root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||
if !entering {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
h, ok := n.(*ast.Heading)
|
||
if !ok {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
if h.Level < 2 || h.Level > 4 {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
segs := h.Lines()
|
||
if segs == nil || segs.Len() == 0 {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
seg := segs.At(0)
|
||
text := string(s.Source[seg.Start:seg.Stop])
|
||
m := designHeadingPrefix.FindStringSubmatch(text)
|
||
if m == nil {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
id := "D§" + m[1]
|
||
line, _ := offsetToLineCol(seg.Start, newlineOffsets)
|
||
if _, dup := out[id]; !dup {
|
||
out[id] = Position{File: s.Path, Line: line, Text: lineText(s, line)}
|
||
}
|
||
return ast.WalkContinue, nil
|
||
})
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/R3.3+D§3.3
|
||
// extractTaskLines walks the tasks.md AST for list items that contain a
|
||
// task-list checkbox (east.TaskCheckBox, surfaced by the TaskList extension
|
||
// enabled in the scanner). For each match it emits one TaskLine: Checked
|
||
// comes from the checkbox, Line from the ListItem's first-line segment,
|
||
// Indent from the raw line's leading spaces.
|
||
//
|
||
// Lines inside fenced / indented / HTML code blocks (per InCode[]) are
|
||
// excluded per PRD R3.3. We derive Indent from the raw line rather than
|
||
// goldmark's ListItem.Offset because the PRD definition ("^\s*- \[[ x]\] ")
|
||
// treats the surface indentation as the source of truth.
|
||
//
|
||
// If the AST walk yields zero matches (defensive — would indicate the
|
||
// scanner's TaskList extension wasn't enabled), falls back to a regex
|
||
// sweep that matches PRD R3.3's definition verbatim.
|
||
func extractTaskLines(s *ScanResult) []TaskLine {
|
||
if s == nil || s.Root == nil {
|
||
return nil
|
||
}
|
||
newlineOffsets := buildNewlineIndex(s.Source)
|
||
var out []TaskLine
|
||
_ = ast.Walk(s.Root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||
if !entering {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
li, ok := n.(*ast.ListItem)
|
||
if !ok {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
cb := firstTaskCheckBox(li)
|
||
if cb == nil {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
segs := li.Lines()
|
||
if segs == nil || segs.Len() == 0 {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
line, _ := offsetToLineCol(segs.At(0).Start, newlineOffsets)
|
||
if line < 1 || line > len(s.Lines) {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
if line <= len(s.InCode) && s.InCode[line-1] {
|
||
return ast.WalkContinue, nil
|
||
}
|
||
out = append(out, TaskLine{
|
||
File: s.Path,
|
||
Line: line,
|
||
Indent: leadingSpaces(s.Lines[line-1]),
|
||
Checked: cb.IsChecked,
|
||
})
|
||
return ast.WalkContinue, nil
|
||
})
|
||
if len(out) == 0 {
|
||
return fallbackScanTaskLines(s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/R3.3+D§3.3
|
||
// firstTaskCheckBox returns the TaskCheckBox child of li if present. The
|
||
// TaskList extension inserts the checkbox at the very start of the list
|
||
// item's first inline block (a TextBlock or Paragraph) — anything past the
|
||
// first inline child is regular content.
|
||
func firstTaskCheckBox(li *ast.ListItem) *east.TaskCheckBox {
|
||
first := li.FirstChild()
|
||
if first == nil {
|
||
return nil
|
||
}
|
||
if cb, ok := first.FirstChild().(*east.TaskCheckBox); ok {
|
||
return cb
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// spec:planctl/R3.3+D§3.3
|
||
// fallbackScanTaskLines implements the AST-empty recovery path. It applies
|
||
// PRD R3.3's regex verbatim to every non-code line, so its output is the
|
||
// R3.3-canonical set of tasks when the primary AST path produces nothing.
|
||
func fallbackScanTaskLines(s *ScanResult) []TaskLine {
|
||
var out []TaskLine
|
||
for i, line := range s.Lines {
|
||
if i < len(s.InCode) && s.InCode[i] {
|
||
continue
|
||
}
|
||
m := taskCheckboxPattern.FindStringSubmatch(line)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
out = append(out, TaskLine{
|
||
File: s.Path,
|
||
Line: i + 1,
|
||
Indent: len(m[1]),
|
||
Checked: m[2] == "x",
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/R3.3+D§3.3
|
||
// leadingSpaces counts ASCII spaces at the start of line. Tabs are NOT
|
||
// counted (R3.3's regex uses `\s*` which matches tabs too, but agents in
|
||
// this template consistently use spaces; `.editorconfig` / `AGENTS.md`
|
||
// treat tabs as a style violation out of scope for planctl).
|
||
func leadingSpaces(line string) int {
|
||
n := 0
|
||
for n < len(line) && line[n] == ' ' {
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
// spec:planctl/R1.3+R1.4+R1.6+R2.3+D§3.3
|
||
// extractTaskTags iterates every non-code line in a tasks.md scan, applies
|
||
// the inline-code mask (R1.5), runs the tag state machine (task 3.3), and
|
||
// body-parses each raw tag (task 3.4). Returns every well-formed TagRef and
|
||
// every diagnostic (tag-unclosed + tag-syntax) produced, in line order.
|
||
//
|
||
// Lines where InCode[i] is true (inside a fenced / indented / HTML block)
|
||
// are skipped per R1.5 — tag-like tokens in those blocks are documentation,
|
||
// not tags.
|
||
func extractTaskTags(s *ScanResult) ([]TagRef, []Diagnostic) {
|
||
if s == nil {
|
||
return nil, nil
|
||
}
|
||
var (
|
||
tags []TagRef
|
||
diags []Diagnostic
|
||
)
|
||
for i, line := range s.Lines {
|
||
if i < len(s.InCode) && s.InCode[i] {
|
||
continue
|
||
}
|
||
var mask []MaskRun
|
||
if i < len(s.LineMask) {
|
||
mask = s.LineMask[i]
|
||
}
|
||
refs, d := extractTags(s.Path, i+1, line, mask)
|
||
tags = append(tags, refs...)
|
||
diags = append(diags, d...)
|
||
}
|
||
return tags, diags
|
||
}
|
||
|
||
// spec:planctl/R1.3+R1.4+R2.1+R2.2+R2.3+R3.3+D§3.3
|
||
// BuildIndex runs every extraction pass over a Plan's ScanResults and
|
||
// returns a fully-populated Index.
|
||
//
|
||
// The p.PRD / p.Design / p.Tasks fields may each be nil (missing-PRD is
|
||
// handled fatally by the rule checker in task 4.5; missing-design triggers
|
||
// R2.8 skip in task 4.3's checkCrossRef). BuildIndex itself never short-
|
||
// circuits — it just produces empty slices / maps for nil inputs, so later
|
||
// pipeline stages can reason uniformly.
|
||
//
|
||
// MalformedTags is kept separate from TaskTags so only well-formed refs
|
||
// flow into cross-reference checking — task-syntax / tag-unclosed
|
||
// diagnostics are surfaced as-is by checkTagSyntax (task 4.2).
|
||
func BuildIndex(p *Plan) Index {
|
||
if p == nil {
|
||
return Index{
|
||
PRDRequirements: map[string]Position{},
|
||
DesignSections: map[string]Position{},
|
||
}
|
||
}
|
||
idx := Index{
|
||
PRDRequirements: extractPRDReqs(p.PRD),
|
||
DesignSections: extractDesignSections(p.Design),
|
||
TaskLines: extractTaskLines(p.Tasks),
|
||
}
|
||
tags, diags := extractTaskTags(p.Tasks)
|
||
idx.TaskTags = tags
|
||
idx.MalformedTags = diags
|
||
return idx
|
||
}
|