template-jj/cmd/planctl/index.go
Sid 1a29ff50b2
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
fix(planctl): paren-aware tag extraction and splitting
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.
2026-04-21 20:42:08 -06:00

792 lines
27 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 24 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
}