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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|