@tekyzinc/gsd-t 4.10.11 → 4.11.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-freshness.cjs +30 -6
- package/bin/gsd-t-graph-index.cjs +29 -10
- package/bin/gsd-t-graph-query-cli.cjs +10 -4
- package/bin/gsd-t-grep-classifier.cjs +97 -0
- package/bin/gsd-t-scip-reader.cjs +28 -8
- package/bin/gsd-t.js +69 -0
- package/package.json +1 -1
- package/scripts/gsd-t-graph-intercept.js +144 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [4.11.10] - 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Added — M97: the code graph is the default for ambient code-reading (grep-intercept), + 3 call-resolution fixes
|
|
8
|
+
|
|
9
|
+
A PostToolUse hook on Claude's built-in `Grep` makes structural searches consult the code graph instead of raw text. The graph only powered explicit GSD-T commands before; now any "where is this used / who calls this" grep gets a precomputed graph answer.
|
|
10
|
+
|
|
11
|
+
- `bin/gsd-t-grep-classifier.cjs`: NEW — conservative structural-vs-text classifier (bare symbol / call shape / member call / import → structural verb; strings/regex/phrases → TEXT pass-through).
|
|
12
|
+
- `scripts/gsd-t-graph-intercept.js`: NEW — PostToolUse hook on Grep. Structural + graph-present → query the graph, REPLACE the grep output via `updatedToolOutput` (original hits kept beneath, labeled by tier); else pass through. FAIL-OPEN, never calls Grep/Read (no loop), no-op without a graph.
|
|
13
|
+
- `bin/gsd-t.js`: `gsd-t install` registers the hook (matcher `Grep`), idempotent.
|
|
14
|
+
|
|
15
|
+
**Fixed — the call-graph was EMPTY on real projects (3 bugs):**
|
|
16
|
+
- `bin/gsd-t-graph-index.cjs`: the CLI `build` path passed the upgrader MODULE as the `scip` option, so `build_index` skipped auto-building the resolver → 0 resolved edges on every `gsd-t graph index` (fixtures passed via the direct `build_index({dbPath})` path). Now the CLI lets build_index auto-build.
|
|
17
|
+
- `bin/gsd-t-scip-reader.cjs`: method names after `#` (`Class#method()`) weren't extracted (split on `/` only) — now split on last `/` OR `#`; and build-output docs (`dist-*`/`build-*`/`out-*`) are skipped so minified bundles don't pollute who-calls.
|
|
18
|
+
- `bin/gsd-t-graph-query-cli.cjs`: who-calls dropped results on a funcId `@line`-suffix mismatch (funcEntities key `file#name@line`, callGraph keys `file#name`) — now tolerant.
|
|
19
|
+
- `bin/gsd-t-graph-freshness.cjs`: hashed files with md5 while the indexer stores sha256 — every file read as "changed" → whole-repo re-index on every query (`gsd-t graph status` 30s→0.38s on a large project). Now sha256(16), excludes synced.
|
|
20
|
+
|
|
21
|
+
binvoice (real project): 2,579 resolved call edges; `who-calls(getSessionToken)` → real callers. Hook latency ~0.46s/call (node startup; query <100ms). Suite: 2519/2519 pass.
|
|
22
|
+
|
|
23
|
+
## [4.10.12] - 2026-06-27
|
|
24
|
+
|
|
25
|
+
### Fixed — freshness hash mismatch made every graph query re-index the whole repo
|
|
26
|
+
|
|
27
|
+
`compute_touched_files` hashed files with md5 (full length) while the indexer stores sha256 sliced to 16 chars — a guaranteed permanent mismatch. So every file read as "edited" on every query and freshness re-indexed the entire repo (re-running SCIP), hanging `gsd-t graph status` for 30s+ (then reporting graph-unavailable) on large projects.
|
|
28
|
+
|
|
29
|
+
- `bin/gsd-t-graph-freshness.cjs`: `hashFileContent` now `sha256().slice(0,16)` — matches the indexer's `contentHash` exactly. `EXCLUDE_DIRS` synced with the indexer's `SKIP_DIRS` (`.venv`/`site-packages`/etc.) so freshness doesn't see phantom ADDs from vendored dirs the indexer skipped.
|
|
30
|
+
- 5 D4 freshness test fixtures seeded stores with md5 hashes — updated to sha256(16) to match the real store.
|
|
31
|
+
|
|
32
|
+
Measured on Tekyz-CRM: `gsd-t graph status` 30s-timeout-unavailable → 0.38s ok; `compute_touched_files` 291ms / 11,821-files-stale → 12ms / 0-stale. This also makes graph queries cheap enough for ambient grep-interception (the next milestone). Suite: 2502/2502 pass.
|
|
33
|
+
|
|
5
34
|
## [4.10.11] - 2026-06-27
|
|
6
35
|
|
|
7
36
|
### Fixed — M96: the code graph now actually runs in projects (native-dep resolution)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v4.10
|
|
3
|
+
**v4.11.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -48,24 +48,48 @@ function fail(msg) { return `${C.red}✘${C.reset} ${msg}`; }
|
|
|
48
48
|
// ─── Source-file extensions the indexer tracks ────────────────────────────────
|
|
49
49
|
const TRACKED_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py']);
|
|
50
50
|
|
|
51
|
-
// ─── Content-hash
|
|
52
|
-
//
|
|
53
|
-
//
|
|
51
|
+
// ─── Content-hash — MUST match the indexer's contentHash EXACTLY ─────────────
|
|
52
|
+
// The indexer (gsd-t-graph-index.cjs::contentHash) stores sha256(content) sliced
|
|
53
|
+
// to the first 16 hex chars. Freshness MUST compute the identical value, or EVERY
|
|
54
|
+
// file reads as "edited" on every query (the hash never matches) and freshness
|
|
55
|
+
// re-indexes the whole repo — the hang. (Was md5/full-length: a guaranteed
|
|
56
|
+
// permanent mismatch.) [RULE] freshness-hash-matches-indexer-hash
|
|
54
57
|
function hashFileContent(filePath) {
|
|
55
58
|
try {
|
|
56
59
|
const content = fs.readFileSync(filePath);
|
|
57
|
-
return crypto.createHash('
|
|
60
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
58
61
|
} catch {
|
|
59
62
|
return null;
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
// ─── Walk the working tree for source files ───────────────────────────────────
|
|
67
|
+
// MUST stay in sync with the indexer's SKIP_DIRS (gsd-t-graph-index.cjs). If
|
|
68
|
+
// freshness walks a dir the indexer skipped, every file in it reads as a phantom
|
|
69
|
+
// ADD on every query → re-index storm (bee-poc/Tekyz-CRM: 9,008 phantom adds from
|
|
70
|
+
// .venv + build bundles). [RULE] freshness-excludes-match-indexer-skipdirs
|
|
64
71
|
const EXCLUDE_DIRS = new Set([
|
|
65
72
|
'node_modules', '.git', 'dist', 'build', 'coverage',
|
|
66
73
|
'.gsd-t', '.claude', '__pycache__', '.next', 'out',
|
|
74
|
+
'.cache', '.nyc_output', '.turbo',
|
|
75
|
+
'.venv', 'venv', 'env', '.dart_tool', 'Pods', 'vendor', '.gradle',
|
|
76
|
+
'.idea', '.vscode', 'tmp', '.tmp', 'site-packages',
|
|
67
77
|
]);
|
|
68
78
|
|
|
79
|
+
// Build-output dirs with a suffix (dist-local, build-ios, out-prod) — prefix
|
|
80
|
+
// match, matching the indexer's shouldSkipDir. [RULE] freshness-excludes-match-indexer-skipdirs
|
|
81
|
+
const EXCLUDE_DIR_PREFIXES = ['dist', 'build', 'out'];
|
|
82
|
+
function shouldExcludeDir(name) {
|
|
83
|
+
if (EXCLUDE_DIRS.has(name)) return true;
|
|
84
|
+
for (const pre of EXCLUDE_DIR_PREFIXES) {
|
|
85
|
+
if (name.length > pre.length && name.startsWith(pre) &&
|
|
86
|
+
(name[pre.length] === '-' || name[pre.length] === '.' || name[pre.length] === '_')) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
function walkTree(dir, results = []) {
|
|
70
94
|
// Defense-in-depth: refuse to walk the filesystem root or the user's home dir.
|
|
71
95
|
// A bogus projectRoot (e.g. "/") from a fake store path would otherwise recurse
|
|
@@ -80,9 +104,9 @@ function walkTree(dir, results = []) {
|
|
|
80
104
|
catch { return results; }
|
|
81
105
|
for (const e of entries) {
|
|
82
106
|
if (e.name.startsWith('.') && e.name !== '.claude') {
|
|
83
|
-
if (
|
|
107
|
+
if (shouldExcludeDir(e.name)) continue;
|
|
84
108
|
}
|
|
85
|
-
if (
|
|
109
|
+
if (shouldExcludeDir(e.name)) continue;
|
|
86
110
|
const full = path.join(dir, e.name);
|
|
87
111
|
if (e.isDirectory()) {
|
|
88
112
|
walkTree(full, results);
|
|
@@ -70,8 +70,30 @@ const SKIP_DIRS = new Set([
|
|
|
70
70
|
'node_modules', '.next', 'dist', 'build', '.git',
|
|
71
71
|
'.cache', '__pycache__', 'coverage', '.nyc_output',
|
|
72
72
|
'out', '.turbo',
|
|
73
|
+
// M96 follow-up: vendored / generated trees that aren't project source and
|
|
74
|
+
// explode the file count (bee-poc had 18K files from .venv + iOS build assets,
|
|
75
|
+
// hanging scip-typescript at 8.5GB). Exclude language vendor/build dirs.
|
|
76
|
+
'.venv', 'venv', 'env', '.dart_tool', 'Pods', 'vendor', '.gradle',
|
|
77
|
+
'.idea', '.vscode', 'tmp', '.tmp', 'site-packages',
|
|
73
78
|
]);
|
|
74
79
|
|
|
80
|
+
// Build-output dirs often carry a suffix (dist-local, dist-test, build-ios,
|
|
81
|
+
// out-prod). Prefix-match these so generated/minified bundles never pollute the
|
|
82
|
+
// graph (binvoice indexed dist-local/dist-test → duplicate symbols in who-calls).
|
|
83
|
+
const SKIP_DIR_PREFIXES = ['dist', 'build', 'out'];
|
|
84
|
+
|
|
85
|
+
function shouldSkipDir(name) {
|
|
86
|
+
if (SKIP_DIRS.has(name)) return true;
|
|
87
|
+
for (const pre of SKIP_DIR_PREFIXES) {
|
|
88
|
+
// exact 'dist' is already in SKIP_DIRS; match 'dist-*'/'dist.*' variants.
|
|
89
|
+
if (name.length > pre.length && name.startsWith(pre) &&
|
|
90
|
+
(name[pre.length] === '-' || name[pre.length] === '.' || name[pre.length] === '_')) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
// ── Content hash ──────────────────────────────────────────────────────────────
|
|
76
98
|
|
|
77
99
|
/**
|
|
@@ -92,7 +114,7 @@ function enumerateFiles(root) {
|
|
|
92
114
|
catch { return; }
|
|
93
115
|
for (const e of entries) {
|
|
94
116
|
if (e.isDirectory()) {
|
|
95
|
-
if (!
|
|
117
|
+
if (!shouldSkipDir(e.name)) walk(path.join(dir, e.name));
|
|
96
118
|
} else if (e.isFile()) {
|
|
97
119
|
const ext = path.extname(e.name).toLowerCase();
|
|
98
120
|
if (PARSED_EXTS.has(ext)) {
|
|
@@ -487,17 +509,14 @@ if (require.main === module) {
|
|
|
487
509
|
info(`build_index — repo: ${repoRoot}`);
|
|
488
510
|
info(`Store: ${dbPath}`);
|
|
489
511
|
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
512
|
+
// Pass scip:null so build_index AUTO-BUILDS the repo-level SCIP resolver
|
|
513
|
+
// (runs scip-typescript once, reads index.scip, resolves call edges). Passing
|
|
514
|
+
// the raw upgrader MODULE here was the bug: build_index saw a truthy `scip`
|
|
515
|
+
// context with no `.resolver`, skipped auto-build, and stored 0 resolved edges
|
|
516
|
+
// on every CLI `gsd-t graph index` (M95/M96 fixtures passed because they called
|
|
517
|
+
// build_index({dbPath}) directly with scip:null). [RULE] cli-build-auto-resolves-scip
|
|
498
518
|
const result = build_index(repoRoot, {
|
|
499
519
|
dbPath,
|
|
500
|
-
scip,
|
|
501
520
|
onProgress: ({ file, tier, fileCount, total }) => {
|
|
502
521
|
if (fileCount % 100 === 0) {
|
|
503
522
|
info(`[${fileCount}/${total}] ${tier === 'compiler-accurate' ? C.green : C.dim}${file}${C.reset}`);
|
|
@@ -573,8 +573,9 @@ function queryWhoCalls(index, identity) {
|
|
|
573
573
|
const coverage = computeCoverage(index.skippedFiles);
|
|
574
574
|
|
|
575
575
|
if (isFuncId) {
|
|
576
|
-
// File-qualified identity — exact funcId lookup
|
|
577
|
-
|
|
576
|
+
// File-qualified identity — exact funcId lookup (tolerate @line suffix:
|
|
577
|
+
// callGraph keys on `file#name`, callers may pass `file#name@line`).
|
|
578
|
+
const callers = index.callGraph.get(identity) || index.callGraph.get(identity.replace(/@\d+$/, ''));
|
|
578
579
|
const results = callers ? Array.from(callers).sort() : [];
|
|
579
580
|
return { results, tier: index.tier, coverage };
|
|
580
581
|
}
|
|
@@ -593,8 +594,13 @@ function queryWhoCalls(index, identity) {
|
|
|
593
594
|
}
|
|
594
595
|
|
|
595
596
|
if (matchingFuncIds.length === 1) {
|
|
596
|
-
// Unambiguous bare name — resolve directly
|
|
597
|
-
|
|
597
|
+
// Unambiguous bare name — resolve directly.
|
|
598
|
+
// funcEntities key as `file#name@line`, but call edges (and thus callGraph)
|
|
599
|
+
// key as `file#name` (no @line) — try both so the @line-suffix difference
|
|
600
|
+
// doesn't drop real callers. [RULE] who-calls-funcid-line-suffix-tolerant
|
|
601
|
+
const fid = matchingFuncIds[0];
|
|
602
|
+
const fidNoLine = fid.replace(/@\d+$/, '');
|
|
603
|
+
const callers = index.callGraph.get(fid) || index.callGraph.get(fidNoLine);
|
|
598
604
|
const results = callers ? Array.from(callers).sort() : [];
|
|
599
605
|
return { results, tier: index.tier, coverage };
|
|
600
606
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gsd-t-grep-classifier.cjs
|
|
3
|
+
*
|
|
4
|
+
* M97 — Classify a Grep tool call as STRUCTURAL (the code graph can answer it
|
|
5
|
+
* better) or TEXT (let grep run). The grep-intercept hook uses this to decide
|
|
6
|
+
* whether to replace grep output with a graph answer.
|
|
7
|
+
*
|
|
8
|
+
* CONSERVATIVE BY DESIGN: when unsure → TEXT (pass grep through). A false
|
|
9
|
+
* "structural" hijacks a legitimate text search (bad); a false "text" just means
|
|
10
|
+
* we miss a graph opportunity (harmless — grep still works). So the bar for
|
|
11
|
+
* STRUCTURAL is high and the patterns are narrow.
|
|
12
|
+
*
|
|
13
|
+
* STRUCTURAL = the pattern is asking "where is this symbol used / who calls it /
|
|
14
|
+
* who imports it" — answerable from the import/call graph:
|
|
15
|
+
* - a bare identifier (a function/class/variable name), no regex metachars
|
|
16
|
+
* - an explicit import line for a symbol (import ... X / from ... import X)
|
|
17
|
+
* - a function/method call shape (foo( / X.method() )
|
|
18
|
+
*
|
|
19
|
+
* TEXT = everything else: string literals, error messages, TODOs, regexes,
|
|
20
|
+
* multi-word phrases, paths, comments, anything with regex metacharacters that
|
|
21
|
+
* isn't a plain call shape.
|
|
22
|
+
*
|
|
23
|
+
* [RULE] grep-classifier-conservative-text-default
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// A plain identifier: starts with a letter/_/$ then word chars. No spaces, no
|
|
29
|
+
// regex metacharacters, no dots (a bare name, not a member access or path).
|
|
30
|
+
const BARE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
31
|
+
|
|
32
|
+
// A member-call shape: Obj.method or this.method (optionally with a trailing "(").
|
|
33
|
+
const MEMBER_CALL = /^[A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*\(?$/;
|
|
34
|
+
|
|
35
|
+
// A call shape: foo( — a bare name immediately followed by "(".
|
|
36
|
+
const CALL_SHAPE = /^[A-Za-z_$][A-Za-z0-9_$]*\($/;
|
|
37
|
+
|
|
38
|
+
// Import-of-a-symbol shapes (JS/TS + Python). Capture the symbol.
|
|
39
|
+
const IMPORT_SHAPES = [
|
|
40
|
+
/^import\s+\{?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\}?/, // import X / import { X }
|
|
41
|
+
/^from\s+\S+\s+import\s+([A-Za-z_$][A-Za-z0-9_$]*)/, // from m import X (python)
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Regex metacharacters that signal a TEXT/regex search (not a bare symbol).
|
|
45
|
+
// (We allow a single trailing "(" for the call shape, handled before this.)
|
|
46
|
+
const REGEX_METACHARS = /[\\^$.*+?\[\]{}|()<>:;,"'`@#%&!~ \t]/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Classify a grep pattern.
|
|
50
|
+
* @param {string} pattern the Grep tool's `pattern` input
|
|
51
|
+
* @param {object} [opts] reserved (e.g. path hints)
|
|
52
|
+
* @returns {{ structural: boolean, kind: string, symbol: string|null, verb: string|null }}
|
|
53
|
+
* kind: 'bare-symbol' | 'member-call' | 'call-shape' | 'import' | 'text'
|
|
54
|
+
* verb: the graph verb to run ('who-calls' | 'who-imports') or null for text
|
|
55
|
+
*/
|
|
56
|
+
function classifyGrep(pattern, opts = {}) {
|
|
57
|
+
const TEXT = { structural: false, kind: 'text', symbol: null, verb: null };
|
|
58
|
+
|
|
59
|
+
if (typeof pattern !== 'string') return TEXT;
|
|
60
|
+
const p = pattern.trim();
|
|
61
|
+
if (!p) return TEXT;
|
|
62
|
+
|
|
63
|
+
// Too long to be a symbol → text. (Symbols are short; long patterns are prose.)
|
|
64
|
+
if (p.length > 80) return TEXT;
|
|
65
|
+
|
|
66
|
+
// 1. Import-of-a-symbol → who-imports on that symbol.
|
|
67
|
+
for (const re of IMPORT_SHAPES) {
|
|
68
|
+
const m = p.match(re);
|
|
69
|
+
if (m && m[1]) {
|
|
70
|
+
return { structural: true, kind: 'import', symbol: m[1], verb: 'who-imports' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Bare identifier → who-calls + who-imports (the hook unions them).
|
|
75
|
+
if (BARE_IDENT.test(p)) {
|
|
76
|
+
return { structural: true, kind: 'bare-symbol', symbol: p, verb: 'who-calls' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. Call shape: foo( → who-calls on foo.
|
|
80
|
+
if (CALL_SHAPE.test(p)) {
|
|
81
|
+
return { structural: true, kind: 'call-shape', symbol: p.slice(0, -1), verb: 'who-calls' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 4. Member call: Obj.method / this.method → who-calls on the method name.
|
|
85
|
+
if (MEMBER_CALL.test(p)) {
|
|
86
|
+
const method = p.replace(/\(?$/, '').split('.').pop();
|
|
87
|
+
return { structural: true, kind: 'member-call', symbol: method, verb: 'who-calls' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 5. Anything with regex metachars / spaces / quotes → TEXT (conservative).
|
|
91
|
+
if (REGEX_METACHARS.test(p)) return TEXT;
|
|
92
|
+
|
|
93
|
+
// 6. Fallthrough → TEXT.
|
|
94
|
+
return TEXT;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { classifyGrep, BARE_IDENT, MEMBER_CALL, CALL_SHAPE };
|
|
@@ -81,12 +81,14 @@ function loadScipProto() {
|
|
|
81
81
|
*/
|
|
82
82
|
function funcNameFromSymbol(symbol) {
|
|
83
83
|
if (!symbol || typeof symbol !== 'string') return null;
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
84
|
+
// Only function/method occurrences (end with "()."). Parameters are "name().(p)"
|
|
85
|
+
// — they don't end with "()." so they're excluded.
|
|
86
|
+
if (!/\(\)\.$/.test(symbol)) return null;
|
|
87
|
+
// The callable name is the last descriptor segment. Top-level functions are
|
|
88
|
+
// ".../`file.ts`/name()."; methods are ".../`file.ts`/Class#method()." — so the
|
|
89
|
+
// name follows the LAST '/' OR '#', whichever is later (methods key on '#').
|
|
90
|
+
const cut = Math.max(symbol.lastIndexOf('/'), symbol.lastIndexOf('#'));
|
|
91
|
+
const descriptor = cut === -1 ? symbol : symbol.slice(cut + 1);
|
|
90
92
|
const m = descriptor.match(/^([A-Za-z_$][\w$]*)\(\)\.$/);
|
|
91
93
|
return m ? m[1] : null;
|
|
92
94
|
}
|
|
@@ -95,6 +97,24 @@ function funcNameFromSymbol(symbol) {
|
|
|
95
97
|
|
|
96
98
|
const SYMBOL_ROLE_DEFINITION = 0x1; // SymbolRole.Definition bit
|
|
97
99
|
|
|
100
|
+
// scip-typescript indexes whatever its tsconfig includes — which on real projects
|
|
101
|
+
// often covers build output (dist-local/, dist-test/, build/). Those generated
|
|
102
|
+
// bundles must NOT enter the graph (duplicate, minified symbols pollute who-calls).
|
|
103
|
+
// Mirror the indexer's SKIP_DIRS prefix logic at the SCIP-doc level.
|
|
104
|
+
function isBuildOutputPath(relPath) {
|
|
105
|
+
const seg = String(relPath).split('/');
|
|
106
|
+
for (const s of seg) {
|
|
107
|
+
if (s === 'node_modules' || s === '.git' || s === 'coverage') return true;
|
|
108
|
+
for (const pre of ['dist', 'build', 'out']) {
|
|
109
|
+
if (s === pre || (s.length > pre.length && s.startsWith(pre) &&
|
|
110
|
+
(s[pre.length] === '-' || s[pre.length] === '.' || s[pre.length] === '_'))) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
/**
|
|
99
119
|
* Decode a `.scip` file and build resolution maps.
|
|
100
120
|
*
|
|
@@ -128,7 +148,7 @@ function readScipIndex(scipPath) {
|
|
|
128
148
|
// First pass: collect every DEFINITION occurrence → symbol → funcId.
|
|
129
149
|
for (const doc of docs) {
|
|
130
150
|
const relPath = doc.relative_path;
|
|
131
|
-
if (!relPath) continue;
|
|
151
|
+
if (!relPath || isBuildOutputPath(relPath)) continue;
|
|
132
152
|
for (const occ of doc.occurrences || []) {
|
|
133
153
|
const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
|
|
134
154
|
if (!isDef) continue;
|
|
@@ -142,7 +162,7 @@ function readScipIndex(scipPath) {
|
|
|
142
162
|
// Second pass: collect every REFERENCE occurrence per file, resolved to the def.
|
|
143
163
|
for (const doc of docs) {
|
|
144
164
|
const relPath = doc.relative_path;
|
|
145
|
-
if (!relPath) continue;
|
|
165
|
+
if (!relPath || isBuildOutputPath(relPath)) continue;
|
|
146
166
|
const refs = [];
|
|
147
167
|
for (const occ of doc.occurrences || []) {
|
|
148
168
|
const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
|
package/bin/gsd-t.js
CHANGED
|
@@ -453,6 +453,13 @@ const CONTEXT_METER_STALE_PATTERNS = [
|
|
|
453
453
|
/node\s+"?\$CLAUDE_PROJECT_DIR\/scripts\/gsd-t-context-meter\.js"?/,
|
|
454
454
|
];
|
|
455
455
|
|
|
456
|
+
// M97 — graph-intercept PostToolUse hook on Grep. Runs from the global package;
|
|
457
|
+
// the script itself fails-open (no-op) in non-GSD-T projects / projects without a
|
|
458
|
+
// graph, so it is safe to register globally with a Grep matcher.
|
|
459
|
+
const GRAPH_INTERCEPT_HOOK_MARKER = "gsd-t-graph-intercept";
|
|
460
|
+
const GRAPH_INTERCEPT_HOOK_COMMAND =
|
|
461
|
+
'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-intercept.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-intercept.js" || true\'';
|
|
462
|
+
|
|
456
463
|
// Append entries to {projectDir}/.gitignore. Each entry added only if absent.
|
|
457
464
|
// Idempotent. Returns true if any entries were added, false otherwise.
|
|
458
465
|
function ensureGitignoreEntries(projectDir, entries) {
|
|
@@ -789,6 +796,60 @@ function configureContextMeterHooks(settingsPath) {
|
|
|
789
796
|
return { installed: true, action };
|
|
790
797
|
}
|
|
791
798
|
|
|
799
|
+
// M97 — register the graph-intercept PostToolUse hook (matcher "Grep").
|
|
800
|
+
// Idempotent: find-by-marker, refresh stale command, else add. Mirrors the
|
|
801
|
+
// context-meter installer. The script fails-open so this is safe globally.
|
|
802
|
+
function configureGraphInterceptHook(settingsPath) {
|
|
803
|
+
const targetPath = settingsPath || SETTINGS_JSON;
|
|
804
|
+
let settings = {};
|
|
805
|
+
if (fs.existsSync(targetPath)) {
|
|
806
|
+
try {
|
|
807
|
+
settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
808
|
+
if (!settings || typeof settings !== "object") settings = {};
|
|
809
|
+
} catch {
|
|
810
|
+
warn("settings.json has invalid JSON — cannot configure graph-intercept hook");
|
|
811
|
+
return { installed: false, action: "noop" };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (!settings.hooks) settings.hooks = {};
|
|
815
|
+
if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
|
|
816
|
+
|
|
817
|
+
const cmd = GRAPH_INTERCEPT_HOOK_COMMAND;
|
|
818
|
+
let action = "noop";
|
|
819
|
+
let found = false;
|
|
820
|
+
for (const entry of settings.hooks.PostToolUse) {
|
|
821
|
+
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
822
|
+
for (const h of entry.hooks) {
|
|
823
|
+
if (!h || typeof h.command !== "string") continue;
|
|
824
|
+
if (h.command === cmd || h.command.includes(GRAPH_INTERCEPT_HOOK_MARKER)) {
|
|
825
|
+
found = true;
|
|
826
|
+
if (h.command !== cmd) { h.command = cmd; action = "updated"; }
|
|
827
|
+
// ensure the matcher targets Grep
|
|
828
|
+
if (entry.matcher !== "Grep") { entry.matcher = "Grep"; action = action === "noop" ? "updated" : action; }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (!found) {
|
|
833
|
+
settings.hooks.PostToolUse.push({
|
|
834
|
+
matcher: "Grep",
|
|
835
|
+
hooks: [{ type: "command", command: cmd }],
|
|
836
|
+
});
|
|
837
|
+
action = "added";
|
|
838
|
+
}
|
|
839
|
+
if (action === "noop") return { installed: true, action: "noop" };
|
|
840
|
+
if (isSymlink(targetPath)) {
|
|
841
|
+
warn("Skipping settings.json write — target is a symlink");
|
|
842
|
+
return { installed: false, action: "noop" };
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
|
|
846
|
+
} catch (e) {
|
|
847
|
+
warn(`Failed to write settings.json: ${e.message}`);
|
|
848
|
+
return { installed: false, action: "noop" };
|
|
849
|
+
}
|
|
850
|
+
return { installed: true, action };
|
|
851
|
+
}
|
|
852
|
+
|
|
792
853
|
// Remove any context meter PostToolUse hooks from settings.json.
|
|
793
854
|
// Used during uninstall. Leaves all other hooks intact.
|
|
794
855
|
function removeContextMeterHook(settingsPath) {
|
|
@@ -1721,6 +1782,14 @@ async function doInstall(opts = {}) {
|
|
|
1721
1782
|
else info("Context meter hook already configured");
|
|
1722
1783
|
}
|
|
1723
1784
|
|
|
1785
|
+
heading("Graph-Intercept (PostToolUse on Grep — M97)");
|
|
1786
|
+
const giHook = configureGraphInterceptHook(SETTINGS_JSON);
|
|
1787
|
+
if (giHook.installed) {
|
|
1788
|
+
if (giHook.action === "added") success("Graph-intercept hook added (structural greps consult the code graph)");
|
|
1789
|
+
else if (giHook.action === "updated") success("Graph-intercept hook refreshed");
|
|
1790
|
+
else info("Graph-intercept hook already configured");
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1724
1793
|
heading("Graph Engine (CGC)");
|
|
1725
1794
|
installCgc();
|
|
1726
1795
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "4.10
|
|
3
|
+
"version": "4.11.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gsd-t-graph-intercept.js — M97
|
|
4
|
+
*
|
|
5
|
+
* A PostToolUse hook on the `Grep` tool. When Claude runs a grep that is actually
|
|
6
|
+
* a STRUCTURAL question (who-calls / who-imports / a bare symbol), this answers it
|
|
7
|
+
* from the precomputed code graph and REPLACES the grep output the model sees
|
|
8
|
+
* (via `updatedToolOutput`). Text searches pass through untouched.
|
|
9
|
+
*
|
|
10
|
+
* Receives JSON on stdin (PostToolUse):
|
|
11
|
+
* { tool_name, tool_input: { pattern, path? }, tool_response/tool_output, cwd, ... }
|
|
12
|
+
*
|
|
13
|
+
* Emits JSON on stdout:
|
|
14
|
+
* { hookSpecificOutput: { hookEventName: "PostToolUse",
|
|
15
|
+
* updatedToolOutput: "<graph answer + original grep beneath>" } }
|
|
16
|
+
* ...or nothing (exit 0) to pass the grep through unchanged.
|
|
17
|
+
*
|
|
18
|
+
* INVARIANTS:
|
|
19
|
+
* - FAIL-OPEN: any error / missing graph / non-structural → pass through (emit nothing).
|
|
20
|
+
* - NEVER calls Grep/Read (loop guard). Only spawns the graph query CLI.
|
|
21
|
+
* - No graph in this project → pure no-op.
|
|
22
|
+
* - Original grep hits are RETAINED beneath the graph answer (no silent hiding).
|
|
23
|
+
*
|
|
24
|
+
* [RULE] graph-intercept-fail-open-never-breaks-grep
|
|
25
|
+
* [RULE] graph-intercept-structural-only-text-passes-through
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
const { spawnSync } = require('node:child_process');
|
|
33
|
+
|
|
34
|
+
const OUTPUT_CAP = 9000; // stay under the 10K hook output cap, leave headroom
|
|
35
|
+
|
|
36
|
+
function passThrough() {
|
|
37
|
+
// Emit nothing → the original grep output reaches the model unchanged.
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emitReplacement(text) {
|
|
42
|
+
const out = {
|
|
43
|
+
hookSpecificOutput: {
|
|
44
|
+
hookEventName: 'PostToolUse',
|
|
45
|
+
updatedToolOutput: text.length > OUTPUT_CAP
|
|
46
|
+
? text.slice(0, OUTPUT_CAP) + '\n…(truncated — query the graph CLI directly for the full list)'
|
|
47
|
+
: text,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
process.stdout.write(JSON.stringify(out));
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Resolve the project-local graph query CLI. Returns null if absent.
|
|
55
|
+
function resolveQueryCli(cwd) {
|
|
56
|
+
const local = path.join(cwd, 'bin', 'gsd-t-graph-query-cli.cjs');
|
|
57
|
+
if (fs.existsSync(local)) return local;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function main(payload) {
|
|
62
|
+
// Only act on Grep.
|
|
63
|
+
if (!payload || payload.tool_name !== 'Grep') passThrough();
|
|
64
|
+
|
|
65
|
+
const cwd = payload.cwd || process.cwd();
|
|
66
|
+
|
|
67
|
+
// Must be a GSD-T project with a graph present.
|
|
68
|
+
if (!fs.existsSync(path.join(cwd, '.gsd-t'))) passThrough();
|
|
69
|
+
if (!fs.existsSync(path.join(cwd, '.gsd-t', 'graph.db'))) passThrough();
|
|
70
|
+
|
|
71
|
+
const pattern = payload.tool_input && payload.tool_input.pattern;
|
|
72
|
+
if (typeof pattern !== 'string' || !pattern) passThrough();
|
|
73
|
+
|
|
74
|
+
// Classify (the classifier itself fails safe → text).
|
|
75
|
+
let cls;
|
|
76
|
+
try {
|
|
77
|
+
const { classifyGrep } = require(path.join(__dirname, '..', 'bin', 'gsd-t-grep-classifier.cjs'));
|
|
78
|
+
cls = classifyGrep(pattern);
|
|
79
|
+
} catch { passThrough(); }
|
|
80
|
+
if (!cls || !cls.structural) passThrough();
|
|
81
|
+
|
|
82
|
+
const cliPath = resolveQueryCli(cwd);
|
|
83
|
+
if (!cliPath) passThrough();
|
|
84
|
+
|
|
85
|
+
// Query the graph. who-calls for symbols/calls; who-imports for imports.
|
|
86
|
+
// For a bare symbol we ALSO try who-imports so the model sees both usages.
|
|
87
|
+
const verbs = cls.verb === 'who-imports'
|
|
88
|
+
? ['who-imports']
|
|
89
|
+
: ['who-calls', 'who-imports'];
|
|
90
|
+
|
|
91
|
+
const sections = [];
|
|
92
|
+
for (const verb of verbs) {
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = spawnSync(process.execPath, [cliPath, verb, cls.symbol], {
|
|
96
|
+
cwd, encoding: 'utf8', timeout: 8000,
|
|
97
|
+
});
|
|
98
|
+
} catch { continue; }
|
|
99
|
+
// Parse the envelope regardless of exit status — an ambiguous-symbol result
|
|
100
|
+
// exits non-zero with ok:false but still carries a valid structural answer
|
|
101
|
+
// (the candidate list). Only a missing/garbled stdout is unusable.
|
|
102
|
+
if (!res || !res.stdout) continue;
|
|
103
|
+
let env;
|
|
104
|
+
try { env = JSON.parse(res.stdout.trim().split('\n').pop()); } catch { continue; }
|
|
105
|
+
if (!env) continue;
|
|
106
|
+
const results = env.results || [];
|
|
107
|
+
if (results.length) {
|
|
108
|
+
const tier = env.tier ? ` [tier: ${env.tier}]` : '';
|
|
109
|
+
const shown = results.slice(0, 60);
|
|
110
|
+
const more = results.length > 60 ? ` (+${results.length - 60} more)` : '';
|
|
111
|
+
sections.push(`${verb}(${cls.symbol})${tier}: ${shown.join(', ')}${more}`);
|
|
112
|
+
} else if ((env.candidates || []).length) {
|
|
113
|
+
// ambiguous symbol (ok:false, reason:'ambiguous-function') — the candidate
|
|
114
|
+
// list IS the structural answer: the symbol exists in N places.
|
|
115
|
+
const cands = env.candidates.slice(0, 20);
|
|
116
|
+
const more = env.candidates.length > 20 ? ` (+${env.candidates.length - 20} more)` : '';
|
|
117
|
+
sections.push(`${verb}(${cls.symbol}) → defined in ${env.candidates.length} place(s): ${cands.join(', ')}${more}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// No graph answer → pass the grep through (don't replace with nothing).
|
|
122
|
+
if (!sections.length) passThrough();
|
|
123
|
+
|
|
124
|
+
// Build the replacement: graph answer first (labeled), original grep beneath.
|
|
125
|
+
const original = payload.tool_response || payload.tool_output || '';
|
|
126
|
+
const replacement =
|
|
127
|
+
`▸ Structural answer from the GSD-T code graph (precomputed; faster + more accurate than text grep for "where is this used"):\n` +
|
|
128
|
+
sections.map((s) => ` • ${s}`).join('\n') +
|
|
129
|
+
`\n\n─── original grep output (kept for reference) ───\n` +
|
|
130
|
+
(typeof original === 'string' ? original : JSON.stringify(original));
|
|
131
|
+
|
|
132
|
+
emitReplacement(replacement);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── stdin → main, fail-open everywhere ────────────────────────────────────────
|
|
136
|
+
let input = '';
|
|
137
|
+
process.stdin.setEncoding('utf8');
|
|
138
|
+
process.stdin.on('data', (c) => { input += c; });
|
|
139
|
+
process.stdin.on('end', () => {
|
|
140
|
+
let payload;
|
|
141
|
+
try { payload = JSON.parse(input); } catch { passThrough(); }
|
|
142
|
+
try { main(payload); } catch { passThrough(); }
|
|
143
|
+
});
|
|
144
|
+
process.stdin.on('error', () => passThrough());
|