simt-dev 0.7.1 → 0.8.0

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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -27,6 +27,10 @@ import { getGate } from '../hooks/gates.mjs';
27
27
  // Q31: the one Node runner table. This module and the card, capability,
28
28
  // dispatcher and CI workflow all spell `<manager> run <script>` from it.
29
29
  import { runScript } from '../ecosystem/node-runner.mjs';
30
+ // Q70: the ecosystem records. A new ecosystem is a new file under `ecosystems/`, not a
31
+ // row added to this table. records.mjs imports nothing from this package, so this edge
32
+ // cannot cycle.
33
+ import { ecosystemGateRows } from '../ecosystem/records.mjs';
30
34
 
31
35
  // Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
32
36
  // and the manifest name is used a dozen times below in remedy strings that name this
@@ -35,6 +39,8 @@ import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
35
39
  import { GENERATED_KEYS_OF, KEYS, loadManifest, manifestPath } from '../manifest/schema.mjs';
36
40
  import { CLI } from '../name.mjs';
37
41
  import { packageVersion } from '../package-version.mjs';
42
+ import { defaultGates, planGates } from '../gates/index.mjs';
43
+ export { ECOSYSTEMS, defaultGates, gateRunnable, planGates, unrunnableGates } from '../gates/index.mjs';
38
44
  export const MANIFEST = MANIFEST_FILE;
39
45
 
40
46
  // Still exported from here because this is where the manifest lived: every caller that
@@ -43,8 +49,6 @@ export { manifestPath };
43
49
 
44
50
  export { MANIFEST_LEGACY };
45
51
 
46
-
47
-
48
52
  /**
49
53
  * The one definition of the file's own comment, exported because `upgrade` rewrites
50
54
  * the manifest and must not carry a stale copy forward. Two literals for one string
@@ -61,7 +65,7 @@ export const MANIFEST_COMMENT = {
61
65
  `Written and maintained by simt-dev. \`${CLI} sync --yes\` regenerates the projected rules.`,
62
66
  `\`${CLI} init\` refreshes the SHA and keeps every key it does not write, so a value you add here survives a re-init.`,
63
67
  `\`plans\` is such a key: it selects the tracker template and the directories \`${CLI} plan\` reads.`,
64
- 'Omit it and the tracker template applies, which reads docs/plans and docs/reviews.',
68
+ 'Omit it and the tracker template applies, which reads docs/plans, docs/research and docs/reviews.',
65
69
  ].join(' '),
66
70
  };
67
71
 
@@ -69,362 +73,14 @@ export const MANIFEST_COMMENT = {
69
73
  * Gate names the shared dispatcher knows how to run (step 8). Choosing these from
70
74
  * the detected stack rather than asking a human keeps `init` non-interactive.
71
75
  */
72
- const GATES_BY_STACK = [
73
- {
74
- when: ['gradle', 'kotlin', 'connectiq'],
75
- preCommit: ['format', 'lint', 'i18n-completeness'],
76
- prePush: ['test'],
77
- },
78
- {
79
- when: ['python'],
80
- preCommit: ['format', 'lint', 'i18n-completeness'],
81
- prePush: ['test', 'coverage'],
82
- },
83
- {
84
- when: ['node', 'node-web', 'cloudflare-workers', 'typescript'],
85
- preCommit: ['format', 'lint'],
86
- prePush: ['test', 'typecheck'],
87
- },
88
- ];
89
-
90
- /** Extra gate implied by a specific tag, applied after the table above. */
91
- const TAG_GATES = [
92
- { tag: 'connectiq', preCommit: ['memory', 'codegen-drift'] },
93
- { tag: 'hono', preCommit: ['i18n-completeness'] },
94
- ];
95
-
96
- /**
97
- * The ecosystems that produce a fact rather than a stack tag, and what a fresh
98
- * adoption of one should declare.
99
- *
100
- * A row proposes gates the way `GATES_BY_STACK` does, and the fact it keys on is
101
- * the same signal a profile's `simt-applies` names, so one marker file reaches
102
- * both: the region this repo reads and the gates its commits run.
103
- *
104
- * `commands` is what a FRESH manifest declares for each proposed gate, and it is
105
- * written only when the probe in `readCapability` found `tool` where this row says
106
- * it lives. A declared gate that cannot run blocks every commit, so a command is
107
- * never declared on the faith of a marker file: the marker says the ecosystem is
108
- * here, the probe says its program can run (ADR-017). A command the repo already
109
- * declared wins over the seed, because a seed is a default and a declaration is a
110
- * choice.
111
- *
112
- * `file` marks a tool that is a file in the repository rather than a binary on
113
- * PATH: the Maven wrapper, probed the way this tool already probes `gradlew`.
114
- */
115
- export const ECOSYSTEMS = [
116
- {
117
- fact: 'has-cargo-toml',
118
- tool: 'cargo',
119
- preCommit: ['format', 'lint'],
120
- prePush: ['test'],
121
- commands: { format: 'cargo fmt --check', lint: 'cargo clippy -- -D warnings', test: 'cargo test' },
122
- },
123
- {
124
- fact: 'has-go-mod',
125
- tool: 'go',
126
- preCommit: ['format', 'lint'],
127
- prePush: ['test'],
128
- commands: { format: 'test -z "$(gofmt -l .)"', lint: 'go vet ./...', test: 'go test ./...' },
129
- },
130
- {
131
- fact: 'has-nuget',
132
- tool: 'dotnet',
133
- preCommit: ['format'],
134
- prePush: ['test', 'typecheck'],
135
- commands: { format: 'dotnet format --verify-no-changes', test: 'dotnet test', typecheck: 'dotnet build -warnaserror' },
136
- },
137
- {
138
- fact: 'has-swift-package',
139
- tool: 'swift',
140
- preCommit: ['format'],
141
- prePush: ['test', 'typecheck'],
142
- commands: { format: 'swift format lint -r .', test: 'swift test', typecheck: 'swift build' },
143
- },
144
- {
145
- fact: 'has-deno-config',
146
- tool: 'deno',
147
- preCommit: ['format', 'lint'],
148
- prePush: ['test', 'typecheck'],
149
- commands: { format: 'deno fmt --check', lint: 'deno lint', test: 'deno test', typecheck: 'deno check .' },
150
- },
151
- {
152
- fact: 'has-gemfile',
153
- tool: 'bundle',
154
- preCommit: ['format'],
155
- prePush: ['test'],
156
- commands: { format: 'bundle exec rubocop', test: 'bundle exec rspec' },
157
- },
158
- {
159
- fact: 'has-pubspec',
160
- tool: 'dart',
161
- preCommit: ['format', 'lint'],
162
- prePush: ['test'],
163
- commands: { format: 'dart format --output=none --set-exit-if-changed .', lint: 'dart analyze', test: 'dart test' },
164
- },
165
- {
166
- fact: 'has-mix',
167
- tool: 'mix',
168
- preCommit: ['format'],
169
- prePush: ['test'],
170
- commands: { format: 'mix format --check-formatted', test: 'mix test' },
171
- },
172
- // The wrapper is a file the repository commits, not a program on PATH. A repo with
173
- // a pom.xml and no wrapper cannot run `./mvnw -q verify`, so the probe is the file.
174
- {
175
- fact: 'has-maven-pom',
176
- tool: 'mvnw',
177
- file: true,
178
- prePush: ['test'],
179
- commands: { test: './mvnw -q verify' },
180
- },
181
- ];
182
-
183
- /**
184
- * Can this repo actually run the gate?
185
- *
186
- * A declared gate that cannot run is worse than an absent one. Absent, an author sees
187
- * nothing and knows nothing is being checked; declared-and-unrunnable, the dispatcher
188
- * fails closed on every commit and the only exit an author can find is `--no-verify`,
189
- * which teaches the whole team that the gate is theatre. The dispatcher is right to
190
- * fail closed — the defect is declaring a gate this repo has no way to run.
191
- *
192
- * The check mirrors what the dispatcher actually attempts:
193
- * - `script` gates run `<manager> run <script>`, so the script must exist
194
- * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
195
- * installed, not merely declared as a dependency
196
- * - `command` gates run whatever the manifest's `gates.commands` declares, so a
197
- * command gate with no declared command is unrunnable by definition
198
- *
199
- * @param {object} gate an entry from GATES in src/hooks/gates.mjs
200
- * @param {{scripts?: Record<string,string>, binaries?: string[], commands?: Record<string,string>}} capability
201
- */
202
- export function gateRunnable(gate, capability = {}) {
203
- if (!gate) return { runnable: false, why: 'unknown gate' };
204
- // Only a KNOWN non-npm runner changes the wording. A capability that does not report
205
- // its runner (a hand-built one, a caller from an older version) is treated as npm,
206
- // because claiming a repo has no package.json is a much worse guess than assuming it.
207
- const foreign = isForeignRunner(capability.runner);
208
-
209
- // A declared command satisfies the gate whatever its kind, because that is the
210
- // dispatcher's own precedence and the two must never disagree about one manifest.
211
- const declared = capability.commands?.[gate.name];
212
- if (declared) {
213
- return declared.trim() === ''
214
- ? { runnable: false, why: `gates.commands["${gate.name}"] is empty` }
215
- : { runnable: true, why: `runs: ${declared}` };
216
- }
217
-
218
- if (gate.kind === 'builtin') {
219
- // The one kind with no external requirement: the implementation ships inside
220
- // the binary that is reading this manifest (ADR-100). Saying otherwise would
221
- // make `check` report a gate as unrunnable that runs on every commit.
222
- return { runnable: true, why: 'runs inside the tool itself; nothing to install' };
223
- }
224
- if (gate.kind === 'command') {
225
- return { runnable: false, why: `no command is declared for it in gates.commands` };
226
- }
227
- if (gate.kind === 'script') {
228
- const script = gate.script ?? gate.name;
229
- // Q31: the runnable sentence names the repo's own runner, never a bare npm
230
- // assumption — the card, the dispatcher and this check read one resolver.
231
- const spell = runScript(capability.runner ?? 'npm', script);
232
- if (capability.scripts?.[script]) return { runnable: true, why: `runs: ${spell}` };
233
- // Saying "no script in package.json" about a repo with no package.json states a
234
- // fact about a file that does not exist and implies a fix that cannot be taken.
235
- return {
236
- runnable: false,
237
- why: foreign
238
- ? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
239
- : `no "${script}" script in package.json`,
240
- };
241
- }
242
- // staged: a formatter must actually be installed
243
- for (const tool of gate.tools ?? []) {
244
- if (!(capability.binaries ?? []).includes(tool)) {
245
- return {
246
- runnable: false,
247
- why: foreign
248
- ? `${tool} is not installed, and this repo has no node_modules/.bin to install it into`
249
- : `${tool} is not installed in node_modules/.bin`,
250
- };
251
- }
252
- }
253
- return { runnable: true, why: `formats staged files with ${(gate.tools ?? []).join(', ')}` };
254
- }
255
-
256
- /**
257
- * What the reader has to do to make a dropped gate runnable.
258
- *
259
- * A drop is a decision the tool made on the repo's behalf, so it owes the reader the
260
- * undo. Every remedy is one of three real choices — install the tool, add the script,
261
- * or name the command — because "this repo cannot run format" with no next step reads
262
- * as simt-dev deciding formatting does not matter here.
263
- *
264
- * The two npm-shaped remedies only apply where npm is the runner. In a Gradle, Python or
265
- * Rust repo the honest undo is the `command` gate: the repo names its own program, which
266
- * is the same escape hatch ConnectIQ memory budgets already use.
267
- */
268
- function gateRemedy(gate, { existing = false, runner = null } = {}) {
269
- const name = gate?.name ?? 'the gate';
270
- const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
271
- const foreign = isForeignRunner(runner);
272
- // Q31: a missing tool in a Node repo names the repo's own installer —
273
- // `pnpm add -D`, not `npm i -D` — and anything elsewhere keeps the honest
274
- // undo: name the program under `gates.commands`.
275
- const addDev = { npm: 'npm i -D', pnpm: 'pnpm add -D', yarn: 'yarn add -D', bun: 'bun add -d' }[runner] ?? null;
276
- const fix =
277
- npmShaped && foreign
278
- ? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
279
- : gate?.kind === 'staged'
280
- ? addDev
281
- ? `install ${gate.tools.join(', ')} as a dev dependency: ${addDev} ${gate.tools.join(' ')} (the gate runs from the pinned dependency)`
282
- : `install ${gate.tools.join(', ')} as a dev dependency`
283
- : gate?.kind === 'script'
284
- ? `add a "${gate.script ?? name}" script to package.json`
285
- : gate?.kind === 'command'
286
- ? `declare the program under "gates.commands" in ${MANIFEST_FILE}`
287
- : `remove "${name}" from the gates list in ${MANIFEST_FILE}`;
288
- // A re-init never re-adds a gate to a manifest that already has one, so "install the
289
- // tool and re-run init" would send the reader round the loop and back to where they
290
- // started. On an existing manifest the declaration is the second half of the fix.
291
- const second =
292
- npmShaped && foreign
293
- ? `, or add "${name}" to gates.preCommit or gates.prePush in ${MANIFEST_FILE}`
294
- : existing
295
- ? `, then add "${name}" to gates.preCommit or gates.prePush in ${MANIFEST_FILE}`
296
- : `, then re-run: ${CLI} init --yes`;
297
- return `${fix}${second}`;
298
- }
299
-
300
- /**
301
- * Is this runner one where `npm run` is not how a gate is spelled?
302
- *
303
- * `undefined` means "the caller did not report a runner", and every Node manager
304
- * (`npm`, `pnpm`, `yarn`, `bun`) spells gates as `<manager> run <script>` with
305
- * scripts living in the same `package.json`. Only a positively identified
306
- * non-Node runner changes the wording. (Q31: pnpm is Node-shaped, not foreign.)
307
- */
308
- function isForeignRunner(runner) {
309
- if (runner == null) return false;
310
- return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
311
- }
312
-
313
- /** How a reader would name the runner in a sentence. */
314
- function runnerName(runner) {
315
- return (
316
- {
317
- none: 'no recognised runner',
318
- gradle: 'Gradle',
319
- python: 'Python',
320
- npm: 'npm',
321
- pnpm: 'pnpm',
322
- yarn: 'yarn',
323
- bun: 'bun',
324
- cargo: 'Cargo',
325
- go: 'Go',
326
- }[runner] ?? runner
327
- );
328
- }
329
-
330
- /**
331
- * The stack table, filtered by what this repo can actually run.
332
- *
333
- * A declared gate that cannot run is worse than an absent one. Absent, an author sees
334
- * nothing and knows nothing is being checked; declared-and-unrunnable, the dispatcher
335
- * fails closed on every commit and the only exit an author can find is `--no-verify`,
336
- * which teaches the whole team that the gate is theatre. The dispatcher is right to
337
- * fail closed — the defect is declaring a gate this repo has no way to run.
338
- *
339
- * The filter mirrors what the dispatcher actually attempts:
340
- * - `script` gates run `npm run <script>`, so the script must exist
341
- * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
342
- * installed, not merely declared as a dependency
343
- * - `command` gates run whatever `gates.commands` declares, so a command gate with
344
- * no declared command is unrunnable by definition
345
- *
346
- * The seeds are returned alongside the plan because a fresh adoption writes them
347
- * into `gates.commands` and the reader is owed the line that says so: a command
348
- * that lands in the manifest without appearing in the plan is a silent write.
349
- *
350
- * @returns {{gates: {preCommit: string[], prePush: string[]}, dropped: {gate: string, why: string}[], commands: Record<string,string>}}
351
- */
352
- export function planGates(stack = [], capability = null, facts = []) {
353
- const proposed = defaultGates(stack, facts);
354
- if (!capability) return { gates: proposed, dropped: [], commands: {} };
355
- const seeds = ecosystemCommands(facts, capability);
356
- // A command the repo already declared wins over a seed: the seed is a default,
357
- // the declaration is a choice, and a default overwriting a choice is a re-init
358
- // that edits the repo's mind.
359
- const effective = { ...capability, commands: { ...seeds, ...capability.commands } };
360
- const dropped = [];
361
- const runnable = (list) =>
362
- list.filter((name) => {
363
- const gate = getGate(name);
364
- const verdict = gateRunnable(gate, effective);
365
- if (!verdict.runnable) {
366
- dropped.push({ gate: name, why: verdict.why, remedy: gateRemedy(gate, { runner: capability.runner }) });
367
- }
368
- return verdict.runnable;
369
- });
370
- return {
371
- gates: { preCommit: runnable(proposed.preCommit), prePush: runnable(proposed.prePush) },
372
- dropped,
373
- commands: seeds,
374
- };
375
- }
376
-
377
- /**
378
- * The canonical `gates.commands` a fresh manifest of this repository should carry.
379
- *
380
- * Two probes, and both must pass before a command is declared: the fact says the
381
- * ecosystem is in the repository, and `capability.tools` says its program can run
382
- * on this machine. A marker file without a toolchain would otherwise declare a
383
- * gate that fails closed on every commit — the defect ADR-017 exists to prevent,
384
- * reached from the ecosystem table rather than the stack table.
385
- *
386
- * Pure, so `planGates` and `buildManifest` answer with one implementation.
387
- */
388
- function ecosystemCommands(facts = [], capability = null) {
389
- if (!capability?.tools?.length) return {};
390
- // A Node runner (npm/pnpm/yarn/bun) already answers "how do I test and format"
391
- // with its own package.json scripts, and a seed only wins over those scripts when
392
- // the repo has not declared a command of its own — which is exactly the mixed
393
- // repos (package.json AND Cargo.toml) where the script is the deliberate answer.
394
- // Seeding there would replace a choice with a default; the reader's escape hatch
395
- // is declaring gates.commands by hand, which a seed would lose to anyway. Pure
396
- // ecosystem repos have runner 'cargo'/'go'/... and seed normally.
397
- // Q31: every Node manager skips seeding — pnpm is not "foreign", it is Node.
398
- if (['npm', 'pnpm', 'yarn', 'bun'].includes(capability.runner)) return {};
399
- const present = new Set(facts);
400
- const commands = {};
401
- for (const eco of ECOSYSTEMS) {
402
- if (!present.has(eco.fact)) continue;
403
- if (!capability.tools.includes(eco.tool)) continue;
404
- for (const [gate, command] of Object.entries(eco.commands)) {
405
- if (!capability.commands?.[gate]) commands[gate] = command;
406
- }
407
- }
408
- return commands;
409
- }
410
-
411
- /**
412
- * Build a manifest from a fingerprint. Pure, so the whole default surface is
413
- * testable without a filesystem.
414
- *
415
- * When `capability` is supplied, newly proposed gates the repo cannot run are dropped.
416
- * Gates the repo ALREADY declared are kept regardless: that is the repo's choice to
417
- * make, and quietly disarming a gate somebody declared on purpose is a worse surprise
418
- * than a visible one.
419
- */
420
76
  export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
421
- const gates = defaultGates(stack, facts);
77
+ const gates = defaultGates(stack, facts, root);
422
78
  const limits = defaultLimits();
423
79
 
424
80
  // A re-init must never silently weaken a repo's own declaration: existing
425
81
  // limits and gates win over defaults, and only the projection stamp is refreshed.
426
82
  const limitsFinal = existing?.limits ? { ...limits, ...existing.limits } : limits;
427
- const planned = planGates(stack, capability, facts);
83
+ const planned = planGates(stack, capability, facts, root);
428
84
  // The seeds land only on a FRESH manifest. On an existing one the repo's
429
85
  // declaration is the answer, and adding commands under it would be a re-init
430
86
  // writing into a field the reader owns — the same rule as the gates above.
@@ -530,91 +186,11 @@ function withRepoKeys(existing, owned) {
530
186
  *
531
187
  * @returns {{file: string, status: string, gate: string, detail: string, remedy: string}[]}
532
188
  */
533
- export function unrunnableGates(manifest, capability) {
534
- const declared = [
535
- ...(manifest?.gates?.preCommit ?? []),
536
- ...(manifest?.gates?.prePush ?? []),
537
- ];
538
- const findings = [];
539
- const seen = new Set();
540
- // `gates.cwd` names a directory a gate runs in, and a name that is not a declared
541
- // gate is as much a landmine as one that cannot run: the entry is inert, the reader
542
- // believes the gate runs in the package it names, and nothing anywhere says so. Same
543
- // shape as the unknown-gate finding in the dispatcher, so both are repaired the same
544
- // way.
545
- const cwds = manifest?.gates?.cwd;
546
- if (cwds && typeof cwds === 'object' && !Array.isArray(cwds)) {
547
- for (const name of Object.keys(cwds).sort()) {
548
- if (declared.includes(name)) continue;
549
- findings.push({
550
- file: MANIFEST,
551
- status: 'unknown-gate-cwd',
552
- gate: name,
553
- detail: `gates.cwd names "${name}", which is not declared in gates.preCommit or gates.prePush, so it is never read`,
554
- remedy: `declare "${name}" in gates.preCommit or gates.prePush, or remove gates.cwd["${name}"]`,
555
- });
556
- }
557
- }
558
- for (const name of declared) {
559
- if (seen.has(name)) continue;
560
- seen.add(name);
561
- const gate = getGate(name);
562
- const verdict = gateRunnable(gate, capability);
563
- if (verdict.runnable) continue;
564
- findings.push({
565
- file: MANIFEST,
566
- status: 'unrunnable',
567
- gate: name,
568
- detail: `declares gate "${name}", which this repo cannot run: ${verdict.why}`,
569
- remedy: gateRemedy(gate, { existing: true, runner: capability.runner }),
570
- });
571
- }
572
- return findings;
573
- }
574
-
575
- /** ADR-005: 400 is a review trigger, 1000 a hard ceiling. Generated files exempt. */
576
189
  function defaultLimits() {
577
190
  return { coverage: null, fileLines: { warn: 400, error: 1000 } };
578
191
  }
579
192
 
580
193
  /** ADR-006: coverage stays null. Set it only if the repo has a real numeric gate. */
581
- function defaultGates(stack, facts = []) {
582
- // Tags and facts are one signal set for gate purposes: a row in `GATES_BY_STACK`
583
- // keys on a tag, a row in `ECOSYSTEMS` on a fact, and the two never name each
584
- // other's vocabulary (facts are `has-*`, tags are not), so one union cannot
585
- // cross-match.
586
- const signals = new Set([...stack, ...facts]);
587
- const gates = { preCommit: [], prePush: [] };
588
-
589
- for (const row of GATES_BY_STACK) {
590
- if (row.when.some((tag) => signals.has(tag))) {
591
- gates.preCommit.push(...row.preCommit);
592
- gates.prePush.push(...row.prePush);
593
- }
594
- }
595
- for (const row of ECOSYSTEMS) {
596
- if (signals.has(row.fact)) {
597
- // A row proposes what it has: the Maven row has no preCommit gates, so a
598
- // missing key is "none", not a defect. Spreading undefined throws.
599
- gates.preCommit.push(...(row.preCommit ?? []));
600
- gates.prePush.push(...(row.prePush ?? []));
601
- }
602
- }
603
- for (const row of TAG_GATES) {
604
- if (signals.has(row.tag)) gates.preCommit.push(...row.preCommit);
605
- }
606
-
607
- // A repo with no recognised stack still gets a gate list, because a gate that
608
- // runs nothing is indistinguishable from a gate that was never installed.
609
- if (gates.preCommit.length === 0) gates.preCommit.push('format', 'lint');
610
- if (gates.prePush.length === 0) gates.prePush.push('test');
611
-
612
- return { preCommit: dedupe(gates.preCommit), prePush: dedupe(gates.prePush) };
613
- }
614
-
615
- function dedupe(list) {
616
- return [...new Set(list)];
617
- }
618
194
 
619
195
  /**
620
196
  * A repo id is the directory name. Using it rather than a guessed field keeps
@@ -735,7 +311,6 @@ export function declaredShared(manifest) {
735
311
  // marked generated, and a second list here is the drift this module exists to end.
736
312
  export const GENERATED_KEYS = GENERATED_KEYS_OF;
737
313
 
738
-
739
314
  // The sub-keys of `projection` this tool writes. `projection` is a generated key, but
740
315
  // not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
741
316
  // one that shipped), and a repair that replaces the object deletes them (review 0003 Q26).
@@ -907,4 +482,4 @@ export async function writeManifest(root, manifest, journal = null) {
907
482
  if (journal) await journaledWriteFile(root, journal, fresh, manifestText(manifest));
908
483
  else await writeFile(fresh, manifestText(manifest));
909
484
  return fresh;
910
- }
485
+ }
@@ -29,6 +29,7 @@ import { fileURLToPath } from 'node:url';
29
29
 
30
30
  import { parse } from './markers.mjs';
31
31
  import { MANIFEST_FILE } from '../name.mjs';
32
+ import { parseFrontmatter as splitBlock, stripFrontmatter as stripBlock } from '../text/frontmatter.mjs';
32
33
 
33
34
  // ROOT is computed here rather than imported: project.mjs is the caller of every
34
35
  // function below, so importing it back would create a cycle for the sake of one
@@ -134,9 +135,8 @@ export async function loadMdcRules() {
134
135
  * rather than skipped, because a silently-dropped substitution projects a rule
135
136
  * containing a literal `{{placeholder}}`. */
136
137
  function parseSubstitutions(text) {
137
- const m = /^---\n([\s\S]*?)\n---/.exec(text);
138
- if (!m) return [];
139
- const block = m[1];
138
+ const { front: block } = splitBlock(text);
139
+ if (block === null) return [];
140
140
  if (!/^simt-substitutions:/m.test(block)) return [];
141
141
 
142
142
  const out = [];
@@ -168,10 +168,10 @@ function parseSubstitutions(text) {
168
168
  }
169
169
 
170
170
  function parseFrontmatter(text) {
171
- const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
172
- if (!m) return {};
171
+ const { front } = splitBlock(text);
172
+ if (front === null) return {};
173
173
  const out = {};
174
- for (const line of m[1].split('\n')) {
174
+ for (const line of front.split('\n')) {
175
175
  // `simt-substitutions:` opens a nested block this parser cannot represent, so
176
176
  // it and its list items are skipped here and read by parseSubstitutions.
177
177
  if (/^\s*-\s/.test(line)) continue;
@@ -201,8 +201,7 @@ function unquote(s) {
201
201
  }
202
202
 
203
203
  function stripFrontmatter(text) {
204
- return text
205
- .replace(/^---\n[\s\S]*?\n---\n*/, '')
204
+ return stripBlock(text)
206
205
  .replace(/^#\s+.*\n+/, '')
207
206
  .trim();
208
207
  }