@renzrollon/interlock 1.4.1 → 1.5.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/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +47 -0
- package/README.md +28 -3
- package/bin/interlock +16 -5
- package/hooks/mod.mjs +307 -48
- package/lib/doctor.mjs +7 -6
- package/lib/graph/cli.mjs +93 -15
- package/lib/graph/extract/bicep.mjs +67 -0
- package/lib/graph/extract/create-extractor.mjs +15 -0
- package/lib/graph/extract/csharp.mjs +206 -0
- package/lib/graph/extract/csproj.mjs +42 -0
- package/lib/graph/extract/memory.mjs +9 -4
- package/lib/graph/extract/openspec.mjs +9 -5
- package/lib/graph/extract/walk.mjs +37 -12
- package/lib/graph/graph/build.mjs +77 -13
- package/lib/graph/graph/link-csharp.mjs +210 -0
- package/lib/graph/graph/query.mjs +2 -0
- package/lib/graph/graph/report.mjs +11 -1
- package/lib/graph/graph/update.mjs +56 -16
- package/lib/graph/paths.mjs +56 -4
- package/lib/graph/retrieve/context.mjs +2 -0
- package/lib/graph/retrieve/docs-index.mjs +9 -10
- package/lib/graph/retrieve/markdown.mjs +8 -8
- package/lib/limits.mjs +11 -3
- package/lib/outcomes.mjs +8 -1
- package/lib/prompts/implementer.mjs +28 -3
- package/lib/prompts/index.mjs +37 -1
- package/lib/prompts/rejected.mjs +24 -0
- package/lib/prompts/review.mjs +38 -1
- package/lib/receipt.mjs +21 -1
- package/lib/resume-card.mjs +19 -0
- package/lib/run-log.mjs +30 -2
- package/lib/run.mjs +198 -25
- package/lib/verify.mjs +32 -1
- package/lib/waves.mjs +161 -19
- package/package.json +2 -2
- package/shared/TEST-PROFILE.md +1 -1
- package/skills/bootstrap/SKILL.md +11 -4
- package/skills/fix-tests/SKILL.md +1 -0
- package/skills/graph/SKILL.md +12 -1
- package/workflows/ship.js +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { rel } from '../paths.mjs';
|
|
3
|
+
import { readIndexed, rel } from '../paths.mjs';
|
|
4
4
|
|
|
5
5
|
const DEFAULT_SKIP_DIRS = new Set([
|
|
6
6
|
'node_modules',
|
|
@@ -39,6 +39,9 @@ const SOURCE_EXTS = new Set([
|
|
|
39
39
|
'.pyi',
|
|
40
40
|
'.sh',
|
|
41
41
|
'.bash',
|
|
42
|
+
'.cs',
|
|
43
|
+
'.csproj',
|
|
44
|
+
'.bicep',
|
|
42
45
|
]);
|
|
43
46
|
|
|
44
47
|
/**
|
|
@@ -65,12 +68,24 @@ const CONFIG_JSON_NAMES = new Set([
|
|
|
65
68
|
|
|
66
69
|
const CONFIG_JSON_MAX_BYTES = 64 * 1024;
|
|
67
70
|
|
|
68
|
-
function loadIgnorePatterns(root) {
|
|
71
|
+
function loadIgnorePatterns(root, skips) {
|
|
69
72
|
const patterns = [];
|
|
70
73
|
for (const name of ['.gitignore', '.interlockignore']) {
|
|
71
74
|
const p = path.join(root, name);
|
|
72
75
|
if (!fs.existsSync(p)) continue;
|
|
73
|
-
|
|
76
|
+
let text;
|
|
77
|
+
if (skips) {
|
|
78
|
+
// An unreadable ignore file contributes no patterns; the walk goes on as if it were absent.
|
|
79
|
+
const got = readIndexed(p);
|
|
80
|
+
if (!got.ok) {
|
|
81
|
+
skips.add(p, got.code, 'its patterns were not applied');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
text = got.text;
|
|
85
|
+
} else {
|
|
86
|
+
text = fs.readFileSync(p, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
for (const line of text.split('\n')) {
|
|
74
89
|
const t = line.trim();
|
|
75
90
|
if (!t || t.startsWith('#')) continue;
|
|
76
91
|
patterns.push(t.replace(/\/$/, ''));
|
|
@@ -126,19 +141,26 @@ function shouldSkipDir(root, dir, entName, relPath) {
|
|
|
126
141
|
|
|
127
142
|
/**
|
|
128
143
|
* Walk root for source + curated config files. Returns absolute paths.
|
|
144
|
+
*
|
|
145
|
+
* `skips` is an optional collector (`createSkips`): with one, a directory that
|
|
146
|
+
* cannot be listed, a `.json` that cannot be stat'ed and an unreadable ignore
|
|
147
|
+
* file are recorded instead of passing in silence.
|
|
129
148
|
*/
|
|
130
|
-
export function walkSourceFiles(root, { extraSkip = [] } = {}) {
|
|
149
|
+
export function walkSourceFiles(root, { extraSkip = [], skips } = {}) {
|
|
131
150
|
const skipExtra = new Set(extraSkip);
|
|
132
|
-
const patterns = loadIgnorePatterns(root);
|
|
151
|
+
const patterns = loadIgnorePatterns(root, skips);
|
|
133
152
|
const out = [];
|
|
134
153
|
|
|
135
154
|
function visit(dir) {
|
|
136
155
|
let entries;
|
|
137
156
|
try {
|
|
138
157
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
139
|
-
} catch {
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (skips) skips.add(dir, error);
|
|
140
160
|
return;
|
|
141
161
|
}
|
|
162
|
+
// A .NET build writes generated sources under bin/ and obj/ next to the project file.
|
|
163
|
+
const holdsProject = entries.some((e) => e.name.endsWith('.csproj') && !e.isDirectory());
|
|
142
164
|
for (const ent of entries) {
|
|
143
165
|
const full = path.join(dir, ent.name);
|
|
144
166
|
const r = rel(root, full);
|
|
@@ -146,6 +168,7 @@ export function walkSourceFiles(root, { extraSkip = [] } = {}) {
|
|
|
146
168
|
if (skipExtra.has(ent.name) || shouldSkipDir(root, dir, ent.name, r) || ignored(r, patterns)) {
|
|
147
169
|
continue;
|
|
148
170
|
}
|
|
171
|
+
if (holdsProject && (ent.name === 'bin' || ent.name === 'obj')) continue;
|
|
149
172
|
visit(full);
|
|
150
173
|
continue;
|
|
151
174
|
}
|
|
@@ -161,7 +184,8 @@ export function walkSourceFiles(root, { extraSkip = [] } = {}) {
|
|
|
161
184
|
let size = 0;
|
|
162
185
|
try {
|
|
163
186
|
size = fs.statSync(full).size;
|
|
164
|
-
} catch {
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (skips) skips.add(full, error);
|
|
165
189
|
continue;
|
|
166
190
|
}
|
|
167
191
|
if (isConfigJson(ent.name, r, size)) out.push(full);
|
|
@@ -176,7 +200,7 @@ export function walkSourceFiles(root, { extraSkip = [] } = {}) {
|
|
|
176
200
|
// Accepts directories or individual markdown files, so callers can mix a docs
|
|
177
201
|
// directory with root-level files such as README.md — on many repos those root
|
|
178
202
|
// files are the only real documentation.
|
|
179
|
-
export function walkMarkdown(root, subdirs) {
|
|
203
|
+
export function walkMarkdown(root, subdirs, { skips } = {}) {
|
|
180
204
|
const out = [];
|
|
181
205
|
for (const sub of subdirs) {
|
|
182
206
|
const base = path.join(root, sub);
|
|
@@ -186,24 +210,25 @@ export function walkMarkdown(root, subdirs) {
|
|
|
186
210
|
} catch {
|
|
187
211
|
continue;
|
|
188
212
|
}
|
|
189
|
-
if (stat.isDirectory()) walkMd(base, out);
|
|
213
|
+
if (stat.isDirectory()) walkMd(base, out, skips);
|
|
190
214
|
else if (/\.mdx?$/i.test(base)) out.push(base);
|
|
191
215
|
}
|
|
192
216
|
return [...new Set(out)].sort();
|
|
193
217
|
}
|
|
194
218
|
|
|
195
|
-
function walkMd(dir, out) {
|
|
219
|
+
function walkMd(dir, out, skips) {
|
|
196
220
|
let entries;
|
|
197
221
|
try {
|
|
198
222
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
199
|
-
} catch {
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (skips) skips.add(dir, error);
|
|
200
225
|
return;
|
|
201
226
|
}
|
|
202
227
|
for (const ent of entries) {
|
|
203
228
|
const full = path.join(dir, ent.name);
|
|
204
229
|
if (ent.isDirectory()) {
|
|
205
230
|
if (ent.name === 'node_modules' || ent.name === '.git' || ent.name === '__pycache__') continue;
|
|
206
|
-
walkMd(full, out);
|
|
231
|
+
walkMd(full, out, skips);
|
|
207
232
|
continue;
|
|
208
233
|
}
|
|
209
234
|
if (ent.isFile() && ent.name.endsWith('.md')) out.push(full);
|
|
@@ -5,15 +5,17 @@ import { extractOpenspec } from '../extract/openspec.mjs';
|
|
|
5
5
|
import { extractMemory } from '../extract/memory.mjs';
|
|
6
6
|
import { walkSourceFiles } from '../extract/walk.mjs';
|
|
7
7
|
import {
|
|
8
|
+
createSkips,
|
|
8
9
|
ensureGraphDir,
|
|
9
10
|
graphPath,
|
|
10
11
|
loadJson,
|
|
11
12
|
manifestPath,
|
|
13
|
+
readIndexed,
|
|
12
14
|
rel,
|
|
13
15
|
reportPath,
|
|
14
|
-
sha256File,
|
|
15
16
|
writeJson,
|
|
16
17
|
} from '../paths.mjs';
|
|
18
|
+
import { linkCsharp } from './link-csharp.mjs';
|
|
17
19
|
import { writeReport } from './report.mjs';
|
|
18
20
|
|
|
19
21
|
function langOf(filePath) {
|
|
@@ -21,6 +23,9 @@ function langOf(filePath) {
|
|
|
21
23
|
if (ext === '.py' || ext === '.pyi') return 'python';
|
|
22
24
|
if (ext === '.sh' || ext === '.bash') return 'shell';
|
|
23
25
|
if (ext === '.json') return 'json';
|
|
26
|
+
if (ext === '.cs') return 'csharp';
|
|
27
|
+
if (ext === '.csproj') return 'csproj';
|
|
28
|
+
if (ext === '.bicep') return 'bicep';
|
|
24
29
|
return 'js';
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -106,6 +111,34 @@ function resolveJsImport(fromFile, specifier, fileIndex) {
|
|
|
106
111
|
return { kind: 'unresolved', id: `unresolved:${candidate}`, path: candidate };
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A project path is an identity compared in a Set, so it is normalized here and
|
|
116
|
+
* nowhere else: backslashes become forward slashes and the result is resolved
|
|
117
|
+
* against the referencing project's directory. A value holding an MSBuild
|
|
118
|
+
* property, and one that names no walked file, is unresolved — no edge to a
|
|
119
|
+
* file is invented for it.
|
|
120
|
+
*/
|
|
121
|
+
function resolveCsprojImport(fromFile, specifier, fileIndex) {
|
|
122
|
+
const spelled = String(specifier).replace(/\\/g, '/');
|
|
123
|
+
if (spelled.includes('$(') || path.posix.isAbsolute(spelled) || /^[A-Za-z]:\//.test(spelled)) {
|
|
124
|
+
return { kind: 'unresolved', id: `unresolved:${spelled}`, path: spelled };
|
|
125
|
+
}
|
|
126
|
+
const candidate = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spelled));
|
|
127
|
+
if (fileIndex.has(candidate)) return { kind: 'file', id: `file:${candidate}`, path: candidate };
|
|
128
|
+
return { kind: 'unresolved', id: `unresolved:${candidate}`, path: candidate };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A registry (`br:`, `br/<alias>:`) or template-spec (`ts:`, `ts/<alias>:`) module reference.
|
|
132
|
+
const BICEP_EXTERNAL_RE = /^(?:br|ts)(?::|\/[^/:]+:)/;
|
|
133
|
+
|
|
134
|
+
function resolveBicepImport(fromFile, specifier, fileIndex) {
|
|
135
|
+
const spec = String(specifier);
|
|
136
|
+
if (BICEP_EXTERNAL_RE.test(spec)) return { kind: 'external', id: `external:${spec}` };
|
|
137
|
+
const candidate = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
|
|
138
|
+
if (fileIndex.has(candidate)) return { kind: 'file', id: `file:${candidate}`, path: candidate };
|
|
139
|
+
return { kind: 'unresolved', id: `unresolved:${candidate}`, path: candidate };
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
/**
|
|
110
143
|
* Resolve an import/require/source specifier against an importing file.
|
|
111
144
|
*/
|
|
@@ -113,6 +146,11 @@ export function resolveImport(fromFile, specifier, fileIndex) {
|
|
|
113
146
|
const lang = langOf(fromFile);
|
|
114
147
|
if (lang === 'python') return resolvePythonImport(fromFile, specifier, fileIndex);
|
|
115
148
|
if (lang === 'shell') return resolveShellImport(fromFile, specifier, fileIndex);
|
|
149
|
+
if (lang === 'csproj') return resolveCsprojImport(fromFile, specifier, fileIndex);
|
|
150
|
+
if (lang === 'bicep') return resolveBicepImport(fromFile, specifier, fileIndex);
|
|
151
|
+
// A C# `using` names a namespace, not a path; linkCsharp places it. Nothing
|
|
152
|
+
// is resolved against the file set here.
|
|
153
|
+
if (lang === 'csharp') return { kind: 'external', id: `external:${specifier}` };
|
|
116
154
|
if (lang === 'json') {
|
|
117
155
|
// package.json main/bin paths are usually relative to package root
|
|
118
156
|
if (fileIndex.has(specifier)) return { kind: 'file', id: `file:${specifier}`, path: specifier };
|
|
@@ -141,17 +179,21 @@ function moduleIdFor(filePath) {
|
|
|
141
179
|
}
|
|
142
180
|
|
|
143
181
|
/**
|
|
144
|
-
* Build a full graph for root. Returns { graph, stats, extractorMode }.
|
|
182
|
+
* Build a full graph for root. Returns { graph, stats, hashes, skipped, extractorMode }.
|
|
183
|
+
*
|
|
184
|
+
* `skips` is the command's collector (`createSkips`). A path under the root that
|
|
185
|
+
* cannot be read is added to it and contributes nothing: no hash, no node, no
|
|
186
|
+
* edge, and no place in the file set imports resolve against. `skipped` is the
|
|
187
|
+
* collector's list when the build ends.
|
|
145
188
|
*/
|
|
146
|
-
export function buildGraph(root, { fileFilter = null } = {}) {
|
|
189
|
+
export function buildGraph(root, { fileFilter = null, skips = createSkips(root) } = {}) {
|
|
147
190
|
const extractor = createExtractor(root);
|
|
148
|
-
let files = walkSourceFiles(root);
|
|
191
|
+
let files = walkSourceFiles(root, { skips });
|
|
149
192
|
if (fileFilter) {
|
|
150
193
|
const allow = new Set(fileFilter);
|
|
151
194
|
files = files.filter((f) => allow.has(rel(root, f)));
|
|
152
195
|
}
|
|
153
196
|
|
|
154
|
-
const fileIndex = new Set(files.map((f) => rel(root, f)));
|
|
155
197
|
const nodes = new Map();
|
|
156
198
|
const edges = [];
|
|
157
199
|
const symbolByName = new Map(); // name -> [symbol ids]
|
|
@@ -161,11 +203,24 @@ export function buildGraph(root, { fileFilter = null } = {}) {
|
|
|
161
203
|
if (!nodes.has(node.id)) nodes.set(node.id, node);
|
|
162
204
|
}
|
|
163
205
|
|
|
206
|
+
// Read every file once, before anything is resolved: the file set an import
|
|
207
|
+
// resolves against must hold only what was actually read.
|
|
208
|
+
const read = [];
|
|
164
209
|
for (const absPath of files) {
|
|
165
210
|
const filePath = rel(root, absPath);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
211
|
+
const got = readIndexed(absPath);
|
|
212
|
+
if (!got.ok) {
|
|
213
|
+
skips.add(absPath, got.code);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
hashes[filePath] = got.sha256;
|
|
217
|
+
read.push({ filePath, extracted: extractor.extract(filePath, got.text) });
|
|
218
|
+
}
|
|
219
|
+
const fileIndex = new Set(read.map((r) => r.filePath));
|
|
220
|
+
const csharpRecords = [];
|
|
221
|
+
|
|
222
|
+
for (const { filePath, extracted } of read) {
|
|
223
|
+
if (extracted.csharp) csharpRecords.push({ filePath, csharp: extracted.csharp });
|
|
169
224
|
|
|
170
225
|
addNode({
|
|
171
226
|
id: `file:${filePath}`,
|
|
@@ -276,8 +331,14 @@ export function buildGraph(root, { fileFilter = null } = {}) {
|
|
|
276
331
|
}
|
|
277
332
|
}
|
|
278
333
|
|
|
334
|
+
// C# is linked across files: a `using` names a namespace, and a namespace is
|
|
335
|
+
// declared in many files. External targets it names get their nodes below.
|
|
336
|
+
const csharp = linkCsharp(csharpRecords);
|
|
337
|
+
for (const n of csharp.nodes) addNode(n);
|
|
338
|
+
for (const e of csharp.edges) edges.push(e);
|
|
339
|
+
|
|
279
340
|
const knownSymbols = new Set(symbolByName.keys());
|
|
280
|
-
const openspec = extractOpenspec(root, knownSymbols, fileIndex);
|
|
341
|
+
const openspec = extractOpenspec(root, knownSymbols, fileIndex, { skips });
|
|
281
342
|
for (const n of openspec.nodes) addNode(n);
|
|
282
343
|
for (const e of openspec.edges) {
|
|
283
344
|
if (e.target.startsWith('symbol-name:')) {
|
|
@@ -292,7 +353,7 @@ export function buildGraph(root, { fileFilter = null } = {}) {
|
|
|
292
353
|
}
|
|
293
354
|
}
|
|
294
355
|
|
|
295
|
-
const memory = extractMemory(root, fileIndex);
|
|
356
|
+
const memory = extractMemory(root, fileIndex, { skips });
|
|
296
357
|
for (const n of memory.nodes) addNode(n);
|
|
297
358
|
for (const e of memory.edges) {
|
|
298
359
|
if (nodes.has(e.source) && nodes.has(e.target)) edges.push(e);
|
|
@@ -349,18 +410,21 @@ export function buildGraph(root, { fileFilter = null } = {}) {
|
|
|
349
410
|
extractor: extractor.mode,
|
|
350
411
|
};
|
|
351
412
|
|
|
352
|
-
return { graph, stats, hashes, extractorMode: extractor.mode };
|
|
413
|
+
return { graph, stats, hashes, skipped: skips.list(), extractorMode: extractor.mode };
|
|
353
414
|
}
|
|
354
415
|
|
|
355
|
-
export function writeGraphArtifacts(root, { graph, stats, hashes }) {
|
|
416
|
+
export function writeGraphArtifacts(root, { graph, stats, hashes, skipped = [] }) {
|
|
356
417
|
ensureGraphDir(root);
|
|
357
418
|
writeJson(graphPath(root), graph);
|
|
419
|
+
// The manifest keeps the path and the code; a skip's note is said on stderr.
|
|
420
|
+
const recorded = skipped.map(({ path: skippedPath, code }) => ({ path: skippedPath, code }));
|
|
358
421
|
writeJson(manifestPath(root), {
|
|
359
422
|
built_at: graph.graph.built_at,
|
|
360
423
|
files: hashes,
|
|
424
|
+
skipped: recorded,
|
|
361
425
|
stats,
|
|
362
426
|
});
|
|
363
|
-
const report = writeReport(root, graph, stats);
|
|
427
|
+
const report = writeReport(root, graph, stats, recorded);
|
|
364
428
|
fs.writeFileSync(reportPath(root), report);
|
|
365
429
|
return { graphPath: graphPath(root), reportPath: reportPath(root), stats };
|
|
366
430
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// C# is linked in a pass after the per-file loop: a `using` names a namespace, and a
|
|
2
|
+
// namespace is declared across many files, so it cannot be resolved against a set of
|
|
3
|
+
// paths. This module is pure (no `node:` import) -- it takes every `extractCsharp`
|
|
4
|
+
// record and returns `{ nodes, edges }`, the shape the OpenSpec and memory overlays
|
|
5
|
+
// already return.
|
|
6
|
+
|
|
7
|
+
const INFERRED_REFERENCE_WEIGHT = 0.5;
|
|
8
|
+
|
|
9
|
+
function nameOf(entry) {
|
|
10
|
+
return typeof entry === 'string' ? entry : entry?.name;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function lineOf(entry, fallback = 1) {
|
|
14
|
+
const line = entry && typeof entry === 'object' ? entry.line : undefined;
|
|
15
|
+
return Number.isInteger(line) && line > 0 ? line : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ascending(lines) {
|
|
19
|
+
if (!Array.isArray(lines)) return [];
|
|
20
|
+
return lines.filter((n) => Number.isInteger(n)).sort((a, b) => a - b);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `A.B.C` -> ['A.B.C', 'A.B', 'A'] */
|
|
24
|
+
function dotPrefixes(name) {
|
|
25
|
+
const parts = name.split('.');
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = parts.length; i > 0; i -= 1) out.push(parts.slice(0, i).join('.'));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function compare(a, b) {
|
|
32
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Link C# records into namespace nodes, test nodes and their edges.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<{filePath: string, csharp?: object}>} records `extractCsharp` results
|
|
39
|
+
* @returns {{ nodes: object[], edges: object[] }}
|
|
40
|
+
*/
|
|
41
|
+
export function linkCsharp(records) {
|
|
42
|
+
const files = (Array.isArray(records) ? records : [])
|
|
43
|
+
.filter((r) => r && typeof r.filePath === 'string' && r.csharp)
|
|
44
|
+
.sort((a, b) => compare(a.filePath, b.filePath));
|
|
45
|
+
|
|
46
|
+
const nodes = [];
|
|
47
|
+
const edges = [];
|
|
48
|
+
const nodeIds = new Set();
|
|
49
|
+
const edgeKeys = new Set();
|
|
50
|
+
|
|
51
|
+
function addNode(node) {
|
|
52
|
+
if (nodeIds.has(node.id)) return;
|
|
53
|
+
nodeIds.add(node.id);
|
|
54
|
+
nodes.push(node);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addEdge(edge) {
|
|
58
|
+
const key = `${edge.source}\u0000${edge.target}\u0000${edge.relation}\u0000${edge.source_location}`;
|
|
59
|
+
if (edgeKeys.has(key)) return;
|
|
60
|
+
edgeKeys.add(key);
|
|
61
|
+
edges.push(edge);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pass 1: every namespace the repository declares, and the types each one holds.
|
|
65
|
+
const declared = new Set();
|
|
66
|
+
const typesByNamespace = new Map(); // namespace -> Map(typeName -> [declFile])
|
|
67
|
+
for (const rec of files) {
|
|
68
|
+
for (const ns of rec.csharp.namespaces || []) {
|
|
69
|
+
const name = nameOf(ns);
|
|
70
|
+
if (!name) continue;
|
|
71
|
+
declared.add(name);
|
|
72
|
+
const id = `namespace:${name}`;
|
|
73
|
+
if (!nodeIds.has(id)) {
|
|
74
|
+
// First declaring file in path order, its first declaration.
|
|
75
|
+
const first = (rec.csharp.namespaces || [])
|
|
76
|
+
.filter((n) => nameOf(n) === name)
|
|
77
|
+
.reduce((best, n) => Math.min(best, lineOf(n)), Infinity);
|
|
78
|
+
addNode({
|
|
79
|
+
id,
|
|
80
|
+
type: 'namespace',
|
|
81
|
+
label: name,
|
|
82
|
+
source_file: rec.filePath,
|
|
83
|
+
source_location: `L${first}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const type of rec.csharp.types || []) {
|
|
88
|
+
const name = nameOf(type);
|
|
89
|
+
if (!name) continue;
|
|
90
|
+
const ns = type && typeof type === 'object' && typeof type.namespace === 'string' ? type.namespace : '';
|
|
91
|
+
if (!typesByNamespace.has(ns)) typesByNamespace.set(ns, new Map());
|
|
92
|
+
const byName = typesByNamespace.get(ns);
|
|
93
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
94
|
+
if (!byName.get(name).includes(rec.filePath)) byName.get(name).push(rec.filePath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const declaredByLength = [...declared].sort((a, b) => b.length - a.length || compare(a, b));
|
|
99
|
+
function resolveNamespace(name) {
|
|
100
|
+
return declaredByLength.find((d) => name === d || name.startsWith(`${d}.`)) || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pass 2: edges, per file in path order.
|
|
104
|
+
for (const rec of files) {
|
|
105
|
+
const fileId = `file:${rec.filePath}`;
|
|
106
|
+
const cs = rec.csharp;
|
|
107
|
+
const visible = new Set();
|
|
108
|
+
|
|
109
|
+
for (const ns of cs.namespaces || []) {
|
|
110
|
+
const name = nameOf(ns);
|
|
111
|
+
if (!name) continue;
|
|
112
|
+
addEdge({
|
|
113
|
+
source: fileId,
|
|
114
|
+
target: `namespace:${name}`,
|
|
115
|
+
relation: 'declares',
|
|
116
|
+
confidence: 'EXTRACTED',
|
|
117
|
+
source_file: rec.filePath,
|
|
118
|
+
source_location: `L${lineOf(ns)}`,
|
|
119
|
+
weight: 1,
|
|
120
|
+
});
|
|
121
|
+
for (const p of dotPrefixes(name)) visible.add(p);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const using of cs.usings || []) {
|
|
125
|
+
const name = nameOf(using);
|
|
126
|
+
if (!name) continue;
|
|
127
|
+
const hit = resolveNamespace(name);
|
|
128
|
+
if (hit) visible.add(hit);
|
|
129
|
+
addEdge({
|
|
130
|
+
source: fileId,
|
|
131
|
+
target: hit ? `namespace:${hit}` : `external:${name}`,
|
|
132
|
+
relation: 'imports',
|
|
133
|
+
confidence: 'EXTRACTED',
|
|
134
|
+
source_file: rec.filePath,
|
|
135
|
+
source_location: `L${lineOf(using)}`,
|
|
136
|
+
weight: 1,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tests = [];
|
|
141
|
+
for (const t of cs.tests || []) {
|
|
142
|
+
if (!t || !t.name) continue;
|
|
143
|
+
const label = `${t.type}.${t.name}`;
|
|
144
|
+
const id = `test:${rec.filePath}#${label}`;
|
|
145
|
+
addNode({
|
|
146
|
+
id,
|
|
147
|
+
type: 'test',
|
|
148
|
+
label,
|
|
149
|
+
source_file: rec.filePath,
|
|
150
|
+
source_location: `L${lineOf(t)}`,
|
|
151
|
+
});
|
|
152
|
+
addEdge({
|
|
153
|
+
source: fileId,
|
|
154
|
+
target: id,
|
|
155
|
+
relation: 'exports',
|
|
156
|
+
confidence: 'EXTRACTED',
|
|
157
|
+
source_file: rec.filePath,
|
|
158
|
+
source_location: `L${lineOf(t)}`,
|
|
159
|
+
weight: 1,
|
|
160
|
+
});
|
|
161
|
+
tests.push({
|
|
162
|
+
id,
|
|
163
|
+
start: Number.isInteger(t.start) ? t.start : lineOf(t),
|
|
164
|
+
end: Number.isInteger(t.end) ? t.end : Infinity,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ownTypes = new Set((cs.types || []).map(nameOf).filter(Boolean));
|
|
169
|
+
const identifiers = cs.identifiers || {};
|
|
170
|
+
for (const ident of Object.keys(identifiers).sort()) {
|
|
171
|
+
if (ownTypes.has(ident)) continue;
|
|
172
|
+
const lines = ascending(identifiers[ident]);
|
|
173
|
+
if (lines.length === 0) continue;
|
|
174
|
+
const targets = new Set();
|
|
175
|
+
for (const ns of visible) {
|
|
176
|
+
for (const declFile of typesByNamespace.get(ns)?.get(ident) || []) {
|
|
177
|
+
targets.add(`symbol:${declFile}#${ident}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const target of [...targets].sort()) {
|
|
181
|
+
addEdge({
|
|
182
|
+
source: fileId,
|
|
183
|
+
target,
|
|
184
|
+
relation: 'references',
|
|
185
|
+
confidence: 'INFERRED',
|
|
186
|
+
source_file: rec.filePath,
|
|
187
|
+
source_location: `L${lines[0]}`,
|
|
188
|
+
weight: INFERRED_REFERENCE_WEIGHT,
|
|
189
|
+
name: ident,
|
|
190
|
+
});
|
|
191
|
+
for (const test of tests) {
|
|
192
|
+
const at = lines.find((n) => n >= test.start && n <= test.end);
|
|
193
|
+
if (at === undefined) continue;
|
|
194
|
+
addEdge({
|
|
195
|
+
source: test.id,
|
|
196
|
+
target,
|
|
197
|
+
relation: 'references',
|
|
198
|
+
confidence: 'INFERRED',
|
|
199
|
+
source_file: rec.filePath,
|
|
200
|
+
source_location: `L${at}`,
|
|
201
|
+
weight: INFERRED_REFERENCE_WEIGHT,
|
|
202
|
+
name: ident,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { nodes, edges };
|
|
210
|
+
}
|
|
@@ -134,6 +134,8 @@ export function consumers(graph, target) {
|
|
|
134
134
|
);
|
|
135
135
|
if (src?.type === 'file') {
|
|
136
136
|
lines.push(` consumer_file: ${src.source_file}`);
|
|
137
|
+
} else if (src?.type === 'test') {
|
|
138
|
+
lines.push(` consumer_test: ${src.source_file}:${src.source_location} ${src.label}`);
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generate GRAPH_REPORT.md content from a graph.
|
|
3
|
+
*
|
|
4
|
+
* `skipped` is the build's list of paths it could not read (`[{ path, code }]`).
|
|
5
|
+
* They are in no node, so the report names them in a section of their own; with
|
|
6
|
+
* none, the report is what it was before the section existed.
|
|
3
7
|
*/
|
|
4
|
-
export function writeReport(_root, graph, stats) {
|
|
8
|
+
export function writeReport(_root, graph, stats, skipped = []) {
|
|
5
9
|
const degree = new Map();
|
|
6
10
|
for (const n of graph.nodes) degree.set(n.id, { in: 0, out: 0, node: n });
|
|
7
11
|
for (const e of graph.links) {
|
|
@@ -60,6 +64,12 @@ export function writeReport(_root, graph, stats) {
|
|
|
60
64
|
lines.push(`- Symbols: ${stats.symbols}`);
|
|
61
65
|
lines.push(`- Modules: ${stats.modules}`);
|
|
62
66
|
lines.push('');
|
|
67
|
+
if (Array.isArray(skipped) && skipped.length > 0) {
|
|
68
|
+
lines.push('## Not indexed (unreadable)');
|
|
69
|
+
lines.push('');
|
|
70
|
+
for (const entry of skipped) lines.push(`- ${entry.path} (${entry.code})`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
63
73
|
lines.push('## God nodes (highest degree)');
|
|
64
74
|
lines.push('');
|
|
65
75
|
if (hubs.length === 0) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { extractMemory } from '../extract/memory.mjs';
|
|
2
|
+
import { extractOpenspec } from '../extract/openspec.mjs';
|
|
2
3
|
import { walkSourceFiles } from '../extract/walk.mjs';
|
|
3
|
-
import { loadJson, manifestPath,
|
|
4
|
+
import { createSkips, loadJson, manifestPath, readIndexed, rel } from '../paths.mjs';
|
|
4
5
|
import { buildGraph, writeGraphArtifacts } from './build.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -8,40 +9,79 @@ import { buildGraph, writeGraphArtifacts } from './build.mjs';
|
|
|
8
9
|
* (v1 keeps it simple — detect staleness, then full rebuild of affected set + dependents
|
|
9
10
|
* is complex; we do full rebuild when any hash differs, which is still fast for mid-size repos).
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* Readability is compared as well as content. A path that was skipped by the
|
|
13
|
+
* recorded build and is still unreadable is not a change: it can never be
|
|
14
|
+
* hashed, so without the manifest's record it would look changed on every run.
|
|
15
|
+
* A path that was indexed and can no longer be read, and one that was skipped
|
|
16
|
+
* and can now be read, each rebuild.
|
|
17
|
+
*
|
|
18
|
+
* `skips` is the command's collector (`createSkips`). It holds the currently
|
|
19
|
+
* unreadable paths when this returns, whether or not the graph was rebuilt.
|
|
20
|
+
*
|
|
21
|
+
* Returns { updated: boolean, stats, reason, skipped }.
|
|
12
22
|
*/
|
|
13
|
-
export function updateGraph(root) {
|
|
23
|
+
export function updateGraph(root, { skips = createSkips(root) } = {}) {
|
|
14
24
|
const manifest = loadJson(manifestPath(root), null);
|
|
15
25
|
if (!manifest?.files) {
|
|
16
|
-
const result = buildGraph(root);
|
|
26
|
+
const result = buildGraph(root, { skips });
|
|
17
27
|
writeGraphArtifacts(root, result);
|
|
18
|
-
return { updated: true, stats: result.stats, reason: 'no-manifest' };
|
|
28
|
+
return { updated: true, stats: result.stats, reason: 'no-manifest', skipped: result.skipped };
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
const currentFiles = walkSourceFiles(root).map((f) => rel(root, f));
|
|
22
31
|
const prevFiles = new Set(Object.keys(manifest.files));
|
|
23
32
|
const changed = [];
|
|
24
33
|
|
|
25
|
-
for (const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
for (const abs of walkSourceFiles(root, { skips })) {
|
|
35
|
+
const f = rel(root, abs);
|
|
36
|
+
const got = readIndexed(abs);
|
|
37
|
+
if (!got.ok) {
|
|
38
|
+
// Unreadable now, not removed. Whether that is news is decided below,
|
|
39
|
+
// against the recorded skips.
|
|
40
|
+
skips.add(abs, got.code);
|
|
41
|
+
prevFiles.delete(f);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (manifest.files[f] !== got.sha256) changed.push(f);
|
|
30
45
|
prevFiles.delete(f);
|
|
31
46
|
}
|
|
32
47
|
const removed = [...prevFiles];
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
// The overlays read prose the hashes above do not cover, and a build records
|
|
50
|
+
// what they could not read. Run the same readers so the two sets are compared
|
|
51
|
+
// like for like; what they extract is not used here.
|
|
52
|
+
extractOpenspec(root, new Set(), new Set(), { skips });
|
|
53
|
+
extractMemory(root, new Set(), { skips });
|
|
54
|
+
|
|
55
|
+
// A manifest written before skips were recorded reads as one with none.
|
|
56
|
+
const recorded = new Set(
|
|
57
|
+
(Array.isArray(manifest.skipped) ? manifest.skipped : []).map((entry) => entry?.path).filter(Boolean),
|
|
58
|
+
);
|
|
59
|
+
const current = skips.list();
|
|
60
|
+
const unreadableNow = current.filter((entry) => !recorded.has(entry.path)).map((entry) => entry.path);
|
|
61
|
+
const currentPaths = new Set(current.map((entry) => entry.path));
|
|
62
|
+
const readableNow = [...recorded].filter((p) => !currentPaths.has(p));
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
changed.length === 0 &&
|
|
66
|
+
removed.length === 0 &&
|
|
67
|
+
unreadableNow.length === 0 &&
|
|
68
|
+
readableNow.length === 0
|
|
69
|
+
) {
|
|
70
|
+
return { updated: false, stats: manifest.stats, reason: 'up-to-date', skipped: current };
|
|
36
71
|
}
|
|
37
72
|
|
|
38
|
-
const result = buildGraph(root);
|
|
73
|
+
const result = buildGraph(root, { skips });
|
|
39
74
|
writeGraphArtifacts(root, result);
|
|
75
|
+
const readability =
|
|
76
|
+
unreadableNow.length || readableNow.length
|
|
77
|
+
? ` unreadable=${unreadableNow.length} readable=${readableNow.length}`
|
|
78
|
+
: '';
|
|
40
79
|
return {
|
|
41
80
|
updated: true,
|
|
42
81
|
stats: result.stats,
|
|
43
|
-
reason: `changed=${changed.length} removed=${removed.length}`,
|
|
82
|
+
reason: `changed=${changed.length} removed=${removed.length}${readability}`,
|
|
44
83
|
changed,
|
|
45
84
|
removed,
|
|
85
|
+
skipped: result.skipped,
|
|
46
86
|
};
|
|
47
87
|
}
|