simt-dev 0.2.0 → 0.2.1
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.
- package/CHANGELOG.md +173 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +89 -13
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +3 -3
- package/src/commands/sync.mjs +153 -11
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +34 -5
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +50 -5
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +247 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// substring scan reports that file as violating the very rule it is correctly
|
|
12
12
|
// restating. So every match is checked for prohibition context first.
|
|
13
13
|
|
|
14
|
+
import { POINTERS, SURFACES } from './surfaces.mjs';
|
|
15
|
+
|
|
14
16
|
/** Files whose text is a hand-written rule surface. Scanned for conflicts only;
|
|
15
17
|
* generated regions are excluded at read time by the caller.
|
|
16
18
|
*
|
|
@@ -18,11 +20,60 @@
|
|
|
18
20
|
* worth recording: the estate's three always-on `.mdc` families were the single
|
|
19
21
|
* worst-drifted content in it, and `simt-dev init` reported `CONFLICTS none detected`
|
|
20
22
|
* on the repo holding them. A conflict detector that only looks at the filenames
|
|
21
|
-
* it was written for will quietly miss a whole rule surface added later.
|
|
23
|
+
* it was written for will quietly miss a whole rule surface added later.
|
|
24
|
+
*
|
|
25
|
+
* And then it did. The comment above was written as a lesson and the lesson was not
|
|
26
|
+
* taken: the list stayed hand-written, two surfaces were added to `SURFACES`
|
|
27
|
+
* afterwards, and `.claude/rules/` and `.agents/rules/` were silently unscanned —
|
|
28
|
+
* two of the five surfaces, and both of them read a rule that this tool writes. A
|
|
29
|
+
* comment saying "derive this" is not deriving it. So the per-rule directories come
|
|
30
|
+
* from `SURFACES` now (see `ruleGlobs`), and this list holds only the files no
|
|
31
|
+
* surface declares. */
|
|
22
32
|
export const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md'];
|
|
23
33
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
34
|
+
/** Pointer targets, scanned because this tool writes them and they are rule files.
|
|
35
|
+
*
|
|
36
|
+
* `CLAUDE.md` was named by hand in `RULE_FILES` for the whole life of the tool, and
|
|
37
|
+
* `GEMINI.md` — which `POINTERS` creates for a repo on the Antigravity surface — was
|
|
38
|
+
* not. So one file this tool writes was scanned and the other was not, with nothing
|
|
39
|
+
* in the source saying which was which: the difference was an accident of the order
|
|
40
|
+
* the two were added. Derived from `POINTERS` now, so it cannot recur. */
|
|
41
|
+
function pointerFiles() {
|
|
42
|
+
return POINTERS.map((p) => p.path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The per-rule directories, read out of `SURFACES` rather than written out here.
|
|
47
|
+
*
|
|
48
|
+
* One list of surfaces, read by everything that has to know where a surface keeps its
|
|
49
|
+
* rules. A surface added to `SURFACES` is now scanned for conflicts the moment it
|
|
50
|
+
* exists, which is the property the hand-written list kept failing to have — and it
|
|
51
|
+
* could only fail quietly, because a detector that matches nothing reports the same
|
|
52
|
+
* answer as a repository with no rules.
|
|
53
|
+
*
|
|
54
|
+
* `CLAUDE.md` and `GEMINI.md` are here through `RULE_FILES`/`POINTERS` rather than
|
|
55
|
+
* through this: they are symlinks to the primary region, so they are scanned as
|
|
56
|
+
* *files*, and `CONTRIBUTING.md` belongs to no surface at all.
|
|
57
|
+
*
|
|
58
|
+
* Takes the list as an argument rather than closing over it so the derivation can be
|
|
59
|
+
* shown on a surface that does not exist. The default is `SURFACES`, so every caller
|
|
60
|
+
* still reads the one authority; there is no way to pass anything else in production.
|
|
61
|
+
*
|
|
62
|
+
* @param {{rule?: string}[]} [surfaces]
|
|
63
|
+
*/
|
|
64
|
+
export function ruleGlobs(surfaces = SURFACES) {
|
|
65
|
+
return surfaces.filter((s) => typeof s.rule === 'string').map((s) => {
|
|
66
|
+
const slash = s.rule.lastIndexOf('/');
|
|
67
|
+
const extension = s.rule.slice(s.rule.lastIndexOf('.'));
|
|
68
|
+
// The FINAL extension, not the whole tail after `{name}`. Copilot's template is
|
|
69
|
+
// `{name}.instructions.md`, and matching that whole suffix would be narrower than
|
|
70
|
+
// the `.md` this file used to list by hand — which is the difference between seeing
|
|
71
|
+
// a hand-written `reviewer.md` in that directory and reporting the directory as
|
|
72
|
+
// empty. A file this tool writes always carries its own suffix; a file a person
|
|
73
|
+
// wrote in the same directory need not, and the person is who this scanner is for.
|
|
74
|
+
return `${s.rule.slice(0, slash)}/*${extension}`;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
26
77
|
|
|
27
78
|
/** Every hand-written rule path in a repo, named or globbed.
|
|
28
79
|
*
|
|
@@ -36,8 +87,8 @@ export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
|
|
|
36
87
|
export async function rulePaths(root) {
|
|
37
88
|
const { readdir } = await import('node:fs/promises');
|
|
38
89
|
const { join } = await import('node:path');
|
|
39
|
-
const out = RULE_FILES.map((f) => join(root, f));
|
|
40
|
-
for (const glob of
|
|
90
|
+
const out = [...new Set([...RULE_FILES, ...pointerFiles()])].map((f) => join(root, f));
|
|
91
|
+
for (const glob of ruleGlobs()) {
|
|
41
92
|
const star = glob.lastIndexOf('*');
|
|
42
93
|
const dir = join(root, glob.slice(0, star));
|
|
43
94
|
const tail = glob.slice(star + 1);
|
|
@@ -29,6 +29,7 @@ import { getGate } from '../hooks/gates.mjs';
|
|
|
29
29
|
// file by name.
|
|
30
30
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
31
31
|
import { CLI } from '../name.mjs';
|
|
32
|
+
import { packageVersion } from '../package-version.mjs';
|
|
32
33
|
export const MANIFEST = MANIFEST_FILE;
|
|
33
34
|
|
|
34
35
|
export { MANIFEST_LEGACY };
|
|
@@ -98,6 +99,93 @@ const TAG_GATES = [
|
|
|
98
99
|
{ tag: 'hono', preCommit: ['i18n-completeness'] },
|
|
99
100
|
];
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The ecosystems that produce a fact rather than a stack tag, and what a fresh
|
|
104
|
+
* adoption of one should declare.
|
|
105
|
+
*
|
|
106
|
+
* A row proposes gates the way `GATES_BY_STACK` does, and the fact it keys on is
|
|
107
|
+
* the same signal a profile's `simt-applies` names, so one marker file reaches
|
|
108
|
+
* both: the region this repo reads and the gates its commits run.
|
|
109
|
+
*
|
|
110
|
+
* `commands` is what a FRESH manifest declares for each proposed gate, and it is
|
|
111
|
+
* written only when the probe in `readCapability` found `tool` where this row says
|
|
112
|
+
* it lives. A declared gate that cannot run blocks every commit, so a command is
|
|
113
|
+
* never declared on the faith of a marker file: the marker says the ecosystem is
|
|
114
|
+
* here, the probe says its program can run (ADR-017). A command the repo already
|
|
115
|
+
* declared wins over the seed, because a seed is a default and a declaration is a
|
|
116
|
+
* choice.
|
|
117
|
+
*
|
|
118
|
+
* `file` marks a tool that is a file in the repository rather than a binary on
|
|
119
|
+
* PATH: the Maven wrapper, probed the way this tool already probes `gradlew`.
|
|
120
|
+
*/
|
|
121
|
+
export const ECOSYSTEMS = [
|
|
122
|
+
{
|
|
123
|
+
fact: 'has-cargo-toml',
|
|
124
|
+
tool: 'cargo',
|
|
125
|
+
preCommit: ['format', 'lint'],
|
|
126
|
+
prePush: ['test'],
|
|
127
|
+
commands: { format: 'cargo fmt --check', lint: 'cargo clippy -- -D warnings', test: 'cargo test' },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
fact: 'has-go-mod',
|
|
131
|
+
tool: 'go',
|
|
132
|
+
preCommit: ['format', 'lint'],
|
|
133
|
+
prePush: ['test'],
|
|
134
|
+
commands: { format: 'test -z "$(gofmt -l .)"', lint: 'go vet ./...', test: 'go test ./...' },
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
fact: 'has-nuget',
|
|
138
|
+
tool: 'dotnet',
|
|
139
|
+
preCommit: ['format'],
|
|
140
|
+
prePush: ['test', 'typecheck'],
|
|
141
|
+
commands: { format: 'dotnet format --verify-no-changes', test: 'dotnet test', typecheck: 'dotnet build -warnaserror' },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
fact: 'has-swift-package',
|
|
145
|
+
tool: 'swift',
|
|
146
|
+
preCommit: ['format'],
|
|
147
|
+
prePush: ['test', 'typecheck'],
|
|
148
|
+
commands: { format: 'swift format lint -r .', test: 'swift test', typecheck: 'swift build' },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
fact: 'has-deno-config',
|
|
152
|
+
tool: 'deno',
|
|
153
|
+
preCommit: ['format', 'lint'],
|
|
154
|
+
prePush: ['test', 'typecheck'],
|
|
155
|
+
commands: { format: 'deno fmt --check', lint: 'deno lint', test: 'deno test', typecheck: 'deno check .' },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
fact: 'has-gemfile',
|
|
159
|
+
tool: 'bundle',
|
|
160
|
+
preCommit: ['format'],
|
|
161
|
+
prePush: ['test'],
|
|
162
|
+
commands: { format: 'bundle exec rubocop', test: 'bundle exec rspec' },
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
fact: 'has-pubspec',
|
|
166
|
+
tool: 'dart',
|
|
167
|
+
preCommit: ['format', 'lint'],
|
|
168
|
+
prePush: ['test'],
|
|
169
|
+
commands: { format: 'dart format --output=none --set-exit-if-changed .', lint: 'dart analyze', test: 'dart test' },
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
fact: 'has-mix',
|
|
173
|
+
tool: 'mix',
|
|
174
|
+
preCommit: ['format'],
|
|
175
|
+
prePush: ['test'],
|
|
176
|
+
commands: { format: 'mix format --check-formatted', test: 'mix test' },
|
|
177
|
+
},
|
|
178
|
+
// The wrapper is a file the repository commits, not a program on PATH. A repo with
|
|
179
|
+
// a pom.xml and no wrapper cannot run `./mvnw -q verify`, so the probe is the file.
|
|
180
|
+
{
|
|
181
|
+
fact: 'has-maven-pom',
|
|
182
|
+
tool: 'mvnw',
|
|
183
|
+
file: true,
|
|
184
|
+
prePush: ['test'],
|
|
185
|
+
commands: { test: './mvnw -q verify' },
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
101
189
|
/**
|
|
102
190
|
* Can this repo actually run the gate?
|
|
103
191
|
*
|
|
@@ -240,22 +328,68 @@ function runnerName(runner) {
|
|
|
240
328
|
* - `command` gates run whatever `gates.commands` declares, so a command gate with
|
|
241
329
|
* no declared command is unrunnable by definition
|
|
242
330
|
*
|
|
243
|
-
*
|
|
331
|
+
* The seeds are returned alongside the plan because a fresh adoption writes them
|
|
332
|
+
* into `gates.commands` and the reader is owed the line that says so: a command
|
|
333
|
+
* that lands in the manifest without appearing in the plan is a silent write.
|
|
334
|
+
*
|
|
335
|
+
* @returns {{gates: {preCommit: string[], prePush: string[]}, dropped: {gate: string, why: string}[], commands: Record<string,string>}}
|
|
244
336
|
*/
|
|
245
|
-
export function planGates(stack = [], capability = null) {
|
|
246
|
-
const proposed = defaultGates(stack);
|
|
247
|
-
if (!capability) return { gates: proposed, dropped: [] };
|
|
337
|
+
export function planGates(stack = [], capability = null, facts = []) {
|
|
338
|
+
const proposed = defaultGates(stack, facts);
|
|
339
|
+
if (!capability) return { gates: proposed, dropped: [], commands: {} };
|
|
340
|
+
const seeds = ecosystemCommands(facts, capability);
|
|
341
|
+
// A command the repo already declared wins over a seed: the seed is a default,
|
|
342
|
+
// the declaration is a choice, and a default overwriting a choice is a re-init
|
|
343
|
+
// that edits the repo's mind.
|
|
344
|
+
const effective = { ...capability, commands: { ...seeds, ...capability.commands } };
|
|
248
345
|
const dropped = [];
|
|
249
346
|
const runnable = (list) =>
|
|
250
347
|
list.filter((name) => {
|
|
251
348
|
const gate = getGate(name);
|
|
252
|
-
const verdict = gateRunnable(gate,
|
|
349
|
+
const verdict = gateRunnable(gate, effective);
|
|
253
350
|
if (!verdict.runnable) {
|
|
254
351
|
dropped.push({ gate: name, why: verdict.why, remedy: gateRemedy(gate, { runner: capability.runner }) });
|
|
255
352
|
}
|
|
256
353
|
return verdict.runnable;
|
|
257
354
|
});
|
|
258
|
-
return {
|
|
355
|
+
return {
|
|
356
|
+
gates: { preCommit: runnable(proposed.preCommit), prePush: runnable(proposed.prePush) },
|
|
357
|
+
dropped,
|
|
358
|
+
commands: seeds,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The canonical `gates.commands` a fresh manifest of this repository should carry.
|
|
364
|
+
*
|
|
365
|
+
* Two probes, and both must pass before a command is declared: the fact says the
|
|
366
|
+
* ecosystem is in the repository, and `capability.tools` says its program can run
|
|
367
|
+
* on this machine. A marker file without a toolchain would otherwise declare a
|
|
368
|
+
* gate that fails closed on every commit — the defect ADR-017 exists to prevent,
|
|
369
|
+
* reached from the ecosystem table rather than the stack table.
|
|
370
|
+
*
|
|
371
|
+
* Pure, so `planGates` and `buildManifest` answer with one implementation.
|
|
372
|
+
*/
|
|
373
|
+
function ecosystemCommands(facts = [], capability = null) {
|
|
374
|
+
if (!capability?.tools?.length) return {};
|
|
375
|
+
// An npm runner already answers "how do I test and format" with its own package.json
|
|
376
|
+
// scripts, and a seed only wins over those scripts when the repo has not declared a
|
|
377
|
+
// command of its own — which is exactly the mixed repos (package.json AND Cargo.toml)
|
|
378
|
+
// where the script is the deliberate answer. Seeding there would replace a choice with
|
|
379
|
+
// a default; the reader's escape hatch is declaring gates.commands by hand, which a
|
|
380
|
+
// seed would lose to anyway. Pure ecosystem repos have runner 'cargo'/'go'/... and
|
|
381
|
+
// seed normally.
|
|
382
|
+
if (capability.runner === 'npm') return {};
|
|
383
|
+
const present = new Set(facts);
|
|
384
|
+
const commands = {};
|
|
385
|
+
for (const eco of ECOSYSTEMS) {
|
|
386
|
+
if (!present.has(eco.fact)) continue;
|
|
387
|
+
if (!capability.tools.includes(eco.tool)) continue;
|
|
388
|
+
for (const [gate, command] of Object.entries(eco.commands)) {
|
|
389
|
+
if (!capability.commands?.[gate]) commands[gate] = command;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return commands;
|
|
259
393
|
}
|
|
260
394
|
|
|
261
395
|
/**
|
|
@@ -267,16 +401,22 @@ export function planGates(stack = [], capability = null) {
|
|
|
267
401
|
* make, and quietly disarming a gate somebody declared on purpose is a worse surprise
|
|
268
402
|
* than a visible one.
|
|
269
403
|
*/
|
|
270
|
-
export function buildManifest({ root, stack = [], sha, existing = null, capability = null, surfaces = undefined }) {
|
|
271
|
-
const gates = defaultGates(stack);
|
|
404
|
+
export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
|
|
405
|
+
const gates = defaultGates(stack, facts);
|
|
272
406
|
const limits = defaultLimits(stack);
|
|
273
407
|
|
|
274
408
|
// A re-init must never silently weaken a repo's own declaration: existing
|
|
275
409
|
// limits and gates win over defaults, and only the projection stamp is refreshed.
|
|
276
410
|
const limitsFinal = existing?.limits ? { ...limits, ...existing.limits } : limits;
|
|
411
|
+
const planned = planGates(stack, capability, facts);
|
|
412
|
+
// The seeds land only on a FRESH manifest. On an existing one the repo's
|
|
413
|
+
// declaration is the answer, and adding commands under it would be a re-init
|
|
414
|
+
// writing into a field the reader owns — the same rule as the gates above.
|
|
277
415
|
const gatesFinal = existing?.gates
|
|
278
416
|
? { ...gates, ...existing.gates }
|
|
279
|
-
:
|
|
417
|
+
: Object.keys(planned.commands).length > 0
|
|
418
|
+
? { ...planned.gates, commands: planned.commands }
|
|
419
|
+
: planned.gates;
|
|
280
420
|
|
|
281
421
|
return {
|
|
282
422
|
...MANIFEST_COMMENT,
|
|
@@ -303,7 +443,12 @@ export function buildManifest({ root, stack = [], sha, existing = null, capabili
|
|
|
303
443
|
// because a typo here silently reduces the estate to AGENTS.md, which looks
|
|
304
444
|
// exactly like a working setup.
|
|
305
445
|
surfaces: surfaces ?? existing?.surfaces ?? null,
|
|
306
|
-
|
|
446
|
+
// simtDevSha is the digest of the rule bytes this projection was rendered from;
|
|
447
|
+
// version is the simt-dev release that produced it. The gate compares the running
|
|
448
|
+
// binary to version and refuses on a mismatch, which is how a repository without
|
|
449
|
+
// npm (a global install, pinned only by this field) is held to the release it
|
|
450
|
+
// adopted. See src/package-version.mjs.
|
|
451
|
+
projection: { simtDevSha: sha ?? null, version: version ?? null },
|
|
307
452
|
};
|
|
308
453
|
}
|
|
309
454
|
|
|
@@ -348,18 +493,30 @@ function defaultLimits() {
|
|
|
348
493
|
}
|
|
349
494
|
|
|
350
495
|
/** ADR-006: coverage stays null. Set it only if the repo has a real numeric gate. */
|
|
351
|
-
function defaultGates(stack) {
|
|
352
|
-
|
|
496
|
+
function defaultGates(stack, facts = []) {
|
|
497
|
+
// Tags and facts are one signal set for gate purposes: a row in `GATES_BY_STACK`
|
|
498
|
+
// keys on a tag, a row in `ECOSYSTEMS` on a fact, and the two never name each
|
|
499
|
+
// other's vocabulary (facts are `has-*`, tags are not), so one union cannot
|
|
500
|
+
// cross-match.
|
|
501
|
+
const signals = new Set([...stack, ...facts]);
|
|
353
502
|
const gates = { preCommit: [], prePush: [] };
|
|
354
503
|
|
|
355
504
|
for (const row of GATES_BY_STACK) {
|
|
356
|
-
if (row.when.some((tag) =>
|
|
505
|
+
if (row.when.some((tag) => signals.has(tag))) {
|
|
357
506
|
gates.preCommit.push(...row.preCommit);
|
|
358
507
|
gates.prePush.push(...row.prePush);
|
|
359
508
|
}
|
|
360
509
|
}
|
|
510
|
+
for (const row of ECOSYSTEMS) {
|
|
511
|
+
if (signals.has(row.fact)) {
|
|
512
|
+
// A row proposes what it has: the Maven row has no preCommit gates, so a
|
|
513
|
+
// missing key is "none", not a defect. Spreading undefined throws.
|
|
514
|
+
gates.preCommit.push(...(row.preCommit ?? []));
|
|
515
|
+
gates.prePush.push(...(row.prePush ?? []));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
361
518
|
for (const row of TAG_GATES) {
|
|
362
|
-
if (
|
|
519
|
+
if (signals.has(row.tag)) gates.preCommit.push(...row.preCommit);
|
|
363
520
|
}
|
|
364
521
|
|
|
365
522
|
// A repo with no recognised stack still gets a gate list, because a gate that
|
|
@@ -388,6 +545,39 @@ function defaultId(root) {
|
|
|
388
545
|
return name === '' || name === '.' ? 'unnamed-repo' : name;
|
|
389
546
|
}
|
|
390
547
|
|
|
548
|
+
/**
|
|
549
|
+
* The fields this tool writes. Everything else in a manifest belongs to the
|
|
550
|
+
* repository that adopted the tool.
|
|
551
|
+
*
|
|
552
|
+
* It is a value rather than an expression inside `manifestDrift` because the question
|
|
553
|
+
* "is this field ours?" now has two callers that must answer it identically: the gate
|
|
554
|
+
* that reports one stale, and `sync`, which repairs exactly the fields the gate names.
|
|
555
|
+
* A second implementation of this set is a repair that fixes a field nothing reports,
|
|
556
|
+
* or a report about a field nothing repairs - and each half passes its own test,
|
|
557
|
+
* which is ADR-045's shape reached from the other direction.
|
|
558
|
+
*/
|
|
559
|
+
export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* A manifest with this tool's own fields replaced by what this build writes, and
|
|
563
|
+
* nothing else touched.
|
|
564
|
+
*
|
|
565
|
+
* Spread from the repository's manifest rather than from `buildManifest`'s output:
|
|
566
|
+
* the builder fills a default for every key, so writing its result wholesale would
|
|
567
|
+
* overwrite `gates` and `limits` in any repository that chose its own. "Content the
|
|
568
|
+
* repo chose is never compared" is a promise about the repair too, not only about
|
|
569
|
+
* the report, and a repair is exactly where it would be broken.
|
|
570
|
+
*
|
|
571
|
+
* @param {object} manifest the repository's manifest, as parsed
|
|
572
|
+
* @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
|
|
573
|
+
* @returns {object} a new manifest; the argument is not mutated
|
|
574
|
+
*/
|
|
575
|
+
export function withGenerated(manifest, expected) {
|
|
576
|
+
const patched = { ...manifest };
|
|
577
|
+
for (const key of GENERATED_KEYS) if (key in expected) patched[key] = expected[key];
|
|
578
|
+
return patched;
|
|
579
|
+
}
|
|
580
|
+
|
|
391
581
|
/**
|
|
392
582
|
* Is a generated field of this manifest wrong, or has a formatter taken its bytes?
|
|
393
583
|
*
|
|
@@ -408,6 +598,12 @@ function defaultId(root) {
|
|
|
408
598
|
* again. `upgrade` reported `current` the whole time, which is correct: the pin
|
|
409
599
|
* was current. The bytes were not.
|
|
410
600
|
*
|
|
601
|
+
* This one is a consequence of routing, not of adoption. Nothing reformatted the
|
|
602
|
+
* manifest until the pre-commit gate started running, so every repository that
|
|
603
|
+
* adopts the tool and runs a formatter arrives here on its first commit. The
|
|
604
|
+
* remedy is therefore "stop the formatter taking it, then `sync`" — and it was
|
|
605
|
+
* `init --yes`, which re-runs adoption to change one file's formatting (ADR-050).
|
|
606
|
+
*
|
|
411
607
|
* Content the repo chose is never compared. A repo that edited `limits.coverage` on
|
|
412
608
|
* purpose is not being told to revert it.
|
|
413
609
|
*
|
|
@@ -417,20 +613,34 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
417
613
|
const { manifest, exists, error } = await readManifest(root);
|
|
418
614
|
if (!exists || error !== null || manifest === null) return null;
|
|
419
615
|
|
|
420
|
-
const expected = buildManifest({ root, stack, sha, existing: manifest });
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
616
|
+
const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
|
|
617
|
+
// `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
|
|
618
|
+
// fields are ours, and the answer is the set above. Iterating the expected object
|
|
619
|
+
// asks the same question of a different list and can only agree by coincidence.
|
|
620
|
+
const stale = [...GENERATED_KEYS].filter(
|
|
621
|
+
(key) => key in expected && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key]),
|
|
622
|
+
);
|
|
428
623
|
if (stale.length > 0) {
|
|
429
624
|
return {
|
|
430
625
|
file: MANIFEST,
|
|
431
626
|
status: 'stale-generated-field',
|
|
432
627
|
detail: `generated field(s) ${stale.join(', ')} do not match what this simt-dev writes`,
|
|
433
|
-
|
|
628
|
+
// `sync`, and not `init`. `sync` repairs exactly this field set and nothing
|
|
629
|
+
// else; `init` re-runs adoption - it tags, it branches, it asks every question
|
|
630
|
+
// again - and two commands must not answer one question with different
|
|
631
|
+
// instructions (ADR-050).
|
|
632
|
+
//
|
|
633
|
+
// The remedy said `init` because for two releases nothing could repair a stale
|
|
634
|
+
// field, so the only command that could was the only one named. `sync` having
|
|
635
|
+
// nothing to repair while `check` refused anyway is the one-question-two-answers
|
|
636
|
+
// defect this repository exists to end, and it was true in all fourteen adopted
|
|
637
|
+
// repositories at the same moment: measured, not inferred. `check` exited 1 in
|
|
638
|
+
// every one of them while `sync` printed "already current; nothing written".
|
|
639
|
+
//
|
|
640
|
+
// No trailing parenthetical, because `compare` prints this string verbatim as
|
|
641
|
+
// the remedy on its own row. A remedy that reads well in one command's output
|
|
642
|
+
// and badly in another's is two answers wearing one string.
|
|
643
|
+
remedy: `run: ${CLI} sync --yes`,
|
|
434
644
|
};
|
|
435
645
|
}
|
|
436
646
|
|
|
@@ -446,10 +656,22 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
446
656
|
file: MANIFEST,
|
|
447
657
|
status: 'reformatted',
|
|
448
658
|
detail: 'content is what simt-dev would write, but the bytes differ - another formatter owns this file',
|
|
449
|
-
remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI}
|
|
659
|
+
remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI} sync --yes`,
|
|
450
660
|
};
|
|
451
661
|
}
|
|
452
662
|
|
|
663
|
+
/**
|
|
664
|
+
* The bytes a manifest is written as.
|
|
665
|
+
*
|
|
666
|
+
* Exported because two callers now need them and one of them is a preview: a plan
|
|
667
|
+
* that reports a byte count it computed with a different serialiser than the writer
|
|
668
|
+
* used reports a number nothing will ever observe. `writeManifest` is the only place
|
|
669
|
+
* this format is spelled.
|
|
670
|
+
*/
|
|
671
|
+
export function manifestText(manifest) {
|
|
672
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
673
|
+
}
|
|
674
|
+
|
|
453
675
|
/** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
|
|
454
676
|
export async function readManifest(root) {
|
|
455
677
|
const path = await manifestPath(root);
|
|
@@ -473,6 +695,6 @@ export async function writeManifest(root, manifest) {
|
|
|
473
695
|
const previous = join(root, MANIFEST_LEGACY);
|
|
474
696
|
const fresh = join(root, MANIFEST);
|
|
475
697
|
if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
|
|
476
|
-
await writeFile(fresh,
|
|
698
|
+
await writeFile(fresh, manifestText(manifest));
|
|
477
699
|
return fresh;
|
|
478
700
|
}
|
package/src/projection/mdc.mjs
CHANGED
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
import { readFile, readdir } from 'node:fs/promises';
|
|
26
26
|
|
|
27
|
-
import { frontmatterBlock } from './surfaces.mjs';
|
|
28
27
|
import { dirname, join, resolve } from 'node:path';
|
|
29
28
|
import { fileURLToPath } from 'node:url';
|
|
30
29
|
|
|
@@ -45,7 +44,9 @@ const MDC_DIR = join(ROOT, 'rules', 'mdc');
|
|
|
45
44
|
* defect — which is the failure ADR-016 shipped once, a rule that reached no repo
|
|
46
45
|
* because `applies` was absent, silently and with a test green.
|
|
47
46
|
*/
|
|
48
|
-
|
|
47
|
+
/** Frontmatter keys this tool reads, ADR-022. Exported so the one authority on the
|
|
48
|
+
* tool's own vocabulary is also the one a guard subtracts, rather than a second copy. */
|
|
49
|
+
export const KNOWN_KEYS = new Set([
|
|
49
50
|
'simt-id',
|
|
50
51
|
'simt-target',
|
|
51
52
|
'simt-title',
|
|
@@ -300,14 +301,6 @@ function dropOrReplace(text, key, value) {
|
|
|
300
301
|
return kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
/** The frontmatter a projected `.mdc` keeps. `alwaysApply` and `description` are
|
|
304
|
-
* read by the editor before any rule text, so they belong outside the region. */
|
|
305
|
-
export function mdcFrontmatter(rule) {
|
|
306
|
-
return frontmatterBlock([
|
|
307
|
-
`description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
|
|
308
|
-
`alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
|
|
309
|
-
]);
|
|
310
|
-
}
|
|
311
304
|
|
|
312
305
|
|
|
313
306
|
/**
|
|
@@ -322,6 +315,4 @@ export function ruleName(rule) {
|
|
|
322
315
|
if (typeof rule.target !== 'string' || rule.target === '') return null;
|
|
323
316
|
const base = rule.target.slice(rule.target.lastIndexOf('/') + 1);
|
|
324
317
|
return base.replace(/\.mdc$/, '');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export { MDC_DIR, parse as parseRegion };
|
|
318
|
+
}
|
|
@@ -478,7 +478,7 @@ async function regionOpts(root) {
|
|
|
478
478
|
* A reader that stops at a byte offset never sees a region that ends past it.
|
|
479
479
|
* The finding names the reader, because "the file is long" does not say who stops.
|
|
480
480
|
*/
|
|
481
|
-
|
|
481
|
+
function readLimitFindings(text, file) {
|
|
482
482
|
const state = parse(text);
|
|
483
483
|
if (state.status !== 'ok' || typeof state.endByte !== 'number') return [];
|
|
484
484
|
const findings = [];
|
package/src/projection/rules.mjs
CHANGED
|
@@ -57,13 +57,15 @@ const RULES_DIR = join(ROOT, 'rules');
|
|
|
57
57
|
* unrecoverably. That is the failure this replaces (ADR-032).
|
|
58
58
|
*
|
|
59
59
|
* The digest covers every byte string that leaves the package, and nothing else. That
|
|
60
|
-
* set is `rules/`
|
|
60
|
+
* set is `rules/` on disk plus the RENDERED baseline bodies: the shared
|
|
61
61
|
* `.editorconfig` and `.gitattributes` blocks are strings a repository receives, so by
|
|
62
62
|
* ADR-015's rule a source is identified by its content rather than by the file it
|
|
63
|
-
* happens to live in, and a body defined in `src/` is a source like any other.
|
|
63
|
+
* happens to live in, and a body defined in `src/` is a source like any other. The
|
|
64
|
+
* registry (`graph/`) is workspace data the package no longer ships (ADR-061), so it is
|
|
65
|
+
* not part of this set even where it exists on disk.
|
|
64
66
|
*
|
|
65
|
-
* It deliberately excludes `src/` as a directory. A refactor that changes no rule
|
|
66
|
-
*
|
|
67
|
+
* It deliberately excludes `src/` as a directory. A refactor that changes no rule and
|
|
68
|
+
* no baseline body produces byte-identical output, and stamping those
|
|
67
69
|
* consumers would report drift where there is none - which is the estate's own rule that
|
|
68
70
|
* a report must be a fact.
|
|
69
71
|
*
|
|
@@ -93,7 +95,7 @@ export async function provenanceDigest() {
|
|
|
93
95
|
*/
|
|
94
96
|
export async function provenanceInputs() {
|
|
95
97
|
const inputs = [];
|
|
96
|
-
for (const dir of ['rules'
|
|
98
|
+
for (const dir of ['rules']) {
|
|
97
99
|
for (const rel of await walk(join(ROOT, dir))) {
|
|
98
100
|
inputs.push({ path: `${dir}/${rel}`, bytes: await readFile(join(ROOT, dir, rel)) });
|
|
99
101
|
}
|
|
@@ -125,7 +127,8 @@ export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
|
|
|
125
127
|
* one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
|
|
126
128
|
* as an editor's instruction — Cursor, Copilot and Antigravity all parse a
|
|
127
129
|
* frontmatter block the same way — and would be silently inert. */
|
|
128
|
-
|
|
130
|
+
/** Frontmatter keys a rule profile declares. See {@link KNOWN_KEYS} on why exported. */
|
|
131
|
+
export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
|
|
129
132
|
|
|
130
133
|
/** Report the first thing wrong with a profile's frontmatter, in the order a reader
|
|
131
134
|
* fixes it: no id to select it by, then a key that will not be read, then a tag the
|
|
@@ -293,8 +296,18 @@ async function ruleBodies(root, manifest, flags) {
|
|
|
293
296
|
|
|
294
297
|
const declared = await readDeclared(root);
|
|
295
298
|
|
|
299
|
+
// Deliberately NOT folded in. This array becomes `mdc.problems`, and `sync` reports it
|
|
300
|
+
// as "canonical .mdc rule(s) cannot be projected" with the remedy "fix the reported
|
|
301
|
+
// substitution keys in simt.json". A surface typo is not a rule problem and has no
|
|
302
|
+
// substitution keys — `surfaces: ["curosr"]` was reported to a reader as though they
|
|
303
|
+
// had misspelled a key in a block they had never heard of, and the only way to find the
|
|
304
|
+
// real cause was to run `check`, which already said it plainly. Two surfaces report the
|
|
305
|
+
// same problem; only one of them names it correctly, and the one that mislabels it is
|
|
306
|
+
// the one the writer uses.
|
|
307
|
+
//
|
|
308
|
+
// Nothing is lost by dropping it here: `plan` returns `surfaceProblems` from this same
|
|
309
|
+
// `resolveSurfaces` call, and `checkTargets` reads it independently at project.mjs.
|
|
296
310
|
const resolved = resolveSurfaces(manifest, flags ?? {});
|
|
297
|
-
for (const p of resolved.problems) problems.push(p);
|
|
298
311
|
|
|
299
312
|
// A manifest key that no shipped rule declares is a typo, or a leftover from a rule
|
|
300
313
|
// that was renamed. Both read as "configured" in the one file a reviewer opens to
|
|
@@ -574,7 +587,7 @@ export function render(selected) {
|
|
|
574
587
|
* so it lands directly under `## Stack: …` instead of under whatever section the
|
|
575
588
|
* previous source ended on.
|
|
576
589
|
*/
|
|
577
|
-
|
|
590
|
+
function frameSource(rule, body, kind) {
|
|
578
591
|
const text = demoteHeadings(body).trim();
|
|
579
592
|
if (!text) return '';
|
|
580
593
|
const title = sourceTitle(rule, kind);
|