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,331 @@
|
|
|
1
|
+
// simt-dev sync --yes — write the generated regions.
|
|
2
|
+
// simt-dev check — the gate.
|
|
3
|
+
//
|
|
4
|
+
// They share plan/project.mjs so they can never disagree about what the regions
|
|
5
|
+
// should contain. sync decides whether to act; check decides severity and exit
|
|
6
|
+
// code. sync never pushes and never commits (ADR-001).
|
|
7
|
+
//
|
|
8
|
+
// Two properties come from writing several targets rather than one:
|
|
9
|
+
//
|
|
10
|
+
// - ALL-OR-NOTHING. A malformed region in any target refuses the whole run, and
|
|
11
|
+
// the refusal happens before the first write. Appending a second region to a
|
|
12
|
+
// document with a broken one would duplicate every rule while reporting
|
|
13
|
+
// success; writing the good targets and skipping the bad one would leave the
|
|
14
|
+
// repo half-adopted with no record of which half.
|
|
15
|
+
// - EVERY WRITE IS REPORTED. Silently syncing AGENTS.md and not mentioning the
|
|
16
|
+
// three `.mdc` rules would let a reader believe a repo is fully current when
|
|
17
|
+
// only the file they happened to open is.
|
|
18
|
+
|
|
19
|
+
import { mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
21
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
22
|
+
import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
|
|
23
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
24
|
+
import { manifestDrift, readManifest, unrunnableGates } from '../projection/manifest.mjs';
|
|
25
|
+
import { readCapability } from '../hooks/capability.mjs';
|
|
26
|
+
import { surfacesLine } from '../projection/surfaces.mjs';
|
|
27
|
+
import { CLI } from '../name.mjs';
|
|
28
|
+
import { MANIFEST_FILE } from '../name.mjs';
|
|
29
|
+
|
|
30
|
+
/** Findings `sync` reports and does not fail on.
|
|
31
|
+
*
|
|
32
|
+
* `pointer-unread` is a misaimed pointer whose reader this repository serves. `sync`
|
|
33
|
+
* must not refuse on it: the remedy is `simt-dev sync --yes`, so a `sync` that blocked
|
|
34
|
+
* would be a gate whose remedy is the command being run — ADR-025 ruling 6's hazard, and
|
|
35
|
+
* worse than a noisy gate. `check` disagrees, and the reason is the whole of ADR-051.
|
|
36
|
+
*
|
|
37
|
+
* `orphaned` is here for a stronger version of the same reason. A generated rule file
|
|
38
|
+
* whose surface now folds the rule into `AGENTS.md` cannot be repaired by any
|
|
39
|
+
* command: `sync` will not write it again, and ADR-021 ruling 6 forbids deleting
|
|
40
|
+
* it. Blocking on it would put every commit in every migrated repo behind a file
|
|
41
|
+
* only a human may remove, which is a gate nothing can satisfy and therefore worse
|
|
42
|
+
* than a gate that is merely noisy. It is reported, with the delete command in the
|
|
43
|
+
* remedy, and it does not fail.
|
|
44
|
+
*
|
|
45
|
+
* A *missing* pointer is not reported at all: every editor that matters already reads
|
|
46
|
+
* `AGENTS.md`, and the pointer saves one symlink. A gate that goes red for it is a gate
|
|
47
|
+
* people learn to ignore. A *foreign* one — a real file or a real directory where the
|
|
48
|
+
* pointer belongs — is reported loudly with what is in the way, because that one is a
|
|
49
|
+
* decision somebody has to make. */
|
|
50
|
+
export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Findings `check` reports and does not fail on.
|
|
54
|
+
*
|
|
55
|
+
* A subset of `SYNC_NON_BLOCKING`, and the difference between the two sets is the whole
|
|
56
|
+
* of ADR-051. The same finding answers different questions in the two commands: `sync`
|
|
57
|
+
* writes the pointer, so refusing there names the command being run as the cure, while
|
|
58
|
+
* `check` is the gate CI runs and the one whose exit code a repository acts on — and a
|
|
59
|
+
* repository whose `CLAUDE.md` points at `README.md` has a reader reading nothing.
|
|
60
|
+
* Thirteen rule citations unread, `check` green, `compare` clean.
|
|
61
|
+
*
|
|
62
|
+
* One set covering both commands is how the defect survived: the rule was written to
|
|
63
|
+
* keep `sync` quiet, and it quietly quieted `check` too — the writer's correct
|
|
64
|
+
* non-blocking finding silencing the gate's exit code. The first version of this fix
|
|
65
|
+
* reproduced that exactly, and a test asserting `check` exited 1 passed on a
|
|
66
|
+
* repository that was red for a missing `AGENTS.md`.
|
|
67
|
+
*
|
|
68
|
+
* `orphaned` stays non-blocking in both. Nothing can satisfy it, in either command.
|
|
69
|
+
*/
|
|
70
|
+
export const CHECK_NON_BLOCKING = new Set(['orphaned']);
|
|
71
|
+
|
|
72
|
+
export async function syncCommand(positional, flags) {
|
|
73
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
74
|
+
const checkOnly = flags.check === true;
|
|
75
|
+
const mode = writeMode(flags);
|
|
76
|
+
|
|
77
|
+
const result = await plan(root, flags);
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
// A `.mdc` rule that cannot render is a defect in this build, not in the repo,
|
|
81
|
+
// and projecting the rest would leave a family half-retired.
|
|
82
|
+
if (result.mdcProblems.length > 0) {
|
|
83
|
+
if (isJson(flags)) {
|
|
84
|
+
emitJson({
|
|
85
|
+
ok: false,
|
|
86
|
+
command: 'sync',
|
|
87
|
+
root,
|
|
88
|
+
error: result.mdcProblems[0],
|
|
89
|
+
problems: result.mdcProblems,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
for (const p of result.mdcProblems) note(`PROBLEM ${p}`);
|
|
93
|
+
return fail(
|
|
94
|
+
`${result.mdcProblems.length} canonical .mdc rule(s) cannot be projected (${result.mdcProblems[0]})`,
|
|
95
|
+
EXIT.FAIL,
|
|
96
|
+
`fix the reported substitution keys in ${MANIFEST_FILE}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const broken = result.targets.filter((t) => t.action === 'malformed');
|
|
101
|
+
if (broken.length > 0) {
|
|
102
|
+
const first = broken[0];
|
|
103
|
+
if (isJson(flags)) {
|
|
104
|
+
emitJson({
|
|
105
|
+
ok: false,
|
|
106
|
+
command: 'sync',
|
|
107
|
+
root,
|
|
108
|
+
error: first.reason,
|
|
109
|
+
file: first.file,
|
|
110
|
+
line: first.line,
|
|
111
|
+
alsoBroken: broken.slice(1).map((t) => t.file),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return fail(
|
|
115
|
+
`${broken.length} file(s) have a malformed generated region (${first.file}: ${first.reason})`,
|
|
116
|
+
EXIT.FAIL,
|
|
117
|
+
`fix the markers at ${first.file}:${first.line}, then: ${CLI} sync --yes`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const n of result.notes) note(`note ${n}`);
|
|
122
|
+
|
|
123
|
+
// Pointers are written after the rule files, and only when the target they point
|
|
124
|
+
// at was actually produced. A `CLAUDE.md` pointing at an `AGENTS.md` that does not
|
|
125
|
+
// exist yet is a broken symlink, and a broken symlink reads to Claude Code as a
|
|
126
|
+
// missing CLAUDE.md with an error nobody sees.
|
|
127
|
+
const pointerPlan = [];
|
|
128
|
+
for (const p of pointerTargets(root, result.surfaces)) {
|
|
129
|
+
const st = await pointerState(root, p);
|
|
130
|
+
if (st.state === 'foreign') {
|
|
131
|
+
// The measure is what is in the way, and it differs by kind: a file is destroyed by
|
|
132
|
+
// the byte, a directory by the entry. Printing `undefined bytes` for the second
|
|
133
|
+
// is how a directory went unreported for as long as it did.
|
|
134
|
+
const size =
|
|
135
|
+
st.entries === undefined
|
|
136
|
+
? `${st.bytes} bytes`
|
|
137
|
+
: `${st.entries} ${st.entries === 1 ? 'entry' : 'entries'}`;
|
|
138
|
+
note(`POINTER ${p.file}: ${st.detail} (${size}); left untouched, not replaced`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (st.state === 'current') continue;
|
|
142
|
+
// A *mispointed* pointer is one this tool wrote and this tool may repair: it is a
|
|
143
|
+
// symlink, so it has no content, and the remedy printed by `check` for this exact
|
|
144
|
+
// finding is `simt-dev sync --yes`. Reporting a plan and then calling `symlink()` at
|
|
145
|
+
// an existing path is `EEXIST` — which is what this did, and the remedy it offered
|
|
146
|
+
// was `simt-dev doctor`, which reports nothing about it. A command whose own remedy
|
|
147
|
+
// crashes on the state it names has no remedy at all.
|
|
148
|
+
//
|
|
149
|
+
// Only a symlink is replaced. A regular file or a directory is somebody's work and
|
|
150
|
+
// was `continue`d above, which is why the distinction is `state` and not a test of
|
|
151
|
+
// existence.
|
|
152
|
+
pointerPlan.push({ ...p, replace: st.state === 'mispointed' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const targets = result.targets.map((t) => ({
|
|
156
|
+
file: t.file,
|
|
157
|
+
surface: t.surface ?? 'agents',
|
|
158
|
+
action: t.action,
|
|
159
|
+
bytes: t.bytes,
|
|
160
|
+
previousBytes: t.previousBytes,
|
|
161
|
+
}));
|
|
162
|
+
const changed = result.targets.filter((t) => t.action !== 'unchanged');
|
|
163
|
+
const pointersAdded = pointerPlan.map((p) => p.path);
|
|
164
|
+
|
|
165
|
+
// Writes happen BEFORE output in every mode. An earlier build emitted `--json`
|
|
166
|
+
// first and returned, so `upgrade --yes --json` reported a plan and changed
|
|
167
|
+
// nothing — the exact combination automation reaches for.
|
|
168
|
+
//
|
|
169
|
+
// `sync` used to be the one writer that wrote by default, which made it the odd
|
|
170
|
+
// one out among six: `init`, `upgrade`, `ci`, `hooks install` and `skills install`
|
|
171
|
+
// all preview unless told otherwise. An agent that learned the other five
|
|
172
|
+
// pattern-matched onto `sync` and got a write instead of a plan, so the preview
|
|
173
|
+
// the whole interface is built on was opt-in by command line and opt-out by habit.
|
|
174
|
+
// The rule is one function's, not this file's (ADR-035).
|
|
175
|
+
//
|
|
176
|
+
// The list above was itself wrong: `hooks install` was writing by default too,
|
|
177
|
+
// so it was the sixth writer in name only. Measured, not assumed - see ADR-040.
|
|
178
|
+
// A comment that enumerates which commands follow a rule is a claim about code
|
|
179
|
+
// this file does not own, and it was one commit out of date before anyone ran it.
|
|
180
|
+
if (mode.apply) {
|
|
181
|
+
for (const t of changed) {
|
|
182
|
+
// Three of the four rule surfaces live in directories that do not exist yet in a
|
|
183
|
+
// repository that has only ever been written to by hand. `init` grew its own
|
|
184
|
+
// mkdir; `sync` did not, so the first `sync` after a surface was added failed with
|
|
185
|
+
// ENOENT on the second target and had already written the first. The parent is
|
|
186
|
+
// derived from the target, never from the surface table, so a future surface with a
|
|
187
|
+
// deeper path needs no change here.
|
|
188
|
+
await mkdir(dirname(t.path), { recursive: true });
|
|
189
|
+
await writeFile(t.path, t.text);
|
|
190
|
+
note(`wrote ${t.file}`);
|
|
191
|
+
}
|
|
192
|
+
for (const p of pointerPlan) {
|
|
193
|
+
await mkdir(dirname(p.path), { recursive: true });
|
|
194
|
+
// Only a symlink this tool wrote is replaced, and only because it has no content:
|
|
195
|
+
// `unlink` on a symlink removes the link, never what it pointed at. A regular file
|
|
196
|
+
// or a directory never reaches here — the loop above `continue`s on both — so this
|
|
197
|
+
// cannot delete anything a person wrote.
|
|
198
|
+
if (p.replace) {
|
|
199
|
+
await unlink(p.path);
|
|
200
|
+
note(`unlinked ${p.file} (was misaimed)`);
|
|
201
|
+
}
|
|
202
|
+
await symlink(p.to, p.path);
|
|
203
|
+
note(`linked ${p.file} -> ${p.to}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isJson(flags)) {
|
|
208
|
+
emitJson(
|
|
209
|
+
{
|
|
210
|
+
// `ok` follows the exit code, which now reports drift for a previewing run
|
|
211
|
+
// too. Leaving it `true` here would break the invariant ADR-035 sets for
|
|
212
|
+
// every command, and the contract table asserts it for this one.
|
|
213
|
+
ok: changed.length === 0 && pointersAdded.length === 0 ? true : mode.apply,
|
|
214
|
+
command: checkOnly ? 'sync --check' : 'sync',
|
|
215
|
+
root,
|
|
216
|
+
stack: result.stack,
|
|
217
|
+
profiles: result.profiles,
|
|
218
|
+
surfaces: result.surfaces,
|
|
219
|
+
surfaceSource: result.surfaceSource,
|
|
220
|
+
sha: result.targets[0].sha,
|
|
221
|
+
targets,
|
|
222
|
+
pointers: pointersAdded,
|
|
223
|
+
written: mode.apply ? changed.length + pointersAdded.length : 0,
|
|
224
|
+
dryRun: !mode.apply,
|
|
225
|
+
},
|
|
226
|
+
{},
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
const lines = [
|
|
230
|
+
`repo ${root}`,
|
|
231
|
+
`stack ${result.stack.length > 0 ? result.stack.join(', ') : '(undetected)'}`,
|
|
232
|
+
`profiles ${result.profiles.join(', ')}${result.fallback ? ' (fallback)' : ''}`,
|
|
233
|
+
surfacesLine({ ids: result.surfaces, source: result.surfaceSource }, 10),
|
|
234
|
+
`sha ${result.targets[0].sha}`,
|
|
235
|
+
'',
|
|
236
|
+
...result.targets.map(
|
|
237
|
+
(t) => `${t.file} ${t.action} ${t.previousBytes} -> ${t.bytes} bytes`,
|
|
238
|
+
),
|
|
239
|
+
...pointerPlan.map((p) => `${p.file} added pointer -> ${p.to}`),
|
|
240
|
+
];
|
|
241
|
+
if (changed.length === 0 && pointerPlan.length === 0) lines.push('already current; nothing written');
|
|
242
|
+
else if (checkOnly) lines.push('would write; rerun without --check');
|
|
243
|
+
else if (!mode.apply) {
|
|
244
|
+
// The preview names the command that writes it. A preview that reports what
|
|
245
|
+
// would change and then exits without saying how to make it happen leaves the
|
|
246
|
+
// reader to guess between three spellings of the same verb.
|
|
247
|
+
lines.push(
|
|
248
|
+
flags.dryRun === true
|
|
249
|
+
? 'dry-run; nothing written'
|
|
250
|
+
: `would write ${changed.length + pointersAdded.length} file(s); apply with: ${CLI} sync --yes`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (checkOnly) return changed.length === 0 && pointerPlan.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
257
|
+
// A preview that found work is not a success: `simt-dev sync --yes` with no flags is a
|
|
258
|
+
// check that also prints the plan, and a check that exits 0 while reporting drift
|
|
259
|
+
// is the one-question-two-answers shape ADR-035 rules out. This is what lets a
|
|
260
|
+
// caller run `sync` in place of `sync --check` and get the same verdict.
|
|
261
|
+
if (!mode.apply) {
|
|
262
|
+
if (flags.dryRun === true) note('dry-run no files written');
|
|
263
|
+
return changed.length === 0 && pointerPlan.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
264
|
+
}
|
|
265
|
+
return EXIT.OK;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function checkCommand(positional, flags) {
|
|
269
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
270
|
+
const findings = await checkTargets(root, flags);
|
|
271
|
+
const rules = await loadRules(root);
|
|
272
|
+
|
|
273
|
+
// A rule file with no id would be projected without a traceable source, so it
|
|
274
|
+
// is a gate failure rather than a warning: `rules why` could not resolve it.
|
|
275
|
+
//
|
|
276
|
+
// Read for `root` and named by `source`, because the rule most likely to be
|
|
277
|
+
// broken is the one nobody in this source tree can see: with no root this loop
|
|
278
|
+
// read central rules only, so a repo-local profile could carry a misspelled key,
|
|
279
|
+
// project nothing at all, and still leave the gate green.
|
|
280
|
+
for (const r of rules) {
|
|
281
|
+
if (r.error) {
|
|
282
|
+
findings.push({ file: r.source, status: 'invalid', detail: r.error });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A declared gate this repo cannot run fails closed on every commit once hooks are
|
|
287
|
+
// installed. Reported here rather than left to `hooks status`, so the repo is red
|
|
288
|
+
// before anyone installs anything rather than after.
|
|
289
|
+
const { manifest } = await readManifest(root);
|
|
290
|
+
if (manifest) {
|
|
291
|
+
findings.push(...unrunnableGates(manifest, await readCapability(root)));
|
|
292
|
+
// A manifest a formatter has rewritten is not wrong, and not editable-away by
|
|
293
|
+
// hand either: the next `init` puts simt-dev's own bytes back and the formatter
|
|
294
|
+
// takes them away again. Reporting it here is what stops that being a surprise
|
|
295
|
+
// discovered at upgrade time.
|
|
296
|
+
const { stack } = await fingerprint(root);
|
|
297
|
+
const drift = await manifestDrift(root, stack, await provenanceDigest());
|
|
298
|
+
if (drift) findings.push(drift);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// `CHECK_NON_BLOCKING`, not the union. This is the gate whose exit code a repository
|
|
302
|
+
// acts on, so a finding it reports and does not fail on is a decision recorded in a
|
|
303
|
+
// set — not one inherited from the writer that shares this file (ADR-051).
|
|
304
|
+
const bad = findings.filter((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
|
|
305
|
+
const payload = {
|
|
306
|
+
ok: bad.length === 0,
|
|
307
|
+
command: 'check',
|
|
308
|
+
root,
|
|
309
|
+
findings,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
if (isJson(flags)) {
|
|
313
|
+
emitJson(payload, {});
|
|
314
|
+
return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Every finding that carries a remedy prints it. A finding with a fix the reader
|
|
318
|
+
// has to guess at is a finding that gets skipped.
|
|
319
|
+
emit(
|
|
320
|
+
findings
|
|
321
|
+
.flatMap((f) => {
|
|
322
|
+
const head = `${f.status.toUpperCase().padEnd(10)} ${f.file}${f.detail ? ` ${f.detail}` : ''}`;
|
|
323
|
+
return f.remedy ? [head, `${' '.repeat(11)}remedy: ${f.remedy}`] : [head];
|
|
324
|
+
})
|
|
325
|
+
.join('\n'),
|
|
326
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
327
|
+
);
|
|
328
|
+
return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export { plan, checkTargets, checkTarget, select };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// simt-dev upgrade - move every consumer to this simt-dev, as one reviewable set.
|
|
2
|
+
//
|
|
3
|
+
// Why this command exists, and why it is shaped the way it is:
|
|
4
|
+
//
|
|
5
|
+
// A simt-dev commit changes the RULES, not the mechanism. 16 repos project those rules
|
|
6
|
+
// into a generated region, and each carries a stamp of the simt-dev that produced it. So
|
|
7
|
+
// one commit makes every consumer behind at once, and the naive fix - "go run sync in
|
|
8
|
+
// each repo" - is 16 commands, 16 diffs, 16 reviews, and no way to see the set as a set.
|
|
9
|
+
// Plan 0001 named this exact risk ("simt-dev churn cascades into 16 repos") and answered
|
|
10
|
+
// it with `upgrade --all`: one operation, one reviewable diff.
|
|
11
|
+
//
|
|
12
|
+
// Four commitments:
|
|
13
|
+
//
|
|
14
|
+
// 1. It moves a PIN. It does not adopt a repo, repair a broken one, or add a region to
|
|
15
|
+
// one that never had one. Those are `init` and `sync`, with different reviews. Every
|
|
16
|
+
// such repo is a skip with its own remedy (src/upgrade/plan.mjs).
|
|
17
|
+
// 2. Dry-run unless `--yes`. It writes to N repositories; an agent that runs it to see
|
|
18
|
+
// what would change must not change it.
|
|
19
|
+
// 3. It never commits and never pushes (ADR-001). One commit in the consumer's own repo
|
|
20
|
+
// is the human's, with a message they chose.
|
|
21
|
+
// 4. A repo it could not upgrade is reported loudly and fails the run. A repo left
|
|
22
|
+
// silently behind is the failure `compare` exists to surface, and this command must
|
|
23
|
+
// not be the thing that creates it.
|
|
24
|
+
|
|
25
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
26
|
+
|
|
27
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
30
|
+
import { provenanceDigest, plan } from '../projection/project.mjs';
|
|
31
|
+
import { MANIFEST, MANIFEST_COMMENT, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
32
|
+
import { FAULT_STATES, planUpgrade, summariseUpgrade } from '../upgrade/plan.mjs';
|
|
33
|
+
import { discover } from '../compare/discover.mjs';
|
|
34
|
+
import { resolveWorkspace, targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
35
|
+
import { CLI } from '../name.mjs';
|
|
36
|
+
import { MANIFEST_FILE } from '../name.mjs';
|
|
37
|
+
|
|
38
|
+
export async function upgradeCommand(positional, flags) {
|
|
39
|
+
const explicit = flags.root ?? positional[0];
|
|
40
|
+
|
|
41
|
+
// `--all` is a claim about SCOPE, so it is checked rather than inferred. "All" of
|
|
42
|
+
// nothing is a contradiction, and defaulting it to the current directory would make
|
|
43
|
+
// the flag mean nothing at all.
|
|
44
|
+
if (flags.all === true && !explicit) {
|
|
45
|
+
const ws = await resolveWorkspace(flags);
|
|
46
|
+
if (!ws.root) {
|
|
47
|
+
return fail(
|
|
48
|
+
`--all needs a workspace and none is configured (${ws.detail})`,
|
|
49
|
+
EXIT.CONFIG,
|
|
50
|
+
WORKSPACE_REMEDY,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const target = await targetRoot(flags, positional);
|
|
56
|
+
const root = resolve(target.root);
|
|
57
|
+
const found = await discover(root);
|
|
58
|
+
if (found.error) {
|
|
59
|
+
return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
|
|
60
|
+
}
|
|
61
|
+
if (found.repos.length === 0) {
|
|
62
|
+
return fail(
|
|
63
|
+
`${root} contains no git repositories`,
|
|
64
|
+
EXIT.CONFIG,
|
|
65
|
+
'point --root at the directory holding the repos, or at one repo to upgrade just it',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sha = await provenanceDigest();
|
|
70
|
+
const mode = writeMode(flags);
|
|
71
|
+
const plans = [];
|
|
72
|
+
for (const repo of found.repos) {
|
|
73
|
+
const { manifest, exists, error } = await readManifest(repo);
|
|
74
|
+
const result = await plan(repo);
|
|
75
|
+
plans.push(planUpgrade({ name: basename(repo), path: repo, manifest, manifestExists: exists, manifestError: error, targets: result.targets, sha }));
|
|
76
|
+
}
|
|
77
|
+
const summary = summariseUpgrade(plans);
|
|
78
|
+
|
|
79
|
+
// Writes happen BEFORE the report, in every output mode. An earlier version emitted
|
|
80
|
+
// the JSON payload and returned, which meant `--yes --json` reported a plan and
|
|
81
|
+
// changed nothing - the exact combination a script uses for automation.
|
|
82
|
+
// `written` in the payload is therefore a fact about the filesystem, not an echo of
|
|
83
|
+
// the flags.
|
|
84
|
+
const applied = [];
|
|
85
|
+
const failed = [];
|
|
86
|
+
// The exact files written, collected from the writes themselves. Reconstructing the
|
|
87
|
+
// list afterwards from the plan would be a second source of truth for what happened.
|
|
88
|
+
const writtenFiles = new Set();
|
|
89
|
+
if (summary.updated > 0 && mode.apply) {
|
|
90
|
+
for (const p of plans) {
|
|
91
|
+
if (p.action !== 'updated') continue;
|
|
92
|
+
try {
|
|
93
|
+
// Region first, then the pin. The reverse order would leave a window in which the
|
|
94
|
+
// manifest claims a simt-dev whose rules are not on disk yet - and `check` would
|
|
95
|
+
// report CURRENT against a stamp that does not describe the bytes next to it.
|
|
96
|
+
// Every planned target, not just AGENTS.md: a repo can have a current AGENTS.md
|
|
97
|
+
// and three stale `.mdc` rules, and restamping only the first leaves `check` red
|
|
98
|
+
// straight after a successful upgrade.
|
|
99
|
+
const { targets } = await plan(p.path);
|
|
100
|
+
for (const t of targets) {
|
|
101
|
+
if (t.action === 'unchanged' || t.action === 'malformed') continue;
|
|
102
|
+
await mkdir(dirname(t.path), { recursive: true });
|
|
103
|
+
await writeFile(t.path, t.text);
|
|
104
|
+
writtenFiles.add(t.file);
|
|
105
|
+
}
|
|
106
|
+
const { manifest } = await readManifest(p.path);
|
|
107
|
+
// Preserve what the repo chose; refresh what simt-dev generates. $comment is
|
|
108
|
+
// ours - it describes this file's provenance - so carrying the previous copy
|
|
109
|
+
// forward left a stale, and in one version a false, description in place
|
|
110
|
+
// forever, because `sync` does not own the manifest and nothing else rewrites
|
|
111
|
+
// it. An adopted repo would never have corrected it.
|
|
112
|
+
const { $comment, ...chosen } = manifest ?? {};
|
|
113
|
+
await writeManifest(p.path, {
|
|
114
|
+
...MANIFEST_COMMENT,
|
|
115
|
+
...chosen,
|
|
116
|
+
projection: { ...(manifest?.projection ?? {}), simtDevSha: sha },
|
|
117
|
+
});
|
|
118
|
+
applied.push(p.name);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// Reported per repo rather than thrown: one unwritable repo must not hide the
|
|
121
|
+
// other fifteen, and the run must still fail so the partial set is not reviewed
|
|
122
|
+
// as if it were complete.
|
|
123
|
+
failed.push({ name: p.name, error: error.message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const payload = {
|
|
129
|
+
ok: summary.skipped === 0 && failed.length === 0,
|
|
130
|
+
command: 'upgrade',
|
|
131
|
+
root,
|
|
132
|
+
shape: found.shape,
|
|
133
|
+
source: target.source,
|
|
134
|
+
sha,
|
|
135
|
+
dryRun: !mode.apply,
|
|
136
|
+
written: applied.length > 0,
|
|
137
|
+
summary: { ...summary, applied, failed },
|
|
138
|
+
plans,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (isJson(flags)) {
|
|
142
|
+
emitJson(payload);
|
|
143
|
+
return exitFor(plans, failed);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
emit(render(plans, summary, { root, shape: found.shape, source: target.source, sha, applied, failed }));
|
|
147
|
+
|
|
148
|
+
for (const w of found.worktrees) {
|
|
149
|
+
// The same reasoning as `compare`: a worktree is its parent's HEAD elsewhere, so
|
|
150
|
+
// writing a pin into it would fork the owner's drift across two directories.
|
|
151
|
+
note(`note ${w.name} is a worktree of ${w.ownerName ?? '(unknown)'}, not upgraded separately`);
|
|
152
|
+
}
|
|
153
|
+
for (const f of failed) note(`FAILED ${f.name} ${f.error}`);
|
|
154
|
+
|
|
155
|
+
if (applied.length > 0) {
|
|
156
|
+
note(`wrote ${applied.length} repo${applied.length === 1 ? '' : 's'}: ${applied.join(', ')} (${[...writtenFiles].join(', ') || 'nothing'} + ${MANIFEST_FILE})`);
|
|
157
|
+
note('next review the diff, then commit in each repo; simt-dev never commits or pushes (ADR-001)');
|
|
158
|
+
} else if (summary.updated > 0) {
|
|
159
|
+
note(`dry-run ${summary.updated} repo${summary.updated === 1 ? '' : 's'} would change; apply with: ${CLI} upgrade --yes`);
|
|
160
|
+
} else if (summary.skipped > 0) {
|
|
161
|
+
note(`note nothing to write; ${summary.skipped} repo${summary.skipped === 1 ? '' : 's'} skipped (see above)`);
|
|
162
|
+
} else {
|
|
163
|
+
note('note every consumer already runs this simt-dev');
|
|
164
|
+
}
|
|
165
|
+
return exitFor(plans, failed);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Exit non-zero for a repo that is BROKEN or mid-repair, not for one that never
|
|
170
|
+
* adopted. An unadopted repo is the expected mid-migration state, and `compare` already
|
|
171
|
+
* established that a command which goes red for it teaches people to ignore it.
|
|
172
|
+
*/
|
|
173
|
+
function exitFor(plans, failed = []) {
|
|
174
|
+
if (failed.length > 0) return EXIT.FAIL;
|
|
175
|
+
return plans.some((p) => p.action === 'skip' && FAULT_STATES.has(p.state)) ? EXIT.FAIL : EXIT.OK;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function render(plans, summary, ctx) {
|
|
179
|
+
const lines = [
|
|
180
|
+
`${ctx.shape === 'repo' ? 'repo ' : 'workspace '} ${ctx.root}`,
|
|
181
|
+
`shape ${ctx.shape}${ctx.shape === 'workspace' ? ` (root from ${ctx.source})` : ''}`,
|
|
182
|
+
`simt-dev ${ctx.sha}`,
|
|
183
|
+
`repos ${summary.repos} (${summary.updated} to update, ${summary.unchanged} current, ${summary.skipped} skipped)`,
|
|
184
|
+
];
|
|
185
|
+
if (summary.skippedStates.length > 0) {
|
|
186
|
+
lines.push(`skipped ${summary.skippedStates.join(', ')}`);
|
|
187
|
+
}
|
|
188
|
+
// Faults are counted separately from skips so "4 repos skipped, 0 of them broken" is
|
|
189
|
+
// a readable sentence rather than a number the reader has to interpret.
|
|
190
|
+
if (summary.faults > 0) lines.push(`faults ${summary.faults} need repair before they can take a pin`);
|
|
191
|
+
lines.push('');
|
|
192
|
+
|
|
193
|
+
for (const p of plans) {
|
|
194
|
+
lines.push(`repo ${p.name}`);
|
|
195
|
+
lines.push(` state ${p.state}`);
|
|
196
|
+
lines.push(` pin ${p.pinnedSha ?? '(none)'}${p.pinnedSha === p.sha ? ' (unchanged)' : ` -> ${p.sha}`}`);
|
|
197
|
+
lines.push(` ${p.detail}`);
|
|
198
|
+
if (p.remedy) lines.push(` remedy ${p.remedy}`);
|
|
199
|
+
lines.push('');
|
|
200
|
+
}
|
|
201
|
+
return lines.join('\n').trimEnd();
|
|
202
|
+
}
|