feat(planctl): scanner — goldmark-backed InCode + LineMask classification

* cmd/planctl/scan.go: Scan() / scanBytes() construct a goldmark parser with
  the TaskList extension; AST walk populates InCode[] for CodeBlock /
  FencedCodeBlock / HTMLBlock and LineMask[] for inline CodeSpan nodes with
  +/-1 byte extension to cover the surrounding backticks.
* offsetToLineCol folds '\n' bytes into the preceding line so block segment
  Stop-1 maps correctly — a subtle off-by-one that tripped the closing fence
  and trailing blank-line tests before the fix landed.
* cmd/planctl/scan_test.go: 7 test functions covering fenced / indented /
  HTML blocks, inline code spans (substring + exact-range), nested emphasis
  around code, mixed features in one document, EOF-in-fenced-block, and
  trailing-newline splitLines variations.
* go.mod: goldmark promoted from indirect to direct (scan.go imports it).

Parent task 2.0 from dev/plans/26172-planctl/tasks.md.
Codex code-review session 019db23c-6c44-7d00-b3a4-6653882f0964 (2 rounds).
This commit is contained in:
Sid 2026-04-21 16:38:27 -06:00
parent a7a79eab65
commit aa73e2f727
5 changed files with 611 additions and 10 deletions

235
cmd/planctl/scan.go Normal file
View file

@ -0,0 +1,235 @@
package main
import (
"bytes"
"os"
"sort"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/text"
)
// spec:planctl/R1.5+D§3.2
// MaskRun is a [Start, End) column range in one line that should be masked
// out before tag-matching. Columns are 0-indexed byte offsets within the line.
type MaskRun struct {
Start int
End int
}
// spec:planctl/R1.5+D§3.2
// ScanResult captures one markdown file parsed to a goldmark AST plus
// per-line classification derived from the AST.
//
// Callers (the indexer, lint rules) use InCode + LineMask to skip tag-like
// tokens that live inside code blocks or inline code spans — per R1.5 those
// are documentation about tags, not tags.
type ScanResult struct {
Path string
Source []byte
Lines []string // newline-stripped, 0-indexed (exposed as 1-indexed to callers)
Root ast.Node // goldmark AST root
InCode []bool // parallel to Lines; true if line is inside a CodeBlock / FencedCodeBlock / HTMLBlock
LineMask [][]MaskRun // parallel to Lines; column ranges occupied by inline CodeSpan content
}
// planParser is the shared goldmark parser. We enable the TaskList extension
// so `[ ]` / `[x]` list items surface as east.TaskCheckBox nodes — the indexer
// (§3.3) relies on that to extract task lines. The extension ships inside the
// goldmark module, so no additional require line is needed.
//
// spec:planctl/D§3.2
var planParser = goldmark.New(goldmark.WithExtensions(extension.TaskList)).Parser()
// spec:planctl/R1.5+D§3.2
// Scan reads the file at path and produces a ScanResult.
func Scan(path string) (ScanResult, error) {
source, err := os.ReadFile(path)
if err != nil {
return ScanResult{}, err
}
return scanBytes(path, source), nil
}
// spec:planctl/R1.5+D§3.2
// scanBytes is the testable core of Scan that operates on in-memory source
// bytes without touching the filesystem.
func scanBytes(path string, source []byte) ScanResult {
reader := text.NewReader(source)
root := planParser.Parse(reader)
lines := splitLines(source)
newlineOffsets := buildNewlineIndex(source)
r := ScanResult{
Path: path,
Source: source,
Lines: lines,
Root: root,
InCode: make([]bool, len(lines)),
LineMask: make([][]MaskRun, len(lines)),
}
_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n.Kind() {
case ast.KindCodeBlock, ast.KindFencedCodeBlock, ast.KindHTMLBlock:
markBlockLines(n, newlineOffsets, r.InCode)
case ast.KindCodeSpan:
appendInlineMask(n, source, newlineOffsets, r.Lines, r.LineMask)
}
return ast.WalkContinue, nil
})
// Normalise LineMask entries: sort by Start for stable consumption.
for i := range r.LineMask {
if len(r.LineMask[i]) > 1 {
sort.Slice(r.LineMask[i], func(a, b int) bool {
return r.LineMask[i][a].Start < r.LineMask[i][b].Start
})
}
}
return r
}
// spec:planctl/D§3.2
// splitLines splits source on '\n' and drops a trailing empty element so the
// result is a natural "lines" slice. An input ending in '\n' produces len(lines)
// elements where the last element is the content before the final '\n'.
func splitLines(source []byte) []string {
if len(source) == 0 {
return nil
}
parts := bytes.Split(source, []byte{'\n'})
if n := len(parts); n > 0 && len(parts[n-1]) == 0 {
parts = parts[:n-1]
}
out := make([]string, len(parts))
for i, p := range parts {
out[i] = string(p)
}
return out
}
// spec:planctl/D§3.2
// buildNewlineIndex returns byte offsets of every '\n' in source, in order.
// Consumers binary-search it to map a byte offset to (line, column).
func buildNewlineIndex(source []byte) []int {
var offsets []int
for i, b := range source {
if b == '\n' {
offsets = append(offsets, i)
}
}
return offsets
}
// spec:planctl/D§3.2
// offsetToLineCol converts a byte offset into (1-indexed line, 0-indexed col).
// A '\n' byte is treated as the END of the line it terminates, not the start
// of the following line — that way a block segment whose Stop-1 lands exactly
// on the terminating '\n' of its last content line maps back to that content
// line, not to the line after.
func offsetToLineCol(offset int, newlineOffsets []int) (line, col int) {
idx := sort.SearchInts(newlineOffsets, offset+1)
// If offset IS a newline position, fold it into the preceding line.
if idx > 0 && newlineOffsets[idx-1] == offset {
line = idx
var lineStart int
if idx > 1 {
lineStart = newlineOffsets[idx-2] + 1
}
col = offset - lineStart
return
}
line = idx + 1
var lineStart int
if idx > 0 {
lineStart = newlineOffsets[idx-1] + 1
}
col = offset - lineStart
return
}
// spec:planctl/R1.5+D§3.2
// markBlockLines sets InCode[i] = true for every line covered by a block
// whose content lies in the given node's Lines() segments.
func markBlockLines(n ast.Node, newlineOffsets []int, inCode []bool) {
segs := n.Lines()
if segs == nil {
return
}
for i := 0; i < segs.Len(); i++ {
seg := segs.At(i)
if seg.Stop <= seg.Start {
continue
}
startLine, _ := offsetToLineCol(seg.Start, newlineOffsets)
// Stop is exclusive; step back one byte to land inside the segment.
stopLine, _ := offsetToLineCol(seg.Stop-1, newlineOffsets)
for L := startLine; L <= stopLine; L++ {
if L >= 1 && L <= len(inCode) {
inCode[L-1] = true
}
}
}
}
// spec:planctl/R1.5+D§3.2
// appendInlineMask appends MaskRun entries covering an inline CodeSpan's
// byte range. The mask extends one byte past the children's segment bounds
// on each side so the surrounding backticks are included — a tag like
// `` `_Requirements: R9.9_` `` must not extract R9.9. If the span crosses a
// line boundary (rare), the mask is expanded across all affected lines.
func appendInlineMask(n ast.Node, source []byte, newlineOffsets []int, lines []string, lineMask [][]MaskRun) {
minStart, maxStop := -1, -1
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if tc, ok := c.(*ast.Text); ok {
if minStart == -1 || tc.Segment.Start < minStart {
minStart = tc.Segment.Start
}
if tc.Segment.Stop > maxStop {
maxStop = tc.Segment.Stop
}
}
}
if minStart == -1 {
// Empty code span or a variant we don't recognise; skip safely.
return
}
spanStart := minStart - 1
if spanStart < 0 {
spanStart = 0
}
spanStop := maxStop + 1
if spanStop > len(source) {
spanStop = len(source)
}
startLine, startCol := offsetToLineCol(spanStart, newlineOffsets)
stopLine, stopCol := offsetToLineCol(spanStop, newlineOffsets)
if startLine == stopLine {
if idx := startLine - 1; idx >= 0 && idx < len(lineMask) {
lineMask[idx] = append(lineMask[idx], MaskRun{Start: startCol, End: stopCol})
}
return
}
// Multi-line code span: mask the trailing portion of the first line,
// full intermediate lines, and the leading portion of the final line.
if idx := startLine - 1; idx >= 0 && idx < len(lineMask) {
lineMask[idx] = append(lineMask[idx], MaskRun{Start: startCol, End: len(lines[idx])})
}
for L := startLine; L < stopLine-1; L++ {
if L >= 0 && L < len(lineMask) {
lineMask[L] = append(lineMask[L], MaskRun{Start: 0, End: len(lines[L])})
}
}
if idx := stopLine - 1; idx >= 0 && idx < len(lineMask) {
lineMask[idx] = append(lineMask[idx], MaskRun{Start: 0, End: stopCol})
}
}

365
cmd/planctl/scan_test.go Normal file
View file

@ -0,0 +1,365 @@
package main
import (
"strings"
"testing"
)
// spec:planctl/R1.5+D§3.2
func TestScanBytes_InCode(t *testing.T) {
tests := []struct {
name string
source string
wantIn []bool // parallel to Lines
wantLen int // expected line count; 0 = skip length assertion
}{
{
name: "plain prose has no code lines",
source: joinLines(
"This is a paragraph.",
"Another paragraph line.",
"",
"Third one.",
),
wantIn: []bool{false, false, false, false},
},
{
name: "fenced code block marks only the content lines",
source: joinLines(
"Before fence.",
"```",
"code line 1",
"code line 2",
"```",
"After fence.",
),
// Goldmark's FencedCodeBlock.Lines() covers the content between fences
// (lines 3 and 4 here), not the fence delimiter lines themselves.
wantIn: []bool{false, false, true, true, false, false},
},
{
name: "fenced code block with language info string",
source: joinLines(
"Intro",
"```go",
"func foo() {}",
"```",
),
wantIn: []bool{false, false, true, false},
},
{
name: "indented code block (4-space)",
source: joinLines(
"Paragraph.",
"",
" indented code",
" more indented code",
"",
"After.",
),
wantIn: []bool{false, false, true, true, false, false},
},
{
name: "HTML block",
source: joinLines(
"Paragraph.",
"",
"<div>",
" raw html",
"</div>",
"",
"After.",
),
wantIn: []bool{false, false, true, true, true, false, false},
},
{
name: "empty source produces empty result",
source: "",
wantIn: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := scanBytes("<test>", []byte(tc.source))
if len(r.InCode) != len(tc.wantIn) {
t.Fatalf("len(InCode) = %d, want %d\nsource:\n%s\nInCode: %v", len(r.InCode), len(tc.wantIn), tc.source, r.InCode)
}
for i := range tc.wantIn {
if r.InCode[i] != tc.wantIn[i] {
t.Errorf("InCode[%d] = %v, want %v (line: %q)", i, r.InCode[i], tc.wantIn[i], safeIndex(r.Lines, i))
}
}
})
}
}
// spec:planctl/R1.5+D§3.2
func TestScanBytes_InlineCodeMask(t *testing.T) {
tests := []struct {
name string
// source: a single-line snippet.
source string
// wantMaskedSubstrings: substrings that must land inside a masked range
// on the line they appear. Each substring is expected to appear exactly
// once; the test finds its columns via strings.Index + len.
wantMaskedSubstrings []string
// wantUnmaskedSubstrings: substrings that must NOT be covered by any
// MaskRun (i.e. they're in plain prose).
wantUnmaskedSubstrings []string
}{
{
name: "inline code span masks backticked content",
source: "Prose with `code here` and more prose.",
wantMaskedSubstrings: []string{"code here"},
wantUnmaskedSubstrings: []string{"Prose with", "more prose"},
},
{
name: "tag-like token inside backticks is masked",
source: "The literal `_Requirements: R9.9_` appears in docs.",
wantMaskedSubstrings: []string{"_Requirements: R9.9_"},
wantUnmaskedSubstrings: []string{"appears in docs"},
},
{
name: "two inline code spans on one line",
source: "First `alpha` then `beta` end.",
wantMaskedSubstrings: []string{"alpha", "beta"},
wantUnmaskedSubstrings: []string{"First", "then", "end"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := scanBytes("<test>", []byte(tc.source))
if len(r.Lines) == 0 {
t.Fatal("expected at least one line")
}
line := r.Lines[0]
mask := r.LineMask[0]
for _, needle := range tc.wantMaskedSubstrings {
col := strings.Index(line, needle)
if col < 0 {
t.Fatalf("substring %q not found in line %q", needle, line)
}
end := col + len(needle)
if !rangeCovered(mask, col, end) {
t.Errorf("substring %q [%d,%d) is not covered by mask %v", needle, col, end, mask)
}
}
for _, needle := range tc.wantUnmaskedSubstrings {
col := strings.Index(line, needle)
if col < 0 {
t.Fatalf("substring %q not found in line %q", needle, line)
}
end := col + len(needle)
if rangeOverlapsAny(mask, col, end) {
t.Errorf("substring %q [%d,%d) should NOT overlap mask %v", needle, col, end, mask)
}
}
})
}
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_NestedEmphasisAroundCode covers task 2.7's "nested emphasis"
// case — an inline code span inside an emphasis span should still produce a
// LineMask entry covering the code. CommonMark allows `_*emph with `code`*_`
// style; goldmark parses it and the scanner should mask "code" regardless.
func TestScanBytes_NestedEmphasisAroundCode(t *testing.T) {
cases := []string{
"Prose *italic with `tag _Requirements: R9.9_` inside* end.",
"Prose **bold with `tag _Design: D§9.9_` inside** end.",
}
for _, source := range cases {
t.Run(source[:20], func(t *testing.T) {
r := scanBytes("<t>", []byte(source))
if len(r.Lines) == 0 {
t.Fatal("expected one line")
}
line := r.Lines[0]
mask := r.LineMask[0]
// Find the tag inside the backticks and assert it's masked.
needle := "tag _"
col := strings.Index(line, needle)
if col < 0 {
t.Fatalf("needle %q not found in %q", needle, line)
}
if !rangeOverlapsAny(mask, col, col+len(needle)) {
t.Errorf("expected %q [%d,%d) to overlap mask %v — emphasis-nested code span must still mask",
needle, col, col+len(needle), mask)
}
})
}
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_MixedFeatures exercises a single source containing a fenced
// block, an indented block, an inline code span, and a plain paragraph
// together — the pattern a real plan file looks like.
func TestScanBytes_MixedFeatures(t *testing.T) {
source := joinLines(
"# Heading",
"",
"Paragraph with `inline code` and more text.",
"",
"```go",
"func fenced() {}",
"```",
"",
"Another paragraph.",
"",
" indented code block",
" second indented line",
"",
"Final paragraph.",
)
r := scanBytes("<t>", []byte(source))
// Expected line-level classification.
type lineCheck struct {
line int // 1-indexed
inCode bool
}
checks := []lineCheck{
{1, false}, // "# Heading"
{3, false}, // paragraph with inline code
{5, false}, // "```go"
{6, true}, // "func fenced() {}"
{7, false}, // closing "```"
{9, false}, // "Another paragraph."
{11, true}, // " indented code block"
{12, true}, // " second indented line"
{14, false}, // "Final paragraph."
}
for _, c := range checks {
if r.InCode[c.line-1] != c.inCode {
t.Errorf("InCode[line %d] = %v, want %v (line content: %q)",
c.line, r.InCode[c.line-1], c.inCode, r.Lines[c.line-1])
}
}
// Inline-code mask on line 3 covers "inline code".
line3 := r.Lines[2]
col := strings.Index(line3, "inline code")
if col < 0 {
t.Fatalf("substring not found in line 3: %q", line3)
}
if !rangeCovered(r.LineMask[2], col, col+len("inline code")) {
t.Errorf("line 3 mask %v does not cover inline code span [%d,%d)",
r.LineMask[2], col, col+len("inline code"))
}
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_FencedBlockWithoutTrailingNewline pins the EOF edge — a source
// that ends mid-fenced-block without a final '\n'. Goldmark's segment Stop
// positions can point past EOF here; the scanner must not panic and must still
// classify the content line as InCode.
func TestScanBytes_FencedBlockWithoutTrailingNewline(t *testing.T) {
// Note: no trailing "\n" — source ends at "content".
source := "prose\n```\ncontent"
r := scanBytes("<t>", []byte(source))
if len(r.Lines) != 3 {
t.Fatalf("len(Lines) = %d, want 3; got %v", len(r.Lines), r.Lines)
}
// Line 3 is "content" inside the (unclosed) fenced block. Depending on
// goldmark's recovery, it may or may not be InCode — both outcomes are
// acceptable, the test's purpose is just to assert the scanner doesn't
// panic on the unterminated input.
_ = r.InCode
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_InlineCodeMask_ExactRanges pins down the boundary columns
// for single- and double-backtick code spans so future regressions in the
// mask-extension-by-1 logic are caught immediately.
func TestScanBytes_InlineCodeMask_ExactRanges(t *testing.T) {
source := "aa `bb` cc\n"
// Column layout: a=0 a=1 ' '=2 `=3 b=4 b=5 `=6 ' '=7 c=8 c=9
// CodeSpan children cover "bb" at [4, 6); mask extends by 1 on each side → [3, 7).
r := scanBytes("<t>", []byte(source))
if len(r.LineMask) != 1 || len(r.LineMask[0]) != 1 {
t.Fatalf("expected one mask on one line, got %v", r.LineMask)
}
got := r.LineMask[0][0]
if got.Start != 3 || got.End != 7 {
t.Errorf("MaskRun = %+v, want {Start:3, End:7}", got)
}
}
// spec:planctl/R1.5+D§3.2
func TestScanBytes_TagInsideFenceIsInCode(t *testing.T) {
// Regression guard for R1.5: a tag-like token inside a fenced block must
// land on a line with InCode==true so the indexer skips it.
source := joinLines(
"Some intro prose.",
"",
"```",
"- [ ] 1.1 do stuff _Requirements: R9.9_",
"```",
"",
"End.",
)
r := scanBytes("<test>", []byte(source))
// Line indices (1-based): 1=intro, 2=blank, 3=```, 4=tag-in-fence, 5=```, 6=blank, 7=End
if !r.InCode[3] { // 0-indexed: line 4 → index 3
t.Errorf("expected InCode[3] (tag-in-fence line) = true; got false.\nInCode=%v\nLines=%v", r.InCode, r.Lines)
}
}
// spec:planctl/D§3.2
func TestScanBytes_NewlineOffsets(t *testing.T) {
// Sanity: splitLines + newline index consistency across trailing-newline
// variations.
cases := []struct {
in string
wantLen int
wantLast string
}{
{"a\nb\nc", 3, "c"},
{"a\nb\nc\n", 3, "c"},
{"single", 1, "single"},
{"", 0, ""},
}
for _, tc := range cases {
r := scanBytes("<t>", []byte(tc.in))
if len(r.Lines) != tc.wantLen {
t.Errorf("splitLines(%q): len = %d, want %d", tc.in, len(r.Lines), tc.wantLen)
continue
}
if tc.wantLen > 0 && r.Lines[tc.wantLen-1] != tc.wantLast {
t.Errorf("splitLines(%q): last line = %q, want %q", tc.in, r.Lines[tc.wantLen-1], tc.wantLast)
}
}
}
// --- helpers ---
func joinLines(lines ...string) string {
return strings.Join(lines, "\n") + "\n"
}
func safeIndex(s []string, i int) string {
if i < 0 || i >= len(s) {
return "<out-of-range>"
}
return s[i]
}
// rangeCovered reports whether every byte in [start, end) lies inside some
// MaskRun in mask.
func rangeCovered(mask []MaskRun, start, end int) bool {
for _, m := range mask {
if m.Start <= start && end <= m.End {
return true
}
}
return false
}
// rangeOverlapsAny reports whether [start, end) intersects any MaskRun.
func rangeOverlapsAny(mask []MaskRun, start, end int) bool {
for _, m := range mask {
if start < m.End && m.Start < end {
return true
}
}
return false
}

View file

@ -6,3 +6,4 @@ Append-only traceability log of codex review sessions for this plan. Each entry
- 2026-04-21 design-review 019db1c6-9329-7272-b683-b4706fc99c17 _(3 codex rounds + 1 user-driven revision round; R1 found 5 issues — umbrella traceability tags, permissive EARS matcher, wrong Case C algorithm, multi-plan output format drift, internal/planctl/ inconsistency; plus DQ feedback + 5 test-fixture gaps. R2 all 5 resolved, 1 remaining — §3.7 single-plan example had stray summary line. R3 approved after removing summary line + narrowing the `**` EARS-exemption regex. R4 was a user-driven switch from regex-only to goldmark AST; codex web-searched goldmark to verify zero-transitive-deps + TaskList extension presence, approved.)_
- 2026-04-21 tasks-review 019db209-a49b-7ae1-a396-4fcbae83e0ec _(2 rounds; R1 flagged 4 issue classes — invalid M3/M5a in `_Requirements:_` tags, invalid `PRD §6` / `infra` in `_Design:_` tags, missing design coverage for D§4 / D§6 / D§7.1 / D§7.5 / D§8, `east.TaskCheckBox` import-alias ambiguity, Relevant Files missing `dev/README.md`; plus task 5.3 breadth + task 5.6 pre-emitter checkpoint. R2 all concerns resolved; one non-blocker noted — 5.x→6.x output-format churn is inherent to the phased decomposition, flagged explicitly in 5.6's text.)_
- 2026-04-21 code-review-parent-1 019db22d-7978-7e70-ae87-5242d1eedc25 _(3 rounds; R1 flagged missing `// spec:planctl/...` tags on `main()`, `runLint()`, and `TestRun_LintStub`. R2 confirmed main/runLint tags landed, flagged test-tag mismatch — test should mirror runLint's R6.2+D§3.6, not just D§3.6. R3 approved after alignment.)_
- 2026-04-21 code-review-parent-2 019db23c-6c44-7d00-b3a4-6653882f0964 _(2 rounds; R1 flagged missing `// spec:planctl/...` tags on `splitLines` / `buildNewlineIndex` / `offsetToLineCol` and missing test coverage for "nested emphasis around code" + "mixed" cases from task 2.7; did not reproduce the `offsetToLineCol` off-by-one I'd mentioned fixing. R2 approved after adding the three tags, three new test functions (NestedEmphasisAroundCode, MixedFeatures, InlineCodeMask_ExactRanges), and TestScanBytes_FencedBlockWithoutTrailingNewline for the residual EOF edge concern. Also fixed: `.gitignore` pattern `planctl``/planctl` so scan.go / scan_test.go weren't silently ignored as "any path matching planctl".)_

View file

@ -68,15 +68,15 @@ As you complete each task, flip `[ ]` to `[x]` in this file. Update after each s
- [x] 1.6 Add `cmd/planctl/main_test.go` with subtests for `--help` (exit 0, usage contains all flag names), `--version` (exit 0), each v2-reserved subcommand (exit 2, message mentions "not implemented in v1"), and unknown subcommand (exit 2). Use `os/exec` to invoke a built binary or call `run(args []string)` directly — pick whichever keeps the binary testable without a build step _Requirements: R5.7, R5.8, R6.1_ _Design: D§3.6_
- [x] 1.7 Verify `go build ./cmd/planctl/` and `go test ./cmd/planctl/` both pass _Requirements: infra_
- [ ] 2.0 Scanner — goldmark-backed fence / inline-code classification _Requirements: R1.5_ _Design: D§3.2_
- [ ] 2.1 Create `cmd/planctl/scan.go` with `ScanResult` and `MaskRun` types per design §3.2 _Requirements: R1.5_ _Design: D§3.2_
- [ ] 2.2 Implement `Scan(path string) (ScanResult, error)`: `os.ReadFile`, run `goldmark.DefaultParser().Parse(text.NewReader(source))`, populate `Source` and `Lines` (split on `\n`, preserve empty trailing lines only if file ends without newline) _Requirements: R1.5_ _Design: D§3.2_
- [ ] 2.3 Construct a parser that enables the goldmark **TaskList extension** (`extension.TaskList` from `github.com/yuin/goldmark/extension`) so `[ ]` / `[x]` list items surface as `east.TaskCheckBox` nodes — where `east` is the conventional import alias for `github.com/yuin/goldmark/extension/ast`. The extension is in the goldmark module — no new `require` _Requirements: R1.5_ _Design: D§3.3 ("Task lines")_
- [ ] 2.4 Precompute a newline-offset index over `Source` so goldmark `text.Segment` byte positions map to 1-indexed line numbers in O(log n) _Requirements: R1.5_ _Design: D§3.2_
- [ ] 2.5 AST walk #1 — for every `ast.CodeBlock`, `ast.FencedCodeBlock`, `ast.HTMLBlock`: resolve segment range → line range; set `InCode[line]=true` for each covered line _Requirements: R1.5_ _Design: D§3.2_
- [ ] 2.6 AST walk #2 — for every `ast.CodeSpan`: resolve segment → `(line, col_start, col_end)`; append a `MaskRun` to `LineMask[line]`. Column indices are 0-indexed over the raw line bytes _Requirements: R1.5_ _Design: D§3.2_
- [ ] 2.7 Create `cmd/planctl/scan_test.go` with table-driven cases: fenced block (``` ```…``` ```), indented code block (4-space), HTML block, inline `` `code` ``, nested emphasis around code, mixed. For each input assert `InCode[]` and `LineMask[]` match expected values _Requirements: R1.5_ _Design: D§3.2, D§7.1_
- [ ] 2.8 Verify `go test ./cmd/planctl/ -run Scan` passes _Requirements: infra_
- [x] 2.0 Scanner — goldmark-backed fence / inline-code classification _Requirements: R1.5_ _Design: D§3.2_
- [x] 2.1 Create `cmd/planctl/scan.go` with `ScanResult` and `MaskRun` types per design §3.2 _Requirements: R1.5_ _Design: D§3.2_
- [x] 2.2 Implement `Scan(path string) (ScanResult, error)`: `os.ReadFile`, run `goldmark.DefaultParser().Parse(text.NewReader(source))`, populate `Source` and `Lines` (split on `\n`, preserve empty trailing lines only if file ends without newline) _Requirements: R1.5_ _Design: D§3.2_
- [x] 2.3 Construct a parser that enables the goldmark **TaskList extension** (`extension.TaskList` from `github.com/yuin/goldmark/extension`) so `[ ]` / `[x]` list items surface as `east.TaskCheckBox` nodes — where `east` is the conventional import alias for `github.com/yuin/goldmark/extension/ast`. The extension is in the goldmark module — no new `require` _Requirements: R1.5_ _Design: D§3.3 ("Task lines")_
- [x] 2.4 Precompute a newline-offset index over `Source` so goldmark `text.Segment` byte positions map to 1-indexed line numbers in O(log n) _Requirements: R1.5_ _Design: D§3.2_
- [x] 2.5 AST walk #1 — for every `ast.CodeBlock`, `ast.FencedCodeBlock`, `ast.HTMLBlock`: resolve segment range → line range; set `InCode[line]=true` for each covered line _Requirements: R1.5_ _Design: D§3.2_
- [x] 2.6 AST walk #2 — for every `ast.CodeSpan`: resolve segment → `(line, col_start, col_end)`; append a `MaskRun` to `LineMask[line]`. Column indices are 0-indexed over the raw line bytes _Requirements: R1.5_ _Design: D§3.2_
- [x] 2.7 Create `cmd/planctl/scan_test.go` with table-driven cases: fenced block (``` ```…``` ```), indented code block (4-space), HTML block, inline `` `code` ``, nested emphasis around code, mixed. For each input assert `InCode[]` and `LineMask[]` match expected values _Requirements: R1.5_ _Design: D§3.2, D§7.1_
- [x] 2.8 Verify `go test ./cmd/planctl/ -run Scan` passes _Requirements: infra_
- [ ] 3.0 Indexer — R-id / D§ / tag / task-line extraction with unclosed-tag recovery _Requirements: R1.1, R1.2, R1.6, R1.7, R2.1, R2.2, R2.3, R2.9, R3.3_ _Design: D§3.3_
- [ ] 3.1 Create `cmd/planctl/index.go` with `Plan`, `TagRef`, `Kind` (enum `KindRequirements` / `KindDesign`), `TaskLine`, `Index`, `Position` types per design §3.3 _Requirements: R2.1, R2.2, R2.3, R3.3_ _Design: D§3.3_

2
go.mod
View file

@ -2,4 +2,4 @@ module forgejo.zerova.net/sid/template-jj
go 1.26.1
require github.com/yuin/goldmark v1.8.2 // indirect
require github.com/yuin/goldmark v1.8.2