@renzrollon/interlock 1.4.2 → 1.5.1

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 (40) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +52 -0
  3. package/README.md +31 -6
  4. package/bin/interlock +16 -5
  5. package/hooks/mod.mjs +10 -6
  6. package/lib/graph/cli.mjs +93 -15
  7. package/lib/graph/extract/bicep.mjs +67 -0
  8. package/lib/graph/extract/create-extractor.mjs +15 -0
  9. package/lib/graph/extract/csharp.mjs +206 -0
  10. package/lib/graph/extract/csproj.mjs +42 -0
  11. package/lib/graph/extract/memory.mjs +9 -4
  12. package/lib/graph/extract/openspec.mjs +9 -5
  13. package/lib/graph/extract/walk.mjs +37 -12
  14. package/lib/graph/graph/build.mjs +77 -13
  15. package/lib/graph/graph/link-csharp.mjs +210 -0
  16. package/lib/graph/graph/query.mjs +2 -0
  17. package/lib/graph/graph/report.mjs +11 -1
  18. package/lib/graph/graph/update.mjs +56 -16
  19. package/lib/graph/paths.mjs +56 -4
  20. package/lib/graph/retrieve/context.mjs +2 -0
  21. package/lib/graph/retrieve/docs-index.mjs +9 -10
  22. package/lib/graph/retrieve/markdown.mjs +8 -8
  23. package/lib/limits.mjs +11 -3
  24. package/lib/outcomes.mjs +8 -1
  25. package/lib/prompts/implementer.mjs +28 -3
  26. package/lib/prompts/index.mjs +37 -1
  27. package/lib/prompts/rejected.mjs +24 -0
  28. package/lib/prompts/review.mjs +38 -1
  29. package/lib/receipt.mjs +21 -1
  30. package/lib/resume-card.mjs +19 -0
  31. package/lib/run-log.mjs +30 -2
  32. package/lib/run.mjs +198 -25
  33. package/lib/verify.mjs +32 -1
  34. package/lib/waves.mjs +161 -19
  35. package/package.json +2 -2
  36. package/shared/TEST-PROFILE.md +1 -1
  37. package/skills/bootstrap/SKILL.md +11 -4
  38. package/skills/fix-tests/SKILL.md +1 -0
  39. package/skills/graph/SKILL.md +12 -1
  40. package/workflows/ship.js +2 -1
@@ -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,
@@ -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
  }