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,268 @@
1
+ // Run the gates a repo declares for a hook event.
2
+ //
3
+ // Design commitments, all of them load-bearing:
4
+ //
5
+ // Fail closed, never fail open. A gate that cannot run is a failure, not a
6
+ // skip. A repository removed tests from pre-commit because they were slow, and
7
+ // the documented consequence was that authors reached for --no-verify. A
8
+ // dispatcher that skips what it cannot do reproduces exactly that.
9
+ //
10
+ // Never weaken a control to make a hook pass. If a gate fails, the gate
11
+ // fails. There is no "warn and continue" mode, and no gate can be downgraded
12
+ // from inside the hook - only from the manifest, where a reviewer sees it.
13
+ //
14
+ // Format and lint are staged-only. (ADR-004) Formatting the whole working
15
+ // tree from a pre-commit hook would rewrite files the author did not stage and
16
+ // did not ask to change.
17
+ //
18
+ // Report what did not run. The summary names every gate, its disposition and
19
+ // its reason, so "passed" can never be mistaken for "only some of it passed".
20
+
21
+ import { execFile } from 'node:child_process';
22
+ import { readFile } from 'node:fs/promises';
23
+ import { basename, join } from 'node:path';
24
+ import { promisify } from 'node:util';
25
+
26
+ import { GATES } from './gates.mjs';
27
+ import { resolveGate } from './resolve.mjs';
28
+ import { CLI } from '../name.mjs';
29
+ import { MANIFEST_FILE } from '../name.mjs';
30
+
31
+ const exec = promisify(execFile);
32
+
33
+ /** Gates that are known but not required for the event to pass. */
34
+ export const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
35
+
36
+ /**
37
+ * @param {string} event 'pre-commit' | 'pre-push'
38
+ * @param {object} ctx
39
+ * @param {string} ctx.root
40
+ * @param {object} ctx.manifest parsed simt-dev.json, or null
41
+ * @returns {Promise<{event: string, gates: object[], failed: boolean}>}
42
+ */
43
+ export async function runGates(event, { root, manifest }) {
44
+ const key = DEFAULT_EVENTS[event];
45
+ if (key === undefined) {
46
+ return { event, gates: [], failed: true, error: `unknown hook event ${event}` };
47
+ }
48
+
49
+ const declared = manifest?.gates?.[key];
50
+ if (!Array.isArray(declared) || declared.length === 0) {
51
+ // An array, not a string. This field sat next to `gates` as a bare string while
52
+ // the caller iterated it, so an empty gate list printed one note line per
53
+ // character — 180 lines of noise in a hook whose only job is to be readable.
54
+ // Only reachable on a repo that has no formatter and no linter, so the one path
55
+ // where an author's patience is already spent.
56
+ return {
57
+ event,
58
+ gates: [],
59
+ failed: false,
60
+ notes: [
61
+ `nothing runs on ${event}: ${MANIFEST_FILE} declares no gates.${key}. That is a valid ` +
62
+ `answer when the repo has no tool for any of them - \`${CLI} check\` reports each ` +
63
+ `gate it could not run and why - but it is also what an unfiltered default looks like.`,
64
+ ],
65
+ };
66
+ }
67
+
68
+ const pkg = await readPkg(root);
69
+ const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
70
+ const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
71
+ const commands = manifest?.gates?.commands;
72
+ const results = [];
73
+ // What the format gate rewrote, so restage touches those files and nothing else.
74
+ let formatted = [];
75
+
76
+ for (const name of declared) {
77
+ const gate = GATES[name];
78
+ if (gate === undefined) {
79
+ results.push({
80
+ name,
81
+ status: 'failed',
82
+ detail: `unknown gate; the shared dispatcher knows: ${Object.keys(GATES).sort().join(', ')}`,
83
+ remedy: `remove "${name}" from ${MANIFEST_FILE} gates.${key}`,
84
+ adr: null,
85
+ });
86
+ continue;
87
+ }
88
+
89
+ const resolution = await resolveGate(gate, { root, pkg, staged, commands });
90
+ if (!resolution.ok) {
91
+ results.push({
92
+ name,
93
+ status: 'failed',
94
+ detail: resolution.reason,
95
+ remedy: resolution.remedy,
96
+ adr: gate.adr,
97
+ });
98
+ continue;
99
+ }
100
+ if (resolution.argv === null) {
101
+ results.push({ name, status: 'skipped', detail: resolution.detail, adr: gate.adr, exitCode: null });
102
+ continue;
103
+ }
104
+
105
+ // A formatter rewrites the WORKING TREE copy of the files it is given, so
106
+ // handing it a file that also has unstaged changes destroys work the author
107
+ // staged around deliberately. `git add -p` exists so a commit can carry part
108
+ // of a file; formatting first and re-adding the whole thing silently throws
109
+ // the rest away, which is ADR-004's exact prohibition on the one path that
110
+ // runs on every commit. Refuse rather than stash: a hook that guesses what the
111
+ // author meant is worse than a hook that says so.
112
+ if (gate.kind === 'staged') {
113
+ // The resolved staged tool is handed an explicit file list, so the targets are
114
+ // known. A `gates.commands` declaration is an arbitrary command line - it may
115
+ // take a directory or a glob rather than paths - so every staged file with
116
+ // unstaged changes is treated as at risk, because nothing here can tell.
117
+ const targets = resolution.declared === true ? staged : resolution.argv.slice(2);
118
+ const conflicts = targets.filter((f) => unstaged.has(f));
119
+ if (conflicts.length > 0) {
120
+ results.push({
121
+ name,
122
+ status: 'failed',
123
+ detail: `${conflicts.length} staged file(s) also have unstaged changes: ${conflicts.join(', ')}`,
124
+ remedy:
125
+ `stage the whole file (git add ${conflicts[0]}) so the formatter sees all of it, ` +
126
+ `or run the formatter yourself and stage the result`,
127
+ adr: gate.adr,
128
+ });
129
+ continue;
130
+ }
131
+ formatted = targets;
132
+ }
133
+
134
+ const outcome = await run(resolution.argv, root);
135
+ results.push({
136
+ name,
137
+ status: outcome.code === 0 ? 'passed' : 'failed',
138
+ detail: `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
139
+ output: outcome.code === 0 ? '' : tail(outcome.stderr || outcome.stdout),
140
+ adr: gate.adr,
141
+ exitCode: outcome.code,
142
+ });
143
+ }
144
+
145
+ // A formatter that rewrote staged files must re-stage them, or the commit
146
+ // records the unformatted bytes while the working tree holds the formatted
147
+ // ones. This is the one case where the hook mutates the index on purpose.
148
+ //
149
+ // Only the files the formatter was actually given, and only when their staged
150
+ // bytes really moved. This used to `git add` every staged path from
151
+ // `--name-only` with no `--diff-filter`, so a commit containing a deletion made
152
+ // the add fail, the catch swallowed it, and the hook reported success while the
153
+ // commit recorded unformatted bytes. It also claimed to have re-staged files
154
+ // whenever anything was staged, formatted or not.
155
+ if (results.some((r) => r.name === 'format' && r.status === 'passed')) {
156
+ const outcome = await restage(root, formatted);
157
+ if (outcome.error) {
158
+ results.push({
159
+ name: 'restage',
160
+ status: 'failed',
161
+ detail: `the formatter passed but its output could not be staged: ${outcome.error}`,
162
+ remedy: 'stage the formatted files yourself and commit again',
163
+ adr: null,
164
+ exitCode: null,
165
+ });
166
+ } else if (outcome.restaged.length > 0) {
167
+ results.push({
168
+ name: 'restage',
169
+ status: 'passed',
170
+ detail: `re-staged ${outcome.restaged.length} file(s) the formatter rewrote: ${outcome.restaged.join(', ')}`,
171
+ adr: null,
172
+ exitCode: 0,
173
+ });
174
+ }
175
+ }
176
+
177
+ return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
178
+ }
179
+
180
+ async function run(argv, cwd) {
181
+ try {
182
+ const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
183
+ return { code: 0, stdout, stderr };
184
+ } catch (error) {
185
+ return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
186
+ }
187
+ }
188
+
189
+ async function readPkg(root) {
190
+ try {
191
+ return JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
198
+ export async function stagedFiles(root) {
199
+ try {
200
+ const { stdout } = await exec('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { cwd: root });
201
+ return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Paths whose working-tree copy differs from the index - the ones a partial stage
209
+ * leaves behind. Read as a Set because every stage check is a membership test over
210
+ * the same staged list, and this is asked once per run.
211
+ *
212
+ * A path staged as DELETED is not here: the index and the working tree agree that
213
+ * it is gone, so there is no unstaged work to protect and no reason to refuse.
214
+ */
215
+ export async function unstagedFiles(root) {
216
+ try {
217
+ const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
218
+ return new Set(stdout.split('\n').map((s) => s.trim()).filter(Boolean));
219
+ } catch {
220
+ return new Set();
221
+ }
222
+ }
223
+
224
+ /** The staged blob hash for each path, or an empty map if git cannot answer. */
225
+ async function stagedBlobs(root, files) {
226
+ if (files.length === 0) return new Map();
227
+ try {
228
+ const { stdout } = await exec('git', ['ls-files', '-s', '--', ...files], { cwd: root });
229
+ const blobs = new Map();
230
+ for (const line of stdout.split('\n')) {
231
+ const [meta, ...rest] = line.split('\t');
232
+ if (rest.length === 0) continue;
233
+ const [, sha] = meta.trim().split(/\s+/);
234
+ blobs.set(rest.join('\t'), sha ?? null);
235
+ }
236
+ return blobs;
237
+ } catch {
238
+ return new Map();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Stage the formatter's output, and report which paths actually moved.
244
+ *
245
+ * The before/after blob comparison is the whole point: a formatter that rewrote
246
+ * nothing is a pass with nothing to report, and a hook that claims otherwise has
247
+ * trained its readers to distrust the lines that matter.
248
+ */
249
+ async function restage(root, files) {
250
+ if (files.length === 0) return { restaged: [], error: null };
251
+ const before = await stagedBlobs(root, files);
252
+ try {
253
+ await exec('git', ['add', '--', ...files], { cwd: root });
254
+ } catch (error) {
255
+ return { restaged: [], error: String(error.stderr ?? error.message ?? error).trim() };
256
+ }
257
+ const after = await stagedBlobs(root, files);
258
+ const restaged = files.filter((f) => before.get(f) !== after.get(f));
259
+ return { restaged, error: null };
260
+ }
261
+
262
+ /** Last few lines of output, for a failure a human or agent has to act on. */
263
+ function tail(text, lines = 12) {
264
+ const parts = String(text).trimEnd().split('\n');
265
+ return parts.slice(-lines).join('\n');
266
+ }
267
+
268
+ export const __internals = { tail, basename };
@@ -0,0 +1,144 @@
1
+ import { MANIFEST_FILE } from '../name.mjs';
2
+ // Gate definitions for the shared hook dispatcher.
3
+ //
4
+ // A gate has one of three kinds, and the choice is not cosmetic:
5
+ //
6
+ // staged run a formatter over the STAGED FILES ONLY, then re-stage them.
7
+ // Whole-repo formatting from a pre-commit hook would rewrite
8
+ // unstaged work the author did not ask to touch.
9
+ // script run a script the repo already declares. Correct for anything
10
+ // global: tests, typecheck, coverage.
11
+ // command run a command the MANIFEST declares. Correct for gates that are
12
+ // genuinely repo-specific - ConnectIQ memory budgets, codegen
13
+ // drift, i18n completeness - because the central dispatcher cannot
14
+ // know what monkeyc or a Kotlin script path looks like in one repo
15
+ // and something else in another. Plan 0001 section 3.2 keeps repo
16
+ // commands and paths local; this is that ruling in code.
17
+ //
18
+ // ADR-010 note: no gate invokes `npx` or `npm exec`. Script gates go through
19
+ // `npm run`, and staged gates resolve the formatter from the project's own
20
+ // `node_modules/.bin`, which is the declared dependency's binary rather than
21
+ // one fetched at hook time.
22
+
23
+ export const GATE_KINDS = new Set(['staged', 'script', 'command']);
24
+
25
+ /** Extensions a `staged` formatter gate will hand to the tool. */
26
+ export const FORMATTABLE = new Set([
27
+ '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.json', '.jsonc',
28
+ '.css', '.less', '.scss', '.html', '.md', '.yml', '.yaml', '.graphql',
29
+ ]);
30
+
31
+ /**
32
+ * @typedef {{name: string, kind: 'staged'|'script'|'command', adr: string,
33
+ * script?: string, tools?: string[], why: string}} Gate
34
+ */
35
+
36
+ /** @type {Record<string, Gate>} */
37
+ export const GATES = {
38
+ format: {
39
+ name: 'format',
40
+ kind: 'staged',
41
+ adr: 'ADR-004',
42
+ tools: ['prettier'],
43
+ why: 'the hook owns format for staged files, so an author never commits unformatted work',
44
+ },
45
+ lint: {
46
+ name: 'lint',
47
+ kind: 'script',
48
+ adr: 'ADR-004',
49
+ script: 'lint',
50
+ why: 'the hook owns lint; re-running it by hand is what ADR-004 tells agents not to do',
51
+ },
52
+ typecheck: {
53
+ name: 'typecheck',
54
+ kind: 'script',
55
+ adr: 'ADR-010',
56
+ script: 'typecheck',
57
+ why: 'not hook-covered evidence: a type error is not a style error',
58
+ },
59
+ test: {
60
+ name: 'test',
61
+ kind: 'script',
62
+ adr: 'ADR-003',
63
+ script: 'test',
64
+ why: 'the focused gate every commit must pass before review',
65
+ },
66
+ coverage: {
67
+ name: 'coverage',
68
+ kind: 'script',
69
+ adr: 'ADR-006',
70
+ script: 'test:coverage',
71
+ why: 'the numeric bar is declared per repo, so this gate is opt-in rather than assumed',
72
+ },
73
+ 'i18n-completeness': {
74
+ name: 'i18n-completeness',
75
+ kind: 'command',
76
+ adr: 'ADR-010',
77
+ why: 'repo-specific: every repo spells this check differently, so the manifest declares the command',
78
+ },
79
+ memory: {
80
+ name: 'memory',
81
+ kind: 'command',
82
+ adr: 'ADR-003',
83
+ why: 'ConnectIQ memory budgets are a hard platform limit; the repo declares the script that proves it',
84
+ },
85
+ 'codegen-drift': {
86
+ name: 'codegen-drift',
87
+ kind: 'command',
88
+ adr: 'ADR-002',
89
+ why: 'a protocol change is a two-repo change; the drift check is what makes half of it fail loudly',
90
+ },
91
+ };
92
+
93
+ export function getGate(name) {
94
+ return GATES[name] ?? null;
95
+ }
96
+
97
+ /**
98
+ * A gate a repo cannot run is a FAILURE, never a skip.
99
+ *
100
+ * This is the load-bearing decision of the whole dispatcher. A gate that
101
+ * silently does nothing is indistinguishable from a gate that passed, and the
102
+ * documented consequence in this estate is an agent reaching for
103
+ * `--no-verify` - a repository removed its tests from pre-commit precisely
104
+ * because they were slow, and slow-plus-silent is what teaches everyone that
105
+ * hooks are advisory.
106
+ */
107
+ export function unresolved(name, { hasScript, binary, command, stack }) {
108
+ const gate = getGate(name);
109
+ if (gate === null) {
110
+ return {
111
+ gate: name,
112
+ reason: `unknown gate; the shared dispatcher knows: ${Object.keys(GATES).sort().join(', ')}`,
113
+ remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
114
+ };
115
+ }
116
+ if (gate.kind === 'script' && !hasScript(gate.script)) {
117
+ return {
118
+ gate: name,
119
+ reason: `no "${gate.script}" script in package.json`,
120
+ remedy: `add the script, or remove "${name}" from ${MANIFEST_FILE} gates`,
121
+ };
122
+ }
123
+ if (gate.kind === 'command' && !command) {
124
+ return {
125
+ gate: name,
126
+ reason: `no command declared for this gate in ${MANIFEST_FILE}`,
127
+ remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
128
+ };
129
+ }
130
+ if (gate.kind === 'staged' && !binary) {
131
+ return {
132
+ gate: name,
133
+ reason: `${gate.tools.join('/')} is not installed in node_modules/.bin`,
134
+ remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
135
+ };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ export function gateNames() {
141
+ return Object.keys(GATES).sort();
142
+ }
143
+
144
+ export const __internals = { FORMATTABLE, GATES };
@@ -0,0 +1,194 @@
1
+ // Resolve a gate to something executable, or explain precisely why it cannot be.
2
+ //
3
+ // Every failure mode returns a remedy the author can act on without reading
4
+ // this source. That is the difference between a hook people keep and a hook
5
+ // people disable: a hook that fails vaguely gets `--no-verify`, and once
6
+ // `--no-verify` is normal, no hook in the estate means anything.
7
+ //
8
+ // ADR-010: nothing here invokes `npx` or `npm exec`. Script gates go through
9
+ // `npm run`, and staged gates resolve the formatter from the project's own
10
+ // `node_modules/.bin` - the declared dependency's binary, not one fetched at
11
+ // hook time.
12
+
13
+ import { access } from 'node:fs/promises';
14
+ import { constants } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { FORMATTABLE, GATES, GATES as _G } from './gates.mjs';
18
+ import { MANIFEST_FILE } from '../name.mjs';
19
+
20
+ /** Gate names the shared dispatcher knows, sorted. */
21
+ export function knownGates() {
22
+ return Object.keys(GATES).sort();
23
+ }
24
+
25
+ export function gateKind(name) {
26
+ return GATES[name]?.kind ?? null;
27
+ }
28
+
29
+ export function gateWhy(name) {
30
+ return GATES[name]?.why ?? null;
31
+ }
32
+
33
+ export function gateAdr(name) {
34
+ return GATES[name]?.adr ?? null;
35
+ }
36
+
37
+ /**
38
+ * Is this path an executable file?
39
+ * @param {string} path
40
+ */
41
+ export async function isExecutable(path) {
42
+ try {
43
+ await access(path, constants.X_OK);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolve the formatter binary a `staged` gate should invoke.
52
+ *
53
+ * Prettier is required because ADR-004 makes format a staged-files obligation
54
+ * and the estate's single agreed formatter is prettier. A repo without it gets a
55
+ * failure with a remedy, never a silent pass.
56
+ */
57
+ export async function resolveStagedTool(root, gate) {
58
+ const tools = gate.tools ?? [];
59
+ const tried = [];
60
+ for (const tool of tools) {
61
+ const path = join(root, 'node_modules', '.bin', tool);
62
+ if (await isExecutable(path)) return { tool, path, tried };
63
+ tried.push(path);
64
+ }
65
+ return { tool: null, path: null, tried };
66
+ }
67
+
68
+ /** Does package.json declare this script? */
69
+ export function hasScript(pkg, script) {
70
+ if (!pkg || typeof pkg.scripts !== 'object' || pkg.scripts === null) return false;
71
+ return typeof pkg.scripts[script] === 'string' && pkg.scripts[script].length > 0;
72
+ }
73
+
74
+ /**
75
+ * Build the argv for a gate, or return a structured refusal.
76
+ *
77
+ * @param {object} gate
78
+ * @param {object} ctx
79
+ * @param {string} ctx.root
80
+ * @param {object|null} ctx.pkg parsed package.json, or null when absent
81
+ * @param {string[]} ctx.staged staged paths (empty for pre-push)
82
+ * @param {Record<string,string>|undefined} ctx.commands manifest gates.commands
83
+ * @returns {Promise<{ok: true, argv: string[], kind: string, detail: string}
84
+ * | {ok: false, gate: string, kind: string, reason: string, remedy: string}>}
85
+ */
86
+ export async function resolveGate(gate, { root, pkg, staged, commands }) {
87
+ const name = gate.name;
88
+ const declared = commands?.[name];
89
+
90
+ // A declared command wins for EVERY kind, not just `command` gates.
91
+ //
92
+ // This is what makes `gates.commands` the ecosystem escape hatch it is advertised as.
93
+ // A Gradle repo has no `test` npm script and never will, so `test` is declared as
94
+ // `command` kind instead — but the repo also has no `npm run lint` for a gate named
95
+ // `lint`, or a prettier for `format`. In each case the reader is told, by this tool's
96
+ // own remedy, to "name the program under gates.commands"; a dispatcher that then
97
+ // ignored that declaration would contradict the remedy, and so would `check`.
98
+ if (declared) {
99
+ const argv = declared.trim().split(/\s+/).filter(Boolean);
100
+ if (argv.length === 0) {
101
+ return {
102
+ ok: false,
103
+ gate: name,
104
+ kind: gate.kind,
105
+ reason: `gates.commands["${name}"] is empty`,
106
+ remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
107
+ };
108
+ }
109
+ return {
110
+ ok: true,
111
+ kind: gate.kind,
112
+ argv,
113
+ // The dispatcher has to know whether these argv entries are paths it chose or
114
+ // words it was handed. Sniffing the detail string meant the safety guard on
115
+ // partial stages would quietly stop working the day that wording changed.
116
+ declared: true,
117
+ detail: `declared command: ${declared}`,
118
+ };
119
+ }
120
+
121
+ if (gate.kind === 'staged') {
122
+ const tool = await resolveStagedTool(root, gate);
123
+ if (tool.path === null) {
124
+ return {
125
+ ok: false,
126
+ gate: name,
127
+ kind: gate.kind,
128
+ reason: `no formatter binary in node_modules/.bin (looked for ${(gate.tools ?? []).join(', ')})`,
129
+ remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
130
+ };
131
+ }
132
+ const files = staged.filter((f) => FORMATTABLE.has(extname(f)));
133
+ if (files.length === 0) {
134
+ return {
135
+ ok: true,
136
+ kind: gate.kind,
137
+ argv: null, // nothing to do; the dispatcher reports this as a pass with a count
138
+ detail: `no staged files this gate handles (looked for ${[...FORMATTABLE].join(', ')})`,
139
+ };
140
+ }
141
+ return {
142
+ ok: true,
143
+ kind: gate.kind,
144
+ argv: [tool.path, '--write', ...files],
145
+ detail: `${files.length} staged file(s) via node_modules/.bin/${tool.tool}`,
146
+ };
147
+ }
148
+
149
+ if (gate.kind === 'script') {
150
+ const script = gate.script;
151
+ if (!hasScript(pkg, script)) {
152
+ return {
153
+ ok: false,
154
+ gate: name,
155
+ kind: gate.kind,
156
+ reason: `package.json declares no "${script}" script`,
157
+ remedy: `add "${script}" to scripts, or remove "${name}" from ${MANIFEST_FILE} gates`,
158
+ };
159
+ }
160
+ return {
161
+ ok: true,
162
+ kind: gate.kind,
163
+ argv: ['npm', 'run', '--silent', script],
164
+ detail: `npm run ${script}`,
165
+ };
166
+ }
167
+
168
+ if (gate.kind === 'command') {
169
+ if (!declared) {
170
+ return {
171
+ ok: false,
172
+ gate: name,
173
+ kind: gate.kind,
174
+ reason: `no command declared for this gate in ${MANIFEST_FILE}`,
175
+ remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
176
+ };
177
+ }
178
+ }
179
+
180
+ return {
181
+ ok: false,
182
+ gate: name,
183
+ kind: 'unknown',
184
+ reason: `unknown gate kind "${gate.kind}"`,
185
+ remedy: `this is a bug in simt-dev; known gates: ${knownGates().join(', ')}`,
186
+ };
187
+ }
188
+
189
+ function extname(path) {
190
+ const dot = path.lastIndexOf('.');
191
+ return dot > 0 ? path.slice(dot).toLowerCase() : '';
192
+ }
193
+
194
+ export const __internals = { extname, FORMATTABLE };