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.
Files changed (56) hide show
  1. package/CHANGELOG.md +173 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/README.md +17 -0
  10. package/package.json +1 -2
  11. package/rules/stacks/dart.md +38 -0
  12. package/rules/stacks/deno.md +36 -0
  13. package/rules/stacks/dotnet.md +35 -0
  14. package/rules/stacks/elixir.md +39 -0
  15. package/rules/stacks/go.md +34 -0
  16. package/rules/stacks/ruby.md +36 -0
  17. package/rules/stacks/rust.md +34 -0
  18. package/rules/stacks/swift.md +37 -0
  19. package/src/argv.mjs +35 -6
  20. package/src/ci/dependabot.mjs +1 -1
  21. package/src/ci/workflow.mjs +33 -11
  22. package/src/commands/graph.mjs +13 -4
  23. package/src/commands/help.mjs +22 -4
  24. package/src/commands/hooks.mjs +173 -57
  25. package/src/commands/index.mjs +53 -45
  26. package/src/commands/init.mjs +89 -13
  27. package/src/commands/rules.mjs +54 -3
  28. package/src/commands/skills.mjs +143 -15
  29. package/src/commands/skillsinstall.mjs +3 -3
  30. package/src/commands/sync.mjs +153 -11
  31. package/src/compare/hooksstate.mjs +96 -9
  32. package/src/compare/row.mjs +34 -5
  33. package/src/decisions/index.mjs +21 -6
  34. package/src/graph/load.mjs +125 -14
  35. package/src/hooks/capability.mjs +48 -4
  36. package/src/hooks/dispatcher.mjs +50 -5
  37. package/src/hooks/gates.mjs +0 -2
  38. package/src/hooks/resolve.mjs +24 -25
  39. package/src/hooks/shim.mjs +29 -5
  40. package/src/identity.mjs +0 -11
  41. package/src/mcp/tools.mjs +2 -2
  42. package/src/output/index.mjs +8 -7
  43. package/src/package-version.mjs +74 -0
  44. package/src/projection/conflicts.mjs +56 -5
  45. package/src/projection/manifest.mjs +247 -25
  46. package/src/projection/mdc.mjs +4 -13
  47. package/src/projection/project.mjs +1 -1
  48. package/src/projection/rules.mjs +21 -8
  49. package/src/projection/targets.mjs +53 -7
  50. package/src/resolve/eval.mjs +64 -0
  51. package/src/resolve/fingerprint.mjs +1 -0
  52. package/src/resolve/near.mjs +26 -7
  53. package/src/resolve/resolve.mjs +1 -1
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/workspace.mjs +0 -9
  56. package/graph/skills.graph.json +0 -1622
@@ -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
- ...result.targets.map(
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
- if (checkOnly) return changed.length === 0 && pointerPlan.length === 0 ? EXIT.OK : EXIT.FAIL;
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 changed.length === 0 && pointerPlan.length === 0 ? EXIT.OK : EXIT.FAIL;
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
- export async function readChainedShim(path) {
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
- export async function describeShim(path) {
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
- if (EVENTS.includes(hook.event) && !(await describeShim(hook.path)).stale) continue;
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
@@ -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
- const [manifest, fp, hooks, findings] = await Promise.all([
64
- readManifest(path),
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
- readHooksState(path),
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
@@ -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
- * @returns {{ruled: string[], rulingNumbers: (number|null)[], rejected: string[], writtenAgainst: string[]}}
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
- const printed = withLead.slice(0, 2);
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
- export function redactCommand(text) {
511
+ function redactCommand(text) {
497
512
  return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
498
513
  }
499
514