simt-dev 0.2.0 → 0.2.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 +173 -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/README.md +17 -0
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +89 -13
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +3 -3
- package/src/commands/sync.mjs +153 -11
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +34 -5
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +50 -5
- 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/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +247 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/targets.mjs +53 -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,17 @@
|
|
|
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, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join } 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';
|
|
29
30
|
|
|
30
31
|
/** Findings `sync` reports and does not fail on.
|
|
31
32
|
*
|
|
@@ -76,6 +77,28 @@ export async function syncCommand(positional, flags) {
|
|
|
76
77
|
|
|
77
78
|
const result = await plan(root, flags);
|
|
78
79
|
|
|
80
|
+
// Before the rule branch, and named for what it is. An unknown surface id is a
|
|
81
|
+
// misspelling in the manifest's `surfaces` list; reporting it as an unprojectable
|
|
82
|
+
// `.mdc` rule sent the reader to a `simt-substitutions` block that has nothing to do
|
|
83
|
+
// with the thing they typed. `check` reported the same problem correctly the whole
|
|
84
|
+
// time, so this was a writer disagreeing with the gate about the same manifest.
|
|
85
|
+
if (result.surfaceProblems.length > 0) {
|
|
86
|
+
if (isJson(flags)) {
|
|
87
|
+
emitJson({
|
|
88
|
+
ok: false,
|
|
89
|
+
command: 'sync',
|
|
90
|
+
root,
|
|
91
|
+
error: result.surfaceProblems[0],
|
|
92
|
+
problems: result.surfaceProblems,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const p of result.surfaceProblems) note(`PROBLEM ${p}`);
|
|
96
|
+
return fail(
|
|
97
|
+
`${result.surfaceProblems.length} surface(s) cannot be resolved (${result.surfaceProblems[0]})`,
|
|
98
|
+
EXIT.FAIL,
|
|
99
|
+
`fix the surface ids in the "surfaces" list of ${MANIFEST_FILE}, or remove the unknown one`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
79
102
|
|
|
80
103
|
// A `.mdc` rule that cannot render is a defect in this build, not in the repo,
|
|
81
104
|
// and projecting the rest would leave a family half-retired.
|
|
@@ -162,6 +185,40 @@ export async function syncCommand(positional, flags) {
|
|
|
162
185
|
const changed = result.targets.filter((t) => t.action !== 'unchanged');
|
|
163
186
|
const pointersAdded = pointerPlan.map((p) => p.path);
|
|
164
187
|
|
|
188
|
+
// The manifest is the second file this tool writes into a repository it does not own,
|
|
189
|
+
// and for two releases it was the only generated file with no path back to correct:
|
|
190
|
+
// `check` refused on a stale generated field and named `init --yes`, while `sync` -
|
|
191
|
+
// the command whose whole job is regenerating what this tool owns - reported
|
|
192
|
+
// "already current; nothing written". Both statements were true of the same
|
|
193
|
+
// repository, and all fourteen adopted repositories were in that state at once.
|
|
194
|
+
//
|
|
195
|
+
// One reason and one place, so `check`'s remedy and this plan cannot drift: the
|
|
196
|
+
// field set is `GENERATED_KEYS`, and the values come from the same `buildManifest`
|
|
197
|
+
// call `manifestDrift` compares against. A repair built from a second copy of
|
|
198
|
+
// either would pass its own test and leave the gate still red.
|
|
199
|
+
//
|
|
200
|
+
// `reformatted` is repaired too, and only when the repository has said the file is
|
|
201
|
+
// not prose: `simt.json` listed in `.prettierignore` is the repo stating that no
|
|
202
|
+
// formatter will take these bytes back.
|
|
203
|
+
//
|
|
204
|
+
// Without that condition there are two wrong answers. Repairing unconditionally
|
|
205
|
+
// means every `format` run rewrites a file the commit just wrote - a permanently
|
|
206
|
+
// dirty working tree. Never repairing means `init --yes` is the only way to put the
|
|
207
|
+
// bytes back, and `init` re-runs adoption: it tags, it switches branch, it asks every
|
|
208
|
+
// question again, to fix one file's formatting (ADR-050). The first repo routed in
|
|
209
|
+
// the estate roll-out hit this on its first commit, so both answers were live.
|
|
210
|
+
const manifestPlan = await planManifestRepair(root, result.stack, result.targets[0].sha);
|
|
211
|
+
if (manifestPlan) {
|
|
212
|
+
targets.push({
|
|
213
|
+
file: manifestPlan.file,
|
|
214
|
+
surface: 'manifest',
|
|
215
|
+
action: 'updated',
|
|
216
|
+
bytes: Buffer.byteLength(manifestPlan.text),
|
|
217
|
+
previousBytes: manifestPlan.previousBytes,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const manifestChanged = manifestPlan !== null;
|
|
221
|
+
|
|
165
222
|
// Writes happen BEFORE output in every mode. An earlier build emitted `--json`
|
|
166
223
|
// first and returned, so `upgrade --yes --json` reported a plan and changed
|
|
167
224
|
// nothing — the exact combination automation reaches for.
|
|
@@ -178,6 +235,13 @@ export async function syncCommand(positional, flags) {
|
|
|
178
235
|
// A comment that enumerates which commands follow a rule is a claim about code
|
|
179
236
|
// this file does not own, and it was one commit out of date before anyone ran it.
|
|
180
237
|
if (mode.apply) {
|
|
238
|
+
// The manifest first, with the rest: a gate that refuses on a stale stamp should
|
|
239
|
+
// not be able to read a half-finished write, and the manifest is the file every
|
|
240
|
+
// other write in this run is described by.
|
|
241
|
+
if (manifestPlan) {
|
|
242
|
+
await writeManifest(root, manifestPlan.manifest);
|
|
243
|
+
note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
|
|
244
|
+
}
|
|
181
245
|
for (const t of changed) {
|
|
182
246
|
// Three of the four rule surfaces live in directories that do not exist yet in a
|
|
183
247
|
// repository that has only ever been written to by hand. `init` grew its own
|
|
@@ -210,7 +274,7 @@ export async function syncCommand(positional, flags) {
|
|
|
210
274
|
// `ok` follows the exit code, which now reports drift for a previewing run
|
|
211
275
|
// too. Leaving it `true` here would break the invariant ADR-035 sets for
|
|
212
276
|
// every command, and the contract table asserts it for this one.
|
|
213
|
-
ok: changed.length === 0 && pointersAdded.length === 0 ? true : mode.apply,
|
|
277
|
+
ok: changed.length === 0 && pointersAdded.length === 0 && !manifestChanged ? true : mode.apply,
|
|
214
278
|
command: checkOnly ? 'sync --check' : 'sync',
|
|
215
279
|
root,
|
|
216
280
|
stack: result.stack,
|
|
@@ -220,7 +284,7 @@ export async function syncCommand(positional, flags) {
|
|
|
220
284
|
sha: result.targets[0].sha,
|
|
221
285
|
targets,
|
|
222
286
|
pointers: pointersAdded,
|
|
223
|
-
written: mode.apply ? changed.length + pointersAdded.length : 0,
|
|
287
|
+
written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
|
|
224
288
|
dryRun: !mode.apply,
|
|
225
289
|
},
|
|
226
290
|
{},
|
|
@@ -233,12 +297,15 @@ export async function syncCommand(positional, flags) {
|
|
|
233
297
|
surfacesLine({ ids: result.surfaces, source: result.surfaceSource }, 10),
|
|
234
298
|
`sha ${result.targets[0].sha}`,
|
|
235
299
|
'',
|
|
236
|
-
|
|
300
|
+
// `targets`, not `result.targets`: the plan is the thing being reported, and a
|
|
301
|
+
// line the JSON payload carries but the text one drops is the reader of the
|
|
302
|
+
// terminal output finding out from the exit code what they were already told.
|
|
303
|
+
...targets.map(
|
|
237
304
|
(t) => `${t.file} ${t.action} ${t.previousBytes} -> ${t.bytes} bytes`,
|
|
238
305
|
),
|
|
239
306
|
...pointerPlan.map((p) => `${p.file} added pointer -> ${p.to}`),
|
|
240
307
|
];
|
|
241
|
-
if (changed.length === 0 && pointerPlan.length === 0) lines.push('already current; nothing written');
|
|
308
|
+
if (changed.length === 0 && pointerPlan.length === 0 && !manifestChanged) lines.push('already current; nothing written');
|
|
242
309
|
else if (checkOnly) lines.push('would write; rerun without --check');
|
|
243
310
|
else if (!mode.apply) {
|
|
244
311
|
// The preview names the command that writes it. A preview that reports what
|
|
@@ -247,24 +314,91 @@ export async function syncCommand(positional, flags) {
|
|
|
247
314
|
lines.push(
|
|
248
315
|
flags.dryRun === true
|
|
249
316
|
? 'dry-run; nothing written'
|
|
250
|
-
: `would write ${changed.length + pointersAdded.length} file(s); apply with: ${CLI} sync --yes`,
|
|
317
|
+
: `would write ${changed.length + pointersAdded.length + (manifestChanged ? 1 : 0)} file(s); apply with: ${CLI} sync --yes`,
|
|
251
318
|
);
|
|
252
319
|
}
|
|
253
320
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
254
321
|
}
|
|
255
322
|
|
|
256
|
-
|
|
323
|
+
const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
|
|
324
|
+
if (checkOnly) return idle ? EXIT.OK : EXIT.FAIL;
|
|
257
325
|
// A preview that found work is not a success: `simt-dev sync --yes` with no flags is a
|
|
258
326
|
// check that also prints the plan, and a check that exits 0 while reporting drift
|
|
259
327
|
// is the one-question-two-answers shape ADR-035 rules out. This is what lets a
|
|
260
328
|
// caller run `sync` in place of `sync --check` and get the same verdict.
|
|
261
329
|
if (!mode.apply) {
|
|
262
330
|
if (flags.dryRun === true) note('dry-run no files written');
|
|
263
|
-
return
|
|
331
|
+
return idle ? EXIT.OK : EXIT.FAIL;
|
|
264
332
|
}
|
|
265
333
|
return EXIT.OK;
|
|
266
334
|
}
|
|
267
335
|
|
|
336
|
+
/**
|
|
337
|
+
* The manifest repair `sync` would make, or null when there is nothing to repair.
|
|
338
|
+
*
|
|
339
|
+
* Returns the parsed manifest to hand to `writeManifest` as well as the bytes, so the
|
|
340
|
+
* writer keeps owning the legacy-name move and the preview keeps showing the real
|
|
341
|
+
* count. `sha` is the same digest the regions were rendered from: the stamp and the
|
|
342
|
+
* text it describes have to agree, or the next `check` finds a region carrying a pin
|
|
343
|
+
* the manifest does not claim.
|
|
344
|
+
*
|
|
345
|
+
* @returns {Promise<{file: string, manifest: object, text: string,
|
|
346
|
+
* stale: string[], previousBytes: number}|null>}
|
|
347
|
+
*/
|
|
348
|
+
async function planManifestRepair(root, stack, sha) {
|
|
349
|
+
const drift = await manifestDrift(root, stack, sha);
|
|
350
|
+
if (drift === null) return null;
|
|
351
|
+
// A `reformatted` manifest is repaired only once the repository has declared that no
|
|
352
|
+
// formatter will take these bytes back. The declaration is read rather than assumed
|
|
353
|
+
// because a repo that runs no formatter should not need a `.prettierignore` to repair
|
|
354
|
+
// a manifest some other tool rewrote, and one that does must be believed only when it
|
|
355
|
+
// has said so.
|
|
356
|
+
if (drift.status === 'reformatted' && !(await formatterIgnoresManifest(root))) return null;
|
|
357
|
+
|
|
358
|
+
const { manifest } = await readManifest(root);
|
|
359
|
+
if (manifest === null) return null;
|
|
360
|
+
const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
|
|
361
|
+
const patched = withGenerated(manifest, expected);
|
|
362
|
+
const stale = Object.keys(expected).filter(
|
|
363
|
+
(key) => JSON.stringify(manifest[key]) !== JSON.stringify(patched[key]),
|
|
364
|
+
);
|
|
365
|
+
return {
|
|
366
|
+
file: MANIFEST_FILE,
|
|
367
|
+
manifest: patched,
|
|
368
|
+
text: manifestText(patched),
|
|
369
|
+
stale,
|
|
370
|
+
previousBytes: Buffer.byteLength(manifestText(manifest)),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Whether this repository's `.prettierignore` excludes the manifest.
|
|
376
|
+
*
|
|
377
|
+
* Lines, `#` comments and blanks only. A glob is not evaluated: the question this
|
|
378
|
+
* answers is "did somebody deliberately write this file's name down", and an exact
|
|
379
|
+
* match is the only reading of that which cannot be wrong. A repository that ignores
|
|
380
|
+
* `*.json` has said something broader, and the honest answer there is that this tool
|
|
381
|
+
* cannot tell - so it declines to repair, which is the state that was already correct.
|
|
382
|
+
*/
|
|
383
|
+
async function formatterIgnoresManifest(root) {
|
|
384
|
+
// ENOENT only. A catch that answers "no" to every error is a catch that turns a
|
|
385
|
+
// typo in this function into a repository silently keeping its pre-commit gate's
|
|
386
|
+
// formatting — which is how the first version of it failed: `join` was not imported,
|
|
387
|
+
// the ReferenceError was swallowed, and every repository read as having declared
|
|
388
|
+
// nothing. Absence is absence; anything else is a fault worth seeing.
|
|
389
|
+
let text;
|
|
390
|
+
try {
|
|
391
|
+
text = await readFile(join(root, '.prettierignore'), 'utf8');
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error.code !== 'ENOENT') throw error;
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return text
|
|
397
|
+
.split('\n')
|
|
398
|
+
.map((line) => line.trim())
|
|
399
|
+
.some((line) => line !== '' && !line.startsWith('#') && (line === MANIFEST_FILE || line === MANIFEST_LEGACY));
|
|
400
|
+
}
|
|
401
|
+
|
|
268
402
|
export async function checkCommand(positional, flags) {
|
|
269
403
|
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
270
404
|
const findings = await checkTargets(root, flags);
|
|
@@ -288,6 +422,14 @@ export async function checkCommand(positional, flags) {
|
|
|
288
422
|
// before anyone installs anything rather than after.
|
|
289
423
|
const { manifest } = await readManifest(root);
|
|
290
424
|
if (manifest) {
|
|
425
|
+
// The gate runs from whatever binary resolved it. For a repository without npm that
|
|
426
|
+
// is a global install, and the only thing that knows which version it should be is
|
|
427
|
+
// the pin the repo recorded at adoption. A stale binary would run a different gate
|
|
428
|
+
// than CI does, so the mismatch fails the gate rather than warning.
|
|
429
|
+
const mismatch = assertVersionPin(manifest);
|
|
430
|
+
if (mismatch) {
|
|
431
|
+
findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
|
|
432
|
+
}
|
|
291
433
|
findings.push(...unrunnableGates(manifest, await readCapability(root)));
|
|
292
434
|
// A manifest a formatter has rewritten is not wrong, and not editable-away by
|
|
293
435
|
// hand either: the next `init` puts simt-dev's own bytes back and the formatter
|
|
@@ -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
|
package/src/compare/row.mjs
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
// reordered its columns would make every comparison between two runs unreadable.
|
|
18
18
|
|
|
19
19
|
import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
|
|
20
|
-
import { readManifest } from '../projection/manifest.mjs';
|
|
20
|
+
import { manifestDrift, readManifest } from '../projection/manifest.mjs';
|
|
21
21
|
import { loadRules, select } from '../projection/rules.mjs';
|
|
22
22
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
23
23
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
24
|
-
import { readHooksState } from './hooksstate.mjs';
|
|
24
|
+
import { readHooksState, wantsShim } from './hooksstate.mjs';
|
|
25
25
|
import { CLI } from '../name.mjs';
|
|
26
26
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
27
27
|
|
|
@@ -60,10 +60,21 @@ export async function buildRow(path, context) {
|
|
|
60
60
|
const problems = [];
|
|
61
61
|
const notes = [];
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// The manifest first, and on its own. The predicate below reads it, and a
|
|
64
|
+
// destructured binding in the same `Promise.all` array is still in its temporal dead
|
|
65
|
+
// zone when the array is evaluated - so this is the same defect ADR-045 records in
|
|
66
|
+
// this file one level up: a gate reading a field the line above has not set yet.
|
|
67
|
+
const manifest = await readManifest(path);
|
|
68
|
+
|
|
69
|
+
const [fp, hooks, findings] = await Promise.all([
|
|
65
70
|
fingerprint(path),
|
|
66
|
-
|
|
71
|
+
// The same predicate `hooks install` writes by. Without it this row defaulted to
|
|
72
|
+
// "a shim is wanted for every event", so a repository declaring only `prePush`
|
|
73
|
+
// gates was reported `hooksComplete: false` for the pre-commit shim it has no
|
|
74
|
+
// reason to write - and no number of runs of `hooks install` could clear it,
|
|
75
|
+
// because the command and the dashboard were answering different questions.
|
|
76
|
+
// Four estate repositories were in that state.
|
|
77
|
+
readHooksState(path, { declaresGate: (e) => wantsShim(manifest.manifest, manifest.exists, e) }),
|
|
67
78
|
checkTargets(path),
|
|
68
79
|
]);
|
|
69
80
|
|
|
@@ -152,6 +163,24 @@ export async function buildRow(path, context) {
|
|
|
152
163
|
problems.push(`${MANIFEST_FILE} ${manifest.error}`);
|
|
153
164
|
}
|
|
154
165
|
|
|
166
|
+
// A generated field of the manifest is stale, and `check` refuses on it. This row
|
|
167
|
+
// said the repository was `current` anyway, in all fourteen adopted repositories at
|
|
168
|
+
// once, so a dashboard that reported `current: 14, problems: 0` was describing a
|
|
169
|
+
// green estate on which every gate exited 1.
|
|
170
|
+
//
|
|
171
|
+
// ADR-045 went the other way: `compare` counted a defect it had been hiding. This is
|
|
172
|
+
// the same defect from the far side — a refusal the dashboard does not count — and
|
|
173
|
+
// the acceptance criterion in the review that owns this ("compare reports current:
|
|
174
|
+
// 14") was satisfiable while `check` exited 1 in every row it covered.
|
|
175
|
+
//
|
|
176
|
+
// `drift.remedy` verbatim, for both statuses. The remedy is the repair command, and
|
|
177
|
+
// two commands answering one question with different instructions is ADR-050's
|
|
178
|
+
// subject; if `check` changes its mind, this row changes with it for free.
|
|
179
|
+
if (adoption === 'adopted') {
|
|
180
|
+
const drift = await manifestDrift(path, row.stack, context.expectedSha);
|
|
181
|
+
if (drift) problems.push(`${drift.file}: ${drift.detail}; ${drift.remedy}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
155
184
|
// --- region --------------------------------------------------------------
|
|
156
185
|
// Only meaningful for an adopted repo. An unmanaged repo has no manifest, so
|
|
157
186
|
// its `absent` region is a consequence of not having adopted, not a second
|
package/src/decisions/index.mjs
CHANGED
|
@@ -180,7 +180,7 @@ export function citations(text) {
|
|
|
180
180
|
*
|
|
181
181
|
* @returns {Promise<{ok: boolean, decisions: object[], errors: string[]}>}
|
|
182
182
|
*/
|
|
183
|
-
export async function loadDecisions() {
|
|
183
|
+
export async function loadDecisions({ all = false } = {}) {
|
|
184
184
|
let names;
|
|
185
185
|
try {
|
|
186
186
|
names = (await readdir(DECISIONS_DIR)).filter((n) => /^ADR-\d{3}-.*\.md$/.test(n)).sort();
|
|
@@ -240,7 +240,7 @@ export async function loadDecisions() {
|
|
|
240
240
|
(m) => `ADR-${m[1]}`,
|
|
241
241
|
),
|
|
242
242
|
partial,
|
|
243
|
-
summary: summarise(text, partial),
|
|
243
|
+
summary: summarise(text, partial, { all }),
|
|
244
244
|
bytes: Buffer.byteLength(text),
|
|
245
245
|
});
|
|
246
246
|
}
|
|
@@ -283,9 +283,12 @@ export function byId(decisions) {
|
|
|
283
283
|
* @param {{ruling: number|null, kind: string, by: string|null}[]} partial the record's
|
|
284
284
|
* partial supersessions, so a ruling that is no longer in force can say so where it is
|
|
285
285
|
* printed rather than only in the status line above it
|
|
286
|
-
* @
|
|
286
|
+
* @param {{all?: boolean}} [options] `all` keeps every ruling instead of the first two.
|
|
287
|
+
* The cap exists for the common case, where a rule cites one record and two rulings
|
|
288
|
+
* answer "why is this here"; the reader who needs the rest asks.
|
|
289
|
+
* @returns {{ruled: string[], rulingNumbers: (number|null)[], rulingCount: number, rejected: string[], writtenAgainst: string[]}}
|
|
287
290
|
*/
|
|
288
|
-
function summarise(text, partial = []) {
|
|
291
|
+
function summarise(text, partial = [], { all = false } = {}) {
|
|
289
292
|
// A section ends at the next `## ` — but not at one inside a fenced code block.
|
|
290
293
|
// ADR-007's entire ruling is a document skeleton in a fence whose headings are `##`,
|
|
291
294
|
// so the old lookahead ended the section at the first line of the ruling itself. It
|
|
@@ -418,9 +421,21 @@ const section = (name) => {
|
|
|
418
421
|
const ruled = units(section('Ruling') || section('Rulings'));
|
|
419
422
|
const withLead = ruled.filter((p, i) => !p.text.endsWith(':') || i === ruled.length - 1);
|
|
420
423
|
|
|
421
|
-
|
|
424
|
+
// Two by default, and every one of them on request. Fifty-five of the sixty records
|
|
425
|
+
// carry more than two rulings, so the default is an excerpt for almost every record —
|
|
426
|
+
// which is fine as a budget and wrong as a silence, and the count below says so.
|
|
427
|
+
//
|
|
428
|
+
// The full set is kept rather than discarded, because the command that names where the
|
|
429
|
+
// rest of it is has to be able to produce it. A remedy that cannot be followed is the
|
|
430
|
+
// defect this whole command exists to prevent, and the first version of this fix
|
|
431
|
+
// printed "the rest: simt rules show ADR-019", which exits 2: `rules show` takes a rule
|
|
432
|
+
// id. So `--all` is the command the excerpt points at, and this is what it reads.
|
|
433
|
+
const printed = all ? withLead : withLead.slice(0, 2);
|
|
422
434
|
return {
|
|
423
435
|
ruled: printed.map((p) => mark(sentence(redactCommand(p.text)), p.number)),
|
|
436
|
+
// How many rulings the record carries. Parallel to `ruled`: "two of eight" and "two"
|
|
437
|
+
// are different answers to the same question and only one of them is true.
|
|
438
|
+
rulingCount: withLead.length,
|
|
424
439
|
// The number each printed ruling declares, parallel to `ruled`. It is the join
|
|
425
440
|
// between a supersession and the text it applies to, so it is exported rather than
|
|
426
441
|
// kept inside: a family that stopped numbering its rulings — `**One.**` losing the
|
|
@@ -493,7 +508,7 @@ export function redactEstate(text) {
|
|
|
493
508
|
*/
|
|
494
509
|
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|--[a-z-]+)';
|
|
495
510
|
|
|
496
|
-
|
|
511
|
+
function redactCommand(text) {
|
|
497
512
|
return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
|
|
498
513
|
}
|
|
499
514
|
|