simt-dev 0.2.2 → 0.3.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/CHANGELOG.md +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Compaction for output this tool itself produced (R18, ADR-095).
|
|
2
|
+
//
|
|
3
|
+
// Failures stay whole: the name, the assertion and the location are in the
|
|
4
|
+
// block that is printed. Passing tests become a count. A stream that is not
|
|
5
|
+
// one of the known formats is left alone, because a summary of an unknown
|
|
6
|
+
// log is how a failure disappears.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compact a known test report. Returns null when the text is not one.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} text
|
|
12
|
+
* @returns {string|null}
|
|
13
|
+
*/
|
|
14
|
+
export function compactOutput(text) {
|
|
15
|
+
const report = parseReport(text);
|
|
16
|
+
if (!report) return null;
|
|
17
|
+
const lines = [`failed ${report.failures.length}`];
|
|
18
|
+
for (const failure of report.failures) lines.push(failure);
|
|
19
|
+
lines.push(`passed ${report.passed}`);
|
|
20
|
+
if (report.skipped > 0) lines.push(`skipped ${report.skipped}`);
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseReport(text) {
|
|
25
|
+
const raw = String(text ?? '');
|
|
26
|
+
if (!raw.trim()) return null;
|
|
27
|
+
if (/^TAP version \d+/m.test(raw) || /^(?:not )?ok \d+/m.test(raw)) {
|
|
28
|
+
const tap = parseTap(raw);
|
|
29
|
+
if (tap) return tap;
|
|
30
|
+
}
|
|
31
|
+
const vitest = parseVitest(raw);
|
|
32
|
+
if (vitest) return vitest;
|
|
33
|
+
const go = parseGo(raw);
|
|
34
|
+
if (go) return go;
|
|
35
|
+
if (/<testcase\b/.test(raw)) return parseJunit(raw);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseTap(text) {
|
|
40
|
+
const lines = text.split('\n');
|
|
41
|
+
const failures = [];
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
let saw = false;
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const res = /^(\s*)(not ok|ok)\s+\d+(?:\s+-\s+(.*?))?(?:\s+#\s*(SKIP|TODO)\b.*)?\s*$/i.exec(lines[i]);
|
|
47
|
+
if (!res) continue;
|
|
48
|
+
saw = true;
|
|
49
|
+
const start = i > 0 && /^\s*# Subtest:/.test(lines[i - 1]) ? i - 1 : i;
|
|
50
|
+
let end = i;
|
|
51
|
+
if (i + 1 < lines.length && /^\s+---\s*$/.test(lines[i + 1])) {
|
|
52
|
+
end = i + 1;
|
|
53
|
+
while (end + 1 < lines.length && !/^\s+\.\.\.\s*$/.test(lines[end + 1])) end++;
|
|
54
|
+
if (end + 1 < lines.length) end++;
|
|
55
|
+
i = end;
|
|
56
|
+
}
|
|
57
|
+
const block = lines.slice(start, end + 1).join('\n');
|
|
58
|
+
if (/^\s*type:\s*['"]?suite['"]?\s*$/m.test(block)) continue;
|
|
59
|
+
if (res[4]) {
|
|
60
|
+
skipped++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (res[2].toLowerCase() === 'not ok') failures.push(block);
|
|
64
|
+
else passed++;
|
|
65
|
+
}
|
|
66
|
+
if (!saw) return null;
|
|
67
|
+
return { failures, passed, skipped };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseVitest(text) {
|
|
71
|
+
const raw = String(text).trim();
|
|
72
|
+
const start = raw.indexOf('{');
|
|
73
|
+
const end = raw.lastIndexOf('}');
|
|
74
|
+
if (start === -1 || end <= start) return null;
|
|
75
|
+
let doc;
|
|
76
|
+
try {
|
|
77
|
+
doc = JSON.parse(raw.slice(start, end + 1));
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(doc.testResults)) return null;
|
|
82
|
+
const failures = [];
|
|
83
|
+
let passed = 0;
|
|
84
|
+
let skipped = 0;
|
|
85
|
+
let saw = false;
|
|
86
|
+
for (const suite of doc.testResults) {
|
|
87
|
+
const file = typeof suite?.name === 'string' ? suite.name : '';
|
|
88
|
+
for (const assertion of suite?.assertionResults ?? []) {
|
|
89
|
+
const name = assertionName(assertion);
|
|
90
|
+
if (!name) continue;
|
|
91
|
+
saw = true;
|
|
92
|
+
const status = String(assertion.status ?? '');
|
|
93
|
+
if (status === 'passed') {
|
|
94
|
+
passed++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (status === 'pending' || status === 'skipped' || status === 'todo') {
|
|
98
|
+
skipped++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (status !== 'failed') continue;
|
|
102
|
+
const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages.filter(Boolean) : [];
|
|
103
|
+
const location = locationIn(messages.join('\n')) || fileLocation(file, assertion.location);
|
|
104
|
+
const lines = [`not ok ${name}`];
|
|
105
|
+
if (location) lines.push(` location: ${location}`);
|
|
106
|
+
if (messages.length > 0) lines.push(messages.join('\n'));
|
|
107
|
+
failures.push(lines.join('\n'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!saw) return null;
|
|
111
|
+
return { failures, passed, skipped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function assertionName(assertion) {
|
|
115
|
+
const parts = [...(assertion.ancestorTitles ?? []), assertion.title].filter(Boolean);
|
|
116
|
+
if (parts.length > 0) return parts.join(' > ');
|
|
117
|
+
return String(assertion.fullName ?? '').trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fileLocation(file, location) {
|
|
121
|
+
if (!file) return '';
|
|
122
|
+
const line = location && Number.isFinite(location.line) ? `:${location.line}` : '';
|
|
123
|
+
const column = location && Number.isFinite(location.column) ? `:${location.column}` : '';
|
|
124
|
+
return `${file}${line}${column}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseGo(text) {
|
|
128
|
+
const output = new Map();
|
|
129
|
+
const failures = [];
|
|
130
|
+
let passed = 0;
|
|
131
|
+
let skipped = 0;
|
|
132
|
+
let saw = false;
|
|
133
|
+
for (const line of String(text).split('\n')) {
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
if (!trimmed.startsWith('{')) continue;
|
|
136
|
+
let ev;
|
|
137
|
+
try {
|
|
138
|
+
ev = JSON.parse(trimmed);
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (typeof ev.Action !== 'string') continue;
|
|
143
|
+
if (typeof ev.Test !== 'string' || ev.Test.length === 0) continue;
|
|
144
|
+
saw = true;
|
|
145
|
+
const id = ev.Package ? `${ev.Package} > ${ev.Test}` : ev.Test;
|
|
146
|
+
if (ev.Action === 'output' && typeof ev.Output === 'string') {
|
|
147
|
+
output.set(id, `${output.get(id) ?? ''}${ev.Output}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ev.Action === 'pass') passed++;
|
|
151
|
+
else if (ev.Action === 'skip') skipped++;
|
|
152
|
+
else if (ev.Action === 'fail') {
|
|
153
|
+
const body = (output.get(id) ?? '').trimEnd();
|
|
154
|
+
failures.push(body ? `not ok ${id}\n${body}` : `not ok ${id}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!saw) return null;
|
|
158
|
+
return { failures, passed, skipped };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseJunit(text) {
|
|
162
|
+
const failures = [];
|
|
163
|
+
let passed = 0;
|
|
164
|
+
let skipped = 0;
|
|
165
|
+
let saw = false;
|
|
166
|
+
const tagRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
167
|
+
for (const match of String(text).matchAll(tagRe)) {
|
|
168
|
+
const attrs = match[1] ?? '';
|
|
169
|
+
const inner = match[2] ?? '';
|
|
170
|
+
const name = xmlAttr(attrs, 'name');
|
|
171
|
+
if (!name) continue;
|
|
172
|
+
saw = true;
|
|
173
|
+
const classname = xmlAttr(attrs, 'classname');
|
|
174
|
+
const id = classname ? `${classname} > ${name}` : name;
|
|
175
|
+
if (/<skipped\b/i.test(inner)) {
|
|
176
|
+
skipped++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!/<(?:failure|error)\b/i.test(inner)) {
|
|
180
|
+
passed++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const file = xmlAttr(attrs, 'file');
|
|
184
|
+
const line = xmlAttr(attrs, 'line');
|
|
185
|
+
const fromAttrs = file ? `${file}${line ? `:${line}` : ''}` : '';
|
|
186
|
+
const body = failureBody(inner);
|
|
187
|
+
const location = fromAttrs || locationIn(body);
|
|
188
|
+
const lines = [`not ok ${id}`];
|
|
189
|
+
if (location) lines.push(` location: ${location}`);
|
|
190
|
+
if (body) lines.push(body);
|
|
191
|
+
failures.push(lines.join('\n'));
|
|
192
|
+
}
|
|
193
|
+
if (!saw) return null;
|
|
194
|
+
return { failures, passed, skipped };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function failureBody(inner) {
|
|
198
|
+
const chunks = [];
|
|
199
|
+
const re = /<(?:failure|error)\b([^>]*)(?:\/>|>([\s\S]*?)<\/(?:failure|error)>)/gi;
|
|
200
|
+
for (const match of String(inner).matchAll(re)) {
|
|
201
|
+
const message = xmlAttr(match[1] ?? '', 'message');
|
|
202
|
+
const body = decode(match[2] ?? '').trim();
|
|
203
|
+
if (message) chunks.push(message);
|
|
204
|
+
if (body && body !== message) chunks.push(body);
|
|
205
|
+
}
|
|
206
|
+
if (chunks.length > 0) return chunks.join('\n');
|
|
207
|
+
return decode(inner).trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function locationIn(text) {
|
|
211
|
+
const match = /[\w./-]+\.(?:java|kt|py|go|mjs|js|ts|tsx):\d+(?::\d+)?/.exec(text);
|
|
212
|
+
return match ? match[0] : '';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function xmlAttr(attrs, key) {
|
|
216
|
+
const match = new RegExp(`\\b${key}=["']([^"']*)["']`).exec(attrs);
|
|
217
|
+
if (!match) return '';
|
|
218
|
+
return decode(match[1]).trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function decode(text) {
|
|
222
|
+
return String(text)
|
|
223
|
+
.replaceAll('"', '"')
|
|
224
|
+
.replaceAll(''', "'")
|
|
225
|
+
.replaceAll('<', '<')
|
|
226
|
+
.replaceAll('>', '>')
|
|
227
|
+
.replaceAll('&', '&');
|
|
228
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Line extractors for the languages the index maps (plan 0003 R21, ADR-090).
|
|
2
|
+
//
|
|
3
|
+
// A structural parser is more accurate and is not a dependency. These read one
|
|
4
|
+
// line at a time: an export, a definition, an import. That is enough for a map
|
|
5
|
+
// and an import graph, and it is the same result on every machine.
|
|
6
|
+
|
|
7
|
+
const IDENT = '[A-Za-z_$][\\w$]*';
|
|
8
|
+
|
|
9
|
+
const EXTS = {
|
|
10
|
+
javascript: ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx'],
|
|
11
|
+
typescript: ['.ts', '.tsx', '.js', '.mjs', '.jsx'],
|
|
12
|
+
kotlin: ['.kt', '.kts'],
|
|
13
|
+
java: ['.java'],
|
|
14
|
+
python: ['.py'],
|
|
15
|
+
go: ['.go'],
|
|
16
|
+
rust: ['.rs'],
|
|
17
|
+
swift: ['.swift'],
|
|
18
|
+
dart: ['.dart'],
|
|
19
|
+
ruby: ['.rb'],
|
|
20
|
+
csharp: ['.cs'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Symbols and import specifiers in one blob.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} filePath
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @param {string} lang
|
|
29
|
+
* @returns {{symbols: object[], imports: string[]}}
|
|
30
|
+
*/
|
|
31
|
+
export function extract(filePath, text, lang) {
|
|
32
|
+
const fn = BY_LANG[lang];
|
|
33
|
+
if (!fn || text.includes('\0')) return { symbols: [], imports: [] };
|
|
34
|
+
const found = fn(text);
|
|
35
|
+
const symbols = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const row of found.symbols) {
|
|
38
|
+
const key = `${row.name}\0${row.kind}\0${row.line}`;
|
|
39
|
+
if (seen.has(key)) continue;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
symbols.push({
|
|
42
|
+
path: filePath,
|
|
43
|
+
name: row.name,
|
|
44
|
+
kind: row.kind,
|
|
45
|
+
line: row.line,
|
|
46
|
+
sig: row.sig,
|
|
47
|
+
exported: row.exported ? 1 : 0,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
symbols.sort(bySymbol);
|
|
51
|
+
return { symbols, imports: found.imports };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Import specifiers become edges. A relative one points at a tracked file when one matches. */
|
|
55
|
+
export function resolveImports(filePath, imports, files, lang) {
|
|
56
|
+
const have = files instanceof Set ? files : new Set(files);
|
|
57
|
+
const edges = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const spec of imports) {
|
|
60
|
+
const dst = resolveOne(filePath, spec, have, lang);
|
|
61
|
+
if (seen.has(dst)) continue;
|
|
62
|
+
seen.add(dst);
|
|
63
|
+
edges.push({ src: filePath, dst, kind: 'import', weight: 1 });
|
|
64
|
+
}
|
|
65
|
+
edges.sort(byEdge);
|
|
66
|
+
return edges;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveOne(filePath, spec, files, lang) {
|
|
70
|
+
const rel = relativeSpec(spec);
|
|
71
|
+
if (rel === null) return spec;
|
|
72
|
+
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
|
73
|
+
const base = slash(dir, rel);
|
|
74
|
+
const candidates = [base];
|
|
75
|
+
const dot = base.lastIndexOf('.');
|
|
76
|
+
const bare = dot > base.lastIndexOf('/') ? null : base;
|
|
77
|
+
if (bare !== null) {
|
|
78
|
+
for (const ext of EXTS[lang] ?? []) {
|
|
79
|
+
candidates.push(bare + ext, `${bare}/index${ext}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
if (files.has(candidate)) return candidate;
|
|
84
|
+
}
|
|
85
|
+
return spec;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function relativeSpec(spec) {
|
|
89
|
+
if (spec.startsWith('./') || spec.startsWith('../')) return spec;
|
|
90
|
+
if (!spec.startsWith('.')) return null;
|
|
91
|
+
let dots = 0;
|
|
92
|
+
while (spec[dots] === '.') dots += 1;
|
|
93
|
+
const rest = spec.slice(dots).replaceAll('.', '/');
|
|
94
|
+
let prefix = '.';
|
|
95
|
+
for (let i = 1; i < dots; i += 1) prefix += '/..';
|
|
96
|
+
return rest === '' ? prefix : `${prefix}/${rest}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function slash(dir, rel) {
|
|
100
|
+
const parts = [];
|
|
101
|
+
if (dir !== '') parts.push(...dir.split('/'));
|
|
102
|
+
for (const part of rel.split('/')) {
|
|
103
|
+
if (part === '' || part === '.') continue;
|
|
104
|
+
if (part === '..') parts.pop();
|
|
105
|
+
else parts.push(part);
|
|
106
|
+
}
|
|
107
|
+
return parts.join('/');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bySymbol(a, b) {
|
|
111
|
+
return a.line - b.line || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function byEdge(a, b) {
|
|
115
|
+
return cmp(a.src, b.src) || cmp(a.dst, b.dst) || cmp(a.kind, b.kind);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cmp(a, b) {
|
|
119
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sigOf(line) {
|
|
123
|
+
return line.trim().slice(0, 200);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function add(symbols, name, kind, line, raw, exported) {
|
|
127
|
+
if (!name) return;
|
|
128
|
+
symbols.push({ name, kind, line, sig: sigOf(raw), exported });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function skipLine(line) {
|
|
132
|
+
return line === '' || line.startsWith('//') || line.startsWith('#') || line.startsWith('/*') || line.startsWith('*');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scan(text, onLine) {
|
|
136
|
+
const symbols = [];
|
|
137
|
+
const imports = [];
|
|
138
|
+
const lines = text.split('\n');
|
|
139
|
+
const state = {};
|
|
140
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
141
|
+
const raw = lines[i];
|
|
142
|
+
const line = raw.trim();
|
|
143
|
+
if (skipLine(line)) continue;
|
|
144
|
+
onLine(line, raw, i + 1, symbols, imports, state);
|
|
145
|
+
}
|
|
146
|
+
return { symbols, imports };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function script(text) {
|
|
150
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
151
|
+
const from = line.match(/^(?:import|export)\s[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/);
|
|
152
|
+
const side = line.match(/^import\s+['"]([^'"]+)['"]/);
|
|
153
|
+
const req = line.match(/\brequire\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
154
|
+
if (from) imports.push(from[1]);
|
|
155
|
+
else if (side) imports.push(side[1]);
|
|
156
|
+
else if (req) imports.push(req[1]);
|
|
157
|
+
let m;
|
|
158
|
+
if ((m = line.match(new RegExp(`^export\\s+(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*(${IDENT})`)))) {
|
|
159
|
+
add(symbols, m[1], 'function', lineNo, raw, 1);
|
|
160
|
+
} else if ((m = line.match(new RegExp(`^export\\s+(?:default\\s+)?(?:abstract\\s+)?class\\s+(${IDENT})`)))) {
|
|
161
|
+
add(symbols, m[1], 'class', lineNo, raw, 1);
|
|
162
|
+
} else if ((m = line.match(new RegExp(`^export\\s+(?:const|let|var)\\s+(${IDENT})`)))) {
|
|
163
|
+
add(symbols, m[1], 'const', lineNo, raw, 1);
|
|
164
|
+
} else if ((m = line.match(/^export\s+(type|interface|enum)\s+/))) {
|
|
165
|
+
const name = line.match(new RegExp(`^export\\s+${m[1]}\\s+(${IDENT})`));
|
|
166
|
+
if (name) add(symbols, name[1], m[1] === 'type' ? 'type' : m[1], lineNo, raw, 1);
|
|
167
|
+
} else if (/^export\s+(?:type\s+)?\{/.test(line)) {
|
|
168
|
+
const body = line.slice(line.indexOf('{') + 1, line.indexOf('}'));
|
|
169
|
+
for (const part of body.split(',')) {
|
|
170
|
+
const bit = part.trim();
|
|
171
|
+
if (bit === '') continue;
|
|
172
|
+
const sides = bit.split(/\s+as\s+/);
|
|
173
|
+
const name = (sides[1] ?? sides[0]).trim();
|
|
174
|
+
if (new RegExp(`^${IDENT}$`).test(name)) add(symbols, name, 'const', lineNo, raw, 1);
|
|
175
|
+
}
|
|
176
|
+
} else if ((m = line.match(new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s*\\*?\\s*(${IDENT})`)))) {
|
|
177
|
+
add(symbols, m[1], 'function', lineNo, raw, line.startsWith('export ') ? 1 : 0);
|
|
178
|
+
} else if ((m = line.match(new RegExp(`^(?:export\\s+)?(?:abstract\\s+)?class\\s+(${IDENT})`)))) {
|
|
179
|
+
add(symbols, m[1], 'class', lineNo, raw, line.startsWith('export ') ? 1 : 0);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const KT_MOD = '(?:public|private|internal|protected|open|data|sealed|abstract|enum|inner|value|annotation|suspend|override|operator|inline)';
|
|
185
|
+
|
|
186
|
+
function kotlin(text) {
|
|
187
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
188
|
+
const imp = line.match(/^import\s+([\w.]+)/);
|
|
189
|
+
if (imp) imports.push(imp[1]);
|
|
190
|
+
const m = line.match(new RegExp(`^(?:${KT_MOD}\\s+)*(class|interface|object|fun)\\s+(${IDENT})`));
|
|
191
|
+
if (!m) return;
|
|
192
|
+
const head = line.slice(0, line.indexOf(m[2]));
|
|
193
|
+
const exported = !/\b(?:private|internal)\b/.test(head);
|
|
194
|
+
add(symbols, m[2], m[1] === 'fun' ? 'function' : m[1], lineNo, raw, exported);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function java(text) {
|
|
199
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
200
|
+
const imp = line.match(/^import\s+(?:static\s+)?([\w.]+)\s*;/);
|
|
201
|
+
if (imp) imports.push(imp[1]);
|
|
202
|
+
const m = line.match(new RegExp(`^(public\\s+)?(?:(?:abstract|final|sealed|static)\\s+)*(class|interface|enum|record)\\s+(${IDENT})`));
|
|
203
|
+
if (!m) return;
|
|
204
|
+
add(symbols, m[3], m[2], lineNo, raw, m[1] ? 1 : 0);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function python(text) {
|
|
209
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
210
|
+
if (raw[0] === ' ' || raw[0] === '\t') return;
|
|
211
|
+
const from = line.match(/^from\s+(\S+)\s+import\s+(.+)/);
|
|
212
|
+
const imp = line.match(/^import\s+(\S+)/);
|
|
213
|
+
if (from) {
|
|
214
|
+
if (from[1] === '.') {
|
|
215
|
+
for (const part of from[2].split(',')) {
|
|
216
|
+
const name = part.trim().split(/\s+as\s+/)[0].trim();
|
|
217
|
+
if (name !== '' && name !== '*') imports.push(`./${name}`);
|
|
218
|
+
}
|
|
219
|
+
} else imports.push(from[1]);
|
|
220
|
+
} else if (imp) imports.push(imp[1].replace(/,$/, ''));
|
|
221
|
+
let m;
|
|
222
|
+
if ((m = line.match(new RegExp(`^(?:async\\s+)?def\\s+(${IDENT})`)))) {
|
|
223
|
+
add(symbols, m[1], 'function', lineNo, raw, m[1].startsWith('_') ? 0 : 1);
|
|
224
|
+
} else if ((m = line.match(new RegExp(`^class\\s+(${IDENT})`)))) {
|
|
225
|
+
add(symbols, m[1], 'class', lineNo, raw, m[1].startsWith('_') ? 0 : 1);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function go(text) {
|
|
231
|
+
return scan(text, (line, raw, lineNo, symbols, imports, state) => {
|
|
232
|
+
if (line === 'import (') {
|
|
233
|
+
state.group = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (state.group) {
|
|
237
|
+
if (line === ')') state.group = false;
|
|
238
|
+
else {
|
|
239
|
+
const q = line.match(/"([^"]+)"/);
|
|
240
|
+
if (q) imports.push(q[1]);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const one = line.match(/^import\s+"([^"]+)"/);
|
|
245
|
+
if (one) imports.push(one[1]);
|
|
246
|
+
const fn = line.match(new RegExp(`^func\\s+(?:\\([^)]*\\)\\s*)?(${IDENT})`));
|
|
247
|
+
const type = line.match(new RegExp(`^type\\s+(${IDENT})`));
|
|
248
|
+
const name = fn?.[1] ?? type?.[1];
|
|
249
|
+
if (!name) return;
|
|
250
|
+
const exported = name[0] !== name[0].toLowerCase();
|
|
251
|
+
add(symbols, name, fn ? 'function' : 'type', lineNo, raw, exported);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function rust(text) {
|
|
256
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
257
|
+
const use = line.match(/^use\s+([^;{]+)/);
|
|
258
|
+
if (use) imports.push(use[1].trim());
|
|
259
|
+
if (/^pub\s*\(/.test(line)) return;
|
|
260
|
+
const pub = line.match(new RegExp(`^pub\\s+(?:async\\s+)?(fn|struct|enum|trait|type|const|mod)\\s+(${IDENT})`));
|
|
261
|
+
if (pub) {
|
|
262
|
+
add(symbols, pub[2], pub[1] === 'fn' ? 'function' : pub[1], lineNo, raw, 1);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const fn = line.match(new RegExp(`^(?:async\\s+)?fn\\s+(${IDENT})`));
|
|
266
|
+
if (fn) add(symbols, fn[1], 'function', lineNo, raw, 0);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function swift(text) {
|
|
271
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
272
|
+
const imp = line.match(/^import\s+(\S+)/);
|
|
273
|
+
if (imp) imports.push(imp[1]);
|
|
274
|
+
const m = line.match(new RegExp(`^(?:(public|open|internal|private|fileprivate)\\s+)?(?:(?:static|class|final|override)\\s+)*(func|class|struct|enum|protocol|actor)\\s+(${IDENT})`));
|
|
275
|
+
if (!m) return;
|
|
276
|
+
add(symbols, m[3], m[2] === 'func' ? 'function' : m[2], lineNo, raw, m[1] === 'public' || m[1] === 'open' ? 1 : 0);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function dart(text) {
|
|
281
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
282
|
+
const imp = line.match(/^import\s+['"]([^'"]+)['"]/);
|
|
283
|
+
if (imp) imports.push(imp[1]);
|
|
284
|
+
const type = line.match(new RegExp(`^(?:class|enum|mixin|extension|typedef)\\s+(${IDENT})`));
|
|
285
|
+
if (type) {
|
|
286
|
+
add(symbols, type[1], 'class', lineNo, raw, type[1].startsWith('_') ? 0 : 1);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const fn = line.match(new RegExp(`^(?!if\\b|for\\b|while\\b|switch\\b|catch\\b)(?:[\\w.?<>]+\\s+)+(${IDENT})\\s*\\(`));
|
|
290
|
+
if (fn && raw[0] !== ' ' && raw[0] !== '\t') add(symbols, fn[1], 'function', lineNo, raw, fn[1].startsWith('_') ? 0 : 1);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function ruby(text) {
|
|
295
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
296
|
+
const req = line.match(/^require(?:_relative)?\s+['"]([^'"]+)['"]/);
|
|
297
|
+
if (req) imports.push(req[1]);
|
|
298
|
+
if (raw[0] === ' ' || raw[0] === '\t') return;
|
|
299
|
+
const type = line.match(/^(class|module)\s+([A-Z]\w*)/);
|
|
300
|
+
if (type) {
|
|
301
|
+
add(symbols, type[2], type[1], lineNo, raw, 1);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const fn = line.match(/^def\s+(?:self\.)?([A-Za-z_][\w?!]*)/);
|
|
305
|
+
if (fn) add(symbols, fn[1], 'function', lineNo, raw, 1);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function csharp(text) {
|
|
310
|
+
return scan(text, (line, raw, lineNo, symbols, imports) => {
|
|
311
|
+
const use = line.match(/^using\s+([A-Za-z0-9_.]+)\s*;/);
|
|
312
|
+
if (use) imports.push(use[1]);
|
|
313
|
+
const m = line.match(new RegExp(`^(?:(public|private|internal|protected)\\s+)?(?:(?:static|sealed|abstract|partial|readonly)\\s+)*(class|interface|enum|record|struct)\\s+(${IDENT})`));
|
|
314
|
+
if (!m) return;
|
|
315
|
+
add(symbols, m[3], m[2], lineNo, raw, m[1] === 'public' ? 1 : 0);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const BY_LANG = {
|
|
320
|
+
javascript: script,
|
|
321
|
+
typescript: script,
|
|
322
|
+
kotlin,
|
|
323
|
+
java,
|
|
324
|
+
python,
|
|
325
|
+
go,
|
|
326
|
+
rust,
|
|
327
|
+
swift,
|
|
328
|
+
dart,
|
|
329
|
+
ruby,
|
|
330
|
+
csharp,
|
|
331
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Which tests a change should run (R16, ADR-094).
|
|
2
|
+
//
|
|
3
|
+
// A test is selected when an import reaches a changed file, directly or through
|
|
4
|
+
// other files. The walk follows importers, so a test that imports a module that
|
|
5
|
+
// imports the change is included, and a cycle cannot. Naming and co-change are
|
|
6
|
+
// not imports, so they are not a selection.
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { isTestPath } from '../bench/mine.mjs';
|
|
12
|
+
import { importsOf } from './tests.mjs';
|
|
13
|
+
|
|
14
|
+
const LANG = {
|
|
15
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
16
|
+
ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
|
|
17
|
+
py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Test files that import one of `changed`, directly or through other files.
|
|
22
|
+
* A changed file that is itself a test is included: the edit is the test.
|
|
23
|
+
*
|
|
24
|
+
* @param {Iterable<string>} changed
|
|
25
|
+
* @param {{src: string, dst: string, kind?: string}[]} imports importer, then imported
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export function selectTests(changed, imports) {
|
|
29
|
+
const reverse = new Map();
|
|
30
|
+
for (const edge of imports) {
|
|
31
|
+
if (edge.kind && edge.kind !== 'import') continue;
|
|
32
|
+
const list = reverse.get(edge.dst);
|
|
33
|
+
if (list) list.push(edge.src);
|
|
34
|
+
else reverse.set(edge.dst, [edge.src]);
|
|
35
|
+
}
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const tests = [];
|
|
38
|
+
const queue = [];
|
|
39
|
+
for (const file of changed) {
|
|
40
|
+
if (!file || seen.has(file)) continue;
|
|
41
|
+
seen.add(file);
|
|
42
|
+
queue.push(file);
|
|
43
|
+
}
|
|
44
|
+
while (queue.length > 0) {
|
|
45
|
+
const file = queue.pop();
|
|
46
|
+
if (isTestPath(file)) tests.push(file);
|
|
47
|
+
const importers = reverse.get(file);
|
|
48
|
+
if (!importers) continue;
|
|
49
|
+
for (const importer of importers) {
|
|
50
|
+
if (seen.has(importer)) continue;
|
|
51
|
+
seen.add(importer);
|
|
52
|
+
queue.push(importer);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
tests.sort();
|
|
56
|
+
return tests;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Import edges for the texts the caller already read. One pass, no second parse. */
|
|
60
|
+
export function importEdges(entries) {
|
|
61
|
+
const files = new Set(entries.map((entry) => entry.path));
|
|
62
|
+
const imports = [];
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.lang || !entry.text) continue;
|
|
65
|
+
imports.push(...importsOf(entry.path, entry.text, entry.lang, files));
|
|
66
|
+
}
|
|
67
|
+
return imports;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Texts for tracked paths, read from the worktree. A missing file contributes none. */
|
|
71
|
+
export async function readEntries(root, files) {
|
|
72
|
+
const entries = [];
|
|
73
|
+
const paths = [...files].sort();
|
|
74
|
+
const batch = 32;
|
|
75
|
+
for (let i = 0; i < paths.length; i += batch) {
|
|
76
|
+
const slice = paths.slice(i, i + batch);
|
|
77
|
+
const read = await Promise.all(slice.map(async (path) => {
|
|
78
|
+
const lang = langOf(path);
|
|
79
|
+
if (!lang) return { path, text: '', lang: '' };
|
|
80
|
+
try {
|
|
81
|
+
return { path, text: await readFile(join(root, path), 'utf8'), lang };
|
|
82
|
+
} catch {
|
|
83
|
+
return { path, text: '', lang };
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
entries.push(...read);
|
|
87
|
+
}
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Import edges for tracked paths. A missing file contributes none. */
|
|
92
|
+
export async function readImportEdges(root, files) {
|
|
93
|
+
return importEdges(await readEntries(root, files));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function langOf(file) {
|
|
97
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
98
|
+
const dot = base.lastIndexOf('.');
|
|
99
|
+
if (dot <= 0) return '';
|
|
100
|
+
return LANG[base.slice(dot + 1)] ?? '';
|
|
101
|
+
}
|