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:
parent
a7a79eab65
commit
aa73e2f727
5 changed files with 611 additions and 10 deletions
235
cmd/planctl/scan.go
Normal file
235
cmd/planctl/scan.go
Normal 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
365
cmd/planctl/scan_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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".)_
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue