@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
package/lib/graph/cli.mjs CHANGED
@@ -13,7 +13,16 @@ import {
13
13
  writeDocsIndex,
14
14
  } from './retrieve/docs-index.mjs';
15
15
  import { formatDocsResult, retrieveMarkdownSections } from './retrieve/markdown.mjs';
16
- import { graphPath, reportPath, resolveRoot } from './paths.mjs';
16
+ import {
17
+ createSkips,
18
+ formatSkip,
19
+ graphPath,
20
+ loadJson,
21
+ manifestPath,
22
+ PARTIAL_MARKER,
23
+ reportPath,
24
+ resolveRoot,
25
+ } from './paths.mjs';
17
26
  import { resolveStateHome } from '../state-home.mjs';
18
27
 
19
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -78,6 +87,51 @@ function graphRootFor(root) {
78
87
  return root;
79
88
  }
80
89
 
90
+ /**
91
+ * The root a query command reads its graph from, having said — once, on stderr —
92
+ * when that graph was built past paths it could not read (graph/unreadable-paths).
93
+ *
94
+ * The build named each skipped path when it skipped it. A query runs later, often
95
+ * in another session, and "no consumers" from a graph that never read the file is
96
+ * not evidence the file is unused. The count comes from the manifest beside the
97
+ * graph that is actually loaded, so a query served from the main checkout reports
98
+ * that graph's skips. A graph built with none, by a version that recorded none, or
99
+ * beside a manifest that cannot be read prints nothing: the query answers as it
100
+ * always did, and stdout and the exit code never move.
101
+ */
102
+ function queryGraphRoot(root) {
103
+ const from = graphRootFor(root);
104
+ let recorded;
105
+ try {
106
+ recorded = loadJson(manifestPath(from), null)?.skipped;
107
+ } catch {
108
+ recorded = null;
109
+ }
110
+ if (Array.isArray(recorded) && recorded.length > 0) {
111
+ process.stderr.write(
112
+ `${PARTIAL_MARKER}: ${recorded.length} path(s) were unreadable when this graph was built and ` +
113
+ `are not in it (GRAPH_REPORT.md lists them under "Not indexed") — no result for one of ` +
114
+ `them is not evidence it is unused; interlock-graph update re-reads them\n`,
115
+ );
116
+ }
117
+ return from;
118
+ }
119
+
120
+ /**
121
+ * Name every path the command could not read, one line each on stderr, after
122
+ * the command's stdout (graph/unreadable-paths). A reader never prints; the
123
+ * command does, here, so a skip is said once and the exit code does not move.
124
+ */
125
+ function speakSkips(skips) {
126
+ for (const entry of skips.list()) process.stderr.write(`${formatSkip(entry)}\n`);
127
+ }
128
+
129
+ /** ` skipped=<n>` for a summary line, and nothing at all on a clean tree. */
130
+ function skippedField(skips) {
131
+ const n = skips.list().length;
132
+ return n > 0 ? ` skipped=${n}` : '';
133
+ }
134
+
81
135
  function parseArgs(argv) {
82
136
  const args = {
83
137
  _: [],
@@ -142,19 +196,27 @@ function main() {
142
196
  break;
143
197
  }
144
198
  case 'build': {
145
- const result = buildGraph(root);
199
+ const skips = createSkips(root);
200
+ const result = buildGraph(root, { skips });
146
201
  const written = writeGraphArtifacts(root, result);
147
202
  process.stdout.write(
148
203
  `Built graph → ${written.graphPath}\nReport → ${written.reportPath}\n` +
149
- `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges} extractor=${result.stats.extractor}\n`,
204
+ `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges} extractor=${result.stats.extractor}${skippedField(skips)}\n`,
150
205
  );
151
206
  // An empty graph is a legitimate outcome — this repo is written in a
152
207
  // language the walker does not structurally index. Saying so is the
153
- // difference between "unsupported" and "broken".
154
- if (result.stats.files === 0) {
208
+ // difference between "unsupported" and "broken". An empty graph beside
209
+ // skipped paths is a third thing: blaming the language there would
210
+ // misname a permissions problem.
211
+ if (result.stats.files === 0 && skips.list().length > 0) {
212
+ process.stdout.write(
213
+ '\nNo source files were indexed: nothing indexable under the root could be read ' +
214
+ '(each skipped path is named on stderr).\n',
215
+ );
216
+ } else if (result.stats.files === 0) {
155
217
  process.stdout.write(
156
218
  '\nNo source files were indexed. Structural indexing covers ' +
157
- '.ts .tsx .js .jsx .mjs .cjs .py .pyi .sh .bash\n' +
219
+ '.ts .tsx .js .jsx .mjs .cjs .py .pyi .sh .bash .cs .csproj .bicep\n' +
158
220
  'plus curated config JSON. Other languages (Go, Rust, Java, Ruby) ' +
159
221
  'are not structurally indexed yet:\n' +
160
222
  'docs, OpenSpec specs and spec→file links still work, but there ' +
@@ -163,18 +225,22 @@ function main() {
163
225
  'prose retrieval on this repo.\n',
164
226
  );
165
227
  }
228
+ speakSkips(skips);
166
229
  break;
167
230
  }
168
231
  case 'update': {
169
- const result = updateGraph(root);
232
+ const skips = createSkips(root);
233
+ const result = updateGraph(root, { skips });
170
234
  if (!result.updated) {
171
- process.stdout.write(`Graph up to date (${result.reason})\n`);
235
+ process.stdout.write(`Graph up to date (${result.reason})${skippedField(skips)}\n`);
172
236
  } else {
173
237
  process.stdout.write(
174
238
  `Updated graph (${result.reason})\n` +
175
- `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges}\n`,
239
+ `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges}${skippedField(skips)}\n`,
176
240
  );
177
241
  }
242
+ // Said whether or not it rebuilt: a path still unreadable is still not indexed.
243
+ speakSkips(skips);
178
244
  break;
179
245
  }
180
246
  case 'report': {
@@ -187,7 +253,10 @@ function main() {
187
253
  modules: graph.nodes.filter((n) => n.type === 'module').length,
188
254
  extractor: graph.graph?.extractor,
189
255
  };
190
- const report = writeReport(root, graph, stats);
256
+ // Regenerated from the graph, which holds no skips: the manifest is
257
+ // their record, so the section survives a re-render.
258
+ const recorded = loadJson(manifestPath(root), null)?.skipped;
259
+ const report = writeReport(root, graph, stats, Array.isArray(recorded) ? recorded : []);
191
260
  fs.writeFileSync(reportPath(root), report);
192
261
  process.stdout.write(report);
193
262
  break;
@@ -195,7 +264,7 @@ function main() {
195
264
  case 'query': {
196
265
  const q = args._.slice(1).join(' ');
197
266
  if (!q) throw new Error('query requires a search string');
198
- const from = graphRootFor(root);
267
+ const from = queryGraphRoot(root);
199
268
  const graph = loadGraph(from);
200
269
  const seeds = findSeedNodes(graph, q);
201
270
  if (!seeds.length) {
@@ -214,32 +283,35 @@ function main() {
214
283
  case 'consumers': {
215
284
  const target = args._.slice(1).join(' ');
216
285
  if (!target) throw new Error('consumers requires a symbol or file');
217
- process.stdout.write(consumers(loadGraph(graphRootFor(root)), target));
286
+ process.stdout.write(consumers(loadGraph(queryGraphRoot(root)), target));
218
287
  break;
219
288
  }
220
289
  case 'path': {
221
290
  const a = args._[1];
222
291
  const b = args._[2];
223
292
  if (!a || !b) throw new Error('path requires two endpoints');
224
- process.stdout.write(shortestPath(loadGraph(graphRootFor(root)), a, b));
293
+ process.stdout.write(shortestPath(loadGraph(queryGraphRoot(root)), a, b));
225
294
  break;
226
295
  }
227
296
  case 'explain': {
228
297
  const q = args._.slice(1).join(' ');
229
298
  if (!q) throw new Error('explain requires a node query');
230
- process.stdout.write(explain(loadGraph(graphRootFor(root)), q));
299
+ process.stdout.write(explain(loadGraph(queryGraphRoot(root)), q));
231
300
  break;
232
301
  }
233
302
  case 'docs': {
234
303
  const q = args._.slice(1).join(' ');
235
304
  if (!q && args.changed.length === 0) throw new Error('docs requires a query or --changed paths');
305
+ const skips = createSkips(root);
236
306
  const result = retrieveMarkdownSections(root, {
237
307
  query: q,
238
308
  changedFiles: args.changed,
239
309
  dirs: args.dirs ?? undefined,
240
310
  budget: defaultBudget('docs', args),
311
+ skips,
241
312
  });
242
313
  process.stdout.write(formatDocsResult(result));
314
+ speakSkips(skips);
243
315
  if (result.empty) process.exit(1);
244
316
  break;
245
317
  }
@@ -248,8 +320,10 @@ function main() {
248
320
  if (!q && args.changed.length === 0 && !args.commitSubject) {
249
321
  throw new Error('context requires a query, --changed paths, or --commit-subject');
250
322
  }
323
+ const skips = createSkips(root);
251
324
  const result = retrieveContext(root, {
252
- graphRoot: graphRootFor(root),
325
+ skips,
326
+ graphRoot: queryGraphRoot(root),
253
327
  query: q,
254
328
  changedFiles: args.changed,
255
329
  commitSubject: args.commitSubject,
@@ -259,13 +333,16 @@ function main() {
259
333
  docsDirs: args.dirs ?? undefined,
260
334
  });
261
335
  process.stdout.write(formatContextResult(result));
336
+ speakSkips(skips);
262
337
  if (result.empty) process.exit(1);
263
338
  break;
264
339
  }
265
340
  case 'docs-index': {
266
341
  const write = args.write !== false;
342
+ const skips = createSkips(root);
267
343
  const { index, path: outPath, written } = writeDocsIndex(root, {
268
344
  write,
345
+ skips,
269
346
  // Omit rather than defaulting, so buildDocsIndex can probe for the
270
347
  // docs root this repo actually uses (doc/, site/, root README, …).
271
348
  dirs: args.dirs ?? undefined,
@@ -274,6 +351,7 @@ function main() {
274
351
  if (!written) {
275
352
  process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
276
353
  }
354
+ speakSkips(skips);
277
355
  break;
278
356
  }
279
357
  default:
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Regex-based Bicep extractor: module imports, param/var/resource/output
3
+ * declarations, and `<module>.outputs.<x>` references.
4
+ */
5
+
6
+ const MODULE_RE = /^[ \t]*module[ \t]+([A-Za-z_]\w*)[ \t]+'([^'\n]+)'[ \t]*=/;
7
+ const DECL_RE = /^[ \t]*(param|var|resource|output)[ \t]+([A-Za-z_]\w*)\b/;
8
+ const COMMENT_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*)/;
9
+
10
+ /**
11
+ * @param {string} filePath relative posix path
12
+ * @param {string} source
13
+ */
14
+ export function extractBicep(filePath, source) {
15
+ const imports = [];
16
+ const exports = [];
17
+ const lines = source.split(/\r?\n/);
18
+ const modules = new Map(); // module symbolic name -> target as written
19
+
20
+ for (let i = 0; i < lines.length; i += 1) {
21
+ const text = lines[i];
22
+ if (COMMENT_LINE_RE.test(text)) continue;
23
+ const line = i + 1;
24
+ let m = MODULE_RE.exec(text);
25
+ if (m) {
26
+ modules.set(m[1], m[2]);
27
+ imports.push({
28
+ from: m[2],
29
+ imported: null,
30
+ local: m[1],
31
+ line,
32
+ confidence: 'EXTRACTED',
33
+ });
34
+ continue;
35
+ }
36
+ m = DECL_RE.exec(text);
37
+ if (m) {
38
+ exports.push({ name: m[2], kind: m[1], line, confidence: 'EXTRACTED' });
39
+ }
40
+ }
41
+
42
+ if (modules.size > 0) {
43
+ const seen = new Set();
44
+ for (let i = 0; i < lines.length; i += 1) {
45
+ const text = lines[i];
46
+ if (COMMENT_LINE_RE.test(text)) continue;
47
+ for (const [name, target] of modules) {
48
+ const re = new RegExp(`(?<![\\w.])${name}(?:\\[[^\\]]*\\])?\\??\\.outputs\\.([A-Za-z_]\\w*)`, 'g');
49
+ let m;
50
+ while ((m = re.exec(text))) {
51
+ const key = `${name}\0${m[1]}`;
52
+ if (seen.has(key)) continue;
53
+ seen.add(key);
54
+ imports.push({
55
+ from: target,
56
+ imported: m[1],
57
+ local: null,
58
+ line: i + 1,
59
+ confidence: 'INFERRED',
60
+ });
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return { filePath, imports, exports, references: [] };
67
+ }
@@ -3,6 +3,9 @@ import { createJsExtractor } from './typescript.mjs';
3
3
  import { extractPython } from './python.mjs';
4
4
  import { extractShell } from './shell.mjs';
5
5
  import { extractConfigJson } from './config-json.mjs';
6
+ import { extractCsproj } from './csproj.mjs';
7
+ import { extractCsharp } from './csharp.mjs';
8
+ import { extractBicep } from './bicep.mjs';
6
9
 
7
10
  /**
8
11
  * Language-dispatching extractor. JS/TS uses typescript AST when available.
@@ -29,6 +32,18 @@ export function createExtractor(root) {
29
32
  modes.add('json');
30
33
  return extractConfigJson(filePath, source);
31
34
  }
35
+ if (ext === '.csproj') {
36
+ modes.add('csproj');
37
+ return extractCsproj(filePath, source);
38
+ }
39
+ if (ext === '.cs') {
40
+ modes.add('csharp');
41
+ return extractCsharp(filePath, source);
42
+ }
43
+ if (ext === '.bicep') {
44
+ modes.add('bicep');
45
+ return extractBicep(filePath, source);
46
+ }
32
47
  return js.extract(filePath, source);
33
48
  },
34
49
  };
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Regex-based C# extractor. Line-oriented, no parser: type declarations as
3
+ * exports, plus a `csharp` record (namespaces, using directives, types,
4
+ * identifiers, tests) that `graph/link-csharp.mjs` links across files.
5
+ *
6
+ * Known limits: nesting is not tracked, and a declaration-shaped line inside a
7
+ * verbatim or raw string literal reads as a declaration.
8
+ */
9
+
10
+ const COMMENT_LINE_RE = /^[ \t]*(?:\/\/|\*|\/\*)/;
11
+ const NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_][\w.]*)/;
12
+ const USING_RE =
13
+ /^[ \t]*(?:global[ \t]+)?using[ \t]+(?:static[ \t]+)?(?:[A-Za-z_]\w*[ \t]*=[ \t]*)?(?:global::)?([A-Za-z_][\w.]*)(?:<[^;]*>)?[ \t]*;/;
14
+ const MODIFIERS =
15
+ '(?:(?:public|private|protected|internal|static|sealed|abstract|partial|readonly|unsafe|new|file|ref|required|virtual)[ \\t]+)*';
16
+ const TYPE_RE = new RegExp(
17
+ `^[ \\t]*(?:\\[[^\\]\\n]*\\][ \\t]*)*${MODIFIERS}(class|record(?:[ \\t]+(?:class|struct))?|struct|interface|enum)[ \\t]+@?([A-Za-z_]\\w*)`,
18
+ );
19
+ const IDENT_RE = /[A-Za-z_][A-Za-z0-9_]*/g;
20
+ const TEST_ATTRIBUTES = new Set(['Fact', 'Theory', 'Test', 'TestCase', 'TestMethod', 'DataTestMethod']);
21
+ const MAX_GROUP_CHARS = 4000;
22
+
23
+ /** Index of the end of a string or char literal starting at `i`, inclusive. */
24
+ function skipLiteral(text, i) {
25
+ const quote = text[i];
26
+ const verbatim = quote === '"' && text[i - 1] === '@';
27
+ for (let j = i + 1; j < text.length; j += 1) {
28
+ const c = text[j];
29
+ if (verbatim) {
30
+ if (c === '"') {
31
+ if (text[j + 1] === '"') j += 1;
32
+ else return j;
33
+ }
34
+ } else if (c === '\\') {
35
+ j += 1;
36
+ } else if (c === quote || c === '\n') {
37
+ return j;
38
+ }
39
+ }
40
+ return text.length - 1;
41
+ }
42
+
43
+ /**
44
+ * Read the `[...]` group opening at `pos`. Returns the index of its closing
45
+ * bracket and the names of the attributes at its top level, or null when it
46
+ * does not close within a plausible span.
47
+ */
48
+ function scanGroup(text, pos) {
49
+ let depth = 0;
50
+ const names = [];
51
+ let segmentStart = pos + 1;
52
+ const flush = (end) => {
53
+ const seg = text.slice(segmentStart, end).trim();
54
+ const m = /^(?:[A-Za-z_]\w*[ \t]*:(?!:)[ \t]*)?([A-Za-z_][\w.]*)/.exec(seg);
55
+ if (!m) return;
56
+ let name = m[1].split('.').pop();
57
+ if (name.endsWith('Attribute') && name.length > 'Attribute'.length) name = name.slice(0, -'Attribute'.length);
58
+ names.push(name);
59
+ };
60
+ for (let i = pos; i < text.length && i - pos < MAX_GROUP_CHARS; i += 1) {
61
+ const c = text[i];
62
+ if (c === '"' || c === "'") {
63
+ i = skipLiteral(text, i);
64
+ } else if (c === '[' || c === '(' || c === '{') {
65
+ depth += 1;
66
+ } else if (c === ']' || c === ')' || c === '}') {
67
+ depth -= 1;
68
+ if (depth === 0 && c === ']') {
69
+ flush(i);
70
+ return { end: i, names };
71
+ }
72
+ if (depth <= 0) return null;
73
+ } else if (c === ',' && depth === 1) {
74
+ flush(i);
75
+ segmentStart = i + 1;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function lastLineOf(source, lines) {
82
+ return source.endsWith('\n') ? lines.length - 1 : lines.length;
83
+ }
84
+
85
+ /**
86
+ * @param {string} filePath relative posix path
87
+ * @param {string} source
88
+ */
89
+ export function extractCsharp(filePath, source) {
90
+ const exports = [];
91
+ const namespaces = [];
92
+ const usings = [];
93
+ const types = [];
94
+ const identifierLines = new Map();
95
+
96
+ const lines = source.split(/\r?\n/);
97
+ const lineStarts = [];
98
+ {
99
+ let off = 0;
100
+ for (const raw of source.split('\n')) {
101
+ lineStarts.push(off);
102
+ off += raw.length + 1;
103
+ }
104
+ }
105
+ const lineAt = (offset) => {
106
+ let lo = 0;
107
+ let hi = lineStarts.length - 1;
108
+ while (lo < hi) {
109
+ const mid = (lo + hi + 1) >> 1;
110
+ if (lineStarts[mid] <= offset) lo = mid;
111
+ else hi = mid - 1;
112
+ }
113
+ return lo + 1;
114
+ };
115
+
116
+ const isComment = lines.map((l) => COMMENT_LINE_RE.test(l));
117
+
118
+ for (let i = 0; i < lines.length; i += 1) {
119
+ if (isComment[i]) continue;
120
+ const text = lines[i];
121
+ const line = i + 1;
122
+
123
+ let m = NAMESPACE_RE.exec(text);
124
+ if (m) {
125
+ namespaces.push({ name: m[1], line });
126
+ } else if ((m = USING_RE.exec(text))) {
127
+ usings.push({ name: m[1], line });
128
+ } else if ((m = TYPE_RE.exec(text))) {
129
+ const name = m[2];
130
+ const kind = m[1].split(/\s+/)[0];
131
+ exports.push({ name, kind, line, confidence: 'EXTRACTED' });
132
+ let ns = '';
133
+ for (const n of namespaces) if (n.line <= line) ns = n.name;
134
+ types.push({ name, line, namespace: ns });
135
+ }
136
+
137
+ IDENT_RE.lastIndex = 0;
138
+ let im;
139
+ while ((im = IDENT_RE.exec(text))) {
140
+ const seen = identifierLines.get(im[0]);
141
+ if (!seen) identifierLines.set(im[0], [line]);
142
+ else if (seen[seen.length - 1] !== line) seen.push(line);
143
+ }
144
+ }
145
+ const identifiers = Object.fromEntries(identifierLines);
146
+
147
+ // Tests: an attribute stack whose top-level names include a test attribute,
148
+ // followed by a method signature.
149
+ const found = [];
150
+ for (let i = 0; i < lines.length; i += 1) {
151
+ if (isComment[i] || !/^[ \t]*\[/.test(lines[i])) continue;
152
+ let p = lineStarts[i] + lines[i].search(/\S/);
153
+ let testLine = 0;
154
+ let groups = 0;
155
+ for (;;) {
156
+ while (p < source.length && /\s/.test(source[p])) p += 1;
157
+ if (source.startsWith('//', p)) {
158
+ const nl = source.indexOf('\n', p);
159
+ p = nl === -1 ? source.length : nl;
160
+ continue;
161
+ }
162
+ if (source[p] !== '[') break;
163
+ const g = scanGroup(source, p);
164
+ if (!g) break;
165
+ groups += 1;
166
+ if (!testLine && g.names.some((n) => TEST_ATTRIBUTES.has(n))) testLine = lineAt(p);
167
+ p = g.end + 1;
168
+ }
169
+ if (groups === 0) continue;
170
+ const sigLine = lineAt(Math.min(p, source.length - 1));
171
+ i = Math.max(i, sigLine - 1);
172
+ if (!testLine) continue;
173
+
174
+ const head = source.slice(p, p + 300);
175
+ const paren = head.indexOf('(');
176
+ if (paren === -1) continue;
177
+ const before = head.slice(0, paren);
178
+ if (/[;{}=]/.test(before) || /\b(?:class|struct|interface|enum|record|namespace)\b/.test(before)) continue;
179
+ const nm = /([A-Za-z_]\w*)$/.exec(before.replace(/<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>\s*$/, '').trimEnd());
180
+ if (!nm) continue;
181
+ found.push({ name: nm[1], line: sigLine, start: testLine });
182
+ }
183
+
184
+ found.sort((a, b) => a.start - b.start);
185
+ const lastLine = lastLineOf(source, lines);
186
+ const tests = found.map((t, idx) => {
187
+ let type = '';
188
+ for (const ty of types) if (ty.line < t.start) type = ty.name;
189
+ const next = found[idx + 1];
190
+ return {
191
+ type,
192
+ name: t.name,
193
+ line: t.line,
194
+ start: t.start,
195
+ end: next ? next.start - 1 : lastLine,
196
+ };
197
+ });
198
+
199
+ return {
200
+ filePath,
201
+ imports: [],
202
+ exports,
203
+ references: [],
204
+ csharp: { namespaces, usings, types, identifiers, tests },
205
+ };
206
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Regex-based .csproj extractor: one `imports` entry per <ProjectReference Include>.
3
+ * The Include value is emitted as written (separators, `$(...)` and all);
4
+ * `resolveImport` normalizes it to a project identity.
5
+ */
6
+
7
+ const PROJECT_REFERENCE_RE =
8
+ /<ProjectReference\b[^>]*?\bInclude\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
9
+
10
+ /** Blank out XML comments, keeping every newline so line numbers hold. */
11
+ function blankComments(source) {
12
+ return source.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
13
+ }
14
+
15
+ function lineOf(text, index) {
16
+ let line = 1;
17
+ for (let i = text.indexOf('\n'); i !== -1 && i < index; i = text.indexOf('\n', i + 1)) line += 1;
18
+ return line;
19
+ }
20
+
21
+ /**
22
+ * @param {string} filePath relative posix path
23
+ * @param {string} source
24
+ */
25
+ export function extractCsproj(filePath, source) {
26
+ const imports = [];
27
+ const text = blankComments(source);
28
+ PROJECT_REFERENCE_RE.lastIndex = 0;
29
+ let m;
30
+ while ((m = PROJECT_REFERENCE_RE.exec(text))) {
31
+ const from = (m[1] ?? m[2] ?? '').trim();
32
+ if (!from) continue;
33
+ imports.push({
34
+ from,
35
+ imported: null,
36
+ local: null,
37
+ line: lineOf(text, m.index),
38
+ confidence: 'EXTRACTED',
39
+ });
40
+ }
41
+ return { filePath, imports, exports: [], references: [] };
42
+ }
@@ -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
  import { walkMarkdown } from './walk.mjs';
5
5
 
6
6
  const COUPLE_RE =
@@ -12,11 +12,11 @@ const FILE_PAIR_RE =
12
12
  /**
13
13
  * Extract memory nodes and coupling edges.
14
14
  */
15
- export function extractMemory(root, knownFiles = new Set()) {
15
+ export function extractMemory(root, knownFiles = new Set(), { skips } = {}) {
16
16
  const candidates = [];
17
17
  const memRoot = path.join(root, '.claude', 'memory');
18
18
  if (fs.existsSync(memRoot)) {
19
- candidates.push(...walkMarkdown(root, ['.claude/memory']));
19
+ candidates.push(...walkMarkdown(root, ['.claude/memory'], { skips }));
20
20
  }
21
21
  const legacy = path.join(root, '.claude', 'memory.md');
22
22
  if (fs.existsSync(legacy)) candidates.push(legacy);
@@ -27,7 +27,12 @@ export function extractMemory(root, knownFiles = new Set()) {
27
27
 
28
28
  for (const absPath of candidates) {
29
29
  const filePath = rel(root, absPath);
30
- const text = fs.readFileSync(absPath, 'utf8');
30
+ const read = readIndexed(absPath);
31
+ if (!read.ok) {
32
+ if (skips) skips.add(absPath, read.code);
33
+ continue;
34
+ }
35
+ const text = read.text;
31
36
  const id = `memory:${filePath}`;
32
37
  if (!seenNodes.has(id)) {
33
38
  seenNodes.add(id);
@@ -1,6 +1,5 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { rel } from '../paths.mjs';
2
+ import { readIndexed, rel } from '../paths.mjs';
4
3
  import { walkMarkdown } from './walk.mjs';
5
4
 
6
5
  // Any repo-relative path with an extension. Deliberately NOT restricted to a
@@ -16,14 +15,19 @@ const SYMBOL_RE = /`([A-Z][A-Za-z0-9_]{2,})`|\b([A-Z][A-Za-z0-9_]{2,}(?:Service|
16
15
  /**
17
16
  * Extract OpenSpec artifact nodes and inferred links to code paths/symbols.
18
17
  */
19
- export function extractOpenspec(root, knownSymbols = new Set(), knownFiles = new Set()) {
20
- const files = walkMarkdown(root, ['openspec']);
18
+ export function extractOpenspec(root, knownSymbols = new Set(), knownFiles = new Set(), { skips } = {}) {
19
+ const files = walkMarkdown(root, ['openspec'], { skips });
21
20
  const nodes = [];
22
21
  const edges = [];
23
22
 
24
23
  for (const absPath of files) {
25
24
  const filePath = rel(root, absPath);
26
- const text = fs.readFileSync(absPath, 'utf8');
25
+ const read = readIndexed(absPath);
26
+ if (!read.ok) {
27
+ if (skips) skips.add(absPath, read.code);
28
+ continue;
29
+ }
30
+ const text = read.text;
27
31
  const id = `openspec:${filePath}`;
28
32
  nodes.push({
29
33
  id,