@azure-id/orc 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3649 -3339
- package/README-id.md +923 -844
- package/README.md +836 -860
- package/bin/build-agents.js +43 -27
- package/bin/cli.js +701 -3
- package/bin/graph-extract.js +927 -0
- package/bin/graph-notes.js +188 -0
- package/bin/graph-query.js +808 -0
- package/bin/graph-resolve.js +178 -0
- package/bin/graph-signals.js +277 -0
- package/bin/graph.js +605 -0
- package/bin/verify-contracts.js +4669 -4527
- package/bin/verify-package.js +626 -616
- package/bin/webui/api.js +1419 -1414
- package/bin/webui/fixtures/index.js +579 -576
- package/bin/webui/fixtures/knowledge.js +316 -291
- package/bin/webui/i18n/en/knowledge.json +167 -151
- package/bin/webui/i18n/en/overview.json +101 -100
- package/bin/webui/i18n/id/knowledge.json +167 -151
- package/bin/webui/i18n/id/overview.json +101 -100
- package/bin/webui/js/panels/knowledge.js +1065 -1006
- package/bin/webui/js/panels/overview.js +492 -488
- package/package.json +39 -39
- package/templates/agents/MODEL-MAPPING.md +163 -158
- package/templates/agents/orc-executor-haiku-4-5.md +133 -113
- package/templates/agents/orc-executor-opus-4-7-high.md +134 -114
- package/templates/agents/orc-executor-opus-4-7-med.md +134 -114
- package/templates/agents/orc-executor-opus-4-8-high.md +134 -114
- package/templates/agents/orc-executor-opus-5-high.md +134 -114
- package/templates/agents/orc-executor-opus-5-low.md +134 -114
- package/templates/agents/orc-executor-opus-5-med.md +134 -114
- package/templates/agents/orc-executor-sonnet-4-6-high.md +134 -114
- package/templates/agents/orc-executor-sonnet-4-6-med.md +134 -114
- package/templates/agents/orc-executor-sonnet-5-high.md +134 -114
- package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +86 -0
- package/templates/hooks/README.md +444 -396
- package/templates/hooks/orc-graph-hook.js +336 -0
- package/templates/hooks/orc-statusline-render.js +922 -921
- package/templates/hooks/orc-statusline.js +1596 -1545
- package/templates/skills/_shared/README.md +4 -0
- package/templates/skills/_shared/code-graph.md +220 -0
- package/templates/skills/_shared/extra-dispatch.md +1 -1
- package/templates/skills/_shared/opus5-only.md +4 -0
- package/templates/skills/_shared/phases/execution.md +166 -145
- package/templates/skills/_shared/phases/planning.md +142 -135
- package/templates/skills/_shared/phases/preflight.md +132 -118
- package/templates/skills/_shared/phases/review.md +63 -53
- package/templates/skills/_shared/phases/rules.md +5 -0
- package/templates/skills/_shared/phases/ship.md +96 -88
- package/templates/skills/_shared/phases/trace.md +6 -0
- package/templates/skills/_shared/phases/wiki-consult.md +194 -189
- package/templates/skills/_shared/read-ladder.md +124 -102
- package/templates/skills/_shared/return-validation.md +259 -250
- package/templates/skills/orc/SKILL.md +255 -254
- package/templates/skills/orc/references/preflight-report.md +6 -0
- package/templates/skills/orc/subskills/orc-execution/SKILL.md +2 -1
- package/templates/skills/orc/subskills/orc-execution/core.md +9 -1
- package/templates/skills/orc-diy/references/flow-schema.md +101 -100
- package/templates/skills/orc-fast/SKILL.md +236 -229
- package/templates/skills/orc-mini/SKILL.md +267 -259
- package/templates/skills/orc-quick/SKILL.md +378 -360
- package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
- package/templates/skills/orc-wiki/references/staleness.md +294 -288
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── orc graph — the RESOLUTION CACHE (v1.8.0 EW2) ───────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// W3 decided "resolution is computed on read, never stored". That was right for
|
|
5
|
+
// correctness and wrong for cost: on django/django ONE file card spent 437 ms
|
|
6
|
+
// working out who calls what, and `impact` up to 520 ms — every single time.
|
|
7
|
+
//
|
|
8
|
+
// EW2 measured what is worth storing, and the answer is narrower than the plan
|
|
9
|
+
// assumed. Two things were built, measured, and thrown away:
|
|
10
|
+
//
|
|
11
|
+
// · the resolver's whole MEMO — 21.7 MB, ~120 ms to read and parse, to save
|
|
12
|
+
// less than that. A net loss. Not stored.
|
|
13
|
+
// · CLOSURE REPAIR (re-resolve only the changed file and its dependents) —
|
|
14
|
+
// measured 2,669 ms against 2,695 ms for a plain full re-resolve on the
|
|
15
|
+
// same one-line edit. **One percent.** On a JSON store the fixed costs
|
|
16
|
+
// (change detection, loading the index, and rewriting the whole file) drown
|
|
17
|
+
// the resolve, so an incremental resolve cannot pay. It was three
|
|
18
|
+
// correctness bugs deep by the time the numbers came in, so it is gone.
|
|
19
|
+
//
|
|
20
|
+
// What IS stored is the REVERSE direction — who calls this symbol — which is
|
|
21
|
+
// the half the 437 ms walk was computing. 13 MB on django/django, and it turns
|
|
22
|
+
// a file card into 458 ms from 727 ms, and `impact` into 397 ms from 631 ms.
|
|
23
|
+
//
|
|
24
|
+
// The rules that keep W3's correctness:
|
|
25
|
+
//
|
|
26
|
+
// 1. `index.json` is still the only SOURCE. `resolved.json` is DERIVED, and
|
|
27
|
+
// throwing it away costs speed and nothing else.
|
|
28
|
+
// 2. Only `update` writes it, inside the same lock. A reader never writes, so
|
|
29
|
+
// readers stay lock-free.
|
|
30
|
+
// 3. A reader uses it ONLY when `resolved.generation === meta.generation`.
|
|
31
|
+
// Any other answer — a crash between two writes, an older engine, a hand
|
|
32
|
+
// deletion — falls back to computing on read. Stale is impossible: it is
|
|
33
|
+
// not "probably fine", it is not used at all.
|
|
34
|
+
// 4. A cached answer must be BYTE-IDENTICAL to a computed one, order
|
|
35
|
+
// included. `test/cli/graph-resolve.test.js` drives a seeded random edit
|
|
36
|
+
// script and compares the two after every step.
|
|
37
|
+
//
|
|
38
|
+
// WHAT IS STORED
|
|
39
|
+
//
|
|
40
|
+
// callers target symbol id → the confident callers of it, as
|
|
41
|
+
// `[caller symbol id, line, state, ref]`.
|
|
42
|
+
// maybe target symbol id → how many AMBIGUOUS calls list this target, by
|
|
43
|
+
// CALLER FILE. Per file, not per call site: 5 MB on django/django
|
|
44
|
+
// instead of 45 MB, and the card only ever shows the total.
|
|
45
|
+
|
|
46
|
+
const fs = require("fs");
|
|
47
|
+
const path = require("path");
|
|
48
|
+
const G = require("./graph.js");
|
|
49
|
+
|
|
50
|
+
const RESOLVE_SCHEMA = 2;
|
|
51
|
+
const CONFIDENT = new Set(["LOCAL", "IMPORT", "UNIQUE"]);
|
|
52
|
+
const NAMES_PER_KEY = 5;
|
|
53
|
+
|
|
54
|
+
function resolvedPath(claudeDir) {
|
|
55
|
+
return path.join(G.graphPaths(claudeDir).dir, "resolved.json");
|
|
56
|
+
}
|
|
57
|
+
function namesPath(claudeDir) {
|
|
58
|
+
return path.join(G.graphPaths(claudeDir).dir, "names.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every map here is keyed by data from the repository — a bare symbol name can
|
|
62
|
+
// be `constructor`, `toString` or `__proto__`. A plain object would hand back
|
|
63
|
+
// `Object.prototype`'s member instead of "nothing here", so maps are built with
|
|
64
|
+
// a null prototype and read through this.
|
|
65
|
+
function own(o, k) {
|
|
66
|
+
return o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined;
|
|
67
|
+
}
|
|
68
|
+
const bare = () => Object.create(null);
|
|
69
|
+
|
|
70
|
+
function readJson(file) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
73
|
+
} catch (_) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── build ───────────────────────────────────────────────────────────────────
|
|
79
|
+
// Called by `graphUpdate` INSIDE the lock, after index.json is on disk. It
|
|
80
|
+
// never throws into the update: a cache that cannot be written is a slower
|
|
81
|
+
// graph, not a broken one.
|
|
82
|
+
function build(claudeDir, root) {
|
|
83
|
+
const t0 = Date.now();
|
|
84
|
+
const Q = require("./graph-query.js");
|
|
85
|
+
try {
|
|
86
|
+
// `noCache` matters: the writer must COMPUTE, never read back the cache it
|
|
87
|
+
// is about to replace.
|
|
88
|
+
const model = Q.loadModel(claudeDir, root, { noCache: true });
|
|
89
|
+
if (!model) return { route: "skipped", reason: "no-index", ms: Date.now() - t0 };
|
|
90
|
+
|
|
91
|
+
const callers = bare();
|
|
92
|
+
const maybe = bare();
|
|
93
|
+
for (const [rel, f] of Object.entries(model.byFile)) {
|
|
94
|
+
for (const s of f.symbols || []) {
|
|
95
|
+
s.file = rel;
|
|
96
|
+
for (const c of s.calls || []) {
|
|
97
|
+
const r = Q.resolveCall(model, rel, s, c);
|
|
98
|
+
if (r.target && CONFIDENT.has(r.state)) {
|
|
99
|
+
(own(callers, r.target) || (callers[r.target] = [])).push([s.id, c.line, r.state, c.ref ? 1 : 0]);
|
|
100
|
+
} else if (r.state === "AMBIGUOUS") {
|
|
101
|
+
for (const cand of r.candidates || []) {
|
|
102
|
+
const m = own(maybe, cand) || (maybe[cand] = bare());
|
|
103
|
+
m[rel] = (own(m, rel) || 0) + 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One stable order, so the same tree always produces the same bytes.
|
|
111
|
+
const out = {
|
|
112
|
+
schema: RESOLVE_SCHEMA,
|
|
113
|
+
engine: G.ENGINE,
|
|
114
|
+
generation: model.meta.generation,
|
|
115
|
+
gen_id: model.meta.gen_id,
|
|
116
|
+
callers: sortedRows(callers),
|
|
117
|
+
maybe: sortedCounts(maybe),
|
|
118
|
+
};
|
|
119
|
+
G.atomicWrite(resolvedPath(claudeDir), JSON.stringify(out));
|
|
120
|
+
writeNames(claudeDir, model, out.callers);
|
|
121
|
+
return { route: "full", ms: Date.now() - t0 };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// A cache is an optimisation. Losing it must never lose the update — and a
|
|
124
|
+
// HALF-written one must never be read, so it goes.
|
|
125
|
+
try {
|
|
126
|
+
fs.rmSync(resolvedPath(claudeDir), { force: true });
|
|
127
|
+
fs.rmSync(namesPath(claudeDir), { force: true });
|
|
128
|
+
} catch (_) {}
|
|
129
|
+
return { route: "failed", reason: String((e && e.message) || e), ms: Date.now() - t0 };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sortedObject(o) {
|
|
134
|
+
const out = bare();
|
|
135
|
+
for (const k of Object.keys(o).sort()) out[k] = o[k];
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function sortedRows(o) {
|
|
139
|
+
const out = bare();
|
|
140
|
+
for (const k of Object.keys(o).sort()) {
|
|
141
|
+
out[k] = o[k].slice().sort((a, b) => String(a[0]).localeCompare(String(b[0])) || a[1] - b[1]);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
// target → [[caller file, count], …], both levels in a stable order.
|
|
146
|
+
function sortedCounts(o) {
|
|
147
|
+
const out = bare();
|
|
148
|
+
for (const k of Object.keys(o).sort()) out[k] = Object.keys(o[k]).sort().map((f) => [f, o[k][f]]);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── names.json ──────────────────────────────────────────────────────────────
|
|
153
|
+
// A bare name → the few symbols worth showing for it. This is the ONLY file a
|
|
154
|
+
// delivery hook is allowed to read: a name lookup, no index parse, no resolve.
|
|
155
|
+
function writeNames(claudeDir, model, callers) {
|
|
156
|
+
const out = bare();
|
|
157
|
+
for (const [name, syms] of model.byName) {
|
|
158
|
+
const rows = syms
|
|
159
|
+
.map((s) => ({ s, fan: (own(callers, s.id) || []).length }))
|
|
160
|
+
.sort((a, b) => b.fan - a.fan || a.s.file.localeCompare(b.s.file))
|
|
161
|
+
.slice(0, NAMES_PER_KEY)
|
|
162
|
+
.map(({ s, fan }) => [s.qname, s.kind, s.file, s.lines[0], s.lines[1], fan]);
|
|
163
|
+
if (rows.length) out[name] = rows;
|
|
164
|
+
}
|
|
165
|
+
G.atomicWrite(namesPath(claudeDir), JSON.stringify({ schema: RESOLVE_SCHEMA, generation: model.meta.generation, names: sortedObject(out) }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── read ────────────────────────────────────────────────────────────────────
|
|
169
|
+
// Used ONLY when the generation matches. Anything else returns null and the
|
|
170
|
+
// reader computes, exactly as it did before this file existed.
|
|
171
|
+
function load(claudeDir, meta) {
|
|
172
|
+
const r = readJson(resolvedPath(claudeDir));
|
|
173
|
+
if (!r || r.schema !== RESOLVE_SCHEMA || r.engine !== G.ENGINE) return null;
|
|
174
|
+
if (!meta || !meta.generation || r.generation !== meta.generation) return null;
|
|
175
|
+
return r;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { own, RESOLVE_SCHEMA, resolvedPath, namesPath, build, load };
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── orc graph — CHEAP SIGNALS for planners and reviewers (v1.8.0 EW5) ───────
|
|
3
|
+
//
|
|
4
|
+
// Four answers that cost NO model tokens, because every one of them is already
|
|
5
|
+
// on disk: git's own history, and the index the parser already built.
|
|
6
|
+
//
|
|
7
|
+
// changes what this diff actually touched, who calls it, and how risky
|
|
8
|
+
// that is. A reviewer asking `impact` on the DECLARED files gets
|
|
9
|
+
// the whole file; this gets the symbols the HUNKS overlap.
|
|
10
|
+
// importance one number per symbol, so a card's rows are ordered by what
|
|
11
|
+
// matters instead of by raw fan-in. A helper called from forty
|
|
12
|
+
// places is not more important than the exported entry point.
|
|
13
|
+
// tests the test files that reach a symbol — by CALL (the graph) and by
|
|
14
|
+
// NAME (`x.test.js` beside `x.js`). Both, because a test that
|
|
15
|
+
// imports a module without naming a symbol is still its test.
|
|
16
|
+
// cochange the files that historically change together with this one. The
|
|
17
|
+
// planner's question "what else will I have to touch" is answered
|
|
18
|
+
// by history far better than by static edges.
|
|
19
|
+
//
|
|
20
|
+
// THE RULE THAT SHAPES ALL FOUR: a signal is a HINT with its evidence attached.
|
|
21
|
+
// `changes` says WHY a symbol is high risk (exported, fan-in 4, no test caller),
|
|
22
|
+
// never just "high". A number nobody can check is a number nobody should act on.
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const { spawnSync } = require("child_process");
|
|
27
|
+
const G = require("./graph.js");
|
|
28
|
+
|
|
29
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
30
|
+
// A merge or a sweeping rename touches hundreds of files and would make every
|
|
31
|
+
// pair look related. CBM's threshold, and the same reason: a commit that broad
|
|
32
|
+
// carries no information about which two files belong together.
|
|
33
|
+
const COCHANGE_MAX_FILES = 20;
|
|
34
|
+
const COCHANGE_MIN = 3;
|
|
35
|
+
const COCHANGE_SINCE = "6.months";
|
|
36
|
+
const COCHANGE_ROWS = 12;
|
|
37
|
+
|
|
38
|
+
function git(root, args) {
|
|
39
|
+
return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const norm = (p) => String(p).split("\\").join("/").replace(/^\.\//, "");
|
|
43
|
+
|
|
44
|
+
// ── importance ──────────────────────────────────────────────────────────────
|
|
45
|
+
// sqrt of the confident in-edges, then knocked down by an order of magnitude for
|
|
46
|
+
// each thing that makes a symbol less likely to be what you are looking for.
|
|
47
|
+
// The square root matters: a symbol with 100 callers is not ten times more
|
|
48
|
+
// important than one with 10, it is a utility.
|
|
49
|
+
function importance(model, sym, fanIn) {
|
|
50
|
+
let v = Math.sqrt(Math.max(0, fanIn));
|
|
51
|
+
if (!sym.exported) v *= 0.1;
|
|
52
|
+
// A name defined in five or more files is a convention (`handler`, `index`,
|
|
53
|
+
// `run`), not a landmark.
|
|
54
|
+
const sameName = (model.byName.get(sym.name) || []).length;
|
|
55
|
+
if (sameName >= 5) v *= 0.1;
|
|
56
|
+
if (require("./graph-query.js").TEST_FILE.test(sym.file)) v *= 0.1;
|
|
57
|
+
return Math.round(v * 1000) / 1000;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── tests ───────────────────────────────────────────────────────────────────
|
|
61
|
+
// The naming half. `src/order.js` is tested by `order.test.js`, `test_order.py`,
|
|
62
|
+
// `order_test.go`, `OrderTest.java` — wherever they live.
|
|
63
|
+
function testsByName(model, rel) {
|
|
64
|
+
const base = path.posix.basename(rel).replace(/\.[^.]+$/, "");
|
|
65
|
+
if (!base) return [];
|
|
66
|
+
const want = new Set([
|
|
67
|
+
`${base}.test`,
|
|
68
|
+
`${base}.spec`,
|
|
69
|
+
`test_${base}`,
|
|
70
|
+
`${base}_test`,
|
|
71
|
+
`${base}Test`,
|
|
72
|
+
`${base}Tests`,
|
|
73
|
+
]);
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const f of model.fileSet) {
|
|
76
|
+
if (f === rel) continue;
|
|
77
|
+
const stem = path.posix.basename(f).replace(/\.[^.]+$/, "");
|
|
78
|
+
if (want.has(stem)) out.push(f);
|
|
79
|
+
}
|
|
80
|
+
return out.sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── changes ─────────────────────────────────────────────────────────────────
|
|
84
|
+
// exit 0 answered · 1 no graph or not a git repository
|
|
85
|
+
//
|
|
86
|
+
// `git diff -U0` gives the hunks. A symbol whose line range overlaps a hunk was
|
|
87
|
+
// really touched; a symbol elsewhere in the same file was not, and saying it was
|
|
88
|
+
// is what makes a whole-file `impact` noisy at review time.
|
|
89
|
+
function parseDiff(text) {
|
|
90
|
+
const byFile = new Map();
|
|
91
|
+
let cur = null;
|
|
92
|
+
for (const line of String(text).split("\n")) {
|
|
93
|
+
const f = /^\+\+\+ b\/(.*)$/.exec(line);
|
|
94
|
+
if (f) {
|
|
95
|
+
cur = f[1] === "/dev/null" ? null : norm(f[1]);
|
|
96
|
+
if (cur && !byFile.has(cur)) byFile.set(cur, []);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!cur) continue;
|
|
100
|
+
const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
101
|
+
if (h) {
|
|
102
|
+
const start = Number(h[1]);
|
|
103
|
+
const count = h[2] === undefined ? 1 : Number(h[2]);
|
|
104
|
+
// A pure deletion has a zero-length new side; it still touched the line it
|
|
105
|
+
// was removed from, so record it as a one-line hunk there.
|
|
106
|
+
byFile.get(cur).push(count === 0 ? [start, start] : [start, start + count - 1]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return byFile;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The paths this diff touches, by name only — two cheap git calls, no parsing.
|
|
113
|
+
// `changes` is a REVIEW-time command, so it heals on these before it answers:
|
|
114
|
+
// a reviewer told "nothing depends on this" by a stale index is worse served
|
|
115
|
+
// than one told nothing at all.
|
|
116
|
+
function changedPaths(root, base) {
|
|
117
|
+
const out = [];
|
|
118
|
+
const d = git(root, base ? ["diff", "--name-only", "-z", base, "--"] : ["diff", "--name-only", "-z", "HEAD", "--"]);
|
|
119
|
+
if (d.status === 0) for (const f of d.stdout.split("\0")) if (f) out.push(norm(f));
|
|
120
|
+
const u = git(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
121
|
+
if (u.status === 0) for (const f of u.stdout.split("\0")) if (f) out.push(norm(f));
|
|
122
|
+
return [...new Set(out)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function graphChanges(claudeDir, root, opts) {
|
|
126
|
+
const Q = require("./graph-query.js");
|
|
127
|
+
const model = Q.loadModel(claudeDir, root);
|
|
128
|
+
if (!model) return { ok: false, state: "none", reason: "no-index", exit: 1 };
|
|
129
|
+
const base = opts.base || null;
|
|
130
|
+
const d = git(root, base ? ["diff", "-U0", base, "--"] : ["diff", "-U0", "HEAD", "--"]);
|
|
131
|
+
if (d.error || d.status !== 0) return { ok: false, state: "unavailable", reason: "git-diff-failed", exit: 1 };
|
|
132
|
+
const hunks = parseDiff(d.stdout);
|
|
133
|
+
|
|
134
|
+
// An untracked file is a change too, and `git diff` never mentions it. Its
|
|
135
|
+
// whole body is the hunk.
|
|
136
|
+
const un = git(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
137
|
+
if (un.status === 0) {
|
|
138
|
+
for (const rel of un.stdout.split("\0")) {
|
|
139
|
+
if (!rel) continue;
|
|
140
|
+
const r = norm(rel);
|
|
141
|
+
if (!hunks.has(r)) hunks.set(r, [[1, Number.MAX_SAFE_INTEGER]]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rows = [];
|
|
146
|
+
const files = [...hunks.keys()].filter((f) => model.fileSet.has(f)).sort();
|
|
147
|
+
const indexable = new Set(Object.keys(G.LANG_BY_EXT));
|
|
148
|
+
const absent = [...hunks.keys()]
|
|
149
|
+
.filter((f) => !model.fileSet.has(f) && indexable.has(path.posix.extname(f).toLowerCase()))
|
|
150
|
+
.sort();
|
|
151
|
+
for (const rel of files) {
|
|
152
|
+
const ranges = hunks.get(rel);
|
|
153
|
+
for (const sym of (model.byFile[rel] || {}).symbols || []) {
|
|
154
|
+
if (sym.kind === "module") continue;
|
|
155
|
+
const touched = ranges.some((h) => sym.lines[0] <= h[1] && h[0] <= sym.lines[1]);
|
|
156
|
+
if (!touched) continue;
|
|
157
|
+
const c = Q.callersOf(model, sym);
|
|
158
|
+
const callerFiles = [...new Set(c.confident.map((x) => x.sym.file))];
|
|
159
|
+
const tests = [...new Set([...callerFiles.filter((f) => Q.TEST_FILE.test(f)), ...testsByName(model, rel)])];
|
|
160
|
+
// The reason is part of the answer. "high" with nothing behind it is a
|
|
161
|
+
// number nobody can check, and nobody should act on.
|
|
162
|
+
const why = [];
|
|
163
|
+
if (sym.exported) why.push("exported");
|
|
164
|
+
why.push(`fan-in ${c.confident.length}`);
|
|
165
|
+
if (!tests.length) why.push("no test reaches it");
|
|
166
|
+
if (c.maybe) why.push(`${c.maybe} ambiguous caller(s) not counted`);
|
|
167
|
+
const risk = sym.exported && c.confident.length >= 3 && !tests.length ? "high" : sym.exported || c.confident.length >= 1 ? "medium" : "low";
|
|
168
|
+
rows.push({
|
|
169
|
+
id: sym.id,
|
|
170
|
+
qname: sym.qname,
|
|
171
|
+
kind: sym.kind,
|
|
172
|
+
file: rel,
|
|
173
|
+
lines: sym.lines,
|
|
174
|
+
exported: !!sym.exported,
|
|
175
|
+
fan_in: c.confident.length,
|
|
176
|
+
maybe_callers: c.maybe,
|
|
177
|
+
caller_files: callerFiles.sort(),
|
|
178
|
+
tests: tests.sort(),
|
|
179
|
+
risk,
|
|
180
|
+
why,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
185
|
+
rows.sort((a, b) => order[a.risk] - order[b.risk] || b.fan_in - a.fan_in || a.file.localeCompare(b.file) || a.lines[0] - b.lines[0]);
|
|
186
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
187
|
+
for (const r of rows) counts[r.risk]++;
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
state: "found",
|
|
191
|
+
base: base || "HEAD",
|
|
192
|
+
files,
|
|
193
|
+
not_in_graph: absent,
|
|
194
|
+
symbols: rows,
|
|
195
|
+
counts,
|
|
196
|
+
generation: model.meta.generation || 0,
|
|
197
|
+
exit: 0,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── cochange ────────────────────────────────────────────────────────────────
|
|
202
|
+
// exit 0 answered · 1 no graph, or git could not answer · 4 nothing reaches the
|
|
203
|
+
// threshold (an ANSWER: this file changes alone)
|
|
204
|
+
//
|
|
205
|
+
// Cached per HEAD. History does not move under a working-tree edit, so the cache
|
|
206
|
+
// is valid for exactly as long as HEAD is.
|
|
207
|
+
function cochangePath(claudeDir) {
|
|
208
|
+
return path.join(G.graphPaths(claudeDir).dir, "cochange.json");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildCochange(root) {
|
|
212
|
+
const r = git(root, ["log", `--since=${COCHANGE_SINCE}`, "--name-only", "--pretty=format:%x00", "--no-merges"]);
|
|
213
|
+
if (r.error || r.status !== 0) return null;
|
|
214
|
+
const pairs = Object.create(null);
|
|
215
|
+
const total = Object.create(null);
|
|
216
|
+
for (const commit of r.stdout.split("\0")) {
|
|
217
|
+
const files = commit.split("\n").map((x) => x.trim()).filter(Boolean).map(norm);
|
|
218
|
+
const uniq = [...new Set(files)];
|
|
219
|
+
if (uniq.length < 2 || uniq.length > COCHANGE_MAX_FILES) continue;
|
|
220
|
+
for (const f of uniq) total[f] = (total[f] || 0) + 1;
|
|
221
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
222
|
+
for (let j = i + 1; j < uniq.length; j++) {
|
|
223
|
+
const k = uniq[i] < uniq[j] ? `${uniq[i]}\u0000${uniq[j]}` : `${uniq[j]}\u0000${uniq[i]}`;
|
|
224
|
+
pairs[k] = (pairs[k] || 0) + 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { pairs, total };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function graphCochange(claudeDir, root, opts) {
|
|
232
|
+
const rel = norm(opts.file || "");
|
|
233
|
+
if (!rel) return { ok: false, reason: "missing-operand", exit: 1 };
|
|
234
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
235
|
+
if (head.status !== 0) return { ok: false, state: "unavailable", reason: "not-git", exit: 1 };
|
|
236
|
+
const at = head.stdout.trim();
|
|
237
|
+
|
|
238
|
+
let cache = null;
|
|
239
|
+
try {
|
|
240
|
+
cache = JSON.parse(fs.readFileSync(cochangePath(claudeDir), "utf8"));
|
|
241
|
+
} catch (_) {}
|
|
242
|
+
if (!cache || cache.head !== at || cache.schema !== 1) {
|
|
243
|
+
const built = buildCochange(root);
|
|
244
|
+
if (!built) return { ok: false, state: "unavailable", reason: "git-log-failed", exit: 1 };
|
|
245
|
+
cache = { schema: 1, head: at, since: COCHANGE_SINCE, ...built };
|
|
246
|
+
try {
|
|
247
|
+
G.atomicWrite(cochangePath(claudeDir), JSON.stringify(cache));
|
|
248
|
+
} catch (_) {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const rows = [];
|
|
252
|
+
const pre = rel + "\u0000";
|
|
253
|
+
const suf = "\u0000" + rel;
|
|
254
|
+
for (const [k, n] of Object.entries(cache.pairs || {})) {
|
|
255
|
+
if (n < COCHANGE_MIN) continue;
|
|
256
|
+
let other = null;
|
|
257
|
+
if (k.startsWith(pre)) other = k.slice(pre.length);
|
|
258
|
+
else if (k.endsWith(suf)) other = k.slice(0, k.length - suf.length);
|
|
259
|
+
if (!other) continue;
|
|
260
|
+
const own = (cache.total || {})[rel] || 0;
|
|
261
|
+
rows.push({ file: other, together: n, of: own, share: own ? Math.round((n / own) * 100) / 100 : 0 });
|
|
262
|
+
}
|
|
263
|
+
rows.sort((a, b) => b.together - a.together || a.file.localeCompare(b.file));
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
state: rows.length ? "found" : "none",
|
|
267
|
+
file: rel,
|
|
268
|
+
commits: (cache.total || {})[rel] || 0,
|
|
269
|
+
since: cache.since,
|
|
270
|
+
min: COCHANGE_MIN,
|
|
271
|
+
rows: rows.slice(0, COCHANGE_ROWS),
|
|
272
|
+
more: Math.max(0, rows.length - COCHANGE_ROWS),
|
|
273
|
+
exit: rows.length ? 0 : 4,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { importance, testsByName, parseDiff, changedPaths, graphChanges, graphCochange, cochangePath, COCHANGE_MIN, COCHANGE_MAX_FILES };
|