simt-dev 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// `simt tests [path]` lists the tests that exercise a file (R24, ADR-092).
|
|
2
|
+
//
|
|
3
|
+
// The index already stores imports, but a test import that spans lines is not
|
|
4
|
+
// one of them. Test files are read back from their blobs, once, in a single
|
|
5
|
+
// batch. Coverage is read only from a fixed list of paths. History is the last
|
|
6
|
+
// 200 non-merge commits, which is a map and not the whole changelog.
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { readFile } 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 { isTestPath } from '../bench/mine.mjs';
|
|
16
|
+
import { readEdges, readFiles, reindex } from '../index/store.mjs';
|
|
17
|
+
import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
|
|
18
|
+
|
|
19
|
+
const exec = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
const LANG = {
|
|
22
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
23
|
+
ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
|
|
24
|
+
py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const COVERAGE_PATHS = [
|
|
28
|
+
'coverage/lcov.info',
|
|
29
|
+
'lcov.info',
|
|
30
|
+
'coverage/coverage-final.json',
|
|
31
|
+
'coverage.json',
|
|
32
|
+
'jacoco.xml',
|
|
33
|
+
'build/reports/jacoco/test/jacocoTestReport.xml',
|
|
34
|
+
'coverage.out',
|
|
35
|
+
'cover.out',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export async function testsCommand(args, flags) {
|
|
39
|
+
if (args.length > 1) return fail(`unknown tests argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} tests [path]`);
|
|
40
|
+
const root = flags.root ?? process.cwd();
|
|
41
|
+
const loaded = await loadFiles(root);
|
|
42
|
+
if (loaded.error) return loaded.error;
|
|
43
|
+
const { files } = loaded;
|
|
44
|
+
const [stored, texts, coverage, commits] = await Promise.all([
|
|
45
|
+
readEdges(root),
|
|
46
|
+
testBlobs(root, files),
|
|
47
|
+
coverageTexts(root),
|
|
48
|
+
history(root),
|
|
49
|
+
]);
|
|
50
|
+
const paths = new Set(files.map((file) => file.path));
|
|
51
|
+
const imports = stored.filter((edge) => edge.kind === 'import');
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
if (!isTestPath(file.path)) continue;
|
|
54
|
+
const text = texts.get(file.blob);
|
|
55
|
+
if (!text) continue;
|
|
56
|
+
imports.push(...importsOf(file.path, text, langOf(file.path), paths));
|
|
57
|
+
}
|
|
58
|
+
const edges = testEdges({ files, imports, coverage, commits });
|
|
59
|
+
const target = args[0];
|
|
60
|
+
if (!target) return summarise(root, flags, edges);
|
|
61
|
+
if (!paths.has(target)) {
|
|
62
|
+
return fail(`not in the index: ${target}`, EXIT.USAGE, `pass a path ${CLI} index knows`);
|
|
63
|
+
}
|
|
64
|
+
const tests = testsFor(target, edges, { cochange: flags.cochange === true });
|
|
65
|
+
const payload = { ok: true, command: 'tests', root, path: target, tests };
|
|
66
|
+
if (isJson(flags)) {
|
|
67
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
68
|
+
return EXIT.OK;
|
|
69
|
+
}
|
|
70
|
+
const lines = [`tests ${tests.length}`, ...tests];
|
|
71
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
72
|
+
return EXIT.OK;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summarise(root, flags, edges) {
|
|
76
|
+
let test = 0;
|
|
77
|
+
let cochange = 0;
|
|
78
|
+
for (const edge of edges) {
|
|
79
|
+
if (edge.kind === 'test') test += 1;
|
|
80
|
+
else if (edge.kind === 'cochange') cochange += 1;
|
|
81
|
+
}
|
|
82
|
+
const payload = { ok: true, command: 'tests', root, test, cochange };
|
|
83
|
+
if (isJson(flags)) {
|
|
84
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
85
|
+
return EXIT.OK;
|
|
86
|
+
}
|
|
87
|
+
emit(`test ${test}\ncochange ${cochange}`, { maxBytes: flags.maxBytes ?? 0 });
|
|
88
|
+
return EXIT.OK;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadFiles(root) {
|
|
92
|
+
let files = await readFiles(root);
|
|
93
|
+
if (files.length === 0) {
|
|
94
|
+
try {
|
|
95
|
+
await reindex(root);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return {
|
|
98
|
+
error: fail(
|
|
99
|
+
`could not map tests in this repository (${error.message})`,
|
|
100
|
+
EXIT.UNSUPPORTED,
|
|
101
|
+
'run this inside a git repository',
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
files = await readFiles(root);
|
|
106
|
+
}
|
|
107
|
+
return { files };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function testBlobs(root, files) {
|
|
111
|
+
const blobs = [];
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
if (isTestPath(file.path) && file.blob) blobs.push(file.blob);
|
|
114
|
+
}
|
|
115
|
+
return cat(root, blobs);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function coverageTexts(root) {
|
|
119
|
+
const texts = [];
|
|
120
|
+
for (const path of COVERAGE_PATHS) {
|
|
121
|
+
try {
|
|
122
|
+
const text = await readFile(join(root, path), 'utf8');
|
|
123
|
+
if (parseCoverage(text).length > 0 || text.trim() !== '') texts.push(text);
|
|
124
|
+
} catch {
|
|
125
|
+
// Absent coverage is the usual case. It is not a failed map.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return texts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function history(root) {
|
|
132
|
+
let stdout = '';
|
|
133
|
+
try {
|
|
134
|
+
({ stdout } = await exec('git', ['log', '-n', String(HISTORY), '--no-merges', '--name-only', '--pretty=format:%x1e'], {
|
|
135
|
+
cwd: root,
|
|
136
|
+
maxBuffer: 16 << 20,
|
|
137
|
+
}));
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const commits = [];
|
|
142
|
+
for (const block of stdout.split('\x1e')) {
|
|
143
|
+
const paths = [];
|
|
144
|
+
for (const line of block.split('\n')) {
|
|
145
|
+
const path = line.trim();
|
|
146
|
+
if (path) paths.push(path);
|
|
147
|
+
}
|
|
148
|
+
if (paths.length > 0) commits.push(paths);
|
|
149
|
+
}
|
|
150
|
+
return commits;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cat(root, blobs) {
|
|
154
|
+
const unique = [...new Set(blobs)];
|
|
155
|
+
const texts = new Map();
|
|
156
|
+
if (unique.length === 0) return texts;
|
|
157
|
+
const body = await batch(root, unique);
|
|
158
|
+
let offset = 0;
|
|
159
|
+
while (offset < body.length) {
|
|
160
|
+
const nl = body.indexOf(0x0a, offset);
|
|
161
|
+
if (nl < 0) break;
|
|
162
|
+
const header = body.toString('utf8', offset, nl);
|
|
163
|
+
const parts = header.split(' ');
|
|
164
|
+
const size = Number(parts[2]);
|
|
165
|
+
if (parts[1] !== 'blob' || !Number.isFinite(size)) {
|
|
166
|
+
offset = nl + 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const start = nl + 1;
|
|
170
|
+
const bytes = body.subarray(start, start + size);
|
|
171
|
+
offset = start + size + 1;
|
|
172
|
+
texts.set(parts[0], bytes.includes(0) ? null : bytes.toString('utf8'));
|
|
173
|
+
}
|
|
174
|
+
return texts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function batch(root, blobs) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const child = execFile('git', ['cat-file', '--batch'], { cwd: root, maxBuffer: 32 << 20, encoding: 'buffer' }, (error, stdout) => {
|
|
180
|
+
resolve(error ? Buffer.alloc(0) : stdout);
|
|
181
|
+
});
|
|
182
|
+
child.stdin.write(`${blobs.join('\n')}\n`);
|
|
183
|
+
child.stdin.end();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function langOf(path) {
|
|
188
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
189
|
+
const dot = base.lastIndexOf('.');
|
|
190
|
+
if (dot <= 0) return '';
|
|
191
|
+
return LANG[base.slice(dot + 1)] ?? '';
|
|
192
|
+
}
|
package/src/commands/tokens.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
mcpTools,
|
|
34
34
|
totals,
|
|
35
35
|
} from '../measure/context.mjs';
|
|
36
|
+
import { measureSession } from '../measure/session.mjs';
|
|
37
|
+
import { sharedTotals } from '../projection/shared.mjs';
|
|
36
38
|
|
|
37
39
|
/** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
|
|
38
40
|
* this command exists so two machines produce two numbers a pull request can diff. */
|
|
@@ -45,6 +47,36 @@ function files(n) {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export async function tokensCommand(positional, flags) {
|
|
50
|
+
if (flags.session !== undefined) {
|
|
51
|
+
const session = await measureSession(flags.session);
|
|
52
|
+
if (!session.ok) {
|
|
53
|
+
return fail(session.error, EXIT.CONFIG, session.remedy);
|
|
54
|
+
}
|
|
55
|
+
if (isJson(flags)) {
|
|
56
|
+
emitJson({
|
|
57
|
+
ok: true,
|
|
58
|
+
command: 'tokens',
|
|
59
|
+
mode: 'session',
|
|
60
|
+
session: session.path,
|
|
61
|
+
bytesPerToken: BYTES_PER_TOKEN,
|
|
62
|
+
rules: session.rules,
|
|
63
|
+
fileReads: session.fileReads,
|
|
64
|
+
toolOutput: session.toolOutput,
|
|
65
|
+
total: session.total,
|
|
66
|
+
});
|
|
67
|
+
return EXIT.OK;
|
|
68
|
+
}
|
|
69
|
+
const lines = [
|
|
70
|
+
`tokens ${session.path} (session)`,
|
|
71
|
+
`rules ${plain(session.rules.bytes)} B ~${plain(session.rules.tokens)} tokens (${plain(session.rules.share)}%)`,
|
|
72
|
+
`file reads ${plain(session.fileReads.bytes)} B ~${plain(session.fileReads.tokens)} tokens (${plain(session.fileReads.share)}%)`,
|
|
73
|
+
`tool output ${plain(session.toolOutput.bytes)} B ~${plain(session.toolOutput.tokens)} tokens (${plain(session.toolOutput.share)}%)`,
|
|
74
|
+
`total ${plain(session.total.bytes)} B ~${plain(session.total.tokens)} tokens`,
|
|
75
|
+
];
|
|
76
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
77
|
+
return EXIT.OK;
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
// `targetRoot` owns the precedence, so `tokens`, `compare` and `upgrade` cannot
|
|
49
81
|
// disagree about which directory was named (ADR-014).
|
|
50
82
|
const target = await targetRoot(flags, positional);
|
|
@@ -78,7 +110,17 @@ export async function tokensCommand(positional, flags) {
|
|
|
78
110
|
for (const path of roots) rows.push(await measureRoot(path));
|
|
79
111
|
annotate(rows);
|
|
80
112
|
|
|
81
|
-
const
|
|
113
|
+
const measured = totals(rows);
|
|
114
|
+
// The workspace copy, counted once (ADR-084). A figure that measured the regions and
|
|
115
|
+
// ignored the file the rules moved into would show a saving the reader's context never
|
|
116
|
+
// saw, so it is in the total from the first run rather than as an afterthought.
|
|
117
|
+
const shared = await sharedTotals(rows);
|
|
118
|
+
const summary = {
|
|
119
|
+
...measured,
|
|
120
|
+
sharedBytes: shared.bytes,
|
|
121
|
+
estateBytes: measured.generatedBytes + shared.bytes,
|
|
122
|
+
sharedFiles: shared.files,
|
|
123
|
+
};
|
|
82
124
|
const mcp = mcpTools();
|
|
83
125
|
|
|
84
126
|
if (isJson(flags)) {
|
|
@@ -111,6 +153,15 @@ export async function tokensCommand(positional, flags) {
|
|
|
111
153
|
lines.push(
|
|
112
154
|
`totals generated ${plain(summary.generatedBytes)} B duplicate ${plain(summary.duplicateBytes)} B (${plain(share)}% of generated) unique ${plain(summary.uniqueBytes)} B repeat ${plain(summary.repeatBytes)} B ~${plain(summary.tokens)} tokens`,
|
|
113
155
|
);
|
|
156
|
+
// Only when there is a copy to count. The line a reader sees most is the one that
|
|
157
|
+
// says what their whole estate loads, and adding a second number to it that is
|
|
158
|
+
// usually zero is how a report stops being read.
|
|
159
|
+
if (summary.sharedBytes > 0) {
|
|
160
|
+
lines.push(
|
|
161
|
+
`shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) estate total ${plain(summary.estateBytes)} B`,
|
|
162
|
+
);
|
|
163
|
+
for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
|
|
164
|
+
}
|
|
114
165
|
} else {
|
|
115
166
|
const row = rows[0];
|
|
116
167
|
lines.push(`tokens ${row.path}`);
|
|
@@ -124,6 +175,15 @@ export async function tokensCommand(positional, flags) {
|
|
|
124
175
|
lines.push(
|
|
125
176
|
`always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
|
|
126
177
|
);
|
|
178
|
+
// The same reason as the `--repos` line, and it matters more here: measuring one
|
|
179
|
+
// repository is the default, so a reader who owns the workspace copy and never saw it
|
|
180
|
+
// counted would read a saving their own session does not get.
|
|
181
|
+
if (summary.sharedBytes > 0) {
|
|
182
|
+
lines.push(
|
|
183
|
+
`shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) always-on for you: ${plain(summary.estateBytes)} B`,
|
|
184
|
+
);
|
|
185
|
+
for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
|
|
186
|
+
}
|
|
127
187
|
lines.push(
|
|
128
188
|
`redundant ${plain(row.redundancy.duplicateBytes)} B repeats an earlier repository ${plain(row.redundancy.repeatBytes)} B repeats an earlier document here ${plain(row.redundancy.uniqueBytes)} B unique of ${plain(row.redundancy.blockBytes)} B counted in blocks`,
|
|
129
189
|
);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// `simt why [path]` lists what governs a file (R25, ADR-093).
|
|
2
|
+
//
|
|
3
|
+
// With a path, one line per decision, rule, skill, gate and lesson. With no
|
|
4
|
+
// path, the Affects tokens that name nothing this tree contains. The file list
|
|
5
|
+
// is `git ls-files`. The index is not rebuilt: governance is the records, and
|
|
6
|
+
// writing the cache to answer a question would change the tree.
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { readFile, readdir } 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, MANIFEST_FILE } from '../name.mjs';
|
|
15
|
+
import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
|
|
16
|
+
import {
|
|
17
|
+
decisionHits,
|
|
18
|
+
lessonPaths,
|
|
19
|
+
scopeHits,
|
|
20
|
+
scopePaths,
|
|
21
|
+
sortHits,
|
|
22
|
+
sourceRoots,
|
|
23
|
+
unresolvedAffects,
|
|
24
|
+
} from '../index/govern.mjs';
|
|
25
|
+
|
|
26
|
+
const exec = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt-dev/profiles'];
|
|
29
|
+
|
|
30
|
+
export async function whyCommand(args, flags, commands = []) {
|
|
31
|
+
if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
|
|
32
|
+
const root = flags.root ?? process.cwd();
|
|
33
|
+
const listed = await tracked(root);
|
|
34
|
+
if (listed.error) return listed.error;
|
|
35
|
+
const files = new Set(listed.files);
|
|
36
|
+
const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
|
|
37
|
+
const records = await decisionRecords(root);
|
|
38
|
+
const target = args[0];
|
|
39
|
+
if (!target) return reportUnresolved(root, flags, unresolvedAffects(records, ctx));
|
|
40
|
+
if (!files.has(target)) return fail(`not in the tree: ${target}`, EXIT.USAGE, `pass a path git tracks`);
|
|
41
|
+
const corpus = await loadCorpus(root);
|
|
42
|
+
const { hits } = decisionHits(records, target, ctx);
|
|
43
|
+
const items = sortHits([...hits, ...scopeHits(target, corpus)]);
|
|
44
|
+
const payload = { ok: true, command: 'why', root, path: target, items };
|
|
45
|
+
if (isJson(flags)) {
|
|
46
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
47
|
+
return EXIT.OK;
|
|
48
|
+
}
|
|
49
|
+
const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
|
|
50
|
+
emit(lines.length > 0 ? lines.join('\n') : 'none', { maxBytes: flags.maxBytes ?? 0 });
|
|
51
|
+
return EXIT.OK;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function reportUnresolved(root, flags, unresolved) {
|
|
55
|
+
const payload = { ok: true, command: 'why', root, unresolved };
|
|
56
|
+
if (isJson(flags)) {
|
|
57
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
58
|
+
return EXIT.OK;
|
|
59
|
+
}
|
|
60
|
+
const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
|
|
61
|
+
emit([`unresolved ${unresolved.length}`, ...lines].join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
62
|
+
return EXIT.OK;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function tracked(root) {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
|
|
68
|
+
return { files: stdout.split('\n').filter(Boolean) };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
error: fail(
|
|
72
|
+
`could not read what governs files here (${error.message})`,
|
|
73
|
+
EXIT.UNSUPPORTED,
|
|
74
|
+
'run this inside a git repository',
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function decisionRecords(root) {
|
|
81
|
+
const dir = join(root, 'docs', 'decisions');
|
|
82
|
+
let names = [];
|
|
83
|
+
try {
|
|
84
|
+
names = (await readdir(dir)).filter((name) => /^ADR-\d{3}-.*\.md$/.test(name)).sort();
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const records = [];
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
const text = await readFile(join(dir, name), 'utf8');
|
|
91
|
+
const id = /^ADR-\d{3}/.exec(name)[0];
|
|
92
|
+
const heading = /^#\s+(.*)$/m.exec(text)?.[1] ?? id;
|
|
93
|
+
const title = heading.replace(/^ADR-\d{3}\s+[—–-]\s+/, '');
|
|
94
|
+
records.push({ id, title, text });
|
|
95
|
+
}
|
|
96
|
+
return records;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadCorpus(root) {
|
|
100
|
+
const [rules, skills, lessons, commands] = await Promise.all([
|
|
101
|
+
ruleScopes(root),
|
|
102
|
+
skillScopes(root),
|
|
103
|
+
lessonScopes(root),
|
|
104
|
+
gateCommands(root),
|
|
105
|
+
]);
|
|
106
|
+
const gates = Object.values(GATES).map((gate) => ({
|
|
107
|
+
id: gate.name,
|
|
108
|
+
detail: gate.why,
|
|
109
|
+
extensions: gate.kind === 'staged' ? FORMATTABLE : null,
|
|
110
|
+
}));
|
|
111
|
+
return { rules, skills, gates, lessons, commands };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function ruleScopes(root) {
|
|
115
|
+
const rules = [];
|
|
116
|
+
for (const dir of RULE_DIRS) {
|
|
117
|
+
let names = [];
|
|
118
|
+
try {
|
|
119
|
+
names = await readdir(join(root, dir));
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const name of names.sort()) {
|
|
124
|
+
if (!name.endsWith('.md') && !name.endsWith('.mdc')) continue;
|
|
125
|
+
const text = await readFile(join(root, dir, name), 'utf8');
|
|
126
|
+
const paths = scopePaths(text);
|
|
127
|
+
if (paths.length === 0) continue;
|
|
128
|
+
const id = /^simt-id:\s*(\S+)/m.exec(text)?.[1] ?? name;
|
|
129
|
+
const detail = /^simt-title:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? id;
|
|
130
|
+
rules.push({ id, detail, paths });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return rules;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function skillScopes(root) {
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(await readFile(join(root, 'graph', 'skills.graph.json'), 'utf8'));
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(parsed?.skills)) return [];
|
|
144
|
+
return parsed.skills
|
|
145
|
+
.filter((skill) => Array.isArray(skill.paths) && skill.paths.length > 0)
|
|
146
|
+
.map((skill) => ({ id: String(skill.name ?? ''), detail: String(skill.summary ?? ''), paths: skill.paths }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function lessonScopes(root) {
|
|
150
|
+
const dir = join(root, 'docs', 'lessons');
|
|
151
|
+
let names = [];
|
|
152
|
+
try {
|
|
153
|
+
names = (await readdir(dir)).filter((name) => name.endsWith('.md')).sort();
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const lessons = [];
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const text = await readFile(join(dir, name), 'utf8');
|
|
160
|
+
const paths = lessonPaths(text);
|
|
161
|
+
if (paths.length === 0) continue;
|
|
162
|
+
const id = name.replace(/\.md$/, '');
|
|
163
|
+
const detail = /^#\s+(.*)$/m.exec(text)?.[1] ?? id;
|
|
164
|
+
lessons.push({ id, detail, paths });
|
|
165
|
+
}
|
|
166
|
+
return lessons;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function gateCommands(root) {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), 'utf8'));
|
|
172
|
+
const commands = parsed?.gates?.commands;
|
|
173
|
+
return commands && typeof commands === 'object' ? commands : {};
|
|
174
|
+
} catch {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/decisions/index.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
22
22
|
import { CLI } from '../name.mjs';
|
|
23
|
+
import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
|
|
23
24
|
|
|
24
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
export const DECISIONS_DIR = resolve(HERE, '..', '..', 'docs', 'decisions');
|
|
@@ -240,7 +241,7 @@ export async function loadDecisions({ all = false } = {}) {
|
|
|
240
241
|
(m) => `ADR-${m[1]}`,
|
|
241
242
|
),
|
|
242
243
|
partial,
|
|
243
|
-
summary: summarise(text, partial, { all }),
|
|
244
|
+
summary: summarise(text, partial, { all, file }),
|
|
244
245
|
bytes: Buffer.byteLength(text),
|
|
245
246
|
});
|
|
246
247
|
}
|
|
@@ -288,7 +289,7 @@ export function byId(decisions) {
|
|
|
288
289
|
* answer "why is this here"; the reader who needs the rest asks.
|
|
289
290
|
* @returns {{ruled: string[], rulingNumbers: (number|null)[], rulingCount: number, rejected: string[], writtenAgainst: string[]}}
|
|
290
291
|
*/
|
|
291
|
-
function summarise(text, partial = [], { all = false } = {}) {
|
|
292
|
+
function summarise(text, partial = [], { all = false, file = null } = {}) {
|
|
292
293
|
// A section ends at the next `## ` — but not at one inside a fenced code block.
|
|
293
294
|
// ADR-007's entire ruling is a document skeleton in a fence whose headings are `##`,
|
|
294
295
|
// so the old lookahead ended the section at the first line of the ruling itself. It
|
|
@@ -446,7 +447,15 @@ const section = (name) => {
|
|
|
446
447
|
// Named rather than stripped. A record that cites this org's repositories is still
|
|
447
448
|
// the record; silently deleting the citation would hide which decisions were made in
|
|
448
449
|
// response to what, and would mangle sentences mid-clause.
|
|
449
|
-
|
|
450
|
+
//
|
|
451
|
+
// What is named is a repository, and the shape was wrong about that: every
|
|
452
|
+
// `simt-<word>` that was not `simt-dev` was reported as one, so the tool's own
|
|
453
|
+
// `simt-priority` was listed as a surveyed repository and the record that settled the
|
|
454
|
+
// kernel read as if it had been decided by looking at some other repository (ADR-080).
|
|
455
|
+
// A record whose subject is the prefix is arguing rather than citing, so it names none.
|
|
456
|
+
writtenAgainst: PREFIX_ARGUING.has(file)
|
|
457
|
+
? []
|
|
458
|
+
: [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name)))].sort(),
|
|
450
459
|
};
|
|
451
460
|
}
|
|
452
461
|
|
|
@@ -458,6 +467,10 @@ const section = (name) => {
|
|
|
458
467
|
* The skill registry's `source.repo` values are asserted to match this (S14); a name
|
|
459
468
|
* the registry adds that is not `simt-*` has to be added here or that assertion fails.
|
|
460
469
|
*
|
|
470
|
+
* The shape is not the whole answer, and treating it as one is what ADR-080 settles: a
|
|
471
|
+
* `simt-` name this tool owns is not a repository, and neither reader can tell that from
|
|
472
|
+
* the shape. `src/toolwords.mjs` is the list both of them share.
|
|
473
|
+
*
|
|
461
474
|
* Not global: callers reuse it with `exec`, and a `/g` flag would remember `lastIndex`.
|
|
462
475
|
*/
|
|
463
476
|
/**
|
|
@@ -486,7 +499,12 @@ export const ESTATE_NAME =
|
|
|
486
499
|
|
|
487
500
|
/** Replace estate repository names with a phrase a reader who does not have them can use. */
|
|
488
501
|
export function redactEstate(text) {
|
|
489
|
-
return text.replace(new RegExp(ESTATE_NAME.source, 'g'),
|
|
502
|
+
return String(text ?? '').replace(new RegExp(ESTATE_NAME.source, 'g'), (name) =>
|
|
503
|
+
// The tool's own names are left where they are. Redacting them turned a ruling about
|
|
504
|
+
// `simt-priority` into a sentence about `a repository`, which is how a reader learned
|
|
505
|
+
// that the record had surveyed something and not what it said (ADR-080).
|
|
506
|
+
TOOL_WORDS.has(name) ? name : 'a repository',
|
|
507
|
+
);
|
|
490
508
|
}
|
|
491
509
|
|
|
492
510
|
/**
|
|
@@ -506,7 +524,7 @@ export function redactEstate(text) {
|
|
|
506
524
|
* Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
|
|
507
525
|
* `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
|
|
508
526
|
*/
|
|
509
|
-
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|--[a-z-]+)';
|
|
527
|
+
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|tokens|harness|bench|shared|evidence|index|map|tests|why|gate|impact|--[a-z-]+)';
|
|
510
528
|
|
|
511
529
|
function redactCommand(text) {
|
|
512
530
|
return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
|
package/src/graph/spec.mjs
CHANGED
|
@@ -164,7 +164,7 @@ export function checkSkill({ directoryName, fileName = 'SKILL.md', text, at }) {
|
|
|
164
164
|
* characters. That is the whole failure this file exists to prevent, one level down - a
|
|
165
165
|
* lint that is confidently wrong about a conforming file.
|
|
166
166
|
*/
|
|
167
|
-
function readFrontmatter(text) {
|
|
167
|
+
export function readFrontmatter(text) {
|
|
168
168
|
const meta = {};
|
|
169
169
|
const present = [];
|
|
170
170
|
const body = text.replace(/^/, '');
|
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { resolveGate } from './resolve.mjs';
|
|
|
28
28
|
import { CLI } from '../name.mjs';
|
|
29
29
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
30
30
|
import { assertVersionPin } from '../package-version.mjs';
|
|
31
|
+
import { recordRun } from '../ledger/index.mjs';
|
|
31
32
|
|
|
32
33
|
const exec = promisify(execFile);
|
|
33
34
|
|
|
@@ -179,7 +180,21 @@ export async function runGates(event, { root, manifest }) {
|
|
|
179
180
|
cwd = resolved;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
const started = Date.now();
|
|
182
184
|
const outcome = await runGate(resolution, cwd);
|
|
185
|
+
// The ledger is not the gate. A tree that cannot be named leaves no line, and the
|
|
186
|
+
// claim check then finds nothing, which is the direction that cannot invent a run.
|
|
187
|
+
try {
|
|
188
|
+
const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
|
|
189
|
+
await recordRun(root, {
|
|
190
|
+
cmd,
|
|
191
|
+
exit: outcome.code,
|
|
192
|
+
ms: Date.now() - started,
|
|
193
|
+
output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
|
|
194
|
+
});
|
|
195
|
+
} catch {
|
|
196
|
+
// Recorded by absence: the next evidence read for this tree has no line.
|
|
197
|
+
}
|
|
183
198
|
results.push({
|
|
184
199
|
name,
|
|
185
200
|
status: outcome.code === 0 ? 'passed' : 'failed',
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -90,6 +90,37 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
|
|
|
90
90
|
})}`;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The commit-msg shim. It is not a dispatcher event: those shims do not forward
|
|
95
|
+
* git's arguments, and this one has to, because the message file is `$1`.
|
|
96
|
+
*
|
|
97
|
+
* Written only when the manifest sets `gates.evidence`. An unset value does not
|
|
98
|
+
* install a hook the repository did not ask for.
|
|
99
|
+
*
|
|
100
|
+
* @param {{npm?: boolean, version?: string}} [opts]
|
|
101
|
+
* @returns {string} POSIX sh source, LF-terminated
|
|
102
|
+
*/
|
|
103
|
+
export function renderEvidenceShim({ npm = true, version } = {}) {
|
|
104
|
+
const v = version ?? packageVersion();
|
|
105
|
+
const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : `npm i -g ${PACKAGE_NAME}@${v}`;
|
|
106
|
+
const D = '$';
|
|
107
|
+
return `#!/bin/sh
|
|
108
|
+
${SHIM_MARKER}
|
|
109
|
+
#
|
|
110
|
+
# A commit message that claims a run is checked against the ledger. The message
|
|
111
|
+
# file is the first argument git passes, so this shim forwards it. Reinstall with:
|
|
112
|
+
# ${CLI} hooks install --yes
|
|
113
|
+
|
|
114
|
+
set -eu
|
|
115
|
+
|
|
116
|
+
ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
117
|
+
|
|
118
|
+
${renderLocator(`evidence check "${D}1" --root "${D}ROOT"`, {
|
|
119
|
+
subject: 'the claim check',
|
|
120
|
+
remedy: `REMEDY run: ${install}, or set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
|
|
121
|
+
})}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
/**
|
|
94
125
|
* A line only a chained shim carries.
|
|
95
126
|
*
|