simt-dev 0.3.0 → 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 +63 -0
- package/README.md +129 -75
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- 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 +1 -1
- package/rules/core.md +1 -1
- package/src/argv.mjs +18 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/bench.mjs +13 -1
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +31 -0
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +35 -0
- package/src/commands/map.mjs +91 -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 +30 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +1 -1
- 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/checks.mjs +378 -0
- package/src/ledger/index.mjs +112 -0
- package/src/measure/context.mjs +7 -5
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +378 -44
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +2 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// A marked line is enforced by a checker, so the projection keeps its name and not
|
|
2
|
+
// its prose (plan 0003 R19, ADR-088).
|
|
3
|
+
//
|
|
4
|
+
// The unit is one list item, including the wrapped lines under it. A blank-line
|
|
5
|
+
// block would be the whole section: the core rules are consecutive bullets, and
|
|
6
|
+
// marking one of them must not take its neighbours with it.
|
|
7
|
+
//
|
|
8
|
+
// `import-direction` records nothing about which way an import may point. The
|
|
9
|
+
// index has the edges (R21). A direction still has to be named before the prose
|
|
10
|
+
// can leave, or the line would say Enforced and nothing would be enforcing it.
|
|
11
|
+
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
14
|
+
import { relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
|
|
17
|
+
const exec = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
export const CHECK_MARKER = 'simt-check';
|
|
20
|
+
|
|
21
|
+
/** Checkers this tool can run. `import-direction` is recognised and waits on a named direction. */
|
|
22
|
+
const RUNNABLE = new Set(['max-file-lines', 'forbid-regex', 'forbidden-path-edit', 'required-file']);
|
|
23
|
+
|
|
24
|
+
const MARKER = /<!--\s*simt-check:\s*([^>]*?)\s*-->/g;
|
|
25
|
+
const MARKER_AT = /\s*<!--\s*simt-check:\s*[^>]*?-->/g;
|
|
26
|
+
const HAS_MARKER = /<!--\s*simt-check:/;
|
|
27
|
+
const ITEM_START = /^(\s*)(?:[-*]|\d+\.)\s+/;
|
|
28
|
+
|
|
29
|
+
/** ADR-005's two tiers, used when a repository has not named its own. */
|
|
30
|
+
const DEFAULT_WARN = 400;
|
|
31
|
+
const DEFAULT_ERROR = 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What is wrong with the markers in a body, or null. The same field as a
|
|
35
|
+
* frontmatter defect, so `check` needs no second way to see a broken rule.
|
|
36
|
+
*/
|
|
37
|
+
export function checkDefect(body) {
|
|
38
|
+
for (const unit of units(body)) {
|
|
39
|
+
if (fenced(unit)) continue;
|
|
40
|
+
const found = markersIn(unit);
|
|
41
|
+
if (found.length === 0) continue;
|
|
42
|
+
if (found.length > 1) {
|
|
43
|
+
return `carries more than one ${CHECK_MARKER} marker on one line; one line is one checker`;
|
|
44
|
+
}
|
|
45
|
+
const parsed = parseCheck(found[0]);
|
|
46
|
+
if (parsed.error) return parsed.error;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replace each runnable check with one `Enforced:` line. A body with no marker
|
|
53
|
+
* is returned unchanged, so a rule nobody marked does not move a byte.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{body: string, enforced: {name: string}[], changed: boolean}}
|
|
56
|
+
*/
|
|
57
|
+
export function foldChecks(body) {
|
|
58
|
+
if (checkDefect(body) !== null || !HAS_MARKER.test(body)) {
|
|
59
|
+
return { body, enforced: [], changed: false };
|
|
60
|
+
}
|
|
61
|
+
const enforced = [];
|
|
62
|
+
const kept = [];
|
|
63
|
+
for (const unit of units(body)) {
|
|
64
|
+
const found = fenced(unit) ? [] : markersIn(unit);
|
|
65
|
+
if (found.length === 0) {
|
|
66
|
+
kept.push(unit);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const parsed = parseCheck(found[0]);
|
|
70
|
+
if (!parsed.runnable) {
|
|
71
|
+
kept.push(stripMarkers(unit));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const indent = ITEM_START.exec(unit)?.[1] ?? '';
|
|
75
|
+
const bullet = ITEM_START.test(unit) ? '- ' : '';
|
|
76
|
+
kept.push(`${indent}${bullet}Enforced: ${parsed.name}`);
|
|
77
|
+
enforced.push({ name: parsed.name });
|
|
78
|
+
}
|
|
79
|
+
const next = kept.join('\n');
|
|
80
|
+
return { body: next, enforced, changed: next !== body };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run the checkers the selected rules declare. A directory that is not a git
|
|
85
|
+
* repository has no tracked tree and no diff, so the checkers that need one
|
|
86
|
+
* report nothing rather than failing a repository they cannot see.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} root
|
|
89
|
+
* @param {{id: string, source: string, body: string}[]} rules
|
|
90
|
+
* @param {object|null} manifest
|
|
91
|
+
*/
|
|
92
|
+
export async function runDeclaredChecks(root, rules, manifest) {
|
|
93
|
+
const declared = [];
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (const rule of rules) {
|
|
96
|
+
if (checkDefect(rule.body) !== null) continue;
|
|
97
|
+
for (const unit of units(rule.body)) {
|
|
98
|
+
if (fenced(unit)) continue;
|
|
99
|
+
for (const expr of markersIn(unit)) {
|
|
100
|
+
const parsed = parseCheck(expr);
|
|
101
|
+
if (parsed.error) continue;
|
|
102
|
+
const key = `${parsed.name}\0${parsed.arg}`;
|
|
103
|
+
if (seen.has(key)) continue;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
declared.push({ ...parsed, source: rule.source });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const findings = [];
|
|
110
|
+
for (const check of declared) {
|
|
111
|
+
findings.push(...(await runOne(root, check, manifest)));
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runOne(root, check, manifest) {
|
|
117
|
+
if (check.name === 'import-direction') {
|
|
118
|
+
return [{
|
|
119
|
+
file: check.source,
|
|
120
|
+
status: 'check-unbuilt',
|
|
121
|
+
detail: 'import-direction has no direction to enforce; the index records imports and no rule names which way they may point',
|
|
122
|
+
remedy: 'leave the instruction in the rule until a direction is named',
|
|
123
|
+
}];
|
|
124
|
+
}
|
|
125
|
+
if (check.name === 'max-file-lines') return fileLines(root, check, manifest);
|
|
126
|
+
if (check.name === 'required-file') return requiredFile(root, check);
|
|
127
|
+
const diff = await workDiff(root);
|
|
128
|
+
if (diff === null) return [];
|
|
129
|
+
if (check.name === 'forbid-regex') return forbidRegex(check, diff);
|
|
130
|
+
return forbiddenPath(check, diff);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function fileLines(root, check, manifest) {
|
|
134
|
+
const limits = manifest?.limits?.fileLines ?? {};
|
|
135
|
+
const generated = limits.generated;
|
|
136
|
+
if (generated !== undefined && !Array.isArray(generated)) {
|
|
137
|
+
return [{
|
|
138
|
+
file: 'simt.json',
|
|
139
|
+
status: 'invalid',
|
|
140
|
+
detail: 'limits.fileLines.generated must be a list of paths',
|
|
141
|
+
remedy: 'name each generated path, or remove the key',
|
|
142
|
+
}];
|
|
143
|
+
}
|
|
144
|
+
const warnAt = Number.isInteger(limits.warn) ? limits.warn : DEFAULT_WARN;
|
|
145
|
+
const errorAt = Number.isInteger(limits.error) ? limits.error : DEFAULT_ERROR;
|
|
146
|
+
const files = await tracked(root);
|
|
147
|
+
if (files === null) return [];
|
|
148
|
+
const findings = [];
|
|
149
|
+
const prefixes = Array.isArray(generated) ? generated.filter((g) => typeof g === 'string' && g !== '') : [];
|
|
150
|
+
for (const rel of files) {
|
|
151
|
+
// A committed dependency is not a file this repository wrote. Counting it
|
|
152
|
+
// fails every install of this package, because the package's own sources
|
|
153
|
+
// are longer than the ceiling the consumer declared.
|
|
154
|
+
if (rel.split('/').includes('node_modules')) continue;
|
|
155
|
+
if (exempt(rel, prefixes)) continue;
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = await readFile(resolve(root, rel));
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (text.includes(0)) continue;
|
|
163
|
+
const source = text.toString('utf8');
|
|
164
|
+
// The region marker is this tool naming itself as the generator. A path the
|
|
165
|
+
// repository names is the other exemption; a filename that looks generated is not.
|
|
166
|
+
if (source.includes('simt:generated')) continue;
|
|
167
|
+
const lines = lineCount(source);
|
|
168
|
+
if (lines > errorAt) {
|
|
169
|
+
findings.push({
|
|
170
|
+
file: rel,
|
|
171
|
+
status: 'file-lines',
|
|
172
|
+
detail: `${lines} lines, over the ceiling of ${errorAt}`,
|
|
173
|
+
remedy: 'split the file, or name its generator in limits.fileLines.generated',
|
|
174
|
+
});
|
|
175
|
+
} else if (lines > warnAt) {
|
|
176
|
+
findings.push({
|
|
177
|
+
file: rel,
|
|
178
|
+
status: 'file-lines-warn',
|
|
179
|
+
detail: `${lines} lines, over the review trigger of ${warnAt}`,
|
|
180
|
+
remedy: 'say why the file stays whole, or split it',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return findings;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function requiredFile(root, check) {
|
|
188
|
+
const full = resolve(root, check.arg);
|
|
189
|
+
const rel = relative(root, full);
|
|
190
|
+
if (rel.startsWith('..') || rel.includes(`..${sep}`)) {
|
|
191
|
+
return [{
|
|
192
|
+
file: check.source,
|
|
193
|
+
status: 'required-file',
|
|
194
|
+
detail: `${check.arg} is outside the repository`,
|
|
195
|
+
remedy: 'name a path inside the repository',
|
|
196
|
+
}];
|
|
197
|
+
}
|
|
198
|
+
const info = await stat(full).catch(() => null);
|
|
199
|
+
if (info?.isFile()) return [];
|
|
200
|
+
return [{
|
|
201
|
+
file: check.arg,
|
|
202
|
+
status: 'required-file',
|
|
203
|
+
detail: 'a rule requires this file and it is absent',
|
|
204
|
+
remedy: `add ${check.arg}`,
|
|
205
|
+
}];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function forbidRegex(check, diff) {
|
|
209
|
+
let pattern;
|
|
210
|
+
try {
|
|
211
|
+
pattern = new RegExp(check.arg);
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const findings = [];
|
|
216
|
+
for (const file of diff.files) {
|
|
217
|
+
if (file.added.some((line) => pattern.test(line))) {
|
|
218
|
+
findings.push({
|
|
219
|
+
file: file.path,
|
|
220
|
+
status: 'forbid-regex',
|
|
221
|
+
detail: `an added line matches ${check.arg}`,
|
|
222
|
+
remedy: 'take the match out of the change',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return findings;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function forbiddenPath(check, diff) {
|
|
230
|
+
const hit = diff.files.find((file) => file.path === check.arg || file.path.startsWith(`${check.arg}/`));
|
|
231
|
+
if (!hit) return [];
|
|
232
|
+
return [{
|
|
233
|
+
file: hit.path,
|
|
234
|
+
status: 'forbidden-path',
|
|
235
|
+
detail: `${check.arg} is a path a rule says not to edit`,
|
|
236
|
+
remedy: 'edit the source the rule names, and leave this path unchanged',
|
|
237
|
+
}];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseCheck(expr) {
|
|
241
|
+
const parts = String(expr).trim().split(/\s+/);
|
|
242
|
+
const name = parts[0] ?? '';
|
|
243
|
+
const arg = parts.slice(1).join(' ');
|
|
244
|
+
if (name === 'import-direction') {
|
|
245
|
+
if (arg !== '') return { error: `${CHECK_MARKER} import-direction takes no argument` };
|
|
246
|
+
return { name, arg: '', runnable: false };
|
|
247
|
+
}
|
|
248
|
+
if (!RUNNABLE.has(name)) {
|
|
249
|
+
return { error: `${CHECK_MARKER} names "${name}", which is not a checker; the checkers are ${[...RUNNABLE, 'import-direction'].sort().join(', ')}` };
|
|
250
|
+
}
|
|
251
|
+
if (name !== 'max-file-lines' && arg === '') {
|
|
252
|
+
return { error: `${CHECK_MARKER} ${name} needs its argument` };
|
|
253
|
+
}
|
|
254
|
+
if (name === 'max-file-lines' && arg !== '') {
|
|
255
|
+
return { error: `${CHECK_MARKER} max-file-lines takes the repository's limits.fileLines, not an argument` };
|
|
256
|
+
}
|
|
257
|
+
if (name === 'forbid-regex') {
|
|
258
|
+
try {
|
|
259
|
+
RegExp(arg);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { error: `${CHECK_MARKER} forbid-regex is not a regular expression (${error.message})` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if ((name === 'forbidden-path-edit' || name === 'required-file') && (arg.startsWith('/') || arg.split('/').includes('..'))) {
|
|
265
|
+
return { error: `${CHECK_MARKER} ${name} needs a path inside the repository` };
|
|
266
|
+
}
|
|
267
|
+
return { name, arg, runnable: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function fenced(unit) {
|
|
271
|
+
return /^\s*(```|~~~)/.test(unit);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function markersIn(unit) {
|
|
275
|
+
return [...unit.matchAll(MARKER)].map((m) => m[1]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stripMarkers(unit) {
|
|
279
|
+
return unit.replace(MARKER_AT, '').replace(/[ \t]+$/gm, '');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Lines of a body, fences kept whole, list items split from their neighbours. */
|
|
283
|
+
function units(body) {
|
|
284
|
+
const lines = String(body ?? '').split('\n');
|
|
285
|
+
const out = [];
|
|
286
|
+
let fenced = false;
|
|
287
|
+
let current = [];
|
|
288
|
+
const flush = () => {
|
|
289
|
+
if (current.length > 0) out.push(current.join('\n'));
|
|
290
|
+
current = [];
|
|
291
|
+
};
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
294
|
+
if (!fenced) flush();
|
|
295
|
+
current.push(line);
|
|
296
|
+
fenced = !fenced;
|
|
297
|
+
if (!fenced) flush();
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (fenced) {
|
|
301
|
+
current.push(line);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (line.trim() === '') {
|
|
305
|
+
flush();
|
|
306
|
+
out.push(line);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ITEM_START.test(line) && current.length > 0) flush();
|
|
310
|
+
current.push(line);
|
|
311
|
+
}
|
|
312
|
+
flush();
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function lineCount(text) {
|
|
317
|
+
if (text.length === 0) return 0;
|
|
318
|
+
const n = text.split('\n').length;
|
|
319
|
+
return text.endsWith('\n') ? n - 1 : n;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function exempt(rel, prefixes) {
|
|
323
|
+
return prefixes.some((prefix) => rel === prefix || rel.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function tracked(root) {
|
|
327
|
+
try {
|
|
328
|
+
const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root });
|
|
329
|
+
return stdout.split('\0').filter((p) => p !== '');
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function workDiff(root) {
|
|
336
|
+
let text = '';
|
|
337
|
+
try {
|
|
338
|
+
const { stdout } = await exec('git', ['diff', '--no-ext-diff', '--unified=0', 'HEAD'], { cwd: root, maxBuffer: 8 * 1024 * 1024 });
|
|
339
|
+
text = stdout;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error.code === 'ENOENT') return null;
|
|
342
|
+
text = typeof error.stdout === 'string' ? error.stdout : '';
|
|
343
|
+
}
|
|
344
|
+
const files = parseDiff(text);
|
|
345
|
+
let untracked = [];
|
|
346
|
+
try {
|
|
347
|
+
const { stdout } = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root });
|
|
348
|
+
untracked = stdout.split('\0').filter((p) => p !== '');
|
|
349
|
+
} catch {
|
|
350
|
+
return { files };
|
|
351
|
+
}
|
|
352
|
+
for (const path of untracked) {
|
|
353
|
+
let raw;
|
|
354
|
+
try {
|
|
355
|
+
raw = await readFile(resolve(root, path));
|
|
356
|
+
} catch {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (raw.includes(0)) continue;
|
|
360
|
+
files.push({ path, added: raw.toString('utf8').split('\n') });
|
|
361
|
+
}
|
|
362
|
+
return { files };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseDiff(text) {
|
|
366
|
+
const files = [];
|
|
367
|
+
let current = null;
|
|
368
|
+
for (const line of text.split('\n')) {
|
|
369
|
+
const header = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
370
|
+
if (header) {
|
|
371
|
+
current = { path: header[2], added: [] };
|
|
372
|
+
files.push(current);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (current && line.startsWith('+') && !line.startsWith('+++')) current.added.push(line.slice(1));
|
|
376
|
+
}
|
|
377
|
+
return files;
|
|
378
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The evidence ledger (ADR-087).
|
|
2
|
+
//
|
|
3
|
+
// A passing command is evidence about one tree and no other. The tree is taken with a
|
|
4
|
+
// private index, because `git add -A` on the real index would stage work the author had
|
|
5
|
+
// left unstaged. Editing any file afterwards produces a different tree, so the old line
|
|
6
|
+
// no longer proves anything about what is on disk now.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { appendFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
|
|
15
|
+
const exec = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
const LEDGER_FILE = '.simt-dev/ledger.jsonl';
|
|
18
|
+
|
|
19
|
+
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
20
|
+
* first, because both contain the word and neither is a claim. */
|
|
21
|
+
const CLAIM = /\btests?\s+pass(?:ed)?\b|\bverified\b/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The tree of everything in the worktree, without staging any of it.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} root
|
|
27
|
+
* @returns {Promise<string>} the write-tree id
|
|
28
|
+
*/
|
|
29
|
+
export async function treeId(root) {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), 'tree-index-'));
|
|
31
|
+
const index = join(dir, 'index');
|
|
32
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
33
|
+
try {
|
|
34
|
+
// The ledger is a record of the tree, so it cannot be part of the tree it names.
|
|
35
|
+
// `git add -A` would take it once the file exists, and the line would prove a
|
|
36
|
+
// tree that stopped existing when the line was written.
|
|
37
|
+
await exec('git', ['add', '-A', '--', '.', ':(exclude).simt-dev/ledger.jsonl'], { cwd: root, env });
|
|
38
|
+
const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Append one run. The output itself is not stored: the digest is enough to notice a
|
|
47
|
+
* change, and a ledger that kept logs would keep whatever the command printed.
|
|
48
|
+
*/
|
|
49
|
+
export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
50
|
+
const line = {
|
|
51
|
+
v: 1,
|
|
52
|
+
ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
53
|
+
tree: await treeId(root),
|
|
54
|
+
cmd,
|
|
55
|
+
exit,
|
|
56
|
+
ms,
|
|
57
|
+
outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
|
|
58
|
+
summary: summarise(output, exit),
|
|
59
|
+
};
|
|
60
|
+
await mkdir(join(root, '.simt-dev'), { recursive: true });
|
|
61
|
+
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
62
|
+
return line;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every well-formed line. A line that does not parse is returned as an error so a
|
|
66
|
+
* damaged ledger cannot look empty. */
|
|
67
|
+
export async function readLedger(root) {
|
|
68
|
+
let text;
|
|
69
|
+
try {
|
|
70
|
+
text = await readFile(join(root, LEDGER_FILE), 'utf8');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT') return { entries: [], errors: [] };
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
const entries = [];
|
|
76
|
+
const errors = [];
|
|
77
|
+
for (const [i, raw] of text.split('\n').entries()) {
|
|
78
|
+
if (raw.trim() === '') continue;
|
|
79
|
+
try {
|
|
80
|
+
const line = JSON.parse(raw);
|
|
81
|
+
if (typeof line.tree !== 'string' || typeof line.exit !== 'number') {
|
|
82
|
+
errors.push(`line ${i + 1} has no tree or exit`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
entries.push(line);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
errors.push(`line ${i + 1} does not parse (${error.message})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { entries, errors };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function claimsVerification(message) {
|
|
94
|
+
const text = String(message).replace(/\bunverified\b/gi, ' ').replace(/\bnot\s+verified\b/gi, ' ');
|
|
95
|
+
return CLAIM.test(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `block` fails the commit. Anything unset is a warning. A third spelling is an
|
|
99
|
+
* error, because a typo for `block` must not quietly warn. */
|
|
100
|
+
export function evidenceMode(manifest) {
|
|
101
|
+
const value = manifest?.gates?.evidence;
|
|
102
|
+
if (value === undefined || value === 'warn') return 'warn';
|
|
103
|
+
if (value === 'block') return 'block';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarise(output, exit) {
|
|
108
|
+
const pass = /(\d+)\s+pass\b/.exec(output) ?? /\bpass\s+(\d+)/.exec(output);
|
|
109
|
+
const fail = /(\d+)\s+fail\b/.exec(output) ?? /\bfail\s+(\d+)/.exec(output);
|
|
110
|
+
if (pass || fail) return `${pass?.[1] ?? 0} pass, ${fail?.[1] ?? 0} fail`;
|
|
111
|
+
return `exit ${exit}`;
|
|
112
|
+
}
|
package/src/measure/context.mjs
CHANGED
|
@@ -26,7 +26,12 @@ import { basename, join } from 'node:path';
|
|
|
26
26
|
|
|
27
27
|
import { resolveSurfaces } from '../projection/surfaces.mjs';
|
|
28
28
|
import { parse } from '../projection/markers.mjs';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
budgetFinding,
|
|
31
|
+
declaredBudget,
|
|
32
|
+
DEFAULT_BUDGET_BYTES,
|
|
33
|
+
readManifest,
|
|
34
|
+
} from '../projection/manifest.mjs';
|
|
30
35
|
import { extractFrontmatter } from '../projection/targets.mjs';
|
|
31
36
|
import { readLimitFindings } from '../projection/project.mjs';
|
|
32
37
|
import { loader } from '../projection/loaders.mjs';
|
|
@@ -328,10 +333,7 @@ export async function measureRoot(root) {
|
|
|
328
333
|
|
|
329
334
|
const generatedBytes = documents.reduce((sum, d) => sum + d.generatedBytes, 0);
|
|
330
335
|
const readerBytes = documents.reduce((sum, d) => sum + d.readerBytes, 0);
|
|
331
|
-
const budgetBytes =
|
|
332
|
-
manifest?.budget?.bytes !== undefined && Number.isFinite(manifest.budget.bytes)
|
|
333
|
-
? manifest.budget.bytes
|
|
334
|
-
: ALWAYS_ON_BUDGET_BYTES;
|
|
336
|
+
const budgetBytes = declaredBudget(manifest);
|
|
335
337
|
|
|
336
338
|
const byChannel = new Map();
|
|
337
339
|
for (const doc of documents) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The names a reader needs before it can open anything that was kept out of the region.
|
|
2
|
+
//
|
|
3
|
+
// A scoped profile and an installed skill are the same situation: the body is not in the
|
|
4
|
+
// file the reader loads at session start, and a reader that is not told the name will
|
|
5
|
+
// never ask for it. This module reads only what the index line needs — a skill's name and
|
|
6
|
+
// its description — and never the body. The body is the cost the index exists to avoid.
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { readFrontmatter } from '../graph/spec.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where an installed skill can live. These are the directories `skills install` writes
|
|
15
|
+
* and the directories the loader table records, which are the same four. A fifth would
|
|
16
|
+
* be a place this tool can write a skill and then fail to tell the reader it is there.
|
|
17
|
+
*/
|
|
18
|
+
const SKILL_DIRS = ['.agents/skills', '.cursor/skills', '.claude/skills', '.opencode/skills'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The skills installed in a repository, one entry per file.
|
|
22
|
+
*
|
|
23
|
+
* A symlink fan-out is one skill: the file is counted by its real path, so the same
|
|
24
|
+
* body linked from two of these directories produces one line. Two different files that
|
|
25
|
+
* share a name both stay, because dropping one would hide a trigger.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} root
|
|
28
|
+
* @returns {Promise<{name: string, trigger: string, dir: string}[]>}
|
|
29
|
+
*/
|
|
30
|
+
export async function installedSkills(root) {
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const skills = [];
|
|
33
|
+
for (const dir of SKILL_DIRS) {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await readdir(join(root, dir), { withFileTypes: true });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const file = await skillFile(root, dir, entry);
|
|
43
|
+
if (file === null) continue;
|
|
44
|
+
let key;
|
|
45
|
+
try {
|
|
46
|
+
key = await realpath(file);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT') continue;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
if (seen.has(key)) continue;
|
|
52
|
+
seen.add(key);
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
text = await readFile(key, 'utf8');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT') continue;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const { meta } = readFrontmatter(text);
|
|
61
|
+
const fallback = entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/, '');
|
|
62
|
+
const name = typeof meta.name === 'string' && meta.name !== '' ? meta.name : fallback;
|
|
63
|
+
const trigger = typeof meta.description === 'string' ? meta.description.replace(/\s+/g, ' ').trim() : '';
|
|
64
|
+
skills.push({ name, trigger, dir });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
|
|
68
|
+
return skills;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The file that holds one skill, or null when the entry is not a skill.
|
|
73
|
+
*
|
|
74
|
+
* A symlink is the fan-out case: the directory a reader opens is a link, and
|
|
75
|
+
* `Dirent.isDirectory` is false for it, so treating "not a directory" as "not a
|
|
76
|
+
* skill" would index the canonical copy and then miss a repository whose only
|
|
77
|
+
* copy is the link.
|
|
78
|
+
*/
|
|
79
|
+
async function skillFile(root, dir, entry) {
|
|
80
|
+
const base = join(root, dir, entry.name);
|
|
81
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
82
|
+
const nested = join(base, 'SKILL.md');
|
|
83
|
+
try {
|
|
84
|
+
await realpath(nested);
|
|
85
|
+
return nested;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code !== 'ENOENT') throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.md')) return base;
|
|
91
|
+
return null;
|
|
92
|
+
}
|