simt-dev 0.2.0 → 0.2.2
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 +375 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/commands/sync.mjs
CHANGED
|
@@ -16,16 +16,18 @@
|
|
|
16
16
|
// three `.mdc` rules would let a reader believe a repo is fully current when
|
|
17
17
|
// only the file they happened to open is.
|
|
18
18
|
|
|
19
|
-
import { mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
20
|
-
import { dirname } from 'node:path';
|
|
19
|
+
import { mkdir, readFile, stat, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
21
21
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
22
22
|
import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
|
|
23
23
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
24
|
-
import { manifestDrift, readManifest, unrunnableGates } from '../projection/manifest.mjs';
|
|
24
|
+
import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText } from '../projection/manifest.mjs';
|
|
25
25
|
import { readCapability } from '../hooks/capability.mjs';
|
|
26
26
|
import { surfacesLine } from '../projection/surfaces.mjs';
|
|
27
27
|
import { CLI } from '../name.mjs';
|
|
28
|
-
import { MANIFEST_FILE } from '../name.mjs';
|
|
28
|
+
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
29
|
+
import { assertVersionPin, packageVersion } from '../package-version.mjs';
|
|
30
|
+
import { resolvePacks } from '../packs.mjs';
|
|
29
31
|
|
|
30
32
|
/** Findings `sync` reports and does not fail on.
|
|
31
33
|
*
|
|
@@ -47,7 +49,7 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
47
49
|
* people learn to ignore. A *foreign* one — a real file or a real directory where the
|
|
48
50
|
* pointer belongs — is reported loudly with what is in the way, because that one is a
|
|
49
51
|
* decision somebody has to make. */
|
|
50
|
-
export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
|
|
52
|
+
export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-unpinned', 'budget']);
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
55
|
* Findings `check` reports and does not fail on.
|
|
@@ -67,7 +69,20 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
|
|
|
67
69
|
*
|
|
68
70
|
* `orphaned` stays non-blocking in both. Nothing can satisfy it, in either command.
|
|
69
71
|
*/
|
|
70
|
-
|
|
72
|
+
// `pack-unpinned` is here and `pack-digest-mismatch` is not, which is the whole
|
|
73
|
+
// judgement. An unpinned pack still renders its rules correctly; what is missing
|
|
74
|
+
// is the record of which bytes were declared, and failing a gate over a field the
|
|
75
|
+
// tool does not require would fail every repository that vendored a pack and
|
|
76
|
+
// pinned it later. A pack whose bytes do not match its pin renders a region
|
|
77
|
+
// nobody can account for, so that one blocks.
|
|
78
|
+
//
|
|
79
|
+
// `budget` is here for the opposite reason, and it is a ruling rather than a patch
|
|
80
|
+
// (ADR-072). An over-budget finding says the projection is bigger than this repository
|
|
81
|
+
// said it would be, and the remedy is a decision about which rules to narrow — not a
|
|
82
|
+
// missing file. Nothing is cut: the projection is still in full mode, so a gate that
|
|
83
|
+
// failed here would be failing a repository for exceeding a ceiling it set itself,
|
|
84
|
+
// with no way to fix it except lowering the number it wrote down.
|
|
85
|
+
export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget']);
|
|
71
86
|
|
|
72
87
|
export async function syncCommand(positional, flags) {
|
|
73
88
|
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
@@ -76,6 +91,28 @@ export async function syncCommand(positional, flags) {
|
|
|
76
91
|
|
|
77
92
|
const result = await plan(root, flags);
|
|
78
93
|
|
|
94
|
+
// Before the rule branch, and named for what it is. An unknown surface id is a
|
|
95
|
+
// misspelling in the manifest's `surfaces` list; reporting it as an unprojectable
|
|
96
|
+
// `.mdc` rule sent the reader to a `simt-substitutions` block that has nothing to do
|
|
97
|
+
// with the thing they typed. `check` reported the same problem correctly the whole
|
|
98
|
+
// time, so this was a writer disagreeing with the gate about the same manifest.
|
|
99
|
+
if (result.surfaceProblems.length > 0) {
|
|
100
|
+
if (isJson(flags)) {
|
|
101
|
+
emitJson({
|
|
102
|
+
ok: false,
|
|
103
|
+
command: 'sync',
|
|
104
|
+
root,
|
|
105
|
+
error: result.surfaceProblems[0],
|
|
106
|
+
problems: result.surfaceProblems,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const p of result.surfaceProblems) note(`PROBLEM ${p}`);
|
|
110
|
+
return fail(
|
|
111
|
+
`${result.surfaceProblems.length} surface(s) cannot be resolved (${result.surfaceProblems[0]})`,
|
|
112
|
+
EXIT.FAIL,
|
|
113
|
+
`fix the surface ids in the "surfaces" list of ${MANIFEST_FILE}, or remove the unknown one`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
79
116
|
|
|
80
117
|
// A `.mdc` rule that cannot render is a defect in this build, not in the repo,
|
|
81
118
|
// and projecting the rest would leave a family half-retired.
|
|
@@ -162,6 +199,40 @@ export async function syncCommand(positional, flags) {
|
|
|
162
199
|
const changed = result.targets.filter((t) => t.action !== 'unchanged');
|
|
163
200
|
const pointersAdded = pointerPlan.map((p) => p.path);
|
|
164
201
|
|
|
202
|
+
// The manifest is the second file this tool writes into a repository it does not own,
|
|
203
|
+
// and for two releases it was the only generated file with no path back to correct:
|
|
204
|
+
// `check` refused on a stale generated field and named `init --yes`, while `sync` -
|
|
205
|
+
// the command whose whole job is regenerating what this tool owns - reported
|
|
206
|
+
// "already current; nothing written". Both statements were true of the same
|
|
207
|
+
// repository, and all fourteen adopted repositories were in that state at once.
|
|
208
|
+
//
|
|
209
|
+
// One reason and one place, so `check`'s remedy and this plan cannot drift: the
|
|
210
|
+
// field set is `GENERATED_KEYS`, and the values come from the same `buildManifest`
|
|
211
|
+
// call `manifestDrift` compares against. A repair built from a second copy of
|
|
212
|
+
// either would pass its own test and leave the gate still red.
|
|
213
|
+
//
|
|
214
|
+
// `reformatted` is repaired too, and only when the repository has said the file is
|
|
215
|
+
// not prose: `simt.json` listed in `.prettierignore` is the repo stating that no
|
|
216
|
+
// formatter will take these bytes back.
|
|
217
|
+
//
|
|
218
|
+
// Without that condition there are two wrong answers. Repairing unconditionally
|
|
219
|
+
// means every `format` run rewrites a file the commit just wrote - a permanently
|
|
220
|
+
// dirty working tree. Never repairing means `init --yes` is the only way to put the
|
|
221
|
+
// bytes back, and `init` re-runs adoption: it tags, it switches branch, it asks every
|
|
222
|
+
// question again, to fix one file's formatting (ADR-050). The first repo routed in
|
|
223
|
+
// the estate roll-out hit this on its first commit, so both answers were live.
|
|
224
|
+
const manifestPlan = await planManifestRepair(root, result.stack, result.targets[0].sha);
|
|
225
|
+
if (manifestPlan) {
|
|
226
|
+
targets.push({
|
|
227
|
+
file: manifestPlan.file,
|
|
228
|
+
surface: 'manifest',
|
|
229
|
+
action: 'updated',
|
|
230
|
+
bytes: Buffer.byteLength(manifestPlan.text),
|
|
231
|
+
previousBytes: manifestPlan.previousBytes,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const manifestChanged = manifestPlan !== null;
|
|
235
|
+
|
|
165
236
|
// Writes happen BEFORE output in every mode. An earlier build emitted `--json`
|
|
166
237
|
// first and returned, so `upgrade --yes --json` reported a plan and changed
|
|
167
238
|
// nothing — the exact combination automation reaches for.
|
|
@@ -178,6 +249,13 @@ export async function syncCommand(positional, flags) {
|
|
|
178
249
|
// A comment that enumerates which commands follow a rule is a claim about code
|
|
179
250
|
// this file does not own, and it was one commit out of date before anyone ran it.
|
|
180
251
|
if (mode.apply) {
|
|
252
|
+
// The manifest first, with the rest: a gate that refuses on a stale stamp should
|
|
253
|
+
// not be able to read a half-finished write, and the manifest is the file every
|
|
254
|
+
// other write in this run is described by.
|
|
255
|
+
if (manifestPlan) {
|
|
256
|
+
await writeManifest(root, manifestPlan.manifest);
|
|
257
|
+
note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
|
|
258
|
+
}
|
|
181
259
|
for (const t of changed) {
|
|
182
260
|
// Three of the four rule surfaces live in directories that do not exist yet in a
|
|
183
261
|
// repository that has only ever been written to by hand. `init` grew its own
|
|
@@ -210,7 +288,7 @@ export async function syncCommand(positional, flags) {
|
|
|
210
288
|
// `ok` follows the exit code, which now reports drift for a previewing run
|
|
211
289
|
// too. Leaving it `true` here would break the invariant ADR-035 sets for
|
|
212
290
|
// every command, and the contract table asserts it for this one.
|
|
213
|
-
ok: changed.length === 0 && pointersAdded.length === 0 ? true : mode.apply,
|
|
291
|
+
ok: changed.length === 0 && pointersAdded.length === 0 && !manifestChanged ? true : mode.apply,
|
|
214
292
|
command: checkOnly ? 'sync --check' : 'sync',
|
|
215
293
|
root,
|
|
216
294
|
stack: result.stack,
|
|
@@ -220,7 +298,7 @@ export async function syncCommand(positional, flags) {
|
|
|
220
298
|
sha: result.targets[0].sha,
|
|
221
299
|
targets,
|
|
222
300
|
pointers: pointersAdded,
|
|
223
|
-
written: mode.apply ? changed.length + pointersAdded.length : 0,
|
|
301
|
+
written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
|
|
224
302
|
dryRun: !mode.apply,
|
|
225
303
|
},
|
|
226
304
|
{},
|
|
@@ -233,12 +311,15 @@ export async function syncCommand(positional, flags) {
|
|
|
233
311
|
surfacesLine({ ids: result.surfaces, source: result.surfaceSource }, 10),
|
|
234
312
|
`sha ${result.targets[0].sha}`,
|
|
235
313
|
'',
|
|
236
|
-
|
|
314
|
+
// `targets`, not `result.targets`: the plan is the thing being reported, and a
|
|
315
|
+
// line the JSON payload carries but the text one drops is the reader of the
|
|
316
|
+
// terminal output finding out from the exit code what they were already told.
|
|
317
|
+
...targets.map(
|
|
237
318
|
(t) => `${t.file} ${t.action} ${t.previousBytes} -> ${t.bytes} bytes`,
|
|
238
319
|
),
|
|
239
320
|
...pointerPlan.map((p) => `${p.file} added pointer -> ${p.to}`),
|
|
240
321
|
];
|
|
241
|
-
if (changed.length === 0 && pointerPlan.length === 0) lines.push('already current; nothing written');
|
|
322
|
+
if (changed.length === 0 && pointerPlan.length === 0 && !manifestChanged) lines.push('already current; nothing written');
|
|
242
323
|
else if (checkOnly) lines.push('would write; rerun without --check');
|
|
243
324
|
else if (!mode.apply) {
|
|
244
325
|
// The preview names the command that writes it. A preview that reports what
|
|
@@ -247,26 +328,128 @@ export async function syncCommand(positional, flags) {
|
|
|
247
328
|
lines.push(
|
|
248
329
|
flags.dryRun === true
|
|
249
330
|
? 'dry-run; nothing written'
|
|
250
|
-
: `would write ${changed.length + pointersAdded.length} file(s); apply with: ${CLI} sync --yes`,
|
|
331
|
+
: `would write ${changed.length + pointersAdded.length + (manifestChanged ? 1 : 0)} file(s); apply with: ${CLI} sync --yes`,
|
|
251
332
|
);
|
|
252
333
|
}
|
|
253
334
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
254
335
|
}
|
|
255
336
|
|
|
256
|
-
|
|
337
|
+
const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
|
|
338
|
+
if (checkOnly) return idle ? EXIT.OK : EXIT.FAIL;
|
|
257
339
|
// A preview that found work is not a success: `simt-dev sync --yes` with no flags is a
|
|
258
340
|
// check that also prints the plan, and a check that exits 0 while reporting drift
|
|
259
341
|
// is the one-question-two-answers shape ADR-035 rules out. This is what lets a
|
|
260
342
|
// caller run `sync` in place of `sync --check` and get the same verdict.
|
|
261
343
|
if (!mode.apply) {
|
|
262
344
|
if (flags.dryRun === true) note('dry-run no files written');
|
|
263
|
-
return
|
|
345
|
+
return idle ? EXIT.OK : EXIT.FAIL;
|
|
264
346
|
}
|
|
265
347
|
return EXIT.OK;
|
|
266
348
|
}
|
|
267
349
|
|
|
268
|
-
|
|
269
|
-
|
|
350
|
+
/**
|
|
351
|
+
* The manifest repair `sync` would make, or null when there is nothing to repair.
|
|
352
|
+
*
|
|
353
|
+
* Returns the parsed manifest to hand to `writeManifest` as well as the bytes, so the
|
|
354
|
+
* writer keeps owning the legacy-name move and the preview keeps showing the real
|
|
355
|
+
* count. `sha` is the same digest the regions were rendered from: the stamp and the
|
|
356
|
+
* text it describes have to agree, or the next `check` finds a region carrying a pin
|
|
357
|
+
* the manifest does not claim.
|
|
358
|
+
*
|
|
359
|
+
* @returns {Promise<{file: string, manifest: object, text: string,
|
|
360
|
+
* stale: string[], previousBytes: number}|null>}
|
|
361
|
+
*/
|
|
362
|
+
async function planManifestRepair(root, stack, sha) {
|
|
363
|
+
const drift = await manifestDrift(root, stack, sha);
|
|
364
|
+
if (drift === null) return null;
|
|
365
|
+
// A `reformatted` manifest is repaired only once the repository has declared that no
|
|
366
|
+
// formatter will take these bytes back. The declaration is read rather than assumed
|
|
367
|
+
// because a repo that runs no formatter should not need a `.prettierignore` to repair
|
|
368
|
+
// a manifest some other tool rewrote, and one that does must be believed only when it
|
|
369
|
+
// has said so.
|
|
370
|
+
if (drift.status === 'reformatted' && !(await formatterIgnoresManifest(root))) return null;
|
|
371
|
+
|
|
372
|
+
const { manifest } = await readManifest(root);
|
|
373
|
+
if (manifest === null) return null;
|
|
374
|
+
const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
|
|
375
|
+
const patched = withGenerated(manifest, expected);
|
|
376
|
+
const stale = Object.keys(expected).filter(
|
|
377
|
+
(key) => JSON.stringify(manifest[key]) !== JSON.stringify(patched[key]),
|
|
378
|
+
);
|
|
379
|
+
return {
|
|
380
|
+
file: MANIFEST_FILE,
|
|
381
|
+
manifest: patched,
|
|
382
|
+
text: manifestText(patched),
|
|
383
|
+
stale,
|
|
384
|
+
previousBytes: Buffer.byteLength(manifestText(manifest)),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Whether this repository's `.prettierignore` excludes the manifest.
|
|
390
|
+
*
|
|
391
|
+
* Lines, `#` comments and blanks only. A glob is not evaluated: the question this
|
|
392
|
+
* answers is "did somebody deliberately write this file's name down", and an exact
|
|
393
|
+
* match is the only reading of that which cannot be wrong. A repository that ignores
|
|
394
|
+
* `*.json` has said something broader, and the honest answer there is that this tool
|
|
395
|
+
* cannot tell - so it declines to repair, which is the state that was already correct.
|
|
396
|
+
*/
|
|
397
|
+
async function formatterIgnoresManifest(root) {
|
|
398
|
+
// ENOENT only. A catch that answers "no" to every error is a catch that turns a
|
|
399
|
+
// typo in this function into a repository silently keeping its pre-commit gate's
|
|
400
|
+
// formatting — which is how the first version of it failed: `join` was not imported,
|
|
401
|
+
// the ReferenceError was swallowed, and every repository read as having declared
|
|
402
|
+
// nothing. Absence is absence; anything else is a fault worth seeing.
|
|
403
|
+
let text;
|
|
404
|
+
try {
|
|
405
|
+
text = await readFile(join(root, '.prettierignore'), 'utf8');
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error.code !== 'ENOENT') throw error;
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return text
|
|
411
|
+
.split('\n')
|
|
412
|
+
.map((line) => line.trim())
|
|
413
|
+
.some((line) => line !== '' && !line.startsWith('#') && (line === MANIFEST_FILE || line === MANIFEST_LEGACY));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* `gates.cwd` entries that do not resolve to a directory inside the repository.
|
|
418
|
+
*
|
|
419
|
+
* Reported here, not only where the gate runs: the dispatcher fails closed on the same
|
|
420
|
+
* condition, so a reader finds out on their next commit — after writing a commit, which
|
|
421
|
+
* is the wrong order. The finding names the gate and the path, and says which of the two
|
|
422
|
+
* things is wrong, because "no such directory" and "outside the repository" are repaired
|
|
423
|
+
* by different edits.
|
|
424
|
+
*
|
|
425
|
+
* @returns {Promise<{file: string, status: string, gate: string, detail: string, remedy: string}[]>}
|
|
426
|
+
*/
|
|
427
|
+
async function gateCwdFindings(root, manifest) {
|
|
428
|
+
const cwds = manifest?.gates?.cwd;
|
|
429
|
+
if (!cwds || typeof cwds !== 'object' || Array.isArray(cwds)) return [];
|
|
430
|
+
const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
|
|
431
|
+
const findings = [];
|
|
432
|
+
for (const name of Object.keys(cwds).sort()) {
|
|
433
|
+
if (!declared.includes(name)) continue;
|
|
434
|
+
const dir = String(cwds[name]);
|
|
435
|
+
const resolved = resolve(root, dir);
|
|
436
|
+
const inside = resolved === root || resolved.startsWith(`${root}${sep}`);
|
|
437
|
+
const isDir = inside && ((await stat(resolved).catch(() => null))?.isDirectory() ?? false);
|
|
438
|
+
if (isDir) continue;
|
|
439
|
+
findings.push({
|
|
440
|
+
file: MANIFEST_FILE,
|
|
441
|
+
status: inside ? 'gate-cwd-missing' : 'gate-cwd-outside',
|
|
442
|
+
gate: name,
|
|
443
|
+
detail: inside
|
|
444
|
+
? `gates.cwd["${name}"] is ${dir}, which is not a directory in this repository`
|
|
445
|
+
: `gates.cwd["${name}"] is ${dir}, which resolves outside the repository`,
|
|
446
|
+
remedy: `point gates.cwd["${name}"] at a directory in this repository, or remove it to run the gate at the root`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return findings;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function checkCommand(positional, flags) { const root = flags.root ?? positional[0] ?? process.cwd();
|
|
270
453
|
const findings = await checkTargets(root, flags);
|
|
271
454
|
const rules = await loadRules(root);
|
|
272
455
|
|
|
@@ -288,7 +471,22 @@ export async function checkCommand(positional, flags) {
|
|
|
288
471
|
// before anyone installs anything rather than after.
|
|
289
472
|
const { manifest } = await readManifest(root);
|
|
290
473
|
if (manifest) {
|
|
474
|
+
// The gate runs from whatever binary resolved it. For a repository without npm that
|
|
475
|
+
// is a global install, and the only thing that knows which version it should be is
|
|
476
|
+
// the pin the repo recorded at adoption. A stale binary would run a different gate
|
|
477
|
+
// than CI does, so the mismatch fails the gate rather than warning.
|
|
478
|
+
const mismatch = assertVersionPin(manifest);
|
|
479
|
+
if (mismatch) {
|
|
480
|
+
findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
|
|
481
|
+
}
|
|
291
482
|
findings.push(...unrunnableGates(manifest, await readCapability(root)));
|
|
483
|
+
findings.push(...(await gateCwdFindings(root, manifest)));
|
|
484
|
+
// A pack the repository declares but cannot produce is reported here rather
|
|
485
|
+
// than when a rule happens to be missing, because that is the failure mode
|
|
486
|
+
// this guards: the region still renders, it renders SHORT, and `check` would
|
|
487
|
+
// pass. A reader whose rules were quietly halved is worse off than one whose
|
|
488
|
+
// gate is red, so this is a blocking finding, not a note.
|
|
489
|
+
findings.push(...(await resolvePacks(root)).findings);
|
|
292
490
|
// A manifest a formatter has rewritten is not wrong, and not editable-away by
|
|
293
491
|
// hand either: the next `init` puts simt-dev's own bytes back and the formatter
|
|
294
492
|
// takes them away again. Reporting it here is what stops that being a surprise
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// simt-dev tokens - what a reader loads, counted.
|
|
2
|
+
//
|
|
3
|
+
// Review 0002 S38 put the estate's always-on context at 90 KB per turn, 72% of it a
|
|
4
|
+
// paragraph an earlier root had already loaded, and could only say so by reading seven
|
|
5
|
+
// repositories by hand. This command is that reading, and it is the figure plan 0003's
|
|
6
|
+
// later slices are measured against: the context budget (S38 P7), the kernel split
|
|
7
|
+
// (R07) and the workspace dedupe (R11) all need a number that moves when the
|
|
8
|
+
// projection moves, or they are opinions.
|
|
9
|
+
//
|
|
10
|
+
// Three decisions keep the figure honest:
|
|
11
|
+
//
|
|
12
|
+
// 1. It is a READ. No write, no git object, no lock - the same ruling `compare`
|
|
13
|
+
// follows. An agent running this to orient itself must not be able to change what
|
|
14
|
+
// the next run will measure.
|
|
15
|
+
// 2. It exits 0. A measurement is not a verdict: a repository over budget is a fact
|
|
16
|
+
// about the repository, and a command that went red for it would train people to
|
|
17
|
+
// ignore the number it exists to print. The only failure is being pointed at a
|
|
18
|
+
// directory that holds no repository to measure.
|
|
19
|
+
// 3. It counts bytes and calls the tokens an approximation every time it prints one.
|
|
20
|
+
// See src/measure/context.mjs for what that costs and why.
|
|
21
|
+
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
25
|
+
import { CLI } from '../name.mjs';
|
|
26
|
+
import { discover } from '../compare/discover.mjs';
|
|
27
|
+
import { targetRoot } from '../workspace.mjs';
|
|
28
|
+
import {
|
|
29
|
+
ALWAYS_ON_BUDGET_BYTES,
|
|
30
|
+
BYTES_PER_TOKEN,
|
|
31
|
+
annotate,
|
|
32
|
+
measureRoot,
|
|
33
|
+
mcpTools,
|
|
34
|
+
totals,
|
|
35
|
+
} from '../measure/context.mjs';
|
|
36
|
+
|
|
37
|
+
/** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
|
|
38
|
+
* this command exists so two machines produce two numbers a pull request can diff. */
|
|
39
|
+
function plain(n) {
|
|
40
|
+
return String(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function files(n) {
|
|
44
|
+
return n === 1 ? '1 file' : `${plain(n)} files`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function tokensCommand(positional, flags) {
|
|
48
|
+
// `targetRoot` owns the precedence, so `tokens`, `compare` and `upgrade` cannot
|
|
49
|
+
// disagree about which directory was named (ADR-014).
|
|
50
|
+
const target = await targetRoot(flags, positional);
|
|
51
|
+
const root = resolve(target.root);
|
|
52
|
+
|
|
53
|
+
const found = await discover(root);
|
|
54
|
+
if (flags.repos) {
|
|
55
|
+
if (found.error) {
|
|
56
|
+
return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
|
|
57
|
+
}
|
|
58
|
+
if (found.repos.length === 0) {
|
|
59
|
+
return fail(
|
|
60
|
+
`${root} contains no git repositories`,
|
|
61
|
+
EXIT.CONFIG,
|
|
62
|
+
`point --root at the directory holding the repositories, or at one repository without --repos`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// `discover` sorts, and the order decides which root owns a shared paragraph. A
|
|
66
|
+
// second sort here would move duplicate bytes between roots and make the workspace
|
|
67
|
+
// figure irreproducible.
|
|
68
|
+
} else if (found.shape === 'workspace') {
|
|
69
|
+
return fail(
|
|
70
|
+
`${root} holds repositories rather than being one`,
|
|
71
|
+
EXIT.CONFIG,
|
|
72
|
+
`measure one with: ${CLI} tokens --root <path>, or every repository under it with: ${CLI} tokens --repos --root ${root}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const roots = flags.repos ? found.repos : [root];
|
|
76
|
+
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const path of roots) rows.push(await measureRoot(path));
|
|
79
|
+
annotate(rows);
|
|
80
|
+
|
|
81
|
+
const summary = totals(rows);
|
|
82
|
+
const mcp = mcpTools();
|
|
83
|
+
|
|
84
|
+
if (isJson(flags)) {
|
|
85
|
+
emitJson({
|
|
86
|
+
ok: true,
|
|
87
|
+
command: 'tokens',
|
|
88
|
+
root,
|
|
89
|
+
mode: flags.repos ? 'repositories' : 'repository',
|
|
90
|
+
source: target.source,
|
|
91
|
+
budget: { bytes: ALWAYS_ON_BUDGET_BYTES, bytesPerToken: BYTES_PER_TOKEN },
|
|
92
|
+
mcp,
|
|
93
|
+
roots: rows.map(({ documents, ...row }) => row),
|
|
94
|
+
totals: summary,
|
|
95
|
+
});
|
|
96
|
+
return EXIT.OK;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const lines = [];
|
|
100
|
+
if (flags.repos) {
|
|
101
|
+
lines.push(`tokens ${root} ${plain(summary.roots)} repositories (${target.source})`);
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const budget = row.budget.over ? ` over budget by ${plain(row.budget.overBy)} B` : '';
|
|
104
|
+
lines.push(
|
|
105
|
+
`${row.name} generated ${plain(row.generatedBytes)} B duplicate ${plain(row.redundancy.duplicateBytes)} B unique ${plain(row.redundancy.uniqueBytes)} B${budget}`,
|
|
106
|
+
);
|
|
107
|
+
for (const finding of row.findings) lines.push(`note ${row.name}: ${finding}`);
|
|
108
|
+
}
|
|
109
|
+
const share =
|
|
110
|
+
summary.generatedBytes === 0 ? 0 : Math.round((summary.duplicateBytes / summary.generatedBytes) * 100);
|
|
111
|
+
lines.push(
|
|
112
|
+
`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
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
const row = rows[0];
|
|
116
|
+
lines.push(`tokens ${row.path}`);
|
|
117
|
+
lines.push(`state ${row.adoption} surfaces ${row.surfaces.length === 0 ? '(AGENTS.md only)' : row.surfaces.join(', ')}`);
|
|
118
|
+
for (const channel of row.channels) {
|
|
119
|
+
lines.push(
|
|
120
|
+
`${channel.channel.padEnd(9)}${files(channel.files)} ${plain(channel.generatedBytes)} B generated ${plain(channel.readerBytes)} B read`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const verdict = row.budget.over ? `over by ${plain(row.budget.overBy)} B` : 'within budget';
|
|
124
|
+
lines.push(
|
|
125
|
+
`always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
|
|
126
|
+
);
|
|
127
|
+
lines.push(
|
|
128
|
+
`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
|
+
);
|
|
130
|
+
for (const finding of row.findings) lines.push(`note ${finding}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(
|
|
133
|
+
`mcp tools/list ${plain(mcp.bytes)} B of ${plain(mcp.tools)} tools (the same for every repository, so it is not in any total)`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
137
|
+
return EXIT.OK;
|
|
138
|
+
}
|
|
@@ -11,8 +11,9 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
11
11
|
import { basename, join } from 'node:path';
|
|
12
12
|
import { promisify } from 'node:util';
|
|
13
13
|
|
|
14
|
-
import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderShim } from '../hooks/shim.mjs';
|
|
14
|
+
import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderShim } from '../hooks/shim.mjs';
|
|
15
15
|
import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
|
|
16
|
+
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
16
17
|
|
|
17
18
|
const exec = promisify(execFile);
|
|
18
19
|
|
|
@@ -30,7 +31,7 @@ const exec = promisify(execFile);
|
|
|
30
31
|
* @param {string} path
|
|
31
32
|
* @returns {Promise<{event:string, target:string}|null>}
|
|
32
33
|
*/
|
|
33
|
-
|
|
34
|
+
async function readChainedShim(path) {
|
|
34
35
|
let content;
|
|
35
36
|
try {
|
|
36
37
|
content = await readFile(path, 'utf8');
|
|
@@ -49,6 +50,54 @@ async function isChainedShim(path) {
|
|
|
49
50
|
return (await readChainedShim(path)) !== null;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Whether this file is a dispatcher shim this tool wrote, whatever version wrote it.
|
|
55
|
+
*
|
|
56
|
+
* Content, on the same argument as {@link readChainedShim}: the name is what git calls
|
|
57
|
+
* it and the path is where this tool happens to put its own, and neither of those is a
|
|
58
|
+
* statement about authorship. The banner is.
|
|
59
|
+
*/
|
|
60
|
+
async function isOurShim(path) {
|
|
61
|
+
try {
|
|
62
|
+
return (await readFile(path, 'utf8')).includes(SHIM_MARKER);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Git event -> manifest gate list. Exported because the report prints from it too. */
|
|
69
|
+
export const GATE_KEY = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
70
|
+
|
|
71
|
+
/** Gates this manifest declares for an event. */
|
|
72
|
+
export function gateCount(manifest, event) {
|
|
73
|
+
return (manifest?.gates?.[GATE_KEY[event]] ?? []).length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a missing shim for this event is a defect.
|
|
78
|
+
*
|
|
79
|
+
* A repo that declares no gate for an event has no reason to want a shim for it, so
|
|
80
|
+
* its absence is the correct end state. A repo with NO manifest is different: we do
|
|
81
|
+
* not know what it wants, so we cannot rule the shim out - absence of knowledge is
|
|
82
|
+
* not a decision.
|
|
83
|
+
*
|
|
84
|
+
* It lives here, not in the command that first needed it, because there are two
|
|
85
|
+
* callers and they were answering differently. `hooks install` asked this question;
|
|
86
|
+
* `compare` did not ask it at all and defaulted to "yes, a shim is wanted for every
|
|
87
|
+
* event", so a repository declaring only `prePush` gates was reported as
|
|
88
|
+
* `hooksComplete: false` for the pre-commit shim it correctly has no reason to write.
|
|
89
|
+
* Four estate repositories were in that state and no amount of running `hooks install`
|
|
90
|
+
* could clear it.
|
|
91
|
+
*
|
|
92
|
+
* @param {object|null} manifest the repository's manifest
|
|
93
|
+
* @param {boolean} exists whether a manifest was found at all
|
|
94
|
+
* @param {string} event
|
|
95
|
+
*/
|
|
96
|
+
export function wantsShim(manifest, exists, event) {
|
|
97
|
+
if (!exists) return true;
|
|
98
|
+
return gateCount(manifest, event) > 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
52
101
|
/** `core.hooksPath`, or null when unset or the repo is not a git repo. */
|
|
53
102
|
export async function readHooksPath(root) {
|
|
54
103
|
try {
|
|
@@ -64,27 +113,40 @@ export async function readHooksPath(root) {
|
|
|
64
113
|
* One shim's state. A shim that differs from what this simt-dev generates is
|
|
65
114
|
* `stale`: the gates it enforces are from another version, so it cannot be
|
|
66
115
|
* assumed to know about a gate added since.
|
|
116
|
+
*
|
|
117
|
+
* `ours` is reported rather than inferred from `stale`, because the two answer different
|
|
118
|
+
* questions and only one is the one `hooks install` has to ask. A shim this tool wrote can be
|
|
119
|
+
* current (identical bytes), or from an earlier release (stale), or a shell it left behind for
|
|
120
|
+
* an event with no gates - also stale, and the one thing here that is obsolete rather than out
|
|
121
|
+
* of date. A file a person wrote by hand is stale too, and it is the one thing install must
|
|
122
|
+
* never delete. Authorship is only on the file, which is the same argument {@link isOurShim}
|
|
123
|
+
* already records for the same reason.
|
|
124
|
+
*
|
|
125
|
+
* `root` is the repository the shim belongs to: the remedy a shim prints depends on
|
|
126
|
+
* the repo's kind (a devDependency where package.json exists, a pinned global install
|
|
127
|
+
* where there is none), so the comparison must render the shim the way THIS repo got it,
|
|
128
|
+
* or a non-npm repository would read as stale the moment this tool ships.
|
|
67
129
|
*/
|
|
68
|
-
|
|
130
|
+
async function describeShim(path, root) {
|
|
69
131
|
let content;
|
|
70
132
|
try {
|
|
71
133
|
content = await readFile(path, 'utf8');
|
|
72
134
|
} catch {
|
|
73
|
-
return { present: false, executable: false, stale: false };
|
|
135
|
+
return { present: false, executable: false, stale: false, ours: false };
|
|
74
136
|
}
|
|
75
137
|
let info;
|
|
76
138
|
try {
|
|
77
139
|
info = await stat(path);
|
|
78
140
|
} catch {
|
|
79
|
-
return { present: false, executable: false, stale: false };
|
|
141
|
+
return { present: false, executable: false, stale: false, ours: false };
|
|
80
142
|
}
|
|
81
143
|
let stale = true;
|
|
82
144
|
try {
|
|
83
|
-
stale = content !== renderShim(basename(path));
|
|
145
|
+
stale = content !== renderShim(basename(path), { npm: await hasNpm(root), version: packageVersion() });
|
|
84
146
|
} catch {
|
|
85
147
|
stale = true;
|
|
86
148
|
}
|
|
87
|
-
return { present: true, executable: (info.mode & 0o111) !== 0, stale };
|
|
149
|
+
return { present: true, executable: (info.mode & 0o111) !== 0, stale, ours: content.includes(SHIM_MARKER) };
|
|
88
150
|
}
|
|
89
151
|
|
|
90
152
|
/**
|
|
@@ -100,7 +162,7 @@ export async function readHooksState(root, options = {}) {
|
|
|
100
162
|
const current = await readHooksPath(root);
|
|
101
163
|
const installed = [];
|
|
102
164
|
for (const event of EVENTS) {
|
|
103
|
-
installed.push({ event, ...(await describeShim(join(root, fileFor(event)))) });
|
|
165
|
+
installed.push({ event, ...(await describeShim(join(root, fileFor(event)), root)) });
|
|
104
166
|
}
|
|
105
167
|
|
|
106
168
|
// What git runs right now, which is not the same question as "is a simt-dev
|
|
@@ -112,8 +174,10 @@ export async function readHooksState(root, options = {}) {
|
|
|
112
174
|
// hand-written one. Only the bytes can.
|
|
113
175
|
const foreign = [];
|
|
114
176
|
const chained = [];
|
|
177
|
+
const staleOurs = [];
|
|
115
178
|
for (const hook of live) {
|
|
116
|
-
|
|
179
|
+
const shim = EVENTS.includes(hook.event) ? await describeShim(hook.path, root) : { stale: true };
|
|
180
|
+
if (!shim.stale) continue;
|
|
117
181
|
// A chained shim is ours too, and its name says nothing: `post-commit` is what the
|
|
118
182
|
// repository called its own hook, and the shim exists under that name precisely so
|
|
119
183
|
// git finds it. Reading it as foreign would make every re-install refuse with "this
|
|
@@ -124,6 +188,25 @@ export async function readHooksState(root, options = {}) {
|
|
|
124
188
|
chained.push({ ...chain, path: hook.path });
|
|
125
189
|
continue;
|
|
126
190
|
}
|
|
191
|
+
// And so is a dispatcher shim from an EARLIER version of this tool, which is the
|
|
192
|
+
// ordinary state of every repository adopted before the last release.
|
|
193
|
+
//
|
|
194
|
+
// Reading it as foreign was wrong twice over. `compare` reported "not a simt-dev
|
|
195
|
+
// shim" about a file simt-dev wrote, and `hooks install --yes` then printed "stopped
|
|
196
|
+
// running <event> ... declare it in simt.json gates.commands to have the dispatcher
|
|
197
|
+
// run it" — advice that, followed, makes the dispatcher run the dispatcher. The file
|
|
198
|
+
// was also described as "still on disk, untouched" in the same breath as the install
|
|
199
|
+
// overwrote it. Both statements were false and both pointed a reader at a worse
|
|
200
|
+
// configuration; nine estate repositories were in exactly this state.
|
|
201
|
+
//
|
|
202
|
+
// What it actually needs is refreshing, which `installed` already reports as `stale`
|
|
203
|
+
// and `compare` already reports as "differs from this simt-dev; reinstall". Keeping
|
|
204
|
+
// it out of `foreign` also stops the takeover guard asking for consent the
|
|
205
|
+
// repository has already given: it routed through this tool once.
|
|
206
|
+
if (await isOurShim(hook.path)) {
|
|
207
|
+
staleOurs.push(hook);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
127
210
|
foreign.push(hook);
|
|
128
211
|
}
|
|
129
212
|
|
|
@@ -146,6 +229,10 @@ export async function readHooksState(root, options = {}) {
|
|
|
146
229
|
complete: current === HOOKS_DIR && problems.length === 0,
|
|
147
230
|
installed,
|
|
148
231
|
foreign,
|
|
232
|
+
// Dispatcher shims this tool wrote in an earlier version. Reported so a reader can
|
|
233
|
+
// tell an out-of-date shim - which `hooks install` refreshes - from one this tool
|
|
234
|
+
// has never heard of, which only a human may decide to replace.
|
|
235
|
+
staleOurs,
|
|
149
236
|
// The chained shims this repo is already running, read back off disk. `install`
|
|
150
237
|
// re-renders them from here so a second run keeps maintaining them rather than
|
|
151
238
|
// quietly forgetting them; without this field the set of things install maintains
|