simt-dev 0.2.0__py3-none-any.whl

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 (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,478 @@
1
+ // simt-dev.json - the one manifest per repo.
2
+ //
3
+ // One file answers every question the tool needs: what this repo is, what it is
4
+ // allowed to declare, which gates it runs, and which simt-dev produced its rules.
5
+ //
6
+ // This is where two rulings land in code:
7
+ // ADR-005 file length is two tiers (400 review trigger, 1000 ceiling) - a default,
8
+ // overridable per repo under limits.fileLines.
9
+ // ADR-006 evidence discipline is central; the NUMERIC bar is declared per repo.
10
+ // So limits.coverage defaults to null. A null is a real, valid answer:
11
+ // the evidence-discipline rules still apply, there is simply no number.
12
+ // Inventing a number here would be worse than declaring none.
13
+ //
14
+ // Every default this module produces is explained by the ADR that requires it. The
15
+ // rationale is not duplicated into the JSON (data must stay data); `simt-dev init`
16
+ // prints the reason for each value it writes.
17
+
18
+ import { readFile, rename, writeFile } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { basename, join, resolve } from 'node:path';
21
+ // gates.mjs imports nothing, so this direction cannot cycle. It is the gate table
22
+ // this file already selects from; duplicating the runnability rules here would be two
23
+ // answers to "can this repo run that gate", which is precisely the kind of drift this
24
+ // file exists to prevent elsewhere.
25
+ import { getGate } from '../hooks/gates.mjs';
26
+
27
+ // Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
28
+ // and the manifest name is used a dozen times below in remedy strings that name this
29
+ // file by name.
30
+ import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
31
+ import { CLI } from '../name.mjs';
32
+ export const MANIFEST = MANIFEST_FILE;
33
+
34
+ export { MANIFEST_LEGACY };
35
+
36
+ /**
37
+ * The manifest a repository has, which is not necessarily the one this tool would write.
38
+ *
39
+ * The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
40
+ * `join(root, MANIFEST)` and would have reported an adopted repository as unadopted the
41
+ * day the constant moved - and "no manifest" means "never adopted", which is the answer
42
+ * that offers to re-adopt a repo that is already done. Fourteen repositories carry the
43
+ * old name on disk.
44
+ *
45
+ * So the old name is read as a fallback and the new one is written. `writeManifest`
46
+ * renames rather than creating a second file, which is how the window closes: the first
47
+ * `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
48
+ */
49
+ export async function manifestPath(root) {
50
+ const next = join(root, MANIFEST);
51
+ if (existsSync(next)) return next;
52
+ const previous = join(root, MANIFEST_LEGACY);
53
+ return existsSync(previous) ? previous : next;
54
+ }
55
+
56
+
57
+ /**
58
+ * The one definition of the file's own comment, exported because `upgrade` rewrites
59
+ * the manifest and must not carry a stale copy forward. Two literals for one string
60
+ * is how the old one survived every upgrade.
61
+ *
62
+ * Every value here is read by someone who cannot see simt-dev's own tree, so this
63
+ * names only things true in the repo it lands in. An earlier version pointed at
64
+ * docs/decisions/ and claimed each value cites the ADR that requires it; no value
65
+ * cited one, so it was decoration that misdescribed the file. The provenance that IS
66
+ * real is the recorded SHA under `projection`.
67
+ */
68
+ export const MANIFEST_COMMENT = {
69
+ $comment:
70
+ `Written and maintained by simt-dev. Edit by hand only if you intend to take ownership of this file: \`${CLI} sync --yes\` regenerates the projected rules, and re-running \`${CLI} init\` refreshes the SHA without weakening a value you set here.`,
71
+ };
72
+
73
+ /**
74
+ * Gate names the shared dispatcher knows how to run (step 8). Choosing these from
75
+ * the detected stack rather than asking a human keeps `init` non-interactive.
76
+ */
77
+ const GATES_BY_STACK = [
78
+ {
79
+ when: ['gradle', 'kotlin', 'connectiq'],
80
+ preCommit: ['format', 'lint', 'i18n-completeness'],
81
+ prePush: ['test'],
82
+ },
83
+ {
84
+ when: ['python'],
85
+ preCommit: ['format', 'lint', 'i18n-completeness'],
86
+ prePush: ['test', 'coverage'],
87
+ },
88
+ {
89
+ when: ['node', 'node-web', 'cloudflare-workers', 'typescript'],
90
+ preCommit: ['format', 'lint'],
91
+ prePush: ['test', 'typecheck'],
92
+ },
93
+ ];
94
+
95
+ /** Extra gate implied by a specific tag, applied after the table above. */
96
+ const TAG_GATES = [
97
+ { tag: 'connectiq', preCommit: ['memory', 'codegen-drift'] },
98
+ { tag: 'hono', preCommit: ['i18n-completeness'] },
99
+ ];
100
+
101
+ /**
102
+ * Can this repo actually run the gate?
103
+ *
104
+ * A declared gate that cannot run is worse than an absent one. Absent, an author sees
105
+ * nothing and knows nothing is being checked; declared-and-unrunnable, the dispatcher
106
+ * fails closed on every commit and the only exit an author can find is `--no-verify`,
107
+ * which teaches the whole team that the gate is theatre. The dispatcher is right to
108
+ * fail closed — the defect is declaring a gate this repo has no way to run.
109
+ *
110
+ * The check mirrors what the dispatcher actually attempts:
111
+ * - `script` gates run `npm run <script>`, so the script must exist
112
+ * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
113
+ * installed, not merely declared as a dependency
114
+ * - `command` gates run whatever the manifest's `gates.commands` declares, so a
115
+ * command gate with no declared command is unrunnable by definition
116
+ *
117
+ * @param {object} gate an entry from GATES in src/hooks/gates.mjs
118
+ * @param {{scripts?: Record<string,string>, binaries?: string[], commands?: Record<string,string>}} capability
119
+ */
120
+ export function gateRunnable(gate, capability = {}) {
121
+ if (!gate) return { runnable: false, why: 'unknown gate' };
122
+ // Only a KNOWN non-npm runner changes the wording. A capability that does not report
123
+ // its runner (a hand-built one, a caller from an older version) is treated as npm,
124
+ // because claiming a repo has no package.json is a much worse guess than assuming it.
125
+ const foreign = isForeignRunner(capability.runner);
126
+
127
+ // A declared command satisfies the gate whatever its kind, because that is the
128
+ // dispatcher's own precedence and the two must never disagree about one manifest.
129
+ const declared = capability.commands?.[gate.name];
130
+ if (declared) {
131
+ return declared.trim() === ''
132
+ ? { runnable: false, why: `gates.commands["${gate.name}"] is empty` }
133
+ : { runnable: true, why: `runs: ${declared}` };
134
+ }
135
+
136
+ if (gate.kind === 'command') {
137
+ return { runnable: false, why: `no command is declared for it in gates.commands` };
138
+ }
139
+ if (gate.kind === 'script') {
140
+ const script = gate.script ?? gate.name;
141
+ if (capability.scripts?.[script]) return { runnable: true, why: `runs: npm run ${script}` };
142
+ // Saying "no script in package.json" about a repo with no package.json states a
143
+ // fact about a file that does not exist and implies a fix that cannot be taken.
144
+ return {
145
+ runnable: false,
146
+ why: foreign
147
+ ? `this repo runs ${runnerName(capability.runner)}, not npm, so "npm run ${script}" cannot run here`
148
+ : `no "${script}" script in package.json`,
149
+ };
150
+ }
151
+ // staged: a formatter must actually be installed
152
+ for (const tool of gate.tools ?? []) {
153
+ if (!(capability.binaries ?? []).includes(tool)) {
154
+ return {
155
+ runnable: false,
156
+ why: foreign
157
+ ? `${tool} is not installed, and this repo has no node_modules/.bin to install it into`
158
+ : `${tool} is not installed in node_modules/.bin`,
159
+ };
160
+ }
161
+ }
162
+ return { runnable: true, why: `formats staged files with ${(gate.tools ?? []).join(', ')}` };
163
+ }
164
+
165
+ /**
166
+ * What the reader has to do to make a dropped gate runnable.
167
+ *
168
+ * A drop is a decision the tool made on the repo's behalf, so it owes the reader the
169
+ * undo. Every remedy is one of three real choices — install the tool, add the script,
170
+ * or name the command — because "this repo cannot run format" with no next step reads
171
+ * as simt-dev deciding formatting does not matter here.
172
+ *
173
+ * The two npm-shaped remedies only apply where npm is the runner. In a Gradle, Python or
174
+ * Rust repo the honest undo is the `command` gate: the repo names its own program, which
175
+ * is the same escape hatch ConnectIQ memory budgets already use.
176
+ */
177
+ function gateRemedy(gate, { existing = false, runner = null } = {}) {
178
+ const name = gate?.name ?? 'the gate';
179
+ const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
180
+ const foreign = isForeignRunner(runner);
181
+ const fix =
182
+ npmShaped && foreign
183
+ ? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
184
+ : gate?.kind === 'staged'
185
+ ? `install ${gate.tools.join(', ')} as a dev dependency`
186
+ : gate?.kind === 'script'
187
+ ? `add a "${gate.script ?? name}" script to package.json`
188
+ : gate?.kind === 'command'
189
+ ? `declare the program under "gates.commands" in ${MANIFEST_FILE}`
190
+ : `remove "${name}" from the gates list in ${MANIFEST_FILE}`;
191
+ // A re-init never re-adds a gate to a manifest that already has one, so "install the
192
+ // tool and re-run init" would send the reader round the loop and back to where they
193
+ // started. On an existing manifest the declaration is the second half of the fix.
194
+ const second =
195
+ npmShaped && foreign
196
+ ? `, or add "${name}" to gates.preCommit or gates.prePush in ${MANIFEST_FILE}`
197
+ : existing
198
+ ? `, then add "${name}" to gates.preCommit or gates.prePush in ${MANIFEST_FILE}`
199
+ : `, then re-run: ${CLI} init --yes`;
200
+ return `${fix}${second}`;
201
+ }
202
+
203
+ /**
204
+ * Is this runner one where `npm run` is not how a gate is spelled?
205
+ *
206
+ * `undefined` means "the caller did not report a runner", and `npm` means the npm
207
+ * remedy applies. Only a positively identified other ecosystem changes the wording.
208
+ */
209
+ function isForeignRunner(runner) {
210
+ return runner != null && runner !== 'npm';
211
+ }
212
+
213
+ /** How a reader would name the runner in a sentence. */
214
+ function runnerName(runner) {
215
+ return (
216
+ {
217
+ none: 'no recognised runner',
218
+ gradle: 'Gradle',
219
+ python: 'Python',
220
+ bun: 'bun',
221
+ cargo: 'Cargo',
222
+ go: 'Go',
223
+ }[runner] ?? runner
224
+ );
225
+ }
226
+
227
+ /**
228
+ * The stack table, filtered by what this repo can actually run.
229
+ *
230
+ * A declared gate that cannot run is worse than an absent one. Absent, an author sees
231
+ * nothing and knows nothing is being checked; declared-and-unrunnable, the dispatcher
232
+ * fails closed on every commit and the only exit an author can find is `--no-verify`,
233
+ * which teaches the whole team that the gate is theatre. The dispatcher is right to
234
+ * fail closed — the defect is declaring a gate this repo has no way to run.
235
+ *
236
+ * The filter mirrors what the dispatcher actually attempts:
237
+ * - `script` gates run `npm run <script>`, so the script must exist
238
+ * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
239
+ * installed, not merely declared as a dependency
240
+ * - `command` gates run whatever `gates.commands` declares, so a command gate with
241
+ * no declared command is unrunnable by definition
242
+ *
243
+ * @returns {{gates: {preCommit: string[], prePush: string[]}, dropped: {gate: string, why: string}[]}}
244
+ */
245
+ export function planGates(stack = [], capability = null) {
246
+ const proposed = defaultGates(stack);
247
+ if (!capability) return { gates: proposed, dropped: [] };
248
+ const dropped = [];
249
+ const runnable = (list) =>
250
+ list.filter((name) => {
251
+ const gate = getGate(name);
252
+ const verdict = gateRunnable(gate, capability);
253
+ if (!verdict.runnable) {
254
+ dropped.push({ gate: name, why: verdict.why, remedy: gateRemedy(gate, { runner: capability.runner }) });
255
+ }
256
+ return verdict.runnable;
257
+ });
258
+ return { gates: { preCommit: runnable(proposed.preCommit), prePush: runnable(proposed.prePush) }, dropped };
259
+ }
260
+
261
+ /**
262
+ * Build a manifest from a fingerprint. Pure, so the whole default surface is
263
+ * testable without a filesystem.
264
+ *
265
+ * When `capability` is supplied, newly proposed gates the repo cannot run are dropped.
266
+ * Gates the repo ALREADY declared are kept regardless: that is the repo's choice to
267
+ * make, and quietly disarming a gate somebody declared on purpose is a worse surprise
268
+ * than a visible one.
269
+ */
270
+ export function buildManifest({ root, stack = [], sha, existing = null, capability = null, surfaces = undefined }) {
271
+ const gates = defaultGates(stack);
272
+ const limits = defaultLimits(stack);
273
+
274
+ // A re-init must never silently weaken a repo's own declaration: existing
275
+ // limits and gates win over defaults, and only the projection stamp is refreshed.
276
+ const limitsFinal = existing?.limits ? { ...limits, ...existing.limits } : limits;
277
+ const gatesFinal = existing?.gates
278
+ ? { ...gates, ...existing.gates }
279
+ : planGates(stack, capability).gates;
280
+
281
+ return {
282
+ ...MANIFEST_COMMENT,
283
+ id: existing?.id ?? defaultId(root),
284
+ stack: existing?.stack?.length ? existing.stack : stack,
285
+ limits: limitsFinal,
286
+ gates: gatesFinal,
287
+ localSkills: existing?.localSkills ?? [],
288
+ // ADR-013: profile ids the repo wants, whether they ship centrally or live
289
+ // in `.simt-dev/profiles/`. Detection already selects the right profiles; this
290
+ // is the declaration a reviewer reads to see the repo's intent stated rather
291
+ // than inferred.
292
+ profiles: existing?.profiles ?? [],
293
+ // ADR-016: per-repo values for the canonical `.mdc` rules' declared
294
+ // substitutions, keyed by rule id. Seeded empty rather than absent so the
295
+ // shape is visible in the file before anything needs it. `__optIn` is not a
296
+ // substitution value: it lists the rules this repo declares it has a problem
297
+ // with, which is something detection cannot infer.
298
+ substitutions: existing?.substitutions ?? {},
299
+ // ADR-020: which editors this repo's rules are projected into. Absent means the
300
+ // global standard applies; an explicit empty list is a real choice meaning
301
+ // AGENTS.md only. Recorded rather than inferred, because "which IDE does this repo
302
+ // support" is a decision a reviewer should be able to read in one line — and
303
+ // because a typo here silently reduces the estate to AGENTS.md, which looks
304
+ // exactly like a working setup.
305
+ surfaces: surfaces ?? existing?.surfaces ?? null,
306
+ projection: { simtDevSha: sha ?? null },
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Declared gates this repo cannot run, as findings.
312
+ *
313
+ * `init` no longer writes them, but a manifest written by an older version still
314
+ * carries them and a hand-edited one can add them back. Leaving that to
315
+ * `hooks status` means the repo is green until someone installs hooks, at which
316
+ * point every commit fails and the first exit anyone finds is `--no-verify`.
317
+ * So the check that guards the generated rules also reports the landmine.
318
+ *
319
+ * @returns {{file: string, status: string, gate: string, detail: string, remedy: string}[]}
320
+ */
321
+ export function unrunnableGates(manifest, capability) {
322
+ const declared = [
323
+ ...(manifest?.gates?.preCommit ?? []),
324
+ ...(manifest?.gates?.prePush ?? []),
325
+ ];
326
+ const findings = [];
327
+ const seen = new Set();
328
+ for (const name of declared) {
329
+ if (seen.has(name)) continue;
330
+ seen.add(name);
331
+ const gate = getGate(name);
332
+ const verdict = gateRunnable(gate, capability);
333
+ if (verdict.runnable) continue;
334
+ findings.push({
335
+ file: MANIFEST,
336
+ status: 'unrunnable',
337
+ gate: name,
338
+ detail: `declares gate "${name}", which this repo cannot run: ${verdict.why}`,
339
+ remedy: gateRemedy(gate, { existing: true, runner: capability.runner }),
340
+ });
341
+ }
342
+ return findings;
343
+ }
344
+
345
+ /** ADR-005: 400 is a review trigger, 1000 a hard ceiling. Generated files exempt. */
346
+ function defaultLimits() {
347
+ return { coverage: null, fileLines: { warn: 400, error: 1000 } };
348
+ }
349
+
350
+ /** ADR-006: coverage stays null. Set it only if the repo has a real numeric gate. */
351
+ function defaultGates(stack) {
352
+ const tags = new Set(stack);
353
+ const gates = { preCommit: [], prePush: [] };
354
+
355
+ for (const row of GATES_BY_STACK) {
356
+ if (row.when.some((tag) => tags.has(tag))) {
357
+ gates.preCommit.push(...row.preCommit);
358
+ gates.prePush.push(...row.prePush);
359
+ }
360
+ }
361
+ for (const row of TAG_GATES) {
362
+ if (tags.has(row.tag)) gates.preCommit.push(...row.preCommit);
363
+ }
364
+
365
+ // A repo with no recognised stack still gets a gate list, because a gate that
366
+ // runs nothing is indistinguishable from a gate that was never installed.
367
+ if (gates.preCommit.length === 0) gates.preCommit.push('format', 'lint');
368
+ if (gates.prePush.length === 0) gates.prePush.push('test');
369
+
370
+ return { preCommit: dedupe(gates.preCommit), prePush: dedupe(gates.prePush) };
371
+ }
372
+
373
+ function dedupe(list) {
374
+ return [...new Set(list)];
375
+ }
376
+
377
+ /**
378
+ * A repo id is the directory name. Using it rather than a guessed field keeps
379
+ * `init` working in a directory that is not yet a git repo (step 8 scaffolds
380
+ * those), where no remote name exists to read.
381
+ */
382
+ function defaultId(root) {
383
+ // Resolve before taking the basename: `basename('.')` is `''`, so every repo adopted
384
+ // with `--root .` or a bare positional was written as `unnamed-repo`. The id is the
385
+ // one field in the manifest a human reads, and it was wrong for the most natural
386
+ // invocation of the command.
387
+ const name = basename(resolve(root));
388
+ return name === '' || name === '.' ? 'unnamed-repo' : name;
389
+ }
390
+
391
+ /**
392
+ * Is a generated field of this manifest wrong, or has a formatter taken its bytes?
393
+ *
394
+ * Two distinct defects, one shape of answer, because they share a cause: a generated
395
+ * file with no path back to being correct.
396
+ *
397
+ * 1. A STALE GENERATED FIELD. `buildManifest` emits `$comment` and
398
+ * `projection.simtDevSha`; the repo owns everything else. `upgrade` spreads the
399
+ * existing manifest and refreshes only the SHA, so a field added to the writer
400
+ * after a repo was adopted never appears — one repository had been projecting for
401
+ * several versions with no `surfaces` key, and nothing said so. Every parse-based
402
+ * comparison passes, because the file is valid and the values in it are all true.
403
+ *
404
+ * 2. REFORMATTED BYTES. A repo with a prettier hook rewrites the manifest on the
405
+ * first commit: prettier collapses short arrays onto one line,
406
+ * `JSON.stringify(m, null, 2)` always expands them. The content is identical, so
407
+ * the next `init` puts simt-dev's bytes back and the formatter takes them away
408
+ * again. `upgrade` reported `current` the whole time, which is correct: the pin
409
+ * was current. The bytes were not.
410
+ *
411
+ * Content the repo chose is never compared. A repo that edited `limits.coverage` on
412
+ * purpose is not being told to revert it.
413
+ *
414
+ * @returns {{file: string, status: string, detail: string, remedy: string}|null}
415
+ */
416
+ export async function manifestDrift(root, stack, sha) {
417
+ const { manifest, exists, error } = await readManifest(root);
418
+ if (!exists || error !== null || manifest === null) return null;
419
+
420
+ const expected = buildManifest({ root, stack, sha, existing: manifest });
421
+ const stale = [];
422
+ for (const key of Object.keys(expected)) {
423
+ const generated =
424
+ key === '$comment' ||
425
+ (key === 'projection' && JSON.stringify(expected.projection) !== JSON.stringify(manifest.projection ?? null));
426
+ if (generated && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key])) stale.push(key);
427
+ }
428
+ if (stale.length > 0) {
429
+ return {
430
+ file: MANIFEST,
431
+ status: 'stale-generated-field',
432
+ detail: `generated field(s) ${stale.join(', ')} do not match what this simt-dev writes`,
433
+ remedy: `run: ${CLI} init --yes (it refreshes the stamp without touching values this repo set)`,
434
+ };
435
+ }
436
+
437
+ // Form is compared by re-serialising the file's OWN parsed content in our shape,
438
+ // not by rebuilding the manifest from defaults. Rebuilding would also report a
439
+ // repo for an optional key it predates (`surfaces`), which is not an error: absent
440
+ // and null both mean "the global standard applies". Comparing content-to-content
441
+ // isolates the one question — does another tool own this file's formatting.
442
+ const onDisk = (await readFile(await manifestPath(root), 'utf8')).trim();
443
+ const ours = JSON.stringify(manifest, null, 2).trim();
444
+ if (onDisk === ours) return null;
445
+ return {
446
+ file: MANIFEST,
447
+ status: 'reformatted',
448
+ detail: 'content is what simt-dev would write, but the bytes differ - another formatter owns this file',
449
+ remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI} init --yes`,
450
+ };
451
+ }
452
+
453
+ /** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
454
+ export async function readManifest(root) {
455
+ const path = await manifestPath(root);
456
+ let text;
457
+ try {
458
+ text = await readFile(path, 'utf8');
459
+ } catch {
460
+ return { manifest: null, exists: false, error: null };
461
+ }
462
+ try {
463
+ return { manifest: JSON.parse(text), exists: true, error: null };
464
+ } catch (error) {
465
+ return { manifest: null, exists: true, error: `unparseable: ${error.message}` };
466
+ }
467
+ }
468
+
469
+ export async function writeManifest(root, manifest) {
470
+ // A repository still holding the old name gets it moved, not shadowed. Two manifests
471
+ // in one repo is a manifest nobody can reason about, and the second would be the one
472
+ // that is never read - so the window closes by moving the file, not by leaving both.
473
+ const previous = join(root, MANIFEST_LEGACY);
474
+ const fresh = join(root, MANIFEST);
475
+ if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
476
+ await writeFile(fresh, `${JSON.stringify(manifest, null, 2)}\n`);
477
+ return fresh;
478
+ }