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