@azure-id/orc 1.7.1 → 1.8.2

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 (63) hide show
  1. package/CHANGELOG.md +3869 -3381
  2. package/README-id.md +942 -844
  3. package/README.md +882 -788
  4. package/bin/build-agents.js +43 -27
  5. package/bin/cli.js +45460 -44169
  6. package/bin/graph-extract.js +3216 -0
  7. package/bin/graph-gain.js +404 -0
  8. package/bin/graph-map.js +232 -0
  9. package/bin/graph-notes.js +229 -0
  10. package/bin/graph-query.js +1770 -0
  11. package/bin/graph-resolve.js +255 -0
  12. package/bin/graph-shard.js +325 -0
  13. package/bin/graph-signals.js +277 -0
  14. package/bin/graph.js +658 -0
  15. package/bin/verify-contracts.js +4809 -4553
  16. package/bin/verify-package.js +640 -616
  17. package/bin/webui/api.js +1425 -1414
  18. package/bin/webui/fixtures/index.js +584 -576
  19. package/bin/webui/fixtures/knowledge.js +356 -291
  20. package/bin/webui/fixtures/stats.js +107 -104
  21. package/bin/webui/i18n/en/knowledge.json +182 -151
  22. package/bin/webui/i18n/en/overview.json +101 -100
  23. package/bin/webui/i18n/id/knowledge.json +182 -151
  24. package/bin/webui/i18n/id/overview.json +101 -100
  25. package/bin/webui/js/panels/knowledge.js +1130 -1006
  26. package/bin/webui/js/panels/overview.js +492 -488
  27. package/package.json +39 -39
  28. package/templates/agents/MODEL-MAPPING.md +163 -158
  29. package/templates/agents/orc-executor-haiku-4-5.md +128 -121
  30. package/templates/agents/orc-executor-opus-4-7-high.md +129 -122
  31. package/templates/agents/orc-executor-opus-4-7-med.md +129 -122
  32. package/templates/agents/orc-executor-opus-4-8-high.md +129 -122
  33. package/templates/agents/orc-executor-opus-5-high.md +129 -122
  34. package/templates/agents/orc-executor-opus-5-low.md +129 -122
  35. package/templates/agents/orc-executor-opus-5-med.md +129 -122
  36. package/templates/agents/orc-executor-sonnet-4-6-high.md +129 -122
  37. package/templates/agents/orc-executor-sonnet-4-6-med.md +129 -122
  38. package/templates/agents/orc-executor-sonnet-5-high.md +129 -122
  39. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +89 -0
  40. package/templates/hooks/README.md +454 -396
  41. package/templates/hooks/orc-graph-hook.js +471 -0
  42. package/templates/hooks/orc-statusline-render.js +922 -921
  43. package/templates/hooks/orc-statusline.js +1596 -1545
  44. package/templates/skills/_shared/README.md +4 -0
  45. package/templates/skills/_shared/code-graph.md +348 -0
  46. package/templates/skills/_shared/opus5-only.md +4 -0
  47. package/templates/skills/_shared/phases/execution.md +168 -147
  48. package/templates/skills/_shared/phases/planning.md +149 -135
  49. package/templates/skills/_shared/phases/preflight.md +132 -118
  50. package/templates/skills/_shared/phases/review.md +63 -53
  51. package/templates/skills/_shared/phases/ship.md +100 -88
  52. package/templates/skills/_shared/phases/trace.md +8 -0
  53. package/templates/skills/_shared/phases/wiki-consult.md +194 -189
  54. package/templates/skills/_shared/read-ladder.md +132 -102
  55. package/templates/skills/_shared/return-validation.md +259 -250
  56. package/templates/skills/orc/SKILL.md +255 -254
  57. package/templates/skills/orc-analyze/SKILL.md +13 -13
  58. package/templates/skills/orc-diy/references/flow-schema.md +101 -100
  59. package/templates/skills/orc-fast/SKILL.md +236 -229
  60. package/templates/skills/orc-mini/SKILL.md +267 -259
  61. package/templates/skills/orc-quick/SKILL.md +378 -361
  62. package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
  63. package/templates/skills/orc-wiki/references/staleness.md +294 -288
package/bin/graph.js ADDED
@@ -0,0 +1,658 @@
1
+ "use strict";
2
+ // ── orc graph (v1.8.0) — the code graph STORE ───────────────────────────────
3
+ //
4
+ // A small, local, git-ignored map of how this repository is connected. This
5
+ // file owns the store and change detection. Extraction is `graph-extract.js`;
6
+ // resolution and the read commands are `graph-query.js`. `bin/cli.js` routes
7
+ // `orc graph …` and resolves the config — no module here reads
8
+ // `.claude/orc.config.yaml`.
9
+ //
10
+ // THE RULES THIS FILE HOLDS
11
+ //
12
+ // 1. A change is found from git's OWN blob SHAs. `git ls-files -s` hashes
13
+ // every tracked file in one call; `git status` names the dirty and
14
+ // untracked ones, and `git hash-object` hashes only those. Nothing is
15
+ // parsed to find out whether it changed. (W0: 42 ms for 7,091 files.)
16
+ // 2. A record is CONTENT-ADDRESSED — `blobs/<ab>/<sha>.json`. A branch
17
+ // switch back, a revert, or a teammate's identical file reuses it.
18
+ // 3. Writes are atomic (temp file + rename) and serialized by `.lock`.
19
+ // Readers never take the lock: they see the old index or the new one.
20
+ // 4. The write ORDER is blobs → index.json → files.json → meta.json. A crash
21
+ // between any two leaves files.json OLD, so the next status reads DRIFTED
22
+ // and the next update redoes the work. Idempotent, never half-applied.
23
+ // 5. No background process and no timer, ever — a continuous rebuild is how
24
+ // the graph tools in the research froze machines. EW3 adds two ONE-SHOT
25
+ // triggers (a read that finds its own target stale, and an executor
26
+ // finishing). Both take the lock below, and the loser SKIPS rather than
27
+ // queues, so nothing can ever pile up.
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+ const { spawnSync } = require("child_process");
32
+ const crypto = require("crypto");
33
+ const X = require("./graph-extract.js");
34
+
35
+ const SCHEMA = 1;
36
+ // Bumped whenever the record shape or extraction changes. A record made by an
37
+ // older engine is re-extracted, and status reads DRIFTED until it is.
38
+ // @3 (W9): route symbols + `ref` edges — an older index re-extracts once.
39
+ // @4 (EW1): per-file COVERAGE and a GENERATION on the index.
40
+ // @5 (v1.8.2): `urls`, `mounts`, decorator routes with `handler`, `bases`,
41
+ // aliases, re-exports. A 1.8.1 store reads DRIFTED with `engine_stale` and
42
+ // the next preflight (`status --heal`) re-extracts every file once.
43
+ const ENGINE = "graph@5";
44
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
45
+ const MAX_BYTES = 512 * 1024;
46
+ const LOCK_STALE_MS = 10 * 60 * 1000;
47
+ // Measured on this machine class: nestjs/nest 1.3 ms per file (heuristic),
48
+ // django/django 3.5 ms per file on 1.8.1 and 5.6 ms on 1.8.2 (Python ast +
49
+ // masking + the W1/W2 alias, decorator and re-export passes). The estimate
50
+ // uses the slower one — a first build that finishes early is fine, one that
51
+ // overruns its own estimate teaches people to ignore it.
52
+ const EST_MS_PER_FILE = 5.6;
53
+ const ESTIMATE_ABOVE = 2000;
54
+
55
+ const LANG_BY_EXT = {
56
+ ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js",
57
+ ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts",
58
+ ".py": "py",
59
+ ".go": "go",
60
+ ".java": "java",
61
+ ".cs": "cs",
62
+ ".php": "php",
63
+ // v1.8.2 W5 (G6) — the heuristic rung gains Ruby, Rust, Kotlin, the two
64
+ // single-file component formats, and C/C++.
65
+ ".rb": "rb", ".rake": "rb",
66
+ ".rs": "rs",
67
+ ".kt": "kt", ".kts": "kt",
68
+ // A single-file component is its `<script>` block, parsed as js or ts. The
69
+ // rest of the file is blanked, so every line number is the file's own.
70
+ ".vue": "vue",
71
+ ".svelte": "svelte",
72
+ ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".cxx": "c", ".hpp": "c", ".hh": "c",
73
+ };
74
+
75
+ // Never part of the map, whatever git says: ORC's own tree, installed or
76
+ // vendored dependencies (sometimes committed), build output, caches, minified
77
+ // or bundled JS, generated files, and `.d.ts` declarations (v1.8.2 W2 — a
78
+ // declaration file duplicates every symbol of its module and made each one
79
+ // AMBIGUOUS). `dist/` and `build/` are skipped at the ROOT only: `pkg/build/`
80
+ // is a package name in more than one real repository. A skipped path has no
81
+ // record, so `coverage` reports it `excluded`, never silently.
82
+ const ALWAYS_SKIP = /^(\.claude\/|dist\/|build\/|out\/|target\/(debug|release)\/)|(^|\/)(node_modules|vendor|__pycache__|\.next|\.nuxt|\.venv|venv|target\/classes)\/|\.(min|bundle|chunk)\.js$|\.d\.ts$|\.generated\.[A-Za-z]+$|\.pb\.go$|_pb2\.py$/;
83
+
84
+ function graphPaths(claudeDir) {
85
+ const dir = path.join(claudeDir, "orc", "graph");
86
+ return {
87
+ dir,
88
+ meta: path.join(dir, "meta.json"),
89
+ files: path.join(dir, "files.json"),
90
+ index: path.join(dir, "index.json"),
91
+ blobs: path.join(dir, "blobs"),
92
+ notes: path.join(dir, "notes.jsonl"),
93
+ lock: path.join(dir, ".lock"),
94
+ };
95
+ }
96
+
97
+ function globRe(g) {
98
+ const raw = String(g).replace(/^\.\//, "");
99
+ const esc = raw
100
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
101
+ .replace(/\*\*/g, "__GLOBSTAR__")
102
+ .replace(/\*/g, "[^/]*")
103
+ .replace(/\?/g, "[^/]")
104
+ .replace(/__GLOBSTAR__/g, ".*");
105
+ // gitignore's rule (W2): a pattern with no slash matches at ANY depth
106
+ // (`*.gen.ts`, `__snapshots__`); one with a slash is anchored at the root.
107
+ const anchored = raw.replace(/\/$/, "").includes("/");
108
+ return new RegExp("^" + (anchored ? "" : "(?:.*/)?") + esc + "(/|$)");
109
+ }
110
+
111
+ function makeFilter(ignore) {
112
+ const res = (Array.isArray(ignore) ? ignore : []).filter(Boolean).map(globRe);
113
+ return (rel) => {
114
+ if (ALWAYS_SKIP.test(rel)) return null;
115
+ if (res.some((re) => re.test(rel))) return null;
116
+ return LANG_BY_EXT[path.extname(rel).toLowerCase()] || null;
117
+ };
118
+ }
119
+
120
+ function git(root, args, input) {
121
+ return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER, input });
122
+ }
123
+
124
+ function stamp(d) {
125
+ const p = (n) => String(n).padStart(2, "0");
126
+ return `${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
127
+ }
128
+
129
+ function readJson(file) {
130
+ try {
131
+ return JSON.parse(fs.readFileSync(file, "utf8"));
132
+ } catch (_) {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // Windows can refuse a rename for a moment while another process has the
138
+ // target open (a reader mid-parse). A short retry is the whole remedy.
139
+ function atomicWrite(file, text) {
140
+ const tmp = `${file}.${process.pid}.tmp`;
141
+ fs.writeFileSync(tmp, text);
142
+ for (let i = 0; ; i++) {
143
+ try {
144
+ fs.renameSync(tmp, file);
145
+ return;
146
+ } catch (e) {
147
+ if (i >= 5 || (e.code !== "EPERM" && e.code !== "EBUSY" && e.code !== "EACCES")) {
148
+ try { fs.rmSync(tmp, { force: true }); } catch (_) {}
149
+ throw e;
150
+ }
151
+ const until = Date.now() + 20 * (i + 1);
152
+ while (Date.now() < until) {} // eslint-disable-line no-empty
153
+ }
154
+ }
155
+ }
156
+
157
+ // ── the lock ────────────────────────────────────────────────────────────────
158
+ function acquireLock(p) {
159
+ fs.mkdirSync(p.dir, { recursive: true });
160
+ for (let attempt = 0; attempt < 2; attempt++) {
161
+ try {
162
+ const fd = fs.openSync(p.lock, "wx");
163
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: stamp(new Date()) }));
164
+ fs.closeSync(fd);
165
+ return { ok: true };
166
+ } catch (e) {
167
+ if (e.code !== "EEXIST") throw e;
168
+ let age = 0;
169
+ try {
170
+ age = Date.now() - fs.statSync(p.lock).mtimeMs;
171
+ } catch (_) {
172
+ continue; // released between the open and the stat
173
+ }
174
+ // A writer that died leaves its lock behind. Ten minutes is far past any
175
+ // real update (W0: 1.7 s for Django), so an older lock is a dead one.
176
+ if (age > LOCK_STALE_MS) {
177
+ try { fs.rmSync(p.lock, { force: true }); } catch (_) {}
178
+ continue;
179
+ }
180
+ return { ok: false, holder: readJson(p.lock), age_ms: Math.round(age) };
181
+ }
182
+ }
183
+ return { ok: false, holder: readJson(p.lock), age_ms: null };
184
+ }
185
+
186
+ function releaseLock(p) {
187
+ try { fs.rmSync(p.lock, { force: true }); } catch (_) {}
188
+ }
189
+
190
+ // ── detection ───────────────────────────────────────────────────────────────
191
+ // Returns { ok, files: Map<rel, blob>, langs, head } or { ok:false, reason }.
192
+ function detect(root, opts) {
193
+ const langOf = makeFilter(opts && opts.ignore);
194
+ const ls = git(root, ["ls-files", "-s", "-z"]);
195
+ if (ls.error || ls.status !== 0) return { ok: false, reason: "not-git" };
196
+ const files = new Map();
197
+ const langs = new Map();
198
+ for (const rec of ls.stdout.split("\0")) {
199
+ if (!rec) continue;
200
+ const tab = rec.indexOf("\t");
201
+ if (tab < 0) continue;
202
+ const [mode, blob] = rec.slice(0, tab).split(" ");
203
+ const rel = rec.slice(tab + 1);
204
+ if (mode === "160000") continue; // a submodule is another repository
205
+ const lang = langOf(rel);
206
+ if (!lang) continue;
207
+ // A conflicted file lists up to three stages; the working tree decides
208
+ // below, so the first stage seen is only a placeholder.
209
+ if (!files.has(rel)) files.set(rel, blob);
210
+ langs.set(rel, lang);
211
+ }
212
+
213
+ const st = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"]);
214
+ if (st.error || st.status !== 0) return { ok: false, reason: "git-status-failed" };
215
+ const hashMe = [];
216
+ for (const rec of st.stdout.split("\0")) {
217
+ if (!rec || rec.length < 4) continue;
218
+ const x = rec[0];
219
+ const y = rec[1];
220
+ const rel = rec.slice(3);
221
+ const lang = langOf(rel);
222
+ if (!lang) continue;
223
+ if (y === "D") {
224
+ files.delete(rel);
225
+ langs.delete(rel);
226
+ continue;
227
+ }
228
+ // `ls-files -s` already carries the STAGED content. Only a working-tree
229
+ // difference (M, T, U, A with intent-to-add) or an untracked file needs its
230
+ // own hash.
231
+ if (x === "?" || x === "U" || y === "M" || y === "T" || y === "U" || y === "A") {
232
+ hashMe.push(rel);
233
+ langs.set(rel, lang);
234
+ }
235
+ }
236
+ if (hashMe.length) {
237
+ // No --no-filters on purpose: the clean filter (autocrlf on Windows) must
238
+ // run, or every CRLF file would hash differently from its index blob and
239
+ // read as changed forever.
240
+ const h = git(root, ["hash-object", "--stdin-paths"], hashMe.join("\n") + "\n");
241
+ if (h.error || h.status !== 0) return { ok: false, reason: "hash-object-failed" };
242
+ const shas = h.stdout.split(/\r?\n/).filter(Boolean);
243
+ hashMe.forEach((rel, i) => {
244
+ if (shas[i]) files.set(rel, shas[i]);
245
+ });
246
+ }
247
+ const head = git(root, ["rev-parse", "HEAD"]);
248
+ return {
249
+ ok: true,
250
+ files,
251
+ langs,
252
+ head: head.status === 0 ? head.stdout.trim() : null,
253
+ };
254
+ }
255
+
256
+ function diffFiles(prev, cur) {
257
+ const added = [];
258
+ const changed = [];
259
+ const deleted = [];
260
+ for (const [rel, blob] of cur) {
261
+ const was = prev[rel];
262
+ if (!was) added.push(rel);
263
+ else if (was.blob !== blob) changed.push(rel);
264
+ }
265
+ for (const rel of Object.keys(prev)) if (!cur.has(rel)) deleted.push(rel);
266
+ return { added, changed, deleted };
267
+ }
268
+
269
+ function blobPath(p, blob) {
270
+ return path.join(p.blobs, blob.slice(0, 2), `${blob}.json`);
271
+ }
272
+
273
+ // ── generation (EW1) ────────────────────────────────────────────────────────
274
+ // `generation` counts index-changing writes; `gen_id` names the CONTENT those
275
+ // writes produced. A reader compares the number (cheap) and a second store —
276
+ // the resolution cache — pins itself to it. Two machines that index the same
277
+ // tree get the same `gen_id` and different `generation` numbers, so the id is
278
+ // what a card may quote and the number is what a cache may compare.
279
+ function genId(filesOut) {
280
+ const h = crypto.createHash("sha1");
281
+ for (const rel of Object.keys(filesOut).sort()) h.update(`${rel}${(filesOut[rel] || {}).blob || ""}`);
282
+ return h.digest("hex").slice(0, 8);
283
+ }
284
+
285
+ // What the extractor saw of ONE file, from the record it already wrote. A path
286
+ // with no record at all is `excluded` — git does not track it, an ignore glob
287
+ // dropped it, or the language has no extractor.
288
+ function coverageOf(entry) {
289
+ if (!entry) return { coverage: "excluded" };
290
+ if (entry.skipped) return { coverage: `skipped:${entry.skipped}` };
291
+ if (entry.coverage === "partial") return { coverage: "partial", ranges: entry.partial || [] };
292
+ return { coverage: "full" };
293
+ }
294
+
295
+ // ── status ──────────────────────────────────────────────────────────────────
296
+ // exit 0 FRESH · 1 NONE (or unavailable) · 2 DRIFTED · 3 OFF
297
+ function graphStatus(claudeDir, root, opts) {
298
+ const p = graphPaths(claudeDir);
299
+ const meta = readJson(p.meta);
300
+ const prev = readJson(p.files);
301
+ const base = {
302
+ ok: true,
303
+ enabled: !!opts.enabled,
304
+ exists: !!(meta && prev),
305
+ files: meta ? meta.files : 0,
306
+ symbols: meta ? meta.symbols : 0,
307
+ updated_at: meta ? meta.updated_at : null,
308
+ head_commit: meta ? meta.head_commit : null,
309
+ engine: meta ? meta.engine : null,
310
+ generation: meta ? meta.generation || 0 : 0,
311
+ gen_id: meta ? meta.gen_id || null : null,
312
+ };
313
+ if (!opts.enabled) return { ...base, state: "off", exit: 3 };
314
+ if (!meta || !prev) return { ...base, state: "none", exit: 1 };
315
+ const d = detect(root, opts);
316
+ if (!d.ok) return { ...base, ok: false, state: "unavailable", reason: d.reason, exit: 1 };
317
+ const diff = diffFiles(prev, d.files);
318
+ const behind = { added: diff.added.length, changed: diff.changed.length, deleted: diff.deleted.length };
319
+ const engineStale = meta.engine !== ENGINE || meta.schema !== SCHEMA;
320
+ const n = behind.added + behind.changed + behind.deleted;
321
+ if (n || engineStale) return { ...base, state: "drifted", behind, engine_stale: engineStale, exit: 2 };
322
+ return { ...base, state: "fresh", behind, engine_stale: false, exit: 0 };
323
+ }
324
+
325
+ // ── update ──────────────────────────────────────────────────────────────────
326
+ // exit 0 built/updated/unchanged · 1 unavailable (not git, locked, io) · 3 off
327
+ function graphUpdate(claudeDir, root, opts) {
328
+ const t0 = Date.now();
329
+ const say = opts.say || (() => {});
330
+ if (!opts.enabled && opts.ifEnabled) return { ok: true, enabled: false, state: "off", exit: 3 };
331
+ const p = graphPaths(claudeDir);
332
+ const d = detect(root, opts);
333
+ if (!d.ok) return { ok: false, enabled: !!opts.enabled, state: "unavailable", reason: d.reason, exit: 1 };
334
+
335
+ const lock = acquireLock(p);
336
+ if (!lock.ok) {
337
+ return { ok: false, enabled: !!opts.enabled, state: "unavailable", reason: "locked", holder: lock.holder, age_ms: lock.age_ms, exit: 1 };
338
+ }
339
+ try {
340
+ const prevMeta = readJson(p.meta);
341
+ const prevFiles = readJson(p.files);
342
+ const first = !prevMeta || !prevFiles;
343
+ const upgrade = !first && (prevMeta.engine !== ENGINE || prevMeta.schema !== SCHEMA);
344
+ const prev = first ? {} : prevFiles;
345
+ const fresh = { schema: SCHEMA, engine: ENGINE, by_file: {} };
346
+ const index = first || upgrade ? fresh : readJson(p.index) || fresh;
347
+
348
+ const diff = diffFiles(prev, d.files);
349
+ // An engine upgrade re-extracts everything. A missing index entry is
350
+ // repaired too — that is how a crash between two writes heals.
351
+ const work = new Set([...diff.added, ...diff.changed]);
352
+ if (upgrade) for (const rel of d.files.keys()) work.add(rel);
353
+ for (const rel of d.files.keys()) if (!index.by_file[rel]) work.add(rel);
354
+
355
+ if (first && work.size > ESTIMATE_ABOVE) {
356
+ say(`graph: building first index (~${work.size} files, est. ${Math.max(1, Math.round((work.size * EST_MS_PER_FILE) / 1000))} s)`);
357
+ }
358
+
359
+ // Phase 1 — reuse what is already on disk; read the rest.
360
+ let reused = 0;
361
+ let skipped = 0;
362
+ const records = new Map();
363
+ const toExtract = [];
364
+ for (const rel of work) {
365
+ const blob = d.files.get(rel);
366
+ const lang = d.langs.get(rel);
367
+ const have = readJson(blobPath(p, blob));
368
+ if (have && have.schema === SCHEMA && have.engine === ENGINE) {
369
+ records.set(rel, have);
370
+ reused++;
371
+ continue;
372
+ }
373
+ const abs = path.join(root, ...rel.split("/"));
374
+ try {
375
+ const size = fs.statSync(abs).size;
376
+ if (size > MAX_BYTES) records.set(rel, { schema: SCHEMA, engine: ENGINE, blob, lang, bytes: size, skipped: "too-large", coverage: "skipped", imports: [], symbols: [] });
377
+ else toExtract.push({ rel, abs, lang, blob, bytes: size, src: fs.readFileSync(abs, "utf8") });
378
+ } catch (_) {
379
+ records.set(rel, { schema: SCHEMA, engine: ENGINE, blob, lang, bytes: 0, skipped: "unreadable", coverage: "skipped", imports: [], symbols: [] });
380
+ }
381
+ }
382
+
383
+ // Phase 2 — extract in ONE batch (one Python process for every .py file).
384
+ const out = X.extractBatch(toExtract, { root });
385
+ for (const it of toExtract) {
386
+ const r = out.get(it.rel) || { extractor: X.HEURISTIC, imports: [], symbols: [] };
387
+ records.set(it.rel, {
388
+ schema: SCHEMA,
389
+ engine: ENGINE,
390
+ blob: it.blob,
391
+ lang: it.lang,
392
+ bytes: it.bytes,
393
+ lines: r.lines || 0,
394
+ extractor: r.extractor,
395
+ ...(r.error ? { skipped: r.error } : {}),
396
+ coverage: r.error ? "skipped" : r.coverage || "full",
397
+ ...(r.partial ? { partial: r.partial } : {}),
398
+ imports: r.imports,
399
+ symbols: r.symbols,
400
+ ...(r.reexports ? { reexports: r.reexports } : {}),
401
+ });
402
+ }
403
+ for (const [rel, record] of records) {
404
+ if (!toExtract.some((t) => t.rel === rel) && reused && readJson(blobPath(p, record.blob))) continue;
405
+ const file = blobPath(p, record.blob);
406
+ fs.mkdirSync(path.dirname(file), { recursive: true });
407
+ atomicWrite(file, JSON.stringify(record));
408
+ }
409
+
410
+ const filesOut = {};
411
+ for (const [rel, blob] of d.files) {
412
+ const record = records.get(rel);
413
+ if (record) {
414
+ if (record.skipped) skipped++;
415
+ index.by_file[rel] = {
416
+ blob,
417
+ lang: record.lang,
418
+ bytes: record.bytes || 0,
419
+ lines: record.lines || 0,
420
+ ...(record.skipped ? { skipped: record.skipped } : {}),
421
+ ...(record.coverage && record.coverage !== "full" ? { coverage: record.coverage } : {}),
422
+ ...(record.partial ? { partial: record.partial } : {}),
423
+ imports: record.imports,
424
+ symbols: record.symbols,
425
+ ...(record.reexports ? { reexports: record.reexports } : {}),
426
+ };
427
+ filesOut[rel] = {
428
+ blob,
429
+ lang: record.lang,
430
+ bytes: record.bytes,
431
+ ...(record.lines ? { lines: record.lines } : {}),
432
+ ...(record.extractor ? { extractor: record.extractor } : {}),
433
+ ...(record.skipped ? { skipped: record.skipped } : {}),
434
+ ...(record.coverage && record.coverage !== "full" ? { coverage: record.coverage } : {}),
435
+ ...(record.partial ? { partial: record.partial } : {}),
436
+ };
437
+ } else {
438
+ filesOut[rel] = prev[rel];
439
+ }
440
+ }
441
+ for (const rel of Object.keys(index.by_file)) if (!d.files.has(rel)) delete index.by_file[rel];
442
+
443
+ let symbols = 0;
444
+ for (const v of Object.values(index.by_file)) symbols += (v.symbols || []).filter((s) => s.kind !== "module").length;
445
+ const unchanged = !first && !upgrade && work.size === 0 && diff.deleted.length === 0;
446
+
447
+ const gen = genId(filesOut);
448
+ const meta = {
449
+ schema: SCHEMA,
450
+ engine: ENGINE,
451
+ head_commit: d.head,
452
+ updated_at: unchanged && prevMeta ? prevMeta.updated_at : stamp(new Date()),
453
+ files: d.files.size,
454
+ symbols,
455
+ // The number goes up only when the index on disk changes, so a reader
456
+ // that saw generation N is looking at exactly the index that wrote N.
457
+ generation: unchanged && prevMeta && prevMeta.generation ? prevMeta.generation : ((prevMeta && prevMeta.generation) || 0) + 1,
458
+ gen_id: gen,
459
+ // EW3: how long the last real update took, end to end. A read that may
460
+ // heal uses it as the estimate for the next one — the only honest
461
+ // estimate available, because an update cannot be stopped half way.
462
+ update_ms: unchanged && prevMeta ? prevMeta.update_ms || 0 : 0,
463
+ };
464
+ if (!unchanged) {
465
+ index.schema = SCHEMA;
466
+ index.engine = ENGINE;
467
+ atomicWrite(p.index, JSON.stringify(index));
468
+ atomicWrite(p.files, JSON.stringify(filesOut));
469
+ atomicWrite(p.meta, JSON.stringify(meta, null, 2) + "\n");
470
+ }
471
+ // EW2: the DERIVED resolution cache, written inside this same lock, AFTER
472
+ // meta.json — it reads the generation it must pin itself to. It is an
473
+ // optimisation: a route of `failed` leaves the graph correct and only
474
+ // slower, so it never changes this function's answer.
475
+ // S2 (v1.8.2 W2): the index is already in memory — `build` must not read
476
+ // the 21 MB file it just wrote a second time. One parse per update.
477
+ const resolveRoute = unchanged && require("./graph-resolve.js").load(claudeDir, meta)
478
+ ? { route: "unchanged", ms: 0 }
479
+ : require("./graph-resolve.js").build(claudeDir, root, index);
480
+ // EW3: stamp the duration. A second 200-byte write of the same file, not a
481
+ // second commit point — every other field is already the one just written,
482
+ // so a crash between the two leaves a valid meta that only lacks an
483
+ // estimate, and a missing estimate simply means "heal, and find out".
484
+ if (!unchanged) {
485
+ meta.update_ms = Date.now() - t0;
486
+ atomicWrite(p.meta, JSON.stringify(meta, null, 2) + "\n");
487
+ }
488
+ return {
489
+ ok: true,
490
+ enabled: !!opts.enabled,
491
+ state: first ? "built" : unchanged ? "unchanged" : "updated",
492
+ files: d.files.size,
493
+ symbols,
494
+ generation: meta.generation,
495
+ gen_id: meta.gen_id,
496
+ route: resolveRoute.route,
497
+ ...(resolveRoute.reason ? { route_reason: resolveRoute.reason } : {}),
498
+ added: diff.added.length,
499
+ changed: diff.changed.length,
500
+ deleted: diff.deleted.length,
501
+ parsed: toExtract.length,
502
+ reused,
503
+ skipped,
504
+ engine_upgrade: upgrade,
505
+ head_commit: d.head,
506
+ ms: Date.now() - t0,
507
+ exit: 0,
508
+ };
509
+ } finally {
510
+ releaseLock(p);
511
+ }
512
+ }
513
+
514
+ // ── coverage ────────────────────────────────────────────────────────────────
515
+ // exit 0 always when a graph exists — a gap IS the answer, never an error.
516
+ // exit 1 no index · 3 off (with --if-enabled)
517
+ //
518
+ // It reads `files.json` (444 KB on django/django) and NEVER `index.json`
519
+ // (22 MB), because the only question is how much of each file was seen.
520
+ function graphCoverage(claudeDir, root, opts) {
521
+ const p = graphPaths(claudeDir);
522
+ const meta = readJson(p.meta);
523
+ const files = readJson(p.files);
524
+ if (!meta || !files) return { ok: false, state: "none", reason: "no-index", exit: 1 };
525
+ const paths = (opts.paths || []).map((x) => String(x).split("\\").join("/").replace(/^\.\//, ""));
526
+ // One `hash-object` for every path that is still on disk — the batch command
527
+ // must not pay one git process per file.
528
+ const onDisk = paths.filter((rel) => files[rel] && fs.existsSync(path.join(root, ...rel.split("/"))));
529
+ const shas = new Map();
530
+ if (onDisk.length) {
531
+ const h = git(root, ["hash-object", "--stdin-paths"], onDisk.join("\n") + "\n");
532
+ if (h.status === 0) {
533
+ const out = h.stdout.split(/\r?\n/).filter(Boolean);
534
+ onDisk.forEach((rel, i) => shas.set(rel, out[i]));
535
+ }
536
+ }
537
+ const rows = [];
538
+ for (const rel of paths) {
539
+ const entry = files[rel];
540
+ const c = coverageOf(entry);
541
+ let changed = null;
542
+ if (entry) {
543
+ if (!fs.existsSync(path.join(root, ...rel.split("/")))) changed = "deleted";
544
+ else if (!shas.has(rel)) changed = "unknown";
545
+ else changed = shas.get(rel) === entry.blob ? "current" : "changed";
546
+ }
547
+ rows.push({ path: rel, ...c, changed_since_index: changed });
548
+ }
549
+ const gaps = rows.filter((r) => r.coverage !== "full").length;
550
+ return {
551
+ ok: true,
552
+ state: "found",
553
+ generation: meta.generation || 0,
554
+ gen_id: meta.gen_id || null,
555
+ rows,
556
+ gaps,
557
+ exit: 0,
558
+ };
559
+ }
560
+
561
+ // ── gc ──────────────────────────────────────────────────────────────────────
562
+ // exit 0 done · 1 none or locked
563
+ function graphGc(claudeDir) {
564
+ const p = graphPaths(claudeDir);
565
+ const files = readJson(p.files);
566
+ if (!files) return { ok: false, state: "none", reason: "no-index", exit: 1 };
567
+ const lock = acquireLock(p);
568
+ if (!lock.ok) return { ok: false, state: "unavailable", reason: "locked", holder: lock.holder, exit: 1 };
569
+ try {
570
+ const keep = new Set(Object.values(files).map((f) => f.blob));
571
+ let removed = 0;
572
+ let kept = 0;
573
+ let bytes = 0;
574
+ let shards = [];
575
+ try {
576
+ shards = fs.readdirSync(p.blobs);
577
+ } catch (_) {}
578
+ for (const shard of shards) {
579
+ const dir = path.join(p.blobs, shard);
580
+ let names = [];
581
+ try {
582
+ names = fs.readdirSync(dir);
583
+ } catch (_) {
584
+ continue;
585
+ }
586
+ for (const name of names) {
587
+ const blob = name.replace(/\.json$/, "");
588
+ const full = path.join(dir, name);
589
+ if (name.endsWith(".json") && keep.has(blob)) {
590
+ kept++;
591
+ continue;
592
+ }
593
+ try {
594
+ bytes += fs.statSync(full).size;
595
+ fs.rmSync(full, { force: true });
596
+ removed++;
597
+ } catch (_) {}
598
+ }
599
+ try {
600
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
601
+ } catch (_) {}
602
+ }
603
+ // S1 (v1.8.2 W8): a shard set from an older generation is never READ — a
604
+ // reader refuses it on the generation check — but it is still tens of
605
+ // megabytes nobody will ever open. An update replaces the set wholesale, so
606
+ // the only way to get here is a crash between two writes.
607
+ let deadShards = { removed: 0, bytes: 0 };
608
+ try {
609
+ const S = require("./graph-shard.js");
610
+ const dir = S.shardDir(claudeDir);
611
+ const meta = readJson(p.meta);
612
+ for (const name of fs.readdirSync(dir)) {
613
+ const full = path.join(dir, name);
614
+ const j = readJson(full);
615
+ if (j && meta && j.generation === meta.generation && j.engine === ENGINE) continue;
616
+ deadShards.bytes += fs.statSync(full).size;
617
+ fs.rmSync(full, { force: true });
618
+ deadShards.removed++;
619
+ }
620
+ } catch (_) {}
621
+
622
+ // A temp file left by a writer that died mid-write.
623
+ for (const name of fs.readdirSync(p.dir)) {
624
+ if (name.endsWith(".tmp")) {
625
+ try { fs.rmSync(path.join(p.dir, name), { force: true }); } catch (_) {}
626
+ }
627
+ }
628
+ // Compact the notes ledger in the same locked pass: the latest note per
629
+ // symbol whose body still hashes the same, and nothing else.
630
+ const notes = require("./graph-notes.js").compactNotes(p, readJson(p.index), atomicWrite);
631
+ // K1 (v1.8.2 W4b): the gain ledger is capped in the same locked pass. It is
632
+ // append-only between compactions, exactly like the notes ledger.
633
+ const gain = require("./graph-gain.js").compact(p, atomicWrite);
634
+ return { ok: true, state: "done", removed, kept, bytes_freed: bytes + deadShards.bytes, shards: deadShards, notes, gain, exit: 0 };
635
+ } finally {
636
+ releaseLock(p);
637
+ }
638
+ }
639
+
640
+ module.exports = {
641
+ SCHEMA,
642
+ ENGINE,
643
+ LANG_BY_EXT,
644
+ graphPaths,
645
+ detect,
646
+ diffFiles,
647
+ graphStatus,
648
+ graphUpdate,
649
+ graphCoverage,
650
+ coverageOf,
651
+ genId,
652
+ graphGc,
653
+ // shared with graph-notes.js — one lock, one atomic writer, one date format
654
+ acquireLock,
655
+ releaseLock,
656
+ atomicWrite,
657
+ stamp,
658
+ };