@renzrollon/interlock 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +47 -0
- package/README.md +28 -3
- package/bin/interlock +16 -5
- package/hooks/mod.mjs +307 -48
- package/lib/doctor.mjs +7 -6
- package/lib/graph/cli.mjs +93 -15
- package/lib/graph/extract/bicep.mjs +67 -0
- package/lib/graph/extract/create-extractor.mjs +15 -0
- package/lib/graph/extract/csharp.mjs +206 -0
- package/lib/graph/extract/csproj.mjs +42 -0
- package/lib/graph/extract/memory.mjs +9 -4
- package/lib/graph/extract/openspec.mjs +9 -5
- package/lib/graph/extract/walk.mjs +37 -12
- package/lib/graph/graph/build.mjs +77 -13
- package/lib/graph/graph/link-csharp.mjs +210 -0
- package/lib/graph/graph/query.mjs +2 -0
- package/lib/graph/graph/report.mjs +11 -1
- package/lib/graph/graph/update.mjs +56 -16
- package/lib/graph/paths.mjs +56 -4
- package/lib/graph/retrieve/context.mjs +2 -0
- package/lib/graph/retrieve/docs-index.mjs +9 -10
- package/lib/graph/retrieve/markdown.mjs +8 -8
- package/lib/limits.mjs +11 -3
- package/lib/outcomes.mjs +8 -1
- package/lib/prompts/implementer.mjs +28 -3
- package/lib/prompts/index.mjs +37 -1
- package/lib/prompts/rejected.mjs +24 -0
- package/lib/prompts/review.mjs +38 -1
- package/lib/receipt.mjs +21 -1
- package/lib/resume-card.mjs +19 -0
- package/lib/run-log.mjs +30 -2
- package/lib/run.mjs +198 -25
- package/lib/verify.mjs +32 -1
- package/lib/waves.mjs +161 -19
- package/package.json +2 -2
- package/shared/TEST-PROFILE.md +1 -1
- package/skills/bootstrap/SKILL.md +11 -4
- package/skills/fix-tests/SKILL.md +1 -0
- package/skills/graph/SKILL.md +12 -1
- package/workflows/ship.js +2 -1
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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,
|