From aa73e2f72714a9353be95cc7f09fee31bb2c0d89 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 21 Apr 2026 16:38:27 -0600 Subject: [PATCH] =?UTF-8?q?feat(planctl):=20scanner=20=E2=80=94=20goldmark?= =?UTF-8?q?-backed=20InCode=20+=20LineMask=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- cmd/planctl/scan.go | 235 ++++++++++++++ cmd/planctl/scan_test.go | 365 ++++++++++++++++++++++ dev/plans/26172-planctl/codex-sessions.md | 1 + dev/plans/26172-planctl/tasks.md | 18 +- go.mod | 2 +- 5 files changed, 611 insertions(+), 10 deletions(-) create mode 100644 cmd/planctl/scan.go create mode 100644 cmd/planctl/scan_test.go diff --git a/cmd/planctl/scan.go b/cmd/planctl/scan.go new file mode 100644 index 0000000..15ab11f --- /dev/null +++ b/cmd/planctl/scan.go @@ -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}) + } +} diff --git a/cmd/planctl/scan_test.go b/cmd/planctl/scan_test.go new file mode 100644 index 0000000..f442fb7 --- /dev/null +++ b/cmd/planctl/scan_test.go @@ -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.", + "", + "
", + " raw html", + "
", + "", + "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("", []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("", []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("", []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("", []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("", []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("", []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("", []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("", []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 "" + } + 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 +} diff --git a/dev/plans/26172-planctl/codex-sessions.md b/dev/plans/26172-planctl/codex-sessions.md index 1f90a3e..9b8803b 100644 --- a/dev/plans/26172-planctl/codex-sessions.md +++ b/dev/plans/26172-planctl/codex-sessions.md @@ -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".)_ diff --git a/dev/plans/26172-planctl/tasks.md b/dev/plans/26172-planctl/tasks.md index 5430e95..77e3126 100644 --- a/dev/plans/26172-planctl/tasks.md +++ b/dev/plans/26172-planctl/tasks.md @@ -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_ diff --git a/go.mod b/go.mod index 60dd873..4dbd585 100644 --- a/go.mod +++ b/go.mod @@ -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