@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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +28 -3
  4. package/bin/interlock +16 -5
  5. package/hooks/mod.mjs +307 -48
  6. package/lib/doctor.mjs +7 -6
  7. package/lib/graph/cli.mjs +93 -15
  8. package/lib/graph/extract/bicep.mjs +67 -0
  9. package/lib/graph/extract/create-extractor.mjs +15 -0
  10. package/lib/graph/extract/csharp.mjs +206 -0
  11. package/lib/graph/extract/csproj.mjs +42 -0
  12. package/lib/graph/extract/memory.mjs +9 -4
  13. package/lib/graph/extract/openspec.mjs +9 -5
  14. package/lib/graph/extract/walk.mjs +37 -12
  15. package/lib/graph/graph/build.mjs +77 -13
  16. package/lib/graph/graph/link-csharp.mjs +210 -0
  17. package/lib/graph/graph/query.mjs +2 -0
  18. package/lib/graph/graph/report.mjs +11 -1
  19. package/lib/graph/graph/update.mjs +56 -16
  20. package/lib/graph/paths.mjs +56 -4
  21. package/lib/graph/retrieve/context.mjs +2 -0
  22. package/lib/graph/retrieve/docs-index.mjs +9 -10
  23. package/lib/graph/retrieve/markdown.mjs +8 -8
  24. package/lib/limits.mjs +11 -3
  25. package/lib/outcomes.mjs +8 -1
  26. package/lib/prompts/implementer.mjs +28 -3
  27. package/lib/prompts/index.mjs +37 -1
  28. package/lib/prompts/rejected.mjs +24 -0
  29. package/lib/prompts/review.mjs +38 -1
  30. package/lib/receipt.mjs +21 -1
  31. package/lib/resume-card.mjs +19 -0
  32. package/lib/run-log.mjs +30 -2
  33. package/lib/run.mjs +198 -25
  34. package/lib/verify.mjs +32 -1
  35. package/lib/waves.mjs +161 -19
  36. package/package.json +2 -2
  37. package/shared/TEST-PROFILE.md +1 -1
  38. package/skills/bootstrap/SKILL.md +11 -4
  39. package/skills/fix-tests/SKILL.md +1 -0
  40. package/skills/graph/SKILL.md +12 -1
  41. 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
- for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
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
- hashes[filePath] = sha256File(absPath);
167
- const source = fs.readFileSync(absPath, 'utf8');
168
- const extracted = extractor.extract(filePath, source);
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 fs from 'node:fs';
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, rel, sha256File } from '../paths.mjs';
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
- * Returns { updated: boolean, stats, reason }.
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 f of currentFiles) {
26
- const abs = `${root}/${f}`;
27
- if (!fs.existsSync(abs)) continue;
28
- const hash = sha256File(abs);
29
- if (manifest.files[f] !== hash) changed.push(f);
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
- if (changed.length === 0 && removed.length === 0) {
35
- return { updated: false, stats: manifest.stats, reason: 'up-to-date' };
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
  }