@tekyzinc/gsd-t 4.10.12 → 4.11.11
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 +28 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-freshness.cjs +16 -2
- package/bin/gsd-t-graph-index.cjs +24 -10
- package/bin/gsd-t-graph-query-cli.cjs +10 -4
- package/bin/gsd-t-graph-scip-upgrade.cjs +5 -2
- 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,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [4.11.11] - 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Fixed — SCIP resolution missed nested/monorepo tsconfig (call-graph empty on subdir-tsconfig projects)
|
|
8
|
+
|
|
9
|
+
`runScipTypescript` ran scip-typescript without `--infer-tsconfig`, so projects whose `tsconfig.json` lives in a subdirectory (`web/tsconfig.json`, monorepo packages) produced 0 resolved call edges — scip-typescript found no root tsconfig and skipped resolution.
|
|
10
|
+
|
|
11
|
+
- `bin/gsd-t-graph-scip-upgrade.cjs`: add `--infer-tsconfig` so scip-typescript discovers a nested tsconfig; bumped the timeout to 180s for larger inferred projects.
|
|
12
|
+
|
|
13
|
+
Proven on ClaudeWebCLI (tsconfig at `web/tsconfig.json`): 0 → 1,149 resolved call edges. Suite: 2519/2519 pass.
|
|
14
|
+
|
|
15
|
+
## [4.11.10] - 2026-06-27
|
|
16
|
+
|
|
17
|
+
### Added — M97: the code graph is the default for ambient code-reading (grep-intercept), + 3 call-resolution fixes
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
- `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).
|
|
22
|
+
- `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.
|
|
23
|
+
- `bin/gsd-t.js`: `gsd-t install` registers the hook (matcher `Grep`), idempotent.
|
|
24
|
+
|
|
25
|
+
**Fixed — the call-graph was EMPTY on real projects (3 bugs):**
|
|
26
|
+
- `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.
|
|
27
|
+
- `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.
|
|
28
|
+
- `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.
|
|
29
|
+
- `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.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
5
33
|
## [4.10.12] - 2026-06-27
|
|
6
34
|
|
|
7
35
|
### Fixed — freshness hash mismatch made every graph query re-index the whole repo
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v4.
|
|
3
|
+
**v4.11.11** - 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.
|
|
@@ -76,6 +76,20 @@ const EXCLUDE_DIRS = new Set([
|
|
|
76
76
|
'.idea', '.vscode', 'tmp', '.tmp', 'site-packages',
|
|
77
77
|
]);
|
|
78
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
|
+
|
|
79
93
|
function walkTree(dir, results = []) {
|
|
80
94
|
// Defense-in-depth: refuse to walk the filesystem root or the user's home dir.
|
|
81
95
|
// A bogus projectRoot (e.g. "/") from a fake store path would otherwise recurse
|
|
@@ -90,9 +104,9 @@ function walkTree(dir, results = []) {
|
|
|
90
104
|
catch { return results; }
|
|
91
105
|
for (const e of entries) {
|
|
92
106
|
if (e.name.startsWith('.') && e.name !== '.claude') {
|
|
93
|
-
if (
|
|
107
|
+
if (shouldExcludeDir(e.name)) continue;
|
|
94
108
|
}
|
|
95
|
-
if (
|
|
109
|
+
if (shouldExcludeDir(e.name)) continue;
|
|
96
110
|
const full = path.join(dir, e.name);
|
|
97
111
|
if (e.isDirectory()) {
|
|
98
112
|
walkTree(full, results);
|
|
@@ -77,6 +77,23 @@ const SKIP_DIRS = new Set([
|
|
|
77
77
|
'.idea', '.vscode', 'tmp', '.tmp', 'site-packages',
|
|
78
78
|
]);
|
|
79
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
|
+
|
|
80
97
|
// ── Content hash ──────────────────────────────────────────────────────────────
|
|
81
98
|
|
|
82
99
|
/**
|
|
@@ -97,7 +114,7 @@ function enumerateFiles(root) {
|
|
|
97
114
|
catch { return; }
|
|
98
115
|
for (const e of entries) {
|
|
99
116
|
if (e.isDirectory()) {
|
|
100
|
-
if (!
|
|
117
|
+
if (!shouldSkipDir(e.name)) walk(path.join(dir, e.name));
|
|
101
118
|
} else if (e.isFile()) {
|
|
102
119
|
const ext = path.extname(e.name).toLowerCase();
|
|
103
120
|
if (PARSED_EXTS.has(ext)) {
|
|
@@ -492,17 +509,14 @@ if (require.main === module) {
|
|
|
492
509
|
info(`build_index — repo: ${repoRoot}`);
|
|
493
510
|
info(`Store: ${dbPath}`);
|
|
494
511
|
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
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
|
|
503
518
|
const result = build_index(repoRoot, {
|
|
504
519
|
dbPath,
|
|
505
|
-
scip,
|
|
506
520
|
onProgress: ({ file, tier, fileCount, total }) => {
|
|
507
521
|
if (fileCount % 100 === 0) {
|
|
508
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
|
}
|
|
@@ -134,10 +134,13 @@ function runScipTypescript(projectRoot, outPath) {
|
|
|
134
134
|
// M95: emit to a REAL file (outPath) so the index can be READ and call edges
|
|
135
135
|
// resolved — not /dev/null. The old /dev/null path only proved invocability.
|
|
136
136
|
const out = outPath || path.join(projectRoot, '.gsd-t', 'index.scip');
|
|
137
|
-
|
|
137
|
+
// --infer-tsconfig: resolve a tsconfig even when one isn't at the repo root
|
|
138
|
+
// (monorepos / nested layouts — e.g. web/tsconfig.json). Without it, projects
|
|
139
|
+
// whose tsconfig lives in a subdir got 0 resolved call edges. [RULE] scip-infer-nested-tsconfig
|
|
140
|
+
const scip = spawnSync('scip-typescript', ['index', '--infer-tsconfig', '--output', out, '.'], {
|
|
138
141
|
cwd: projectRoot,
|
|
139
142
|
encoding: 'utf8',
|
|
140
|
-
timeout:
|
|
143
|
+
timeout: 180_000,
|
|
141
144
|
});
|
|
142
145
|
if (scip.status === 0) return { ok: true, scipPath: out };
|
|
143
146
|
return { ok: false, error: scip.stderr || `exit code ${scip.status}` };
|
|
@@ -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.
|
|
3
|
+
"version": "4.11.11",
|
|
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());
|