simt-dev 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/README.md +138 -74
- 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 +2 -2
- package/rules/core.md +2 -2
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +34 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +53 -16
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +65 -3
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +52 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +126 -0
- package/src/commands/repoindex.mjs +55 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/shared.mjs +398 -0
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +87 -3
- package/src/commands/tests.mjs +145 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +172 -0
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- 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 +89 -0
- package/src/index/govern.mjs +234 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +627 -0
- package/src/index/persist.mjs +239 -0
- package/src/index/store.mjs +515 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +147 -0
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/measure/context.mjs +7 -5
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +125 -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 +407 -49
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +8 -0
|
@@ -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
|
@@ -16,6 +16,12 @@ ${CLI} upgrade move every consumer to this simt-dev SHA, as one s
|
|
|
16
16
|
(dry-run unless --yes; moves a pin only - it never
|
|
17
17
|
adopts or repairs a repo; never commits or pushes)
|
|
18
18
|
|
|
19
|
+
${CLI} brief "<task>" one packet for a task: the kind, the commands, the
|
|
20
|
+
likely files, the tests and the decisions that govern
|
|
21
|
+
them, inside --budget bytes (default 2048; 0 is
|
|
22
|
+
unlimited). Says how many items it cut; --explain
|
|
23
|
+
says why each was kept or dropped
|
|
24
|
+
|
|
19
25
|
${CLI} skills index tier-0 catalog: every skill, one line each
|
|
20
26
|
${CLI} skills brief --task tier-1 pre-assembled brief <- the common case
|
|
21
27
|
${CLI} skills show <name> tier-2 body (respects --max-bytes)
|
|
@@ -28,6 +34,9 @@ ${CLI} skills eval [corpus] measure the resolver against a gold corpus
|
|
|
28
34
|
${CLI} skills install <name> pin a skill into the repo as a local override
|
|
29
35
|
(dry-run unless --yes; a multi-variant skill needs
|
|
30
36
|
--variant or --from rather than a guess)
|
|
37
|
+
${CLI} skills compile [<name>] compile a skill to Agent Skills standard, flag bodies
|
|
38
|
+
over 5000 tokens with a split plan, and report per-skill
|
|
39
|
+
trigger recall and precision from R02 transcripts
|
|
31
40
|
|
|
32
41
|
${CLI} rules list the rule catalogue (--root marks what applies to a repo)
|
|
33
42
|
${CLI} rules show <id> one rule body
|
|
@@ -39,7 +48,8 @@ ${CLI} hooks install [--yes] install the shared dispatcher; report what it wou
|
|
|
39
48
|
${CLI} hooks status what each git event routes to, and whether it is wired
|
|
40
49
|
${CLI} hooks run <event> run one event's gates now, as git would
|
|
41
50
|
(pre-commit, pre-push, post-merge, ...)
|
|
42
|
-
${CLI} agent-hook <event> the entry point a harness calls
|
|
51
|
+
${CLI} agent-hook <event> the entry point a harness calls: pre-tool-use decides,
|
|
52
|
+
prompt-submit delivers the packet as prompt context
|
|
43
53
|
(reads one JSON payload on stdin and answers in that
|
|
44
54
|
harness's dialect; exit 2 blocks; needs --harness)
|
|
45
55
|
${CLI} agent-hooks install write this repo's policies into the harnesses that
|
|
@@ -61,6 +71,46 @@ ${CLI} ci simt-dev's own .github files: create or refresh th
|
|
|
61
71
|
${CLI} compare org-wide matrix across every sibling repo
|
|
62
72
|
(read-only; exits non-zero on defects, not on
|
|
63
73
|
repos that have not adopted yet)
|
|
74
|
+
${CLI} index rebuild the local file index from git blob hashes
|
|
75
|
+
${CLI} why [path] what governs a file: a decision whose Affects line names
|
|
76
|
+
it, a rule whose scope matches it, a skill, a gate
|
|
77
|
+
or a lesson. With no path, the Affects tokens this
|
|
78
|
+
tree cannot resolve (UNRESOLVED-AFFECTS)
|
|
79
|
+
${CLI} tests [path] the tests that exercise a file: an import, the conventional
|
|
80
|
+
source beside the test, or a coverage record that
|
|
81
|
+
names both (--cochange adds files that only share
|
|
82
|
+
commits, and only those)
|
|
83
|
+
${CLI} gate [path...] the tests a change reaches, and only those
|
|
84
|
+
(--changed reads the diff instead of the paths)
|
|
85
|
+
${CLI} impact <file|symbol> who a change reaches: importers, their tests,
|
|
86
|
+
what governs it, and a contract another repository references
|
|
87
|
+
${CLI} map [--task "<text>"] [--level outline|names|signatures]
|
|
88
|
+
the files this task is most likely to touch, within
|
|
89
|
+
--budget bytes (default 2048; 0 is unlimited). outline
|
|
90
|
+
is paths, names adds exports, signatures adds the line.
|
|
91
|
+
--recall reports gold-file recall@10 on mined tasks
|
|
92
|
+
${CLI} evidence the runs recorded against the tree on disk
|
|
93
|
+
${CLI} evidence check <file> a commit message that claims a run this tree does not
|
|
94
|
+
have: a warning, or a block when gates.evidence is "block"
|
|
95
|
+
${CLI} lessons list deterministic lesson candidates in .simt-dev/lessons.jsonl
|
|
96
|
+
and promoted lessons in docs/lessons/*.md
|
|
97
|
+
${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
|
|
98
|
+
(dry-run unless --yes; writes Appendix A frontmatter)
|
|
99
|
+
${CLI} decide [<title>] scaffold an ADR with the next free number, a Status: line,
|
|
100
|
+
a rulings skeleton, and structured simt-affects +
|
|
101
|
+
simt-supersedes frontmatter (dry-run unless --yes;
|
|
102
|
+
--check lints ADRs for prose-only partial supersessions)
|
|
103
|
+
${CLI} local doctor probe configured local model endpoints (chat and embed)
|
|
104
|
+
(reads local in ${MANIFEST_FILE}; opens no socket when
|
|
105
|
+
unset; refuses non-loopback URLs without --allow-remote)
|
|
106
|
+
${CLI} shared plan what a workspace's repeated rules cost to carry
|
|
107
|
+
once: the shared set, its bytes, and every path that would be written
|
|
108
|
+
(read-only; touches nothing, not even your home directory)
|
|
109
|
+
${CLI} shared install [--yes] carry those rules once per reader, at user level,
|
|
110
|
+
and declare the delta in each repo's manifest (dry-run unless
|
|
111
|
+
--yes; copies land in files the vendors' own docs name, and a reader
|
|
112
|
+
with no such file gets the block printed to paste; ${CLI} doctor
|
|
113
|
+
reports a copy that is missing or stale)
|
|
64
114
|
${CLI} tokens [--repos] what a reader loads at session start, in bytes
|
|
65
115
|
(read-only; exits 0 whatever it finds, because a
|
|
66
116
|
measurement is not a verdict; --repos measures every
|
|
@@ -88,6 +138,9 @@ ${CLI} bench ablate [--yes] leave-one-out ablation across core, profile and p
|
|
|
88
138
|
${CLI} bench probes [--yes] 12 synthetic rule-compliance probes with deterministic graders
|
|
89
139
|
(dry-run unless --yes; grades git state, transcript and ledger,
|
|
90
140
|
and reports compliance per arm)
|
|
141
|
+
${CLI} bench variants [--yes] evaluate simt-variants rule slots on the canary suite and lock
|
|
142
|
+
winning variants in simt-dev.lock.json (dry-run unless --yes;
|
|
143
|
+
re-opens when model or harness changes)
|
|
91
144
|
${CLI} mcp the read-only surface as MCP tools over stdio
|
|
92
145
|
(--list prints it without reading stdin; init, sync,
|
|
93
146
|
upgrade and the installers stay CLI-only by design)`;
|
|
@@ -95,7 +148,8 @@ ${CLI} mcp the read-only surface as MCP tools over stdio
|
|
|
95
148
|
const FLAGS = `--json structured output (compact; one document, one line)
|
|
96
149
|
--pretty indent --json for a human
|
|
97
150
|
--max-bytes <n> hard ceiling on emitted bytes (0 = unlimited, default 0)
|
|
98
|
-
--budget <n> context budget for
|
|
151
|
+
--budget <n> context budget for a brief (default 2048)
|
|
152
|
+
(skills brief keeps its own default of 20480)
|
|
99
153
|
--plan emit a fetch plan instead of inlining skill bodies
|
|
100
154
|
--skill <name> force a skill into the brief (repeatable)
|
|
101
155
|
--task "<text>" the task text used for capability resolution
|
|
@@ -110,8 +164,11 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
110
164
|
--dry-run report what would change, change nothing
|
|
111
165
|
--yes assume yes; required in automation, never prompts
|
|
112
166
|
--check report drift and write nothing (sync, ci)
|
|
167
|
+
--frozen check: the lock must match this projection; an output
|
|
168
|
+
change no input change explains fails the gate
|
|
113
169
|
--explain sync: name each line dropped as already inferable, with
|
|
114
|
-
the fact behind it
|
|
170
|
+
the fact behind it; brief: name every item and why it
|
|
171
|
+
was kept or cut, with its section's value
|
|
115
172
|
--all ignore a cap: every ruling of a record, or the skills budget
|
|
116
173
|
--variant <id> which body of a multi-variant skill to use
|
|
117
174
|
--from <repo/path> install from a declared source by name
|
|
@@ -145,6 +202,11 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
145
202
|
--compare <a> <b> bench report: compare two arms with deltas and p-values
|
|
146
203
|
--test <cmd> plan done: the test command that closes the row
|
|
147
204
|
--state <name> plan done: target state (default: Complete)
|
|
205
|
+
--level <name> map: detail level (outline, names, signatures)
|
|
206
|
+
--supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
|
|
207
|
+
--allow-remote local doctor: allow non-loopback model URLs (refused by default)
|
|
208
|
+
--embed index / map / brief: compute or query optional local vector embeddings
|
|
209
|
+
(off by default unless recall@10 gains at least 10 points)
|
|
148
210
|
--help this text
|
|
149
211
|
--version the version, and the SHA it was built from`;
|
|
150
212
|
|
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) {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// `simt impact` reports who a file or an export reaches (R23, ADR-096).
|
|
2
|
+
//
|
|
3
|
+
// Importers, the tests that cover them, the decisions and rules that govern
|
|
4
|
+
// the file, the CODEOWNERS line, and — when a workspace is configured —
|
|
5
|
+
// another repository that references a contract this change touches.
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { basename, join, resolve } from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
|
|
12
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
13
|
+
import { CLI } from '../name.mjs';
|
|
14
|
+
import { discover } from '../compare/discover.mjs';
|
|
15
|
+
import { extract } from '../index/extract.mjs';
|
|
16
|
+
import { importEdges, readEntries } from '../index/gate.mjs';
|
|
17
|
+
import {
|
|
18
|
+
contractReferences,
|
|
19
|
+
dependents,
|
|
20
|
+
filesExporting,
|
|
21
|
+
isContractPath,
|
|
22
|
+
ownersOf,
|
|
23
|
+
relatedContracts,
|
|
24
|
+
} from '../index/impact.mjs';
|
|
25
|
+
import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
|
|
26
|
+
import { decisionRecords, ruleScopes } from './why.mjs';
|
|
27
|
+
import { resolveWorkspace } from '../workspace.mjs';
|
|
28
|
+
|
|
29
|
+
const exec = promisify(execFile);
|
|
30
|
+
const TEXT_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'md', 'toml', 'xml', 'py', 'go', 'kt', 'java', 'rs']);
|
|
31
|
+
const READ_CAP = 256 * 1024;
|
|
32
|
+
const FILE_CAP = 2000;
|
|
33
|
+
|
|
34
|
+
export async function impactCommand(args, flags, commands = []) {
|
|
35
|
+
if (args.length > 1) return fail(`unknown impact argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
|
|
36
|
+
const target = args[0];
|
|
37
|
+
if (!target) return fail(`a file or a symbol is required`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
|
|
38
|
+
const root = flags.root ?? process.cwd();
|
|
39
|
+
const listed = await tracked(root);
|
|
40
|
+
if (listed.error) return listed.error;
|
|
41
|
+
const files = new Set(listed.files);
|
|
42
|
+
const entries = await readEntries(root, files);
|
|
43
|
+
const imports = importEdges(entries);
|
|
44
|
+
const symbols = entries.flatMap((entry) => (entry.lang ? extract(entry.path, entry.text, entry.lang).symbols : []));
|
|
45
|
+
const resolved = resolveTarget(target, files, symbols);
|
|
46
|
+
if (resolved.error) return resolved.error;
|
|
47
|
+
const reach = dependents(resolved.file, imports);
|
|
48
|
+
const governs = await governance(root, resolved.file, files, commands);
|
|
49
|
+
const owners = await readOwners(root, resolved.file);
|
|
50
|
+
const contracts = await contractsFor(root, listed.files, resolved, reach.importers);
|
|
51
|
+
const consumers = await consumersFor(root, flags, basename(root), contracts);
|
|
52
|
+
const truncated = reach.truncated || consumers.truncated;
|
|
53
|
+
const payload = {
|
|
54
|
+
ok: true,
|
|
55
|
+
command: 'impact',
|
|
56
|
+
root,
|
|
57
|
+
file: resolved.file,
|
|
58
|
+
symbol: resolved.symbol,
|
|
59
|
+
importers: reach.importers,
|
|
60
|
+
tests: reach.tests,
|
|
61
|
+
governs,
|
|
62
|
+
owners,
|
|
63
|
+
consumers: consumers.references,
|
|
64
|
+
truncated,
|
|
65
|
+
};
|
|
66
|
+
if (isJson(flags)) {
|
|
67
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
68
|
+
return EXIT.OK;
|
|
69
|
+
}
|
|
70
|
+
emit(render(payload), { maxBytes: flags.maxBytes ?? 0 });
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveTarget(target, files, symbols) {
|
|
75
|
+
if (files.has(target)) return { file: target, symbol: '' };
|
|
76
|
+
const exported = filesExporting(target, symbols);
|
|
77
|
+
if (exported.length === 1) return { file: exported[0], symbol: target };
|
|
78
|
+
if (exported.length === 0) {
|
|
79
|
+
return { error: fail(`not a file or an export: ${target}`, EXIT.USAGE, `pass a path git tracks, or the name of one export`) };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
error: fail(
|
|
83
|
+
`${target} is exported by ${exported.join(', ')}`,
|
|
84
|
+
EXIT.USAGE,
|
|
85
|
+
`pass the path of the export you mean`,
|
|
86
|
+
),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function governance(root, file, files, commands) {
|
|
91
|
+
const records = await decisionRecords(root);
|
|
92
|
+
const rules = await ruleScopes(root);
|
|
93
|
+
const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
|
|
94
|
+
const { hits } = decisionHits(records, file, ctx);
|
|
95
|
+
return sortHits([...hits, ...scopeHits(file, { rules })]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readOwners(root, file) {
|
|
99
|
+
for (const path of ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
100
|
+
try {
|
|
101
|
+
return ownersOf(await readFile(join(root, path), 'utf8'), file);
|
|
102
|
+
} catch {
|
|
103
|
+
// The next location may hold the file. GitHub reads the first that exists.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function contractsFor(root, files, resolved, importers) {
|
|
110
|
+
const contracts = [];
|
|
111
|
+
for (const path of files) {
|
|
112
|
+
if (!isContractPath(path) && !mightSniff(path)) continue;
|
|
113
|
+
let text = '';
|
|
114
|
+
try {
|
|
115
|
+
text = await readFile(join(root, path), 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!isContractPath(path) && !sniffs(text)) continue;
|
|
120
|
+
contracts.push({ path, text });
|
|
121
|
+
}
|
|
122
|
+
return relatedContracts(contracts, { file: resolved.file, symbol: resolved.symbol, importers });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function consumersFor(root, flags, repoName, contracts) {
|
|
126
|
+
if (contracts.length === 0) return { references: [], truncated: false };
|
|
127
|
+
const workspace = await resolveWorkspace(flags);
|
|
128
|
+
if (!workspace.root) return { references: [], truncated: false };
|
|
129
|
+
const found = await discover(workspace.root);
|
|
130
|
+
if (found.shape !== 'workspace') return { references: [], truncated: false };
|
|
131
|
+
const here = resolve(root);
|
|
132
|
+
const files = [];
|
|
133
|
+
let truncated = false;
|
|
134
|
+
for (const repo of found.repos) {
|
|
135
|
+
if (resolve(repo) === here) continue;
|
|
136
|
+
const listed = await tracked(repo);
|
|
137
|
+
if (listed.error) continue;
|
|
138
|
+
const name = basename(repo);
|
|
139
|
+
let read = 0;
|
|
140
|
+
for (const path of listed.files) {
|
|
141
|
+
if (!textExt(path)) continue;
|
|
142
|
+
if (read >= FILE_CAP) {
|
|
143
|
+
truncated = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
read += 1;
|
|
147
|
+
try {
|
|
148
|
+
const text = await readFile(join(repo, path), 'utf8');
|
|
149
|
+
if (text.length > READ_CAP) continue;
|
|
150
|
+
files.push({ repo: name, path, text });
|
|
151
|
+
} catch {
|
|
152
|
+
// A file git lists and the worktree no longer has is not a consumer.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const refs = contractReferences(contracts, repoName, files);
|
|
157
|
+
return { references: refs.references, truncated: truncated || refs.truncated };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function render(payload) {
|
|
161
|
+
const lines = [`file ${payload.file}`];
|
|
162
|
+
if (payload.symbol) lines.push(`symbol ${payload.symbol}`);
|
|
163
|
+
for (const path of payload.importers) lines.push(`importer ${path}`);
|
|
164
|
+
for (const path of payload.tests) lines.push(`test ${path}`);
|
|
165
|
+
for (const hit of payload.governs) lines.push(`${hit.kind.padEnd(10)}${hit.id} ${hit.detail}`);
|
|
166
|
+
for (const owner of payload.owners) lines.push(`owner ${owner}`);
|
|
167
|
+
for (const consumer of payload.consumers) lines.push(`consumer ${consumer.repo}/${consumer.path} ${consumer.contract}`);
|
|
168
|
+
if (payload.truncated) lines.push('truncated 1');
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mightSniff(file) {
|
|
173
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
174
|
+
const dot = base.lastIndexOf('.');
|
|
175
|
+
if (dot <= 0) return false;
|
|
176
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
177
|
+
return ext === 'json' || ext === 'yaml' || ext === 'yml';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sniffs(text) {
|
|
181
|
+
const head = text.slice(0, 500);
|
|
182
|
+
return head.includes('"openapi"') || head.includes('"$schema"');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function textExt(file) {
|
|
186
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
187
|
+
const dot = base.lastIndexOf('.');
|
|
188
|
+
if (dot <= 0) return false;
|
|
189
|
+
return TEXT_EXT.has(base.slice(dot + 1).toLowerCase());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function tracked(root) {
|
|
193
|
+
try {
|
|
194
|
+
const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
|
|
195
|
+
return { files: stdout.split('\n').filter(Boolean) };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
error: fail(
|
|
199
|
+
`could not read the tree (${error.message})`,
|
|
200
|
+
EXIT.UNSUPPORTED,
|
|
201
|
+
'run this inside a git repository',
|
|
202
|
+
),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|