simt-dev 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -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
+ }