simt-dev 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +202 -0
  3. package/README.md +92 -2
  4. package/bin/simt-dev.mjs +28 -0
  5. package/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  6. package/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  7. package/docs/decisions/ADR-003-test-breadth.md +46 -0
  8. package/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  9. package/docs/decisions/ADR-005-file-length.md +42 -0
  10. package/docs/decisions/ADR-006-coverage-bar.md +59 -0
  11. package/docs/decisions/ADR-007-section-skeleton.md +54 -0
  12. package/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  13. package/docs/decisions/ADR-009-skill-layout.md +48 -0
  14. package/docs/decisions/ADR-010-runner-invocation.md +56 -0
  15. package/docs/decisions/ADR-011-windows-support.md +52 -0
  16. package/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  17. package/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  18. package/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  19. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  20. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  21. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  22. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  23. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  24. package/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  25. package/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  26. package/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  27. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  28. package/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  29. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  30. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  31. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  32. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  33. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  34. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  35. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  36. package/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  37. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  38. package/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  39. package/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  40. package/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  41. package/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  42. package/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  43. package/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  44. package/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  45. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  46. package/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  47. package/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  48. package/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  49. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  50. package/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  51. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  52. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  53. package/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  54. package/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  55. package/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  56. package/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  57. package/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  58. package/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  59. package/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  60. package/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  61. package/docs/decisions/README.md +528 -0
  62. package/graph/skills.graph.json +1622 -0
  63. package/package.json +41 -4
  64. package/rules/core.md +76 -0
  65. package/rules/mdc/intake-questions.mdc +50 -0
  66. package/rules/mdc/preview-postflight.mdc +61 -0
  67. package/rules/mdc/slice-commit.mdc +52 -0
  68. package/rules/mdc/test-gate.mdc +54 -0
  69. package/rules/stacks/android.md +63 -0
  70. package/rules/stacks/cloudflare-workers.md +65 -0
  71. package/rules/stacks/connectiq.md +62 -0
  72. package/rules/stacks/container.md +31 -0
  73. package/rules/stacks/jvm.md +54 -0
  74. package/rules/stacks/node.md +45 -0
  75. package/rules/stacks/python.md +48 -0
  76. package/rules/stacks/react-native.md +58 -0
  77. package/rules/stacks/react-web.md +61 -0
  78. package/rules/stacks/typescript.md +21 -0
  79. package/rules/stacks/uv.md +32 -0
  80. package/src/argv.mjs +116 -0
  81. package/src/ci/dependabot.mjs +268 -0
  82. package/src/ci/workflow.mjs +360 -0
  83. package/src/commands/ci.mjs +285 -0
  84. package/src/commands/compare.mjs +195 -0
  85. package/src/commands/graph.mjs +73 -0
  86. package/src/commands/help.mjs +108 -0
  87. package/src/commands/hooks.mjs +471 -0
  88. package/src/commands/index.mjs +241 -0
  89. package/src/commands/init.mjs +566 -0
  90. package/src/commands/mcp.mjs +63 -0
  91. package/src/commands/rules.mjs +374 -0
  92. package/src/commands/skills.mjs +647 -0
  93. package/src/commands/skillsinstall.mjs +515 -0
  94. package/src/commands/sync.mjs +331 -0
  95. package/src/commands/upgrade.mjs +202 -0
  96. package/src/compare/discover.mjs +212 -0
  97. package/src/compare/hooksstate.mjs +159 -0
  98. package/src/compare/row.mjs +299 -0
  99. package/src/decisions/index.mjs +512 -0
  100. package/src/graph/load.mjs +541 -0
  101. package/src/hooks/capability.mjs +129 -0
  102. package/src/hooks/dispatcher.mjs +268 -0
  103. package/src/hooks/gates.mjs +144 -0
  104. package/src/hooks/resolve.mjs +194 -0
  105. package/src/hooks/shim.mjs +140 -0
  106. package/src/hooks/where.mjs +118 -0
  107. package/src/locate.mjs +75 -0
  108. package/src/mcp/server.mjs +167 -0
  109. package/src/mcp/tools.mjs +410 -0
  110. package/src/name.mjs +95 -0
  111. package/src/output/index.mjs +96 -0
  112. package/src/projection/baselines.mjs +209 -0
  113. package/src/projection/conflicts.mjs +205 -0
  114. package/src/projection/manifest.mjs +478 -0
  115. package/src/projection/markers.mjs +349 -0
  116. package/src/projection/mdc.mjs +327 -0
  117. package/src/projection/project.mjs +504 -0
  118. package/src/projection/rules.mjs +640 -0
  119. package/src/projection/surfaces.mjs +398 -0
  120. package/src/projection/targets.mjs +221 -0
  121. package/src/resolve/fingerprint.mjs +553 -0
  122. package/src/resolve/near.mjs +61 -0
  123. package/src/resolve/resolve.mjs +457 -0
  124. package/src/resolve/source.mjs +105 -0
  125. package/src/toolchain.mjs +202 -0
  126. package/src/tsconfig.mjs +314 -0
  127. package/src/upgrade/plan.mjs +203 -0
  128. package/src/workspace.mjs +289 -0
@@ -0,0 +1,203 @@
1
+ import { MANIFEST_COMMENT } from '../projection/manifest.mjs';
2
+ import { CLI } from '../name.mjs';
3
+ import { MANIFEST_FILE } from '../name.mjs';
4
+ // Classify one repository's upgrade state. Pure: no filesystem, no git.
5
+ //
6
+ // The line this module draws is narrow on purpose. `upgrade` moves a PIN. It does not
7
+ // adopt a repo, repair a broken one, or decide that a repo should have a region it never
8
+ // had. Each of those is a different command with a different review, and a command that
9
+ // silently performs them during an "upgrade" is a command nobody can predict.
10
+ //
11
+ // So there are exactly two outcomes that write: `updated` and `unchanged`. Everything
12
+ // else is a skip with its own remedy, and the skip is reported rather than tolerated -
13
+ // a repo silently left behind is the exact failure `compare` exists to surface, and it
14
+ // must not be one this command creates.
15
+
16
+ /**
17
+ * @param {object} input
18
+ * @param {string} input.name
19
+ * @param {string} input.path
20
+ * @param {object|null} input.manifest parsed simt-dev.json, or null
21
+ * @param {boolean} input.manifestExists
22
+ * @param {string|null} input.manifestError parse failure, when there is one
23
+ * @param {object[]} input.targets every planned target from `plan(root).targets`: the
24
+ * AGENTS.md region first, then the generated `.mdc` rules
25
+ * @param {string} input.sha the SHA this simt-dev stamps into regions
26
+ */
27
+ export function planUpgrade({ name, path, manifest, manifestExists, manifestError, targets, sha }) {
28
+ const list = Array.isArray(targets) ? targets : [];
29
+ const primary = list[0] ?? null;
30
+ const base = { name, path, sha, pinnedSha: manifest?.projection?.simtDevSha ?? null };
31
+
32
+ // A repo that never adopted is not behind; it is absent. `simt-dev init` is the command
33
+ // for it, and calling this a failure would make the exit code describe the estate's
34
+ // migration progress rather than this run's correctness.
35
+ if (!manifestExists) {
36
+ return { ...base, state: 'not-adopted', action: 'skip', detail: `no ${MANIFEST_FILE}`, remedy: `${CLI} init` };
37
+ }
38
+ if (manifestError) {
39
+ return {
40
+ ...base,
41
+ state: 'broken-manifest',
42
+ action: 'skip',
43
+ detail: `${MANIFEST_FILE} is unparseable (${manifestError})`,
44
+ remedy: `fix the JSON, or run: ${CLI} init`,
45
+ };
46
+ }
47
+ // Only a hand-owned file's malformed region is a fault. A managed `.mdc` rule is a
48
+ // generated file, so a malformed region there is repaired by replacement and nothing
49
+ // hand-written is lost — `plan` reports its action as `updated`, not `malformed`, and
50
+ // the write fixes it. AGENTS.md holds hand-written prose around its region, so
51
+ // overwriting it could destroy work: that is the fault, and the whole repo is left
52
+ // alone so no file is half-migrated.
53
+ const malformed = list.find((t) => t.action === 'malformed');
54
+ if (malformed) {
55
+ return {
56
+ ...base,
57
+ state: 'malformed-region',
58
+ action: 'skip',
59
+ detail: `${malformed.file} region is malformed (${malformed.reason})`,
60
+ remedy: `fix the markers at ${malformed.file}:${malformed.line}, then: ${CLI} sync --yes`,
61
+ };
62
+ }
63
+ if (primary === null) {
64
+ return {
65
+ ...base,
66
+ state: 'no-region',
67
+ action: 'skip',
68
+ detail: 'no generated file could be planned for this repo',
69
+ remedy: `run: ${CLI} check`,
70
+ };
71
+ }
72
+ // A manifest with no region is a repo mid-adoption or one that never adopted, and
73
+ // `upgrade` writing a region into it turns adoption into a side effect of a version
74
+ // bump. The question is whether the region exists, and `parse` has always answered it —
75
+ // reading `previousBytes` instead gets a repository whose AGENTS.md is hand-written prose
76
+ // wrong, which is the shape this guard was written for and the one it let through. A
77
+ // byte count cannot tell "no region" from "small file", and a hand-written AGENTS.md is
78
+ // never small.
79
+ //
80
+ // Keyed on the PRIMARY target alone, and that is not a simplification. The estate builds
81
+ // this state by adopting and then replacing AGENTS.md with prose, which leaves the
82
+ // baselines' regions in place: a guard asking whether *every* target lost its region sees
83
+ // two regions, decides the repo is adopted, and writes one — which is the defect. It is
84
+ // also the rule `compare` already applies (ADR-016 ruling on display versus gate: the row's
85
+ // `region` field is the primary document's), so the two commands now agree on which
86
+ // repository counts as adopted.
87
+ if (primary.region === 'absent') {
88
+ return {
89
+ ...base,
90
+ state: 'no-region',
91
+ action: 'skip',
92
+ detail: `${MANIFEST_FILE} exists but AGENTS.md has no generated region`,
93
+ // `sync --yes`, not `init --yes`. The remedy is a command that does the thing the
94
+ // skip refused to do and nothing else: `sync` writes regions and leaves the rest of
95
+ // the repository alone, while `init` re-runs adoption. `compare` already names
96
+ // `sync --yes` for this state, and two commands must not answer the same question
97
+ // with different instructions.
98
+ remedy: `run: ${CLI} sync --yes`,
99
+ };
100
+ }
101
+
102
+ // Every target counts: a repo whose AGENTS.md is current but whose `.mdc` rules are
103
+ // stale is behind, and restamping only the first of its files would leave `check` red
104
+ // immediately after a successful `upgrade`.
105
+ const regionBehind = list.some((t) => t.action !== 'unchanged');
106
+ // "Behind" is not "the rules changed", and the difference is what the message below
107
+ // has to state. A target is `updated` for two unrelated reasons: its region BODY moved,
108
+ // which is a change to the content this tool projects, or its seam moved, which is this
109
+ // tool's own formatting around a body that was already correct (ADR-037). Both read as
110
+ // `updated`, so reading the cause off `action` reports a formatting repair as a change
111
+ // of policy — and this command's whole purpose is to say why a repository is behind, so
112
+ // a false cause here sends the reader to diff the rules when the rules did not move.
113
+ //
114
+ // The comparison is body to body rather than byte to byte, because the region header
115
+ // carries the stamp: a stamp-only move is what `pinBehind` already reports separately,
116
+ // and folding it in here would make every pin bump claim the rules changed.
117
+ //
118
+ // And the two kinds of projected content are counted apart, because they are different
119
+ // files to diff. A baseline region is not a rule: `.editorconfig` and `.gitattributes`
120
+ // are settings a repository owns, and telling a reader the RULES changed when only an
121
+ // indentation setting did sends them to the wrong file (S53). It was reachable because
122
+ // the stamp could not see a baseline body change at all, so the pin reported unchanged
123
+ // and the only cause left to report was the one that was least true.
124
+ const moved = (t) => {
125
+ if (t.action === 'unchanged' || t.action === 'added') return t.action === 'added';
126
+ const before = (t.previousBody ?? '').replace(/^\n+/, '').replace(/\n+$/, '');
127
+ const after = (t.body ?? '').replace(/^\n+/, '').replace(/\n+$/, '');
128
+ return before !== after;
129
+ };
130
+ const rulesChanged = list.some((t) => t.kind !== 'baseline' && moved(t));
131
+ const baselineChanged = list.some((t) => t.kind === 'baseline' && moved(t));
132
+ // The cause, as the sentence the reader is sent to act on. Null means no projected body
133
+ // moved, so whatever is behind this repository is a seam, a stamp or the manifest.
134
+ const cause = rulesChanged && baselineChanged
135
+ ? 'the rules and a baseline changed'
136
+ : rulesChanged
137
+ ? 'the rules changed'
138
+ : baselineChanged
139
+ ? 'a baseline changed'
140
+ : null;
141
+ const pinBehind = base.pinnedSha !== sha;
142
+ // A generated field of the manifest can alone be stale. `$comment` is written by
143
+ // simt-dev and describes this file's provenance, so an adopted repo whose regions
144
+ // and pin are all current can still be carrying an older version's wording -
145
+ // and because `sync` does not own the manifest, nothing else would ever correct
146
+ // it. Counting it here is what gives staleness a path back to being correct: a
147
+ // field nobody checks is a field nobody fixes.
148
+ const manifestBehind =
149
+ manifest?.$comment !== undefined && manifest.$comment !== MANIFEST_COMMENT.$comment;
150
+ if (!regionBehind && !pinBehind && !manifestBehind) {
151
+ return { ...base, state: 'current', action: 'unchanged', detail: 'already at this simt-dev' };
152
+ }
153
+
154
+ // Why it moved is worth stating, and the causes are different problems even though
155
+ // one command fixes all of them: content drift means a projected BODY changed, a seam
156
+ // repair means none did and only the formatting around one was wrong, a pin-only move
157
+ // means the region was already correct and only its stamp was stale, and a
158
+ // generated-field move means nothing about the repo changed and this tool's own
159
+ // wording did. The transition itself is printed on its own `pin` line, so `detail`
160
+ // names the cause without repeating it.
161
+ // `cause` is null on the last branch and can only be: a body that moved is a target
162
+ // whose action is not `unchanged`, so "no region behind" already implies no cause.
163
+ return {
164
+ ...base,
165
+ state: 'behind',
166
+ action: 'updated',
167
+ detail: pinBehind
168
+ ? regionBehind
169
+ ? cause === null
170
+ ? 'pin moved and region repaired; the rules were already correct'
171
+ : `pin moved and region restamped (${cause})`
172
+ : 'pin moved; the region was already current'
173
+ : regionBehind
174
+ ? cause === null
175
+ ? 'region repaired; the pin and the rules were already current'
176
+ : `region restamped; the pin was already current (${cause})`
177
+ : 'manifest metadata refreshed; the rules and the pin were already current',
178
+ };
179
+ }
180
+
181
+ /**
182
+ * States that mean something is WRONG, as opposed to states that mean something is
183
+ * merely ABSENT. Only these fail the run.
184
+ *
185
+ * The distinction is the same one `compare` makes, and it is load-bearing: on day one of
186
+ * a migration most repos have no manifest at all. A command that exits non-zero for them
187
+ * would report a workspace that looks broken and is merely early, and people would stop
188
+ * reading its exit code.
189
+ */
190
+ export const FAULT_STATES = new Set(['broken-manifest', 'malformed-region', 'no-region']);
191
+
192
+ /** @returns {{repos:number, updated:number, unchanged:number, skipped:number, faults:number, skippedStates:string[]}} */
193
+ export function summariseUpgrade(plans) {
194
+ const skipped = plans.filter((p) => p.action === 'skip');
195
+ return {
196
+ repos: plans.length,
197
+ updated: plans.filter((p) => p.action === 'updated').length,
198
+ unchanged: plans.filter((p) => p.action === 'unchanged').length,
199
+ skipped: skipped.length,
200
+ faults: skipped.filter((p) => FAULT_STATES.has(p.state)).length,
201
+ skippedStates: [...new Set(skipped.map((p) => p.state))].sort(),
202
+ };
203
+ }
@@ -0,0 +1,289 @@
1
+ // Where the workspace is, and how to be sure.
2
+ //
3
+ // The workspace is the directory that CONTAINS repositories. It is not the same thing as a
4
+ // repository, and it is not required: a single repo with no workspace is a normal, supported
5
+ // case (ADR-014). What the workspace provides is the *sibling* relationship - it is how
6
+ // `graph/skills.graph.json`'s `repo/path` sources resolve, and how `compare` finds the rest of
7
+ // the estate.
8
+ //
9
+ // Three real failure modes motivated this module, all measured rather than reasoned about:
10
+ //
11
+ // 1. `resolve(HERE, '..', '..', '..')` is correct in the checkout and wrong in node_modules,
12
+ // where it lands inside `node_modules/@quazarmedia`. Every `skills` command then fails
13
+ // with ENOENT for all 38 sources.
14
+ // 2. A single repo passed to `compare` was reported as "contains no git repositories",
15
+ // because discovery scanned the CHILDREN of the given path.
16
+ // 3. The published package shipped no `docs/`, so `rules why` could not resolve a single ADR.
17
+ //
18
+ // The fix is not a cleverer heuristic. It is: resolve from explicit configuration, fall back to
19
+ // the heuristic only when it verifies, and report which one answered. A silent fallback to the
20
+ // wrong directory is worse than no answer at all, because every downstream answer is then
21
+ // confidently wrong.
22
+
23
+ import { readFile } from 'node:fs/promises';
24
+ import { existsSync } from 'node:fs';
25
+ import { homedir, platform as osPlatform } from 'node:os';
26
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ /**
31
+ * The package root. Here that is ONE level up from `src/`, not two - and getting
32
+ * it wrong silently shifts the workspace heuristic by a whole directory, which is
33
+ * why it is a named constant rather than an inline expression.
34
+ */
35
+ export const PACKAGE_ROOT = resolve(HERE, '..');
36
+
37
+ // Re-exported rather than re-spelled: the literal belongs to src/name.mjs with the
38
+ // other LOCKED on-disk names, and a second copy here is a name that can drift from the
39
+ // list of names that are allowed to move. Imported as well as re-exported, because a
40
+ // bare `export { X } from` does not bind X here and every use below would fail.
41
+ import { CONFIG_DIR } from './name.mjs';
42
+ export { CONFIG_DIR };
43
+ export const CONFIG_FILE = 'config.json';
44
+
45
+ /**
46
+ * Candidate config paths, most explicit first.
47
+ *
48
+ * `$XDG_CONFIG_HOME` comes first on every platform because it is the only way to *override* the
49
+ * location on a POSIX system, and honouring an explicit override before guessing is the entire
50
+ * point. macOS is listed after it because `~/Library/Application Support` is Apple's documented
51
+ * location for application data, but a developer who already exports XDG_CONFIG_HOME should not
52
+ * have to also move the file. Both conventions are therefore supported, and which one is in use
53
+ * is reported rather than assumed.
54
+ *
55
+ * Windows is listed although ADR-012 drops Windows support. Keeping the function total makes it
56
+ * testable on any host and correct if that ruling is ever reversed; `package.json` still refuses
57
+ * to install on Windows.
58
+ *
59
+ * @returns {string[]}
60
+ *
61
+ * `env` defaults to `process.env`, NOT to an empty object. An earlier version
62
+ * defaulted to `{}` so that a test could pass a fake environment, and the result was
63
+ * that `XDG_CONFIG_HOME` - the one documented way to OVERRIDE the config location -
64
+ * was silently ignored on every real invocation. Every unit test passed `{env}`
65
+ * explicitly, so nothing caught it; only running the CLI did. The default is part of
66
+ * this function's contract, not a convenience.
67
+ */
68
+ export function configPaths({ env = process.env, platform = osPlatform(), home = homedir() } = {}) {
69
+ const candidates = [];
70
+ const push = (p) => {
71
+ if (p && !candidates.includes(p)) candidates.push(p);
72
+ };
73
+
74
+ if (env.XDG_CONFIG_HOME) push(join(env.XDG_CONFIG_HOME, CONFIG_DIR, CONFIG_FILE));
75
+ push(join(home, '.config', CONFIG_DIR, CONFIG_FILE));
76
+ if (platform === 'darwin') {
77
+ push(join(home, 'Library', 'Application Support', CONFIG_DIR, CONFIG_FILE));
78
+ }
79
+ if (platform === 'win32') {
80
+ const base = env.APPDATA ?? join(home, 'AppData', 'Roaming');
81
+ push(join(base, CONFIG_DIR, CONFIG_FILE));
82
+ }
83
+ return candidates;
84
+ }
85
+
86
+ /**
87
+ * Read the user config, if there is one.
88
+ *
89
+ * An absent file is not an error: it means "not configured", and resolution continues down the
90
+ * precedence list. Only a file that EXISTS and does not parse is an error, because that is a
91
+ * real mistake the reader can fix - and silently ignoring it would send them looking for a
92
+ * configuration problem in the wrong place.
93
+ */
94
+ export async function readUserConfig(options = {}) {
95
+ const searched = configPaths(options);
96
+ for (const path of searched) {
97
+ let text;
98
+ try {
99
+ text = await readFile(path, 'utf8');
100
+ } catch {
101
+ continue;
102
+ }
103
+ try {
104
+ const parsed = JSON.parse(text);
105
+ return { path, exists: true, config: parsed ?? {}, error: null, searched };
106
+ } catch (error) {
107
+ return { path, exists: true, config: {}, error: `unparseable JSON (${error.message})`, searched };
108
+ }
109
+ }
110
+ return { path: null, exists: false, config: {}, error: null, searched };
111
+ }
112
+
113
+ /**
114
+ * Does this directory contain at least one git repository? The heuristic is only accepted when
115
+ * it verifies, which is what stops a stray `node_modules` parent from being silently believed.
116
+ */
117
+ async function looksLikeWorkspace(dir) {
118
+ const { readdir } = await import('node:fs/promises');
119
+ let entries;
120
+ try {
121
+ entries = await readdir(dir, { withFileTypes: true });
122
+ } catch {
123
+ return false;
124
+ }
125
+ for (const entry of entries) {
126
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
127
+ if (entry.name === 'node_modules') continue;
128
+ if (existsSync(join(dir, entry.name, '.git'))) return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * Resolve the workspace.
135
+ *
136
+ * Precedence, highest first: `--workspace`, `SIMT_DEV_WORKSPACE`, the user config file, then the
137
+ * sibling heuristic of this package - and that last one only if it actually contains a git
138
+ * repository.
139
+ *
140
+ * @returns {Promise<{root: string|null, source: string, detail: string, searched: string[], error: string|null}>}
141
+ */
142
+ export async function resolveWorkspace(flags = {}, options = {}) {
143
+ const env = options.env ?? process.env;
144
+ const searched = configPaths(options);
145
+
146
+ // The config file is read FIRST, before precedence is applied, and a parse failure
147
+ // is reported immediately. That is deliberate: precedence decides which *answer*
148
+ // wins, but a config file that exists and does not parse is a defect in the
149
+ // reader's setup, and hiding it because some other source outranked it would send
150
+ // them looking for a configuration problem in the wrong place.
151
+ const user = await readUserConfig(options);
152
+ if (user.error) {
153
+ return { root: null, source: 'config', detail: user.path, searched, error: user.error };
154
+ }
155
+
156
+ // Only `--workspace` counts here. `--root` names a REPOSITORY, and a repository
157
+ // is not its own parent, so treating one as the other would resolve every
158
+ // `repo/path` source one directory too low - a wrong answer that reads as right.
159
+ const flag = flags.workspace;
160
+ if (flag) {
161
+ return (
162
+ disabled(flag, 'flag', '--workspace', searched) ?? {
163
+ root: absolute(flag),
164
+ source: 'flag',
165
+ detail: '--workspace',
166
+ searched,
167
+ error: null,
168
+ }
169
+ );
170
+ }
171
+
172
+ if (env.SIMT_DEV_WORKSPACE) {
173
+ return (
174
+ disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched) ?? {
175
+ root: absolute(env.SIMT_DEV_WORKSPACE),
176
+ source: 'env',
177
+ detail: 'SIMT_DEV_WORKSPACE',
178
+ searched,
179
+ error: null,
180
+ }
181
+ );
182
+ }
183
+
184
+ if (user.exists && typeof user.config.workspace === 'string' && user.config.workspace.length > 0) {
185
+ return (
186
+ disabled(user.config.workspace, 'config', user.path, searched) ?? {
187
+ root: absolute(user.config.workspace),
188
+ source: 'config',
189
+ detail: user.path,
190
+ searched,
191
+ error: null,
192
+ }
193
+ );
194
+ }
195
+
196
+ const parent = resolve(PACKAGE_ROOT, '..');
197
+ if (await looksLikeWorkspace(parent)) {
198
+ return { root: parent, source: 'heuristic', detail: 'sibling of this package', searched, error: null };
199
+ }
200
+
201
+ return {
202
+ root: null,
203
+ source: 'none',
204
+ detail: 'no workspace configured',
205
+ searched,
206
+ error: null,
207
+ };
208
+ }
209
+
210
+ /** Values that mean "there is deliberately no workspace", from any source. */
211
+ const OFF = new Set(['off', 'none', 'no', '0', '-']);
212
+
213
+ /**
214
+ * The sentinel. Without it there is no way to *decline* the sibling heuristic, so a
215
+ * reader who does not want a directory guessed for them cannot say so - and no test
216
+ * running from this checkout can reach the unconfigured path at all. `off` is
217
+ * returned from any source and takes the same shape as any other answer, so callers
218
+ * need no special case for it.
219
+ */
220
+ function disabled(value, source, detail, searched) {
221
+ return OFF.has(String(value).trim().toLowerCase())
222
+ ? {
223
+ root: null,
224
+ source: 'disabled',
225
+ detail: `${detail}=off (workspace declined)`,
226
+ searched: searched ?? [],
227
+ error: null,
228
+ }
229
+ : null;
230
+ }
231
+
232
+ /**
233
+ * The one-line remedy every workspace-dependent failure prints.
234
+ *
235
+ * Built from this machine's OWN candidate list rather than a hardcoded example, because a
236
+ * remedy that names a path the reader does not have is a second problem stacked on the first.
237
+ * Index 0 is the right one to name: on POSIX that is `$XDG_CONFIG_HOME/...` when set and
238
+ * `$HOME/.config/...` when not, which is the path the reader most likely already expects.
239
+ */
240
+ // The remedy is a phrase, not a sentence: several callers embed it after their own
241
+ // lead-in ("workspace none configured - <here>"), so it must not start with one.
242
+ export const WORKSPACE_REMEDY = (() => {
243
+ const first = configPaths()[0];
244
+ const where = first ?? join('$HOME', '.config', CONFIG_DIR, CONFIG_FILE);
245
+ return `--workspace <path>, the SIMT_DEV_WORKSPACE environment variable, or ${where}`;
246
+ })();
247
+
248
+ function absolute(p) {
249
+ const expanded = p.startsWith('~/')
250
+ ? join(homedir(), p.slice(2))
251
+ : p;
252
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
253
+ }
254
+
255
+ /**
256
+ * The workspace, or null. Callers that can degrade use this and report `source: 'none'`;
257
+ * callers that genuinely cannot proceed should use `resolveWorkspace` and fail with its detail.
258
+ */
259
+ export async function workspaceOrNull(flags = {}, options = {}) {
260
+ const { root, error } = await resolveWorkspace(flags, options);
261
+ return error ? null : root;
262
+ }
263
+
264
+ /**
265
+ * The path a multi-repo command should work on: `--root` or the first positional if
266
+ * given, else the resolved workspace, else the current directory.
267
+ *
268
+ * This lives here rather than in each command because precedence is a user-visible
269
+ * contract: `compare`, `upgrade` and any future workspace-wide command must agree on
270
+ * which source named the directory, or a reader who set `SIMT_DEV_WORKSPACE` gets
271
+ * three different answers from three commands.
272
+ *
273
+ * `--root` is returned verbatim as the source even though it names a repository; whether
274
+ * that repository is the only thing that counts is the caller's question, and `discover`
275
+ * answers it from the filesystem rather than from a guess.
276
+ */
277
+ export async function targetRoot(flags = {}, positional = []) {
278
+ const explicit = flags.root ?? positional[0];
279
+ if (explicit) {
280
+ return { root: absolute(explicit), source: 'flag', detail: explicit, ws: null };
281
+ }
282
+ const ws = await resolveWorkspace(flags);
283
+ return {
284
+ root: absolute(ws.root ?? process.cwd()),
285
+ source: ws.source,
286
+ detail: ws.detail,
287
+ ws,
288
+ };
289
+ }