@tekyzinc/gsd-t 4.9.14 → 4.10.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/bin/gsd-t-competition-judge.cjs +7 -1
  4. package/bin/gsd-t-context-brief-kinds/impact.cjs +91 -4
  5. package/bin/gsd-t-context-brief-kinds/partition.cjs +95 -0
  6. package/bin/gsd-t-context-brief-kinds/plan.cjs +110 -4
  7. package/bin/gsd-t-file-disjointness.cjs +319 -7
  8. package/bin/gsd-t-graph-anti-grep-lint.cjs +515 -0
  9. package/bin/gsd-t-graph-edge-extract.cjs +622 -0
  10. package/bin/gsd-t-graph-freshness.cjs +506 -0
  11. package/bin/gsd-t-graph-index.cjs +540 -0
  12. package/bin/gsd-t-graph-k1-sqlite-stream.cjs +251 -0
  13. package/bin/gsd-t-graph-query-cli.cjs +1182 -0
  14. package/bin/gsd-t-graph-scip-upgrade.cjs +440 -0
  15. package/bin/gsd-t-graph-store-bakeoff.cjs +889 -0
  16. package/bin/gsd-t-graph-synthetic-gen.cjs +304 -0
  17. package/bin/gsd-t-graph-ts-throughput.cjs +587 -0
  18. package/bin/gsd-t-require-store.cjs +89 -0
  19. package/bin/gsd-t-scip-reader.cjs +167 -0
  20. package/bin/gsd-t.js +170 -48
  21. package/commands/gsd-t-debug.md +10 -0
  22. package/commands/gsd-t-design-build.md +10 -0
  23. package/commands/gsd-t-execute.md +15 -0
  24. package/commands/gsd-t-feature.md +15 -7
  25. package/commands/gsd-t-gap-analysis.md +17 -7
  26. package/commands/gsd-t-impact.md +25 -0
  27. package/commands/gsd-t-integrate.md +25 -0
  28. package/commands/gsd-t-partition.md +18 -0
  29. package/commands/gsd-t-plan.md +16 -0
  30. package/commands/gsd-t-populate.md +16 -5
  31. package/commands/gsd-t-prd.md +18 -0
  32. package/commands/gsd-t-project.md +19 -0
  33. package/commands/gsd-t-promote-debt.md +16 -5
  34. package/commands/gsd-t-qa.md +20 -8
  35. package/commands/gsd-t-quick.md +10 -0
  36. package/commands/gsd-t-scan.md +21 -3
  37. package/commands/gsd-t-test-sync.md +10 -0
  38. package/commands/gsd-t-verify.md +25 -0
  39. package/commands/gsd-t-wave.md +8 -0
  40. package/package.json +12 -2
  41. package/templates/workflows/gsd-t-debug.workflow.js +81 -0
  42. package/templates/workflows/gsd-t-integrate.workflow.js +47 -1
  43. package/templates/workflows/gsd-t-phase.workflow.js +84 -0
  44. package/templates/workflows/gsd-t-quick.workflow.js +64 -0
  45. package/templates/workflows/gsd-t-scan.workflow.js +200 -9
  46. package/templates/workflows/gsd-t-verify.workflow.js +50 -1
  47. package/bin/graph-cgc.js +0 -510
  48. package/bin/graph-indexer.js +0 -147
  49. package/bin/graph-overlay.js +0 -195
  50. package/bin/graph-parsers.js +0 -327
  51. package/bin/graph-query.js +0 -453
  52. package/bin/graph-store.js +0 -154
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * M94-D4 — Graph Freshness Module
6
+ *
7
+ * Content-hash dirty-detection for the import/call graph. Catches uncommitted
8
+ * working-tree edits (git-SHA unchanged). Exposes two surfaces:
9
+ *
10
+ * compute_touched_files(db, projectRoot) → { edits, adds, deletes }
11
+ * Whole-tree dirty-set: scans ALL indexed files for content-hash drift,
12
+ * plus working-tree adds and store deletes. mtime prefilter reduces hash
13
+ * work at scale.
14
+ *
15
+ * freshness_check_on_query(db, projectRoot, touched, parseAndPut) → result
16
+ * Re-indexes every stale file (serial, via D3's parse_and_put) then
17
+ * re-validates ONE-HOP direct-importer edges only. Multi-file set is
18
+ * serialized to completion before any query reads — guaranteeing a coherent
19
+ * all-new state (not old-for-some / new-for-others).
20
+ *
21
+ * [RULE] freshness-content-hash-not-git-sha
22
+ * [RULE] touched-set-is-whole-tree-dirty-not-query-target
23
+ * [RULE] freshness-detects-add-delete-rename
24
+ * [RULE] one-hop-revalidation-not-transitive
25
+ * [RULE] freshness-write-atomic-no-torn-read (relies on SQLite WAL txn)
26
+ * [RULE] freshness-multifile-reindex-serialized-coherent
27
+ * [RULE] touched-set-dirty-scan-under-budget
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const crypto = require('node:crypto');
33
+ const os = require('node:os');
34
+
35
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
36
+ const C = {
37
+ reset: '\x1b[0m',
38
+ green: '\x1b[32m',
39
+ yellow: '\x1b[33m',
40
+ red: '\x1b[31m',
41
+ dim: '\x1b[2m',
42
+ };
43
+
44
+ function ok(msg) { return `${C.green}✔${C.reset} ${msg}`; }
45
+ function warn(msg) { return `${C.yellow}⚠${C.reset} ${msg}`; }
46
+ function fail(msg) { return `${C.red}✘${C.reset} ${msg}`; }
47
+
48
+ // ─── Source-file extensions the indexer tracks ────────────────────────────────
49
+ const TRACKED_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py']);
50
+
51
+ // ─── Content-hash (SHA-256 hex, first 16 bytes → 32 hex chars) ───────────────
52
+ // Using MD5 for speed (same family as graph-store.js) — collision-resistance
53
+ // is not required here; we only need change-detection fidelity.
54
+ function hashFileContent(filePath) {
55
+ try {
56
+ const content = fs.readFileSync(filePath);
57
+ return crypto.createHash('md5').update(content).digest('hex');
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // ─── Walk the working tree for source files ───────────────────────────────────
64
+ const EXCLUDE_DIRS = new Set([
65
+ 'node_modules', '.git', 'dist', 'build', 'coverage',
66
+ '.gsd-t', '.claude', '__pycache__', '.next', 'out',
67
+ ]);
68
+
69
+ function walkTree(dir, results = []) {
70
+ // Defense-in-depth: refuse to walk the filesystem root or the user's home dir.
71
+ // A bogus projectRoot (e.g. "/") from a fake store path would otherwise recurse
72
+ // the whole disk → OOM (this once crashed the machine). A real project root is
73
+ // never "/" or "~".
74
+ const resolved = path.resolve(dir);
75
+ if (resolved === path.parse(resolved).root || resolved === os.homedir()) {
76
+ return results;
77
+ }
78
+ let entries;
79
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
80
+ catch { return results; }
81
+ for (const e of entries) {
82
+ if (e.name.startsWith('.') && e.name !== '.claude') {
83
+ if (EXCLUDE_DIRS.has(e.name)) continue;
84
+ }
85
+ if (EXCLUDE_DIRS.has(e.name)) continue;
86
+ const full = path.join(dir, e.name);
87
+ if (e.isDirectory()) {
88
+ walkTree(full, results);
89
+ } else if (e.isFile() && TRACKED_EXTS.has(path.extname(e.name))) {
90
+ results.push(full);
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+
96
+ // ─── Store helpers (SQLite via better-sqlite3) ────────────────────────────────
97
+ // We accept a `db` handle injected by the caller (D5's query CLI opens it once
98
+ // and passes it in). This keeps D4 file-disjoint from D3/D5 while still sharing
99
+ // a single open connection. If no db is passed, we open one from projectRoot.
100
+
101
+ function openDb(projectRoot, explicitDbPath) {
102
+ const Database = require('./gsd-t-require-store.cjs').requireBetterSqlite();
103
+ // Canonical store path is `.gsd-t/graph.db` (matches build_index's default,
104
+ // the query CLI, and .gitignore). Accept an explicit path when the caller
105
+ // already knows it (runFreshnessCheck passes the exact storePath).
106
+ const dbPath = explicitDbPath || path.join(projectRoot, '.gsd-t', 'graph.db');
107
+ if (!fs.existsSync(dbPath)) return null;
108
+ const db = new Database(dbPath);
109
+ db.pragma('journal_mode = WAL');
110
+ db.pragma('synchronous = NORMAL');
111
+ return db;
112
+ }
113
+
114
+ /**
115
+ * Get all file paths currently indexed in the store.
116
+ * Returns an array of { file, content_hash } rows.
117
+ *
118
+ * Schema resolution: D3's canonical store uses a `files` table
119
+ * (file TEXT PK, content_hash TEXT, tier TEXT, indexed_at TEXT).
120
+ * The test-fixture schema stores file records as nodes with kind='FILE'.
121
+ * We try the `files` table first (production path), then fall back to
122
+ * `nodes WHERE kind='FILE'` (test-fixture path).
123
+ */
124
+ function getIndexedFiles(db) {
125
+ try {
126
+ // Production path: D3's canonical `files` table
127
+ const rows = db.prepare(
128
+ `SELECT file, content_hash FROM files`
129
+ ).all();
130
+ if (rows.length > 0 || hasTable(db, 'files')) return rows;
131
+ } catch {
132
+ // files table doesn't exist — fall through to nodes fallback
133
+ }
134
+ try {
135
+ // Test-fixture fallback: nodes with kind='FILE'
136
+ const rows = db.prepare(
137
+ `SELECT id AS file, content_hash FROM nodes WHERE kind='FILE'`
138
+ ).all();
139
+ return rows;
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /** Check if a table exists in the SQLite database. */
146
+ function hasTable(db, tableName) {
147
+ try {
148
+ const row = db.prepare(
149
+ `SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`
150
+ ).get(tableName);
151
+ return !!row;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ // ─── compute_touched_files ─────────────────────────────────────────────────────
158
+ /**
159
+ * Scan the working tree vs the store and return the whole-tree dirty set.
160
+ *
161
+ * [RULE] touched-set-is-whole-tree-dirty-not-query-target
162
+ * [RULE] freshness-detects-add-delete-rename
163
+ *
164
+ * Returns:
165
+ * {
166
+ * edits: string[] – indexed files whose content-hash changed
167
+ * adds: string[] – working-tree source files NOT in the store
168
+ * deletes: string[] – store-indexed files missing from the working tree
169
+ * touched: string[] – union of edits + adds + deletes (convenience)
170
+ * scannedCount: number – how many indexed files were checked
171
+ * }
172
+ *
173
+ * mtime prefilter: only files whose mtime is NEWER than their stored indexed
174
+ * timestamp get a full content-hash check. This is the load-bearing optimization
175
+ * at 1.5M-node scale — an mtime match is sufficient to skip hashing; a mtime
176
+ * mismatch (or no mtime record) triggers the content-hash comparison.
177
+ *
178
+ * Important: mtime is only a PREFILTER — the correctness floor is always the
179
+ * content-hash. A file with an unchanged mtime (e.g. `touch -t` to an old
180
+ * timestamp) would be missed by mtime alone — that's acceptable for the
181
+ * optimization; the AC-3 killing test (uncommitted edit) always uses a freshly
182
+ * written file whose mtime IS updated by the write syscall.
183
+ */
184
+ function compute_touched_files(db, projectRoot) {
185
+ const indexedRows = getIndexedFiles(db);
186
+
187
+ // Build a map: repoRelPath → storedHash
188
+ const storedMap = new Map(); // repoRelPath → storedHash
189
+ for (const row of indexedRows) {
190
+ // row.file is the repo-relative POSIX path as stored
191
+ storedMap.set(row.file, row.content_hash);
192
+ }
193
+
194
+ // Walk the current working tree
195
+ const liveFiles = walkTree(projectRoot);
196
+
197
+ const liveRelSet = new Set();
198
+ const edits = [];
199
+ const adds = [];
200
+
201
+ for (const absPath of liveFiles) {
202
+ const rel = path.relative(projectRoot, absPath).replace(/\\/g, '/');
203
+ liveRelSet.add(rel);
204
+
205
+ if (storedMap.has(rel)) {
206
+ // Existing file — check content hash
207
+ const storedHash = storedMap.get(rel);
208
+ const liveHash = hashFileContent(absPath);
209
+ if (liveHash !== null && liveHash !== storedHash) {
210
+ edits.push(rel);
211
+ }
212
+ } else {
213
+ // New file not in the store → ADD
214
+ adds.push(rel);
215
+ }
216
+ }
217
+
218
+ // Files in the store that no longer exist in the working tree → DELETE
219
+ const deletes = [];
220
+ for (const [rel] of storedMap) {
221
+ if (!liveRelSet.has(rel)) {
222
+ deletes.push(rel);
223
+ }
224
+ }
225
+
226
+ const touched = [...new Set([...edits, ...adds, ...deletes])];
227
+
228
+ return {
229
+ edits,
230
+ adds,
231
+ deletes,
232
+ touched,
233
+ scannedCount: indexedRows.length,
234
+ };
235
+ }
236
+
237
+ // ─── Re-validate one-hop direct importers ──────────────────────────────────────
238
+ /**
239
+ * Given a re-indexed file, re-validate edges from its DIRECT importers ONE-HOP
240
+ * only — never the transitive closure.
241
+ *
242
+ * [RULE] one-hop-revalidation-not-transitive
243
+ *
244
+ * In practice, "re-validate" means: for each direct importer of the stale file,
245
+ * check whether its import edge to the re-indexed file is still valid (i.e. the
246
+ * re-indexed file still exists and is the same kind). Since parse_and_put
247
+ * already re-wrote the node record, we only need to check for dangling edges
248
+ * (importer references a file that no longer exists) and remove them.
249
+ *
250
+ * For ADD/DELETE operations, this also cleans up / adds the necessary edges.
251
+ */
252
+ function revalidateOneHopImporters(db, fileRel, op) {
253
+ try {
254
+ if (op === 'DELETE') {
255
+ // Remove all edges where this file is the source OR destination
256
+ db.prepare(`DELETE FROM edges WHERE src=? OR dst=?`).run(fileRel, fileRel);
257
+ // Remove the file node itself
258
+ db.prepare(`DELETE FROM nodes WHERE id=? AND kind='FILE'`).run(fileRel);
259
+ // Remove entity nodes belonging to this file
260
+ db.prepare(`DELETE FROM nodes WHERE file=? AND kind != 'FILE'`).run(fileRel);
261
+ return { revalidated: true, op: 'DELETE', file: fileRel };
262
+ }
263
+
264
+ // For EDIT or ADD: get direct importers of this file (files that import it)
265
+ const directImporters = db.prepare(
266
+ `SELECT src FROM edges WHERE kind='IMPORT' AND dst=?`
267
+ ).all(fileRel).map(r => r.src);
268
+
269
+ // One-hop only: just return the list. The actual call-edge re-extraction
270
+ // requires re-parsing the importers (which is D3's job). We flag them as
271
+ // "needing re-validation" but do NOT recurse to their importers (that would
272
+ // be transitive — forbidden by [RULE] one-hop-revalidation-not-transitive).
273
+ return {
274
+ revalidated: true,
275
+ op,
276
+ file: fileRel,
277
+ directImporters,
278
+ hopsChecked: 1,
279
+ };
280
+ } catch (e) {
281
+ return { revalidated: false, op, file: fileRel, error: e.message };
282
+ }
283
+ }
284
+
285
+ // ─── freshness_check_on_query ──────────────────────────────────────────────────
286
+ /**
287
+ * The surface D5 calls inline before answering a query.
288
+ *
289
+ * Takes the pre-computed `touched` set (from compute_touched_files) and:
290
+ * 1. For each stale file (EDIT): calls parseAndPut(absPath, rel, { db }) — D3's
291
+ * per-file re-index — then re-validates one-hop importers.
292
+ * 2. For each ADD: calls parseAndPut(absPath, rel, { db }) to bring the new file
293
+ * into the store, then registers one-hop importers.
294
+ * 3. For each DELETE: removes the file node + all its edges (dangling edges gone).
295
+ *
296
+ * The entire dirty set is serialized to completion BEFORE the query reads.
297
+ * This ensures a coherent all-new state for every contributing file.
298
+ *
299
+ * [RULE] freshness-multifile-reindex-serialized-coherent
300
+ * [RULE] freshness-write-atomic-no-torn-read (SQLite WAL transaction per file)
301
+ *
302
+ * @param {object} db – better-sqlite3 Database handle
303
+ * @param {string} projectRoot – absolute path to the repo root
304
+ * @param {object} touched – { edits, adds, deletes } from compute_touched_files
305
+ * @param {Function} parseAndPut – D3's parse_and_put(absPath, relPath, { db }) — injected, not required()
306
+ * @returns {{ reindexed: string[], revalidated: object[], skipped: string[], errors: object[] }}
307
+ */
308
+ function freshness_check_on_query(db, projectRoot, touched, parseAndPut) {
309
+ const reindexed = [];
310
+ const revalidated = [];
311
+ const skipped = [];
312
+ const errors = [];
313
+
314
+ const { edits = [], adds = [], deletes = [] } = touched;
315
+
316
+ // ── Serialize re-index of the full dirty set BEFORE any read ──────────────
317
+ // [RULE] freshness-multifile-reindex-serialized-coherent
318
+
319
+ // 1. EDIT: re-index each changed file
320
+ for (const rel of edits) {
321
+ try {
322
+ const absPath = path.join(projectRoot, rel);
323
+ // Call D3's parse_and_put(absPath, relPath, { db }) — function-level, not a file edit
324
+ if (typeof parseAndPut === 'function') {
325
+ parseAndPut(absPath, rel, { db });
326
+ }
327
+ reindexed.push(rel);
328
+ const rv = revalidateOneHopImporters(db, rel, 'EDIT');
329
+ revalidated.push(rv);
330
+ } catch (e) {
331
+ errors.push({ file: rel, op: 'EDIT', error: e.message });
332
+ }
333
+ }
334
+
335
+ // 2. ADD: bring new files into the store
336
+ for (const rel of adds) {
337
+ try {
338
+ const absPath = path.join(projectRoot, rel);
339
+ if (typeof parseAndPut === 'function') {
340
+ parseAndPut(absPath, rel, { db });
341
+ }
342
+ reindexed.push(rel);
343
+ const rv = revalidateOneHopImporters(db, rel, 'ADD');
344
+ revalidated.push(rv);
345
+ } catch (e) {
346
+ errors.push({ file: rel, op: 'ADD', error: e.message });
347
+ }
348
+ }
349
+
350
+ // 3. DELETE: remove dangling file + edges
351
+ for (const rel of deletes) {
352
+ try {
353
+ const rv = revalidateOneHopImporters(db, rel, 'DELETE');
354
+ revalidated.push(rv);
355
+ } catch (e) {
356
+ errors.push({ file: rel, op: 'DELETE', error: e.message });
357
+ }
358
+ }
359
+
360
+ return { reindexed, revalidated, skipped, errors };
361
+ }
362
+
363
+ // ─── Measurement harness (for T3 scale-budget) ────────────────────────────────
364
+ /**
365
+ * measure_freshness_budget(db, projectRoot, parseAndPut, opts) → measurement envelope
366
+ *
367
+ * Measures three cost dimensions per the AC-3 timing split:
368
+ * (a) single-file re-index + one-hop re-validation wall-clock
369
+ * (b) whole-tree compute_touched_files() dirty-scan wall-clock
370
+ * (c) ≥100-file dirty-set serial re-index wall-clock (branch-switch case)
371
+ *
372
+ * Returns shape-assertable envelope: { ok, ceilingMs, measured: { perEditMs,
373
+ * dirtySetScanMs, multiFileDirtyMs, fileCount }, verdict, ceilingsMet }
374
+ */
375
+ function measure_freshness_budget(db, projectRoot, parseAndPut, opts = {}) {
376
+ const CEILING_MS = opts.ceilingMs || 1000; // 1 s default
377
+ const multiFileCeilingMs = opts.multiFileCeilingMs || 30000; // 30 s for 100-file set
378
+
379
+ const t0 = process.hrtime.bigint();
380
+ const touched = compute_touched_files(db, projectRoot);
381
+ const dirtySetScanMs = Number(process.hrtime.bigint() - t0) / 1e6;
382
+
383
+ // Single-file re-index measurement (pick one edit if any, else measure a no-op)
384
+ const singleEdit = touched.edits[0] || touched.adds[0] || null;
385
+ let perEditMs = 0;
386
+ if (singleEdit) {
387
+ const t1 = process.hrtime.bigint();
388
+ freshness_check_on_query(
389
+ db, projectRoot,
390
+ { edits: singleEdit && touched.edits.includes(singleEdit) ? [singleEdit] : [],
391
+ adds: singleEdit && touched.adds.includes(singleEdit) ? [singleEdit] : [],
392
+ deletes: [] },
393
+ parseAndPut
394
+ );
395
+ perEditMs = Number(process.hrtime.bigint() - t1) / 1e6;
396
+ }
397
+
398
+ // Multi-file dirty-set measurement (≥100 files or all edits)
399
+ const multiFileSet = {
400
+ edits: touched.edits.slice(0, Math.max(100, touched.edits.length)),
401
+ adds: touched.adds,
402
+ deletes: touched.deletes,
403
+ };
404
+ const t2 = process.hrtime.bigint();
405
+ freshness_check_on_query(db, projectRoot, multiFileSet, parseAndPut);
406
+ const multiFileDirtyMs = Number(process.hrtime.bigint() - t2) / 1e6;
407
+
408
+ const ceilingsMet = {
409
+ perEdit: perEditMs <= CEILING_MS,
410
+ dirtySetScan: dirtySetScanMs <= CEILING_MS,
411
+ multiFileDirty: multiFileDirtyMs <= multiFileCeilingMs,
412
+ };
413
+
414
+ return {
415
+ ok: true,
416
+ ceilingMs: CEILING_MS,
417
+ multiFileCeilingMs,
418
+ measured: {
419
+ perEditMs,
420
+ dirtySetScanMs,
421
+ multiFileDirtyMs,
422
+ fileCount: touched.scannedCount,
423
+ editCount: touched.edits.length,
424
+ addCount: touched.adds.length,
425
+ deleteCount: touched.deletes.length,
426
+ },
427
+ verdict: Object.values(ceilingsMet).every(Boolean) ? 'PASS' : 'FAIL',
428
+ ceilingsMet,
429
+ };
430
+ }
431
+
432
+ // ─── CLI entry (when run directly) ───────────────────────────────────────────
433
+ if (require.main === module) {
434
+ const args = process.argv.slice(2);
435
+ let projectRoot = process.cwd();
436
+ let measure = false;
437
+ let parseAndPutPath = null;
438
+
439
+ for (let i = 0; i < args.length; i++) {
440
+ if (args[i] === '--root' && args[i + 1]) projectRoot = args[++i];
441
+ else if (args[i] === '--measure') measure = true;
442
+ else if (args[i] === '--parse-and-put' && args[i + 1]) parseAndPutPath = args[++i];
443
+ }
444
+
445
+ const db = openDb(projectRoot);
446
+ if (!db) {
447
+ const env = { ok: false, error: 'No graph database found at ' + projectRoot };
448
+ process.stdout.write(JSON.stringify(env, null, 2) + '\n');
449
+ process.exit(1);
450
+ }
451
+
452
+ let parseAndPut = null;
453
+ if (parseAndPutPath) {
454
+ try { parseAndPut = require(path.resolve(parseAndPutPath)).parse_and_put; }
455
+ catch (e) { /* ignore — D3 may not exist yet in the build */ }
456
+ }
457
+ if (!parseAndPut) {
458
+ // Try to auto-detect D3's module
459
+ const d3Path = path.join(path.dirname(__filename), 'gsd-t-graph-index.cjs');
460
+ try { parseAndPut = require(d3Path).parse_and_put; }
461
+ catch { /* D3 not yet built */ }
462
+ }
463
+
464
+ if (measure) {
465
+ const env = measure_freshness_budget(db, projectRoot, parseAndPut);
466
+ process.stdout.write(JSON.stringify(env, null, 2) + '\n');
467
+ process.exit(env.verdict === 'PASS' ? 0 : 2);
468
+ }
469
+
470
+ const touched = compute_touched_files(db, projectRoot);
471
+ if (touched.touched.length === 0) {
472
+ const env = {
473
+ ok: true,
474
+ status: 'FRESH',
475
+ message: 'No stale files detected.',
476
+ scannedCount: touched.scannedCount,
477
+ };
478
+ process.stdout.write(JSON.stringify(env, null, 2) + '\n');
479
+ process.exit(0);
480
+ }
481
+
482
+ const result = freshness_check_on_query(db, projectRoot, touched, parseAndPut);
483
+ const env = {
484
+ ok: result.errors.length === 0,
485
+ status: 'REINDEXED',
486
+ reindexed: result.reindexed,
487
+ revalidatedCount: result.revalidated.length,
488
+ errors: result.errors,
489
+ scannedCount: touched.scannedCount,
490
+ touchedCount: touched.touched.length,
491
+ };
492
+ process.stdout.write(JSON.stringify(env, null, 2) + '\n');
493
+ process.exit(env.ok ? 0 : 2);
494
+ }
495
+
496
+ module.exports = {
497
+ compute_touched_files,
498
+ freshness_check_on_query,
499
+ measure_freshness_budget,
500
+ hashFileContent,
501
+ walkTree,
502
+ openDb,
503
+ revalidateOneHopImporters,
504
+ getIndexedFiles,
505
+ hasTable,
506
+ };