@mmerterden/multi-agent-pipeline 16.12.0 → 16.14.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 +31 -0
- package/README.md +4 -4
- package/README.tr.md +4 -4
- package/docs/adr/0010-own-code-graph.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +2 -2
- package/docs/ecosystem.md +5 -5
- package/docs/features.md +8 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/graph/SKILL.md +105 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +8 -8
- package/pipeline/commands/multi-agent/sync/SKILL.md +12 -12
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +9 -7
- package/pipeline/multi-agent-refs/analysis/evidence.md +21 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -10
- package/pipeline/multi-agent-refs/features/code-graph.md +69 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +44 -2
- package/pipeline/multi-agent-refs/knowledge.md +6 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +5 -0
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +3 -3
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -0
- package/pipeline/preferences-template.json +2 -0
- package/pipeline/schemas/code-graph.schema.json +91 -0
- package/pipeline/schemas/prefs.schema.json +45 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/_code-graph.mjs +545 -0
- package/pipeline/scripts/_path-match.mjs +87 -0
- package/pipeline/scripts/code-graph-rules/android.json +130 -0
- package/pipeline/scripts/code-graph-rules/go.json +124 -0
- package/pipeline/scripts/code-graph-rules/ios.json +95 -0
- package/pipeline/scripts/code-graph-rules/node.json +151 -0
- package/pipeline/scripts/code-graph-rules/python.json +91 -0
- package/pipeline/scripts/graph-affected.mjs +161 -0
- package/pipeline/scripts/graph-build.mjs +157 -0
- package/pipeline/scripts/graph-query.mjs +191 -0
- package/pipeline/scripts/graph-report.mjs +237 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +7 -4
- package/pipeline/scripts/test-gap-rules/go.json +24 -0
- package/pipeline/scripts/test-gap-rules/ios.json +38 -10
- package/pipeline/scripts/test-gap-scan.mjs +2 -21
- package/pipeline/scripts/uninstall.mjs +11 -2
- package/pipeline/scripts/validate-code-graph.mjs +174 -0
- package/pipeline/skills/.skills-index.json +14 -3
- package/pipeline/skills/shared/README.md +6 -5
- package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +106 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +11 -11
- package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +4 -4
- package/pipeline/skills/skills-index.md +4 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-affected.mjs - reverse traversal: what breaks if this changes.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1's impact surface, feeding `analysis.touchedAreas[]`. Where
|
|
7
|
+
* graph-query.mjs answers "where does this live", this answers "who depends on
|
|
8
|
+
* it": it walks edges backwards from a named symbol, so the result is the blast
|
|
9
|
+
* radius of a change rather than its neighbourhood.
|
|
10
|
+
*
|
|
11
|
+
* Inputs:
|
|
12
|
+
* "<symbol>" Symbol name, exact or case-insensitive. Required.
|
|
13
|
+
* --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
|
|
14
|
+
* --depth N Reverse traversal depth. Default: 2
|
|
15
|
+
* --kind K Restrict to one edge kind (repeatable via comma)
|
|
16
|
+
* --json Emit JSON instead of text
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 - answered (possibly empty)
|
|
20
|
+
* 1 - graph missing or unreadable
|
|
21
|
+
* 64 - usage error
|
|
22
|
+
*
|
|
23
|
+
* @module pipeline/scripts/graph-affected
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { parseFlags } from "./graph-build.mjs";
|
|
28
|
+
import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Nodes whose name matches, preferring an exact hit.
|
|
32
|
+
*
|
|
33
|
+
* A case-insensitive fallback keeps the command usable from a task description,
|
|
34
|
+
* where the symbol is often typed in prose casing.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} graph
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @returns {object[]}
|
|
39
|
+
*/
|
|
40
|
+
export function findByName(graph, name) {
|
|
41
|
+
const exact = graph.nodes.filter((n) => n.name === name);
|
|
42
|
+
if (exact.length) return exact;
|
|
43
|
+
const lower = name.toLowerCase();
|
|
44
|
+
return graph.nodes.filter((n) => (n.name || "").toLowerCase() === lower);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Incoming adjacency only.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} graph
|
|
51
|
+
* @param {Set<string>|null} kinds
|
|
52
|
+
* @returns {Map<string, Array<{id: string, kind: string}>>}
|
|
53
|
+
*/
|
|
54
|
+
export function incoming(graph, kinds = null) {
|
|
55
|
+
const adj = new Map();
|
|
56
|
+
for (const e of graph.edges) {
|
|
57
|
+
if (kinds && !kinds.has(e.kind)) continue;
|
|
58
|
+
if (!adj.has(e.to)) adj.set(e.to, []);
|
|
59
|
+
adj.get(e.to).push({ id: e.from, kind: e.kind });
|
|
60
|
+
}
|
|
61
|
+
return adj;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Walk backwards from each seed.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params
|
|
68
|
+
* @returns {Array<{id: string, dist: number, kind: string, node: object}>}
|
|
69
|
+
*/
|
|
70
|
+
export function affected({ graph, seeds, depth, kinds }) {
|
|
71
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
72
|
+
const adj = incoming(graph, kinds);
|
|
73
|
+
const seen = new Set(seeds.map((n) => n.id));
|
|
74
|
+
const out = [];
|
|
75
|
+
const queue = seeds.map((n) => ({ id: n.id, dist: 0, kind: "seed" }));
|
|
76
|
+
|
|
77
|
+
while (queue.length) {
|
|
78
|
+
const cur = queue.shift();
|
|
79
|
+
if (cur.dist > 0) out.push({ ...cur, node: byId.get(cur.id) });
|
|
80
|
+
if (cur.dist >= depth) continue;
|
|
81
|
+
for (const dep of adj.get(cur.id) || []) {
|
|
82
|
+
if (seen.has(dep.id)) continue;
|
|
83
|
+
seen.add(dep.id);
|
|
84
|
+
queue.push({ id: dep.id, dist: cur.dist + 1, kind: dep.kind });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function main() {
|
|
91
|
+
const { flags, positional } = parseFlags(process.argv.slice(2));
|
|
92
|
+
const name = positional.join(" ").trim();
|
|
93
|
+
if (!name) {
|
|
94
|
+
process.stderr.write(
|
|
95
|
+
'graph-affected: a symbol name is required, e.g. graph-affected "Flight"\n',
|
|
96
|
+
);
|
|
97
|
+
process.exit(64);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const path = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
|
|
101
|
+
let graph;
|
|
102
|
+
try {
|
|
103
|
+
graph = loadGraph(path);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
process.stderr.write(`graph-affected: ${e.message}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const depth = Number(flags.depth) > 0 ? Number(flags.depth) : 2;
|
|
110
|
+
const kinds = flags.kind && flags.kind !== true ? new Set(String(flags.kind).split(",")) : null;
|
|
111
|
+
|
|
112
|
+
const seeds = findByName(graph, name);
|
|
113
|
+
if (seeds.length === 0) {
|
|
114
|
+
if (flags.json === true) {
|
|
115
|
+
process.stdout.write(
|
|
116
|
+
JSON.stringify({ symbol: name, seeds: [], affected: [] }, null, 2) + "\n",
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
process.stdout.write(`no node named '${name}'\n`);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const hits = affected({ graph, seeds, depth, kinds });
|
|
125
|
+
|
|
126
|
+
if (flags.json === true) {
|
|
127
|
+
process.stdout.write(
|
|
128
|
+
JSON.stringify(
|
|
129
|
+
{
|
|
130
|
+
symbol: name,
|
|
131
|
+
seeds: seeds.map((s) => s.id),
|
|
132
|
+
affected: hits.map((h) => ({
|
|
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;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
process.stdout.write(`# affected by ${name}\n\n`);
|
|
147
|
+
for (const seed of seeds) {
|
|
148
|
+
process.stdout.write(`* ${seed.name} (${seed.symbolKind || seed.kind}) ${seed.path || "-"}\n`);
|
|
149
|
+
}
|
|
150
|
+
process.stdout.write("\n");
|
|
151
|
+
const files = new Set();
|
|
152
|
+
for (const hit of hits) {
|
|
153
|
+
if (hit.node?.path) files.add(hit.node.path);
|
|
154
|
+
process.stdout.write(
|
|
155
|
+
`${" ".repeat(hit.dist)}- ${hit.node?.name || hit.id} [${hit.kind}] ${hit.node?.path || "-"}\n`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
process.stdout.write(`\n${hits.length} dependents across ${files.size} files\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (process.argv[1] && process.argv[1].endsWith("graph-affected.mjs")) main();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-build.mjs - build the code graph for one repo.
|
|
5
|
+
*
|
|
6
|
+
* Full rebuild every time, by measurement rather than by principle: on the
|
|
7
|
+
* corporate iOS app (4,311 Swift sources, 14.3 MB) the whole pass takes under
|
|
8
|
+
* three seconds, so an incremental extraction path would add a cache file, a
|
|
9
|
+
* staleness bug surface and a second code path to test in exchange for nothing.
|
|
10
|
+
* The manifest is still written - Phase 1 uses it to report how far behind a
|
|
11
|
+
* graph is, not to rebuild a subset.
|
|
12
|
+
*
|
|
13
|
+
* Inputs:
|
|
14
|
+
* --root <path> Repo root. Default: cwd
|
|
15
|
+
* --stack <id> ios | android | node | python. Required.
|
|
16
|
+
* --out <path> Graph output. Default: ~/.claude/knowledge/<project>/code-graph.json
|
|
17
|
+
* --max-nodes N Refuse to write a graph larger than this. Default: 200000.
|
|
18
|
+
* Callers pass prefs.global.codeGraph.maxNodes. A tree that
|
|
19
|
+
* blows past it is vendored or generated source, and writing
|
|
20
|
+
* a several-hundred-megabyte file for it helps nobody, so
|
|
21
|
+
* the build fails loudly instead of succeeding quietly.
|
|
22
|
+
* --json Emit the summary as JSON instead of a line
|
|
23
|
+
*
|
|
24
|
+
* Output:
|
|
25
|
+
* Writes the graph, prints a one-line summary.
|
|
26
|
+
*
|
|
27
|
+
* Exit codes:
|
|
28
|
+
* 0 - built
|
|
29
|
+
* 1 - rule load, walk, write error, or graph past --max-nodes
|
|
30
|
+
* 64 - usage error
|
|
31
|
+
*
|
|
32
|
+
* @module pipeline/scripts/graph-build
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import { join, dirname, resolve, basename } from "node:path";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
import {
|
|
41
|
+
loadRules,
|
|
42
|
+
listSourceFiles,
|
|
43
|
+
extractFile,
|
|
44
|
+
buildGraph,
|
|
45
|
+
diffManifest,
|
|
46
|
+
} from "./_code-graph.mjs";
|
|
47
|
+
|
|
48
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const DEFAULT_MAX_NODES = 200000;
|
|
50
|
+
|
|
51
|
+
export function parseFlags(argv) {
|
|
52
|
+
const flags = {};
|
|
53
|
+
const positional = [];
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
if (a.startsWith("--")) {
|
|
57
|
+
const k = a.slice(2);
|
|
58
|
+
const v = argv[i + 1];
|
|
59
|
+
if (v !== undefined && !v.startsWith("--")) {
|
|
60
|
+
flags[k] = v;
|
|
61
|
+
i++;
|
|
62
|
+
} else {
|
|
63
|
+
flags[k] = true;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
positional.push(a);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { flags, positional };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function defaultGraphPath(root) {
|
|
73
|
+
return join(homedir(), ".claude", "knowledge", basename(root), "code-graph.json");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function headCommit(root) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
81
|
+
}).trim();
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main() {
|
|
88
|
+
const { flags } = parseFlags(process.argv.slice(2));
|
|
89
|
+
const stack = flags.stack;
|
|
90
|
+
if (!stack || stack === true) {
|
|
91
|
+
process.stderr.write("graph-build: --stack <ios|android|node|python> required\n");
|
|
92
|
+
process.exit(64);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const root = resolve(flags.root || process.cwd());
|
|
96
|
+
if (!existsSync(root)) {
|
|
97
|
+
process.stderr.write(`graph-build: root not found: ${root}\n`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const out = flags.out && flags.out !== true ? resolve(flags.out) : defaultGraphPath(root);
|
|
101
|
+
|
|
102
|
+
let rules;
|
|
103
|
+
try {
|
|
104
|
+
rules = loadRules(stack, SCRIPT_DIR);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
process.stderr.write(`graph-build: ${e.message}\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const startedAt = Date.now();
|
|
111
|
+
const files = listSourceFiles(root, rules);
|
|
112
|
+
const extracted = new Map();
|
|
113
|
+
for (const path of files) {
|
|
114
|
+
try {
|
|
115
|
+
extracted.set(path, extractFile(readFileSync(join(root, path), "utf8"), rules));
|
|
116
|
+
} catch {
|
|
117
|
+
/* unreadable file: skip, it simply does not reach the graph */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { manifest } = diffManifest(root, [...extracted.keys()], {});
|
|
122
|
+
const graph = buildGraph({
|
|
123
|
+
root,
|
|
124
|
+
stack,
|
|
125
|
+
extracted,
|
|
126
|
+
rules,
|
|
127
|
+
manifest,
|
|
128
|
+
baseCommit: headCommit(root),
|
|
129
|
+
generatedAt: new Date().toISOString(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const maxNodes = Number(flags["max-nodes"]) > 0 ? Number(flags["max-nodes"]) : DEFAULT_MAX_NODES;
|
|
133
|
+
if (graph.stats.nodes > maxNodes) {
|
|
134
|
+
process.stderr.write(
|
|
135
|
+
`graph-build: ${graph.stats.nodes} nodes exceeds --max-nodes ${maxNodes}; ` +
|
|
136
|
+
`nothing written. Narrow the tree with the stack rules' excludePathGlobs, ` +
|
|
137
|
+
`or raise prefs.global.codeGraph.maxNodes if this repo really is that large.\n`,
|
|
138
|
+
);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
143
|
+
writeFileSync(out, JSON.stringify(graph), "utf8");
|
|
144
|
+
|
|
145
|
+
const elapsedMs = Date.now() - startedAt;
|
|
146
|
+
const summary = { out, elapsedMs, ...graph.stats };
|
|
147
|
+
if (flags.json === true) {
|
|
148
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
149
|
+
} else {
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`graph-build: ${graph.stats.files} files, ${graph.stats.nodes} nodes, ` +
|
|
152
|
+
`${graph.stats.edges} edges in ${elapsedMs}ms -> ${out}\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (process.argv[1] && process.argv[1].endsWith("graph-build.mjs")) main();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-query.mjs - token-budgeted traversal of the code graph.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1's read surface. Ranks graph nodes against the task text with the
|
|
7
|
+
* pipeline's own BM25 (`_retrieval.mjs`, already gated by eval-recall.mjs),
|
|
8
|
+
* takes the best seeds, then walks their edges breadth-first until the token
|
|
9
|
+
* budget is spent. The budget is the point: an unbounded neighbourhood dump is
|
|
10
|
+
* the context-stuffing anti-pattern this is supposed to replace, so the walk
|
|
11
|
+
* stops on budget rather than on depth alone.
|
|
12
|
+
*
|
|
13
|
+
* Inputs:
|
|
14
|
+
* "<question>" Free text. Required.
|
|
15
|
+
* --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
|
|
16
|
+
* --budget N Output token cap. Default: 2000
|
|
17
|
+
* --depth N Max BFS depth from a seed. Default: 2
|
|
18
|
+
* --seeds N How many ranked seeds to expand. Default: 8
|
|
19
|
+
* --json Emit JSON instead of text
|
|
20
|
+
*
|
|
21
|
+
* Exit codes:
|
|
22
|
+
* 0 - answered (possibly empty)
|
|
23
|
+
* 1 - graph missing or unreadable
|
|
24
|
+
* 64 - usage error
|
|
25
|
+
*
|
|
26
|
+
* @module pipeline/scripts/graph-query
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
30
|
+
import { join, resolve, basename } from "node:path";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { bm25 } from "./_retrieval.mjs";
|
|
33
|
+
import { parseFlags } from "./graph-build.mjs";
|
|
34
|
+
|
|
35
|
+
const CHARS_PER_TOKEN = 4;
|
|
36
|
+
|
|
37
|
+
export function defaultGraphPath(cwd = process.cwd()) {
|
|
38
|
+
return join(homedir(), ".claude", "knowledge", basename(resolve(cwd)), "code-graph.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadGraph(path) {
|
|
42
|
+
if (!existsSync(path)) throw new Error(`graph not found: ${path}`);
|
|
43
|
+
const graph = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
|
45
|
+
throw new Error(`graph is malformed: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
return graph;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adjacency in both directions.
|
|
52
|
+
*
|
|
53
|
+
* Traversal has to cross an edge either way: a task naming a base class must
|
|
54
|
+
* reach its subclasses (incoming) as readily as its own file (outgoing).
|
|
55
|
+
*
|
|
56
|
+
* @param {object} graph
|
|
57
|
+
* @returns {Map<string, Array<{id: string, kind: string, dir: string}>>}
|
|
58
|
+
*/
|
|
59
|
+
export function adjacency(graph) {
|
|
60
|
+
const adj = new Map();
|
|
61
|
+
const push = (from, to, kind, dir) => {
|
|
62
|
+
if (!adj.has(from)) adj.set(from, []);
|
|
63
|
+
adj.get(from).push({ id: to, kind, dir });
|
|
64
|
+
};
|
|
65
|
+
for (const e of graph.edges) {
|
|
66
|
+
push(e.from, e.to, e.kind, "out");
|
|
67
|
+
push(e.to, e.from, e.kind, "in");
|
|
68
|
+
}
|
|
69
|
+
return adj;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rank nodes against the question.
|
|
74
|
+
*
|
|
75
|
+
* `name` outweighs `path` because a task names symbols far more often than
|
|
76
|
+
* directories, and `symbolKind` carries a little signal ("the FlightList view
|
|
77
|
+
* model") without letting the kind word alone win a match.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} graph
|
|
80
|
+
* @param {string} question
|
|
81
|
+
* @returns {Array<{node: object, score: number}>} descending, zero scores dropped
|
|
82
|
+
*/
|
|
83
|
+
export function rankNodes(graph, question) {
|
|
84
|
+
const docs = graph.nodes.map((n) => ({
|
|
85
|
+
name: n.name || "",
|
|
86
|
+
path: n.path || "",
|
|
87
|
+
symbolKind: n.symbolKind || "",
|
|
88
|
+
}));
|
|
89
|
+
const scored = bm25(question, docs, { fieldWeights: { name: 3, path: 1, symbolKind: 1 } });
|
|
90
|
+
return scored
|
|
91
|
+
.filter((s) => s.score > 0)
|
|
92
|
+
.sort((a, b) => b.score - a.score)
|
|
93
|
+
.map((s) => ({ node: graph.nodes[s.index], score: s.score }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderNode(node) {
|
|
97
|
+
const where = node.path ? `${node.path}${node.line ? `:${node.line}` : ""}` : "-";
|
|
98
|
+
const kind = node.symbolKind || node.kind;
|
|
99
|
+
return `${node.name} (${kind}) ${where}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Seed, then breadth-first expand until the budget is spent.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} params
|
|
106
|
+
* @returns {{seeds: object[], hits: object[], truncated: boolean, tokens: number}}
|
|
107
|
+
*/
|
|
108
|
+
export function traverse({ graph, question, budget, depth, seedCount }) {
|
|
109
|
+
const ranked = rankNodes(graph, question);
|
|
110
|
+
const seeds = ranked.slice(0, seedCount).map((r) => r.node);
|
|
111
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
112
|
+
const adj = adjacency(graph);
|
|
113
|
+
|
|
114
|
+
const seen = new Set(seeds.map((n) => n.id));
|
|
115
|
+
const hits = [];
|
|
116
|
+
let tokens = 0;
|
|
117
|
+
let truncated = false;
|
|
118
|
+
|
|
119
|
+
const queue = seeds.map((n) => ({ node: n, dist: 0, via: null }));
|
|
120
|
+
while (queue.length) {
|
|
121
|
+
const { node, dist, via } = queue.shift();
|
|
122
|
+
const cost = Math.ceil(renderNode(node).length / CHARS_PER_TOKEN);
|
|
123
|
+
if (tokens + cost > budget) {
|
|
124
|
+
truncated = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
tokens += cost;
|
|
128
|
+
hits.push({ id: node.id, dist, via, line: renderNode(node), degree: node.degree });
|
|
129
|
+
|
|
130
|
+
if (dist >= depth) continue;
|
|
131
|
+
const neighbours = (adj.get(node.id) || [])
|
|
132
|
+
.slice()
|
|
133
|
+
.sort((a, b) => (byId.get(b.id)?.degree || 0) - (byId.get(a.id)?.degree || 0));
|
|
134
|
+
for (const n of neighbours) {
|
|
135
|
+
if (seen.has(n.id)) continue;
|
|
136
|
+
const target = byId.get(n.id);
|
|
137
|
+
if (!target) continue;
|
|
138
|
+
seen.add(n.id);
|
|
139
|
+
queue.push({ node: target, dist: dist + 1, via: `${n.kind}/${n.dir}` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { seeds, hits, truncated, tokens };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function main() {
|
|
147
|
+
const { flags, positional } = parseFlags(process.argv.slice(2));
|
|
148
|
+
const question = positional.join(" ").trim();
|
|
149
|
+
if (!question) {
|
|
150
|
+
process.stderr.write('graph-query: a question is required, e.g. graph-query "flight search"\n');
|
|
151
|
+
process.exit(64);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const path = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
|
|
155
|
+
let graph;
|
|
156
|
+
try {
|
|
157
|
+
graph = loadGraph(path);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
process.stderr.write(`graph-query: ${e.message}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const budget = Number(flags.budget) > 0 ? Number(flags.budget) : 2000;
|
|
164
|
+
const depth = Number(flags.depth) > 0 ? Number(flags.depth) : 2;
|
|
165
|
+
const seedCount = Number(flags.seeds) > 0 ? Number(flags.seeds) : 8;
|
|
166
|
+
|
|
167
|
+
const result = traverse({ graph, question, budget, depth, seedCount });
|
|
168
|
+
|
|
169
|
+
if (flags.json === true) {
|
|
170
|
+
process.stdout.write(JSON.stringify({ question, budget, ...result }, null, 2) + "\n");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
process.stdout.write(`# ${question}\n\n`);
|
|
175
|
+
if (result.hits.length === 0) {
|
|
176
|
+
process.stdout.write("no matching nodes\n");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const hit of result.hits) {
|
|
180
|
+
const prefix = hit.dist === 0 ? "*" : " ".repeat(hit.dist) + "-";
|
|
181
|
+
const via = hit.via ? ` [${hit.via}]` : "";
|
|
182
|
+
process.stdout.write(`${prefix} ${hit.line}${via}\n`);
|
|
183
|
+
}
|
|
184
|
+
process.stdout.write(
|
|
185
|
+
`\n${result.hits.length} nodes, ~${result.tokens} tokens` +
|
|
186
|
+
(result.truncated ? " (budget reached)" : "") +
|
|
187
|
+
"\n",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (process.argv[1] && process.argv[1].endsWith("graph-query.mjs")) main();
|