Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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:])
}