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