simt-dev 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,640 @@
1
+ // Which rules apply to a repo.
2
+ //
3
+ // Split from project.mjs, which crossed the 400-line review trigger (ADR-005)
4
+ // twice while gaining two unrelated concerns. The rule is that a module answers
5
+ // one question: this one answers *which rules*, project.mjs answers *what files
6
+ // that produces*, targets.mjs answers *how a rule is rendered per surface*.
7
+ //
8
+ // Selection is a pure function of the repo's signals plus the manifest, with no
9
+ // filesystem beyond reading the rule files themselves. That purity is what makes
10
+ // the profile-selection test possible at all, and it is why `select` takes a
11
+ // pre-read rule list rather than a path.
12
+ //
13
+ // The core rule set is always included. Stack profiles are selected by
14
+ // intersection with the repo's fingerprint - platform tags *and* emitted facts,
15
+ // which are interchangeable in a profile (ADR-013) - plus one fallback profile so
16
+ // a JS/TS repo is never left with core rules and nothing else.
17
+ //
18
+ // Profiles may live in the repo, not only here. `.simt-dev/profiles/*.md` uses the
19
+ // same frontmatter contract as `rules/stacks/*.md` and is selected by the same
20
+ // matcher, so supporting an ecosystem nobody here has heard of requires no code
21
+ // change and no release.
22
+
23
+ import { readFile, readdir } from 'node:fs/promises';
24
+ import { createHash } from 'node:crypto';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { dirname, join, resolve } from 'node:path';
27
+
28
+ import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
29
+ import { loadMdcRules, renderMdc } from './mdc.mjs';
30
+ import { foldsRules, frontmatterBlock, resolveSurfaces } from './surfaces.mjs';
31
+ import { ruleTargets } from './targets.mjs';
32
+ import { manifestPath } from './manifest.mjs';
33
+ import { BASELINES } from './baselines.mjs';
34
+
35
+ const HERE = dirname(fileURLToPath(import.meta.url));
36
+ export const ROOT = resolve(HERE, '..', '..');
37
+ const RULES_DIR = join(ROOT, 'rules');
38
+
39
+ /**
40
+ * The identity recorded in generated regions and in the manifest pin.
41
+ *
42
+ * This is a digest of the CONTENT that leaves this package, not a commit id, and the
43
+ * distinction is not cosmetic. `git rev-parse` run inside `node_modules/` walks *up*
44
+ * until it finds a `.git`, so for anyone who installed this package into a repository -
45
+ * the whole audience for it - the stamp recorded the CONSUMER's HEAD. Git succeeded, so
46
+ * no fallback could catch it. A consumer's HEAD moves on every commit they make,
47
+ * including commits that touch nothing this tool projects, so `check` reported DRIFT on
48
+ * all three generated files after an unrelated commit and exited 1, and `upgrade` named
49
+ * a cause ("the rules changed") that was false.
50
+ *
51
+ * Where git could not answer at all - a published tarball has no `.git` (`files` in
52
+ * package.json does not include one and npm never packs it) - the old code returned the
53
+ * string `unknown`, and that was treated as a value: since the marker grammar reads
54
+ * `[0-9a-f]{7,40}` the tool could not read back its own stamp, and `upgrade` compared
55
+ * `unknown` against a real pin as though they were two versions, reporting "the rules
56
+ * changed" when they had not and overwriting a good pin with `unknown` on `--yes`,
57
+ * unrecoverably. That is the failure this replaces (ADR-032).
58
+ *
59
+ * The digest covers every byte string that leaves the package, and nothing else. That
60
+ * set is `rules/` and `graph/` on disk plus the RENDERED baseline bodies: the shared
61
+ * `.editorconfig` and `.gitattributes` blocks are strings a repository receives, so by
62
+ * ADR-015's rule a source is identified by its content rather than by the file it
63
+ * happens to live in, and a body defined in `src/` is a source like any other.
64
+ *
65
+ * It deliberately excludes `src/` as a directory. A refactor that changes no rule, no
66
+ * registry entry and no baseline body produces byte-identical output, and stamping those
67
+ * consumers would report drift where there is none - which is the estate's own rule that
68
+ * a report must be a fact.
69
+ *
70
+ * Sorted path-then-bytes, so the value depends on content and not on readdir order,
71
+ * and length-prefixed so no rename can be crafted to collide with a rename that did
72
+ * not happen (the reason to hash a structured encoding rather than joined text).
73
+ */
74
+ export async function provenanceDigest() {
75
+ const hash = createHash('sha256');
76
+ for (const { path, bytes } of await provenanceInputs()) {
77
+ hash.update(`${path}:${bytes.length}:`);
78
+ hash.update(bytes);
79
+ }
80
+ return hash.digest('hex').slice(0, 40);
81
+ }
82
+
83
+ /**
84
+ * The exact byte strings `provenanceDigest` hashes, as `{path, bytes}` in hash order.
85
+ *
86
+ * Exported because "the digest covers the right things" is a claim about a SET, and a
87
+ * set that is only ever assembled inside the function that hashes it cannot be
88
+ * asserted from outside: the test would have to mutate the package to see the answer.
89
+ * This is the list, so a test can ask what is in it, and the rendering is one function
90
+ * both the digest and that test read - the arrangement ADR-042's ruling 3 settled on.
91
+ *
92
+ * @returns {Promise<{path: string, bytes: Buffer}[]>}
93
+ */
94
+ export async function provenanceInputs() {
95
+ const inputs = [];
96
+ for (const dir of ['rules', 'graph']) {
97
+ for (const rel of await walk(join(ROOT, dir))) {
98
+ inputs.push({ path: `${dir}/${rel}`, bytes: await readFile(join(ROOT, dir, rel)) });
99
+ }
100
+ }
101
+ // Last, and keyed by the FILE it becomes in the adopting repository rather than the
102
+ // module it is written in: the stamp answers what content produced those bytes, and
103
+ // `.editorconfig` is the name a reader of a generated region recognises.
104
+ for (const b of BASELINES) {
105
+ inputs.push({ path: `baselines/${b.file}`, bytes: Buffer.from(b.body()) });
106
+ }
107
+ return inputs;
108
+ }
109
+
110
+ /** @returns {Promise<string[]>} repo-relative paths, sorted, directories omitted. */
111
+ async function walk(dir, prefix = '') {
112
+ const out = [];
113
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
114
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
115
+ if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), rel)));
116
+ else if (entry.isFile()) out.push(rel);
117
+ }
118
+ return out.sort();
119
+ }
120
+
121
+ /** Repo-local profile directory, relative to a repo root (ADR-013 section 2). */
122
+ export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
123
+
124
+ /** Every key a stack profile or core rule may declare is this tool's own, so every
125
+ * one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
126
+ * as an editor's instruction — Cursor, Copilot and Antigravity all parse a
127
+ * frontmatter block the same way — and would be silently inert. */
128
+ const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
129
+
130
+ /** Report the first thing wrong with a profile's frontmatter, in the order a reader
131
+ * fixes it: no id to select it by, then a key that will not be read, then a tag the
132
+ * detector cannot emit.
133
+ *
134
+ * The third check is the one a repo can get wrong by itself. A profile naming a tag
135
+ * no repository emits selects on nothing and fails silently: it loads, it appears in
136
+ * the region's source list, and it is never applied. `graph lint` checks the
137
+ * registry's skills against the same derived vocabulary (ADR-013, Consequences), and
138
+ * nothing checked the profiles until S28 changed six `simt-applies` lists with no
139
+ * guard that any of them named a tag the engine could produce. */
140
+ function describeDefect(meta) {
141
+ if (meta['simt-id'] === undefined) return 'missing simt-id in frontmatter';
142
+ const unknown = Object.keys(meta).find((k) => !PROFILE_KEYS.has(k));
143
+ if (unknown !== undefined) {
144
+ const prefixed = `simt-${unknown.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
145
+ return PROFILE_KEYS.has(prefixed)
146
+ ? `key "${unknown}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
147
+ : `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback`;
148
+ }
149
+ const applies = meta['simt-applies'];
150
+ if (!Array.isArray(applies)) return null;
151
+ const undeclared = applies.find((tag) => !KNOWN_TAGS.has(tag));
152
+ return undeclared === undefined
153
+ ? null
154
+ : `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
155
+ }
156
+
157
+ /**
158
+ * Manifest key holding per-rule substitution values, keyed by rule id.
159
+ *
160
+ * Substitutions live in the manifest rather than in the rule file because they are
161
+ * facts about the repo — which glob holds its plans, which domains are production
162
+ * — while a rule is shared by every repo that adopts it. Putting them in the rule
163
+ * either hard-codes one repo's paths for everyone or pushes the decision back
164
+ * into prose, which is the drift this mechanism exists to remove.
165
+ */
166
+ export const SUBSTITUTIONS_KEY = 'substitutions';
167
+
168
+ export function isEligible(rule, signals, optedIn) {
169
+ if (rule.optIn === true) return optedIn.has(rule.id);
170
+ if (rule.applies === 'always') return true;
171
+ return Array.isArray(rule.applies) && rule.applies.some((t) => signals.has(t));
172
+ }
173
+
174
+ /**
175
+ * Read the repo's declared substitutions and opt-ins once.
176
+ *
177
+ * Both callers used to read and parse `simt-dev.json` separately — and `checkTargets`
178
+ * did it inside its per-rule loop, so a repo with three projected `.mdc` rules read the
179
+ * manifest three times to reach the same two values. A helper that answers once is also
180
+ * the only way to be sure the writer and the gate cannot disagree about what was
181
+ * declared.
182
+ */
183
+ export async function readDeclared(root) {
184
+ let parsed = {};
185
+ try {
186
+ parsed = JSON.parse(await readFile(await manifestPath(root), 'utf8')) ?? {};
187
+ } catch {
188
+ parsed = {};
189
+ }
190
+ const declarations = parsed[SUBSTITUTIONS_KEY] ?? {};
191
+ const list = declarations.__optIn;
192
+ return {
193
+ values: declarations,
194
+ optIn: new Set(Array.isArray(list) ? list : []),
195
+ };
196
+ }
197
+
198
+
199
+
200
+ /**
201
+ * Render every canonical `.mdc` rule into its own generated region.
202
+ *
203
+ * A rule with a defect (no id, no target) is reported and skipped rather than
204
+ * written somewhere guessed: a rule file that silently stopped being projected
205
+ * would look exactly like a rule file that had nothing to say.
206
+ */
207
+ export async function mdcTargets(root, sha, manifest, flags) {
208
+ const targets = [];
209
+
210
+ const { resolved, bodies, problems, notes, skipped } = await ruleBodies(root, manifest, flags);
211
+
212
+ // A surface that folds its rules into the primary document contributes no file;
213
+ // `ruleTargets` skips it. Folding itself is `desiredRegion`'s job, and it reads the
214
+ // same `bodies` from the same call, so the region and the files cannot disagree.
215
+ if (foldsRules(resolved) && !resolved.surfaces.some((s) => s.rule)) {
216
+ if (bodies.length > 0) {
217
+ notes.push(
218
+ `every rule is folded into ${resolved.surfaces[0].primary}; add a surface with its own rule path for a file per rule`,
219
+ );
220
+ }
221
+ return { targets, problems, notes };
222
+ }
223
+
224
+ for (const { rule, body } of bodies) {
225
+ const built = await ruleTargets(root, rule, body, sha, resolved, notes);
226
+ targets.push(...built.targets);
227
+ }
228
+
229
+ if (skipped.length > 0) {
230
+ notes.push(`no .mdc rule projected for ${skipped.join(', ')}; opt in with substitutions.__optIn`);
231
+ }
232
+ return { targets, problems, notes };
233
+ }
234
+
235
+ /**
236
+ * Select the per-rule canonical rules and render each one, once.
237
+ *
238
+ * Extracted from `mdcTargets` because ADR-021 needs the SAME bodies for two
239
+ * different destinations: folded into the primary region when the selected surface
240
+ * has no per-rule path, and written as one file per surface when it does. Two
241
+ * selection passes would be two answers to "which rules apply", which is the defect
242
+ * that produced eight divergent `.mdc` files in the first place.
243
+ */
244
+ /**
245
+ * The value every selected surface gives `key`, or null when they do not all give one.
246
+ *
247
+ * A missing entry is a disagreement: the surface that has no name for the tool would
248
+ * otherwise be handed another harness's.
249
+ */
250
+ function agreedSubstitution(surfaces, key) {
251
+ if (surfaces.length === 0) return null;
252
+ const values = surfaces.map((s) => s.substitutions?.[key]);
253
+ if (values.some((v) => typeof v !== 'string' || v === '')) return null;
254
+ if (new Set(values).size !== 1) return null;
255
+ return values[0];
256
+ }
257
+
258
+ /**
259
+ * The values renderMdc receives for one rule: what the repo declared, plus a
260
+ * surface-named value used only when every selected surface names the same one.
261
+ *
262
+ * The body is written once and shared by every selected surface, so one harness's
263
+ * tool name must not be what another harness reads. `ruleBodies` (the writer) and
264
+ * `checkTargets` (the gate) both go through this, so they cannot disagree about what
265
+ * the body should contain — that divergence made `check` report drift on a tree
266
+ * `init` had just written, once a surface named a substitution (S45's askTool).
267
+ */
268
+ export function ruleValues(rule, declared, surfaces) {
269
+ const values = { ...declared };
270
+ for (const sub of rule.substitutions) {
271
+ if (Object.prototype.hasOwnProperty.call(values, sub.key)) continue;
272
+ const agreed = agreedSubstitution(surfaces, sub.key);
273
+ if (agreed !== null) values[sub.key] = agreed;
274
+ }
275
+ return values;
276
+ }
277
+
278
+ async function ruleBodies(root, manifest, flags) {
279
+ const problems = [];
280
+ const notes = [];
281
+ const skipped = [];
282
+ const bodies = [];
283
+
284
+ const { stack, facts } = await fingerprint(root);
285
+ const signals = new Set([...stack, ...facts]);
286
+
287
+ const rules = await loadMdcRules();
288
+ if (rules.length === 0) {
289
+ notes.push('no canonical .mdc rules shipped in this build');
290
+ const resolved = resolveSurfaces(manifest, flags ?? {});
291
+ return { resolved, bodies, problems, notes, skipped };
292
+ }
293
+
294
+ const declared = await readDeclared(root);
295
+
296
+ const resolved = resolveSurfaces(manifest, flags ?? {});
297
+ for (const p of resolved.problems) problems.push(p);
298
+
299
+ // A manifest key that no shipped rule declares is a typo, or a leftover from a rule
300
+ // that was renamed. Both read as "configured" in the one file a reviewer opens to
301
+ // see what a repo has chosen, and neither has any effect — so one is reported rather
302
+ // than ignored. `__optIn` is the one key with no rule of the same name.
303
+ const knownKeys = new Set(['__optIn']);
304
+ for (const rule of rules) for (const sub of rule.substitutions) knownKeys.add(sub.key);
305
+ for (const ruleId of Object.keys(declared.values)) {
306
+ if (ruleId === '__optIn') continue;
307
+ if (!rules.some((r) => r.id === ruleId)) {
308
+ notes.push(
309
+ `manifest declares substitutions for "${ruleId}", which no canonical rule matches; ignored`,
310
+ );
311
+ continue;
312
+ }
313
+ for (const key of Object.keys(declared.values[ruleId] ?? {})) {
314
+ if (!knownKeys.has(key)) {
315
+ problems.push(`${ruleId}: manifest declares substitution "${key}", which the rule does not define`);
316
+ }
317
+ }
318
+ }
319
+
320
+ for (const rule of rules) {
321
+ if (rule.error !== null) {
322
+ problems.push(`${rule.file}: ${rule.error}`);
323
+ continue;
324
+ }
325
+ // Selection mirrors the stack profiles: `simt-applies: always` is unconditional,
326
+ // a `simt-applies` array intersects the repo's signals, and an opt-in rule waits for
327
+ // the manifest to name it. A repo that matches nothing gets no `.mdc` rule
328
+ // rather than a plausible-looking one it cannot act on.
329
+ if (!isEligible(rule, signals, declared.optIn)) {
330
+ skipped.push(rule.id);
331
+ continue;
332
+ }
333
+ const { body, problems: renderProblems } = renderMdc(
334
+ rule,
335
+ ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
336
+ );
337
+ for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
338
+ if (renderProblems.length > 0) continue;
339
+
340
+ // One canonical body, one target per selected surface. The body is identical
341
+ // everywhere; only the frontmatter the editor reads before any rule text
342
+ // differs. Without this loop a repo in a Claude Code session receives
343
+ // AGENTS.md and none of the always-on process rules, because those lived only
344
+ // in a directory only Cursor reads.
345
+ bodies.push({ rule, body });
346
+ }
347
+
348
+ return { resolved, bodies, problems, notes, skipped };
349
+ }
350
+
351
+ /**
352
+ * The one place the desired region is computed. `plan` (which writes) and
353
+ * `checkTarget` (which gates) both go through this, so they cannot disagree
354
+ * about what the region should contain — a divergence here would report drift
355
+ * where none exists, and CI would flake on a clean tree.
356
+ */
357
+ export async function desiredRegion(root, flags) {
358
+ const { stack, facts, manifest } = await fingerprint(root);
359
+ const all = await loadRules(root);
360
+ const { selected, fallback, notes } = select(all, stack, facts);
361
+
362
+ let body = render(selected);
363
+ let sources = selected.map((r) => r.source);
364
+ const folded = [];
365
+
366
+ // When every selected surface carries its rules inside the primary document, the
367
+ // per-rule bodies belong in the region too — otherwise a repo on the standard
368
+ // surface receives AGENTS.md and none of the always-on process rules, because
369
+ // those had nowhere else to live. One file, one region, one place a reader opens.
370
+ const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
371
+ root,
372
+ manifest,
373
+ flags ?? {},
374
+ );
375
+ notes.push(...ruleNotes);
376
+ if (foldsRules(resolved)) {
377
+ for (const { rule, body: ruleBody } of bodies) {
378
+ // `## Process:`, not `### id`. The body already uses `##`, and a third-level
379
+ // heading in front of a second-level one is the break a reader cannot repair.
380
+ body += `\n\n${frameSource(rule, ruleBody, 'process')}`;
381
+ sources.push(rule.source);
382
+ folded.push(rule.id);
383
+ }
384
+ }
385
+
386
+ const meta = {
387
+ sha: await provenanceDigest(),
388
+ sources,
389
+ };
390
+ return {
391
+ stack,
392
+ facts,
393
+ manifest,
394
+ all,
395
+ profiles: selected.map((r) => r.id),
396
+ folded,
397
+ surfaces: resolved.ids,
398
+ surfaceSource: resolved.source,
399
+ problems,
400
+ fallback,
401
+ notes,
402
+ body,
403
+ meta,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Read every rule file once, with its frontmatter parsed, plus any repo-local
409
+ * profile under `.simt-dev/profiles/`.
410
+ *
411
+ * A rule without a `simt-id` is a defect, not a default: ids are what `rules why`
412
+ * and the generated region's source list refer to.
413
+ *
414
+ * A local profile with the same id as a central one SHADOWS it — replaces it
415
+ * rather than stacking with it. Two profiles claiming the same id would project
416
+ * the same rules twice, and duplicated source-of-truth text is a review blocker,
417
+ * not a style nit. The shadow is reported in `notes` so it is never silent.
418
+ */
419
+ export async function loadRules(root = null) {
420
+ const entries = await readdir(RULES_DIR, { withFileTypes: true });
421
+ const files = entries.filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
422
+ const stackFiles = entries.some((e) => e.isDirectory() && e.name === 'stacks')
423
+ ? (await readdir(join(RULES_DIR, 'stacks')))
424
+ .filter((n) => n.endsWith('.md'))
425
+ .map((n) => `stacks/${n}`)
426
+ : [];
427
+
428
+ const collected = [];
429
+
430
+ for (const file of [...files, ...stackFiles].sort()) {
431
+ collected.push({ file, dir: RULES_DIR, origin: 'central', source: `rules/${file}` });
432
+ }
433
+
434
+ if (root !== null) {
435
+ const localDir = join(root, LOCAL_PROFILE_DIR);
436
+ let localNames = [];
437
+ try {
438
+ localNames = (await readdir(localDir)).filter((n) => n.endsWith('.md')).sort();
439
+ } catch {
440
+ localNames = [];
441
+ }
442
+ for (const name of localNames) {
443
+ collected.push({
444
+ file: name,
445
+ dir: localDir,
446
+ origin: 'local',
447
+ source: `${LOCAL_PROFILE_DIR}/${name}`,
448
+ });
449
+ }
450
+ }
451
+
452
+ const out = [];
453
+ for (const item of collected) {
454
+ const text = await readFile(join(item.dir, item.file), 'utf8');
455
+ const meta = parseFrontmatter(text);
456
+ out.push({
457
+ file: item.file,
458
+ origin: item.origin,
459
+ source: item.source,
460
+ id: meta['simt-id'] ?? null,
461
+ title: meta['simt-title'] ?? null,
462
+ applies: meta['simt-applies'] ?? [],
463
+ fallback: meta['simt-fallback'] === true,
464
+ bytes: Buffer.byteLength(text),
465
+ body: stripFrontmatter(text),
466
+ error: describeDefect(meta),
467
+ });
468
+ }
469
+ return out;
470
+ }
471
+
472
+ /**
473
+ * Selection is pure set intersection: core always, then every profile whose
474
+ * `simt-applies` intersects the repo's signals. `node` is the fallback, so it is only
475
+ * chosen when nothing more specific matched — otherwise every JS repo would
476
+ * receive both node and react-web.
477
+ *
478
+ * `signals` is stack tags ∪ facts (ADR-013). A profile may name either, and a
479
+ * fact is the more durable choice because it survives the framework being
480
+ * replaced.
481
+ *
482
+ * `shadowed` lists local profiles that replaced a central one, so a caller can
483
+ * report it rather than silently changing what a repo receives.
484
+ */
485
+ export function select(rules, stack, facts = []) {
486
+ const notes = [];
487
+ const shadowed = [];
488
+ const ids = new Map();
489
+ for (const rule of rules) {
490
+ if (rule.id === null) continue;
491
+ const prior = ids.get(rule.id);
492
+ if (prior === undefined) {
493
+ ids.set(rule.id, rule);
494
+ continue;
495
+ }
496
+ // A local profile is allowed to replace a central one; two of the same
497
+ // origin are a defect the caller must be able to see.
498
+ const localWins = rule.origin === 'local' && prior.origin !== 'local';
499
+ if (localWins) {
500
+ ids.set(rule.id, rule);
501
+ shadowed.push({
502
+ id: rule.id,
503
+ winner: rule.source,
504
+ replaced: prior.source,
505
+ ok: true,
506
+ });
507
+ } else {
508
+ shadowed.push({
509
+ id: rule.id,
510
+ winner: prior.source,
511
+ replaced: rule.source,
512
+ ok: false,
513
+ });
514
+ }
515
+ }
516
+ const keep = new Set(ids.values());
517
+
518
+ const effective = rules.filter((r) => r.id === null || keep.has(r));
519
+ const effCore = effective.filter((r) => r.applies === 'always');
520
+ const effProfiles = effective.filter((r) => Array.isArray(r.applies) && !r.fallback);
521
+ const effFallbacks = effective.filter((r) => r.fallback);
522
+
523
+ const signals = [...new Set([...(stack ?? []), ...(facts ?? [])])];
524
+ const tags = new Set(signals);
525
+ const matched = effProfiles.filter((r) => r.applies.some((tag) => tags.has(tag)));
526
+
527
+ let usedFallback = false;
528
+ const selected = [...effCore, ...matched];
529
+ if (matched.length === 0) {
530
+ for (const f of effFallbacks) {
531
+ if (f.applies.some((tag) => tags.has(tag))) {
532
+ selected.push(f);
533
+ usedFallback = true;
534
+ notes.push(
535
+ `no specific profile matched ${signals.join(', ') || '(no signals)'}; using fallback ${f.id}`,
536
+ );
537
+ }
538
+ }
539
+ }
540
+ if (matched.length === 0 && usedFallback === false) {
541
+ notes.push(
542
+ `no profile matched ${signals.length > 0 ? signals.join(', ') : '(no detected signals)'}; projecting shared rules only`,
543
+ );
544
+ }
545
+ for (const s of shadowed.filter((x) => x.ok)) {
546
+ notes.push(`local profile ${s.id} shadows ${s.replaced}`);
547
+ }
548
+ for (const s of shadowed.filter((x) => !x.ok)) {
549
+ notes.push(`duplicate profile id ${s.id}: ignored ${s.replaced}, kept ${s.winner}`);
550
+ }
551
+ return { selected, fallback: usedFallback, notes, shadowed };
552
+ }
553
+
554
+ /** The region body. Stack profiles come after core so the general rule wins a
555
+ * conflict: a stack profile refines the shared rule, it does not replace it.
556
+ *
557
+ * Each source is one `##` section. The source's own headings drop one level,
558
+ * so a profile's `##` cannot sit beside core's and look like the same document
559
+ * continuing (S34). */
560
+ export function render(selected) {
561
+ // Strip again here: loadRules already does it, but render is the boundary the
562
+ // region bytes pass through, and a stray frontmatter or duplicated H1 in the
563
+ // output would be projected verbatim.
564
+ return selected
565
+ .map((r) => frameSource(r, stripFrontmatter(r.body).trim(), 'profile'))
566
+ .filter(Boolean)
567
+ .join('\n\n');
568
+ }
569
+
570
+ /**
571
+ * One source, one `##` heading, then its body with every heading demoted.
572
+ *
573
+ * `Applies to` is already the first line of a stack profile once its H1 is gone,
574
+ * so it lands directly under `## Stack: …` instead of under whatever section the
575
+ * previous source ended on.
576
+ */
577
+ export function frameSource(rule, body, kind) {
578
+ const text = demoteHeadings(body).trim();
579
+ if (!text) return '';
580
+ const title = sourceTitle(rule, kind);
581
+ if (!title) return text;
582
+ return `## ${title}\n\n${text}`;
583
+ }
584
+
585
+ function sourceTitle(rule, kind) {
586
+ if (!rule?.id) return null;
587
+ if (kind === 'process') return `Process: ${rule.id}`;
588
+ if (rule.id === 'core') return 'Core';
589
+ return `Stack: ${rule.id}`;
590
+ }
591
+
592
+ /** `##` becomes `###`. A fence is left alone: a heading in an example is an example. */
593
+ function demoteHeadings(text) {
594
+ let fence = false;
595
+ return text
596
+ .split('\n')
597
+ .map((line) => {
598
+ if (/^(```|~~~)/.test(line)) {
599
+ fence = !fence;
600
+ return line;
601
+ }
602
+ if (fence) return line;
603
+ return line.replace(/^(#{1,5})(?=\s)/, '$1#');
604
+ })
605
+ .join('\n');
606
+ }
607
+
608
+ function parseFrontmatter(text) {
609
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
610
+ if (!m) return {};
611
+ const out = {};
612
+ for (const line of m[1].split('\n')) {
613
+ const kv = /^([a-zA-Z-]+):\s*(.*)$/.exec(line);
614
+ if (!kv) continue;
615
+ const [, key, raw] = kv;
616
+ if (raw === 'always') out[key] = 'always';
617
+ else if (raw === 'true') out[key] = true;
618
+ else if (raw === 'false') out[key] = false;
619
+ else if (raw.startsWith('[')) {
620
+ out[key] = raw
621
+ .slice(1, raw.lastIndexOf(']'))
622
+ .split(',')
623
+ .map((s) => s.trim())
624
+ .filter(Boolean);
625
+ } else out[key] = raw.trim();
626
+ }
627
+ return out;
628
+ }
629
+
630
+ /** Frontmatter and the duplicated H1 are dropped: the region is embedded in a
631
+ * host document that already has a title, so repeating it wastes context. */
632
+ function stripFrontmatter(text) {
633
+ // The frontmatter strip must also swallow the blank lines that follow it, or
634
+ // the H1 strip's `^` anchor lands on a newline and silently matches nothing.
635
+ return text
636
+ .replace(/^---\n[\s\S]*?\n---\n*/, '')
637
+ .replace(/^#\s+.*\n+/, '')
638
+ .trim();
639
+ }
640
+