simt-dev 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +129 -75
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +1 -1
  20. package/rules/core.md +1 -1
  21. package/src/argv.mjs +18 -0
  22. package/src/ci/workflow.mjs +11 -1
  23. package/src/commands/bench.mjs +13 -1
  24. package/src/commands/evidence.mjs +96 -0
  25. package/src/commands/gate.mjs +165 -0
  26. package/src/commands/help.mjs +31 -0
  27. package/src/commands/hooks.mjs +45 -5
  28. package/src/commands/impact.mjs +205 -0
  29. package/src/commands/index.mjs +35 -0
  30. package/src/commands/map.mjs +91 -0
  31. package/src/commands/repoindex.mjs +52 -0
  32. package/src/commands/shared.mjs +398 -0
  33. package/src/commands/sync.mjs +72 -3
  34. package/src/commands/tests.mjs +192 -0
  35. package/src/commands/tokens.mjs +30 -1
  36. package/src/commands/why.mjs +177 -0
  37. package/src/decisions/index.mjs +1 -1
  38. package/src/graph/spec.mjs +1 -1
  39. package/src/hooks/dispatcher.mjs +15 -0
  40. package/src/hooks/shim.mjs +31 -0
  41. package/src/index/compact.mjs +228 -0
  42. package/src/index/extract.mjs +331 -0
  43. package/src/index/gate.mjs +101 -0
  44. package/src/index/govern.mjs +222 -0
  45. package/src/index/impact.mjs +186 -0
  46. package/src/index/map.mjs +393 -0
  47. package/src/index/persist.mjs +172 -0
  48. package/src/index/store.mjs +301 -0
  49. package/src/index/tests.mjs +294 -0
  50. package/src/kernel/budget.mjs +100 -0
  51. package/src/kernel/checks.mjs +378 -0
  52. package/src/ledger/index.mjs +112 -0
  53. package/src/measure/context.mjs +7 -5
  54. package/src/projection/disclosure.mjs +92 -0
  55. package/src/projection/lock.mjs +93 -0
  56. package/src/projection/manifest.mjs +52 -0
  57. package/src/projection/markers.mjs +56 -17
  58. package/src/projection/project.mjs +24 -17
  59. package/src/projection/rules.mjs +378 -44
  60. package/src/projection/shared.mjs +428 -0
  61. package/src/projection/targets.mjs +2 -1
  62. package/src/toolwords.mjs +2 -0
@@ -0,0 +1,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
+ }
@@ -45,6 +45,14 @@ import {
45
45
  benchProbesCommand,
46
46
  } from './bench.mjs';
47
47
  import { planCommand } from './plan.mjs';
48
+ import { sharedPlanCommand, sharedInstallCommand, sharedWarning } from './shared.mjs';
49
+ import { evidenceCommand } from './evidence.mjs';
50
+ import { indexCommand } from './repoindex.mjs';
51
+ import { mapCommand } from './map.mjs';
52
+ import { testsCommand } from './tests.mjs';
53
+ import { whyCommand } from './why.mjs';
54
+ import { gateCommand } from './gate.mjs';
55
+ import { impactCommand } from './impact.mjs';
48
56
  import { parse } from '../argv.mjs';
49
57
  import { fingerprint } from '../resolve/fingerprint.mjs';
50
58
  import { resolveWorkspace } from '../workspace.mjs';
@@ -66,6 +74,11 @@ const GROUPS = new Map([
66
74
  ['agent-hooks', 'status'],
67
75
  ['harness', 'detect'],
68
76
  ['bench', 'mine'],
77
+ // ADR-084. `shared` is the one group whose default writes nothing, and it is `plan` for
78
+ // the same reason `skills`'s is `index`: the read answers "what would this do, and
79
+ // where", and `install` — the subcommand that touches a reader's home directory — is the
80
+ // one that needs `--yes` before it does anything.
81
+ ['shared', 'plan'],
69
82
  ]);
70
83
 
71
84
  /**
@@ -99,6 +112,16 @@ export const IMPLEMENTED = {
99
112
  'plan status': (args, flags) => planCommand('status', args, flags),
100
113
  'plan next': (args, flags) => planCommand('next', args, flags),
101
114
  'plan done': (args, flags) => planCommand('done', args, flags),
115
+ 'shared plan': sharedPlanCommand,
116
+ 'shared install': sharedInstallCommand,
117
+ evidence: evidenceCommand,
118
+ index: indexCommand,
119
+ map: mapCommand,
120
+ tests: testsCommand,
121
+ why: (args, flags) => whyCommand(args, flags, COMMANDS),
122
+ gate: gateCommand,
123
+ impact: (args, flags) => impactCommand(args, flags, COMMANDS),
124
+ 'evidence check': (args, flags) => evidenceCommand(['check', ...args], flags),
102
125
  init: initCommand,
103
126
  ci: ciCommand,
104
127
  'hooks install': (args, flags) => hooksCommand('install', args, flags),
@@ -220,6 +243,13 @@ async function runDoctor(positional, flags) {
220
243
  // answer confidently wrong, so the answer is reported rather than inferred
221
244
  // (ADR-014).
222
245
  const ws = await resolveWorkspace(flags);
246
+ // The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
247
+ // because it is the one thing about a repository that is true of the reader's machine
248
+ // rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
249
+ // that can notice a region that dropped rules into a file nobody has. Reported, never
250
+ // blocking — a stale copy costs a reader some of their rules, which is worth knowing and
251
+ // is not this repository's gate failing.
252
+ const shared = await sharedWarning(root, result.manifest ?? null, flags);
223
253
 
224
254
  // Under `--json` the document is the whole report, so it has to carry both
225
255
  // failures the text path reports AND the workspace answer the text path prints.
@@ -254,6 +284,7 @@ async function runDoctor(positional, flags) {
254
284
  detail: ws.detail,
255
285
  error: ws.error ?? null,
256
286
  },
287
+ shared,
257
288
  ...(failure ?? {}),
258
289
  });
259
290
  return code;
@@ -276,6 +307,10 @@ async function runDoctor(positional, flags) {
276
307
  }`,
277
308
  `manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
278
309
  `workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
310
+ // Printed only when this repository handed rules to a workspace copy: a line in
311
+ // every `doctor` is a line in every reader's terminal, and the reader who has
312
+ // nothing to install is not served by one (ADR-084).
313
+ ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
279
314
  ].join('\n'),
280
315
  { maxBytes: flags.maxBytes ?? 0 },
281
316
  );
@@ -0,0 +1,91 @@
1
+ // `simt map` ranks the files a task is most likely to touch (R22, ADR-091).
2
+ // The cache is read, and rebuilt once when it is empty, because a map of no
3
+ // files is not an answer. The rebuild writes the gitignored index, which is
4
+ // the same cache `simt index` writes.
5
+
6
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { readEdges, readFiles, readSymbols, reindex } from '../index/store.mjs';
9
+ import { DEFAULT_BUDGET, formatRecall, measureRecall, rankFiles, renderMap } from '../index/map.mjs';
10
+
11
+ const LEVELS = new Set(['outline', 'names', 'signatures']);
12
+
13
+ export async function mapCommand(args, flags) {
14
+ if (args.length > 0) return fail(`unknown map argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} map`);
15
+ const root = flags.root ?? process.cwd();
16
+ if (flags.recall === true) return recall(root, flags);
17
+ const level = flags.level ?? 'outline';
18
+ if (!LEVELS.has(level)) {
19
+ return fail(`unknown level ${level}`, EXIT.USAGE, 'use outline, names or signatures');
20
+ }
21
+ const budget = flags.budget === undefined ? DEFAULT_BUDGET : Number(flags.budget);
22
+ if (!Number.isInteger(budget) || budget < 0) {
23
+ return fail(`budget must be a non-negative integer`, EXIT.USAGE, `run: ${CLI} map --budget 2048`);
24
+ }
25
+ const loaded = await loadGraph(root);
26
+ if (loaded.error) return loaded.error;
27
+ const task = typeof flags.task === 'string' ? flags.task : '';
28
+ const ranked = rankFiles({ ...loaded, task });
29
+ const rendered = renderMap(ranked, loaded.symbols, { level, budget });
30
+ const payload = {
31
+ ok: true,
32
+ command: 'map',
33
+ root,
34
+ level,
35
+ budget,
36
+ shown: rendered.shown,
37
+ total: rendered.total,
38
+ truncated: rendered.truncated,
39
+ files: rendered.files,
40
+ text: rendered.text,
41
+ };
42
+ if (isJson(flags)) {
43
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
44
+ return EXIT.OK;
45
+ }
46
+ const lines = [
47
+ `level ${level}`,
48
+ `shown ${rendered.shown}`,
49
+ `total ${rendered.total}`,
50
+ `budget ${budget}`,
51
+ ];
52
+ if (rendered.text) lines.push(rendered.text);
53
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
54
+ return EXIT.OK;
55
+ }
56
+
57
+ async function recall(root, flags) {
58
+ let result;
59
+ try {
60
+ result = await measureRecall(root);
61
+ } catch (error) {
62
+ return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
63
+ }
64
+ const payload = { ok: true, command: 'map', root, recall: result };
65
+ if (isJson(flags)) {
66
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
67
+ return EXIT.OK;
68
+ }
69
+ emit(formatRecall(result), { maxBytes: flags.maxBytes ?? 0 });
70
+ return EXIT.OK;
71
+ }
72
+
73
+ async function loadGraph(root) {
74
+ let files = await readFiles(root);
75
+ if (files.length === 0) {
76
+ try {
77
+ await reindex(root);
78
+ } catch (error) {
79
+ return {
80
+ error: fail(
81
+ `could not map this repository (${error.message})`,
82
+ EXIT.UNSUPPORTED,
83
+ 'run this inside a git repository',
84
+ ),
85
+ };
86
+ }
87
+ files = await readFiles(root);
88
+ }
89
+ const [symbols, edges] = await Promise.all([readSymbols(root), readEdges(root)]);
90
+ return { files, symbols, edges };
91
+ }
@@ -0,0 +1,52 @@
1
+ // `simt index` rebuilds the local cache from git. The cache is not repository
2
+ // content: it is gitignored, and a second run with the same blobs writes the same
3
+ // rows (ADR-089).
4
+
5
+ import { execFile } from 'node:child_process';
6
+
7
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
+ import { CLI } from '../name.mjs';
9
+ import { JSONL_FILE, SQLITE_FILE, reindex } from '../index/store.mjs';
10
+
11
+ export async function indexCommand(args, flags) {
12
+ if (args.length > 0) return fail(`unknown index argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} index`);
13
+ const root = flags.root ?? process.cwd();
14
+ let result;
15
+ try {
16
+ result = await reindex(root);
17
+ } catch (error) {
18
+ return fail(
19
+ `could not index this repository (${error.message})`,
20
+ EXIT.UNSUPPORTED,
21
+ 'run this inside a git repository',
22
+ );
23
+ }
24
+ const file = result.backend === 'sqlite' ? SQLITE_FILE : JSONL_FILE;
25
+ const ignored = await isIgnored(root, file);
26
+ const payload = { ok: true, command: 'index', root, file, ignored, ...result };
27
+ if (isJson(flags)) {
28
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
29
+ return EXIT.OK;
30
+ }
31
+ const lines = [
32
+ `index ${result.backend}`,
33
+ `files ${result.files}`,
34
+ `updated ${result.updated}`,
35
+ `removed ${result.removed}`,
36
+ `symbols ${result.symbols}`,
37
+ `edges ${result.edges}`,
38
+ `ms ${result.ms}`,
39
+ ];
40
+ if (result.parsers === 'missing') lines.push('note index.parsers did not load, so the built-in extractors ran');
41
+ if (!ignored) lines.push(`note ${file} is not gitignored, and the cache is not meant to be committed`);
42
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
43
+ return EXIT.OK;
44
+ }
45
+
46
+ function isIgnored(root, file) {
47
+ return new Promise((resolve) => {
48
+ execFile('git', ['check-ignore', '-q', '--', file], { cwd: root }, (error) => {
49
+ resolve(!error);
50
+ });
51
+ });
52
+ }