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,254 @@
|
|
|
1
|
+
// The command card: the exact commands for THIS repository, at the top of the
|
|
2
|
+
// projected region (plan 0003 R10).
|
|
3
|
+
//
|
|
4
|
+
// The card holds the bytes a reader cannot infer. "Run the tests" is useless advice in a
|
|
5
|
+
// repository whose test command is `./gradlew :app:testDebugUnitTest`; the runner, the
|
|
6
|
+
// wrapper and the task name are facts about this repository, and none of them are in the
|
|
7
|
+
// rules, which are the same in every repository. So the card is derived rather than
|
|
8
|
+
// written: from the manifest's declared gates, the repository's own `package.json`
|
|
9
|
+
// scripts, and the marker files that name its ecosystem.
|
|
10
|
+
//
|
|
11
|
+
// It reads committed evidence ONLY. It must never consult PATH or `node_modules`, because
|
|
12
|
+
// the projected region is required to be byte-identical on two machines (plan 0003 P4,
|
|
13
|
+
// R13) and a card that named whichever linter happened to be installed would differ
|
|
14
|
+
// between a laptop and CI for no reason a diff could explain. Runnability is a property
|
|
15
|
+
// of the machine; a command is a property of the repository.
|
|
16
|
+
//
|
|
17
|
+
// The focused-test entries are TEMPLATES. `{file}`, `{pkg}`, `{Test}` and `{class}` are
|
|
18
|
+
// placeholders the reader fills from the file they are editing, and the label names the
|
|
19
|
+
// path kind so a template is never mistaken for a command that can be pasted as-is.
|
|
20
|
+
// Refining which test a given `{file}` maps to is R24's job, not R10's.
|
|
21
|
+
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { ECOSYSTEMS } from '../projection/manifest.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
|
|
29
|
+
* dropped rather than the ceiling raised: the point of the card is the bytes a reader
|
|
30
|
+
* most needs, and an unbounded card is a second copy of the rules.
|
|
31
|
+
*/
|
|
32
|
+
export const CARD_LIMIT_BYTES = 600;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The verbs the card carries, in the order a reader meets them. The plan names exactly
|
|
36
|
+
* these five. A `build` command exists only where a repository declares one, so the order
|
|
37
|
+
* is a priority as well as a sequence: the most universally true verb comes first.
|
|
38
|
+
*
|
|
39
|
+
* Exported because a second reader has to agree with it: a rule may declare `card:<verb>`
|
|
40
|
+
* as the evidence that makes its line redundant (R09), and a copy of this list living in
|
|
41
|
+
* the filter would let the card name a verb the filter called unknown.
|
|
42
|
+
*/
|
|
43
|
+
export const CARD_VERBS = ['build', 'test', 'lint', 'format', 'typecheck'];
|
|
44
|
+
|
|
45
|
+
const VERBS = CARD_VERBS;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The order verbs are dropped in when the card is over its ceiling: the verb whose loss a
|
|
49
|
+
* reader misses least goes first. `test` is absent on purpose — a card that dropped the
|
|
50
|
+
* test command would have dropped the one command it exists to carry.
|
|
51
|
+
*/
|
|
52
|
+
const DROP_ORDER = ['format', 'typecheck', 'lint', 'build'];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build commands for ecosystems whose build entrypoint is not a package script. Keyed by
|
|
56
|
+
* a marker fact or stack tag, whichever the ecosystem is identified by. `npm` is handled
|
|
57
|
+
* before this table, because a Node repository states its own build script and stating it
|
|
58
|
+
* again here would be a second answer to the same question.
|
|
59
|
+
*/
|
|
60
|
+
const BUILDS = [
|
|
61
|
+
{ fact: 'has-cargo-toml', command: 'cargo build' },
|
|
62
|
+
{ fact: 'has-go-mod', command: 'go build ./...' },
|
|
63
|
+
{ fact: 'has-nuget', command: 'dotnet build' },
|
|
64
|
+
{ fact: 'has-swift-package', command: 'swift build' },
|
|
65
|
+
{ fact: 'has-mix', command: 'mix compile' },
|
|
66
|
+
{ stack: 'gradle', command: './gradlew build' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One focused-test template per path kind. Entries are ordered, and the FIRST match wins,
|
|
71
|
+
* so a repository that carries more than one marker gets the more specific template:
|
|
72
|
+
* `has-uv-lock` before `has-pyproject`, `has-android-manifest` before the plain `gradle`
|
|
73
|
+
* tag, `has-deno-config` before the Node fallback. A duplicate template for the same path
|
|
74
|
+
* kind would be two answers, and the second is the wrong one.
|
|
75
|
+
*/
|
|
76
|
+
const FOCUSED = [
|
|
77
|
+
{ kind: 'Android', when: (s, f) => f.has('has-android-manifest'), template: () => './gradlew :{module}:testDebugUnitTest --tests {class}' },
|
|
78
|
+
{ kind: 'JVM', when: (s) => s.has('gradle'), template: () => './gradlew test --tests {class}' },
|
|
79
|
+
{ kind: 'Maven', when: (s, f) => f.has('has-maven-pom'), template: () => './mvnw test -Dtest={class}' },
|
|
80
|
+
{ kind: 'Go package', when: (s, f) => f.has('has-go-mod'), template: () => 'go test ./{pkg} -run {Test}' },
|
|
81
|
+
{ kind: 'Rust', when: (s, f) => f.has('has-cargo-toml'), template: () => 'cargo test {name}' },
|
|
82
|
+
{ kind: 'Python', when: (s, f) => f.has('has-uv-lock'), template: () => 'uv run -m pytest {file}::{test}' },
|
|
83
|
+
{ kind: 'Python', when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), template: () => 'pytest {file}::{test}' },
|
|
84
|
+
{ kind: '.NET', when: (s, f) => f.has('has-nuget'), template: () => 'dotnet test --filter {test}' },
|
|
85
|
+
{ kind: 'Swift', when: (s, f) => f.has('has-swift-package'), template: () => 'swift test --filter {test}' },
|
|
86
|
+
{ kind: 'Deno', when: (s, f) => f.has('has-deno-config'), template: () => 'deno test {file}' },
|
|
87
|
+
{ kind: 'Ruby', when: (s, f) => f.has('has-gemfile'), template: () => 'bundle exec rspec {file}' },
|
|
88
|
+
{ kind: 'Dart', when: (s, f) => f.has('has-pubspec'), template: () => 'dart test {file}' },
|
|
89
|
+
{ kind: 'Elixir', when: (s, f) => f.has('has-mix'), template: () => 'mix test {file}' },
|
|
90
|
+
{
|
|
91
|
+
kind: 'Node',
|
|
92
|
+
when: (s) => s.has('node'),
|
|
93
|
+
// The repo's own runner decides the shape. A repository whose test script is vitest
|
|
94
|
+
// or jest takes a file argument through the script; one with no such script gets the
|
|
95
|
+
// built-in runner, which is the only answer that needs nothing installed to already
|
|
96
|
+
// be true. `npm test -- <file>` is the ADR-010 shape (never `npx`).
|
|
97
|
+
template: (s, f, scripts) => {
|
|
98
|
+
const test = scripts?.test ?? '';
|
|
99
|
+
return /vitest|jest/.test(test) ? 'npm test -- {file}' : 'node --test {file}';
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verbs for ecosystems the gate table does not seed, used by the card alone.
|
|
106
|
+
*
|
|
107
|
+
* Python is the case, and it is not an oversight that the tool seeds no Python gate: the
|
|
108
|
+
* runner genuinely varies (uv, poetry, tox, nox, a project wrapper), so a seeded gate would
|
|
109
|
+
* be wrong in the repositories it guessed about. The card can still name the one command a
|
|
110
|
+
* repository has proved: a committed `uv.lock` means uv, and the uv profile is explicit
|
|
111
|
+
* that `uv run -m pytest` is correct and `uv run pytest` is not — the console entrypoint
|
|
112
|
+
* can miss the `uv` import path and run a different copy of the package than the one under
|
|
113
|
+
* test. Without a lockfile the card names the plain runner, which is what the python
|
|
114
|
+
* profile assumes when it says "no `pytest.skip`".
|
|
115
|
+
*/
|
|
116
|
+
const CARD_FALLBACKS = [
|
|
117
|
+
{ when: (s, f) => f.has('has-uv-lock'), commands: { test: 'uv run -m pytest' } },
|
|
118
|
+
{ when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), commands: { test: 'pytest' } },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/** A non-empty string, or null. Guards JSON that is present but not a command. */
|
|
122
|
+
function text(value) {
|
|
123
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The repository's own `package.json` scripts, or an empty map. */
|
|
127
|
+
async function readScripts(root) {
|
|
128
|
+
try {
|
|
129
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
130
|
+
return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
|
|
131
|
+
} catch {
|
|
132
|
+
// No package.json is a normal shape (a Python or Go repository), and an unreadable one
|
|
133
|
+
// is reported by the manifest path. Either way the card has no scripts to draw on.
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The command for one verb, or null when this repository proves none.
|
|
140
|
+
*
|
|
141
|
+
* Precedence is deliberate and mirrors the manifest's own. A declared `gates.commands`
|
|
142
|
+
* entry is the repository naming its own program and wins over anything inferred; a
|
|
143
|
+
* `package.json` script is the repository's declared entrypoint (`npm run <verb>`); and
|
|
144
|
+
* the ecosystem table is the canonical command for a marker that is present but whose
|
|
145
|
+
* repository has declared nothing. The last is the same table the tool seeds gates from,
|
|
146
|
+
* so the card and the gate list cannot name two different commands for one repository.
|
|
147
|
+
*/
|
|
148
|
+
function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
149
|
+
const declared = text(manifest?.gates?.commands?.[verb]);
|
|
150
|
+
if (declared) return declared;
|
|
151
|
+
|
|
152
|
+
// The script a verb maps to. `format` is a staged gate rather than a script gate, so its
|
|
153
|
+
// two conventional script names are tried directly — `format:check` first, because that
|
|
154
|
+
// is the one a hook and CI run, which is what the card is reporting.
|
|
155
|
+
const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
|
|
156
|
+
for (const name of scriptNames) {
|
|
157
|
+
if (text(scripts?.[name])) return `npm run ${name}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (verb === 'build') {
|
|
161
|
+
if (text(scripts?.build)) return 'npm run build';
|
|
162
|
+
for (const row of BUILDS) {
|
|
163
|
+
if (row.fact && facts.has(row.fact)) return row.command;
|
|
164
|
+
if (row.stack && stack.has(row.stack)) return row.command;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const eco of ECOSYSTEMS) {
|
|
170
|
+
if (!facts.has(eco.fact)) continue;
|
|
171
|
+
const command = text(eco.commands?.[verb]);
|
|
172
|
+
if (command) return command;
|
|
173
|
+
}
|
|
174
|
+
for (const row of CARD_FALLBACKS) {
|
|
175
|
+
if (!row.when(stack, facts, scripts)) continue;
|
|
176
|
+
const command = text(row.commands?.[verb]);
|
|
177
|
+
if (command) return command;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The text a set of lines renders to. Kept beside `commandCard` so both agree. */
|
|
183
|
+
function renderCard(lines) {
|
|
184
|
+
const body = lines.map((l) => `- ${l.label}: \`${l.command}\``).join('\n');
|
|
185
|
+
return `Commands for this repository:\n\n${body}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderBytes(lines) {
|
|
189
|
+
return lines.length === 0 ? 0 : Buffer.byteLength(renderCard(lines));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The index of the next line to drop, or -1 when nothing may be dropped.
|
|
194
|
+
*
|
|
195
|
+
* Verbs drop in `DROP_ORDER`. Focused templates drop only after every verb, from the last
|
|
196
|
+
* first, because a focused template is the plan's headline content and a repository with
|
|
197
|
+
* several is the one case where dropping one still leaves the reader a template.
|
|
198
|
+
*/
|
|
199
|
+
function pickDroppable(lines) {
|
|
200
|
+
for (const verb of DROP_ORDER) {
|
|
201
|
+
const at = lines.findIndex((l) => l.verb === verb);
|
|
202
|
+
if (at !== -1) return at;
|
|
203
|
+
}
|
|
204
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
205
|
+
if (lines[i].verb === 'focused') return i;
|
|
206
|
+
}
|
|
207
|
+
return -1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build the card for one repository.
|
|
212
|
+
*
|
|
213
|
+
* @param {{root: string, manifest?: object, stack?: string[], facts?: string[]}} input
|
|
214
|
+
* @returns {Promise<{text: string, bytes: number, lines: {label: string, command: string,
|
|
215
|
+
* verb: string}[], dropped: string[]}>}
|
|
216
|
+
*/
|
|
217
|
+
export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
|
|
218
|
+
const factSet = new Set(facts);
|
|
219
|
+
const stackSet = new Set(stack);
|
|
220
|
+
const scripts = await readScripts(root);
|
|
221
|
+
|
|
222
|
+
const lines = [];
|
|
223
|
+
for (const verb of VERBS) {
|
|
224
|
+
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
|
|
225
|
+
if (command) lines.push({ label: verb, command, verb });
|
|
226
|
+
}
|
|
227
|
+
for (const entry of FOCUSED) {
|
|
228
|
+
if (!entry.when(stackSet, factSet, scripts)) continue;
|
|
229
|
+
// One template per path kind: `has-uv-lock` and `has-pyproject` both match a uv repo,
|
|
230
|
+
// and two Python lines would be two answers with the second one wrong.
|
|
231
|
+
if (lines.some((l) => l.kind === entry.kind)) continue;
|
|
232
|
+
lines.push({
|
|
233
|
+
label: `focused test (${entry.kind})`,
|
|
234
|
+
command: entry.template(stackSet, factSet, scripts),
|
|
235
|
+
kind: entry.kind,
|
|
236
|
+
verb: 'focused',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Fit under the ceiling by dropping whole lines, least valuable first. Nothing is cut
|
|
241
|
+
// silently: `dropped` names every line removed so the caller can say so, because a card
|
|
242
|
+
// that quietly lost a command is a card a reader cannot tell is incomplete.
|
|
243
|
+
const dropped = [];
|
|
244
|
+
let kept = lines.slice();
|
|
245
|
+
while (renderBytes(kept) > CARD_LIMIT_BYTES) {
|
|
246
|
+
const victim = pickDroppable(kept);
|
|
247
|
+
if (victim === -1) break;
|
|
248
|
+
dropped.push(kept[victim].label);
|
|
249
|
+
kept = kept.filter((_, i) => i !== victim);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const text = kept.length > 0 ? renderCard(kept) : '';
|
|
253
|
+
return { text, bytes: Buffer.byteLength(text), lines: kept, dropped };
|
|
254
|
+
}
|
|
@@ -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
|
+
}
|