simt-dev 0.1.1 → 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 +209 -7
- package/README.md +32 -5
- package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
- package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
- package/docs/decisions/ADR-003-test-breadth.md +4 -4
- package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
- package/docs/decisions/ADR-005-file-length.md +5 -5
- package/docs/decisions/ADR-006-coverage-bar.md +3 -3
- package/docs/decisions/ADR-007-section-skeleton.md +5 -5
- package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-010-runner-invocation.md +1 -1
- package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
- package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
- package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
- package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
- package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
- package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
- package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
- package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
- package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
- package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
- package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
- package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -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 +20 -1
- 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 +183 -67
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +156 -14
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +4 -4
- package/src/commands/sync.mjs +153 -11
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +36 -7
- package/src/decisions/index.mjs +51 -12
- package/src/graph/load.mjs +126 -15
- package/src/hooks/capability.mjs +49 -5
- package/src/hooks/dispatcher.mjs +51 -6
- package/src/hooks/gates.mjs +1 -3
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/hooks/where.mjs +1 -1
- package/src/identity.mjs +207 -0
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/conflicts.mjs +57 -6
- package/src/projection/manifest.mjs +248 -26
- package/src/projection/markers.mjs +3 -3
- package/src/projection/mdc.mjs +6 -15
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/surfaces.mjs +2 -2
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +4 -3
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +2 -2
- package/src/tsconfig.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
|
@@ -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
|
*
|
|
@@ -397,7 +587,7 @@ function defaultId(root) {
|
|
|
397
587
|
* 1. A STALE GENERATED FIELD. `buildManifest` emits `$comment` and
|
|
398
588
|
* `projection.simtDevSha`; the repo owns everything else. `upgrade` spreads the
|
|
399
589
|
* existing manifest and refreshes only the SHA, so a field added to the writer
|
|
400
|
-
* after a repo was adopted never appears —
|
|
590
|
+
* after a repo was adopted never appears — one repository had been projecting for
|
|
401
591
|
* several versions with no `surfaces` key, and nothing said so. Every parse-based
|
|
402
592
|
* comparison passes, because the file is valid and the values in it are all true.
|
|
403
593
|
*
|
|
@@ -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
|
}
|
|
@@ -217,7 +217,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
|
|
|
217
217
|
);
|
|
218
218
|
const trimmed = body.replace(/\n+$/, '');
|
|
219
219
|
// Two lines here exist only because a real repo proved they are needed. A
|
|
220
|
-
// formatter that runs on commit -
|
|
220
|
+
// formatter that runs on commit - several repositories have one, and three of the
|
|
221
221
|
// other repos do too - rewrote the region on the very first commit: it inserted a
|
|
222
222
|
// blank line after the header comment and added the trailing newline. Both edits
|
|
223
223
|
// are things prettier considers correct, and either one alone makes `check`
|
|
@@ -226,7 +226,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
|
|
|
226
226
|
// projection emits the formatter's shape in the first place. The blank line before
|
|
227
227
|
// END is the same class: prettier treats an HTML comment after a paragraph as a
|
|
228
228
|
// new block and inserts the separating blank line itself, which made
|
|
229
|
-
//
|
|
229
|
+
// a `format:check` fail on a file whose generated region was correct.
|
|
230
230
|
const out =
|
|
231
231
|
trimmed === ''
|
|
232
232
|
? [s.begin, ...header, s.end]
|
|
@@ -293,7 +293,7 @@ export function inject(text, body, meta, opts = {}) {
|
|
|
293
293
|
// terminates the region with one; adding a second left every adopted file
|
|
294
294
|
// ending in a blank line. Nothing in this tool noticed, because `check`
|
|
295
295
|
// compares region bodies and the byte after END is not part of the region —
|
|
296
|
-
// but
|
|
296
|
+
// but a repository's own `git diff --check` gate did, and it is a gate
|
|
297
297
|
// that repo runs before every commit. The `updated` branch below has always
|
|
298
298
|
// been correct for exactly this reason.
|
|
299
299
|
//
|
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',
|
|
@@ -201,7 +202,7 @@ function stripFrontmatter(text) {
|
|
|
201
202
|
*
|
|
202
203
|
* An empty-string default is a deliberate "this repo has nothing to add here"
|
|
203
204
|
* and the line it sits on is DROPPED. That is how `preview-postflight` reaches
|
|
204
|
-
*
|
|
205
|
+
* a repository without printing an empty bullet, and how a repo with no
|
|
205
206
|
* production-domain note gets a clean document instead of a blank one.
|
|
206
207
|
*
|
|
207
208
|
* A placeholder with neither a declared nor a default value is a defect: the
|
|
@@ -218,7 +219,7 @@ export function renderMdc(rule, declared = {}) {
|
|
|
218
219
|
|
|
219
220
|
if (has && provided === '') {
|
|
220
221
|
// A repo declared this key and declared it empty: the line it sits on is
|
|
221
|
-
// removed. This is how preview-postflight reaches
|
|
222
|
+
// removed. This is how preview-postflight reaches a repository without
|
|
222
223
|
// printing a blank bullet.
|
|
223
224
|
body = dropOrReplace(body, sub.key, '');
|
|
224
225
|
continue;
|
|
@@ -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);
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// 1. A POINTER IS NOT A COPY. `CLAUDE.md` and `GEMINI.md` are symlinks to
|
|
19
19
|
// `AGENTS.md`. A copy is the corpus's one true content duplicate
|
|
20
|
-
// (
|
|
20
|
+
// (one repository's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
|
|
21
21
|
// the DeepSeek harness explicitly rejects loading both in one directory
|
|
22
22
|
// because "repositories in transition commonly duplicate guidance" — and
|
|
23
23
|
// *its* dedup is content-based, so a symlink collapses to one while a
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// mode bit. So those get machine-written copies — and because every byte is
|
|
33
33
|
// derived from `rules/mdc/*.mdc` and `simt-dev check` compares each against
|
|
34
34
|
// that same derivation, they are not duplication, they are a build output.
|
|
35
|
-
// The difference from
|
|
35
|
+
// The difference from that duplicate is that nobody can hand-edit one
|
|
36
36
|
// and have it survive a sync.
|
|
37
37
|
//
|
|
38
38
|
// 3. AN ALWAYS-ON RULE EMITS NO SCOPING FRONTMATTER. Claude Code's `paths:` key
|
|
@@ -64,14 +64,45 @@ export async function ruleTargets(root, rule, body, sha, resolved, notes) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const mine = s.ruleFrontmatter(rule);
|
|
67
|
-
|
|
67
|
+
// The frontmatter is re-derived, never inherited (S16).
|
|
68
|
+
//
|
|
69
|
+
// Two things were wrong and only the second was visible. The first was
|
|
70
|
+
// `extractFrontmatter(existing) ?? frontmatterBlock(mine)`, where the file's own
|
|
71
|
+
// block won whenever the file already had one. The second was this line's
|
|
72
|
+
// neighbour: when the existing region parsed cleanly, `composed` was the whole
|
|
73
|
+
// existing file, so `inject` replaced the region between the markers and left the
|
|
74
|
+
// frontmatter above them exactly as it found it — whatever this tool emitted the
|
|
75
|
+
// first time it saw this surface, forever. A surface that adds a key, or whose
|
|
76
|
+
// `alwaysApply` flips, could never reach a repository that had already adopted it,
|
|
77
|
+
// and no gate could report it: the file agrees with itself, so nothing is stale by
|
|
78
|
+
// any comparison the tool makes.
|
|
79
|
+
//
|
|
80
|
+
// One asymmetry, deliberately: a surface that emits NO frontmatter leaves whatever
|
|
81
|
+
// is there alone. This tool owns the block it writes and will replace it; deleting a
|
|
82
|
+
// block it did not write is a different operation, and a surface with no frontmatter
|
|
83
|
+
// of its own has no opinion about it.
|
|
84
|
+
const frontmatter = mine.length > 0 ? frontmatterBlock(mine) : extractFrontmatter(existing) ?? '';
|
|
68
85
|
const state = exists ? parse(existing) : { status: 'absent' };
|
|
69
86
|
// A surface that wants no frontmatter must not get a blank line where the
|
|
70
87
|
// frontmatter would have been: Claude's rule files opened with two empty lines
|
|
71
88
|
// before the marker, which is untidy in a file the reader will open, and a
|
|
72
89
|
// difference between surfaces that no check reported.
|
|
90
|
+
//
|
|
91
|
+
// Swapped in place rather than stripped and rejoined. My first attempt here was
|
|
92
|
+
// `${frontmatter}\n${bodyAfterFrontmatter(existing)}`, which looked equivalent and
|
|
93
|
+
// was not: that helper's trailing `\n*` swallows the blank line between `---` and
|
|
94
|
+
// the region, so the reconstruction was one newline short of the file and `inject`
|
|
95
|
+
// reported `updated` on every run forever while writing identical bytes. The file
|
|
96
|
+
// was right; the verdict never settled, so `check` reported drift no reader could
|
|
97
|
+
// see and `sync` rewrote a correct file on every invocation. Anything that rebuilds
|
|
98
|
+
// a file has to reproduce the bytes it did not mean to touch, and the cheapest way
|
|
99
|
+
// to guarantee that is not to rebuild them.
|
|
73
100
|
const composed =
|
|
74
|
-
state.status === 'ok'
|
|
101
|
+
state.status === 'ok'
|
|
102
|
+
? swapFrontmatter(existing, frontmatter)
|
|
103
|
+
: frontmatter === ''
|
|
104
|
+
? ''
|
|
105
|
+
: `${frontmatter}\n`;
|
|
75
106
|
const result = inject(composed, body, { sha, sources: [rule.source] });
|
|
76
107
|
|
|
77
108
|
// Report the prose being retired rather than letting it vanish into a diff the
|
|
@@ -201,11 +232,6 @@ export async function pointerState(root, pointer) {
|
|
|
201
232
|
return { file: pointer.file, state: 'mispointed', link, detail: `points at ${link}` };
|
|
202
233
|
}
|
|
203
234
|
|
|
204
|
-
export function surfaceFrontmatter(surfaceId, rule) {
|
|
205
|
-
const s = SURFACES.find((x) => x.id === surfaceId);
|
|
206
|
-
return s && s.ruleFrontmatter ? s.ruleFrontmatter(rule) : [];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
235
|
// The per-surface path lives in surfaces.mjs next to the row that justifies it;
|
|
210
236
|
// re-exported so a caller building a target never has to know which module owns
|
|
211
237
|
// path construction as opposed to frontmatter construction.
|
|
@@ -216,6 +242,26 @@ export function extractFrontmatter(text) {
|
|
|
216
242
|
return m ? m[0].trimEnd() : null;
|
|
217
243
|
}
|
|
218
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Put `frontmatter` where a frontmatter block already is, and leave every other byte
|
|
247
|
+
* exactly where it was.
|
|
248
|
+
*
|
|
249
|
+
* The block match deliberately stops at the closing `---` and not past it, so the
|
|
250
|
+
* replacement starts at the newline that follows — the blank line a formatter requires
|
|
251
|
+
* before an HTML comment survives belongs to the file, not to the block, and consuming
|
|
252
|
+
* it here is what made `inject` report `updated` forever on files whose bytes never
|
|
253
|
+
* changed.
|
|
254
|
+
*
|
|
255
|
+
* A file with no block gets one above its content. The separator below it is left to
|
|
256
|
+
* `inject`'s `seam`, which is the one place that knows what the surrounding document
|
|
257
|
+
* format requires.
|
|
258
|
+
*/
|
|
259
|
+
function swapFrontmatter(text, frontmatter) {
|
|
260
|
+
if (frontmatter === '') return text;
|
|
261
|
+
const m = /^---\n[\s\S]*?\n---/.exec(text);
|
|
262
|
+
return m === null ? `${frontmatter}\n${text}` : `${frontmatter}${text.slice(m[0].length)}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
219
265
|
function bodyAfterFrontmatter(text) {
|
|
220
266
|
return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
221
267
|
}
|