simt-dev 0.2.0 → 0.2.2
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 +375 -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/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- 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/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -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
|
@@ -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,
|
|
@@ -296,6 +436,29 @@ export function buildManifest({ root, stack = [], sha, existing = null, capabili
|
|
|
296
436
|
// substitution value: it lists the rules this repo declares it has a problem
|
|
297
437
|
// with, which is something detection cannot infer.
|
|
298
438
|
substitutions: existing?.substitutions ?? {},
|
|
439
|
+
// ADR-065: rule sets this repository shares with its siblings, and the digest
|
|
440
|
+
// of the bytes it has for each. Not a generated field - which packs a repo uses
|
|
441
|
+
// is the repo's decision, and `check` reports a digest that does not match the
|
|
442
|
+
// directory rather than a value here that differs from what the tool would pick.
|
|
443
|
+
packs: existing?.packs ?? [],
|
|
444
|
+
// ADR-072: how many bytes of always-on projection this repository has agreed to
|
|
445
|
+
// carry. A declaration, not a generated field, for the same reason `packs` is not:
|
|
446
|
+
// the number is the repository's own line in the sand, and `upgrade` must not
|
|
447
|
+
// rewrite it to whatever the tool happens to project today — a budget that moved by
|
|
448
|
+
// itself would be a budget nobody is held to.
|
|
449
|
+
//
|
|
450
|
+
// Absent when the repository declared nothing, which is the point. Writing the
|
|
451
|
+
// default here would make every adopted repo look like it had chosen it, and a
|
|
452
|
+
// reader told "declared: true" about a line nobody wrote is being lied to about the
|
|
453
|
+
// only thing the number is for. The default belongs to whoever measures, and
|
|
454
|
+
// `budgetFinding` and `simt tokens` both read it from one place.
|
|
455
|
+
...(existing?.budget === undefined ? {} : { budget: existing.budget }),
|
|
456
|
+
// ADR-073: `full` projects every selected rule; `kernel` projects only the hard
|
|
457
|
+
// constraints and defers the rest to `rules show`. Absent means `full`, and the
|
|
458
|
+
// default stays `full` because nobody has yet measured whether a kernel loses
|
|
459
|
+
// compliance (plan 0003 R02) — a mode chosen by default would be a bet the
|
|
460
|
+
// repository never made. Declared, not generated, for the same reason as `budget`.
|
|
461
|
+
...(existing?.mode === undefined ? {} : { mode: existing.mode }),
|
|
299
462
|
// ADR-020: which editors this repo's rules are projected into. Absent means the
|
|
300
463
|
// global standard applies; an explicit empty list is a real choice meaning
|
|
301
464
|
// AGENTS.md only. Recorded rather than inferred, because "which IDE does this repo
|
|
@@ -303,7 +466,12 @@ export function buildManifest({ root, stack = [], sha, existing = null, capabili
|
|
|
303
466
|
// because a typo here silently reduces the estate to AGENTS.md, which looks
|
|
304
467
|
// exactly like a working setup.
|
|
305
468
|
surfaces: surfaces ?? existing?.surfaces ?? null,
|
|
306
|
-
|
|
469
|
+
// simtDevSha is the digest of the rule bytes this projection was rendered from;
|
|
470
|
+
// version is the simt-dev release that produced it. The gate compares the running
|
|
471
|
+
// binary to version and refuses on a mismatch, which is how a repository without
|
|
472
|
+
// npm (a global install, pinned only by this field) is held to the release it
|
|
473
|
+
// adopted. See src/package-version.mjs.
|
|
474
|
+
projection: { simtDevSha: sha ?? null, version: version ?? null },
|
|
307
475
|
};
|
|
308
476
|
}
|
|
309
477
|
|
|
@@ -325,6 +493,24 @@ export function unrunnableGates(manifest, capability) {
|
|
|
325
493
|
];
|
|
326
494
|
const findings = [];
|
|
327
495
|
const seen = new Set();
|
|
496
|
+
// `gates.cwd` names a directory a gate runs in, and a name that is not a declared
|
|
497
|
+
// gate is as much a landmine as one that cannot run: the entry is inert, the reader
|
|
498
|
+
// believes the gate runs in the package it names, and nothing anywhere says so. Same
|
|
499
|
+
// shape as the unknown-gate finding in the dispatcher, so both are repaired the same
|
|
500
|
+
// way.
|
|
501
|
+
const cwds = manifest?.gates?.cwd;
|
|
502
|
+
if (cwds && typeof cwds === 'object' && !Array.isArray(cwds)) {
|
|
503
|
+
for (const name of Object.keys(cwds).sort()) {
|
|
504
|
+
if (declared.includes(name)) continue;
|
|
505
|
+
findings.push({
|
|
506
|
+
file: MANIFEST,
|
|
507
|
+
status: 'unknown-gate-cwd',
|
|
508
|
+
gate: name,
|
|
509
|
+
detail: `gates.cwd names "${name}", which is not declared in gates.preCommit or gates.prePush, so it is never read`,
|
|
510
|
+
remedy: `declare "${name}" in gates.preCommit or gates.prePush, or remove gates.cwd["${name}"]`,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
328
514
|
for (const name of declared) {
|
|
329
515
|
if (seen.has(name)) continue;
|
|
330
516
|
seen.add(name);
|
|
@@ -348,18 +534,30 @@ function defaultLimits() {
|
|
|
348
534
|
}
|
|
349
535
|
|
|
350
536
|
/** ADR-006: coverage stays null. Set it only if the repo has a real numeric gate. */
|
|
351
|
-
function defaultGates(stack) {
|
|
352
|
-
|
|
537
|
+
function defaultGates(stack, facts = []) {
|
|
538
|
+
// Tags and facts are one signal set for gate purposes: a row in `GATES_BY_STACK`
|
|
539
|
+
// keys on a tag, a row in `ECOSYSTEMS` on a fact, and the two never name each
|
|
540
|
+
// other's vocabulary (facts are `has-*`, tags are not), so one union cannot
|
|
541
|
+
// cross-match.
|
|
542
|
+
const signals = new Set([...stack, ...facts]);
|
|
353
543
|
const gates = { preCommit: [], prePush: [] };
|
|
354
544
|
|
|
355
545
|
for (const row of GATES_BY_STACK) {
|
|
356
|
-
if (row.when.some((tag) =>
|
|
546
|
+
if (row.when.some((tag) => signals.has(tag))) {
|
|
357
547
|
gates.preCommit.push(...row.preCommit);
|
|
358
548
|
gates.prePush.push(...row.prePush);
|
|
359
549
|
}
|
|
360
550
|
}
|
|
551
|
+
for (const row of ECOSYSTEMS) {
|
|
552
|
+
if (signals.has(row.fact)) {
|
|
553
|
+
// A row proposes what it has: the Maven row has no preCommit gates, so a
|
|
554
|
+
// missing key is "none", not a defect. Spreading undefined throws.
|
|
555
|
+
gates.preCommit.push(...(row.preCommit ?? []));
|
|
556
|
+
gates.prePush.push(...(row.prePush ?? []));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
361
559
|
for (const row of TAG_GATES) {
|
|
362
|
-
if (
|
|
560
|
+
if (signals.has(row.tag)) gates.preCommit.push(...row.preCommit);
|
|
363
561
|
}
|
|
364
562
|
|
|
365
563
|
// A repo with no recognised stack still gets a gate list, because a gate that
|
|
@@ -388,6 +586,86 @@ function defaultId(root) {
|
|
|
388
586
|
return name === '' || name === '.' ? 'unnamed-repo' : name;
|
|
389
587
|
}
|
|
390
588
|
|
|
589
|
+
/**
|
|
590
|
+
* The fields this tool writes. Everything else in a manifest belongs to the
|
|
591
|
+
* repository that adopted the tool.
|
|
592
|
+
*
|
|
593
|
+
* It is a value rather than an expression inside `manifestDrift` because the question
|
|
594
|
+
* "is this field ours?" now has two callers that must answer it identically: the gate
|
|
595
|
+
* that reports one stale, and `sync`, which repairs exactly the fields the gate names.
|
|
596
|
+
* A second implementation of this set is a repair that fixes a field nothing reports,
|
|
597
|
+
* or a report about a field nothing repairs - and each half passes its own test,
|
|
598
|
+
* which is ADR-045's shape reached from the other direction.
|
|
599
|
+
*/
|
|
600
|
+
/**
|
|
601
|
+
* The bytes of always-on projection a repository carries per root before the number is
|
|
602
|
+
* worth an argument.
|
|
603
|
+
*
|
|
604
|
+
* Thirty thousand is S38's figure and not a measurement this tool took: it is the line
|
|
605
|
+
* the review drew, the one the whole row exists to check. A default nobody would accept
|
|
606
|
+
* is a default that reports nothing, and a budget only works as a budget when exceeding
|
|
607
|
+
* it is something a reader can see.
|
|
608
|
+
*/
|
|
609
|
+
export const DEFAULT_BUDGET_BYTES = 30000;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Report a projection that carries more than the repository agreed to carry.
|
|
613
|
+
*
|
|
614
|
+
* Non-blocking by design, and that is the honest answer rather than a convenient one:
|
|
615
|
+
* the plan's own wording is that `check` reports `BUDGET-CUT` and nothing is cut, because
|
|
616
|
+
* cutting requires a kernel to pack lines into and the kernel does not exist yet
|
|
617
|
+
* (R07, blocked on the R02 evaluation). A gate that failed here would fail every
|
|
618
|
+
* repository in the estate on a number this tool has not yet earned the right to enforce.
|
|
619
|
+
*
|
|
620
|
+
* The finding names what is over and what is NOT cut, because a reader who is told only
|
|
621
|
+
* that a budget was exceeded will assume something was removed and stop looking.
|
|
622
|
+
*
|
|
623
|
+
* @returns {{file: string, status: string, detail: string, remedy: string}|null}
|
|
624
|
+
*/
|
|
625
|
+
export function budgetFinding(bytes, budget, mode = 'full') {
|
|
626
|
+
const allowed = budget?.bytes ?? DEFAULT_BUDGET_BYTES;
|
|
627
|
+
if (!Number.isFinite(bytes) || bytes <= allowed) return null;
|
|
628
|
+
// The two modes answer different questions, and conflating them is the one way this
|
|
629
|
+
// finding could be actively wrong. In `full` nothing is cut, so a reader told only
|
|
630
|
+
// "over budget" would look for a removed rule and find none. In `kernel` the packing
|
|
631
|
+
// already ran and the projection the reader will see is the packed one — so a number
|
|
632
|
+
// over the line here means the KERNEL is over the line, which is a different remedy
|
|
633
|
+
// entirely: no amount of raising the number fixes a kernel that is too large on its own.
|
|
634
|
+
const kernel = mode === 'kernel';
|
|
635
|
+
return {
|
|
636
|
+
file: MANIFEST,
|
|
637
|
+
status: 'budget',
|
|
638
|
+
detail: kernel
|
|
639
|
+
? `${bytes} bytes of always-on projection in kernel mode; this repository declared ${allowed}. The kernel itself is over the line: only hard and command rules were packed, so this is not a projection waiting to be trimmed`
|
|
640
|
+
: `${bytes} bytes of always-on projection; this repository declared ${allowed}. Nothing was cut: the projection is still in full mode, so every rule is loaded as written`,
|
|
641
|
+
remedy: kernel
|
|
642
|
+
? `raise budget.bytes to ${bytes}, or move rules out of the hard and command classes into convention or style, which is the only way a kernel shrinks`
|
|
643
|
+
: `raise budget.bytes to ${bytes} if the projection is meant to be this size, switch mode to kernel to defer the convention and style rules, or narrow the rules that carry it`,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* A manifest with this tool's own fields replaced by what this build writes, and
|
|
651
|
+
* nothing else touched.
|
|
652
|
+
*
|
|
653
|
+
* Spread from the repository's manifest rather than from `buildManifest`'s output:
|
|
654
|
+
* the builder fills a default for every key, so writing its result wholesale would
|
|
655
|
+
* overwrite `gates` and `limits` in any repository that chose its own. "Content the
|
|
656
|
+
* repo chose is never compared" is a promise about the repair too, not only about
|
|
657
|
+
* the report, and a repair is exactly where it would be broken.
|
|
658
|
+
*
|
|
659
|
+
* @param {object} manifest the repository's manifest, as parsed
|
|
660
|
+
* @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
|
|
661
|
+
* @returns {object} a new manifest; the argument is not mutated
|
|
662
|
+
*/
|
|
663
|
+
export function withGenerated(manifest, expected) {
|
|
664
|
+
const patched = { ...manifest };
|
|
665
|
+
for (const key of GENERATED_KEYS) if (key in expected) patched[key] = expected[key];
|
|
666
|
+
return patched;
|
|
667
|
+
}
|
|
668
|
+
|
|
391
669
|
/**
|
|
392
670
|
* Is a generated field of this manifest wrong, or has a formatter taken its bytes?
|
|
393
671
|
*
|
|
@@ -408,6 +686,12 @@ function defaultId(root) {
|
|
|
408
686
|
* again. `upgrade` reported `current` the whole time, which is correct: the pin
|
|
409
687
|
* was current. The bytes were not.
|
|
410
688
|
*
|
|
689
|
+
* This one is a consequence of routing, not of adoption. Nothing reformatted the
|
|
690
|
+
* manifest until the pre-commit gate started running, so every repository that
|
|
691
|
+
* adopts the tool and runs a formatter arrives here on its first commit. The
|
|
692
|
+
* remedy is therefore "stop the formatter taking it, then `sync`" — and it was
|
|
693
|
+
* `init --yes`, which re-runs adoption to change one file's formatting (ADR-050).
|
|
694
|
+
*
|
|
411
695
|
* Content the repo chose is never compared. A repo that edited `limits.coverage` on
|
|
412
696
|
* purpose is not being told to revert it.
|
|
413
697
|
*
|
|
@@ -417,20 +701,34 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
417
701
|
const { manifest, exists, error } = await readManifest(root);
|
|
418
702
|
if (!exists || error !== null || manifest === null) return null;
|
|
419
703
|
|
|
420
|
-
const expected = buildManifest({ root, stack, sha, existing: manifest });
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
704
|
+
const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
|
|
705
|
+
// `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
|
|
706
|
+
// fields are ours, and the answer is the set above. Iterating the expected object
|
|
707
|
+
// asks the same question of a different list and can only agree by coincidence.
|
|
708
|
+
const stale = [...GENERATED_KEYS].filter(
|
|
709
|
+
(key) => key in expected && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key]),
|
|
710
|
+
);
|
|
428
711
|
if (stale.length > 0) {
|
|
429
712
|
return {
|
|
430
713
|
file: MANIFEST,
|
|
431
714
|
status: 'stale-generated-field',
|
|
432
715
|
detail: `generated field(s) ${stale.join(', ')} do not match what this simt-dev writes`,
|
|
433
|
-
|
|
716
|
+
// `sync`, and not `init`. `sync` repairs exactly this field set and nothing
|
|
717
|
+
// else; `init` re-runs adoption - it tags, it branches, it asks every question
|
|
718
|
+
// again - and two commands must not answer one question with different
|
|
719
|
+
// instructions (ADR-050).
|
|
720
|
+
//
|
|
721
|
+
// The remedy said `init` because for two releases nothing could repair a stale
|
|
722
|
+
// field, so the only command that could was the only one named. `sync` having
|
|
723
|
+
// nothing to repair while `check` refused anyway is the one-question-two-answers
|
|
724
|
+
// defect this repository exists to end, and it was true in all fourteen adopted
|
|
725
|
+
// repositories at the same moment: measured, not inferred. `check` exited 1 in
|
|
726
|
+
// every one of them while `sync` printed "already current; nothing written".
|
|
727
|
+
//
|
|
728
|
+
// No trailing parenthetical, because `compare` prints this string verbatim as
|
|
729
|
+
// the remedy on its own row. A remedy that reads well in one command's output
|
|
730
|
+
// and badly in another's is two answers wearing one string.
|
|
731
|
+
remedy: `run: ${CLI} sync --yes`,
|
|
434
732
|
};
|
|
435
733
|
}
|
|
436
734
|
|
|
@@ -446,10 +744,22 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
446
744
|
file: MANIFEST,
|
|
447
745
|
status: 'reformatted',
|
|
448
746
|
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}
|
|
747
|
+
remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI} sync --yes`,
|
|
450
748
|
};
|
|
451
749
|
}
|
|
452
750
|
|
|
751
|
+
/**
|
|
752
|
+
* The bytes a manifest is written as.
|
|
753
|
+
*
|
|
754
|
+
* Exported because two callers now need them and one of them is a preview: a plan
|
|
755
|
+
* that reports a byte count it computed with a different serialiser than the writer
|
|
756
|
+
* used reports a number nothing will ever observe. `writeManifest` is the only place
|
|
757
|
+
* this format is spelled.
|
|
758
|
+
*/
|
|
759
|
+
export function manifestText(manifest) {
|
|
760
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
761
|
+
}
|
|
762
|
+
|
|
453
763
|
/** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
|
|
454
764
|
export async function readManifest(root) {
|
|
455
765
|
const path = await manifestPath(root);
|
|
@@ -473,6 +783,6 @@ export async function writeManifest(root, manifest) {
|
|
|
473
783
|
const previous = join(root, MANIFEST_LEGACY);
|
|
474
784
|
const fresh = join(root, MANIFEST);
|
|
475
785
|
if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
|
|
476
|
-
await writeFile(fresh,
|
|
786
|
+
await writeFile(fresh, manifestText(manifest));
|
|
477
787
|
return fresh;
|
|
478
788
|
}
|
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
|
+
}
|