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,536 @@
1
+ // The decision index: what `simt-dev rules why` traces back to.
2
+ //
3
+ // Rule bodies cite the decision that settled them inline, as `(ADR-001)`. That
4
+ // citation is the only thing standing between "a rule someone wrote" and "a rule
5
+ // the org decided", so it has to be resolvable: `rules why` reads the citation,
6
+ // finds the record, and reports its title and status.
7
+ //
8
+ // The status matters more than it looks. ADR-011 was accepted and then reversed
9
+ // by ADR-012 the same day. Printing ADR-011's ruling without saying so would
10
+ // hand a reader a decision that is no longer in force - the failure mode of a
11
+ // provenance feature that only proves provenance, not currency.
12
+ //
13
+ // A rule with NO citation is a finding, not an empty result. It is reported as
14
+ // such, because "no decision behind this" is exactly what a reviewer needs to
15
+ // see and exactly what an empty output would hide.
16
+
17
+ import { readFile, readdir } from 'node:fs/promises';
18
+ import { readdirSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { MANIFEST_FILE } from '../name.mjs';
22
+ import { CLI } from '../name.mjs';
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ export const DECISIONS_DIR = resolve(HERE, '..', '..', 'docs', 'decisions');
26
+ const PACKAGE_ROOT = resolve(HERE, '..', '..');
27
+
28
+ /**
29
+ * Top-level files this tool writes into a repository that adopts it.
30
+ *
31
+ * Read one at a time, because each is somebody else's decision and none of them is
32
+ * derivable here: `AGENTS.md` is the primary target, `simt-dev.json` the manifest,
33
+ * `package.json` belongs to the adopting repository rather than to us. Anything at the
34
+ * top level NOT on this list is this package's own tree and does not exist at the root
35
+ * of a repository that ran `simt-dev init`.
36
+ *
37
+ * The list is asserted against a real `init` in `rules.test.mjs`, because a name that
38
+ * drifts onto this list stops being redacted and a name that drifts off it starts
39
+ * redacting a file the reader does have.
40
+ */
41
+ const INHERITED = ['AGENTS.md', 'package.json', `${MANIFEST_FILE}`];
42
+
43
+ /**
44
+ * Matches a location that resolves inside this package and nowhere a reader stands.
45
+ *
46
+ * Derived from the package root rather than written out, so a directory added tomorrow is
47
+ * covered by the same rule. Dot-entries are excluded because the tool writes into a
48
+ * consumer's `.github/` and `.cursor/` and those paths do travel; `INHERITED` is excluded
49
+ * because those files are the reader's own.
50
+ */
51
+ let internalPath = null;
52
+ function internalPathPattern() {
53
+ if (internalPath !== null) return internalPath;
54
+ const names = readdirSync(PACKAGE_ROOT, { withFileTypes: true })
55
+ .filter((e) => !e.name.startsWith('.') && !INHERITED.includes(e.name))
56
+ .map((e) => e.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
57
+ // A name with nothing after the slash is not a location, so the separator is required:
58
+ // that is what keeps `graph lint` — a command the reader can run — from being redacted
59
+ // by a directory that happens to share its name.
60
+ internalPath = new RegExp(`\\b(?:${names.join('|')})\\/[\\w./-]+`, 'g');
61
+ return internalPath;
62
+ }
63
+
64
+ /**
65
+ * Name the thing rather than the path, in text that travels.
66
+ *
67
+ * `rules why` output is read inside a repository that owns none of this package's files,
68
+ * and a path that resolves here resolves nowhere there. The judgement this function
69
+ * refuses to make is the one about PROSE: `docs/plans/` means this package's plan in one
70
+ * record and the adopting repository's own plans in another, and no amount of reading the
71
+ * string separates them. So this is not applied to a record's argument — it is applied to
72
+ * the `Status:` line, where every clause is a statement about THIS package by
73
+ * construction and there is no benign reading to confuse it with (S54).
74
+ *
75
+ * @param {string} text
76
+ * @returns {string}
77
+ */
78
+ export function redactInternalPaths(text) {
79
+ return String(text ?? '').replace(internalPathPattern(), 'a file in this tool');
80
+ }
81
+
82
+ const ADR_IN_TEXT = /\bADR-(\d{3})\b/g;
83
+
84
+ /**
85
+ * A line that begins one of the record's other front-matter fields.
86
+ *
87
+ * The status is a paragraph, and its continuation lines are prose that happens to
88
+ * start with a lower-case word or a `[` - the link that carries the ADR it names. A
89
+ * sibling field starts with a capitalised label and a colon, which no continuation
90
+ * line in the corpus does. Getting this wrong is not cosmetic in either direction:
91
+ * stopping early truncates the sentence that says which ruling was overturned (the
92
+ * defect this module was filed for), and reading on swallows `Affects:`, whose value
93
+ * is a list of paths - and `rules why` output is read inside a repository that owns
94
+ * none of them.
95
+ */
96
+ const SIBLING_FIELD = /^\*{0,2}[A-Z][\w]*(?:[ \t]+[\w]+){0,3}:\*{0,2}(?:[ \t]|$)/;
97
+
98
+ /**
99
+ * `ruling N <verb> by <ADR>`, in every form the corpus uses.
100
+ *
101
+ * Four spellings, all of them real: `ruling 3 superseded by ADR-046`, `rulings 1 and 2
102
+ * narrowed by ADR-026`, `rulings 3 and 4 narrowed by the same record`, and `one ruling
103
+ * narrowed by ADR-045` - which names no number at all, because the record does not say
104
+ * which ruling it meant. That last one is carried with `ruling: null` rather than
105
+ * dropped: a clause the tool can see but cannot resolve is reported as unresolved, and
106
+ * the corpus test below pins that there are none.
107
+ */
108
+ const PARTIAL_CLAUSE =
109
+ /\b(?:one ruling|rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*))\s+(superseded|narrowed|withdrawn)\s+by\s+(ADR-\d{3}|the same record)\b/gi;
110
+
111
+ /** How a record says a ruling was numbered, when it did not use a digit. */
112
+ const ORDINALS = ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
113
+ const ORDINAL_NUMBER = new Map(ORDINALS.map((w, i) => [w, i + 1]));
114
+
115
+ /**
116
+ * The `Status:` field, whole.
117
+ *
118
+ * @returns {string|null} the paragraph, flattened, or null when there is no such field.
119
+ */
120
+ function statusParagraph(text) {
121
+ const lines = text.split('\n');
122
+ const start = lines.findIndex((l) => /^Status:/.test(l));
123
+ if (start === -1) return null;
124
+ const parts = [lines[start].slice('Status:'.length)];
125
+ for (let i = start + 1; i < lines.length; i += 1) {
126
+ if (lines[i].trim() === '' || SIBLING_FIELD.test(lines[i])) break;
127
+ parts.push(lines[i]);
128
+ }
129
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
130
+ }
131
+
132
+ /**
133
+ * Rulings this record no longer stands behind, whole or in part.
134
+ *
135
+ * The shape is the reader's question rather than the writer's grammar: for each ruling
136
+ * a later record moved, which record moved it and what it did. `ruling: null` is a
137
+ * record that says "one ruling" without saying which, which is a fact about the record
138
+ * and is carried rather than resolved by guessing.
139
+ *
140
+ * `by: null` is the same honesty in the other field: "narrowed by the same record"
141
+ * resolves to the last record this status named, and a status that says "the same
142
+ * record" without having named one leaves the question open rather than answering it
143
+ * with the wrong record.
144
+ *
145
+ * @returns {{ruling: number|null, kind: string, by: string|null}[]}
146
+ */
147
+ function partialSupersessions(status) {
148
+ const out = [];
149
+ let lastNamed = null;
150
+ for (const m of status.matchAll(PARTIAL_CLAUSE)) {
151
+ const [, list, kind, ref] = m;
152
+ const by = /^ADR-/.test(ref) ? ref : lastNamed;
153
+ if (/^ADR-/.test(ref)) lastNamed = ref;
154
+ const rulings = list ? list.split(/\D+/).filter(Boolean).map(Number) : [null];
155
+ for (const ruling of rulings) out.push({ ruling, kind: kind.toLowerCase(), by });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * Every ADR cited in a body, sorted and deduped. A citation is matched anywhere
162
+ * in the text, including inside a link, because `Status: ... supersedes
163
+ * [ADR-011](ADR-011-...)` names the same record a rule body would.
164
+ *
165
+ * @returns {string[]} e.g. ['ADR-001', 'ADR-005']
166
+ */
167
+ export function citations(text) {
168
+ const found = new Set();
169
+ for (const match of String(text ?? '').matchAll(ADR_IN_TEXT)) {
170
+ found.add(`ADR-${match[1]}`);
171
+ }
172
+ return [...found].sort();
173
+ }
174
+
175
+ /**
176
+ * Load every ADR record once.
177
+ *
178
+ * An unreadable or unparseable record is an error rather than a skipped file: a
179
+ * missing decision is the one thing `rules why` must never report silently.
180
+ *
181
+ * @returns {Promise<{ok: boolean, decisions: object[], errors: string[]}>}
182
+ */
183
+ export async function loadDecisions() {
184
+ let names;
185
+ try {
186
+ names = (await readdir(DECISIONS_DIR)).filter((n) => /^ADR-\d{3}-.*\.md$/.test(n)).sort();
187
+ } catch (error) {
188
+ return { ok: false, decisions: [], errors: [`${DECISIONS_DIR}: unreadable (${error.message})`] };
189
+ }
190
+
191
+ const decisions = [];
192
+ const errors = [];
193
+
194
+ for (const file of names) {
195
+ const text = await readFile(join(DECISIONS_DIR, file), 'utf8');
196
+ const id = /^ADR-(\d{3})/.exec(file)?.[0];
197
+ if (!id) {
198
+ errors.push(`${file}: filename does not start with ADR-NNN`);
199
+ continue;
200
+ }
201
+ const heading = /^#\s+(.*)$/m.exec(text)?.[1] ?? null;
202
+ if (!heading) {
203
+ errors.push(`${file}: no level-1 heading`);
204
+ continue;
205
+ }
206
+ const rawStatus = statusParagraph(text);
207
+ if (rawStatus === null) {
208
+ errors.push(`${file}: no Status: line, so currency cannot be reported`);
209
+ continue;
210
+ }
211
+ // Every field below is read off the flattened paragraph, with markdown already
212
+ // removed. The link form and the bare form are then one spelling, so a record that
213
+ // writes `superseded by ADR-046` and one that writes `superseded by
214
+ // [ADR-046](...)` are read by the same line of code rather than by two regexes that
215
+ // have to be kept in step.
216
+ const status = stripMarkdown(rawStatus);
217
+ const partial = partialSupersessions(status);
218
+ // The heading repeats the id; the decision title is what follows the dash.
219
+ const title = heading.replace(/^ADR-\d{3}\s*[—–-]\s*/, '').trim();
220
+
221
+ decisions.push({
222
+ id,
223
+ file,
224
+ title,
225
+ // Everything structural above this line is derived from the record's own prose and
226
+ // is therefore unredacted: `partial` in particular has to say "ruling 1 superseded
227
+ // by ADR-053", and a ruling number is not a location. What a reader in another
228
+ // repository receives is the status line, so that is what is scrubbed — here, once,
229
+ // rather than at each of the four places that print it.
230
+ status: redactInternalPaths(redactCommand(status)),
231
+ // Anchored to the start of the status, and this is the whole of the rule. A record
232
+ // is superseded only when its own status says so first. Unanchored, the clause
233
+ // "ruling 2 superseded by [ADR-024]" inside ADR-023's paragraph matched, and the
234
+ // command would then answer "ruling 2" by printing ADR-024's rulings and
235
+ // suppressing the five in ADR-023 that still stand - a partial supersession
236
+ // silently becoming a total one, which is the reverse of what the status says.
237
+ accepted: /^accepted\b/i.test(status),
238
+ supersededBy: firstMatch(status, /^superseded by ADR-(\d{3})/i),
239
+ supersedes: [...status.matchAll(/supersedes ADR-(\d{3})(?!\s+[Ss]?rulings?\b)/g)].map(
240
+ (m) => `ADR-${m[1]}`,
241
+ ),
242
+ partial,
243
+ summary: summarise(text, partial),
244
+ bytes: Buffer.byteLength(text),
245
+ });
246
+ }
247
+
248
+ return { ok: errors.length === 0, decisions, errors };
249
+ }
250
+
251
+ /** Index by id, for a lookup per cited ADR. */
252
+ export function byId(decisions) {
253
+ return new Map(decisions.map((d) => [d.id, d]));
254
+ }
255
+
256
+ /**
257
+ * What the decision actually says, in the reader's terms.
258
+ *
259
+ * A citation has to be *resolvable*, or it is decoration. Printing only a title and a
260
+ * status answers "does this exist and is it current?" but not "why am I being told
261
+ * this?", which is the only question an agent following `(ADR-005)` actually has. The
262
+ * full record is far too long to print, so this takes the ruling and the alternatives
263
+ * it refused — the two parts that generalise to a repository that owns none of the
264
+ * files the record cites.
265
+ *
266
+ * The `## Context` section is deliberately NOT summarised. It is a survey of what
267
+ * specific repositories used to say, which is exactly what makes an ADR worth writing
268
+ * and exactly what a reader in another repository cannot follow. The rule itself is
269
+ * already in full, in plain language, in the projected content; the citation exists
270
+ * for the reader who doubts it, and that reader needs the ruling, not the estate.
271
+ *
272
+ * The `ruled` and `rejected` halves are the record's own argument and are printed as
273
+ * written. This comment used to claim they could "never contain a path", which was never
274
+ * true: measured over the corpus, five records name a location that resolves only inside
275
+ * this package. The claim was left in place because it is the right aspiration and an
276
+ * unverifiable one, and an unverifiable promise in a docstring is the same defect ADR-049
277
+ * is about — a sentence that reads like evidence and carries none. S54 fixed the field
278
+ * where the promise can be kept (`Status:`, scrubbed at load) and narrowed this one to
279
+ * the truth: prose is the record's argument, and redaction here would have to guess
280
+ * whether `docs/plans/` means this package's plan or the reader's.
281
+ *
282
+ * @param {string} text the whole record
283
+ * @param {{ruling: number|null, kind: string, by: string|null}[]} partial the record's
284
+ * partial supersessions, so a ruling that is no longer in force can say so where it is
285
+ * printed rather than only in the status line above it
286
+ * @returns {{ruled: string[], rulingNumbers: (number|null)[], rejected: string[], writtenAgainst: string[]}}
287
+ */
288
+ function summarise(text, partial = []) {
289
+ // A section ends at the next `## ` — but not at one inside a fenced code block.
290
+ // ADR-007's entire ruling is a document skeleton in a fence whose headings are `##`,
291
+ // so the old lookahead ended the section at the first line of the ruling itself. It
292
+ // went unnoticed because the truncated remnant was still longer than the minimum
293
+ // length, and the test that asks whether every record yields a ruling asked only that.
294
+ const section = (name) => {
295
+ const lines = text.split('\n');
296
+ const start = lines.findIndex((l) => l.startsWith(`## ${name}`));
297
+ if (start === -1) return '';
298
+ const out = [];
299
+ let fence = false;
300
+ for (let i = start + 1; i < lines.length; i += 1) {
301
+ if (lines[i].trim().startsWith('```')) fence = !fence;
302
+ else if (!fence && lines[i].startsWith('## ')) break;
303
+ out.push(lines[i]);
304
+ }
305
+ return out.join('\n');
306
+ };
307
+
308
+ // The records number their rulings in five ways and this has to read all of them,
309
+ // because a supersession names a ruling by number and a number that cannot be located
310
+ // cannot be reported as no longer in force. Measured across the corpus: `1. **bold**`
311
+ // (17 records), `### 1. Title` (4), `**One.**` (7), `**1. prose**` (8), and plain
312
+ // paragraphs with no number at all (the first twelve). Reading only the first form is
313
+ // what merged the other four into one blob per record, so ADR-045 declared six rulings
314
+ // and printed one, and ADR-013 printed the text "### 2." to an agent as though the
315
+ // record had said it.
316
+ //
317
+ // A blank line does not close a ruling. A record's parenthetical about its own rulings
318
+ // sits between them, and treating the gap as the boundary attributes that aside to
319
+ // whichever ruling happens to precede it.
320
+ // A record's ruling section has one family, decided by its first line, and that
321
+ // family is what opens a ruling for the whole section. Without the decision, a
322
+ // numbered list *inside* a ruling reads as four more rulings - ADR-015 lists four
323
+ // resolution strategies under `### 2.` and the first draft printed them as rulings
324
+ // numbered 1, 2 and 3. With it, a ruling is whatever the section's own shape says it
325
+ // is, and a list inside a ruling stays inside it.
326
+ //
327
+ // The families are the ones the corpus uses, measured rather than imagined: `### 1.`
328
+ // (4 records), `1. **bold**` (17), `**1. prose**` (8), `**One.**` (7), a bare bullet
329
+ // list (1), and plain paragraphs with no numbering at all (the first twelve). Reading
330
+ // only the second is what merged the rest into one blob per record: ADR-045 declared
331
+ // six rulings and printed one, and ADR-013 printed the literal text "### 2." to an
332
+ // agent as though the record had ruled with it.
333
+ //
334
+ // A blank line does not close a ruling. A record's parenthetical about its own rulings
335
+ // sits between them, and treating the gap as the boundary attributes that aside to
336
+ // whichever ruling happens to precede it.
337
+ // The closing `**` is optional in both families, because a record's emphasis runs past
338
+ // the end of the line - ADR-039's first ruling is `**1. A command whose output a CI
339
+ // step consumes…` and the marker does not close until four lines later. The markers
340
+ // are stripped from the text afterwards either way.
341
+ const BULLET = /^[-*]\s+/;
342
+ const FAMILIES = [
343
+ { id: 'heading', open: /^#{2,6}\s+(\d+)\.\s*(.*)$/, number: (m) => Number(m[1]), demark: true },
344
+ { id: 'numbered-bold', open: /^\*\*(\d+)\.\s/, number: (m) => Number(m[1]) },
345
+ { id: 'word-bold', open: /^\*\*([A-Z][a-z]+)\.(?:\*\*|\s)/, number: (m) => ORDINAL_NUMBER.get(m[1]) ?? null },
346
+ { id: 'numbered', open: /^(\d+)\.\s+(.*)$/, number: (m) => Number(m[1]) },
347
+ { id: 'bullet', open: BULLET, number: () => null, demark: true },
348
+ ];
349
+
350
+ const units = (body) => {
351
+ // The family is read off the first line that looks like any of them, not off the
352
+ // first line. ADR-032 opens its ruling section with a bold lead paragraph and only
353
+ // then numbers its rulings, and requiring the marker on line one leaves that record
354
+ // a single merged block.
355
+ const family = FAMILIES.find((f) => body.split('\n').some((l) => f.open.test(l.trim())));
356
+ const blocks = [];
357
+ let current = null;
358
+ let fence = false;
359
+ for (const raw of body.split('\n')) {
360
+ const line = raw.trim();
361
+ if (line === '') continue;
362
+ // A fence marker is structure and never part of a ruling. Its contents are: ADR-007's
363
+ // entire ruling is a skeleton in a code block, so dropping the contents left the
364
+ // record with a lead-in and no ruling at all. What a fence does forbid is opening
365
+ // one — a line of YAML or JSON inside a block must not be read as `1. ` and split
366
+ // the ruling around it.
367
+ if (line.startsWith('```')) {
368
+ fence = !fence;
369
+ continue;
370
+ }
371
+ const m = !fence && family ? family.open.exec(line) : null;
372
+ if (m) {
373
+ // A new ruling closes the one it was appended to, whether or not there was a
374
+ // blank line between them.
375
+ if (current) blocks.push(current);
376
+ current = {
377
+ number: family.number(m),
378
+ // The heading marker and the bullet are structure. Leaving `###` or `-` in the
379
+ // text hands an agent a line of markdown as though the record had ruled with
380
+ // it. The numbered families keep their number on purpose: it is what a
381
+ // supersession names, and this text is what the annotation is attached to.
382
+ lines: [family.demark ? line.replace(/^#{2,6}\s+/, '').replace(BULLET, '') : line],
383
+ };
384
+ } else if (current) {
385
+ // A continuation line folds into the ruling it belongs to, except the next
386
+ // heading of any level, which is structure rather than prose.
387
+ if (!/^#{2,6}\s/.test(line)) current.lines.push(line);
388
+ } else {
389
+ current = { number: null, lines: [line] };
390
+ }
391
+ }
392
+ if (current) blocks.push(current);
393
+ return blocks
394
+ .map((b) => ({
395
+ number: b.number,
396
+ text: b.lines.join(' ').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim(),
397
+ }))
398
+ .filter((p) => p.text.length > 40 && !p.text.startsWith('|'));
399
+ };
400
+
401
+ // A ruling that a later record moved says so where it is printed. Without this the
402
+ // only place the fact existed was the status line, and ADR-023's status line is the
403
+ // one the parser used to truncate - so the reversed ruling and the sentence saying it
404
+ // was reversed were lost by the same line of code.
405
+ const mark = (text2, number) => {
406
+ const hit = partial.find((p) => p.ruling === number);
407
+ if (!hit) return text2;
408
+ const by = hit.by ? ` by ${hit.by}` : ' by a record this status line does not name';
409
+ return `${text2} (${hit.kind}${by})`;
410
+ };
411
+
412
+ const rejected = units(section('Rejected'))
413
+ .slice(0, 3)
414
+ .map((p) => sentence(redactCommand(p.text)));
415
+
416
+ // A lead-in ending in a colon introduces what follows it, so on its own it is a
417
+ // sentence with no predicate. Kept only when nothing follows.
418
+ const ruled = units(section('Ruling') || section('Rulings'));
419
+ const withLead = ruled.filter((p, i) => !p.text.endsWith(':') || i === ruled.length - 1);
420
+
421
+ const printed = withLead.slice(0, 2);
422
+ return {
423
+ ruled: printed.map((p) => mark(sentence(redactCommand(p.text)), p.number)),
424
+ // The number each printed ruling declares, parallel to `ruled`. It is the join
425
+ // between a supersession and the text it applies to, so it is exported rather than
426
+ // kept inside: a family that stopped numbering its rulings — `**One.**` losing the
427
+ // ordinal is one line of code — would otherwise be invisible, because no record in
428
+ // the estate has both a word-numbered ruling and a partial supersession to catch it.
429
+ rulingNumbers: printed.map((p) => p.number),
430
+ rejected,
431
+ // Named rather than stripped. A record that cites this org's repositories is still
432
+ // the record; silently deleting the citation would hide which decisions were made in
433
+ // response to what, and would mangle sentences mid-clause.
434
+ writtenAgainst: [...new Set(text.match(ESTATE) ?? [])].sort(),
435
+ };
436
+ }
437
+
438
+ /**
439
+ * A repository name from the estate this tool was written in.
440
+ *
441
+ * `simt-dev` is the tool, so a reader needs that word. Every other `simt-*` name is a
442
+ * repository, matched by prefix so a new one cannot pass by being absent from a list.
443
+ * The skill registry's `source.repo` values are asserted to match this (S14); a name
444
+ * the registry adds that is not `simt-*` has to be added here or that assertion fails.
445
+ *
446
+ * Not global: callers reuse it with `exec`, and a `/g` flag would remember `lastIndex`.
447
+ */
448
+ /**
449
+ * Identifiers that belong to the repositories this tool was built against, rather than
450
+ * to anything a reader has.
451
+ *
452
+ * This used to be a list of names, and that was the defect: the guard that exists to
453
+ * keep those names out of shipped text was itself shipped, carrying them. Every
454
+ * adopting repository could read the portfolio out of `src/`.
455
+ *
456
+ * So it matches shapes instead:
457
+ *
458
+ * - `simt-<something>` that is not `simt-dev`, which is most of the estate
459
+ * - `repo-NN`, the pseudonyms the decision records now use in place of a name. These
460
+ * must not leak either: a label that identifies nothing publicly is still a label
461
+ * that joins one record to another, and shipping it into a consumer's repository
462
+ * would carry that join with it.
463
+ *
464
+ * What this no longer catches, stated plainly: three estate repositories whose names
465
+ * do not begin `simt-`, which a shape-based rule cannot recognise without enumerating
466
+ * them - which is what this file used to do. Detection of those now depends on a name
467
+ * never being written into shipped text, rather than on a check.
468
+ */
469
+ export const ESTATE_NAME =
470
+ /\b(?:simt-(?!dev\b)[\w-]+|repo-\d{2})\b/;
471
+
472
+ /** Replace estate repository names with a phrase a reader who does not have them can use. */
473
+ export function redactEstate(text) {
474
+ return text.replace(new RegExp(ESTATE_NAME.source, 'g'), 'a repository');
475
+ }
476
+
477
+ /**
478
+ * Commands that existed when a record was written and that this package no longer installs.
479
+ *
480
+ * The binary was `simt-dev` for the whole of this corpus and is `simt` now. The records
481
+ * quote it verbatim, often quoting the output of the version they were written against:
482
+ * ADR-036 instructs the reader to `run simt-dev sync --yes`, and a reader who types that
483
+ * today gets a shell error. Fifty-odd records ship in the package and are opened with
484
+ * `rules why` from inside an adopting repository.
485
+ *
486
+ * The alternative was to rewrite the records, and this repository's rule is that a record
487
+ * is never edited in place - the quoted command is evidence of what happened, so it stays
488
+ * in the file and is converted on the way out. Same shape as redactEstate above, for the
489
+ * same reason: what travels must be actionable, and what lives here must stay true.
490
+ *
491
+ * Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
492
+ * `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
493
+ */
494
+ const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|--[a-z-]+)';
495
+
496
+ export function redactCommand(text) {
497
+ return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
498
+ }
499
+
500
+ /**
501
+ * Repositories the records were written about, so a maintainer can see which decisions
502
+ * cite which. `rules why` redacts them before printing: the names are not actionable in
503
+ * a repository that does not contain those files.
504
+ *
505
+ * Same shape rule as {@link ESTATE_NAME} and for the same reason - this shipped too, so
506
+ * enumerating the names here put the portfolio in every adopting repository. The records
507
+ * themselves now carry `repo-NN` labels, so that is what this matches in practice.
508
+ */
509
+ const ESTATE = /\b(?:simt-(?!dev\b)[\w-]+|repo-\d{2})\b/gi;
510
+
511
+ /** Trim to whole sentences within a budget, never leaving a stub fragment. */
512
+ function sentence(text, limit = 260) {
513
+ const flat = text.replace(/\s+/g, ' ').trim();
514
+ if (flat.length <= limit) return flat;
515
+ const cut = flat.slice(0, limit);
516
+ const stop = cut.lastIndexOf('. ');
517
+ if (stop > limit * 0.4) return cut.slice(0, stop + 1);
518
+ const word = cut.lastIndexOf(' ');
519
+ return `${cut.slice(0, word).replace(/[,;:]$/, '')}...`;
520
+ }
521
+
522
+ function stripMarkdown(text) {
523
+ // Links are flattened to their label and emphasis markers are dropped: this
524
+ // output is read by agents, and `[ADR-012](ADR-012-windows-support.md)` is a
525
+ // fact an agent cannot use without re-parsing markdown.
526
+ return text
527
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
528
+ .replace(/[*`]/g, '')
529
+ .replace(/\s+/g, ' ')
530
+ .trim();
531
+ }
532
+
533
+ function firstMatch(text, re) {
534
+ const m = re.exec(text);
535
+ return m ? `ADR-${m[1]}` : null;
536
+ }