a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
295 lines
9.3 KiB
Go
295 lines
9.3 KiB
Go
// Package todos scans source comments for TODO / FIXME / HACK / XXX
|
|
// / NOTE markers and emits graph nodes and edges for each finding,
|
|
// so an agent can ask "list TODOs older than 90 days assigned to me"
|
|
// with a single graph query rather than a multi-file grep.
|
|
//
|
|
// The scanner is intentionally simple: a per-line regex restricted to
|
|
// "comment context" lines (preceded only by whitespace and a known
|
|
// comment opener). String literals containing the word "TODO" are not
|
|
// matched because they have non-whitespace content before the opener.
|
|
// This is the v1 tradeoff — a future revision can switch to
|
|
// AST-driven extraction once every language parser uniformly exposes
|
|
// comment nodes.
|
|
package todos
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// scanBufPool holds reusable 64 KB scratch buffers for bufio.Scanner.
|
|
// See internal/codegen/scanner.go for the same rationale — TODO
|
|
// scanning runs on every indexed file.
|
|
var scanBufPool = sync.Pool{
|
|
New: func() any {
|
|
b := make([]byte, 64*1024)
|
|
return &b
|
|
},
|
|
}
|
|
|
|
// Finding is one matched marker. Line is 1-based.
|
|
type Finding struct {
|
|
Tag string // TODO / FIXME / HACK / XXX / NOTE
|
|
Assignee string // value inside (parens) immediately after the tag, "" if absent
|
|
Due string // YYYY-MM-DD value inside [brackets] immediately after the tag, "" if absent
|
|
Ticket string // first #123 / JIRA-456 / ABC-123 reference found, "" if absent
|
|
Text string // remaining text after the tag/assignee/due, trimmed and capped at maxText
|
|
Line int // 1-based line number in the file
|
|
}
|
|
|
|
// commentLine is the gating regex: line must start with whitespace,
|
|
// then a known opener (// or # or -- or /* or *), then whitespace,
|
|
// then one of the configured tags, then a word boundary. The tag
|
|
// alternation is built dynamically from the user's tag list.
|
|
//
|
|
// We capture two groups: (1) opener — discarded, kept for clarity;
|
|
// (2) the tag itself; (3) the rest of the line (assignee/due/text).
|
|
// The assignee/due/text split happens in parseRest so the regex stays
|
|
// readable and dialect-flexible.
|
|
var ticketRe = regexp.MustCompile(`(?:#\d+|[A-Z][A-Z0-9]+-\d+)`)
|
|
|
|
// dueRe matches a [YYYY-MM-DD] suffix immediately after the
|
|
// (assignee) (or after the tag if no assignee). We accept loose ISO
|
|
// dates; full RFC 3339 validation is over-engineering for a TODO
|
|
// scanner.
|
|
var dueRe = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2})\]`)
|
|
|
|
// assigneeRe matches a (name) suffix immediately after the tag.
|
|
// Allowed chars: letters, digits, _, -, @, ., space — covers
|
|
// usernames, emails, and "first last".
|
|
var assigneeRe = regexp.MustCompile(`^\(([\w@.\-\s]+)\)`)
|
|
|
|
// Scan walks source line-by-line and returns markers in document
|
|
// order. tags is the set of marker tokens to recognise (case-
|
|
// sensitive); maxText caps the stored text length.
|
|
func Scan(source []byte, tags []string, maxText int) []Finding {
|
|
if len(tags) == 0 || maxText <= 0 || len(source) == 0 {
|
|
return nil
|
|
}
|
|
|
|
tagPattern := buildTagPattern(tags)
|
|
if tagPattern == nil {
|
|
return nil
|
|
}
|
|
|
|
// Raw (un-escaped) tag list for a cheap substring pre-filter. The
|
|
// gating regex is anchored with `^\s*`, which defeats Go regexp's
|
|
// literal-prefix scan — without a pre-filter the full backtracking
|
|
// VM runs on every line of every file, including the ~99.99% with
|
|
// no marker at all. A line the regex can match must contain a tag
|
|
// verbatim, so rejecting tagless lines first drops zero true hits.
|
|
rawTags := make([]string, 0, len(tags))
|
|
for _, t := range tags {
|
|
if t = strings.TrimSpace(t); t != "" {
|
|
rawTags = append(rawTags, t)
|
|
}
|
|
}
|
|
|
|
var findings []Finding
|
|
bufPtr := scanBufPool.Get().(*[]byte)
|
|
defer scanBufPool.Put(bufPtr)
|
|
scanner := bufio.NewScanner(bytes.NewReader(source))
|
|
scanner.Buffer(*bufPtr, 1024*1024)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
if !containsAnyTag(line, rawTags) {
|
|
continue
|
|
}
|
|
match := tagPattern.FindStringSubmatch(line)
|
|
if match == nil {
|
|
continue
|
|
}
|
|
tag := match[2]
|
|
rest := strings.TrimSpace(line[len(match[0]):])
|
|
assignee, due, text := parseRest(rest)
|
|
ticket := ""
|
|
if loc := ticketRe.FindString(rest); loc != "" {
|
|
ticket = loc
|
|
}
|
|
if len(text) > maxText {
|
|
text = text[:maxText]
|
|
}
|
|
findings = append(findings, Finding{
|
|
Tag: tag,
|
|
Assignee: assignee,
|
|
Due: due,
|
|
Ticket: ticket,
|
|
Text: text,
|
|
Line: lineNum,
|
|
})
|
|
}
|
|
return findings
|
|
}
|
|
|
|
// containsAnyTag reports whether line contains any configured tag as a
|
|
// verbatim substring — the cheap pre-filter that lets Scan skip the
|
|
// regexp VM for lines that cannot possibly match. strings.Contains is
|
|
// a SIMD memchr scan, orders of magnitude cheaper than the backtracking
|
|
// engine the anchored gating pattern would otherwise run per line.
|
|
func containsAnyTag(line string, tags []string) bool {
|
|
for _, t := range tags {
|
|
if strings.Contains(line, t) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// buildTagPattern constructs the per-line regex from the configured
|
|
// tag list. Tags are matched case-sensitively; this is intentional —
|
|
// "Todo" mid-comment ("// I'll Todo this later") should not match.
|
|
func buildTagPattern(tags []string) *regexp.Regexp {
|
|
cleaned := make([]string, 0, len(tags))
|
|
for _, t := range tags {
|
|
t = strings.TrimSpace(t)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
cleaned = append(cleaned, regexp.QuoteMeta(t))
|
|
}
|
|
if len(cleaned) == 0 {
|
|
return nil
|
|
}
|
|
// Comment openers we recognise:
|
|
// // C-family line
|
|
// # Python / shell / Ruby / YAML / TOML
|
|
// -- SQL / Lua / Haskell
|
|
// /* C-family block start
|
|
// * continuation line inside a /* ... */ block
|
|
pattern := `^\s*(//|#|--|/\*|\*)\s+(` + strings.Join(cleaned, "|") + `)\b[:\s]*`
|
|
return regexp.MustCompile(pattern)
|
|
}
|
|
|
|
// parseRest splits "(assignee)[2026-05-01] rest of text" into its
|
|
// pieces. Either prefix is optional; whichever appears first is
|
|
// consumed first. The fragment we keep as Text is the trimmed
|
|
// remainder.
|
|
func parseRest(rest string) (assignee, due, text string) {
|
|
rest = strings.TrimSpace(rest)
|
|
if m := assigneeRe.FindStringSubmatch(rest); m != nil {
|
|
assignee = strings.TrimSpace(m[1])
|
|
rest = strings.TrimSpace(rest[len(m[0]):])
|
|
}
|
|
if m := dueRe.FindStringSubmatch(rest); m != nil {
|
|
due = m[1]
|
|
rest = strings.TrimSpace(rest[len(m[0]):])
|
|
}
|
|
// Drop a leading colon left by patterns like "TODO(zzet): text".
|
|
rest = strings.TrimLeft(rest, ":")
|
|
rest = strings.TrimSpace(rest)
|
|
text = rest
|
|
return
|
|
}
|
|
|
|
// BuildGraphArtifacts converts findings for a single file into the
|
|
// node/edge pairs the indexer will append. The owning file node is
|
|
// linked via EdgeAnnotated so the existing decorator-walk machinery
|
|
// surfaces todos alongside @Deprecated, @Test, etc.
|
|
//
|
|
// IDs follow the existing graph convention `<file>::<name>` so the
|
|
// indexer's applyRepoPrefix pass can prepend the repo prefix
|
|
// uniformly without special-casing this kind. Within a file, todos
|
|
// are named `todo:<line>`. Different TODOs on the same line are
|
|
// disambiguated by appending #<seq> — a rare case but keeps IDs
|
|
// unique.
|
|
//
|
|
// filePath is the unprefixed (per-file extractor) path; the indexer
|
|
// adds the repo prefix downstream via applyRepoPrefix.
|
|
func BuildGraphArtifacts(filePath string, findings []Finding, language string) ([]*graph.Node, []*graph.Edge) {
|
|
if len(findings) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
filePath = filepath.ToSlash(filePath)
|
|
fileID := filePath
|
|
nodes := make([]*graph.Node, 0, len(findings))
|
|
edges := make([]*graph.Edge, 0, len(findings))
|
|
seen := make(map[string]int)
|
|
for _, f := range findings {
|
|
baseID := todoNodeID(filePath, f.Line)
|
|
id := baseID
|
|
if n := seen[baseID]; n > 0 {
|
|
id = baseID + "#" + intToString(n)
|
|
}
|
|
seen[baseID] = seen[baseID] + 1
|
|
|
|
meta := map[string]any{
|
|
"tag": f.Tag,
|
|
"text": f.Text,
|
|
}
|
|
if f.Assignee != "" {
|
|
meta["assignee"] = f.Assignee
|
|
}
|
|
if f.Due != "" {
|
|
meta["due"] = f.Due
|
|
}
|
|
if f.Ticket != "" {
|
|
meta["ticket"] = f.Ticket
|
|
}
|
|
nodes = append(nodes, &graph.Node{
|
|
ID: id,
|
|
Kind: graph.KindTodo,
|
|
Name: "todo:" + intToString(f.Line),
|
|
FilePath: filePath,
|
|
StartLine: f.Line,
|
|
EndLine: f.Line,
|
|
Language: language,
|
|
Meta: meta,
|
|
})
|
|
edges = append(edges, &graph.Edge{
|
|
From: fileID,
|
|
To: id,
|
|
Kind: graph.EdgeAnnotated,
|
|
FilePath: filePath,
|
|
Line: f.Line,
|
|
Origin: graph.OriginASTResolved,
|
|
})
|
|
}
|
|
return nodes, edges
|
|
}
|
|
|
|
// todoNodeID builds the canonical node ID for a TODO at (path, line).
|
|
// Format `<file>::todo:<line>` slots cleanly into applyRepoPrefix
|
|
// (which prepends "<repo>/" to the whole ID) and into nodeShort
|
|
// (which returns whatever follows the last "::"). We intentionally
|
|
// do not include the tag — the same line cannot hold two markers in
|
|
// practice, and including the tag would mean a rename from FIXME →
|
|
// TODO produces a new node ID rather than an in-place update.
|
|
func todoNodeID(filePath string, line int) string {
|
|
return filePath + "::todo:" + intToString(line)
|
|
}
|
|
|
|
// intToString avoids pulling in strconv just for one int conversion.
|
|
// Allocation cost dominates over the conversion cost; this is in the
|
|
// hot path during full re-index.
|
|
func intToString(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
neg := false
|
|
if n < 0 {
|
|
neg = true
|
|
n = -n
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
if neg {
|
|
i--
|
|
buf[i] = '-'
|
|
}
|
|
return string(buf[i:])
|
|
}
|