simt-dev 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +129 -75
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +1 -1
- package/rules/core.md +1 -1
- package/src/argv.mjs +18 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/bench.mjs +13 -1
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +31 -0
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +35 -0
- package/src/commands/map.mjs +91 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +1 -1
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/ledger/index.mjs +112 -0
- package/src/measure/context.mjs +7 -5
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +378 -44
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +2 -0
|
@@ -0,0 +1,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
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
totals,
|
|
35
35
|
} from '../measure/context.mjs';
|
|
36
36
|
import { measureSession } from '../measure/session.mjs';
|
|
37
|
+
import { sharedTotals } from '../projection/shared.mjs';
|
|
37
38
|
|
|
38
39
|
/** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
|
|
39
40
|
* this command exists so two machines produce two numbers a pull request can diff. */
|
|
@@ -109,7 +110,17 @@ export async function tokensCommand(positional, flags) {
|
|
|
109
110
|
for (const path of roots) rows.push(await measureRoot(path));
|
|
110
111
|
annotate(rows);
|
|
111
112
|
|
|
112
|
-
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
|
+
};
|
|
113
124
|
const mcp = mcpTools();
|
|
114
125
|
|
|
115
126
|
if (isJson(flags)) {
|
|
@@ -142,6 +153,15 @@ export async function tokensCommand(positional, flags) {
|
|
|
142
153
|
lines.push(
|
|
143
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`,
|
|
144
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
|
+
}
|
|
145
165
|
} else {
|
|
146
166
|
const row = rows[0];
|
|
147
167
|
lines.push(`tokens ${row.path}`);
|
|
@@ -155,6 +175,15 @@ export async function tokensCommand(positional, flags) {
|
|
|
155
175
|
lines.push(
|
|
156
176
|
`always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
|
|
157
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
|
+
}
|
|
158
187
|
lines.push(
|
|
159
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`,
|
|
160
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
|
@@ -524,7 +524,7 @@ export function redactEstate(text) {
|
|
|
524
524
|
* Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
|
|
525
525
|
* `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
|
|
526
526
|
*/
|
|
527
|
-
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|tokens|harness|bench|--[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-]+)';
|
|
528
528
|
|
|
529
529
|
function redactCommand(text) {
|
|
530
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
|
*
|