@mmerterden/multi-agent-pipeline 16.12.0 → 16.13.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 +20 -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/cross-cli-contract.md +10 -10
- package/pipeline/multi-agent-refs/features/code-graph.md +62 -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 +518 -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/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/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,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file _code-graph.mjs - deterministic, LLM-free code graph for Phase 1 and Phase 7.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 narrows its Explore fan-out with this graph, and Phase 7 refreshes it
|
|
5
|
+
* instead of hand-writing architecture.md from a single task's window. The
|
|
6
|
+
* design follows graphify (Graphify-Labs/graphify): AST-quality extraction with
|
|
7
|
+
* zero LLM cost, one graph file, an incremental manifest gate, god-node hubs,
|
|
8
|
+
* and token-budgeted traversal as the query surface. The extraction itself is
|
|
9
|
+
* regex over comment-stripped source rather than tree-sitter - a tree-sitter
|
|
10
|
+
* binding would be an npm runtime dependency, which ADR-0004 forbids. That
|
|
11
|
+
* trade is deliberate: definitions and imports survive it, call graphs and type
|
|
12
|
+
* resolution do not, and neither consumer needs those.
|
|
13
|
+
*
|
|
14
|
+
* Definition and import patterns come from the rule files. References do NOT:
|
|
15
|
+
* a per-language reference regex is both brittle and redundant once every
|
|
16
|
+
* defined symbol name is known, so pass 2 intersects each file's identifier set
|
|
17
|
+
* with the global symbol index instead. That is language-agnostic and cannot
|
|
18
|
+
* drift from the definition patterns, because it is derived from them.
|
|
19
|
+
*
|
|
20
|
+
* Two passes, one read per file:
|
|
21
|
+
* 1. strip comments and strings, extract definitions + imports, keep the
|
|
22
|
+
* file's identifier set (a Set of strings, not the source text)
|
|
23
|
+
* 2. intersect each identifier set with the symbol index to emit references
|
|
24
|
+
*
|
|
25
|
+
* Zero runtime dependencies (ADR-0004).
|
|
26
|
+
*
|
|
27
|
+
* @module pipeline/scripts/_code-graph
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { join, dirname, relative, sep } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { isExcludedBy } from "./_path-match.mjs";
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
|
|
38
|
+
export const GRAPH_SCHEMA_VERSION = "1.0.0";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load and merge the two rule files for a stack.
|
|
42
|
+
*
|
|
43
|
+
* `test-gap-rules/<stack>.json` is read directly rather than copied: it already
|
|
44
|
+
* owns sourceExtensions, excludePathGlobs and the test-path predicates, and a
|
|
45
|
+
* second copy would drift the first time a pattern changed.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} stack - ios | android | node | python
|
|
48
|
+
* @param {string} [baseDir] - directory holding the two rule dirs
|
|
49
|
+
* @returns {object} merged rules
|
|
50
|
+
*/
|
|
51
|
+
export function loadRules(stack, baseDir = __dirname) {
|
|
52
|
+
const gapPath = join(baseDir, "test-gap-rules", `${stack}.json`);
|
|
53
|
+
const graphPath = join(baseDir, "code-graph-rules", `${stack}.json`);
|
|
54
|
+
if (!existsSync(gapPath)) throw new Error(`no test-gap rules for stack '${stack}': ${gapPath}`);
|
|
55
|
+
if (!existsSync(graphPath))
|
|
56
|
+
throw new Error(`no code-graph rules for stack '${stack}': ${graphPath}`);
|
|
57
|
+
|
|
58
|
+
const gap = JSON.parse(readFileSync(gapPath, "utf8"));
|
|
59
|
+
const graph = JSON.parse(readFileSync(graphPath, "utf8"));
|
|
60
|
+
|
|
61
|
+
if (graph.stack !== stack || gap.stack !== stack) {
|
|
62
|
+
throw new Error(`rule file stack mismatch for '${stack}'`);
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(graph.definitionPatterns) || graph.definitionPatterns.length === 0) {
|
|
65
|
+
throw new Error(`code-graph rules for '${stack}' declare no definitionPatterns`);
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(graph.referenceKinds) || graph.referenceKinds.length === 0) {
|
|
68
|
+
throw new Error(`code-graph rules for '${stack}' declare no referenceKinds`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
stack,
|
|
73
|
+
sourceExtensions: gap.sourceExtensions || [],
|
|
74
|
+
excludePathGlobs: gap.excludePathGlobs || [],
|
|
75
|
+
testPathSuffixes: gap.testPathSuffixes || [],
|
|
76
|
+
testPathContains: gap.testPathContains || [],
|
|
77
|
+
comments: graph.comments || {},
|
|
78
|
+
definitionPatterns: graph.definitionPatterns,
|
|
79
|
+
importPatterns: graph.importPatterns || [],
|
|
80
|
+
importSpecifiersAreStrings: graph.importSpecifiersAreStrings === true,
|
|
81
|
+
ignoredIdentifiers: new Set(graph.ignoredIdentifiers || []),
|
|
82
|
+
referenceKinds: new Set(graph.referenceKinds || []),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove comments and string literals so they cannot produce phantom edges.
|
|
88
|
+
*
|
|
89
|
+
* Character-scanned rather than regex-replaced: a regex pass cannot tell a `//`
|
|
90
|
+
* inside a string from a real comment, and that difference is exactly what
|
|
91
|
+
* makes reference edges noisy. Replaces removed spans with spaces so byte
|
|
92
|
+
* offsets, and therefore line numbers, are preserved.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} text - source text
|
|
95
|
+
* @param {object} comments - rules.comments
|
|
96
|
+
* @returns {string} same length, comments and string bodies blanked
|
|
97
|
+
*/
|
|
98
|
+
export function stripCode(text, comments = {}) {
|
|
99
|
+
const line = comments.line || [];
|
|
100
|
+
const block = comments.block || [];
|
|
101
|
+
const quotes = comments.string || [];
|
|
102
|
+
const out = text.split("");
|
|
103
|
+
|
|
104
|
+
let i = 0;
|
|
105
|
+
const blank = (from, to) => {
|
|
106
|
+
for (let k = from; k < to && k < out.length; k++) {
|
|
107
|
+
if (out[k] !== "\n") out[k] = " ";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
while (i < text.length) {
|
|
112
|
+
const rest = text.slice(i, i + 8);
|
|
113
|
+
|
|
114
|
+
const lineTok = line.find((t) => rest.startsWith(t));
|
|
115
|
+
if (lineTok) {
|
|
116
|
+
let end = text.indexOf("\n", i);
|
|
117
|
+
if (end === -1) end = text.length;
|
|
118
|
+
blank(i, end);
|
|
119
|
+
i = end;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const blockTok = block.find(([open]) => rest.startsWith(open));
|
|
124
|
+
if (blockTok) {
|
|
125
|
+
const [open, close] = blockTok;
|
|
126
|
+
let end = text.indexOf(close, i + open.length);
|
|
127
|
+
end = end === -1 ? text.length : end + close.length;
|
|
128
|
+
blank(i, end);
|
|
129
|
+
i = end;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const quoteTok = quotes.find((q) => rest.startsWith(q));
|
|
134
|
+
if (quoteTok) {
|
|
135
|
+
let j = i + quoteTok.length;
|
|
136
|
+
while (j < text.length) {
|
|
137
|
+
if (text[j] === "\\") {
|
|
138
|
+
j += 2;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (text.startsWith(quoteTok, j)) {
|
|
142
|
+
j += quoteTok.length;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (text[j] === "\n" && quoteTok.length === 1) break;
|
|
146
|
+
j++;
|
|
147
|
+
}
|
|
148
|
+
blank(i, j);
|
|
149
|
+
i = j;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
i++;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return out.join("");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* First populated capture group of a match.
|
|
161
|
+
*
|
|
162
|
+
* Rule-file patterns may alternate (Swift's `import class Mod.Sym` needs one
|
|
163
|
+
* branch, plain `import Mod` another), which shifts the meaningful group.
|
|
164
|
+
* Reading m[1] alone silently dropped every match of a later branch.
|
|
165
|
+
*
|
|
166
|
+
* @param {RegExpMatchArray} m
|
|
167
|
+
* @returns {string|undefined}
|
|
168
|
+
*/
|
|
169
|
+
function captured(m) {
|
|
170
|
+
return m.slice(1).find((g) => g !== undefined);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const IMPORT_LINE_RE = /^[ \t]*import[ \t].*$/gm;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Blank every import line, preserving offsets.
|
|
177
|
+
*
|
|
178
|
+
* Swift's submodule form - `import class DesignKit.PrimaryButton` - otherwise
|
|
179
|
+
* feeds the `class` definition pattern, which registers the MODULE name as a
|
|
180
|
+
* declared type. On a large Swift app that alone lifted two module names into
|
|
181
|
+
* the top ten god-nodes, which is how the bug surfaced. Imports are extracted
|
|
182
|
+
* before this runs, so nothing is lost.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} stripped - output of stripCode
|
|
185
|
+
* @returns {string} same length, import lines blanked
|
|
186
|
+
*/
|
|
187
|
+
function blankImportLines(stripped) {
|
|
188
|
+
return stripped.replace(IMPORT_LINE_RE, (line) => " ".repeat(line.length));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_]*/g;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Distinct identifiers in a piece of stripped source.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} stripped - output of stripCode
|
|
197
|
+
* @returns {Set<string>}
|
|
198
|
+
*/
|
|
199
|
+
export function identifiers(stripped) {
|
|
200
|
+
const found = new Set();
|
|
201
|
+
for (const m of stripped.matchAll(IDENTIFIER_RE)) found.add(m[0]);
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function lineOf(text, index) {
|
|
206
|
+
let line = 1;
|
|
207
|
+
for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
|
|
208
|
+
return line;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Leading whitespace on the line holding this index.
|
|
213
|
+
*
|
|
214
|
+
* Nesting is read from the line's indentation, not from where the pattern
|
|
215
|
+
* happened to match: `public final class Foo` matches at `class`, seven columns
|
|
216
|
+
* in, and is still a top-level declaration. Indentation is the signal that holds
|
|
217
|
+
* across every language these rules cover - a brace language indents a nested
|
|
218
|
+
* type by convention, Python by grammar - which is why the test lives in the
|
|
219
|
+
* engine rather than in a per-stack rule.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} text
|
|
222
|
+
* @param {number} index
|
|
223
|
+
* @returns {number}
|
|
224
|
+
*/
|
|
225
|
+
function indentOf(text, index) {
|
|
226
|
+
const start = text.lastIndexOf("\n", index - 1) + 1;
|
|
227
|
+
let i = start;
|
|
228
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
|
|
229
|
+
return i - start;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Extract one file's definitions, imports and identifier set.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} text - raw source
|
|
236
|
+
* @param {object} rules - merged rules from loadRules
|
|
237
|
+
* @returns {{definitions: Array, imports: string[], tokens: Set<string>}}
|
|
238
|
+
*/
|
|
239
|
+
export function extractFile(text, rules) {
|
|
240
|
+
const stripped = stripCode(text, rules.comments);
|
|
241
|
+
|
|
242
|
+
// Swift and the JVM name a module with a bare identifier, so the fully
|
|
243
|
+
// stripped body is the right text to read imports from. A JavaScript module
|
|
244
|
+
// specifier is a string literal, and stripping strings blanks it: run the
|
|
245
|
+
// import patterns over a text that kept its strings, or every import edge in
|
|
246
|
+
// that stack disappears with nothing to notice it. Comments are still gone
|
|
247
|
+
// either way, so an import written inside a comment never counts, and the
|
|
248
|
+
// definition and identifier passes below still read the fully stripped body.
|
|
249
|
+
const importSource = rules.importSpecifiersAreStrings
|
|
250
|
+
? stripCode(text, { line: rules.comments.line, block: rules.comments.block })
|
|
251
|
+
: stripped;
|
|
252
|
+
|
|
253
|
+
const imports = [];
|
|
254
|
+
for (const pattern of rules.importPatterns) {
|
|
255
|
+
const re = new RegExp(pattern.regex, "gm");
|
|
256
|
+
for (const m of importSource.matchAll(re)) {
|
|
257
|
+
const mod = captured(m);
|
|
258
|
+
if (mod && !imports.includes(mod)) imports.push(mod);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const body = blankImportLines(stripped);
|
|
263
|
+
const definitions = [];
|
|
264
|
+
const seen = new Set();
|
|
265
|
+
|
|
266
|
+
for (const pattern of rules.definitionPatterns) {
|
|
267
|
+
const re = new RegExp(pattern.regex, "gm");
|
|
268
|
+
for (const m of body.matchAll(re)) {
|
|
269
|
+
const name = captured(m);
|
|
270
|
+
if (!name) continue;
|
|
271
|
+
const key = `${name}:${pattern.kind || pattern.id}`;
|
|
272
|
+
if (seen.has(key)) continue;
|
|
273
|
+
seen.add(key);
|
|
274
|
+
definitions.push({
|
|
275
|
+
name,
|
|
276
|
+
kind: pattern.kind || pattern.id,
|
|
277
|
+
patternId: pattern.id,
|
|
278
|
+
line: lineOf(body, m.index),
|
|
279
|
+
nested: indentOf(body, m.index) > 0,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { definitions, imports, tokens: identifiers(body) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Every source file under root, honouring .gitignore and excludePathGlobs.
|
|
289
|
+
*
|
|
290
|
+
* Uses `git ls-files` when root is a work tree, so .gitignore and
|
|
291
|
+
* .git/info/exclude are respected without reimplementing them; falls back to a
|
|
292
|
+
* directory walk otherwise.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} root - absolute repo root
|
|
295
|
+
* @param {object} rules - merged rules
|
|
296
|
+
* @returns {string[]} repo-relative paths, sorted
|
|
297
|
+
*/
|
|
298
|
+
export function listSourceFiles(root, rules) {
|
|
299
|
+
let candidates;
|
|
300
|
+
try {
|
|
301
|
+
const out = execFileSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard"], {
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
304
|
+
});
|
|
305
|
+
candidates = out.split("\n").filter(Boolean);
|
|
306
|
+
} catch {
|
|
307
|
+
candidates = walkDir(root, root);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const isSource = (p) => {
|
|
311
|
+
if (!rules.sourceExtensions.some((ext) => p.endsWith(ext))) return false;
|
|
312
|
+
if (isExcludedBy(p, rules.excludePathGlobs)) return false;
|
|
313
|
+
return true;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return candidates.filter(isSource).sort();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function walkDir(dir, root, acc = []) {
|
|
320
|
+
let entries;
|
|
321
|
+
try {
|
|
322
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
323
|
+
} catch {
|
|
324
|
+
return acc;
|
|
325
|
+
}
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
328
|
+
const full = join(dir, entry.name);
|
|
329
|
+
if (entry.isDirectory()) walkDir(full, root, acc);
|
|
330
|
+
else if (entry.isFile()) acc.push(relative(root, full).split(sep).join("/"));
|
|
331
|
+
}
|
|
332
|
+
return acc;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Is this a test file per the stack's test-path predicates?
|
|
337
|
+
*
|
|
338
|
+
* Test files stay in the graph - they are how Phase 1 finds existing coverage -
|
|
339
|
+
* but they are tagged so the report can separate them from production hubs.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} path - repo-relative path
|
|
342
|
+
* @param {object} rules - merged rules
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
export function isTestPath(path, rules) {
|
|
346
|
+
if (rules.testPathSuffixes.some((s) => path.endsWith(s))) return true;
|
|
347
|
+
return rules.testPathContains.some((c) => path.includes(c));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const fileNodeId = (path) => `file:${path}`;
|
|
351
|
+
export const symbolNodeId = (path, name) => `sym:${path}#${name}`;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Build the graph from a set of extracted files.
|
|
355
|
+
*
|
|
356
|
+
* Pass 2 resolves a reference only when the identifier maps to exactly one
|
|
357
|
+
* defined symbol. An ambiguous name (the same type declared in several files)
|
|
358
|
+
* would otherwise fan out to every candidate and turn the busiest names into
|
|
359
|
+
* false hubs, which is the failure that makes god-node output useless.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} params
|
|
362
|
+
* @param {string} params.root - absolute repo root
|
|
363
|
+
* @param {string} params.stack
|
|
364
|
+
* @param {Map<string, object>} params.extracted - relPath -> extractFile result
|
|
365
|
+
* @param {object} params.rules
|
|
366
|
+
* @param {object} [params.manifest] - relPath -> {size, mtimeMs}
|
|
367
|
+
* @param {string|null} [params.baseCommit]
|
|
368
|
+
* @param {string} [params.generatedAt] - ISO timestamp, injected by the caller
|
|
369
|
+
* @returns {object} graph conforming to code-graph.schema.json
|
|
370
|
+
*/
|
|
371
|
+
export function buildGraph({
|
|
372
|
+
root,
|
|
373
|
+
stack,
|
|
374
|
+
extracted,
|
|
375
|
+
rules,
|
|
376
|
+
manifest = {},
|
|
377
|
+
baseCommit = null,
|
|
378
|
+
generatedAt,
|
|
379
|
+
}) {
|
|
380
|
+
const nodes = new Map();
|
|
381
|
+
const edges = [];
|
|
382
|
+
const symbolsByName = new Map();
|
|
383
|
+
|
|
384
|
+
for (const [path, data] of extracted) {
|
|
385
|
+
const fid = fileNodeId(path);
|
|
386
|
+
nodes.set(fid, {
|
|
387
|
+
id: fid,
|
|
388
|
+
kind: "file",
|
|
389
|
+
name: path.split("/").pop(),
|
|
390
|
+
path,
|
|
391
|
+
isTest: isTestPath(path, rules),
|
|
392
|
+
degree: 0,
|
|
393
|
+
});
|
|
394
|
+
for (const def of data.definitions) {
|
|
395
|
+
const sid = symbolNodeId(path, def.name);
|
|
396
|
+
if (!nodes.has(sid)) {
|
|
397
|
+
nodes.set(sid, {
|
|
398
|
+
id: sid,
|
|
399
|
+
kind: "symbol",
|
|
400
|
+
name: def.name,
|
|
401
|
+
path,
|
|
402
|
+
line: def.line,
|
|
403
|
+
symbolKind: def.kind,
|
|
404
|
+
isTest: isTestPath(path, rules),
|
|
405
|
+
nested: def.nested === true,
|
|
406
|
+
degree: 0,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
edges.push({ from: fid, to: sid, kind: "defines" });
|
|
410
|
+
// A nested declaration stays a node and keeps its defines edge, so it is
|
|
411
|
+
// still findable by name, but it never becomes a reference target. Sealed
|
|
412
|
+
// hierarchies name their cases after the concept they model - Icon, Color,
|
|
413
|
+
// Success, Disabled - and each is declared exactly once, so the ambiguity
|
|
414
|
+
// rule below does not catch them: every file that merely mentions the
|
|
415
|
+
// framework's Color would otherwise gain an edge to one app's nested case.
|
|
416
|
+
if (def.nested === true) continue;
|
|
417
|
+
if (!symbolsByName.has(def.name)) symbolsByName.set(def.name, []);
|
|
418
|
+
const bucket = symbolsByName.get(def.name);
|
|
419
|
+
if (!bucket.includes(sid)) bucket.push(sid);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const byBasename = new Map();
|
|
424
|
+
for (const path of extracted.keys()) {
|
|
425
|
+
const base = path
|
|
426
|
+
.split("/")
|
|
427
|
+
.pop()
|
|
428
|
+
.replace(/\.[^.]+$/, "");
|
|
429
|
+
if (!byBasename.has(base)) byBasename.set(base, []);
|
|
430
|
+
byBasename.get(base).push(path);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const [path, data] of extracted) {
|
|
434
|
+
const fid = fileNodeId(path);
|
|
435
|
+
for (const mod of data.imports) {
|
|
436
|
+
const targets = byBasename.get(mod);
|
|
437
|
+
if (targets && targets.length === 1) {
|
|
438
|
+
edges.push({ from: fid, to: fileNodeId(targets[0]), kind: "imports" });
|
|
439
|
+
} else {
|
|
440
|
+
const mid = `module:${mod}`;
|
|
441
|
+
if (!nodes.has(mid)) {
|
|
442
|
+
nodes.set(mid, { id: mid, kind: "module", name: mod, path: null, degree: 0 });
|
|
443
|
+
}
|
|
444
|
+
edges.push({ from: fid, to: mid, kind: "imports" });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const [path, data] of extracted) {
|
|
450
|
+
const fid = fileNodeId(path);
|
|
451
|
+
const emitted = new Set();
|
|
452
|
+
for (const token of data.tokens) {
|
|
453
|
+
if (rules.ignoredIdentifiers.has(token)) continue;
|
|
454
|
+
const candidates = symbolsByName.get(token);
|
|
455
|
+
if (!candidates || candidates.length !== 1) continue;
|
|
456
|
+
const sid = candidates[0];
|
|
457
|
+
const target = nodes.get(sid);
|
|
458
|
+
if (target.path === path) continue;
|
|
459
|
+
if (!rules.referenceKinds.has(target.symbolKind)) continue;
|
|
460
|
+
if (emitted.has(sid)) continue;
|
|
461
|
+
emitted.add(sid);
|
|
462
|
+
edges.push({ from: fid, to: sid, kind: "references" });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const edge of edges) {
|
|
467
|
+
if (nodes.has(edge.from)) nodes.get(edge.from).degree++;
|
|
468
|
+
if (nodes.has(edge.to)) nodes.get(edge.to).degree++;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
schemaVersion: GRAPH_SCHEMA_VERSION,
|
|
473
|
+
stack,
|
|
474
|
+
root,
|
|
475
|
+
baseCommit,
|
|
476
|
+
generatedAt,
|
|
477
|
+
stats: {
|
|
478
|
+
files: extracted.size,
|
|
479
|
+
nodes: nodes.size,
|
|
480
|
+
edges: edges.length,
|
|
481
|
+
},
|
|
482
|
+
nodes: [...nodes.values()],
|
|
483
|
+
edges,
|
|
484
|
+
manifest,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Manifest entry for one file, used as the incremental gate.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} absPath
|
|
492
|
+
* @returns {{size: number, mtimeMs: number}}
|
|
493
|
+
*/
|
|
494
|
+
export function manifestEntry(absPath) {
|
|
495
|
+
const st = statSync(absPath);
|
|
496
|
+
return { size: st.size, mtimeMs: Math.floor(st.mtimeMs) };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Which files changed since the previous manifest?
|
|
501
|
+
*
|
|
502
|
+
* @param {string} root
|
|
503
|
+
* @param {string[]} files - repo-relative paths
|
|
504
|
+
* @param {object} prevManifest
|
|
505
|
+
* @returns {{changed: string[], removed: string[], manifest: object}}
|
|
506
|
+
*/
|
|
507
|
+
export function diffManifest(root, files, prevManifest = {}) {
|
|
508
|
+
const manifest = {};
|
|
509
|
+
const changed = [];
|
|
510
|
+
for (const path of files) {
|
|
511
|
+
const entry = manifestEntry(join(root, path));
|
|
512
|
+
manifest[path] = entry;
|
|
513
|
+
const prev = prevManifest[path];
|
|
514
|
+
if (!prev || prev.size !== entry.size || prev.mtimeMs !== entry.mtimeMs) changed.push(path);
|
|
515
|
+
}
|
|
516
|
+
const removed = Object.keys(prevManifest).filter((p) => !(p in manifest));
|
|
517
|
+
return { changed, removed, manifest };
|
|
518
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file _path-match.mjs - shared path-glob matching for the stack rule files.
|
|
3
|
+
*
|
|
4
|
+
* `excludePathGlobs` has more than one consumer now: test-gap-scan.mjs walks a
|
|
5
|
+
* diff, _code-graph.mjs walks a whole tree, and both must agree on which paths
|
|
6
|
+
* a rule file excludes. A second copy of the matcher would drift the first time
|
|
7
|
+
* a pattern shape changed - the same reason _stack-routing.mjs was extracted
|
|
8
|
+
* out of build-stack-plugins.mjs - and the two consumers would then silently
|
|
9
|
+
* disagree about what is in scope.
|
|
10
|
+
*
|
|
11
|
+
* Zero runtime dependencies (ADR-0004): the glob subset is hand-rolled rather
|
|
12
|
+
* than delegated to a micromatch-style library.
|
|
13
|
+
*
|
|
14
|
+
* @module pipeline/scripts/_path-match
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DOUBLE_STAR_TOKEN = "\u0000";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Translate a glob's body to regex source, without anchors.
|
|
21
|
+
*
|
|
22
|
+
* `**` is tokenized before the single-`*` pass so the `*` rewrite cannot mangle
|
|
23
|
+
* the already-translated `.*`, which broke cross-directory matches.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} pattern
|
|
26
|
+
* @returns {string} regex source
|
|
27
|
+
*/
|
|
28
|
+
function globBody(pattern) {
|
|
29
|
+
return pattern
|
|
30
|
+
.replace(/\./g, "\\.")
|
|
31
|
+
.replace(/\*\*/g, DOUBLE_STAR_TOKEN)
|
|
32
|
+
.replace(/\*/g, "[^/]*")
|
|
33
|
+
.replace(new RegExp(DOUBLE_STAR_TOKEN, "g"), ".*");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Translate one rule-file glob to a fully anchored RegExp.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} pattern - glob from a rule file's excludePathGlobs
|
|
40
|
+
* @returns {RegExp} anchored matcher
|
|
41
|
+
*/
|
|
42
|
+
export function globToRegExp(pattern) {
|
|
43
|
+
return new RegExp("^" + globBody(pattern) + "$");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does this path match one rule-file glob?
|
|
48
|
+
*
|
|
49
|
+
* Four pattern shapes, in the order the rule files use them:
|
|
50
|
+
*
|
|
51
|
+
* `build/` a directory segment anywhere in the path
|
|
52
|
+
* `*.egg-info/` the same, with a wildcard in the segment
|
|
53
|
+
* `**\/BuildConfig.java` a full-path pattern, anchored both ends
|
|
54
|
+
* `*.d.ts` a path suffix, with a wildcard
|
|
55
|
+
* `.pb.swift` a literal path suffix
|
|
56
|
+
*
|
|
57
|
+
* The two wildcard shapes used to fall through to a literal `endsWith` /
|
|
58
|
+
* `includes` test against the pattern text, which can never be true: nothing
|
|
59
|
+
* ends with the four characters `*.d.` followed by `ts`. `*.d.ts` in the node
|
|
60
|
+
* rules and `*.egg-info/` in the python rules were both silently inert, so
|
|
61
|
+
* generated typings and build residue reached every consumer of these rules.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} path - repo-relative path
|
|
64
|
+
* @param {string} pattern - one glob
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function matchesGlob(path, pattern) {
|
|
68
|
+
if (pattern.endsWith("/")) {
|
|
69
|
+
if (pattern.includes("*")) return new RegExp("(^|/)" + globBody(pattern)).test(path);
|
|
70
|
+
return path.includes(pattern) || path.startsWith(pattern);
|
|
71
|
+
}
|
|
72
|
+
if (pattern.includes("**")) return globToRegExp(pattern).test(path);
|
|
73
|
+
if (pattern.includes("*")) return new RegExp("(^|/)" + globBody(pattern) + "$").test(path);
|
|
74
|
+
return path.endsWith(pattern);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this path excluded by a rule file's excludePathGlobs?
|
|
79
|
+
*
|
|
80
|
+
* @param {string} path - repo-relative path
|
|
81
|
+
* @param {string[]|undefined} globs - rules.excludePathGlobs
|
|
82
|
+
* @returns {boolean} false when the rule file declares no globs
|
|
83
|
+
*/
|
|
84
|
+
export function isExcludedBy(path, globs) {
|
|
85
|
+
if (!Array.isArray(globs)) return false;
|
|
86
|
+
return globs.some((pattern) => matchesGlob(path, pattern));
|
|
87
|
+
}
|