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,38 @@
|
|
|
1
|
+
# ADR-096 — impact names who a change reaches
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R23 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/impact.mjs`, `src/commands/impact.mjs`, `test/impact.test.mjs`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A change has a neighbourhood: the files that import it, the tests of that
|
|
10
|
+
neighbourhood, the decisions and rules that govern the file, and the owner a
|
|
11
|
+
CODEOWNERS line names. In a workspace the neighbourhood crosses a repository
|
|
12
|
+
when another one references a contract this change touches. That is the check
|
|
13
|
+
behind "the producer ships before the consumer".
|
|
14
|
+
|
|
15
|
+
## Rulings
|
|
16
|
+
|
|
17
|
+
1. **The argument is a tracked path, or the name of one export.** A private
|
|
18
|
+
symbol is not an export. Several files exporting the same name is a usage
|
|
19
|
+
error that names those files. A name that is neither a path nor an export
|
|
20
|
+
is a usage error. A tree that is not git is unsupported.
|
|
21
|
+
2. **Importers are the non-test files that import the target, at any depth.**
|
|
22
|
+
The tests are the test files that import that closure. Both lists stop at
|
|
23
|
+
64. Past that, the report says it was truncated.
|
|
24
|
+
3. **Governance is what `why` would name for the file, limited to decisions
|
|
25
|
+
and rules.** Owners are the last matching pattern in the first CODEOWNERS
|
|
26
|
+
file that exists. A pattern with no slash matches the file name in any
|
|
27
|
+
directory.
|
|
28
|
+
4. **A cross-repo consumer is another repository whose text contains a
|
|
29
|
+
contract path this change touches.** A contract is an OpenAPI document or
|
|
30
|
+
a JSON Schema file. The path has to include a directory. No workspace
|
|
31
|
+
means no consumers, and that is not a failure. The scan stops at 64
|
|
32
|
+
references and 2000 files in a repository.
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
`simt impact` answers who is reached. `simt gate` still runs the tests.
|
|
37
|
+
`simt why` still answers governance on its own, including skills, gates and
|
|
38
|
+
lessons, which this report does not repeat.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simt-dev",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Shared agent rules, skills and git hooks for a repository: one command adopts a repo, and every gate it declares is probed before it is written.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Said Elimam",
|
package/rules/core.md
CHANGED
|
@@ -60,7 +60,7 @@ commands belongs in this repository, not here.
|
|
|
60
60
|
|
|
61
61
|
- No type-system escapes, no unawaited async work, no debug printing in application code.
|
|
62
62
|
- 400 lines is a review trigger; 1000 lines is a hard ceiling. Generated files are exempt
|
|
63
|
-
when the hook names the generator. (ADR-005)
|
|
63
|
+
when the hook names the generator. (ADR-005) <!-- simt-check: max-file-lines -->
|
|
64
64
|
- Never weaken CSP, CORS, auth, audit, cache or validation controls to silence a test, warning
|
|
65
65
|
or alert.
|
|
66
66
|
- Review verdicts are exactly three: `BLOCK`, `APPROVE WITH RESIDUAL RISK` (must list what was
|
package/src/argv.mjs
CHANGED
|
@@ -59,6 +59,21 @@ export const BOOL_FLAGS = new Set([
|
|
|
59
59
|
// (plan 0003 R09). Off by default because a count is enough to act on; the per-line
|
|
60
60
|
// detail is what an author needs, and a reader is not an author.
|
|
61
61
|
'explain',
|
|
62
|
+
// check: the lock must match. Off by default because a repository that has not
|
|
63
|
+
// written one yet is not drift, and a gate that failed for the absence would be
|
|
64
|
+
// red on every repository this package already adopted.
|
|
65
|
+
'frozen',
|
|
66
|
+
// map: report gold-file recall@10 instead of rendering the ranking. Off by
|
|
67
|
+
// default because the ranking is the answer a task asked for, and the recall
|
|
68
|
+
// figure needs the hidden benchmark patches.
|
|
69
|
+
'recall',
|
|
70
|
+
// tests: also list files that only changed in the same commits as a test.
|
|
71
|
+
// Off by default because a shared commit is not a test.
|
|
72
|
+
'cochange',
|
|
73
|
+
// gate: read the working-tree diff instead of the paths named after the command.
|
|
74
|
+
// Off by default because a path is the change the caller named, and the diff
|
|
75
|
+
// is a different question.
|
|
76
|
+
'changed',
|
|
62
77
|
]);
|
|
63
78
|
|
|
64
79
|
export const VALUE_FLAGS = new Set([
|
|
@@ -100,6 +115,9 @@ export const VALUE_FLAGS = new Set([
|
|
|
100
115
|
// plan done: the test command that proves the slice. Plan 0003 R31.
|
|
101
116
|
'test',
|
|
102
117
|
'state',
|
|
118
|
+
// map: outline (paths), names (exported symbols) or signatures (the line).
|
|
119
|
+
// A boolean would not say which of the three the caller wanted.
|
|
120
|
+
'level',
|
|
103
121
|
]);
|
|
104
122
|
|
|
105
123
|
/**
|
package/src/ci/workflow.mjs
CHANGED
|
@@ -148,6 +148,16 @@ function uses(action) {
|
|
|
148
148
|
return ` - uses: ${repo}@${commit} # ${release}`;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** This repository's own gate runs `npm test`, and two of those tests read
|
|
152
|
+
* history. A consumer's gate does not, so its checkout stays shallow. */
|
|
153
|
+
function checkoutFull() {
|
|
154
|
+
return `${uses('checkout')}
|
|
155
|
+
with:
|
|
156
|
+
# \`bench mine\` counts commits, and the Affects snapshot reads
|
|
157
|
+
# 4c27514. A depth-1 checkout fails both, and both run in \`npm test\`.
|
|
158
|
+
fetch-depth: 0`;
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
/** The YAML every copy shares, so the two cannot drift on triggers, permissions or
|
|
152
162
|
* concurrency. Only `name`, the runner and `steps` vary, and only because they have
|
|
153
163
|
* to. The own gate also varies the platforms and the Node versions, because this
|
|
@@ -246,7 +256,7 @@ export function renderWorkflow({ own = false, npm = true, version } = {}) {
|
|
|
246
256
|
return `${header(true)}${skeleton(
|
|
247
257
|
'simt-dev own gate',
|
|
248
258
|
[
|
|
249
|
-
|
|
259
|
+
checkoutFull(),
|
|
250
260
|
setupNode('${{ matrix.node }}'),
|
|
251
261
|
// The two checks `.githooks/pre-push` runs, named here rather than discovered,
|
|
252
262
|
// because a reader of this file has to be able to answer "what stops a merge"
|
package/src/commands/bench.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.m
|
|
|
12
12
|
import { DEFAULT_MINE_TARGET, mineTasks } from '../bench/mine.mjs';
|
|
13
13
|
import { regradeRun, runBench } from '../bench/run.mjs';
|
|
14
14
|
import { buildBenchReport } from '../bench/report.mjs';
|
|
15
|
+
import { formatRecall, measureRecall } from '../index/map.mjs';
|
|
15
16
|
import { ablateBench } from '../bench/ablate.mjs';
|
|
16
17
|
import { runBenchProbes } from '../bench/probes.mjs';
|
|
17
18
|
import { listFlag } from './skills.mjs';
|
|
@@ -223,6 +224,8 @@ export async function benchReportCommand(positional, flags) {
|
|
|
223
224
|
);
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
const mapRecall = await recallOrUnmeasured(root);
|
|
228
|
+
|
|
226
229
|
if (isJson(flags)) {
|
|
227
230
|
emitJson({
|
|
228
231
|
ok: true,
|
|
@@ -232,14 +235,23 @@ export async function benchReportCommand(positional, flags) {
|
|
|
232
235
|
runCount: report.runCount,
|
|
233
236
|
arms: report.arms,
|
|
234
237
|
comparison: report.comparison,
|
|
238
|
+
mapRecall,
|
|
235
239
|
});
|
|
236
240
|
return EXIT.OK;
|
|
237
241
|
}
|
|
238
242
|
|
|
239
|
-
emit(report.markdown
|
|
243
|
+
emit(`${report.markdown}\n\n### Map\n\n${formatRecall(mapRecall)}`, { maxBytes: flags.maxBytes ?? 0 });
|
|
240
244
|
return EXIT.OK;
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
async function recallOrUnmeasured(root) {
|
|
248
|
+
try {
|
|
249
|
+
return await measureRecall(root);
|
|
250
|
+
} catch {
|
|
251
|
+
return { measured: false, reason: 'not run', tasks: 0, missing: 0, recall: null };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
243
255
|
export async function benchAblateCommand(positional, flags) {
|
|
244
256
|
const root = resolve(flags.root ?? positional[0] ?? process.cwd());
|
|
245
257
|
const { dryRun, apply } = writeMode(flags);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `simt evidence` — the proof block for this tree, and the commit-message check.
|
|
2
|
+
//
|
|
3
|
+
// The read answers "what has actually been run against what is on disk". The check
|
|
4
|
+
// answers the other half: a message that says the tests passed, when nothing in the
|
|
5
|
+
// ledger was run against this tree (ADR-087).
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
|
|
9
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
10
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
11
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
12
|
+
import { claimsVerification, evidenceMode, readLedger, treeId } from '../ledger/index.mjs';
|
|
13
|
+
|
|
14
|
+
export async function evidenceCommand(args, flags) {
|
|
15
|
+
const root = flags.root ?? process.cwd();
|
|
16
|
+
if (args[0] === 'check') return checkClaim(root, args[1], flags);
|
|
17
|
+
if (args[0] !== undefined) {
|
|
18
|
+
return fail(`unknown evidence subcommand ${args[0]}`, EXIT.USAGE, `run: ${CLI} evidence`);
|
|
19
|
+
}
|
|
20
|
+
return showEvidence(root, flags);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function showEvidence(root, flags) {
|
|
24
|
+
let tree;
|
|
25
|
+
try {
|
|
26
|
+
tree = await treeId(root);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return fail(
|
|
29
|
+
`could not name this tree (${error.message})`,
|
|
30
|
+
EXIT.UNSUPPORTED,
|
|
31
|
+
'run this inside a git repository',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const { entries, errors } = await readLedger(root);
|
|
35
|
+
const mine = entries.filter((e) => e.tree === tree);
|
|
36
|
+
const payload = { ok: errors.length === 0, command: 'evidence', root, tree, entries: mine, errors };
|
|
37
|
+
if (isJson(flags)) {
|
|
38
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
39
|
+
return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
40
|
+
}
|
|
41
|
+
const lines = [`tree ${tree}`];
|
|
42
|
+
if (mine.length === 0) lines.push('none no run is recorded for this tree');
|
|
43
|
+
for (const e of mine) {
|
|
44
|
+
lines.push(`${e.exit === 0 ? 'pass' : 'fail'} ${e.cmd} exit ${e.exit} ${e.summary}`);
|
|
45
|
+
}
|
|
46
|
+
for (const error of errors) lines.push(`error ${error}`);
|
|
47
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
48
|
+
return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function checkClaim(root, file, flags) {
|
|
52
|
+
if (!file) return fail('evidence check needs the commit message file', EXIT.USAGE, `run: ${CLI} evidence check <file>`);
|
|
53
|
+
let message;
|
|
54
|
+
try {
|
|
55
|
+
message = await readFile(file, 'utf8');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return fail(`could not read ${file} (${error.message})`, EXIT.USAGE, `pass the path git gives commit-msg`);
|
|
58
|
+
}
|
|
59
|
+
const { manifest, error } = await readManifest(root);
|
|
60
|
+
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG, `fix ${MANIFEST_FILE} so it parses`);
|
|
61
|
+
const mode = evidenceMode(manifest);
|
|
62
|
+
if (mode === null) {
|
|
63
|
+
return fail(
|
|
64
|
+
`${MANIFEST_FILE} gates.evidence must be "warn" or "block"`,
|
|
65
|
+
EXIT.CONFIG,
|
|
66
|
+
`set gates.evidence to "warn" or "block", or remove it`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const claim = claimsVerification(message);
|
|
70
|
+
if (!claim) {
|
|
71
|
+
const payload = { ok: true, command: 'evidence check', claim: false, mode };
|
|
72
|
+
if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
|
|
73
|
+
return EXIT.OK;
|
|
74
|
+
}
|
|
75
|
+
let tree;
|
|
76
|
+
try {
|
|
77
|
+
tree = await treeId(root);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return fail(`could not name this tree (${error.message})`, EXIT.UNSUPPORTED, 'run this inside a git repository');
|
|
80
|
+
}
|
|
81
|
+
const { entries } = await readLedger(root);
|
|
82
|
+
const supported = entries.some((e) => e.tree === tree && e.exit === 0);
|
|
83
|
+
const payload = { ok: supported || mode === 'warn', command: 'evidence check', claim: true, supported, mode, tree };
|
|
84
|
+
if (supported) {
|
|
85
|
+
if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
|
|
86
|
+
return EXIT.OK;
|
|
87
|
+
}
|
|
88
|
+
const detail = 'the message claims a run this tree does not have';
|
|
89
|
+
const remedy = `run the command, then commit, or take the claim out of the message`;
|
|
90
|
+
if (isJson(flags)) {
|
|
91
|
+
emitJson({ ...payload, detail, remedy }, { pretty: flags.pretty === true });
|
|
92
|
+
return mode === 'block' ? EXIT.FAIL : EXIT.OK;
|
|
93
|
+
}
|
|
94
|
+
emit(`${mode === 'block' ? 'BLOCKED' : 'WARNING'} ${detail}\n remedy: ${remedy}`, { maxBytes: flags.maxBytes ?? 0 });
|
|
95
|
+
return mode === 'block' ? EXIT.FAIL : EXIT.OK;
|
|
96
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// `simt gate` runs the tests a change reaches (R16, ADR-094).
|
|
2
|
+
//
|
|
3
|
+
// Paths name the change. `--changed` reads the diff instead. The tests are the
|
|
4
|
+
// ones that import a changed file, directly or through other files, and the
|
|
5
|
+
// runner is the one the package already uses. Nothing else runs. A report
|
|
6
|
+
// this tool knows how to read is compacted; the raw log is kept.
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
|
|
13
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
|
+
import { CLI } from '../name.mjs';
|
|
15
|
+
import { detectTestRunner } from '../bench/mine.mjs';
|
|
16
|
+
import { compactOutput } from '../index/compact.mjs';
|
|
17
|
+
import { readImportEdges, selectTests } from '../index/gate.mjs';
|
|
18
|
+
import { packageOf } from '../index/store.mjs';
|
|
19
|
+
|
|
20
|
+
const exec = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
export async function gateCommand(args, flags) {
|
|
23
|
+
const root = flags.root ?? process.cwd();
|
|
24
|
+
const listed = await tracked(root);
|
|
25
|
+
if (listed.error) return listed.error;
|
|
26
|
+
let changed = args.filter(Boolean);
|
|
27
|
+
if (flags.changed === true) {
|
|
28
|
+
const diff = await changedFiles(root);
|
|
29
|
+
if (diff.error) return diff.error;
|
|
30
|
+
changed = [...new Set([...changed, ...diff.files])].sort();
|
|
31
|
+
}
|
|
32
|
+
if (changed.length === 0) {
|
|
33
|
+
if (flags.changed === true) {
|
|
34
|
+
if (isJson(flags)) {
|
|
35
|
+
emitJson({ ok: true, command: 'gate', root, selected: [], code: 0 }, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
36
|
+
} else {
|
|
37
|
+
emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
|
|
38
|
+
}
|
|
39
|
+
return EXIT.OK;
|
|
40
|
+
}
|
|
41
|
+
return fail(`nothing changed`, EXIT.USAGE, `pass a path, or ${CLI} gate --changed`);
|
|
42
|
+
}
|
|
43
|
+
const files = new Set(listed.files);
|
|
44
|
+
for (const file of changed) files.add(file);
|
|
45
|
+
const selected = selectTests(changed, await readImportEdges(root, files));
|
|
46
|
+
if (selected.length === 0) {
|
|
47
|
+
const payload = { ok: true, command: 'gate', root, selected, code: 0 };
|
|
48
|
+
if (isJson(flags)) {
|
|
49
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
50
|
+
return EXIT.OK;
|
|
51
|
+
}
|
|
52
|
+
emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
|
|
53
|
+
return EXIT.OK;
|
|
54
|
+
}
|
|
55
|
+
return runSelected(root, flags, selected);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runSelected(root, flags, selected) {
|
|
59
|
+
const cache = new Map();
|
|
60
|
+
const groups = new Map();
|
|
61
|
+
for (const file of selected) {
|
|
62
|
+
const pkg = await packageOf(root, file, cache);
|
|
63
|
+
const list = groups.get(pkg);
|
|
64
|
+
if (list) list.push(file);
|
|
65
|
+
else groups.set(pkg, [file]);
|
|
66
|
+
}
|
|
67
|
+
const runs = [];
|
|
68
|
+
let code = 0;
|
|
69
|
+
const text = [];
|
|
70
|
+
const rawParts = [];
|
|
71
|
+
for (const [pkg, files] of [...groups.entries()].sort((a, b) => cmp(a[0], b[0]))) {
|
|
72
|
+
const relative = files.map((file) => (pkg && file.startsWith(`${pkg}/`) ? file.slice(pkg.length + 1) : file));
|
|
73
|
+
const cwd = join(root, pkg);
|
|
74
|
+
const runner = await detectTestRunner(cwd, relative);
|
|
75
|
+
if (!runner) {
|
|
76
|
+
return fail(
|
|
77
|
+
`no runner for ${files.length} test${files.length === 1 ? '' : 's'} in ${pkg || 'this repository'}`,
|
|
78
|
+
EXIT.UNSUPPORTED,
|
|
79
|
+
'declare a package manifest the test runner can see',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const built = runner.buildExec(cwd);
|
|
83
|
+
text.push(`selected ${files.length}`, ...files, '', runner.displayCmd);
|
|
84
|
+
// A parent `node --test` puts NODE_TEST_CONTEXT in the environment. Left
|
|
85
|
+
// there, the child reports to that parent and exits 0 whether or not it failed.
|
|
86
|
+
const env = { ...process.env };
|
|
87
|
+
for (const key of Object.keys(env)) {
|
|
88
|
+
if (key.startsWith('NODE_TEST')) delete env[key];
|
|
89
|
+
}
|
|
90
|
+
const result = await exec(built.cmd, built.args, { cwd, env, maxBuffer: 32 << 20 }).then(
|
|
91
|
+
(out) => ({ code: 0, stdout: out.stdout, stderr: out.stderr }),
|
|
92
|
+
(error) => ({
|
|
93
|
+
code: typeof error.code === 'number' ? error.code : 1,
|
|
94
|
+
stdout: error.stdout ?? '',
|
|
95
|
+
stderr: error.stderr ?? '',
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
if (result.code !== 0) code = result.code;
|
|
99
|
+
const stdout = String(result.stdout ?? '');
|
|
100
|
+
const stderr = String(result.stderr ?? '');
|
|
101
|
+
const body = stderr ? `${stdout}\n${stderr}` : stdout;
|
|
102
|
+
rawParts.push(body);
|
|
103
|
+
const compact = compactOutput(body);
|
|
104
|
+
runs.push({ package: pkg, command: runner.displayCmd, code: result.code, stdout, stderr });
|
|
105
|
+
text.push(compact ?? body);
|
|
106
|
+
}
|
|
107
|
+
const raw = await writeRaw(root, rawParts.join('\n'));
|
|
108
|
+
if (raw) text.push('', `raw ${raw}`);
|
|
109
|
+
const payload = { ok: code === 0, command: 'gate', root, selected, code, raw, runs };
|
|
110
|
+
if (isJson(flags)) {
|
|
111
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
112
|
+
return code === 0 ? EXIT.OK : EXIT.FAIL;
|
|
113
|
+
}
|
|
114
|
+
emit(text.filter((line) => line !== undefined).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
115
|
+
return code === 0 ? EXIT.OK : EXIT.FAIL;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function writeRaw(root, body) {
|
|
119
|
+
try {
|
|
120
|
+
const dir = join(root, '.simt-dev', 'gate');
|
|
121
|
+
await mkdir(dir, { recursive: true });
|
|
122
|
+
const name = `gate-${Date.now()}.log`;
|
|
123
|
+
await writeFile(join(dir, name), body);
|
|
124
|
+
return `.simt-dev/gate/${name}`;
|
|
125
|
+
} catch {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function tracked(root) {
|
|
131
|
+
try {
|
|
132
|
+
const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
|
|
133
|
+
return { files: stdout.split('\n').filter(Boolean) };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
error: fail(
|
|
137
|
+
`could not read the change (${error.message})`,
|
|
138
|
+
EXIT.UNSUPPORTED,
|
|
139
|
+
'run this inside a git repository',
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function changedFiles(root) {
|
|
146
|
+
try {
|
|
147
|
+
const { stdout } = await exec('git', ['diff', '--name-only', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
|
|
148
|
+
const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, maxBuffer: 8 << 20 });
|
|
149
|
+
return { files: [...new Set([...stdout.split('\n'), ...untracked.stdout.split('\n')].filter(Boolean))].sort() };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return {
|
|
152
|
+
error: fail(
|
|
153
|
+
`could not read the diff (${error.message})`,
|
|
154
|
+
EXIT.UNSUPPORTED,
|
|
155
|
+
'run this inside a git repository that has a commit',
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cmp(a, b) {
|
|
162
|
+
if (a < b) return -1;
|
|
163
|
+
if (a > b) return 1;
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
package/src/commands/help.mjs
CHANGED
|
@@ -61,6 +61,35 @@ ${CLI} ci simt-dev's own .github files: create or refresh th
|
|
|
61
61
|
${CLI} compare org-wide matrix across every sibling repo
|
|
62
62
|
(read-only; exits non-zero on defects, not on
|
|
63
63
|
repos that have not adopted yet)
|
|
64
|
+
${CLI} index rebuild the local file index from git blob hashes
|
|
65
|
+
${CLI} why [path] what governs a file: a decision whose Affects line names
|
|
66
|
+
it, a rule whose scope matches it, a skill, a gate
|
|
67
|
+
or a lesson. With no path, the Affects tokens this
|
|
68
|
+
tree cannot resolve (UNRESOLVED-AFFECTS)
|
|
69
|
+
${CLI} tests [path] the tests that exercise a file: an import, the conventional
|
|
70
|
+
source beside the test, or a coverage record that
|
|
71
|
+
names both (--cochange adds files that only share
|
|
72
|
+
commits, and only those)
|
|
73
|
+
${CLI} gate [path...] the tests a change reaches, and only those
|
|
74
|
+
(--changed reads the diff instead of the paths)
|
|
75
|
+
${CLI} impact <file|symbol> who a change reaches: importers, their tests,
|
|
76
|
+
what governs it, and a contract another repository references
|
|
77
|
+
${CLI} map [--task "<text>"] [--level outline|names|signatures]
|
|
78
|
+
the files this task is most likely to touch, within
|
|
79
|
+
--budget bytes (default 2048; 0 is unlimited). outline
|
|
80
|
+
is paths, names adds exports, signatures adds the line.
|
|
81
|
+
--recall reports gold-file recall@10 on mined tasks
|
|
82
|
+
${CLI} evidence the runs recorded against the tree on disk
|
|
83
|
+
${CLI} evidence check <file> a commit message that claims a run this tree does not
|
|
84
|
+
have: a warning, or a block when gates.evidence is "block"
|
|
85
|
+
${CLI} shared plan what a workspace's repeated rules cost to carry
|
|
86
|
+
once: the shared set, its bytes, and every path that would be written
|
|
87
|
+
(read-only; touches nothing, not even your home directory)
|
|
88
|
+
${CLI} shared install [--yes] carry those rules once per reader, at user level,
|
|
89
|
+
and declare the delta in each repo's manifest (dry-run unless
|
|
90
|
+
--yes; copies land in files the vendors' own docs name, and a reader
|
|
91
|
+
with no such file gets the block printed to paste; ${CLI} doctor
|
|
92
|
+
reports a copy that is missing or stale)
|
|
64
93
|
${CLI} tokens [--repos] what a reader loads at session start, in bytes
|
|
65
94
|
(read-only; exits 0 whatever it finds, because a
|
|
66
95
|
measurement is not a verdict; --repos measures every
|
|
@@ -110,6 +139,8 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
110
139
|
--dry-run report what would change, change nothing
|
|
111
140
|
--yes assume yes; required in automation, never prompts
|
|
112
141
|
--check report drift and write nothing (sync, ci)
|
|
142
|
+
--frozen check: the lock must match this projection; an output
|
|
143
|
+
change no input change explains fails the gate
|
|
113
144
|
--explain sync: name each line dropped as already inferable, with
|
|
114
145
|
the fact behind it
|
|
115
146
|
--all ignore a cap: every ruling of a record, or the skills budget
|
package/src/commands/hooks.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { promisify } from 'node:util';
|
|
|
19
19
|
|
|
20
20
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
21
21
|
import { MANIFEST, readManifest } from '../projection/manifest.mjs';
|
|
22
|
-
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
|
|
22
|
+
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
|
|
23
23
|
import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
|
|
24
24
|
import { GATES } from '../hooks/gates.mjs';
|
|
25
25
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
@@ -222,11 +222,29 @@ async function installHooks(root, flags) {
|
|
|
222
222
|
(i) => i.present && i.ours && !wanted.includes(i.event) && !chained.some((c) => c.event === i.event),
|
|
223
223
|
);
|
|
224
224
|
|
|
225
|
+
// The claim check is not one of EVENTS. Those shims do not forward git's
|
|
226
|
+
// arguments, and commit-msg's only argument is the message. It is installed
|
|
227
|
+
// when the repository sets gates.evidence, and removed when that setting goes,
|
|
228
|
+
// because a shim that stays after the setting left still replaces git's own hook.
|
|
229
|
+
const evidenceDeclared = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
|
|
230
|
+
const evidenceFile = fileFor('commit-msg');
|
|
231
|
+
const evidenceExisting = await readIfExists(join(root, evidenceFile));
|
|
232
|
+
const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, version }) : null;
|
|
233
|
+
const evidenceOurs = evidenceExisting !== null && evidenceExisting.includes(SHIM_MARKER);
|
|
234
|
+
if (!evidenceDeclared && evidenceOurs) obsolete.push({ event: 'commit-msg' });
|
|
235
|
+
// A foreign commit-msg would otherwise be chained onto the same path. The chain
|
|
236
|
+
// is written after this shim, so it would replace the claim check and the check
|
|
237
|
+
// would never run. The repository's hook stays on disk and the note says it stopped.
|
|
238
|
+
if (evidenceDeclared) {
|
|
239
|
+
const at = chained.findIndex((c) => c.event === 'commit-msg');
|
|
240
|
+
if (at !== -1) chained.splice(at, 1);
|
|
241
|
+
}
|
|
242
|
+
|
|
225
243
|
// Nothing to write and nothing to take away is the only refusal. It sits after
|
|
226
244
|
// `obsolete` because a repository whose last gate just left the manifest has
|
|
227
245
|
// something to take away, and blocking that one left no way out except undoing
|
|
228
246
|
// the change by hand - see the note on `obsolete`.
|
|
229
|
-
if (wanted.length === 0 && obsolete.length === 0) {
|
|
247
|
+
if (wanted.length === 0 && obsolete.length === 0 && !evidenceDeclared) {
|
|
230
248
|
if (isJson(flags)) {
|
|
231
249
|
emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
|
|
232
250
|
return EXIT.FAIL;
|
|
@@ -276,10 +294,20 @@ async function installHooks(root, flags) {
|
|
|
276
294
|
// Named in the plan because the reader approving this write is about to lose a
|
|
277
295
|
// file, and "REMOVED pre-push" does not say why - which is the difference between
|
|
278
296
|
// agreeing with it and discovering later that a gate had been dropped by accident.
|
|
279
|
-
note:
|
|
297
|
+
note:
|
|
298
|
+
h.event === 'commit-msg'
|
|
299
|
+
? `gates.evidence is unset, so the claim check stopped`
|
|
300
|
+
: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (evidenceSource) {
|
|
304
|
+
plan.push({
|
|
305
|
+
file: evidenceFile,
|
|
306
|
+
action: evidenceExisting === evidenceSource ? 'unchanged' : evidenceExisting === null ? 'created' : 'updated',
|
|
307
|
+
note: 'commit-msg checks a verification claim against the ledger',
|
|
280
308
|
});
|
|
281
309
|
}
|
|
282
|
-
const routed = wanted.length > 0 || chained.length > 0;
|
|
310
|
+
const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
|
|
283
311
|
|
|
284
312
|
// The routing this install leaves behind, which is not always `wants`. A repository
|
|
285
313
|
// that removed its last gate has no shims to route to, and leaving `core.hooksPath`
|
|
@@ -362,6 +390,12 @@ async function installHooks(root, flags) {
|
|
|
362
390
|
for (const h of obsolete) {
|
|
363
391
|
await rm(join(root, fileFor(h.event)));
|
|
364
392
|
}
|
|
393
|
+
// After the chain loop, so a chained commit-msg cannot replace the claim check.
|
|
394
|
+
if (evidenceSource) {
|
|
395
|
+
const path = join(root, evidenceFile);
|
|
396
|
+
await writeFile(path, evidenceSource);
|
|
397
|
+
await chmod(path, 0o755);
|
|
398
|
+
}
|
|
365
399
|
|
|
366
400
|
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
367
401
|
// pointing at a directory of no shims stops the repository's own hooks - the ones in
|
|
@@ -385,6 +419,10 @@ async function installHooks(root, flags) {
|
|
|
385
419
|
notes.push(
|
|
386
420
|
`chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`,
|
|
387
421
|
);
|
|
422
|
+
} else if (f.event === 'commit-msg' && evidenceDeclared) {
|
|
423
|
+
notes.push(
|
|
424
|
+
`stopped running commit-msg at ${f.path} (${f.bytes} bytes); the claim check replaced it, and the file is still on disk, untouched`,
|
|
425
|
+
);
|
|
388
426
|
} else if (leftRunning.some((c) => c.event === f.event)) {
|
|
389
427
|
// A third case, and the one that was misreported: a hook this tool could not chain
|
|
390
428
|
// because the shim would have had to run itself. Nothing overwrote it, so it still
|
|
@@ -403,7 +441,9 @@ async function installHooks(root, flags) {
|
|
|
403
441
|
}
|
|
404
442
|
for (const h of obsolete) {
|
|
405
443
|
notes.push(
|
|
406
|
-
|
|
444
|
+
h.event === 'commit-msg'
|
|
445
|
+
? `removed ${fileFor(h.event)}: ${MANIFEST_FILE} no longer sets gates.evidence, so the claim check stopped replacing git's own commit-msg`
|
|
446
|
+
: `removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
|
|
407
447
|
);
|
|
408
448
|
}
|
|
409
449
|
if (leaves === null && current !== null) {
|