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.
Files changed (62) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +129 -75
  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 +1 -1
  20. package/rules/core.md +1 -1
  21. package/src/argv.mjs +18 -0
  22. package/src/ci/workflow.mjs +11 -1
  23. package/src/commands/bench.mjs +13 -1
  24. package/src/commands/evidence.mjs +96 -0
  25. package/src/commands/gate.mjs +165 -0
  26. package/src/commands/help.mjs +31 -0
  27. package/src/commands/hooks.mjs +45 -5
  28. package/src/commands/impact.mjs +205 -0
  29. package/src/commands/index.mjs +35 -0
  30. package/src/commands/map.mjs +91 -0
  31. package/src/commands/repoindex.mjs +52 -0
  32. package/src/commands/shared.mjs +398 -0
  33. package/src/commands/sync.mjs +72 -3
  34. package/src/commands/tests.mjs +192 -0
  35. package/src/commands/tokens.mjs +30 -1
  36. package/src/commands/why.mjs +177 -0
  37. package/src/decisions/index.mjs +1 -1
  38. package/src/graph/spec.mjs +1 -1
  39. package/src/hooks/dispatcher.mjs +15 -0
  40. package/src/hooks/shim.mjs +31 -0
  41. package/src/index/compact.mjs +228 -0
  42. package/src/index/extract.mjs +331 -0
  43. package/src/index/gate.mjs +101 -0
  44. package/src/index/govern.mjs +222 -0
  45. package/src/index/impact.mjs +186 -0
  46. package/src/index/map.mjs +393 -0
  47. package/src/index/persist.mjs +172 -0
  48. package/src/index/store.mjs +301 -0
  49. package/src/index/tests.mjs +294 -0
  50. package/src/kernel/budget.mjs +100 -0
  51. package/src/kernel/checks.mjs +378 -0
  52. package/src/ledger/index.mjs +112 -0
  53. package/src/measure/context.mjs +7 -5
  54. package/src/projection/disclosure.mjs +92 -0
  55. package/src/projection/lock.mjs +93 -0
  56. package/src/projection/manifest.mjs +52 -0
  57. package/src/projection/markers.mjs +56 -17
  58. package/src/projection/project.mjs +24 -17
  59. package/src/projection/rules.mjs +378 -44
  60. package/src/projection/shared.mjs +428 -0
  61. package/src/projection/targets.mjs +2 -1
  62. 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.0",
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
  /**
@@ -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
- uses('checkout'),
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"
@@ -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, { maxBytes: flags.maxBytes ?? 0 });
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
+ }
@@ -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
@@ -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: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
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
- `removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
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) {