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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- 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 };
|