@tekyzinc/gsd-t 4.9.13 → 4.10.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 +24 -0
- package/README.md +1 -1
- package/bin/gsd-t-competition-judge.cjs +7 -1
- package/bin/gsd-t-context-brief-kinds/impact.cjs +91 -4
- package/bin/gsd-t-context-brief-kinds/partition.cjs +95 -0
- package/bin/gsd-t-context-brief-kinds/plan.cjs +110 -4
- package/bin/gsd-t-file-disjointness.cjs +319 -7
- package/bin/gsd-t-graph-anti-grep-lint.cjs +515 -0
- package/bin/gsd-t-graph-edge-extract.cjs +612 -0
- package/bin/gsd-t-graph-freshness.cjs +506 -0
- package/bin/gsd-t-graph-index.cjs +540 -0
- package/bin/gsd-t-graph-k1-sqlite-stream.cjs +251 -0
- package/bin/gsd-t-graph-query-cli.cjs +1182 -0
- package/bin/gsd-t-graph-scip-upgrade.cjs +440 -0
- package/bin/gsd-t-graph-store-bakeoff.cjs +889 -0
- package/bin/gsd-t-graph-synthetic-gen.cjs +304 -0
- package/bin/gsd-t-graph-ts-throughput.cjs +587 -0
- package/bin/gsd-t-scip-reader.cjs +167 -0
- package/bin/gsd-t.js +166 -48
- package/commands/gsd-t-debug.md +10 -0
- package/commands/gsd-t-design-build.md +10 -0
- package/commands/gsd-t-execute.md +15 -0
- package/commands/gsd-t-feature.md +15 -7
- package/commands/gsd-t-gap-analysis.md +17 -7
- package/commands/gsd-t-impact.md +25 -0
- package/commands/gsd-t-integrate.md +25 -0
- package/commands/gsd-t-partition.md +18 -0
- package/commands/gsd-t-plan.md +16 -0
- package/commands/gsd-t-populate.md +16 -5
- package/commands/gsd-t-prd.md +18 -0
- package/commands/gsd-t-project.md +19 -0
- package/commands/gsd-t-promote-debt.md +16 -5
- package/commands/gsd-t-qa.md +20 -8
- package/commands/gsd-t-quick.md +10 -0
- package/commands/gsd-t-scan.md +21 -3
- package/commands/gsd-t-test-sync.md +10 -0
- package/commands/gsd-t-verify.md +25 -0
- package/commands/gsd-t-wave.md +8 -0
- package/package.json +10 -2
- package/templates/workflows/gsd-t-debug.workflow.js +81 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +47 -1
- package/templates/workflows/gsd-t-phase.workflow.js +99 -1
- package/templates/workflows/gsd-t-quick.workflow.js +64 -0
- package/templates/workflows/gsd-t-scan.workflow.js +200 -9
- package/templates/workflows/gsd-t-verify.workflow.js +50 -1
- package/bin/graph-cgc.js +0 -510
- package/bin/graph-indexer.js +0 -147
- package/bin/graph-overlay.js +0 -195
- package/bin/graph-parsers.js +0 -327
- package/bin/graph-query.js +0 -453
- 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('better-sqlite3');
|
|
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
|
+
};
|