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,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
|
+
}
|