@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.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +52 -0
- package/README.md +31 -6
- package/bin/interlock +16 -5
- package/hooks/mod.mjs +10 -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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
}
|