@mmerterden/multi-agent-pipeline 20.20.0 → 20.22.0
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 +58 -0
- package/README.md +24 -13
- package/README.tr.md +21 -10
- package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/ecosystem.md +7 -5
- package/docs/facts.json +6 -4
- package/docs/features.md +1 -0
- package/install/catalog-history.json +1 -1
- package/install/templates/copilot-instructions.md +1 -0
- package/manifest.json +71 -64
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/route-on/SKILL.md +32 -8
- package/pipeline/commands/multi-agent/route-status/SKILL.md +12 -2
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -16
- package/pipeline/lib/model-dispatch.sh +72 -24
- package/pipeline/lib/outbound-gate.mjs +2 -0
- package/pipeline/lib/route-state.sh +118 -10
- package/pipeline/lib/toolkit-llm.sh +30 -0
- package/pipeline/lib/unattended-profile-check.mjs +23 -2
- package/pipeline/multi-agent-refs/_dev-context.md +15 -0
- package/pipeline/multi-agent-refs/analysis/evidence.md +7 -3
- package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +9 -3
- package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/free-models.md +111 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -15
- package/pipeline/multi-agent-refs/features/product-context.md +238 -0
- package/pipeline/multi-agent-refs/features/unattended-security.md +4 -2
- package/pipeline/multi-agent-refs/knowledge.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +7 -12
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
- package/pipeline/multi-agent-refs/platform-parity.md +23 -9
- package/pipeline/multi-agent-refs/setup/free-models.md +22 -0
- package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/agent-state.schema.json +31 -0
- package/pipeline/schemas/analysis-spec.schema.json +14 -0
- package/pipeline/schemas/code-graph.schema.json +12 -1
- package/pipeline/schemas/diff-risk.schema.json +2 -1
- package/pipeline/schemas/prefs.schema.json +68 -1
- package/pipeline/schemas/route-config.schema.json +33 -8
- package/pipeline/schemas/secret-patterns.json +14 -0
- package/pipeline/scripts/_code-graph.mjs +76 -3
- package/pipeline/scripts/_diff-impact.mjs +312 -0
- package/pipeline/scripts/bulk-read.sh +98 -12
- package/pipeline/scripts/capture-flush.sh +58 -3
- package/pipeline/scripts/cost-table.json +9 -1
- package/pipeline/scripts/diff-risk-score.mjs +80 -9
- package/pipeline/scripts/doctor.mjs +100 -0
- package/pipeline/scripts/graph-affected.mjs +167 -36
- package/pipeline/scripts/graph-query.mjs +16 -4
- package/pipeline/scripts/pre-commit-check.sh +1 -1
- package/pipeline/scripts/product-context.mjs +539 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
- package/pipeline/scripts/unattended_policy.py +20 -0
- package/pipeline/scripts/validate-code-graph.mjs +2 -1
- package/pipeline/scripts/validate-diff-risk.mjs +1 -0
- package/pipeline/skills/.skill-manifest.json +6 -6
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +2 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +31 -7
- package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +12 -2
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +9 -6
|
@@ -16,11 +16,18 @@
|
|
|
16
16
|
* ui_critical - *View.swift / *Screen.kt / Configuration w=1.5
|
|
17
17
|
* migration - DB schema / migration path w=4.0
|
|
18
18
|
* test_lines_removed - test file shrinks (removed > added) w=3.0
|
|
19
|
+
* dependents_reached - distinct non-test files that depend on the w=2.0
|
|
20
|
+
* changed declarations, two rings out, from
|
|
21
|
+
* the code graph; only with --graph, capped
|
|
22
|
+
* at 20 so one hub cannot outweigh a
|
|
23
|
+
* security path
|
|
19
24
|
*
|
|
20
25
|
* Inputs:
|
|
21
26
|
* --base <ref> Base ref. Default: origin/main, fallback: main
|
|
22
27
|
* --head <ref> Head ref. Default: HEAD
|
|
23
28
|
* --diff <path> Pre-computed diff file (skip git invocation)
|
|
29
|
+
* --graph <path> Code graph (graph-build.mjs). Adds dependents_reached;
|
|
30
|
+
* an unreadable graph is reported on stderr and skipped
|
|
24
31
|
* --root <path> Repo root for git -C. Default: cwd
|
|
25
32
|
* --task-id <id> Tag the output with a task id (no functional effect)
|
|
26
33
|
* --top <N> Truncate `files` to top N. Default: keep all,
|
|
@@ -40,7 +47,9 @@
|
|
|
40
47
|
|
|
41
48
|
import { readFileSync, existsSync } from "node:fs";
|
|
42
49
|
import { execFileSync } from "node:child_process";
|
|
43
|
-
import { unquoteGitPath
|
|
50
|
+
import { unquoteGitPath } from "./git-path.mjs";
|
|
51
|
+
import { affected } from "./graph-affected.mjs";
|
|
52
|
+
import { parseDiffFiles, changedSymbols, graphSide } from "./_diff-impact.mjs";
|
|
44
53
|
|
|
45
54
|
const argv = process.argv.slice(2);
|
|
46
55
|
const flags = {};
|
|
@@ -65,6 +74,9 @@ const DIFF_FILE = flags.diff || null;
|
|
|
65
74
|
const TASK_ID = flags["task-id"] || null;
|
|
66
75
|
const TOP = flags.top ? Number(flags.top) : null;
|
|
67
76
|
const PRETTY = flags.json === true;
|
|
77
|
+
const GRAPH_FILE = flags.graph && flags.graph !== true ? flags.graph : null;
|
|
78
|
+
const DEPENDENTS_CAP = 20;
|
|
79
|
+
const DEPENDENTS_DEPTH = 2;
|
|
68
80
|
|
|
69
81
|
function git(args) {
|
|
70
82
|
return execFileSync("git", ["-C", ROOT, ...args], { encoding: "utf8" }).trim();
|
|
@@ -95,7 +107,7 @@ function loadDiff() {
|
|
|
95
107
|
if (!existsSync(DIFF_FILE)) throw new Error(`diff file not found: ${DIFF_FILE}`);
|
|
96
108
|
return readFileSync(DIFF_FILE, "utf8");
|
|
97
109
|
}
|
|
98
|
-
return git(["diff", "--unified=0", `${BASE}...${HEAD}`]);
|
|
110
|
+
return git(["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", `${BASE}...${HEAD}`]);
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
function loadNumstat() {
|
|
@@ -118,16 +130,30 @@ function loadNumstat() {
|
|
|
118
130
|
}
|
|
119
131
|
return Array.from(map.entries()).map(([path, v]) => ({ path, ...v }));
|
|
120
132
|
}
|
|
121
|
-
|
|
133
|
+
// -z: paths are raw (never quoted), and a rename is `A\tR\t\0old\0new\0`,
|
|
134
|
+
// so the new path is read directly instead of parsed out of `{a => b}`.
|
|
135
|
+
const out = execFileSync("git", ["-C", ROOT, "diff", "--numstat", "-z", `${BASE}...${HEAD}`], {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
});
|
|
122
138
|
if (!out) return [];
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
const fields = out.split("\0");
|
|
140
|
+
const rows = [];
|
|
141
|
+
for (let i = 0; i < fields.length; i++) {
|
|
142
|
+
const head = fields[i];
|
|
143
|
+
if (!head) continue;
|
|
144
|
+
const [a, r, path] = head.split("\t");
|
|
145
|
+
let name = path;
|
|
146
|
+
if (path === "") {
|
|
147
|
+
name = fields[i + 2];
|
|
148
|
+
i += 2;
|
|
149
|
+
}
|
|
150
|
+
rows.push({
|
|
151
|
+
path: name,
|
|
127
152
|
added: a === "-" ? 0 : Number(a) || 0,
|
|
128
153
|
removed: r === "-" ? 0 : Number(r) || 0,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
const SECURITY_GLOBS = [
|
|
@@ -332,6 +358,47 @@ function buildRow(stat, addedLines, allChangedPaths) {
|
|
|
332
358
|
};
|
|
333
359
|
}
|
|
334
360
|
|
|
361
|
+
function loadGraphOrNull(path) {
|
|
362
|
+
try {
|
|
363
|
+
const graph = JSON.parse(readFileSync(path, "utf8"));
|
|
364
|
+
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges))
|
|
365
|
+
throw new Error("not a code graph");
|
|
366
|
+
return graph;
|
|
367
|
+
} catch (e) {
|
|
368
|
+
process.stderr.write(
|
|
369
|
+
`diff-risk-score: graph unreadable at ${path}, dependents_reached skipped: ${e.message}\n`,
|
|
370
|
+
);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Add dependents_reached to each row whose changed declarations have
|
|
377
|
+
* dependents in the graph. Test files are not counted as dependents: a change
|
|
378
|
+
* that only its own tests use is not wide.
|
|
379
|
+
*/
|
|
380
|
+
function addGraphSignal(rows, graph, diffText, root) {
|
|
381
|
+
const side = graphSide(graph, { root });
|
|
382
|
+
const diffFiles = parseDiffFiles(diffText);
|
|
383
|
+
for (const row of rows) {
|
|
384
|
+
const f = diffFiles.find((x) => (x.newPath || x.oldPath) === row.path);
|
|
385
|
+
if (!f) continue;
|
|
386
|
+
const path = side === "old" ? f.oldPath || f.newPath : f.newPath || f.oldPath;
|
|
387
|
+
const r = side === "old" ? f.oldRanges : f.newRanges;
|
|
388
|
+
if (!r.length) continue;
|
|
389
|
+
const { seeds } = changedSymbols(graph, new Map([[path, r]]));
|
|
390
|
+
if (!seeds.length) continue;
|
|
391
|
+
const hits = affected({ graph, seeds, depth: DEPENDENTS_DEPTH });
|
|
392
|
+
const files = new Set(
|
|
393
|
+
hits.filter((h) => h.node?.path && !h.node.isTest).map((h) => h.node.path),
|
|
394
|
+
);
|
|
395
|
+
if (!files.size) continue;
|
|
396
|
+
const w = 2.0;
|
|
397
|
+
row.signals.push({ name: "dependents_reached", weight: w, value: files.size });
|
|
398
|
+
row.score = Math.round((row.score + Math.min(files.size, DEPENDENTS_CAP) * 2 * w) * 100) / 100;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
335
402
|
function main() {
|
|
336
403
|
// Resolve the base ref ONCE, before any git diff runs. loadNumstat() and
|
|
337
404
|
// loadDiff() both interpolate BASE; resolving it lazily inside loadDiff()
|
|
@@ -348,6 +415,10 @@ function main() {
|
|
|
348
415
|
const rows = numstat.map((stat) =>
|
|
349
416
|
buildRow(stat, perFileAdded.get(stat.path) || [], allChangedPaths),
|
|
350
417
|
);
|
|
418
|
+
if (GRAPH_FILE) {
|
|
419
|
+
const graph = loadGraphOrNull(GRAPH_FILE);
|
|
420
|
+
if (graph) addGraphSignal(rows, graph, diffText, ROOT);
|
|
421
|
+
}
|
|
351
422
|
rows.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
352
423
|
|
|
353
424
|
const totals = {
|
|
@@ -104,6 +104,7 @@ const CHECK_IDS = [
|
|
|
104
104
|
"disk-space",
|
|
105
105
|
"worktree-residue",
|
|
106
106
|
"usage-report",
|
|
107
|
+
"free-models",
|
|
107
108
|
"xcode-skills",
|
|
108
109
|
];
|
|
109
110
|
|
|
@@ -935,6 +936,104 @@ function checkUsageReport() {
|
|
|
935
936
|
);
|
|
936
937
|
}
|
|
937
938
|
|
|
939
|
+
// route-on --free promises that simple tasks leave Claude. With no usable
|
|
940
|
+
// provider every one of them silently escalates back, which costs nothing but
|
|
941
|
+
// saves nothing either - the user believes a control is working that is not.
|
|
942
|
+
// --probe sends each configured provider one tiny request, so a dead key or an
|
|
943
|
+
// exhausted quota shows up here instead of as a week of escalations.
|
|
944
|
+
function checkFreeModels() {
|
|
945
|
+
const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
|
|
946
|
+
const routing = prefs?.global?.modelRouting;
|
|
947
|
+
if (routing?.enabled !== true || routing?.free?.enabled !== true) {
|
|
948
|
+
skip("free-models", "free-model routing is off");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const lib = [
|
|
952
|
+
join(HERE, "..", "lib", "toolkit-llm.sh"),
|
|
953
|
+
join(CLAUDE, "lib", "toolkit-llm.sh"),
|
|
954
|
+
].find((p) => existsSync(p));
|
|
955
|
+
if (!lib) {
|
|
956
|
+
report(
|
|
957
|
+
"free-models",
|
|
958
|
+
"WARN",
|
|
959
|
+
"route-on --free is on but lib/toolkit-llm.sh is not installed, so every free task escalates to Claude",
|
|
960
|
+
`run ${hostCommand("update", HERE)}`,
|
|
961
|
+
);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const train = routing.free.allowTraining === true ? ["--allow-training"] : [];
|
|
965
|
+
const run = (args) =>
|
|
966
|
+
JSON.parse(
|
|
967
|
+
execFileSync("bash", ["-c", '. "$1"; shift; ma_toolkit_llm "$@"', "_", lib, ...args], {
|
|
968
|
+
encoding: "utf8",
|
|
969
|
+
timeout: PROBE ? 180000 : 60000,
|
|
970
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
971
|
+
}),
|
|
972
|
+
);
|
|
973
|
+
let listing;
|
|
974
|
+
try {
|
|
975
|
+
listing = run(["providers", ...train]);
|
|
976
|
+
} catch {
|
|
977
|
+
report(
|
|
978
|
+
"free-models",
|
|
979
|
+
"WARN",
|
|
980
|
+
"route-on --free is on but the multi-agent-toolkit CLI could not be run (node and npx are needed), so every free task escalates to Claude",
|
|
981
|
+
"run npx -y --@mmerterden:registry=https://registry.npmjs.org -p @mmerterden/multi-agent-toolkit-mcp@latest multi-agent-toolkit-llm providers",
|
|
982
|
+
);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const usable = (listing.providers || []).filter((p) => p.configured && p.allowed_by_policy);
|
|
986
|
+
const detail = (listing.providers || []).map(
|
|
987
|
+
(p) =>
|
|
988
|
+
`${p.id}: ${p.configured ? (p.allowed_by_policy ? `ready (${p.key_source})` : `configured, outside the ${listing.data_policy} policy`) : `missing - ${p.setup}`}`,
|
|
989
|
+
);
|
|
990
|
+
if (!usable.length) {
|
|
991
|
+
report(
|
|
992
|
+
"free-models",
|
|
993
|
+
"WARN",
|
|
994
|
+
`route-on --free is on but no free provider is usable under the ${listing.data_policy} data policy, so every free task escalates to Claude`,
|
|
995
|
+
'run bash "$HOME/.claude/lib/credential-store.sh" set llm-groq - and paste a free key from https://console.groq.com/keys',
|
|
996
|
+
detail,
|
|
997
|
+
);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (!PROBE) {
|
|
1001
|
+
report("free-models", "OK", null, null, detail);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
let probed;
|
|
1005
|
+
try {
|
|
1006
|
+
probed = run(["probe"]);
|
|
1007
|
+
} catch {
|
|
1008
|
+
report(
|
|
1009
|
+
"free-models",
|
|
1010
|
+
"WARN",
|
|
1011
|
+
"the free-provider probe could not run",
|
|
1012
|
+
"re-run with --probe once the network is reachable",
|
|
1013
|
+
detail,
|
|
1014
|
+
);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const dead = probed.filter((r) => usable.some((u) => u.id === r.provider) && r.outcome !== "ok");
|
|
1018
|
+
if (dead.length === usable.length) {
|
|
1019
|
+
report(
|
|
1020
|
+
"free-models",
|
|
1021
|
+
"WARN",
|
|
1022
|
+
`every usable free provider failed a probe (${dead.map((r) => `${r.provider}: ${r.outcome}`).join(", ")}), so free tasks escalate to Claude until one recovers`,
|
|
1023
|
+
"set a fresh key for the failing provider with the credential store, or add a second provider",
|
|
1024
|
+
probed.map((r) => `${r.provider}: ${r.outcome}${r.reason ? ` (${r.reason})` : ""}`),
|
|
1025
|
+
);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
report(
|
|
1029
|
+
"free-models",
|
|
1030
|
+
"OK",
|
|
1031
|
+
null,
|
|
1032
|
+
null,
|
|
1033
|
+
probed.map((r) => `${r.provider}: ${r.outcome}${r.reason ? ` (${r.reason})` : ""}`),
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
938
1037
|
// Apple's Xcode skills are the reference for iPhone Duo and window resizing,
|
|
939
1038
|
// SwiftUI, App Intents and the accessibility audits, and Claude Code reads them
|
|
940
1039
|
// from a copy the installer keeps at the installed Xcode build. A newer Xcode
|
|
@@ -1263,6 +1362,7 @@ function main() {
|
|
|
1263
1362
|
checkDiskSpace();
|
|
1264
1363
|
checkWorktreeResidue();
|
|
1265
1364
|
checkUsageReport();
|
|
1365
|
+
checkFreeModels();
|
|
1266
1366
|
checkXcodeSkills();
|
|
1267
1367
|
if (PROFILE === "server") checkServerProfile(resolved);
|
|
1268
1368
|
|
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* radius of a change rather than its neighbourhood.
|
|
10
10
|
*
|
|
11
11
|
* Inputs:
|
|
12
|
-
* "<symbol>" Symbol name, exact or case-insensitive. Required
|
|
12
|
+
* "<symbol>" Symbol name, exact or case-insensitive. Required unless
|
|
13
|
+
* --diff or --diff-file is given.
|
|
14
|
+
* --diff <spec> Seed from a change instead: `<base>` (working tree against
|
|
15
|
+
* base) or `<base>..<head>`. Hunks map onto the innermost
|
|
16
|
+
* declarations they touch, read on the side of the diff
|
|
17
|
+
* the graph was built from (_diff-impact.mjs graphSide).
|
|
18
|
+
* --diff-file <p> The same, from a saved unified diff.
|
|
19
|
+
* --root <path> Repo for --diff. Default: the root recorded in the graph
|
|
13
20
|
* --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
|
|
14
21
|
* --depth N Reverse traversal depth. Default: 2
|
|
15
22
|
* --kind K Restrict to one edge kind (repeatable via comma)
|
|
@@ -24,8 +31,16 @@
|
|
|
24
31
|
*/
|
|
25
32
|
|
|
26
33
|
import { resolve } from "node:path";
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
27
35
|
import { parseFlags } from "./graph-build.mjs";
|
|
28
36
|
import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
|
|
37
|
+
import {
|
|
38
|
+
parseHunkRanges,
|
|
39
|
+
changedSymbols,
|
|
40
|
+
graphFreshness,
|
|
41
|
+
graphSide,
|
|
42
|
+
diffFor,
|
|
43
|
+
} from "./_diff-impact.mjs";
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
46
|
* Nodes whose name matches, preferring an exact hit.
|
|
@@ -62,37 +77,155 @@ export function incoming(graph, kinds = null) {
|
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
/**
|
|
65
|
-
* Walk backwards from each seed.
|
|
80
|
+
* Walk backwards from each seed, over files.
|
|
81
|
+
*
|
|
82
|
+
* Dependents are files: a file depends on a target when it references a
|
|
83
|
+
* symbol or imports a file. The walk continues through what a dependent file
|
|
84
|
+
* itself declares, because the next ring depends on those declarations, not on
|
|
85
|
+
* the file node - references point from files to symbols, so a walk that only
|
|
86
|
+
* followed a file's own incoming edges would stop after one ring. `defines`
|
|
87
|
+
* is never a dependency, and the files that define the seeds are never
|
|
88
|
+
* reported as their own dependents.
|
|
66
89
|
*
|
|
67
90
|
* @param {object} params
|
|
91
|
+
* @param {object} params.graph
|
|
92
|
+
* @param {object[]} params.seeds - symbol or file nodes
|
|
93
|
+
* @param {number} params.depth
|
|
94
|
+
* @param {Set<string>|null} [params.kinds] - dependency edge kinds to follow
|
|
68
95
|
* @returns {Array<{id: string, dist: number, kind: string, node: object}>}
|
|
69
96
|
*/
|
|
70
|
-
export function affected({ graph, seeds, depth, kinds }) {
|
|
97
|
+
export function affected({ graph, seeds, depth, kinds = null }) {
|
|
71
98
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
72
99
|
const adj = incoming(graph, kinds);
|
|
73
|
-
const
|
|
100
|
+
const declares = new Map();
|
|
101
|
+
const definer = new Map();
|
|
102
|
+
for (const e of graph.edges) {
|
|
103
|
+
if (e.kind !== "defines") continue;
|
|
104
|
+
if (!declares.has(e.from)) declares.set(e.from, []);
|
|
105
|
+
declares.get(e.from).push(e.to);
|
|
106
|
+
definer.set(e.to, e.from);
|
|
107
|
+
}
|
|
108
|
+
const expand = (node) =>
|
|
109
|
+
node.kind === "file"
|
|
110
|
+
? [node.id, ...(declares.get(node.id) || [])]
|
|
111
|
+
: [node.id, ...(definer.has(node.id) ? [definer.get(node.id)] : [])];
|
|
112
|
+
|
|
113
|
+
const seedFiles = new Set(
|
|
114
|
+
seeds.map((n) => (n.kind === "file" ? n.id : definer.get(n.id))).filter(Boolean),
|
|
115
|
+
);
|
|
116
|
+
const walked = new Set();
|
|
117
|
+
let frontier = [];
|
|
118
|
+
for (const seed of seeds)
|
|
119
|
+
for (const id of expand(seed)) if (!walked.has(id)) walked.add(id) && frontier.push(id);
|
|
120
|
+
|
|
121
|
+
const reported = new Set();
|
|
74
122
|
const out = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
123
|
+
for (let dist = 1; dist <= depth && frontier.length; dist++) {
|
|
124
|
+
const next = [];
|
|
125
|
+
for (const target of frontier) {
|
|
126
|
+
for (const dep of adj.get(target) || []) {
|
|
127
|
+
if (dep.kind === "defines" || seedFiles.has(dep.id) || reported.has(dep.id)) continue;
|
|
128
|
+
const node = byId.get(dep.id);
|
|
129
|
+
if (!node) continue;
|
|
130
|
+
reported.add(dep.id);
|
|
131
|
+
out.push({ id: dep.id, dist, kind: dep.kind, node });
|
|
132
|
+
for (const id of expand(node)) if (!walked.has(id)) walked.add(id) && next.push(id);
|
|
133
|
+
}
|
|
85
134
|
}
|
|
135
|
+
frontier = next;
|
|
86
136
|
}
|
|
87
137
|
return out;
|
|
88
138
|
}
|
|
89
139
|
|
|
140
|
+
function printJson(obj) {
|
|
141
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hitRows(hits) {
|
|
145
|
+
return hits.map((h) => ({
|
|
146
|
+
id: h.id,
|
|
147
|
+
dist: h.dist,
|
|
148
|
+
edgeKind: h.kind,
|
|
149
|
+
path: h.node?.path || null,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function freshnessLine(f) {
|
|
154
|
+
if (!f.stale) return "graph: current";
|
|
155
|
+
const parts = [];
|
|
156
|
+
if (f.commitMoved)
|
|
157
|
+
parts.push(
|
|
158
|
+
`built at ${String(f.baseCommit).slice(0, 12)}, HEAD is ${String(f.head).slice(0, 12)}`,
|
|
159
|
+
);
|
|
160
|
+
if (f.changedFiles.length) parts.push(`${f.changedFiles.length} file(s) edited since the build`);
|
|
161
|
+
if (f.removedFiles.length) parts.push(`${f.removedFiles.length} file(s) removed`);
|
|
162
|
+
return `graph: STALE (${parts.join("; ")}) - rebuild with graph-build.mjs for a current answer`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runDiff(flags, graph, depth, kinds) {
|
|
166
|
+
const root = flags.root && flags.root !== true ? resolve(flags.root) : graph.root;
|
|
167
|
+
let text;
|
|
168
|
+
if (flags["diff-file"] && flags["diff-file"] !== true) {
|
|
169
|
+
text = readFileSync(resolve(flags["diff-file"]), "utf8");
|
|
170
|
+
} else {
|
|
171
|
+
text = diffFor(root, flags.diff);
|
|
172
|
+
}
|
|
173
|
+
const freshness = graphFreshness(graph, { root });
|
|
174
|
+
const side = graphSide(graph, { root });
|
|
175
|
+
const mapped = changedSymbols(graph, parseHunkRanges(text, side));
|
|
176
|
+
const hits = affected({ graph, seeds: mapped.seeds, depth, kinds });
|
|
177
|
+
const result = {
|
|
178
|
+
mode: "diff",
|
|
179
|
+
diff: flags["diff-file"] && flags["diff-file"] !== true ? flags["diff-file"] : flags.diff,
|
|
180
|
+
changedSymbols: mapped.changed.map((c) => ({
|
|
181
|
+
name: c.name,
|
|
182
|
+
path: c.path,
|
|
183
|
+
line: c.line,
|
|
184
|
+
endLine: c.endLine,
|
|
185
|
+
})),
|
|
186
|
+
seeds: mapped.seeds.map((s) => s.id),
|
|
187
|
+
fileLevel: mapped.fileLevel,
|
|
188
|
+
notIndexed: mapped.notIndexed,
|
|
189
|
+
affected: hitRows(hits),
|
|
190
|
+
side,
|
|
191
|
+
freshness,
|
|
192
|
+
};
|
|
193
|
+
if (flags.json === true) return printJson(result);
|
|
194
|
+
|
|
195
|
+
process.stdout.write(`# affected by diff ${result.diff}\n\n`);
|
|
196
|
+
for (const c of result.changedSymbols)
|
|
197
|
+
process.stdout.write(`* ${c.name} ${c.path}:${c.line}-${c.endLine}\n`);
|
|
198
|
+
for (const p of result.fileLevel)
|
|
199
|
+
process.stdout.write(`* ${p} (whole file: graph has no spans, rebuild for symbol level)\n`);
|
|
200
|
+
const files = new Set();
|
|
201
|
+
process.stdout.write("\n");
|
|
202
|
+
for (const hit of hits) {
|
|
203
|
+
if (hit.node?.path) files.add(hit.node.path);
|
|
204
|
+
process.stdout.write(
|
|
205
|
+
`${" ".repeat(hit.dist)}- ${hit.node?.name || hit.id} [${hit.kind}] ${hit.node?.path || "-"}\n`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
process.stdout.write(`\n${hits.length} dependents across ${files.size} files\n`);
|
|
209
|
+
if (result.notIndexed.length)
|
|
210
|
+
process.stdout.write(`not in the graph: ${result.notIndexed.join(", ")}\n`);
|
|
211
|
+
process.stdout.write(`${freshnessLine(freshness)}\n`);
|
|
212
|
+
}
|
|
213
|
+
|
|
90
214
|
function main() {
|
|
91
215
|
const { flags, positional } = parseFlags(process.argv.slice(2));
|
|
92
216
|
const name = positional.join(" ").trim();
|
|
93
|
-
|
|
217
|
+
const diffMode = flags.diff !== undefined || flags["diff-file"] !== undefined;
|
|
218
|
+
if (diffMode) {
|
|
219
|
+
const spec = flags["diff-file"] !== undefined ? flags["diff-file"] : flags.diff;
|
|
220
|
+
if (spec === true || String(spec).startsWith("-")) {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
"graph-affected: --diff takes <base> or <base>..<head>; --diff-file takes a path\n",
|
|
223
|
+
);
|
|
224
|
+
process.exit(64);
|
|
225
|
+
}
|
|
226
|
+
} else if (!name) {
|
|
94
227
|
process.stderr.write(
|
|
95
|
-
'graph-affected: a symbol name is required, e.g. graph-affected "Flight"
|
|
228
|
+
'graph-affected: a symbol name is required, e.g. graph-affected "Flight", or --diff <base>\n',
|
|
96
229
|
);
|
|
97
230
|
process.exit(64);
|
|
98
231
|
}
|
|
@@ -109,12 +242,20 @@ function main() {
|
|
|
109
242
|
const depth = Number(flags.depth) > 0 ? Number(flags.depth) : 2;
|
|
110
243
|
const kinds = flags.kind && flags.kind !== true ? new Set(String(flags.kind).split(",")) : null;
|
|
111
244
|
|
|
245
|
+
if (diffMode) {
|
|
246
|
+
try {
|
|
247
|
+
return runDiff(flags, graph, depth, kinds);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
process.stderr.write(`graph-affected: ${e.message}\n`);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
112
254
|
const seeds = findByName(graph, name);
|
|
255
|
+
const freshness = graphFreshness(graph);
|
|
113
256
|
if (seeds.length === 0) {
|
|
114
257
|
if (flags.json === true) {
|
|
115
|
-
|
|
116
|
-
JSON.stringify({ symbol: name, seeds: [], affected: [] }, null, 2) + "\n",
|
|
117
|
-
);
|
|
258
|
+
printJson({ symbol: name, seeds: [], affected: [], freshness });
|
|
118
259
|
} else {
|
|
119
260
|
process.stdout.write(`no node named '${name}'\n`);
|
|
120
261
|
}
|
|
@@ -124,23 +265,12 @@ function main() {
|
|
|
124
265
|
const hits = affected({ graph, seeds, depth, kinds });
|
|
125
266
|
|
|
126
267
|
if (flags.json === true) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
id: h.id,
|
|
134
|
-
dist: h.dist,
|
|
135
|
-
edgeKind: h.kind,
|
|
136
|
-
path: h.node?.path || null,
|
|
137
|
-
})),
|
|
138
|
-
},
|
|
139
|
-
null,
|
|
140
|
-
2,
|
|
141
|
-
) + "\n",
|
|
142
|
-
);
|
|
143
|
-
return;
|
|
268
|
+
return printJson({
|
|
269
|
+
symbol: name,
|
|
270
|
+
seeds: seeds.map((s) => s.id),
|
|
271
|
+
affected: hitRows(hits),
|
|
272
|
+
freshness,
|
|
273
|
+
});
|
|
144
274
|
}
|
|
145
275
|
|
|
146
276
|
process.stdout.write(`# affected by ${name}\n\n`);
|
|
@@ -156,6 +286,7 @@ function main() {
|
|
|
156
286
|
);
|
|
157
287
|
}
|
|
158
288
|
process.stdout.write(`\n${hits.length} dependents across ${files.size} files\n`);
|
|
289
|
+
process.stdout.write(`${freshnessLine(freshness)}\n`);
|
|
159
290
|
}
|
|
160
291
|
|
|
161
292
|
if (process.argv[1] && process.argv[1].endsWith("graph-affected.mjs")) main();
|
|
@@ -99,6 +99,20 @@ function renderNode(node) {
|
|
|
99
99
|
return `${node.name} (${kind}) ${where}`;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The line main() prints for a hit. The budget is charged for this, not for
|
|
104
|
+
* the bare node text, so the token count it reports is the one the caller pays.
|
|
105
|
+
*
|
|
106
|
+
* @param {number} dist
|
|
107
|
+
* @param {string} line
|
|
108
|
+
* @param {string|null} via
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function printedLine(dist, line, via) {
|
|
112
|
+
const prefix = dist === 0 ? "*" : " ".repeat(dist) + "-";
|
|
113
|
+
return `${prefix} ${line}${via ? ` [${via}]` : ""}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
/**
|
|
103
117
|
* Seed, then breadth-first expand until the budget is spent.
|
|
104
118
|
*
|
|
@@ -119,7 +133,7 @@ export function traverse({ graph, question, budget, depth, seedCount }) {
|
|
|
119
133
|
const queue = seeds.map((n) => ({ node: n, dist: 0, via: null }));
|
|
120
134
|
while (queue.length) {
|
|
121
135
|
const { node, dist, via } = queue.shift();
|
|
122
|
-
const cost = Math.ceil(renderNode(node).length / CHARS_PER_TOKEN);
|
|
136
|
+
const cost = Math.ceil(printedLine(dist, renderNode(node), via).length / CHARS_PER_TOKEN);
|
|
123
137
|
if (tokens + cost > budget) {
|
|
124
138
|
truncated = true;
|
|
125
139
|
break;
|
|
@@ -177,9 +191,7 @@ function main() {
|
|
|
177
191
|
return;
|
|
178
192
|
}
|
|
179
193
|
for (const hit of result.hits) {
|
|
180
|
-
|
|
181
|
-
const via = hit.via ? ` [${hit.via}]` : "";
|
|
182
|
-
process.stdout.write(`${prefix} ${hit.line}${via}\n`);
|
|
194
|
+
process.stdout.write(`${printedLine(hit.dist, hit.line, hit.via)}\n`);
|
|
183
195
|
}
|
|
184
196
|
process.stdout.write(
|
|
185
197
|
`\n${result.hits.length} nodes, ~${result.tokens} tokens` +
|
|
@@ -227,7 +227,7 @@ $folded"
|
|
|
227
227
|
|
|
228
228
|
# Model-provider and ML-hub keys. `sk-ant-` and `sk-proj-` are checked before
|
|
229
229
|
# the bare `sk-` form so the message names the provider a reader can revoke.
|
|
230
|
-
if echo "$content" | grep -qE 'sk-ant-[A-Za-z0-9_-]{32,}|sk-proj-[A-Za-z0-9_-]{32,}|pplx-[A-Za-z0-9]{32,}|hf_[A-Za-z0-9]{30,}'; then
|
|
230
|
+
if echo "$content" | grep -qE 'sk-ant-[A-Za-z0-9_-]{32,}|sk-proj-[A-Za-z0-9_-]{32,}|sk-or-v1-[A-Za-z0-9]{40,}|pplx-[A-Za-z0-9]{32,}|hf_[A-Za-z0-9]{30,}|gsk_[A-Za-z0-9]{40,}'; then
|
|
231
231
|
echo "BLOCKED: Model-provider API key in $file" >&2
|
|
232
232
|
FOUND=1
|
|
233
233
|
fi
|