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,349 @@
1
+ // The generated-region primitive.
2
+ //
3
+ // This is the whole reason the estate can share rules without a git submodule and
4
+ // without a runtime symlink (docs/plans/0001 section 2.2). A repo keeps its own
5
+ // hand-written AGENTS.md; simt-dev owns only the bytes between two markers.
6
+ //
7
+ // The four properties that make it safe to hand to 16 repos:
8
+ //
9
+ // idempotent injecting the same body twice yields identical bytes, so `sync`
10
+ // never produces a spurious diff and CI never flakes
11
+ // contained bytes outside the markers are never touched, so hand-written
12
+ // content cannot be clobbered by a projection
13
+ // diagnosable the region records which simt-dev SHA produced it, so drift is
14
+ // detectable rather than invisible (ADR: 0001 section 7)
15
+ // honest a malformed region is reported as malformed instead of being
16
+ // silently appended to, which is how a projection engine quietly
17
+ // duplicates a rule
18
+
19
+ // The comment syntax a managed file speaks, and where in it the region goes.
20
+ //
21
+ // A marker has to be invisible to the tool that reads the file the region lives in,
22
+ // so the syntax is a property of the FILE, not of this module. Markdown and every
23
+ // `.mdc` are HTML comments; `.editorconfig` is INI, where `#` is the comment
24
+ // character and an HTML comment would be parsed as a malformed key and make every
25
+ // editor fall back to its own defaults - the file would be worse than absent.
26
+ //
27
+ // `where: 'top'` exists for the same reason. In a file whose semantics are
28
+ // last-definition-wins, a shared block appended below the repo's own settings
29
+ // silently overrules them. No byte is removed, so nothing looks clobbered, and the
30
+ // repo's setting simply stops taking effect. Putting the shared block first means
31
+ // the repo always wins, and `check` reports a region that has been moved down.
32
+ import { CLI, MARKER_PREFIX, MARKER_PREFIX_LEGACY } from '../name.mjs';
33
+
34
+ // The region namespace moved with the command (ADR-056), like the manifest. The read
35
+ // regexes accept both spellings, so a repository still stamped with the legacy prefix
36
+ // is seen rather than reported as unadopted, and the first sync migrates it in place:
37
+ // the composed region carries only the new prefix, so the body comparison in `inject`
38
+ // finds drift and rewrites it. Writing never emits the legacy spelling, so the window
39
+ // closes on every write. A file holding BOTH regions is a state only a hand edit can
40
+ // make; the marker validation below reports it as malformed instead of guessing which
41
+ // region wins, and a human deletes the old one.
42
+ //
43
+ // Both prefixes are pinned to literals in test/naming.test.mjs, so they carry nothing
44
+ // that needs escaping inside the regexes built here.
45
+ const PREFIX = `(?:${MARKER_PREFIX}|${MARKER_PREFIX_LEGACY})`;
46
+
47
+ export const STYLES = {
48
+ html: {
49
+ begin: `<!-- ${MARKER_PREFIX}:begin -->`,
50
+ end: `<!-- ${MARKER_PREFIX}:end -->`,
51
+ line: (s) => `<!-- ${s} -->`,
52
+ note: (first, second) => `<!-- ${first}\n ${second} -->`,
53
+ sha: new RegExp(`<!--\\s*${PREFIX}:sha=([0-9a-f]{7,40})\\s*-->`),
54
+ beginRe: new RegExp(`^<!--\\s*${PREFIX}:begin\\s*-->$`, 'i'),
55
+ endRe: new RegExp(`^<!--\\s*${PREFIX}:end\\s*-->$`, 'i'),
56
+ where: 'bottom',
57
+ },
58
+ hash: {
59
+ begin: `# ${MARKER_PREFIX}:begin`,
60
+ end: `# ${MARKER_PREFIX}:end`,
61
+ line: (s) => `# ${s}`,
62
+ note: (first, second) => `# ${first}\n# ${second}`,
63
+ sha: new RegExp(`^#\\s*${PREFIX}:sha=([0-9a-f]{7,40})\\s*$`, 'm'),
64
+ beginRe: new RegExp(`^#\\s*${PREFIX}:begin\\s*$`, 'i'),
65
+ endRe: new RegExp(`^#\\s*${PREFIX}:end\\s*$`, 'i'),
66
+ where: 'top',
67
+ },
68
+ };
69
+
70
+ export const BEGIN = STYLES.html.begin;
71
+ export const END = STYLES.html.end;
72
+
73
+ /**
74
+ * The separator between hand-written text and the region, in the shape a Markdown
75
+ * formatter insists on.
76
+ *
77
+ * Markdown treats an HTML comment that follows a paragraph, a list, a fenced block
78
+ * or a heading as a new block, and every formatter inserts the blank line separating
79
+ * the two before writing it back. Measured with Prettier 3.8.1: a region appended
80
+ * after one newline comes back rewritten, and `--check` exits 1 — on a file the
81
+ * projection got right and the repository's own gate is simply enforcing. The
82
+ * projection emits the formatter's shape from the start, for the same reason it
83
+ * emits the trailing newline `compose` already owns.
84
+ *
85
+ * Note the list case is worse than a blank line: without one, Prettier reads the
86
+ * region as a continuation of the last list item and indents all of it.
87
+ */
88
+ function seam(before) {
89
+ if (before === '') return '';
90
+ return before.endsWith('\n\n') ? before : `${before.replace(/\n+$/, '')}\n\n`;
91
+ }
92
+
93
+ function styleOf(style) {
94
+ return typeof style === 'string' ? (STYLES[style] ?? STYLES.html) : (style ?? STYLES.html);
95
+ }
96
+
97
+ const FENCE = /^\s*(`{3,}|~{3,})/;
98
+
99
+ /**
100
+ * Split a document into the parts around the generated region.
101
+ *
102
+ * @param {string} text
103
+ * @param {string|object} [style] a key of `STYLES`, or a style row
104
+ * @returns {{status: 'absent'|'ok'|'malformed', before: string, body: string,
105
+ * after: string, reason?: string, sha?: string}}
106
+ */
107
+ export function parse(text, style) {
108
+ const s = styleOf(style);
109
+ const lines = text.split('\n');
110
+ const absent = { status: 'absent', before: text, body: '', after: '' };
111
+
112
+ const markers = [];
113
+ let fence = null;
114
+ for (let i = 0; i < lines.length; i += 1) {
115
+ const line = lines[i];
116
+ // Markers inside a fenced block are documentation, not control. An AGENTS.md
117
+ // that explains this mechanism with an example must not confuse the engine.
118
+ const fenceMatch = FENCE.exec(line);
119
+ if (fenceMatch) {
120
+ const char = fenceMatch[1][0];
121
+ if (fence === null) fence = char;
122
+ else if (fence === char) fence = null;
123
+ continue;
124
+ }
125
+ if (fence !== null) continue;
126
+ if (s.beginRe.test(line)) markers.push({ kind: 'begin', i });
127
+ else if (s.endRe.test(line)) markers.push({ kind: 'end', i });
128
+ }
129
+
130
+ if (markers.length === 0) return absent;
131
+
132
+ const first = markers[0];
133
+ if (first.kind !== 'begin') {
134
+ return malformed(lines, 0, first.i, 'end marker before any begin marker');
135
+ }
136
+ const last = markers[markers.length - 1];
137
+ if (last.kind !== 'end') {
138
+ return malformed(lines, first.i, first.i, 'begin marker with no matching end marker');
139
+ }
140
+
141
+ const between = markers.slice(1, -1);
142
+ const strayBegin = between.find((m) => m.kind === 'begin');
143
+ if (strayBegin) {
144
+ return malformed(lines, first.i, strayBegin.i, 'nested begin marker');
145
+ }
146
+ const strayEnd = between.find((m) => m.kind === 'end');
147
+ if (strayEnd) {
148
+ return malformed(lines, first.i, strayEnd.i, 'nested end marker');
149
+ }
150
+ if (markers.length > 2 && markers.slice(1).some((m) => m.kind === 'end' && m.i < last.i)) {
151
+ return malformed(lines, first.i, last.i, 'more than one region');
152
+ }
153
+
154
+ const body = lines.slice(first.i + 1, last.i).join('\n');
155
+ const sha = readSha(body, s);
156
+ return {
157
+ status: 'ok',
158
+ before: lines.slice(0, first.i).join('\n'),
159
+ body,
160
+ after: lines.slice(last.i + 1).join('\n'),
161
+ sha,
162
+ // The last byte of the end marker. A reader that stops at a fixed offset never
163
+ // sees a region whose end is past that offset, however correct the body is.
164
+ endByte: Buffer.byteLength(lines.slice(0, last.i + 1).join('\n')),
165
+ };
166
+ }
167
+
168
+ function malformed(lines, from, at, reason) {
169
+ return {
170
+ status: 'malformed',
171
+ before: lines.join('\n'),
172
+ body: '',
173
+ after: '',
174
+ reason,
175
+ line: at + 1,
176
+ fromLine: from + 1,
177
+ };
178
+ }
179
+
180
+ /** Pull the recorded source SHA out of the region's provenance header. */
181
+ function readSha(body, style) {
182
+ const m = style.sha.exec(body);
183
+ return m ? m[1] : undefined;
184
+ }
185
+
186
+ /**
187
+ * Render the full region text, markers and provenance header included.
188
+ * The header lives *inside* the region so it regenerates with the body and can
189
+ * never drift independently of it.
190
+ */
191
+ export function compose(body, { sha, sources = [] } = {}, style) {
192
+ const s = styleOf(style);
193
+ // A stamp this tool cannot read back is worse than no stamp: `parse` matches
194
+ // `[0-9a-f]{7,40}`, so the old `unknown` fallback produced a region that reported
195
+ // no provenance at all, while `upgrade` still compared it against real pins as a
196
+ // version (ADR-032). Identity is now a content digest and is always present, so the
197
+ // fallback is gone rather than fixed - but the asymmetry it created is worth naming.
198
+ if (typeof sha !== 'string' || !/^[0-9a-f]{7,40}$/.test(sha)) {
199
+ throw new Error(`compose: sha must match [0-9a-f]{7,40}, got ${JSON.stringify(sha)}`);
200
+ }
201
+ // Write-new, like the markers: the legacy spelling is read but never emitted, so a
202
+ // migrated region is stable and a legacy one drifts exactly once.
203
+ const header = [s.line(`${MARKER_PREFIX}:generated`), s.line(`${MARKER_PREFIX}:sha=${sha}`)];
204
+ if (sources.length > 0) {
205
+ header.push(s.line(`${MARKER_PREFIX}:sources=${sources.join(',')}`));
206
+ }
207
+ // Addressed to whoever reads this repo, not to whoever owns the rules upstream.
208
+ // "Edit the source in simt-dev" was the wrong instruction twice over: a consumer
209
+ // of the published package has no such tree, and an agent reading this repo has
210
+ // no business editing the region it was told not to edit. The only two actions
211
+ // available to a reader are: don't edit it, or regenerate it.
212
+ header.push(
213
+ s.note(
214
+ 'Generated content. Do not edit by hand - your changes are overwritten.',
215
+ `Run \`${CLI} sync --yes\` to regenerate, or \`${CLI} check\` to see what is stale.`,
216
+ ),
217
+ );
218
+ const trimmed = body.replace(/\n+$/, '');
219
+ // Two lines here exist only because a real repo proved they are needed. A
220
+ // formatter that runs on commit - several repositories have one, and three of the
221
+ // other repos do too - rewrote the region on the very first commit: it inserted a
222
+ // blank line after the header comment and added the trailing newline. Both edits
223
+ // are things prettier considers correct, and either one alone makes `check`
224
+ // permanently red on a repo whose own tooling is working correctly. A generated
225
+ // file that a formatter rewrites is a fight the projection loses, so the
226
+ // projection emits the formatter's shape in the first place. The blank line before
227
+ // END is the same class: prettier treats an HTML comment after a paragraph as a
228
+ // new block and inserts the separating blank line itself, which made
229
+ // a `format:check` fail on a file whose generated region was correct.
230
+ const out =
231
+ trimmed === ''
232
+ ? [s.begin, ...header, s.end]
233
+ : [s.begin, ...header, '', ...trimmed.split('\n'), '', s.end];
234
+ return `${out.join('\n')}\n`;
235
+ }
236
+
237
+ /**
238
+ * Put the region back into the parts either side of it.
239
+ *
240
+ * `where` decides which side is authoritative. It is a style property rather than
241
+ * a caller argument because the correct answer is a fact about the file's syntax,
242
+ * and a caller who has to remember it is a caller who will get it wrong once.
243
+ */
244
+ function splice(state, region, where) {
245
+ if (where === 'top') {
246
+ // Top placement is a repair, not a relocation: a region below the file's own
247
+ // settings is moved back above them, which is the one thing the bytes either
248
+ // side of the markers are allowed to move for. So the result here is not
249
+ // byte-comparable with the input even when nothing has changed.
250
+ const rest = `${state.before}${state.before === '' ? '' : '\n'}${state.after}`
251
+ .replace(/^\n+/, '')
252
+ .replace(/\n+$/, '');
253
+ if (rest === '') return region;
254
+ return `${region}\n${rest}\n`;
255
+ }
256
+ const before = seam(state.before);
257
+ const after = state.after.startsWith('\n') ? state.after : state.after === '' ? '' : `\n${state.after}`;
258
+ return `${before}${region}${after}`;
259
+ }
260
+
261
+ /**
262
+ * Insert or replace the region in `text`, preserving everything outside it.
263
+ *
264
+ * `region` and `previousBody` are the state the document was in, not the action taken on
265
+ * it. A caller asking "did this repository's *rules* change?" cannot read that off
266
+ * `action`: a wrong seam with a correct body is `updated` (ADR-037), and so is a changed
267
+ * body. Reporting "the rules changed" for the first is a false cause, and `upgrade` prints
268
+ * it to whoever ran the command. Two of them also answer "has this repo ever been
269
+ * adopted?" — `region: 'absent'` is that answer, and reading it off `previousBytes` gets a
270
+ * repository with hand-written prose and no region wrong.
271
+ *
272
+ * @param {object} [opts] `style` overrides the syntax; `where` overrides placement.
273
+ * @returns {{text: string, action: 'added'|'updated'|'unchanged'|'malformed', reason?: string,
274
+ * region: 'absent'|'ok'|'malformed', previousBody: string}}
275
+ */
276
+ export function inject(text, body, meta, opts = {}) {
277
+ const s = styleOf(opts.style);
278
+ const where = opts.where ?? s.where;
279
+ const state = parse(text, s);
280
+ const seen = { region: state.status, previousBody: state.body ?? '' };
281
+ if (state.status === 'malformed') {
282
+ return { ...seen, text, action: 'malformed', reason: state.reason, line: state.line };
283
+ }
284
+ const region = compose(body, meta, s);
285
+
286
+ if (state.status === 'absent') {
287
+ if (where === 'top') {
288
+ const rest = text.replace(/^\n+/, '').replace(/\n+$/, '');
289
+ return { ...seen, text: rest === '' ? region : `${region}\n${rest}\n`, action: 'added' };
290
+ }
291
+ const base = seam(text);
292
+ // No trailing newline here, and that is load-bearing. `compose` already
293
+ // terminates the region with one; adding a second left every adopted file
294
+ // ending in a blank line. Nothing in this tool noticed, because `check`
295
+ // compares region bodies and the byte after END is not part of the region —
296
+ // but a repository's own `git diff --check` gate did, and it is a gate
297
+ // that repo runs before every commit. The `updated` branch below has always
298
+ // been correct for exactly this reason.
299
+ //
300
+ // `seam` replaced a hand-rolled equivalent here. The two agreed, which is why the
301
+ // seam defect survived a read of this branch: it looks like the place the blank
302
+ // line is added, and it is — for a document with no region yet. It is not where
303
+ // the defect was, which is in `splice`.
304
+ return {
305
+ ...seen,
306
+ text: `${base}${region}`,
307
+ action: 'added',
308
+ };
309
+ }
310
+
311
+ // Compare ignoring only the trailing newline the region may pick up from the
312
+ // document; any other byte difference is real drift worth rewriting.
313
+ const current = state.body.replace(/^\n+/, '').replace(/\n+$/, '');
314
+ const next = region.slice(s.begin.length + 1, region.length - s.end.length - 1);
315
+ const unchanged = current === next.replace(/^\n+/, '').replace(/\n+$/, '');
316
+ const spliced = splice(state, region, where);
317
+
318
+ // Comparing the region BODY is not enough to decide, because the byte between the
319
+ // hand-written text and the region is part of the file a Markdown formatter reads
320
+ // too. A body that matches with a seam that does not is red on `format:check`, and
321
+ // reporting `unchanged` there means `check` finds no drift and `sync` never repairs
322
+ // it - the repository stays red with no remedy the tool offers. Top placement is
323
+ // excluded because there the splice is a repair that legitimately moves bytes
324
+ // outside the markers, so the two files differ for a reason unrelated to the seam.
325
+ const settled = unchanged && (where === 'top' || spliced === text);
326
+ if (settled) return { ...seen, text, action: 'unchanged' };
327
+
328
+ return { ...seen, text: spliced, action: 'updated' };
329
+ }
330
+
331
+ /**
332
+ * Compare a document against a desired region body without writing.
333
+ * `meta` must be the same provenance the writer would use, so a region produced
334
+ * by an older simt-dev SHA reads as drift rather than as unchanged.
335
+ *
336
+ * @returns {{action: 'added'|'updated'|'unchanged'|'malformed', reason?: string, line?: number,
337
+ * region: 'absent'|'ok'|'malformed', previousBody: string}}
338
+ */
339
+ export function diff(text, body, meta, opts = {}) {
340
+ const state = parse(text, styleOf(opts.style));
341
+ if (state.status === 'malformed') {
342
+ return { region: 'malformed', previousBody: '', action: 'malformed', reason: state.reason, line: state.line };
343
+ }
344
+ if (state.status === 'absent') {
345
+ return { region: 'absent', previousBody: '', action: 'added' };
346
+ }
347
+ const probe = inject(text, body, meta, opts);
348
+ return { region: probe.region, previousBody: probe.previousBody, action: probe.action };
349
+ }
@@ -0,0 +1,327 @@
1
+ // Project `.mdc` rules as generated regions.
2
+ //
3
+ // The estate carried three always-on `.mdc` rule families across the Social repos
4
+ // in eight files, and no two were byte-identical. The worst pair differed by one
5
+ // byte while three semantically loaded lines changed, so no size-, hash- or
6
+ // line-count-based duplicate detector could ever have flagged it. This module is
7
+ // the answer to that finding: one canonical body, projected.
8
+ //
9
+ // Two decisions are load-bearing and neither is obvious:
10
+ //
11
+ // 1. VARIANT IS THE WRONG SHAPE FOR THIS CONTENT. The two divergent BLOCK
12
+ // bullets in `test-gate` are complementary (one catches a consumer that
13
+ // requires an unshipped field, the other a producer that ships an untested
14
+ // one), and the two divergent Requirements bullets in
15
+ // `preview-postflight` are complementary too (a build-config instruction and
16
+ // a credential-handling prohibition). Choosing one per repo would keep the
17
+ // estate's worst bug — a rule file that quietly stops catching half of what
18
+ // it exists to catch. So the canonical body carries BOTH, unconditionally.
19
+ //
20
+ // 2. WHAT ACTUALLY VARIES IS SUBSTRATE, NOT POLICY. A plan-tracker glob, a test
21
+ // path, a production domain. Those become declared substitutions with
22
+ // defaults, so an unconfigured repo still receives a correct, complete rule
23
+ // and the defaults are visible rather than guessed at render time.
24
+
25
+ import { readFile, readdir } from 'node:fs/promises';
26
+
27
+ import { frontmatterBlock } from './surfaces.mjs';
28
+ import { dirname, join, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ import { parse } from './markers.mjs';
32
+
33
+ // ROOT is computed here rather than imported: project.mjs is the caller of every
34
+ // function below, so importing it back would create a cycle for the sake of one
35
+ // constant.
36
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
37
+ const MDC_DIR = join(ROOT, 'rules', 'mdc');
38
+
39
+ /**
40
+ * The keys a canonical `.mdc` may declare: ours prefixed, the editor's as documented
41
+ * (ADR-022).
42
+ *
43
+ * The allowlist earns its place twice. It is what tells a reader of the file which
44
+ * reader owns a key, and it is what turns an unprefixed simt key into a reported
45
+ * defect — which is the failure ADR-016 shipped once, a rule that reached no repo
46
+ * because `applies` was absent, silently and with a test green.
47
+ */
48
+ const KNOWN_KEYS = new Set([
49
+ 'simt-id',
50
+ 'simt-target',
51
+ 'simt-title',
52
+ 'simt-applies',
53
+ 'simt-opt-in',
54
+ 'simt-substitutions',
55
+ 'description',
56
+ 'alwaysApply',
57
+ ]);
58
+
59
+ /** A bare key beside a prefixed one is the typo this rule exists to catch. */
60
+ function unknownKey(key) {
61
+ const prefixed = `simt-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
62
+ return KNOWN_KEYS.has(prefixed)
63
+ ? `key "${key}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
64
+ : `unrecognised frontmatter key "${key}"; simt-dev's own keys are simt-id, simt-target, simt-title, simt-applies, simt-opt-in, simt-substitutions`;
65
+ }
66
+
67
+ /**
68
+ * Read every canonical `.mdc` rule with its frontmatter parsed.
69
+ *
70
+ * A rule without `simt-id` or `simt-target` is a defect rather than a default:
71
+ * `simt-id` is what a manifest keys its substitutions on, and `simt-target` is the
72
+ * only thing saying where the projected file goes. A rule that silently wrote nowhere
73
+ * would look like a successful sync.
74
+ */
75
+ export async function loadMdcRules() {
76
+ let names;
77
+ try {
78
+ names = (await readdir(MDC_DIR)).filter((n) => n.endsWith('.mdc')).sort();
79
+ } catch {
80
+ return [];
81
+ }
82
+
83
+ const out = [];
84
+ for (const file of names) {
85
+ const text = await readFile(join(MDC_DIR, file), 'utf8');
86
+ const meta = parseFrontmatter(text);
87
+ // `simt-substitutions:` is a YAML list of maps, which the shared line-based
88
+ // frontmatter parser cannot express, so it is parsed separately and strictly.
89
+ const subs = parseSubstitutions(text);
90
+ const unknown = Object.keys(meta).filter((k) => !KNOWN_KEYS.has(k));
91
+ out.push({
92
+ file,
93
+ source: `rules/mdc/${file}`,
94
+ id: meta['simt-id'] ?? null,
95
+ target: meta['simt-target'] ?? null,
96
+ title: meta['simt-title'] ?? null,
97
+ description: meta.description ?? null,
98
+ alwaysApply: meta.alwaysApply === true,
99
+ // `simt-applies` is 'always' or a tag array; `simt-opt-in` means the repo must name
100
+ // this rule in its manifest rather than matching on a detected signal. A rule that
101
+ // names one product's Workers and secrets cannot be selected by stack, because
102
+ // detection cannot distinguish a Workers repo that has that problem from one
103
+ // that does not.
104
+ applies:
105
+ meta['simt-applies'] === 'always'
106
+ ? 'always'
107
+ : Array.isArray(meta['simt-applies'])
108
+ ? meta['simt-applies']
109
+ : [],
110
+ optIn: meta['simt-opt-in'] === true,
111
+ // A rule with no `simt-substitutions:` block has no placeholders, so an empty
112
+ // list is the correct answer. (This was `substitutions` — an undefined binding
113
+ // that would only have fired on a future rule without the block, which is
114
+ // exactly the kind of latent crash a new rule is most likely to trigger.)
115
+ substitutions: subs,
116
+ bytes: Buffer.byteLength(text),
117
+ body: stripFrontmatter(text),
118
+ error:
119
+ meta['simt-id'] === null
120
+ ? 'missing simt-id in frontmatter'
121
+ : meta['simt-target'] === null
122
+ ? 'missing simt-target in frontmatter'
123
+ : unknown.length > 0
124
+ ? unknownKey(unknown[0])
125
+ : null,
126
+ });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** `simt-substitutions:` is a list of `- key: x` blocks. Any deviation is reported
132
+ * rather than skipped, because a silently-dropped substitution projects a rule
133
+ * containing a literal `{{placeholder}}`. */
134
+ function parseSubstitutions(text) {
135
+ const m = /^---\n([\s\S]*?)\n---/.exec(text);
136
+ if (!m) return [];
137
+ const block = m[1];
138
+ if (!/^simt-substitutions:/m.test(block)) return [];
139
+
140
+ const out = [];
141
+ const re = /^\s*-\s*key:\s*(\S+)\s*$/gm;
142
+ const hits = [...block.matchAll(re)];
143
+ for (const hit of hits) {
144
+ const start = hit.index + hit[0].length;
145
+ const tail = block.slice(start);
146
+ const next = /^\s*-\s*key:/m.exec(tail.slice(1));
147
+ const scope = next ? tail.slice(0, next.index + 1) : tail;
148
+ const def = /^\s*default:\s*(.*)$/m.exec(scope);
149
+ const desc = /^\s*describe:\s*(.*)$/m.exec(scope);
150
+ out.push({
151
+ key: hit[1],
152
+ default: def ? unquote(def[1].trim()) : '',
153
+ describe: desc ? unquote(desc[1].trim()) : '',
154
+ });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ function parseFrontmatter(text) {
160
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
161
+ if (!m) return {};
162
+ const out = {};
163
+ for (const line of m[1].split('\n')) {
164
+ // `simt-substitutions:` opens a nested block this parser cannot represent, so
165
+ // it and its list items are skipped here and read by parseSubstitutions.
166
+ if (/^\s*-\s/.test(line)) continue;
167
+ const kv = /^([a-zA-Z-]+):\s*(.*)$/.exec(line);
168
+ if (!kv) continue;
169
+ const [, key, raw] = kv;
170
+ if (raw === '') continue;
171
+ if (raw === 'always') out[key] = 'always';
172
+ else if (raw === 'true') out[key] = true;
173
+ else if (raw === 'false') out[key] = false;
174
+ else if (raw.startsWith('[')) {
175
+ out[key] = raw
176
+ .slice(1, raw.lastIndexOf(']'))
177
+ .split(',')
178
+ .map((s) => s.trim())
179
+ .filter(Boolean);
180
+ } else out[key] = unquote(raw.trim());
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function unquote(s) {
186
+ if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
187
+ return s.slice(1, -1);
188
+ }
189
+ return s;
190
+ }
191
+
192
+ function stripFrontmatter(text) {
193
+ return text
194
+ .replace(/^---\n[\s\S]*?\n---\n*/, '')
195
+ .replace(/^#\s+.*\n+/, '')
196
+ .trim();
197
+ }
198
+
199
+ /**
200
+ * Substitute declared placeholders.
201
+ *
202
+ * An empty-string default is a deliberate "this repo has nothing to add here"
203
+ * and the line it sits on is DROPPED. That is how `preview-postflight` reaches
204
+ * a repository without printing an empty bullet, and how a repo with no
205
+ * production-domain note gets a clean document instead of a blank one.
206
+ *
207
+ * A placeholder with neither a declared nor a default value is a defect: the
208
+ * rendered rule would ship a literal `{{key}}`, which reads as a finished rule
209
+ * and is not one.
210
+ */
211
+ export function renderMdc(rule, declared = {}) {
212
+ const problems = [];
213
+ let body = rule.body;
214
+
215
+ for (const sub of rule.substitutions) {
216
+ const has = Object.prototype.hasOwnProperty.call(declared, sub.key);
217
+ const provided = has ? declared[sub.key] : undefined;
218
+
219
+ if (has && provided === '') {
220
+ // A repo declared this key and declared it empty: the line it sits on is
221
+ // removed. This is how preview-postflight reaches a repository without
222
+ // printing a blank bullet.
223
+ body = dropOrReplace(body, sub.key, '');
224
+ continue;
225
+ }
226
+
227
+ if (provided === undefined || provided === null || provided === '') {
228
+ // Fall back to the default. An empty DEFAULT is also a drop instruction,
229
+ // which is how the same file serves a repo that has nothing to add.
230
+ if (sub.default === '') {
231
+ body = dropOrReplace(body, sub.key, '');
232
+ continue;
233
+ }
234
+ if (sub.default === undefined) {
235
+ problems.push(`substitution ${sub.key} has no default and none declared`);
236
+ continue;
237
+ }
238
+ body = dropOrReplace(body, sub.key, sub.default);
239
+ continue;
240
+ }
241
+
242
+ body = dropOrReplace(body, sub.key, String(provided));
243
+ }
244
+
245
+ // A key that was already reported as unsubstitutable is not reported a second
246
+ // time as a leftover placeholder. Two messages for one fault trains a reader
247
+ // to skim past problems, and the first message is the actionable one.
248
+ const reported = new Set(
249
+ problems
250
+ .filter((p) => /^substitution /.test(p))
251
+ .map((p) => /^substitution ([^ ]+) /.exec(p)?.[1]),
252
+ );
253
+ const leftover = [...body.matchAll(/\{\{([a-zA-Z0-9_-]+)\}\}/g)].map((m) => m[1]);
254
+ for (const key of new Set(leftover)) {
255
+ if (reported.has(key)) continue;
256
+ problems.push(`placeholder {{${key}}} was never substituted`);
257
+ }
258
+
259
+ return { body, problems };
260
+ }
261
+
262
+ /**
263
+ * Replace every `{{key}}`, or remove the whole bullet when the value is empty.
264
+ *
265
+ * "Whole bullet" rather than "the line containing the token": an empty value on a
266
+ * wrapped bullet leaves its continuation lines behind, and the rule then reads as
267
+ * half a sentence starting with a lowercase word. A markdown bullet is the token's
268
+ * line plus every more-indented line that follows it.
269
+ *
270
+ * One placeholder per line is a rule on the content side, not a preference. Dropping
271
+ * a line removes EVERY token on it, so `{{a}}{{b}}` with `a` empty deletes `b` too —
272
+ * silently, and with no leftover placeholder left to report. Two independent optional
273
+ * clauses on one line lose one clause with no diagnostic at all.
274
+ */
275
+ function dropOrReplace(text, key, value) {
276
+ const token = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
277
+ if (value !== '') return text.replace(token, value);
278
+ // A second, non-global pattern for the line scan: reusing a `/g` regex with
279
+ // `.test()` carries `lastIndex` between lines, so every other line would be
280
+ // tested against the wrong offset.
281
+ const probe = new RegExp(`\\{\\{${key}\\}\\}`);
282
+
283
+ const kept = [];
284
+ let dropping = false;
285
+ for (const line of text.split('\n')) {
286
+ if (probe.test(line)) {
287
+ dropping = true;
288
+ continue;
289
+ }
290
+ if (dropping) {
291
+ // A continuation of the bullet just dropped: indented, and not itself the
292
+ // start of a new block element.
293
+ const indent = /^(\s*)/.exec(line)[1].length;
294
+ if (line.trim() === '') continue;
295
+ if (indent > 0 && !/^\s*([-*+]|\d+\.)\s/.test(line)) continue;
296
+ dropping = false;
297
+ }
298
+ kept.push(line);
299
+ }
300
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n');
301
+ }
302
+
303
+ /** The frontmatter a projected `.mdc` keeps. `alwaysApply` and `description` are
304
+ * read by the editor before any rule text, so they belong outside the region. */
305
+ export function mdcFrontmatter(rule) {
306
+ return frontmatterBlock([
307
+ `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
308
+ `alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
309
+ ]);
310
+ }
311
+
312
+
313
+ /**
314
+ * The rule's bare name, taken from the path it declares.
315
+ *
316
+ * `simt-target` is the canonical declaration and predates the surface table; deriving
317
+ * every other surface's path from its basename means a rule names itself once and
318
+ * a rename moves every editor's file with it, instead of four paths that can
319
+ * disagree about which rule a file holds.
320
+ */
321
+ export function ruleName(rule) {
322
+ if (typeof rule.target !== 'string' || rule.target === '') return null;
323
+ const base = rule.target.slice(rule.target.lastIndexOf('/') + 1);
324
+ return base.replace(/\.mdc$/, '');
325
+ }
326
+
327
+ export { MDC_DIR, parse as parseRegion };