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,202 @@
1
+ // What the repository pins, and whether those pins agree with each other.
2
+ //
3
+ // rules/stacks/node.md already makes four claims about this and enforces none of them:
4
+ // one `packageManager` field matching the committed lockfile, a floor in `engines.node`,
5
+ // a `.nvmrc` that agrees with both, and `engines` stated at all. This module is the
6
+ // gate for those claims, so the file is read but never written: the values are the
7
+ // repository's decisions and a version is not this tool's to pick.
8
+ //
9
+ // The one thing deliberately not checked is whether one repository's node major matches
10
+ // another's. The plan called three `.nvmrc` files "disagreeing", and reading them showed
11
+ // they agree with their own `engines` perfectly; what differs is a per-repository choice
12
+ // that each one has a reason for. A check that reported that would be a check with no
13
+ // remedy, and a gate whose finding cannot be acted on is the failure AGENTS.md exists to
14
+ // prevent.
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ import { fingerprint } from './resolve/fingerprint.mjs';
20
+ import { readJsonc } from './tsconfig.mjs';
21
+
22
+ export const LOCKFILES = [
23
+ { file: 'package-lock.json', manager: 'npm' },
24
+ { file: 'pnpm-lock.yaml', manager: 'pnpm' },
25
+ { file: 'yarn.lock', manager: 'yarn' },
26
+ { file: 'bun.lock', manager: 'bun' },
27
+ { file: 'bun.lockb', manager: 'bun' },
28
+ ];
29
+
30
+ const PKG_FILE = 'package.json';
31
+ const NVMRC_FILE = '.nvmrc';
32
+
33
+ /**
34
+ * The comparators this tool will evaluate, and nothing else.
35
+ *
36
+ * A range it does not understand produces no finding rather than an approximate one: a
37
+ * wrong contradiction names two values that may well be fine, and a reader who cannot tell
38
+ * which of our readings was the guess stops trusting the ones we got right. Anything not
39
+ * listed here is deliberately silent.
40
+ *
41
+ * @returns {null | {min: number[], max: number[]|null, minExclusive: boolean}}
42
+ */
43
+ function comparator(text) {
44
+ const operator = /^(>=|<=|>|<|\^|~|=)?\s*(\S+)$/.exec(text.trim());
45
+ const sign = operator[1] ?? '';
46
+ const version = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(operator[2].replace(/^[v=]+/, ''));
47
+ if (!version) return null;
48
+ const parts = [Number(version[1]), Number(version[2] ?? 0), Number(version[3] ?? 0)];
49
+ const major = parts[0];
50
+ const minor = parts[1];
51
+ const nextMinor = { min: parts, minExclusive: false, max: [major, minor + 1, 0], maxExclusive: true };
52
+ const unbounded = { max: null, maxExclusive: false };
53
+
54
+ if (sign === '>=') return { min: parts, minExclusive: false, ...unbounded };
55
+ if (sign === '>') return { min: parts, minExclusive: true, ...unbounded };
56
+ if (sign === '<=') return { min: null, minExclusive: false, max: parts, maxExclusive: false };
57
+ if (sign === '<') return { min: null, minExclusive: false, max: parts, maxExclusive: true };
58
+ if (sign === '^') {
59
+ const ceiling = major === 0 ? (minor === 0 ? [0, 0, parts[2] + 1] : [0, minor + 1, 0]) : [major + 1, 0, 0];
60
+ return { min: parts, minExclusive: false, max: ceiling, maxExclusive: true };
61
+ }
62
+ if (sign === '~') return nextMinor;
63
+ // A bare `22` means every 22, as npm reads it, and `22.1` every 22.1. Reading either as
64
+ // one exact version would report a matching pin as a contradiction.
65
+ if (version[3] !== undefined) return { min: parts, minExclusive: false, max: parts, maxExclusive: false };
66
+ if (version[2] !== undefined) return nextMinor;
67
+ return { min: parts, minExclusive: false, max: [major + 1, 0, 0], maxExclusive: true };
68
+ }
69
+
70
+ /** @returns {null | number[]} `null` when the range uses something this tool does not evaluate. */
71
+ export function allows(range, version) {
72
+ const target = typeof version === 'string' ? version.trim().replace(/^[v=]+/, '') : version;
73
+ if (!/^\d+(\.\d+)*$/.test(String(target))) return null;
74
+ const wanted = String(target).split('.').map(Number);
75
+ const padded = [wanted[0], wanted[1] ?? 0, wanted[2] ?? 0];
76
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
77
+
78
+ // An `||` satisfies if any branch does; every branch must be readable or the answer is
79
+ // unknown rather than "no".
80
+ const branches = String(range).split('||');
81
+ const read = branches.map((branch) => branch.trim().split(/\s+/).map(comparator));
82
+ if (read.some((terms) => terms.some((term) => term === null))) return null;
83
+
84
+ return read.some((terms) =>
85
+ terms.every((term) => {
86
+ if (term.min !== null) {
87
+ const cmp = compare(padded, term.min);
88
+ if (cmp < 0 || (cmp === 0 && term.minExclusive)) return false;
89
+ }
90
+ if (term.max === null) return true;
91
+ const cmp = compare(padded, term.max);
92
+ return cmp < 0 || (cmp === 0 && !term.maxExclusive);
93
+ })
94
+ );
95
+ }
96
+
97
+ /**
98
+ * @returns {Promise<{manager: string, lockfile: string, declared: string|null,
99
+ * engines: string|null, nvmrc: string|null}[]>} one entry per lockfile found
100
+ */
101
+ export async function readPins(root) {
102
+ // `fingerprint().manifest` is this tool's own manifest, not package.json, so the node
103
+ // stack tag decides whether there is a package.json to read and the file is read here.
104
+ const { stack } = await fingerprint(root);
105
+ if (!stack.includes('node')) return [];
106
+ const text = await readFile(join(root, PKG_FILE), 'utf8').catch(() => null);
107
+ if (text === null) return [];
108
+ const { value: pkg, error } = readJsonc(text);
109
+ if (error !== null) return [];
110
+
111
+ const found = [];
112
+ for (const entry of LOCKFILES) {
113
+ if (!(await readFile(join(root, entry.file), 'utf8').then(() => true).catch(() => false))) continue;
114
+ found.push({
115
+ manager: entry.manager,
116
+ lockfile: entry.file,
117
+ declared: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
118
+ engines: pkg.engines && typeof pkg.engines.node === 'string' ? pkg.engines.node : null,
119
+ nvmrc: await readFile(join(root, NVMRC_FILE), 'utf8').then((t) => t.trim()).catch(() => null),
120
+ });
121
+ }
122
+ return found;
123
+ }
124
+
125
+ /**
126
+ * @returns {Promise<{file: string, status: string, detail: string, remedy: string}[]>}
127
+ */
128
+ export async function checkToolchain(root) {
129
+ const pins = await readPins(root);
130
+ const findings = [];
131
+ const report = (file, status, detail, remedy) => findings.push({ file, status, detail, remedy });
132
+
133
+ if (pins.length === 0) return findings;
134
+
135
+ // Two lockfiles is not a disagreement this tool can resolve by picking one, so it is
136
+ // reported as what it is: the repository does not have a single package manager.
137
+ if (pins.length > 1) {
138
+ report(
139
+ PKG_FILE,
140
+ 'mismatched',
141
+ `this repo has ${pins.length} lockfiles (${pins.map((p) => p.lockfile).join(', ')}), so the ` +
142
+ 'package manager is whichever one each contributor installed',
143
+ 'delete the lockfile for the manager this repo does not use, and commit the other one',
144
+ );
145
+ return findings;
146
+ }
147
+
148
+ const [pin] = pins;
149
+
150
+ if (pin.declared === null) {
151
+ report(
152
+ PKG_FILE,
153
+ 'undeclared',
154
+ `this repo commits ${pin.lockfile} and declares no "packageManager", so the manager is ` +
155
+ 'whoever installed it last and corepack is free to pick the other one',
156
+ `npm pkg set packageManager=${pin.manager}@$(npm --version) (run it here; it fills in the version this machine has)`,
157
+ );
158
+ } else if (pin.declared.split('@')[0] !== pin.manager) {
159
+ report(
160
+ PKG_FILE,
161
+ 'mismatched',
162
+ `"packageManager" says ${pin.declared} and this repo commits ${pin.lockfile}, so the field ` +
163
+ 'pins a manager whose lockfile is not the one being committed',
164
+ `npm pkg set packageManager=${pin.manager}@$(npm --version)`,
165
+ );
166
+ }
167
+
168
+ if (pin.engines === null) {
169
+ // The remedy prefers the version the repository already pins over the one this machine
170
+ // happens to run. Taking the local one is what turns a single missing floor into two
171
+ // findings: a developer on a newer node than the repo's `.nvmrc` would floor above their
172
+ // own pin and be handed a contradiction the moment they acted on it.
173
+ const floor = pin.nvmrc !== null
174
+ ? ` (${NVMRC_FILE} already pins ${pin.nvmrc}; floor to that version, not to this machine's)`
175
+ : ' (run it here; it fills in the version this machine has)';
176
+ report(
177
+ PKG_FILE,
178
+ 'unfloored',
179
+ `this repo commits ${pin.lockfile} and declares no "engines": {"node": ...}, so the floor ` +
180
+ 'is a comment and the version is whatever CI happens to be running',
181
+ `node --version (then add "engines": {"node": ">=<that version>"} to package.json)${floor}`,
182
+ );
183
+ }
184
+
185
+ // A `.nvmrc` that the declared floor forbids is the one contradiction in this file that
186
+ // has a single obvious answer, because the floor is the promise and the pin is the
187
+ // instance: the pin is the thing that must move.
188
+ if (pin.nvmrc !== null && pin.engines !== null) {
189
+ const satisfies = allows(pin.engines, pin.nvmrc);
190
+ if (satisfies === false) {
191
+ report(
192
+ NVMRC_FILE,
193
+ 'contradiction',
194
+ `${NVMRC_FILE} pins ${pin.nvmrc} and "engines": {"node": "${pin.engines}"} does not allow it, ` +
195
+ 'so the version a developer gets locally is one the project says it does not support',
196
+ `change ${NVMRC_FILE} to a version that satisfies "${pin.engines}", or raise "engines": {"node": ...} to cover ${pin.nvmrc}`,
197
+ );
198
+ }
199
+ }
200
+
201
+ return findings;
202
+ }
@@ -0,0 +1,314 @@
1
+ // `tsconfig.base.json`: the strictness floor every TypeScript repo in the estate agrees on.
2
+ //
3
+ // This is ADR-026's mechanism — a config whose grammar has no comments is a whole file the
4
+ // repo owns from the first commit — pointed at the file ADR-025 ruled out by name. Two
5
+ // consequences shape everything here, and both are easy to get wrong:
6
+ //
7
+ // 1. A base file is a FLOOR, not a policy. `extends` lets a child override any key, so a
8
+ // base can only repair a repo that is *missing* a flag. It can never repair one that
9
+ // spells a flag differently — the child wins and the base is inert for that key. Every
10
+ // key below is therefore one the estate already agrees on, and the one place the estate
11
+ // disagrees is a check instead (see `checkTsconfig`).
12
+ // 2. The bytes cannot explain themselves. JSON has no comments, so this file ships with no
13
+ // header saying who wrote it or what a reader must do next, which is normally the one
14
+ // thing a shipped file owes its reader. `init` prints the `extends` line it requires
15
+ // and `check` reports a repo that has not adopted it, so the obligation is met by the
16
+ // tool's output rather than the file's.
17
+ //
18
+ // The whole file is owned by `init` alone. It is not in `src/ci/` and not in
19
+ // `src/projection/baselines.mjs` because it is neither a `.github` file that `simt-dev ci`
20
+ // refreshes nor a marker region: `simt-dev check` reports its absence and never its contents.
21
+
22
+ import { readFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+
25
+ import { fingerprint } from './resolve/fingerprint.mjs';
26
+ import { CLI } from './name.mjs';
27
+
28
+ export const TSCONFIG_BASE_FILE = 'tsconfig.base.json';
29
+ export const TSCONFIG_FILE = 'tsconfig.json';
30
+
31
+ /**
32
+ * The strictness floor.
33
+ *
34
+ * Every key here is already set, and set the same way, by every `tsconfig.json` in the
35
+ * estate that could afford it. One repository is the outlier and it is not one on purpose: it
36
+ * is missing the seven flags below that the three social repos share, which is the gap
37
+ * plan section 3.1 row 5 named. Nothing in this object is a value anybody has argued about;
38
+ * it is the intersection of what four repositories independently wrote.
39
+ *
40
+ * Absent, and each for a reason that would be invisible to the next reader:
41
+ *
42
+ * - `noEmit`. node.md:36 wants it for "any project that bundles elsewhere", and it would
43
+ * be harmless to inherit here because a child can set `false`. It is left out because a
44
+ * base that hands `noEmit` to a repo nobody looked at is a build that stops emitting,
45
+ * and the two repositories that bundle elsewhere did not ask for it. A floor nobody
46
+ * opted into is not a floor.
47
+ * - `moduleResolution`. All four repositories set it; three write `Bundler` and one writes
48
+ * `bundler`. Shipping either would change nothing, because the child overrides the base
49
+ * and the disagreement is precisely that the child sets it. `check` reports the spelling
50
+ * instead.
51
+ * - `target`, `lib`, `module`. Genuinely per-repo: `lib` is DOM+DOM.Iterable in two
52
+ * repositories and DOM+WebWorker in the other two, and a base that chose would silence
53
+ * one of them. These describe the platform, not the estate.
54
+ * - `include`, `exclude`, `paths`, `types`, `jsx`. What a project is made of.
55
+ */
56
+ export const STRICTNESS_FLOOR = {
57
+ strict: true,
58
+ noUncheckedIndexedAccess: true,
59
+ exactOptionalPropertyTypes: true,
60
+ noImplicitOverride: true,
61
+ useUnknownInCatchVariables: true,
62
+ noFallthroughCasesInSwitch: true,
63
+ noImplicitReturns: true,
64
+ isolatedModules: true,
65
+ verbatimModuleSyntax: true,
66
+ allowJs: false,
67
+ resolveJsonModule: true,
68
+ skipLibCheck: true,
69
+ };
70
+
71
+ /**
72
+ * `moduleResolution` as TypeScript documents it.
73
+ *
74
+ * The parser is case-insensitive, which is the whole reason the estate has drifted: both
75
+ * `bundler` and `Bundler` work, so nothing ever told anybody the other one did not. node.md:38
76
+ * asks for it "spelled consistently across every repo in the workspace", and consistency
77
+ * with an external document is the only spelling that can be checked in one repo at a time
78
+ * — consistency with the other repositories would need a workspace, and `check` runs in a
79
+ * repo on its own.
80
+ */
81
+ export const DOCUMENTED_RESOLUTIONS = new Set([
82
+ 'classic',
83
+ 'node',
84
+ 'node10',
85
+ 'node16',
86
+ 'nodenext',
87
+ 'bundler',
88
+ ]);
89
+
90
+ /**
91
+ * The base file, as bytes.
92
+ *
93
+ * Key order is the order of `STRICTNESS_FLOOR` and not alphabetical, because the object
94
+ * literal is the readable form and reordering it here would mean the file and the reason
95
+ * disagree. `JSON.stringify` preserves insertion order, so the two cannot drift.
96
+ *
97
+ * @returns {string} JSON, LF-terminated
98
+ */
99
+ export function renderTsconfigBase() {
100
+ return `${JSON.stringify({ compilerOptions: STRICTNESS_FLOOR }, null, 2)}\n`;
101
+ }
102
+
103
+ /**
104
+ * Parse a file that TypeScript allows to carry comments.
105
+ *
106
+ * `tsconfig.json` is JSONC: tsc strips comments before reading it, so a repo with a comment
107
+ * at the top of its tsconfig is normal and not a mistake. A plain `JSON.parse` would throw,
108
+ * and a gate that throws on a file it did not write is worse than a gate that says nothing
109
+ * — the reader would see a stack trace where the answer was one line. Every failure here
110
+ * returns a value rather than throwing, so a malformed tsconfig is reported as unreadable
111
+ * and nothing else claims to know what is in it.
112
+ *
113
+ * Written as a scanner rather than a regex for one reason: `//` and `/*` occur inside
114
+ * strings, and the commonest string in a tsconfig-adjacent file is a URL. A regex would
115
+ * delete `https://` and then report a parse error on a file that is perfectly valid.
116
+ *
117
+ * @param {string} text
118
+ * @returns {{value: object|null, error: string|null}}
119
+ */
120
+ export function readJsonc(text) {
121
+ let out = '';
122
+ let inString = false;
123
+ let i = 0;
124
+ while (i < text.length) {
125
+ const c = text[i];
126
+ if (inString) {
127
+ if (c === '\\') {
128
+ out += c + (text[i + 1] ?? '');
129
+ i += 2;
130
+ continue;
131
+ }
132
+ if (c === '"') inString = false;
133
+ out += c;
134
+ i += 1;
135
+ continue;
136
+ }
137
+ if (c === '"') {
138
+ inString = true;
139
+ out += c;
140
+ i += 1;
141
+ continue;
142
+ }
143
+ if (c === '/' && text[i + 1] === '/') {
144
+ while (i < text.length && text[i] !== '\n') i += 1;
145
+ continue;
146
+ }
147
+ if (c === '/' && text[i + 1] === '*') {
148
+ i += 2;
149
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
150
+ i += 2;
151
+ continue;
152
+ }
153
+ // A trailing comma is legal in JSONC and illegal in JSON. Dropping it on the way out
154
+ // rather than with a regex over the finished string, because `, }` is also what a
155
+ // string containing a closing brace looks like.
156
+ if (c === '}' || c === ']') {
157
+ let end = out.length - 1;
158
+ while (end >= 0 && /\s/.test(out[end])) end -= 1;
159
+ if (out[end] === ',') out = out.slice(0, end) + out.slice(end + 1);
160
+ }
161
+ out += c;
162
+ i += 1;
163
+ }
164
+ try {
165
+ const value = JSON.parse(out);
166
+ return { value: typeof value === 'object' && value !== null ? value : null, error: null };
167
+ } catch (e) {
168
+ return { value: null, error: e.message };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * What this repo's base file should be, and what should happen to it.
174
+ *
175
+ * There is no `refresh`. ADR-026 gave simt-dev's own `.github` files a refresh path because
176
+ * this repository is the template's only author; a base file has the same argument, and it
177
+ * is deliberately not extended to it. `simt-dev check` in this repository reports the
178
+ * baseline files as unmanaged by design, so a refresh path here would be a way to make this
179
+ * repository's own gate green by rewriting the file it is judging — which is the same
180
+ * mistake as a gate that edits its own expectations.
181
+ *
182
+ * The skip case is detection, not politeness: a repository with no TypeScript has nothing to
183
+ * extend the base, and a `tsconfig.base.json` in a JavaScript repository is a file that
184
+ * claims to be an authority and is read by nothing.
185
+ *
186
+ * @param {string} root repo root
187
+ * @returns {Promise<{file:string, path:string, action:'create'|'unchanged'|'skip',
188
+ * exists:boolean, bytes:number, current:boolean, typescript:boolean,
189
+ * reason:string|null}>}
190
+ */
191
+ export async function planTsconfigBase(root) {
192
+ const path = join(root, TSCONFIG_BASE_FILE);
193
+ let text = null;
194
+ try {
195
+ text = await readFile(path, 'utf8');
196
+ } catch {
197
+ text = null;
198
+ }
199
+ const exists = text !== null;
200
+ const bytes = exists ? Buffer.byteLength(text) : 0;
201
+ const { stack } = await fingerprint(root);
202
+ const typescript = stack.includes('typescript');
203
+ if (exists) {
204
+ return {
205
+ file: TSCONFIG_BASE_FILE,
206
+ path,
207
+ action: 'unchanged',
208
+ exists,
209
+ bytes,
210
+ current: true,
211
+ typescript,
212
+ reason: null,
213
+ };
214
+ }
215
+ return {
216
+ file: TSCONFIG_BASE_FILE,
217
+ path,
218
+ action: typescript ? 'create' : 'skip',
219
+ exists,
220
+ bytes: 0,
221
+ current: false,
222
+ typescript,
223
+ reason: typescript
224
+ ? null
225
+ : 'no TypeScript in this repository, so nothing would extend the base',
226
+ };
227
+ }
228
+
229
+ /**
230
+ * What is wrong with a repository's TypeScript configuration.
231
+ *
232
+ * Two findings, and the split between them is the point of the whole module. A base file
233
+ * repairs a repository that is missing a flag and is structurally unable to repair one that
234
+ * spells a flag differently, so exactly one of these two problems can be handed to a file
235
+ * and the other has to be a check.
236
+ *
237
+ * Returns `[]` rather than throwing on unreadable input, for the reason in `readJsonc`.
238
+ *
239
+ * @param {string} root repo root
240
+ * @returns {Promise<{file:string, status:string, detail:string, remedy:string}[]>}
241
+ */
242
+ export async function checkTsconfig(root) {
243
+ const findings = [];
244
+
245
+ let baseExists = true;
246
+ try {
247
+ await readFile(join(root, TSCONFIG_BASE_FILE), 'utf8');
248
+ } catch {
249
+ baseExists = false;
250
+ }
251
+
252
+ const { stack } = await fingerprint(root);
253
+ const typescript = stack.includes('typescript');
254
+ if (typescript && !baseExists) {
255
+ findings.push({
256
+ file: TSCONFIG_BASE_FILE,
257
+ status: 'absent',
258
+ detail:
259
+ 'this repo is TypeScript and has no base file, so every strictness flag is whatever ' +
260
+ 'one person happened to write',
261
+ remedy: `${CLI} init --yes`,
262
+ });
263
+ }
264
+
265
+ // Nothing below reads the base file's contents, and that is deliberate. This tool does not
266
+ // own those bytes (ADR-026), so a repo that edited them is not drift — and a finding that
267
+ // compared them would report somebody's work as an error. Only the question of whether
268
+ // `tsconfig.json` points at the base is this tool's to ask.
269
+ let own = null;
270
+ try {
271
+ own = readJsonc(await readFile(join(root, TSCONFIG_FILE), 'utf8'));
272
+ } catch {
273
+ own = null;
274
+ }
275
+ if (own === null) return findings;
276
+ if (own.error !== null) {
277
+ // A file that cannot be parsed is a finding about `tsc`, not about simt-dev, and tsc
278
+ // says it better. Staying quiet here is the difference between a gate with an opinion
279
+ // and a gate that guesses.
280
+ return findings;
281
+ }
282
+ const config = own.value;
283
+ if (!config) return findings;
284
+
285
+ if (baseExists && config.extends === undefined) {
286
+ findings.push({
287
+ file: TSCONFIG_FILE,
288
+ status: 'inert',
289
+ detail:
290
+ `this repo has a ${TSCONFIG_BASE_FILE} that ${TSCONFIG_FILE} does not extend, so the ` +
291
+ 'strictness floor is in the repository and not in effect',
292
+ remedy: `add "extends": "./${TSCONFIG_BASE_FILE}" to ${TSCONFIG_FILE}`,
293
+ });
294
+ }
295
+
296
+ const resolution = config.compilerOptions?.moduleResolution;
297
+ if (typeof resolution === 'string' && !DOCUMENTED_RESOLUTIONS.has(resolution)) {
298
+ const canonical = [...DOCUMENTED_RESOLUTIONS].find(
299
+ (r) => r.toLowerCase() === resolution.toLowerCase(),
300
+ );
301
+ if (canonical) {
302
+ findings.push({
303
+ file: TSCONFIG_FILE,
304
+ status: 'inconsistent',
305
+ detail:
306
+ `"moduleResolution": "${resolution}" compiles, which is why it disagrees with the ` +
307
+ `repositories that write "${canonical}" and why nothing has ever said so`,
308
+ remedy: `change it to "${canonical}"`,
309
+ });
310
+ }
311
+ }
312
+
313
+ return findings;
314
+ }