@azure-id/orc 1.8.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 (46) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README-id.md +90 -71
  3. package/README.md +77 -31
  4. package/bin/cli.js +45460 -44867
  5. package/bin/graph-extract.js +2409 -120
  6. package/bin/graph-gain.js +404 -0
  7. package/bin/graph-map.js +232 -0
  8. package/bin/graph-notes.js +49 -8
  9. package/bin/graph-query.js +1770 -808
  10. package/bin/graph-resolve.js +93 -16
  11. package/bin/graph-shard.js +325 -0
  12. package/bin/graph.js +658 -605
  13. package/bin/verify-contracts.js +140 -0
  14. package/bin/verify-package.js +14 -0
  15. package/bin/webui/api.js +6 -0
  16. package/bin/webui/fixtures/index.js +6 -1
  17. package/bin/webui/fixtures/knowledge.js +41 -1
  18. package/bin/webui/fixtures/stats.js +107 -104
  19. package/bin/webui/i18n/en/knowledge.json +16 -1
  20. package/bin/webui/i18n/id/knowledge.json +16 -1
  21. package/bin/webui/js/panels/knowledge.js +68 -3
  22. package/package.json +1 -1
  23. package/templates/agents/orc-executor-haiku-4-5.md +8 -13
  24. package/templates/agents/orc-executor-opus-4-7-high.md +8 -13
  25. package/templates/agents/orc-executor-opus-4-7-med.md +8 -13
  26. package/templates/agents/orc-executor-opus-4-8-high.md +8 -13
  27. package/templates/agents/orc-executor-opus-5-high.md +8 -13
  28. package/templates/agents/orc-executor-opus-5-low.md +8 -13
  29. package/templates/agents/orc-executor-opus-5-med.md +8 -13
  30. package/templates/agents/orc-executor-sonnet-4-6-high.md +8 -13
  31. package/templates/agents/orc-executor-sonnet-4-6-med.md +8 -13
  32. package/templates/agents/orc-executor-sonnet-5-high.md +8 -13
  33. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +15 -12
  34. package/templates/hooks/README.md +13 -3
  35. package/templates/hooks/orc-graph-hook.js +148 -13
  36. package/templates/skills/_shared/code-graph.md +148 -20
  37. package/templates/skills/_shared/phases/execution.md +13 -11
  38. package/templates/skills/_shared/phases/planning.md +8 -1
  39. package/templates/skills/_shared/phases/ship.md +5 -1
  40. package/templates/skills/_shared/phases/trace.md +2 -0
  41. package/templates/skills/_shared/phases/wiki-consult.md +1 -1
  42. package/templates/skills/_shared/read-ladder.md +10 -2
  43. package/templates/skills/orc/SKILL.md +1 -1
  44. package/templates/skills/orc-analyze/SKILL.md +13 -13
  45. package/templates/skills/orc-diy/references/flow-schema.md +1 -1
  46. package/templates/skills/orc-wiki/references/staleness.md +1 -1
@@ -0,0 +1,404 @@
1
+ "use strict";
2
+ // ── orc graph — THE GAIN METER (v1.8.2 W4b) ─────────────────────────────────
3
+ //
4
+ // The question this answers is the user's: can ORC show what the graph saves,
5
+ // the way `rtk gain` does? Yes — but only while it keeps apart the two halves
6
+ // EW8 taught us to keep apart, and only while it refuses the one number that
7
+ // would make it lie.
8
+ //
9
+ // PAID the tokens the graph PUT INTO a context — every card, every
10
+ // `--source` block, every hook hint. RECORDED, exact, counted with
11
+ // the same `tok()` the budget uses.
12
+ // AVOIDED what the read ladder would have cost for the same question had
13
+ // there been no graph. AN ESTIMATE. A counterfactual. `rtk gain`
14
+ // can print one number because a filtered command output has one
15
+ // exact raw size; the alternative to `ctx OrderService.create` is a
16
+ // Grep and then a read, and how much read is a guess.
17
+ // MEASURED what runs with the graph ON actually did against runs with it
18
+ // OFF, in this project's own history. RECORDED, joined from ORC's
19
+ // traces and Claude Code's usage transcripts.
20
+ //
21
+ // So the meter prints AVOIDED AS A RANGE, NEVER AS ONE NUMBER (DE-L), and it
22
+ // NEVER prints a percent of the session: EW8 §1 measured that percent at a
23
+ // tenth of one percent, and printing it would make the meter lie by omission.
24
+ //
25
+ // K6 — what this file must never do:
26
+ // · never print a percent of the session (the only percent is the MEASURED
27
+ // executor-window delta, with N and the spread beside it)
28
+ // · never count a hook coverage note as avoided anything — it adds, it saves
29
+ // nothing, and it is listed as paid only
30
+ // · never count a card the agent's return marked `graph_used: none`
31
+ // · never block a read: the ledger is appended AFTER the answer is out, and a
32
+ // failed append is silent
33
+
34
+ const fs = require("fs");
35
+ const path = require("path");
36
+ const G = require("./graph.js");
37
+
38
+ const LEDGER_MAX = 5000;
39
+ const tok = (s) => Math.ceil(String(s).length / 4);
40
+
41
+ // A Grep line in an agent's tool result: the path, the line number and the
42
+ // matched line. W9's transcripts put it at ~120 characters.
43
+ const GREP_LINE_CHARS = 120;
44
+ // The read ladder's step 3. The card names a range; a careful agent reads it.
45
+ const RANGE_LINES = 80;
46
+ // Step 2: the declarations of a file a reader is orienting in.
47
+ const OUTLINE_LINES = 60;
48
+
49
+ function ledgerPath(claudeDir) {
50
+ return path.join(G.graphPaths(claudeDir).dir, "gain.jsonl");
51
+ }
52
+
53
+ // ── the counterfactual (K2) ─────────────────────────────────────────────────
54
+ // The rules are written down HERE, in one place, so the number can be checked
55
+ // by hand. `low` is the floor a careful agent would have spent. `high` is what
56
+ // W9 actually measured agents doing — 1,077 whole-file reads against 139
57
+ // searches. The truth is between, and the meter says so on its last line.
58
+
59
+ // A file's whole cost, and the cost of N of its lines, from the bytes and the
60
+ // line count the extractor already recorded. No file is opened to price it.
61
+ function fileCost(model, rel) {
62
+ const f = (model && model.byFile && model.byFile[rel]) || {};
63
+ const bytes = Number(f.bytes) || 0;
64
+ const lines = Number(f.lines) || 0;
65
+ return {
66
+ path: rel,
67
+ tokens: Math.ceil(bytes / 4),
68
+ lines,
69
+ perLine: lines > 0 ? bytes / lines : 40,
70
+ };
71
+ }
72
+
73
+ const rangeTokens = (c, n) => Math.ceil(Math.min(c.lines || n, n) * c.perLine / 4);
74
+ // A null `hits` means the basis is UNKNOWN (the sharded read path, W8), not
75
+ // zero. It contributes nothing to the estimate and `orc graph gain` counts the
76
+ // rows it could not size, so a shrinking estimate is never mistaken for a
77
+ // shrinking saving.
78
+ const grepTokens = (hits) => (hits == null ? 0 : Math.ceil(Math.max(0, hits) * GREP_LINE_CHARS / 4));
79
+
80
+ // `ctx <symbol>`: one Grep for the name, then a read. Done well that read is
81
+ // the 80 lines the card named; done badly it is the whole target file AND the
82
+ // whole file of one caller — the one W9 saw agents open to "check".
83
+ function ctxSymbol(model, sym, hits, callerFiles) {
84
+ const target = fileCost(model, sym.file);
85
+ const others = (callerFiles || []).map((rel) => fileCost(model, rel)).sort((a, b) => b.tokens - a.tokens);
86
+ const grep = grepTokens(hits);
87
+ const low = grep + rangeTokens(target, RANGE_LINES);
88
+ const high = grep + target.tokens + (others[0] ? others[0].tokens : 0);
89
+ return {
90
+ avoided: { low, high, calls_low: 2, calls_high: others[0] ? 3 : 2 },
91
+ basis: { files: [target, ...others.slice(0, 1)].map(strip), grep_hits: hits },
92
+ };
93
+ }
94
+
95
+ // `ctx <file>`: an outline read done well, the whole file done badly.
96
+ function ctxFile(model, rel) {
97
+ const c = fileCost(model, rel);
98
+ return {
99
+ avoided: { low: rangeTokens(c, OUTLINE_LINES), high: c.tokens, calls_low: 1, calls_high: 1 },
100
+ basis: { files: [strip(c)], grep_hits: 0 },
101
+ };
102
+ }
103
+
104
+ // `--source` is the ONE half of this file that is not an estimate: it printed
105
+ // exactly the lines a range read would have returned, from the same file.
106
+ function sourceAvoided(model, rel, from, to) {
107
+ const c = fileCost(model, rel);
108
+ const n = Math.max(0, (Number(to) || 0) - (Number(from) || 0) + 1);
109
+ const t = Math.ceil(n * c.perLine / 4);
110
+ return { low: t, high: t, calls_low: 1, calls_high: 1 };
111
+ }
112
+
113
+ // `impact`: one Grep per exported symbol done well; the Greps plus a whole-file
114
+ // read of every caller file done badly.
115
+ function impactCost(model, files, symbolCount, hits, callerFiles) {
116
+ const grep = grepTokens(hits);
117
+ const others = [...new Set(callerFiles || [])].map((rel) => fileCost(model, rel));
118
+ const n = Math.max(1, symbolCount || 1);
119
+ return {
120
+ avoided: {
121
+ low: grep,
122
+ high: grep + others.reduce((a, c) => a + c.tokens, 0),
123
+ calls_low: n,
124
+ calls_high: n + others.length,
125
+ },
126
+ basis: { files: others.slice(0, 5).map(strip), grep_hits: hits },
127
+ };
128
+ }
129
+
130
+ // `path a b`: two Greps and two reads, range or whole.
131
+ function pathCost(model, aRel, bRel, hits) {
132
+ const a = fileCost(model, aRel);
133
+ const b = fileCost(model, bRel);
134
+ const grep = grepTokens(hits);
135
+ return {
136
+ avoided: {
137
+ low: grep + rangeTokens(a, RANGE_LINES) + rangeTokens(b, RANGE_LINES),
138
+ high: grep + a.tokens + b.tokens,
139
+ calls_low: 4,
140
+ calls_high: 4,
141
+ },
142
+ basis: { files: [strip(a), strip(b)], grep_hits: hits },
143
+ };
144
+ }
145
+
146
+ // D4 (v1.8.2 W7) `map`: the ORIENTATION sweep it replaces — a Glob over the
147
+ // tree, then reads of the few files the planner picked out of the listing.
148
+ // Done well those are outline reads of the top three; done badly they are
149
+ // whole-file reads of the top five, which is what the planning window shows.
150
+ //
151
+ // The Glob itself is priced at ZERO. Its real size depends on a pattern the
152
+ // graph cannot know, and a meter that guesses high about its own value is a
153
+ // meter nobody should believe. Only the reads count.
154
+ const MAP_LOW_FILES = 3;
155
+ const MAP_HIGH_FILES = 5;
156
+ function mapCost(model, shownFiles) {
157
+ const cs = (shownFiles || []).map((rel) => fileCost(model, rel));
158
+ const low = cs.slice(0, MAP_LOW_FILES).reduce((a, c) => a + rangeTokens(c, OUTLINE_LINES), 0);
159
+ const high = cs.slice(0, MAP_HIGH_FILES).reduce((a, c) => a + c.tokens, 0);
160
+ return {
161
+ avoided: {
162
+ low,
163
+ high,
164
+ calls_low: 1 + Math.min(MAP_LOW_FILES, cs.length),
165
+ calls_high: 1 + Math.min(MAP_HIGH_FILES, cs.length),
166
+ },
167
+ basis: { files: cs.slice(0, 5).map(strip), grep_hits: 0 },
168
+ };
169
+ }
170
+
171
+ // `changes`: the reviewer already reads the diff, so that half is zero.
172
+ function changesCost(model, symbolCount, hits, callerFiles) {
173
+ return impactCost(model, null, symbolCount, hits, callerFiles);
174
+ }
175
+
176
+ // A PreToolUse hint saves NOTHING when the ladder is done well — the search
177
+ // still runs. Done badly it saved the range read the anchor pointed at.
178
+ function hintAvoided(model, rel) {
179
+ const c = rel ? fileCost(model, rel) : { lines: 0, perLine: 40 };
180
+ return { low: 0, high: rangeTokens(c, RANGE_LINES), calls_low: 0, calls_high: 1 };
181
+ }
182
+
183
+ const strip = (c) => ({ path: c.path, tokens: c.tokens, lines: c.lines });
184
+
185
+ // ── the ledger (K1) ─────────────────────────────────────────────────────────
186
+ // Append-only, torn-line tolerant, never under the lock. A reader stays
187
+ // lock-free, and one `appendFileSync` of one line is atomic enough on both
188
+ // platforms — a torn line is skipped exactly as `notes.jsonl`'s is.
189
+ function append(claudeDir, row) {
190
+ try {
191
+ const dir = G.graphPaths(claudeDir).dir;
192
+ if (!fs.existsSync(dir)) return false;
193
+ fs.appendFileSync(ledgerPath(claudeDir), JSON.stringify(row) + "\n");
194
+ return true;
195
+ } catch (_) {
196
+ return false; // a meter that can break a read is worse than no meter
197
+ }
198
+ }
199
+
200
+ function readLedger(claudeDir) {
201
+ let text;
202
+ try {
203
+ text = fs.readFileSync(ledgerPath(claudeDir), "utf8");
204
+ } catch (_) {
205
+ return null;
206
+ }
207
+ const rows = [];
208
+ for (const line of text.split("\n")) {
209
+ if (!line.trim()) continue;
210
+ try {
211
+ const r = JSON.parse(line);
212
+ if (r && typeof r.cmd === "string" && r.paid && r.avoided) rows.push(r);
213
+ } catch (_) {
214
+ // a torn last line from a crash mid-append
215
+ }
216
+ }
217
+ return rows;
218
+ }
219
+
220
+ // Called by `graphGc` while it already holds the lock.
221
+ function compact(p, atomicWrite) {
222
+ let text;
223
+ try {
224
+ text = fs.readFileSync(path.join(p.dir, "gain.jsonl"), "utf8");
225
+ } catch (_) {
226
+ return { kept: 0, removed: 0 };
227
+ }
228
+ const lines = text.split("\n").filter((l) => l.trim());
229
+ if (lines.length <= LEDGER_MAX) return { kept: lines.length, removed: 0 };
230
+ const keep = lines.slice(-LEDGER_MAX);
231
+ atomicWrite(path.join(p.dir, "gain.jsonl"), keep.join("\n") + "\n");
232
+ return { kept: keep.length, removed: lines.length - keep.length };
233
+ }
234
+
235
+ // ── aggregation (K3) ────────────────────────────────────────────────────────
236
+ function parseSince(spec) {
237
+ const m = /^(\d+)([dhw])$/.exec(String(spec || ""));
238
+ if (!m) return null;
239
+ const n = Number(m[1]);
240
+ const ms = m[2] === "h" ? 3600e3 : m[2] === "w" ? 7 * 864e5 : 864e5;
241
+ return Date.now() - n * ms;
242
+ }
243
+
244
+ // The ledger stamp is ORC's own `DD-MM-YYYY HH:MM:SS` — what `graph.stamp()`
245
+ // writes, and the format every ORC artifact uses. The trace's own
246
+ // `DDMMYY HH:MM:SS.mmm` is accepted too, so a row written by the hook parses
247
+ // whichever shape it carries. Parsed here and nowhere else.
248
+ function stampMs(at) {
249
+ const s = String(at || "");
250
+ const d = /^(\d{2})-(\d{2})-(\d{4}) (\d{2}):(\d{2}):(\d{2})/.exec(s);
251
+ if (d) return new Date(Number(d[3]), Number(d[2]) - 1, Number(d[1]), Number(d[4]), Number(d[5]), Number(d[6])).getTime();
252
+ const t = /^(\d{2})(\d{2})(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(s);
253
+ if (t) return new Date(2000 + Number(t[3]), Number(t[2]) - 1, Number(t[1]), Number(t[4]), Number(t[5]), Number(t[6])).getTime();
254
+ return null;
255
+ }
256
+
257
+ function gain(claudeDir, opts) {
258
+ const o = opts || {};
259
+ const all = readLedger(claudeDir);
260
+ if (!all) return { ok: false, state: "none", reason: "no-ledger", exit: 1 };
261
+ const sinceMs = parseSince(o.since);
262
+ let rows = all;
263
+ if (o.run) rows = rows.filter((r) => r.run === o.run);
264
+ if (sinceMs != null) rows = rows.filter((r) => {
265
+ const t = stampMs(r.at);
266
+ return t == null || t >= sinceMs;
267
+ });
268
+ if (!rows.length) return { ok: false, state: "none", reason: o.run ? "no-rows-for-run" : "no-rows", exit: 1 };
269
+
270
+ const paid = { card: 0, source: 0, hints: 0 };
271
+ const avoided = { low: 0, high: 0 };
272
+ const calls = { low: 0, high: 0 };
273
+ const byCommand = {};
274
+ const hints = { injected: 0, read_notes: 0, updates: 0 };
275
+ // W8: rows the sharded read path could not size. Their estimate omits the
276
+ // search term, so a FALLING estimate can be told apart from a falling saving.
277
+ let unsized = 0;
278
+ const gens = [];
279
+ const runs = new Set();
280
+ for (const r of rows) {
281
+ // K6: a card the agent said it did not use is not a saving.
282
+ const used = r.used !== false;
283
+ paid.card += Number(r.paid.card) || 0;
284
+ paid.source += Number(r.paid.source) || 0;
285
+ paid.hints += Number(r.paid.hints) || 0;
286
+ if (used) {
287
+ avoided.low += Number(r.avoided.low) || 0;
288
+ avoided.high += Number(r.avoided.high) || 0;
289
+ calls.low += Number(r.avoided.calls_low) || 0;
290
+ calls.high += Number(r.avoided.calls_high) || 0;
291
+ }
292
+ if (r.basis && r.basis.grep_hits === null) unsized++;
293
+ byCommand[r.cmd] = (byCommand[r.cmd] || 0) + 1;
294
+ if (r.cmd === "hint") hints.injected++;
295
+ if (r.cmd === "read-note") hints.read_notes++;
296
+ if (r.cmd === "hook-update") hints.updates++;
297
+ if (r.gen) gens.push(Number(r.gen));
298
+ if (r.run) runs.add(r.run);
299
+ }
300
+ const paidTotal = paid.card + paid.source + paid.hints;
301
+ gens.sort((a, b) => a - b);
302
+ return {
303
+ ok: true,
304
+ state: "rows",
305
+ scope: o.run ? "run" : o.since ? "since" : "project",
306
+ run: o.run || null,
307
+ since: o.since || null,
308
+ calls_recorded: rows.length,
309
+ runs: runs.size,
310
+ generation_range: gens.length ? [gens[0], gens[gens.length - 1]] : null,
311
+ paid: { ...paid, total: paidTotal },
312
+ avoided,
313
+ calls,
314
+ net: { low: avoided.low - paidTotal, high: avoided.high - paidTotal },
315
+ by_command: byCommand,
316
+ hints,
317
+ unsized,
318
+ estimate: true,
319
+ carry_note: "a card is re-sent on every later turn of the agent that received it — and so is the read it replaced. Both halves carry the same multiplier, so these are FIRST-ENTRY tokens on both sides (eval/graph-replay.js).",
320
+ exit: 0,
321
+ };
322
+ }
323
+
324
+ function history(claudeDir, opts) {
325
+ const o = opts || {};
326
+ const all = readLedger(claudeDir);
327
+ if (!all) return { ok: false, state: "none", reason: "no-ledger", exit: 1 };
328
+ const limit = Math.max(1, Math.min(500, Number(o.limit) || 40));
329
+ const rows = all.slice(-limit).reverse();
330
+ if (!rows.length) return { ok: false, state: "none", reason: "no-rows", exit: 1 };
331
+ return { ok: true, state: "rows", total: all.length, rows, estimate: true, exit: 0 };
332
+ }
333
+
334
+ function reset(claudeDir) {
335
+ try {
336
+ fs.rmSync(ledgerPath(claudeDir), { force: true });
337
+ return { ok: true, state: "reset", exit: 0 };
338
+ } catch (e) {
339
+ return { ok: false, state: "unavailable", reason: String((e && e.message) || e), exit: 1 };
340
+ }
341
+ }
342
+
343
+ // ── K4 — the recorded A/B, and the arithmetic that keeps it honest ───────
344
+ // It needs at least this many runs in EACH group before it prints a median.
345
+ // Below it, the rows are printed and the comparison is not.
346
+ const MEASURED_MIN_RUNS = 3;
347
+
348
+ function median(xs) {
349
+ if (!xs.length) return null;
350
+ const a = xs.slice().sort((x, y) => x - y);
351
+ const m = a.length >> 1;
352
+ return a.length % 2 ? a[m] : Math.round((a[m - 1] + a[m]) / 2);
353
+ }
354
+
355
+ // A median alone is a claim. A median with its N and its spread is a finding.
356
+ function summarise(xs) {
357
+ if (!xs.length) return null;
358
+ const a = xs.slice().sort((x, y) => x - y);
359
+ return { n: a.length, median: median(a), min: a[0], max: a[a.length - 1] };
360
+ }
361
+
362
+ // The OFF group's own spread, as a percent of its median. Any ON/OFF delta
363
+ // smaller than this is INSIDE THE NOISE and must be reported as such — W9
364
+ // round 2 read a −1.8% session total as a result, and it was not one.
365
+ function spreadPct(s) {
366
+ if (!s || !s.median) return null;
367
+ return Math.round((100 * (s.max - s.min)) / s.median);
368
+ }
369
+
370
+ function deltaPct(on, off) {
371
+ if (!on || !off || !off.median) return null;
372
+ return Math.round((100 * (on.median - off.median)) / off.median);
373
+ }
374
+
375
+ module.exports = {
376
+ MEASURED_MIN_RUNS,
377
+ median,
378
+ summarise,
379
+ spreadPct,
380
+ deltaPct,
381
+ LEDGER_MAX,
382
+ GREP_LINE_CHARS,
383
+ RANGE_LINES,
384
+ OUTLINE_LINES,
385
+ tok,
386
+ ledgerPath,
387
+ fileCost,
388
+ ctxSymbol,
389
+ ctxFile,
390
+ sourceAvoided,
391
+ impactCost,
392
+ pathCost,
393
+ changesCost,
394
+ mapCost,
395
+ hintAvoided,
396
+ append,
397
+ readLedger,
398
+ compact,
399
+ gain,
400
+ history,
401
+ reset,
402
+ stampMs,
403
+ parseSince,
404
+ };
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ // ── orc graph — D4: the RANKED REPOSITORY MAP (v1.8.2 W7) ───────────────────
3
+ //
4
+ // The question this answers is the one a planner asks FIRST, before it knows a
5
+ // single file name: "what is this repository, and which files matter?" Until
6
+ // now the only way to ask it was a Glob followed by a handful of whole-file
7
+ // Reads — the biggest `Read` share in a lane run, and the one place where reads
8
+ // cluster on files nobody ends up touching.
9
+ //
10
+ // Aider's repo map is the reference. The idea is small: a file that many other
11
+ // files call is a landmark, and a file that a LANDMARK calls is a landmark too.
12
+ // That is PageRank, and ORC already holds every edge it needs — the resolution
13
+ // cache is a map of "who calls this", which is exactly a referencing file
14
+ // pointing at a defining one.
15
+ //
16
+ // Three rules keep the ranking honest:
17
+ //
18
+ // · An edge is weighted by the SQUARE ROOT of the confident calls between the
19
+ // pair. A file called 100 times from one place is not ten times the
20
+ // landmark of one called 10 times; it is one import used in a loop.
21
+ // · A test file is knocked down by ten. It is real code and it belongs on the
22
+ // map, but a repository where the tests outrank the thing being tested is a
23
+ // map that sends a planner to the wrong half of the tree.
24
+ // · A file whose every symbol is private is halved. Nothing outside it can
25
+ // call it by name, so it is an implementation detail of its own directory.
26
+ //
27
+ // `--focus` is the half that makes it a TOOL rather than a list. A personalised
28
+ // PageRank biased toward the files (and the defining files of the names) the
29
+ // request already mentions re-ranks the whole repository around that request,
30
+ // so the same command answers "what is this project" and "what surrounds the
31
+ // thing I am about to change".
32
+ //
33
+ // WHAT IS STORED — `map.json`, generation-pinned like every other derived file
34
+ // in this directory:
35
+ //
36
+ // files the file list, in index order — the node ids
37
+ // edges [from, to, weight] over those ids, referencing → defining
38
+ // base the unfocused PageRank, one number per file
39
+ //
40
+ // It does NOT store symbols. The ranges and the importance come from the model,
41
+ // which a read command has loaded anyway, and storing them a second time would
42
+ // be `wide.json` again for no gain. Losing `map.json` costs the edge walk and
43
+ // nothing else: the reader computes it and answers the same bytes.
44
+
45
+ const path = require("path");
46
+ const G = require("./graph.js");
47
+
48
+ const MAP_SCHEMA = 1;
49
+ // Damping and iteration count are Aider's, and they are not worth tuning: the
50
+ // ORDER of the top rows stops moving long before iteration 30 on every fixture.
51
+ const DAMPING = 0.85;
52
+ const ITERATIONS = 30;
53
+ const SYMS_PER_FILE = 4;
54
+
55
+ function mapPath(claudeDir) {
56
+ return path.join(G.graphPaths(claudeDir).dir, "map.json");
57
+ }
58
+
59
+ function readJson(file) {
60
+ try {
61
+ return JSON.parse(require("fs").readFileSync(file, "utf8"));
62
+ } catch (_) {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ const own = (o, k) => (o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
68
+
69
+ // ── the file graph ──────────────────────────────────────────────────────────
70
+ // `callers` is the resolution cache's own shape: target symbol id → rows of
71
+ // [caller symbol id, line, state, kind, extra]. Every row is one referencing
72
+ // file pointing at one defining file. A call inside a file is not an edge —
73
+ // PageRank on a self-loop only inflates the file that already won.
74
+ //
75
+ // The IMPORT half matters as much as the call half: a file that imports a
76
+ // constant and never calls it still depends on it, and a planner that cannot
77
+ // see that edge plans a change that breaks a file it never listed.
78
+ function buildEdges(model, callers) {
79
+ const files = Object.keys(model.byFile).sort();
80
+ const idOf = new Map(files.map((f, i) => [f, i]));
81
+ const pair = new Map(); // "from|to" → confident call count
82
+
83
+ const bump = (fromRel, toRel, n) => {
84
+ const a = idOf.get(fromRel);
85
+ const b = idOf.get(toRel);
86
+ if (a === undefined || b === undefined || a === b) return;
87
+ const k = a + "|" + b;
88
+ pair.set(k, (pair.get(k) || 0) + n);
89
+ };
90
+
91
+ for (const [target, rows] of Object.entries(callers || {})) {
92
+ const to = model.symbols.get(target);
93
+ if (!to) continue;
94
+ for (const row of rows) {
95
+ const from = model.symbols.get(row[0]);
96
+ if (from) bump(from.file, to.file, 1);
97
+ }
98
+ }
99
+
100
+ // Imports. One edge per (file, imported file) pair, weight 1 — an import is a
101
+ // single fact about a binding, however many times the binding is then used.
102
+ const Q = require("./graph-query.js");
103
+ for (const rel of files) {
104
+ for (const t of Q.importsOf(model, rel).files) bump(rel, t, 1);
105
+ }
106
+
107
+ const edges = [];
108
+ for (const [k, n] of pair) {
109
+ const [a, b] = k.split("|");
110
+ edges.push([Number(a), Number(b), Math.round(Math.sqrt(n) * 1000) / 1000]);
111
+ }
112
+ edges.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
113
+ return { files, edges };
114
+ }
115
+
116
+ // ── PageRank ────────────────────────────────────────────────────────────────
117
+ // `pers` is the personalisation vector — null for the unfocused rank. Rank
118
+ // leaks out of a file with no outgoing edge, so the dangling mass is put back
119
+ // through the personalisation vector rather than spread evenly: without that,
120
+ // a repository of mostly leaf files washes the focus straight out again.
121
+ function pageRank(n, edges, pers) {
122
+ if (!n) return [];
123
+ const outWeight = new Float64Array(n);
124
+ for (const [a, , w] of edges) outWeight[a] += w;
125
+ const p = new Float64Array(n);
126
+ if (pers) {
127
+ let sum = 0;
128
+ for (let i = 0; i < n; i++) sum += pers[i];
129
+ if (sum <= 0) for (let i = 0; i < n; i++) p[i] = 1 / n;
130
+ else for (let i = 0; i < n; i++) p[i] = pers[i] / sum;
131
+ } else for (let i = 0; i < n; i++) p[i] = 1 / n;
132
+
133
+ let r = new Float64Array(n);
134
+ for (let i = 0; i < n; i++) r[i] = p[i];
135
+ for (let it = 0; it < ITERATIONS; it++) {
136
+ const next = new Float64Array(n);
137
+ let dangling = 0;
138
+ for (let i = 0; i < n; i++) if (outWeight[i] === 0) dangling += r[i];
139
+ for (const [a, b, w] of edges) if (outWeight[a] > 0) next[b] += (r[a] * w) / outWeight[a];
140
+ for (let i = 0; i < n; i++) next[i] = (1 - DAMPING) * p[i] + DAMPING * (next[i] + dangling * p[i]);
141
+ r = next;
142
+ }
143
+ return Array.from(r);
144
+ }
145
+
146
+ // ── the two per-file knock-downs ────────────────────────────────────────────
147
+ function multiplier(model, rel) {
148
+ const Q = require("./graph-query.js");
149
+ let m = 1;
150
+ if (Q.TEST_FILE.test(rel)) m *= 0.1;
151
+ const syms = (model.byFile[rel].symbols || []).filter((s) => s.kind !== "module");
152
+ if (syms.length && !syms.some((s) => s.exported)) m *= 0.5;
153
+ return m;
154
+ }
155
+
156
+ // ── build (called by `update`, inside the lock) ─────────────────────────────
157
+ // It never throws into the update: a map that cannot be written is a slower
158
+ // `orc graph map`, not a broken graph.
159
+ function build(claudeDir, model, callers) {
160
+ const t0 = Date.now();
161
+ try {
162
+ const { files, edges } = buildEdges(model, callers);
163
+ const base = pageRank(files.length, edges, null);
164
+ const out = {
165
+ schema: MAP_SCHEMA,
166
+ engine: G.ENGINE,
167
+ generation: model.meta.generation,
168
+ gen_id: model.meta.gen_id,
169
+ files,
170
+ edges,
171
+ base: base.map((x) => Math.round(x * 1e9) / 1e9),
172
+ };
173
+ G.atomicWrite(mapPath(claudeDir), JSON.stringify(out));
174
+ return { route: "full", files: files.length, edges: edges.length, ms: Date.now() - t0 };
175
+ } catch (e) {
176
+ try {
177
+ require("fs").rmSync(mapPath(claudeDir), { force: true });
178
+ } catch (_) {}
179
+ return { route: "failed", reason: String((e && e.message) || e), ms: Date.now() - t0 };
180
+ }
181
+ }
182
+
183
+ // Used ONLY when the generation matches — the same rule as `resolved.json`.
184
+ function load(claudeDir, meta) {
185
+ const m = readJson(mapPath(claudeDir));
186
+ if (!m || m.schema !== MAP_SCHEMA || m.engine !== G.ENGINE) return null;
187
+ if (!meta || !meta.generation || m.generation !== meta.generation) return null;
188
+ return m;
189
+ }
190
+
191
+ // The reader's fallback: no cache, or a cache from another generation. It
192
+ // computes the same edges from the model and answers the same bytes.
193
+ function edgesFor(claudeDir, model) {
194
+ const cached = load(claudeDir, model.meta);
195
+ if (cached) return { files: cached.files, edges: cached.edges, base: cached.base, cached: true };
196
+ const callers = model.callersCache || computeCallers(model);
197
+ const { files, edges } = buildEdges(model, callers);
198
+ return { files, edges, base: null, cached: false };
199
+ }
200
+
201
+ // Only reached when the resolution cache is absent or stale. It is the same
202
+ // walk `graph-resolve.build` does, kept here rather than exported from there so
203
+ // the cache writer keeps exactly one caller.
204
+ function computeCallers(model) {
205
+ const Q = require("./graph-query.js");
206
+ const out = Object.create(null);
207
+ for (const [rel, f] of Object.entries(model.byFile)) {
208
+ for (const s of f.symbols || []) {
209
+ s.file = rel;
210
+ for (const c of s.calls || []) {
211
+ const r = Q.resolveCall(model, rel, s, c);
212
+ if (r.target && (r.state === "LOCAL" || r.state === "IMPORT" || r.state === "UNIQUE" || r.state === "ROUTE")) {
213
+ (own(out, r.target) || (out[r.target] = [])).push([s.id]);
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // The fan-in every printed row needs, counted ONCE. `importance` wants the
222
+ // confident caller count per symbol, and asking `callersOf` per symbol is a
223
+ // property read with the resolution cache and a full walk without it — so the
224
+ // count is taken from the same object the edges came from.
225
+ function fanCounts(model) {
226
+ const callers = model.callersCache || computeCallers(model);
227
+ const out = new Map();
228
+ for (const [target, rows] of Object.entries(callers)) out.set(target, rows.length);
229
+ return out;
230
+ }
231
+
232
+ module.exports = { MAP_SCHEMA, SYMS_PER_FILE, mapPath, build, load, edgesFor, buildEdges, pageRank, multiplier, fanCounts };