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,301 @@
|
|
|
1
|
+
// The local index (ADR-089).
|
|
2
|
+
//
|
|
3
|
+
// `node:sqlite` when this Node has it, including FTS5, and a JSON-lines file
|
|
4
|
+
// otherwise. Either one is a cache: `git ls-files -s` can rebuild it, so a missing
|
|
5
|
+
// file is not a lost database. A blob that has not changed is not read again.
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { stat } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
12
|
+
import { extract, resolveImports } from './extract.mjs';
|
|
13
|
+
import {
|
|
14
|
+
JSONL_FILE,
|
|
15
|
+
SCHEMA_V,
|
|
16
|
+
SQLITE_FILE,
|
|
17
|
+
countSqlite,
|
|
18
|
+
readJsonl,
|
|
19
|
+
readSchema,
|
|
20
|
+
readSqliteRows,
|
|
21
|
+
writeJsonl,
|
|
22
|
+
writeSqlite,
|
|
23
|
+
} from './persist.mjs';
|
|
24
|
+
|
|
25
|
+
export { JSONL_FILE, SQLITE_FILE };
|
|
26
|
+
|
|
27
|
+
const MARKERS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'];
|
|
28
|
+
|
|
29
|
+
const LANG = {
|
|
30
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
31
|
+
ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
|
|
32
|
+
py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** `sqlite` when this process can open a database and create an FTS5 table. */
|
|
36
|
+
async function probeBackend() {
|
|
37
|
+
try {
|
|
38
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
39
|
+
const db = new DatabaseSync(':memory:');
|
|
40
|
+
db.exec('create virtual table fts using fts5(kind, ref, body)');
|
|
41
|
+
db.close();
|
|
42
|
+
return 'sqlite';
|
|
43
|
+
} catch {
|
|
44
|
+
return 'jsonl';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rebuild the index from the git index. Unchanged blobs are left as they are.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} root
|
|
52
|
+
* @param {{backend?: 'sqlite'|'jsonl'}} [opts]
|
|
53
|
+
*/
|
|
54
|
+
export async function reindex(root, opts = {}) {
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
const backend = opts.backend ?? await probeBackend();
|
|
57
|
+
const listed = await lsFiles(root);
|
|
58
|
+
const version = await readSchema(root, backend);
|
|
59
|
+
// A cache written before symbols existed has the right blobs and the wrong
|
|
60
|
+
// tables. Re-extract once, then an unchanged blob stays untouched.
|
|
61
|
+
const force = version !== SCHEMA_V;
|
|
62
|
+
const previous = await readFiles(root, { backend });
|
|
63
|
+
const prior = new Map(previous.map((row) => [row.path, row]));
|
|
64
|
+
const cache = new Map();
|
|
65
|
+
const changed = [];
|
|
66
|
+
for (const entry of listed) {
|
|
67
|
+
const old = prior.get(entry.path);
|
|
68
|
+
if (!force && old && old.blob === entry.blob) continue;
|
|
69
|
+
changed.push(entry);
|
|
70
|
+
}
|
|
71
|
+
const sizes = await blobSizes(root, changed.map((entry) => entry.blob));
|
|
72
|
+
const texts = await blobTexts(root, changed.map((entry) => entry.blob));
|
|
73
|
+
const adapter = await resolveAdapter(root, opts);
|
|
74
|
+
const paths = new Set(listed.map((entry) => entry.path));
|
|
75
|
+
const next = [];
|
|
76
|
+
const symbols = [];
|
|
77
|
+
const edges = [];
|
|
78
|
+
const fts = [];
|
|
79
|
+
for (const entry of changed) {
|
|
80
|
+
const lang = langOf(entry.path);
|
|
81
|
+
next.push({
|
|
82
|
+
path: entry.path,
|
|
83
|
+
blob: entry.blob,
|
|
84
|
+
lang,
|
|
85
|
+
bytes: sizes.get(entry.blob) ?? 0,
|
|
86
|
+
pkg: await packageOf(root, entry.path, cache),
|
|
87
|
+
});
|
|
88
|
+
const text = texts.get(entry.blob);
|
|
89
|
+
const pulled = text == null
|
|
90
|
+
? { symbols: [], imports: [], edges: null }
|
|
91
|
+
: await runExtract(entry.path, text, lang, adapter.module);
|
|
92
|
+
symbols.push(...pulled.symbols);
|
|
93
|
+
edges.push(...(pulled.edges ?? resolveImports(entry.path, pulled.imports, paths, lang)));
|
|
94
|
+
const names = [...new Set(pulled.symbols.map((row) => row.name))].sort();
|
|
95
|
+
if (names.length > 0) fts.push({ kind: 'code', ref: entry.path, body: names.join(' ') });
|
|
96
|
+
}
|
|
97
|
+
const removed = previous.filter((row) => !paths.has(row.path)).map((row) => row.path);
|
|
98
|
+
let stored;
|
|
99
|
+
if (backend === 'sqlite') {
|
|
100
|
+
await writeSqlite(root, next, removed, symbols, edges, fts);
|
|
101
|
+
stored = await countSqlite(root);
|
|
102
|
+
} else {
|
|
103
|
+
stored = await writeJsonl(root, previous, next, removed, symbols, edges, fts, force);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
backend,
|
|
107
|
+
files: listed.length,
|
|
108
|
+
updated: next.length,
|
|
109
|
+
removed: removed.length,
|
|
110
|
+
symbols: stored.symbols,
|
|
111
|
+
edges: stored.edges,
|
|
112
|
+
parsers: adapter.state,
|
|
113
|
+
ms: Date.now() - started,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** File rows, sorted by path. The same blobs read back as the same rows. */
|
|
118
|
+
export async function readFiles(root, opts = {}) {
|
|
119
|
+
const backend = opts.backend ?? await probeBackend();
|
|
120
|
+
if (backend === 'sqlite') return readSqliteRows(root, 'select path, blob, lang, bytes, pkg from files order by path');
|
|
121
|
+
return readJsonl(root, 'files');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Symbol rows for the blobs currently indexed. */
|
|
125
|
+
export async function readSymbols(root, opts = {}) {
|
|
126
|
+
const backend = opts.backend ?? await probeBackend();
|
|
127
|
+
if (backend === 'sqlite') return readSqliteRows(root, 'select path, name, kind, line, sig, exported from symbols order by path, line, name, kind');
|
|
128
|
+
return readJsonl(root, 'symbols');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Edges currently indexed. An import edge's src is the file that imports. */
|
|
132
|
+
export async function readEdges(root, opts = {}) {
|
|
133
|
+
const backend = opts.backend ?? await probeBackend();
|
|
134
|
+
if (backend === 'sqlite') return readSqliteRows(root, 'select src, dst, kind, weight from edges order by src, dst, kind');
|
|
135
|
+
return readJsonl(root, 'edges');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function langOf(filePath) {
|
|
139
|
+
const base = filePath.slice(filePath.lastIndexOf('/') + 1);
|
|
140
|
+
const dot = base.lastIndexOf('.');
|
|
141
|
+
if (dot <= 0) return '';
|
|
142
|
+
return LANG[base.slice(dot + 1)] ?? '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function packageOf(root, filePath, cache) {
|
|
146
|
+
let dir = dirname(filePath);
|
|
147
|
+
if (dir === '.') dir = '';
|
|
148
|
+
const seen = [];
|
|
149
|
+
for (;;) {
|
|
150
|
+
if (cache.has(dir)) {
|
|
151
|
+
const found = cache.get(dir);
|
|
152
|
+
for (const prior of seen) cache.set(prior, found);
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
seen.push(dir);
|
|
156
|
+
let hit = false;
|
|
157
|
+
for (const name of MARKERS) {
|
|
158
|
+
try {
|
|
159
|
+
await stat(join(root, dir, name));
|
|
160
|
+
hit = true;
|
|
161
|
+
break;
|
|
162
|
+
} catch {
|
|
163
|
+
// This directory is not that kind of package. The next marker may be.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (hit || dir === '') {
|
|
167
|
+
const found = hit ? dir : '';
|
|
168
|
+
for (const prior of seen) cache.set(prior, found);
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
const parent = dirname(dir);
|
|
172
|
+
dir = parent === '.' ? '' : parent;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function git(root, args, input, binary = false) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const child = execFile('git', args, { cwd: root, encoding: binary ? 'buffer' : 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
179
|
+
if (error) reject(Object.assign(error, { stdout, stderr }));
|
|
180
|
+
else resolve(stdout);
|
|
181
|
+
});
|
|
182
|
+
if (input !== undefined) {
|
|
183
|
+
child.stdin.end(input);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function lsFiles(root) {
|
|
189
|
+
const stdout = await git(root, ['ls-files', '-s', '-z']);
|
|
190
|
+
const rows = [];
|
|
191
|
+
for (const rec of stdout.split('\0')) {
|
|
192
|
+
if (rec === '') continue;
|
|
193
|
+
const tab = rec.indexOf('\t');
|
|
194
|
+
if (tab < 0) continue;
|
|
195
|
+
const meta = rec.slice(0, tab).split(' ');
|
|
196
|
+
if (meta[2] !== '0') continue;
|
|
197
|
+
rows.push({ blob: meta[1], path: rec.slice(tab + 1) });
|
|
198
|
+
}
|
|
199
|
+
rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function resolveAdapter(root, opts) {
|
|
204
|
+
if (opts.adapter) return { module: opts.adapter, state: 'loaded' };
|
|
205
|
+
let manifest = null;
|
|
206
|
+
try {
|
|
207
|
+
const read = await readManifest(root);
|
|
208
|
+
if (!read.error) manifest = read.manifest;
|
|
209
|
+
} catch {
|
|
210
|
+
return { module: null, state: 'off' };
|
|
211
|
+
}
|
|
212
|
+
const name = manifest?.index?.parsers;
|
|
213
|
+
if (typeof name !== 'string' || name === '') return { module: null, state: 'off' };
|
|
214
|
+
try {
|
|
215
|
+
const { createRequire } = await import('node:module');
|
|
216
|
+
const req = createRequire(join(root, 'package.json'));
|
|
217
|
+
return { module: req(name), state: 'loaded' };
|
|
218
|
+
} catch {
|
|
219
|
+
return { module: null, state: 'missing' };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runExtract(filePath, text, lang, adapter) {
|
|
224
|
+
if (adapter && typeof adapter.extract === 'function') {
|
|
225
|
+
try {
|
|
226
|
+
const got = await adapter.extract(filePath, text, lang);
|
|
227
|
+
if (got && Array.isArray(got.symbols)) return normalise(filePath, got);
|
|
228
|
+
} catch {
|
|
229
|
+
// A parser that throws does not sink the index. The built-in rows are the cache.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return extract(filePath, text, lang);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalise(filePath, got) {
|
|
236
|
+
const symbols = [];
|
|
237
|
+
for (const row of got.symbols) {
|
|
238
|
+
if (!row || typeof row.name !== 'string' || row.name === '') continue;
|
|
239
|
+
symbols.push({
|
|
240
|
+
path: filePath,
|
|
241
|
+
name: row.name,
|
|
242
|
+
kind: typeof row.kind === 'string' ? row.kind : 'symbol',
|
|
243
|
+
line: Number.isInteger(row.line) ? row.line : 0,
|
|
244
|
+
sig: typeof row.sig === 'string' ? row.sig.slice(0, 200) : '',
|
|
245
|
+
exported: row.exported ? 1 : 0,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
symbols.sort((a, b) => a.line - b.line || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
249
|
+
let edges = null;
|
|
250
|
+
if (Array.isArray(got.edges)) {
|
|
251
|
+
edges = [];
|
|
252
|
+
for (const row of got.edges) {
|
|
253
|
+
if (!row || typeof row.dst !== 'string') continue;
|
|
254
|
+
edges.push({
|
|
255
|
+
src: filePath,
|
|
256
|
+
dst: row.dst,
|
|
257
|
+
kind: typeof row.kind === 'string' ? row.kind : 'import',
|
|
258
|
+
weight: typeof row.weight === 'number' ? row.weight : 1,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
edges.sort((a, b) => (a.dst < b.dst ? -1 : a.dst > b.dst ? 1 : 0) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0));
|
|
262
|
+
}
|
|
263
|
+
return { symbols, imports: Array.isArray(got.imports) ? got.imports : [], edges };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function blobTexts(root, blobs) {
|
|
267
|
+
const unique = [...new Set(blobs)];
|
|
268
|
+
const texts = new Map();
|
|
269
|
+
if (unique.length === 0) return texts;
|
|
270
|
+
const stdout = await git(root, ['cat-file', '--batch'], `${unique.join('\n')}\n`, true);
|
|
271
|
+
let offset = 0;
|
|
272
|
+
while (offset < stdout.length) {
|
|
273
|
+
const nl = stdout.indexOf(0x0a, offset);
|
|
274
|
+
if (nl < 0) break;
|
|
275
|
+
const header = stdout.toString('utf8', offset, nl);
|
|
276
|
+
const parts = header.split(' ');
|
|
277
|
+
const size = Number(parts[2]);
|
|
278
|
+
if (parts[1] !== 'blob' || !Number.isFinite(size)) {
|
|
279
|
+
offset = nl + 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const start = nl + 1;
|
|
283
|
+
const body = stdout.subarray(start, start + size);
|
|
284
|
+
offset = start + size + 1;
|
|
285
|
+
texts.set(parts[0], body.includes(0) ? null : body.toString('utf8'));
|
|
286
|
+
}
|
|
287
|
+
return texts;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function blobSizes(root, blobs) {
|
|
291
|
+
const unique = [...new Set(blobs)];
|
|
292
|
+
const sizes = new Map();
|
|
293
|
+
if (unique.length === 0) return sizes;
|
|
294
|
+
const stdout = await git(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], `${unique.join('\n')}\n`);
|
|
295
|
+
for (const line of stdout.split('\n')) {
|
|
296
|
+
if (line === '') continue;
|
|
297
|
+
const sp = line.indexOf(' ');
|
|
298
|
+
sizes.set(line.slice(0, sp), Number(line.slice(sp + 1)));
|
|
299
|
+
}
|
|
300
|
+
return sizes;
|
|
301
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Which tests exercise a source file (R24, ADR-092).
|
|
2
|
+
//
|
|
3
|
+
// Three precise signals, and one historical one kept apart. An import from a
|
|
4
|
+
// test file, a conventional name beside that test, and a coverage record that
|
|
5
|
+
// names both sides are test edges. A stem that is unique somewhere else in
|
|
6
|
+
// the tree is not a guess. Co-change is a different kind: files that land in
|
|
7
|
+
// the same commit are not, by that fact, a test.
|
|
8
|
+
|
|
9
|
+
import { isTestPath } from '../bench/mine.mjs';
|
|
10
|
+
import { extract, resolveImports } from './extract.mjs';
|
|
11
|
+
|
|
12
|
+
const HISTORY = 200;
|
|
13
|
+
const COCHANGE_MIN = 2;
|
|
14
|
+
const COCHANGE_CAP = 8;
|
|
15
|
+
const WIDE_TESTS = 5;
|
|
16
|
+
const WIDE_SOURCES = 15;
|
|
17
|
+
|
|
18
|
+
export { HISTORY };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{files: {path: string}[]|string[], imports?: {src: string, dst: string, kind?: string}[], coverage?: string[], commits?: string[][]}} input
|
|
22
|
+
* @returns {{src: string, dst: string, kind: string, weight: number}[]}
|
|
23
|
+
*/
|
|
24
|
+
export function testEdges({ files, imports = [], coverage = [], commits = [] }) {
|
|
25
|
+
const paths = [...new Set(files.map((file) => (typeof file === 'string' ? file : file.path)))].sort();
|
|
26
|
+
const have = new Set(paths);
|
|
27
|
+
const edges = [];
|
|
28
|
+
for (const edge of imports) {
|
|
29
|
+
if (edge.kind && edge.kind !== 'import') continue;
|
|
30
|
+
if (!isTestPath(edge.src) || isTestPath(edge.dst) || !have.has(edge.dst)) continue;
|
|
31
|
+
edges.push({ src: edge.src, dst: edge.dst, kind: 'test', weight: 1 });
|
|
32
|
+
}
|
|
33
|
+
for (const test of paths) {
|
|
34
|
+
if (!isTestPath(test)) continue;
|
|
35
|
+
const source = namedSource(test, have);
|
|
36
|
+
if (source) edges.push({ src: test, dst: source, kind: 'test', weight: 1 });
|
|
37
|
+
}
|
|
38
|
+
for (const text of coverage) edges.push(...coverageEdges(text, have));
|
|
39
|
+
edges.push(...cochangeEdges(commits));
|
|
40
|
+
return dedupe(edges);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test files for one source file. Co-change is included only when asked. */
|
|
44
|
+
export function testsFor(source, edges, { cochange = false } = {}) {
|
|
45
|
+
const direct = new Set();
|
|
46
|
+
const history = [];
|
|
47
|
+
for (const edge of edges) {
|
|
48
|
+
if (edge.dst !== source) continue;
|
|
49
|
+
if (edge.kind === 'test') direct.add(edge.src);
|
|
50
|
+
else if (edge.kind === 'cochange') history.push(edge);
|
|
51
|
+
}
|
|
52
|
+
const found = [...direct].sort();
|
|
53
|
+
if (!cochange) return found;
|
|
54
|
+
history.sort((a, b) => b.weight - a.weight || cmp(a.src, b.src));
|
|
55
|
+
for (const edge of history) {
|
|
56
|
+
if (direct.has(edge.src)) continue;
|
|
57
|
+
found.push(edge.src);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Import specifiers in a script, including an import that spans lines. */
|
|
63
|
+
export function scriptSpecifiers(text) {
|
|
64
|
+
const specs = [];
|
|
65
|
+
const lines = String(text).split('\n');
|
|
66
|
+
let buf = '';
|
|
67
|
+
let open = false;
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!open) {
|
|
71
|
+
if (!/^(?:import|export)\b/.test(trimmed)) continue;
|
|
72
|
+
// A declaration is not an import. Capturing it would swallow the next
|
|
73
|
+
// import's `from` and report a specifier the file does not have.
|
|
74
|
+
if (/^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\b/.test(trimmed) && !trimmed.includes(' from ')) continue;
|
|
75
|
+
buf = trimmed;
|
|
76
|
+
open = true;
|
|
77
|
+
} else {
|
|
78
|
+
buf += `\n${trimmed}`;
|
|
79
|
+
}
|
|
80
|
+
const done = /\bfrom\s+['"][^'"]+['"]/.test(buf) || /^import\s+['"][^'"]+['"]/.test(buf) || buf.includes(';') || buf.length > 2000;
|
|
81
|
+
if (!done) continue;
|
|
82
|
+
const from = buf.match(/\bfrom\s+['"]([^'"]+)['"]/);
|
|
83
|
+
const side = buf.match(/^import\s+['"]([^'"]+)['"]/);
|
|
84
|
+
if (from) specs.push(from[1]);
|
|
85
|
+
else if (side) specs.push(side[1]);
|
|
86
|
+
buf = '';
|
|
87
|
+
open = false;
|
|
88
|
+
}
|
|
89
|
+
for (const match of String(text).matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
|
|
90
|
+
for (const match of String(text).matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
|
|
91
|
+
return [...new Set(specs)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseCoverage(text) {
|
|
95
|
+
const body = String(text).replace(/^\uFEFF/, '');
|
|
96
|
+
const start = body.trimStart();
|
|
97
|
+
if (start.startsWith('TN:') || start.startsWith('SF:') || start.includes('\nSF:')) return parseLcov(body);
|
|
98
|
+
if (start.startsWith('mode:')) return parseGoCover(body);
|
|
99
|
+
if (start.startsWith('<')) return parseJacoco(body);
|
|
100
|
+
if (start.startsWith('{')) return parseCoveragePy(body);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseLcov(text) {
|
|
105
|
+
const pairs = [];
|
|
106
|
+
let test = '';
|
|
107
|
+
for (const line of String(text).split('\n')) {
|
|
108
|
+
if (line.startsWith('TN:')) test = line.slice(3).trim();
|
|
109
|
+
else if (line.startsWith('SF:') && test) pairs.push({ test, source: line.slice(3).trim() });
|
|
110
|
+
else if (line === 'end_of_record') test = '';
|
|
111
|
+
}
|
|
112
|
+
return pairs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseJacoco(text) {
|
|
116
|
+
const sources = [...String(text).matchAll(/<sourcefile\s+name="([^"]+)"/g)].map((match) => match[1]);
|
|
117
|
+
const tests = [...String(text).matchAll(/<class\s+name="([^"]+)"/g)]
|
|
118
|
+
.map((match) => match[1].split(/[./]/).pop())
|
|
119
|
+
.filter((name) => /Tests?$/.test(name));
|
|
120
|
+
const pairs = [];
|
|
121
|
+
for (const source of sources) {
|
|
122
|
+
const stem = source.slice(0, source.lastIndexOf('.'));
|
|
123
|
+
const hit = tests.filter((name) => name === `${stem}Test` || name === `${stem}Tests`);
|
|
124
|
+
if (hit.length === 1) pairs.push({ test: hit[0], source });
|
|
125
|
+
}
|
|
126
|
+
return pairs;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseCoveragePy(text) {
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(text);
|
|
133
|
+
} catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const pairs = [];
|
|
137
|
+
const files = parsed?.files;
|
|
138
|
+
if (!files || typeof files !== 'object') return pairs;
|
|
139
|
+
for (const [source, info] of Object.entries(files)) {
|
|
140
|
+
const contexts = info?.contexts;
|
|
141
|
+
if (!contexts || typeof contexts !== 'object') continue;
|
|
142
|
+
for (const name of Object.keys(contexts)) {
|
|
143
|
+
if (name) pairs.push({ test: name, source });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return pairs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseGoCover(text) {
|
|
150
|
+
const sources = new Set();
|
|
151
|
+
for (const line of String(text).split('\n')) {
|
|
152
|
+
const match = /^(.+):\d+\.\d+,\d+\.\d+ \d+ (\d+)/.exec(line);
|
|
153
|
+
if (match && Number(match[2]) > 0) sources.add(match[1]);
|
|
154
|
+
}
|
|
155
|
+
return [...sources].map((source) => ({ test: '', source }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Pairs that changed together. A commit that touches the whole tree is not a
|
|
160
|
+
* mapping, and a pair seen once is a coincidence. Each test keeps its strongest
|
|
161
|
+
* pairs, so one wide test cannot point at every file it ever met.
|
|
162
|
+
*/
|
|
163
|
+
export function cochangeEdges(commits) {
|
|
164
|
+
const counts = new Map();
|
|
165
|
+
for (const commit of commits) {
|
|
166
|
+
const tests = [];
|
|
167
|
+
const sources = [];
|
|
168
|
+
for (const path of commit) {
|
|
169
|
+
if (isTestPath(path)) tests.push(path);
|
|
170
|
+
else sources.push(path);
|
|
171
|
+
}
|
|
172
|
+
if (tests.length === 0 || sources.length === 0) continue;
|
|
173
|
+
if (tests.length > WIDE_TESTS || sources.length > WIDE_SOURCES) continue;
|
|
174
|
+
for (const test of tests) {
|
|
175
|
+
for (const source of sources) {
|
|
176
|
+
const key = `${test}\0${source}`;
|
|
177
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const byTest = new Map();
|
|
182
|
+
for (const [key, weight] of counts) {
|
|
183
|
+
if (weight < COCHANGE_MIN) continue;
|
|
184
|
+
const split = key.indexOf('\0');
|
|
185
|
+
const edge = { src: key.slice(0, split), dst: key.slice(split + 1), kind: 'cochange', weight };
|
|
186
|
+
const list = byTest.get(edge.src) ?? [];
|
|
187
|
+
list.push(edge);
|
|
188
|
+
byTest.set(edge.src, list);
|
|
189
|
+
}
|
|
190
|
+
const edges = [];
|
|
191
|
+
for (const list of byTest.values()) {
|
|
192
|
+
list.sort((a, b) => b.weight - a.weight || cmp(a.dst, b.dst));
|
|
193
|
+
edges.push(...list.slice(0, COCHANGE_CAP));
|
|
194
|
+
}
|
|
195
|
+
edges.sort(byEdge);
|
|
196
|
+
return edges;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function coverageEdges(text, files) {
|
|
200
|
+
const edges = [];
|
|
201
|
+
for (const pair of parseCoverage(text)) {
|
|
202
|
+
const source = resolveToken(pair.source, files);
|
|
203
|
+
if (!source || isTestPath(source)) continue;
|
|
204
|
+
let test = pair.test ? resolveToken(pair.test, files) : '';
|
|
205
|
+
if (!test && pair.test === '') test = goTestFor(source, files);
|
|
206
|
+
if (!test || !isTestPath(test)) continue;
|
|
207
|
+
edges.push({ src: test, dst: source, kind: 'test', weight: 1 });
|
|
208
|
+
}
|
|
209
|
+
return edges;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function goTestFor(source, files) {
|
|
213
|
+
if (!source.endsWith('.go')) return '';
|
|
214
|
+
const slash = source.lastIndexOf('/');
|
|
215
|
+
const dir = slash < 0 ? '' : source.slice(0, slash);
|
|
216
|
+
const stem = source.slice(slash + 1, -3);
|
|
217
|
+
const exact = dir ? `${dir}/${stem}_test.go` : `${stem}_test.go`;
|
|
218
|
+
if (files.has(exact)) return exact;
|
|
219
|
+
const tests = [];
|
|
220
|
+
const prefix = dir ? `${dir}/` : '';
|
|
221
|
+
for (const file of files) {
|
|
222
|
+
if (!file.startsWith(prefix) || file.slice(prefix.length).includes('/')) continue;
|
|
223
|
+
if (file.endsWith('_test.go')) tests.push(file);
|
|
224
|
+
}
|
|
225
|
+
return tests.length === 1 ? tests[0] : '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function namedSource(test, files) {
|
|
229
|
+
// Same directory, or the main tree mirrored from src/test. A stem that is
|
|
230
|
+
// unique somewhere else in the repo is still a guess: test/hooks.test.mjs
|
|
231
|
+
// is not the test for src/commands/hooks.mjs.
|
|
232
|
+
const exact = conventional(test);
|
|
233
|
+
return exact && files.has(exact) ? exact : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function conventional(test) {
|
|
237
|
+
const moved = test.match(/^(?:(.*)\/)?src\/test\/(java|kotlin)\/(.+?)Tests?\.(java|kt)$/);
|
|
238
|
+
if (moved) {
|
|
239
|
+
const prefix = moved[1] ? `${moved[1]}/` : '';
|
|
240
|
+
return `${prefix}src/main/${moved[2]}/${moved[3]}.${moved[4]}`;
|
|
241
|
+
}
|
|
242
|
+
const slash = test.lastIndexOf('/');
|
|
243
|
+
const dir = slash < 0 ? '' : test.slice(0, slash);
|
|
244
|
+
const base = test.slice(slash + 1);
|
|
245
|
+
let source = '';
|
|
246
|
+
if (/_test\.go$/.test(base)) source = `${base.slice(0, -'_test.go'.length)}.go`;
|
|
247
|
+
else if (/^test_.+\.py$/.test(base)) source = `${base.slice('test_'.length)}`;
|
|
248
|
+
else if (/Tests?\.(java|kt)$/.test(base)) source = base.replace(/Tests?(\.(?:java|kt))$/, '$1');
|
|
249
|
+
else if (/\.(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) source = base.replace(/\.(?:test|spec)(\.[A-Za-z0-9]+)$/, '$1');
|
|
250
|
+
if (!source || source === base) return '';
|
|
251
|
+
return dir ? `${dir}/${source}` : source;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveToken(token, files) {
|
|
255
|
+
const norm = String(token).replaceAll('\\', '/').replace(/^\.\//, '');
|
|
256
|
+
if (files.has(norm)) return norm;
|
|
257
|
+
const parts = norm.split('/');
|
|
258
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
259
|
+
const suffix = parts.slice(i).join('/');
|
|
260
|
+
const hits = [];
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
if (file === suffix || file.endsWith(`/${suffix}`)) hits.push(file);
|
|
263
|
+
}
|
|
264
|
+
if (hits.length === 1) return hits[0];
|
|
265
|
+
if (hits.length > 1) return '';
|
|
266
|
+
}
|
|
267
|
+
return '';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function dedupe(edges) {
|
|
271
|
+
const byKey = new Map();
|
|
272
|
+
for (const edge of edges) {
|
|
273
|
+
const key = `${edge.kind}\0${edge.src}\0${edge.dst}`;
|
|
274
|
+
const prior = byKey.get(key);
|
|
275
|
+
if (!prior || edge.weight > prior.weight) byKey.set(key, edge);
|
|
276
|
+
}
|
|
277
|
+
return [...byKey.values()].sort(byEdge);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function byEdge(a, b) {
|
|
281
|
+
return cmp(a.kind, b.kind) || cmp(a.src, b.src) || cmp(a.dst, b.dst);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function cmp(a, b) {
|
|
285
|
+
if (a < b) return -1;
|
|
286
|
+
if (a > b) return 1;
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Relative imports in a test file, resolved against the tracked tree. */
|
|
291
|
+
export function importsOf(testPath, text, lang, files) {
|
|
292
|
+
const specs = lang === 'javascript' || lang === 'typescript' ? scriptSpecifiers(text) : extract(testPath, text, lang).imports;
|
|
293
|
+
return resolveImports(testPath, specs, files, lang);
|
|
294
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Budgeted compilation: which kernel lines may be cut so a repository's declared budget
|
|
2
|
+
// is met (plan 0003 R08).
|
|
3
|
+
//
|
|
4
|
+
// A budget nobody enforces is a number in a JSON file. ADR-072 shipped the number and
|
|
5
|
+
// reported it; what it could not do is meet it, because meeting a budget means something
|
|
6
|
+
// gives. Kernel mode is what gives (ADR-073), and this module is the half of it that
|
|
7
|
+
// measures first and cuts second — the half that turns "your projection is over the line"
|
|
8
|
+
// from a finding a reader has to act on alone into a projection that was already under it.
|
|
9
|
+
//
|
|
10
|
+
// Two rulings live here and nowhere else, because both are the kind of decision that
|
|
11
|
+
// looks like arithmetic until it is written down:
|
|
12
|
+
//
|
|
13
|
+
// 1. Nothing is cut in `full` mode. ADR-072's finding already words the consequence —
|
|
14
|
+
// "nothing was cut: the projection is still in full mode" — and a full projection
|
|
15
|
+
// that quietly lost a rule would turn that sentence into a lie told by the tool
|
|
16
|
+
// about itself. A repository that has not asked for a kernel gets its rules.
|
|
17
|
+
// 2. `hard` is the floor. A priority class exists to say what a reader loses by not
|
|
18
|
+
// having the line (ADR-073), and a byte count is not a reader. Cutting a hard
|
|
19
|
+
// constraint to reach a ceiling would remove the one class whose whole claim is that
|
|
20
|
+
// its absence produces a defect, and it would report itself as compliance.
|
|
21
|
+
//
|
|
22
|
+
// Everything here is a pure function of its arguments: no clock, no network, no PATH. Two
|
|
23
|
+
// machines given the same repository and the same budget cut the same rules in the same
|
|
24
|
+
// order, which is R08's second accept criterion and the only reason a pull request can
|
|
25
|
+
// diff the result.
|
|
26
|
+
|
|
27
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
28
|
+
|
|
29
|
+
/** The class a budget may not cut. Ruling 2 above. */
|
|
30
|
+
const BUDGET_FLOOR = 'hard';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cut candidates, least valuable first, until the region fits `ceiling`.
|
|
34
|
+
*
|
|
35
|
+
* The candidate order is the caller's, not this module's. Which class a reader loses least
|
|
36
|
+
* is a judgement about the rules, and the rules declare their own class in their own
|
|
37
|
+
* frontmatter (ADR-073); this module is handed a list already in the order it should lose
|
|
38
|
+
* them and does the one thing that is arithmetic.
|
|
39
|
+
*
|
|
40
|
+
* `costs` is called with the ids cut so far and returns what the document then weighs. It
|
|
41
|
+
* is re-rendered per attempt rather than subtracting each rule's own size, because the
|
|
42
|
+
* three things that change the total when a rule leaves are not the rule's length: the
|
|
43
|
+
* provenance header lists every source, the deferred section gains a line naming what left,
|
|
44
|
+
* and both shrink as the cut list grows. Arithmetic that ignored them would be a budget
|
|
45
|
+
* that is either never quite met or overshot by the width of a comment.
|
|
46
|
+
*
|
|
47
|
+
* @param {{candidates: {id: string, priority: string}[], ceiling: number,
|
|
48
|
+
* costs: (cut: Set<string>) => number}} input
|
|
49
|
+
* @returns {{cut: string[], before: number, after: number, floorHeld: boolean}}
|
|
50
|
+
*/
|
|
51
|
+
export function packToBudget({ candidates, ceiling, costs }) {
|
|
52
|
+
const cut = new Set();
|
|
53
|
+
const before = costs(cut);
|
|
54
|
+
let after = before;
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
if (after <= ceiling) break;
|
|
57
|
+
if (candidate.priority === BUDGET_FLOOR) continue;
|
|
58
|
+
cut.add(candidate.id);
|
|
59
|
+
after = costs(cut);
|
|
60
|
+
}
|
|
61
|
+
// `floorHeld` is not a cut count. It says the kernel is over the line with everything
|
|
62
|
+
// cuttable already cut, which is a different fact from "we cut some rules" and is
|
|
63
|
+
// reported differently: nothing else can shrink it, so the reader is told to move a rule
|
|
64
|
+
// out of the two classes kernel mode packs, exactly as ADR-072's kernel wording says.
|
|
65
|
+
return { cut: [...cut], before, after, floorHeld: after > ceiling };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Report that the kernel was cut to fit the budget this repository declared.
|
|
70
|
+
*
|
|
71
|
+
* Non-blocking, and for the same reason `budget` is (ADR-072): the cut is the tool doing
|
|
72
|
+
* what the repository asked, and the remedy is a decision about the number, not a missing
|
|
73
|
+
* file. A gate that failed here would fail a repository for shipping a smaller projection.
|
|
74
|
+
*
|
|
75
|
+
* The detail carries the three numbers a reader needs to judge the cut and names every
|
|
76
|
+
* rule that left, because a cut line that is not named is indistinguishable from a rule
|
|
77
|
+
* that was never written (ADR-073 ruling 6). `before` is the size the reader would have
|
|
78
|
+
* had, so the remedy can quote a number rather than sending them to go and measure one.
|
|
79
|
+
*
|
|
80
|
+
* The remedy's number is the one that ends the cut, and it is `before + reserve`, because
|
|
81
|
+
* the ceiling the region was cut against is the budget less what was held back for the
|
|
82
|
+
* files loading beside it.
|
|
83
|
+
*
|
|
84
|
+
* @param {{file: string, bytes: number, reserve: number, before: number, after: number,
|
|
85
|
+
* cuts: {id: string, priority: string}[]}} input
|
|
86
|
+
* @returns {{file: string, status: string, detail: string, remedy: string}}
|
|
87
|
+
*/
|
|
88
|
+
export function budgetCutFinding({ file, bytes, reserve, before, after, cuts }) {
|
|
89
|
+
const named = cuts.map((c) => `${c.id} (${c.priority})`).join(', ');
|
|
90
|
+
return {
|
|
91
|
+
file,
|
|
92
|
+
status: 'budget-cut',
|
|
93
|
+
detail: `BUDGET-CUT: the kernel projection was cut from ${before} to ${after} bytes against a declared budget of ${bytes}${reserve > 0 ? `, with ${reserve} bytes reserved for the generated files that load beside the region` : ''}; cut: ${named}`,
|
|
94
|
+
// The number has to be `before + reserve`, not `before`. The region is cut against a
|
|
95
|
+
// ceiling that already holds the rule files back, so a budget of `before` leaves the
|
|
96
|
+
// cut standing and the reader has followed the remedy to no effect — which is worse
|
|
97
|
+
// than naming no number at all, because they will believe they were told what to do.
|
|
98
|
+
remedy: `raise budget.bytes to ${before + reserve} in ${MANIFEST_FILE}, or move a cut rule out of the hard and command classes so kernel mode leaves it out; ${CLI} rules show <id> reads any of them`,
|
|
99
|
+
};
|
|
100
|
+
}
|