simt-dev 0.2.1 → 0.3.0
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 +239 -0
- package/bin/simt-dev.mjs +21 -2
- 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/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- 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 +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -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 +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -67,8 +67,12 @@ export async function manifestPath(root) {
|
|
|
67
67
|
* real is the recorded SHA under `projection`.
|
|
68
68
|
*/
|
|
69
69
|
export const MANIFEST_COMMENT = {
|
|
70
|
-
$comment:
|
|
71
|
-
`Written and maintained by simt-dev.
|
|
70
|
+
$comment: [
|
|
71
|
+
`Written and maintained by simt-dev. \`${CLI} sync --yes\` regenerates the projected rules.`,
|
|
72
|
+
`\`${CLI} init\` refreshes the SHA and keeps every key it does not write, so a value you add here survives a re-init.`,
|
|
73
|
+
`\`plans\` is such a key: it selects the tracker template and the directories \`${CLI} plan\` reads.`,
|
|
74
|
+
'Omit it and the tracker template applies, which reads docs/plans and docs/reviews.',
|
|
75
|
+
].join(' '),
|
|
72
76
|
};
|
|
73
77
|
|
|
74
78
|
/**
|
|
@@ -418,7 +422,7 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
|
|
|
418
422
|
? { ...planned.gates, commands: planned.commands }
|
|
419
423
|
: planned.gates;
|
|
420
424
|
|
|
421
|
-
|
|
425
|
+
const owned = {
|
|
422
426
|
...MANIFEST_COMMENT,
|
|
423
427
|
id: existing?.id ?? defaultId(root),
|
|
424
428
|
stack: existing?.stack?.length ? existing.stack : stack,
|
|
@@ -436,6 +440,29 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
|
|
|
436
440
|
// substitution value: it lists the rules this repo declares it has a problem
|
|
437
441
|
// with, which is something detection cannot infer.
|
|
438
442
|
substitutions: existing?.substitutions ?? {},
|
|
443
|
+
// ADR-065: rule sets this repository shares with its siblings, and the digest
|
|
444
|
+
// of the bytes it has for each. Not a generated field - which packs a repo uses
|
|
445
|
+
// is the repo's decision, and `check` reports a digest that does not match the
|
|
446
|
+
// directory rather than a value here that differs from what the tool would pick.
|
|
447
|
+
packs: existing?.packs ?? [],
|
|
448
|
+
// ADR-072: how many bytes of always-on projection this repository has agreed to
|
|
449
|
+
// carry. A declaration, not a generated field, for the same reason `packs` is not:
|
|
450
|
+
// the number is the repository's own line in the sand, and `upgrade` must not
|
|
451
|
+
// rewrite it to whatever the tool happens to project today — a budget that moved by
|
|
452
|
+
// itself would be a budget nobody is held to.
|
|
453
|
+
//
|
|
454
|
+
// Absent when the repository declared nothing, which is the point. Writing the
|
|
455
|
+
// default here would make every adopted repo look like it had chosen it, and a
|
|
456
|
+
// reader told "declared: true" about a line nobody wrote is being lied to about the
|
|
457
|
+
// only thing the number is for. The default belongs to whoever measures, and
|
|
458
|
+
// `budgetFinding` and `simt tokens` both read it from one place.
|
|
459
|
+
...(existing?.budget === undefined ? {} : { budget: existing.budget }),
|
|
460
|
+
// ADR-073: `full` projects every selected rule; `kernel` projects only the hard
|
|
461
|
+
// constraints and defers the rest to `rules show`. Absent means `full`, and the
|
|
462
|
+
// default stays `full` because nobody has yet measured whether a kernel loses
|
|
463
|
+
// compliance (plan 0003 R02) — a mode chosen by default would be a bet the
|
|
464
|
+
// repository never made. Declared, not generated, for the same reason as `budget`.
|
|
465
|
+
...(existing?.mode === undefined ? {} : { mode: existing.mode }),
|
|
439
466
|
// ADR-020: which editors this repo's rules are projected into. Absent means the
|
|
440
467
|
// global standard applies; an explicit empty list is a real choice meaning
|
|
441
468
|
// AGENTS.md only. Recorded rather than inferred, because "which IDE does this repo
|
|
@@ -450,6 +477,25 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
|
|
|
450
477
|
// adopted. See src/package-version.mjs.
|
|
451
478
|
projection: { simtDevSha: sha ?? null, version: version ?? null },
|
|
452
479
|
};
|
|
480
|
+
return withRepoKeys(existing, owned);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Keys this writer does not recognise belong to the repository.
|
|
485
|
+
*
|
|
486
|
+
* A re-init used to return only the object above, so anything else in the file
|
|
487
|
+
* disappeared. `plans` is the tracker overlay, and it is not the only key a
|
|
488
|
+
* repository might have added. Owned keys stay in the order the writer emits
|
|
489
|
+
* them; an unknown key is copied after them, in the order the file had it.
|
|
490
|
+
*/
|
|
491
|
+
function withRepoKeys(existing, owned) {
|
|
492
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return owned;
|
|
493
|
+
const kept = {};
|
|
494
|
+
for (const key of Object.keys(existing)) {
|
|
495
|
+
if (Object.hasOwn(owned, key)) continue;
|
|
496
|
+
kept[key] = existing[key];
|
|
497
|
+
}
|
|
498
|
+
return { ...owned, ...kept };
|
|
453
499
|
}
|
|
454
500
|
|
|
455
501
|
/**
|
|
@@ -470,6 +516,24 @@ export function unrunnableGates(manifest, capability) {
|
|
|
470
516
|
];
|
|
471
517
|
const findings = [];
|
|
472
518
|
const seen = new Set();
|
|
519
|
+
// `gates.cwd` names a directory a gate runs in, and a name that is not a declared
|
|
520
|
+
// gate is as much a landmine as one that cannot run: the entry is inert, the reader
|
|
521
|
+
// believes the gate runs in the package it names, and nothing anywhere says so. Same
|
|
522
|
+
// shape as the unknown-gate finding in the dispatcher, so both are repaired the same
|
|
523
|
+
// way.
|
|
524
|
+
const cwds = manifest?.gates?.cwd;
|
|
525
|
+
if (cwds && typeof cwds === 'object' && !Array.isArray(cwds)) {
|
|
526
|
+
for (const name of Object.keys(cwds).sort()) {
|
|
527
|
+
if (declared.includes(name)) continue;
|
|
528
|
+
findings.push({
|
|
529
|
+
file: MANIFEST,
|
|
530
|
+
status: 'unknown-gate-cwd',
|
|
531
|
+
gate: name,
|
|
532
|
+
detail: `gates.cwd names "${name}", which is not declared in gates.preCommit or gates.prePush, so it is never read`,
|
|
533
|
+
remedy: `declare "${name}" in gates.preCommit or gates.prePush, or remove gates.cwd["${name}"]`,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
473
537
|
for (const name of declared) {
|
|
474
538
|
if (seen.has(name)) continue;
|
|
475
539
|
seen.add(name);
|
|
@@ -556,6 +620,53 @@ function defaultId(root) {
|
|
|
556
620
|
* or a report about a field nothing repairs - and each half passes its own test,
|
|
557
621
|
* which is ADR-045's shape reached from the other direction.
|
|
558
622
|
*/
|
|
623
|
+
/**
|
|
624
|
+
* The bytes of always-on projection a repository carries per root before the number is
|
|
625
|
+
* worth an argument.
|
|
626
|
+
*
|
|
627
|
+
* Thirty thousand is S38's figure and not a measurement this tool took: it is the line
|
|
628
|
+
* the review drew, the one the whole row exists to check. A default nobody would accept
|
|
629
|
+
* is a default that reports nothing, and a budget only works as a budget when exceeding
|
|
630
|
+
* it is something a reader can see.
|
|
631
|
+
*/
|
|
632
|
+
export const DEFAULT_BUDGET_BYTES = 30000;
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Report a projection that carries more than the repository agreed to carry.
|
|
636
|
+
*
|
|
637
|
+
* Non-blocking by design, and that is the honest answer rather than a convenient one:
|
|
638
|
+
* the plan's own wording is that `check` reports `BUDGET-CUT` and nothing is cut, because
|
|
639
|
+
* cutting requires a kernel to pack lines into and the kernel does not exist yet
|
|
640
|
+
* (R07, blocked on the R02 evaluation). A gate that failed here would fail every
|
|
641
|
+
* repository in the estate on a number this tool has not yet earned the right to enforce.
|
|
642
|
+
*
|
|
643
|
+
* The finding names what is over and what is NOT cut, because a reader who is told only
|
|
644
|
+
* that a budget was exceeded will assume something was removed and stop looking.
|
|
645
|
+
*
|
|
646
|
+
* @returns {{file: string, status: string, detail: string, remedy: string}|null}
|
|
647
|
+
*/
|
|
648
|
+
export function budgetFinding(bytes, budget, mode = 'full') {
|
|
649
|
+
const allowed = budget?.bytes ?? DEFAULT_BUDGET_BYTES;
|
|
650
|
+
if (!Number.isFinite(bytes) || bytes <= allowed) return null;
|
|
651
|
+
// The two modes answer different questions, and conflating them is the one way this
|
|
652
|
+
// finding could be actively wrong. In `full` nothing is cut, so a reader told only
|
|
653
|
+
// "over budget" would look for a removed rule and find none. In `kernel` the packing
|
|
654
|
+
// already ran and the projection the reader will see is the packed one — so a number
|
|
655
|
+
// over the line here means the KERNEL is over the line, which is a different remedy
|
|
656
|
+
// entirely: no amount of raising the number fixes a kernel that is too large on its own.
|
|
657
|
+
const kernel = mode === 'kernel';
|
|
658
|
+
return {
|
|
659
|
+
file: MANIFEST,
|
|
660
|
+
status: 'budget',
|
|
661
|
+
detail: kernel
|
|
662
|
+
? `${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`
|
|
663
|
+
: `${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`,
|
|
664
|
+
remedy: kernel
|
|
665
|
+
? `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`
|
|
666
|
+
: `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`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
559
670
|
export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
560
671
|
|
|
561
672
|
/**
|
|
@@ -25,10 +25,11 @@ import { planDependabot } from '../ci/dependabot.mjs';
|
|
|
25
25
|
import { checkTsconfig } from '../tsconfig.mjs';
|
|
26
26
|
import { checkToolchain } from '../toolchain.mjs';
|
|
27
27
|
import { diff, parse, inject, BEGIN, END } from './markers.mjs';
|
|
28
|
-
import { readManifest } from './manifest.mjs';
|
|
28
|
+
import { budgetFinding, readManifest } from './manifest.mjs';
|
|
29
|
+
import { measureRoot } from '../measure/context.mjs';
|
|
29
30
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
31
|
import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
|
|
31
|
-
import { pointerState, pointerTargets, ruleTargets } from './targets.mjs';
|
|
32
|
+
import { pointerState, pointerTargets, ruleTargets, scopedTargets } from './targets.mjs';
|
|
32
33
|
import { CLI } from '../name.mjs';
|
|
33
34
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
34
35
|
import {
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
provenanceDigest,
|
|
37
38
|
isEligible,
|
|
38
39
|
loadRules,
|
|
40
|
+
packageRegions,
|
|
39
41
|
LOCAL_PROFILE_DIR,
|
|
40
42
|
mdcTargets,
|
|
41
43
|
readDeclared,
|
|
@@ -69,12 +71,31 @@ export async function plan(root, flags = {}) {
|
|
|
69
71
|
|
|
70
72
|
const surfaces = resolveSurfaces(manifest, flags);
|
|
71
73
|
|
|
74
|
+
// Profiles that narrowed out of the region, written as files for the surfaces whose
|
|
75
|
+
// scoping key was read in the vendor's own documentation. Only when EVERY selected
|
|
76
|
+
// surface can scope: a profile that left the region while a reader could not narrow
|
|
77
|
+
// would be a rule that reader never loads, and `desiredRegion` already kept it in the
|
|
78
|
+
// region in that case, which is why `narrowing` is the gate here too.
|
|
79
|
+
const scoped =
|
|
80
|
+
desired.narrowing === true
|
|
81
|
+
? await scopedTargets(root, desired.scoped ?? [], meta.sha, surfaces, notes)
|
|
82
|
+
: { targets: [] };
|
|
83
|
+
|
|
72
84
|
// The baselines are the same list shape as every other target, so `sync` writes
|
|
73
85
|
// them and reports them in the same plan it prints for AGENTS.md and the rule
|
|
74
86
|
// files. A file the reader was not shown is a file they approve by reading the diff
|
|
75
87
|
// afterwards, which is the one review step this tool exists to make unnecessary.
|
|
76
88
|
const baselines = await baselineTargets(root, meta.sha);
|
|
77
89
|
|
|
90
|
+
// A monorepo projects once per package as well as once at the root. The nested
|
|
91
|
+
// regions come after the root's and before the rule files, because the root is
|
|
92
|
+
// what a reader meets first and a plan that listed a package's region before the
|
|
93
|
+
// root's would read as though the packages were the repository.
|
|
94
|
+
const packages = await packageRegions(root, flags, profiles);
|
|
95
|
+
const nested = await Promise.all(
|
|
96
|
+
packages.regions.map((r) => targetFor(join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
|
|
97
|
+
);
|
|
98
|
+
|
|
78
99
|
return {
|
|
79
100
|
root,
|
|
80
101
|
stack,
|
|
@@ -85,13 +106,14 @@ export async function plan(root, flags = {}) {
|
|
|
85
106
|
surfaceSource: surfaces.source,
|
|
86
107
|
surfaceProblems: surfaces.problems,
|
|
87
108
|
rules: all,
|
|
88
|
-
targets: [target, ...mdc.targets, ...baselines.targets],
|
|
109
|
+
targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets],
|
|
110
|
+
packages: packages.regions.map((r) => ({ path: r.path, profiles: r.profiles })),
|
|
89
111
|
mdcProblems: mdc.problems,
|
|
90
|
-
notes: [...notes, ...mdc.notes],
|
|
112
|
+
notes: [...notes, ...mdc.notes, ...packages.notes],
|
|
91
113
|
};
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
async function targetFor(root, body, meta) {
|
|
116
|
+
async function targetFor(root, body, meta, label = PRIMARY_TARGET) {
|
|
95
117
|
const path = join(root, PRIMARY_TARGET);
|
|
96
118
|
let existing = '';
|
|
97
119
|
let exists = true;
|
|
@@ -103,7 +125,10 @@ async function targetFor(root, body, meta) {
|
|
|
103
125
|
|
|
104
126
|
const result = inject(existing, body, meta, await regionOpts(root));
|
|
105
127
|
return {
|
|
106
|
-
|
|
128
|
+
// The repo-relative path, and not the basename: a monorepo writes the same
|
|
129
|
+
// document once per package, and a plan that printed `AGENTS.md` twice with no
|
|
130
|
+
// directory on either line would leave the reader unable to tell which is which.
|
|
131
|
+
file: label,
|
|
107
132
|
path,
|
|
108
133
|
exists,
|
|
109
134
|
action: result.action,
|
|
@@ -140,6 +165,36 @@ export async function checkTargets(root, flags = {}) {
|
|
|
140
165
|
findings.push(...readLimitFindings(text, PRIMARY_TARGET));
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
// The budget: what this repository agreed to carry, against what it carries.
|
|
169
|
+
//
|
|
170
|
+
// Measured from the same `desiredRegion` the region above was compared against, plus
|
|
171
|
+
// the rule files this function went on to compare, because a gate and a meter that
|
|
172
|
+
// each counted their own set would report two different totals for one repository and
|
|
173
|
+
// the reader would have no way to tell which was the budget. Both numbers are the
|
|
174
|
+
// DESIRED ones, so a drifted repo is reported against what it would carry after a
|
|
175
|
+
// sync rather than what it carries today.
|
|
176
|
+
//
|
|
177
|
+
// Non-blocking by design: nothing is cut, because there is nothing to cut yet (ADR-072).
|
|
178
|
+
const budgetTotal = await alwaysOnBytes(root);
|
|
179
|
+
if (budgetTotal !== null) {
|
|
180
|
+
const { manifest } = await readManifest(root);
|
|
181
|
+
const over = budgetFinding(budgetTotal.bytes, manifest?.budget, manifest?.mode);
|
|
182
|
+
if (over) findings.push({ ...over, file: MANIFEST_FILE, detail: over.detail });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Every package of a monorepo is a projection root in its own right, so the gate
|
|
186
|
+
// covers it the way it covers the root. A nested region that drifts is as stale as
|
|
187
|
+
// the root's region, and the reader working in that package reads only that file.
|
|
188
|
+
//
|
|
189
|
+
// Gated on the root having a region at all: a monorepo that has not adopted has no
|
|
190
|
+
// nested regions either, and reporting one as absent would describe a half-adoption
|
|
191
|
+
// that nobody performed.
|
|
192
|
+
const { regions: packageBodies } = await packageRegions(root, flags);
|
|
193
|
+
for (const region of packageBodies) {
|
|
194
|
+
const file = `${region.path}/${PRIMARY_TARGET}`;
|
|
195
|
+
findings.push({ ...(await checkRegion(root, join(root, region.path), region.body, region.meta)), file, profiles: region.profiles });
|
|
196
|
+
}
|
|
197
|
+
|
|
143
198
|
// The gate has to run where the merge happens, not only on the machine of whoever
|
|
144
199
|
// remembers to run it. A repo that already runs GitHub Actions and has no simt-dev
|
|
145
200
|
// job is ungated at its front door, and that is the one finding here that no amount
|
|
@@ -189,7 +244,8 @@ export async function checkTargets(root, flags = {}) {
|
|
|
189
244
|
// Selection must match `plan` exactly, or the gate reports a rule absent that
|
|
190
245
|
// `sync` was never going to write — a permanently red check nothing can fix.
|
|
191
246
|
const all = await loadMdcRules();
|
|
192
|
-
const
|
|
247
|
+
const desired = await desiredRegion(root, flags);
|
|
248
|
+
const { stack, facts, manifest } = desired;
|
|
193
249
|
const declared = await readDeclared(root);
|
|
194
250
|
const resolved = resolveSurfaces(manifest, flags);
|
|
195
251
|
for (const p of resolved.problems) {
|
|
@@ -200,6 +256,36 @@ export async function checkTargets(root, flags = {}) {
|
|
|
200
256
|
const sha = await provenanceDigest();
|
|
201
257
|
const notes = [];
|
|
202
258
|
|
|
259
|
+
// The profiles that left the region, gated through the same construction that wrote
|
|
260
|
+
// them. Without this loop a scoped file is planned and written but never checked, so a
|
|
261
|
+
// repository that edits or deletes one shows a clean `check` and a stale reader — the
|
|
262
|
+
// exact asymmetry the region-versus-file gate exists to prevent.
|
|
263
|
+
const scopedRules = desired.scoped ?? [];
|
|
264
|
+
if (desired.narrowing === true) {
|
|
265
|
+
for (const target of (await scopedTargets(root, scopedRules, sha, resolved, notes)).targets) {
|
|
266
|
+
if (!target.exists) {
|
|
267
|
+
findings.push({
|
|
268
|
+
file: target.file,
|
|
269
|
+
surface: target.surface,
|
|
270
|
+
status: 'absent',
|
|
271
|
+
detail: `no ${target.surface} rule here; run: ${CLI} sync --yes`,
|
|
272
|
+
});
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// The writer's own verdict, from the same inject call `plan` will make. A second
|
|
276
|
+
// construction here would be a third place computing the same file, which is the
|
|
277
|
+
// defect this whole function exists to avoid.
|
|
278
|
+
const action = target.action;
|
|
279
|
+
findings.push({
|
|
280
|
+
file: target.file,
|
|
281
|
+
surface: target.surface,
|
|
282
|
+
status: action === 'unchanged' ? 'current' : 'drift',
|
|
283
|
+
detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
|
|
284
|
+
recordedSha: parse(target.previous).sha ?? null,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
203
289
|
for (const rule of rules) {
|
|
204
290
|
if (rule.error !== null) {
|
|
205
291
|
findings.push({
|
|
@@ -433,7 +519,18 @@ export async function checkTarget(root, flags = {}) {
|
|
|
433
519
|
|
|
434
520
|
async function checkPrimary(root, flags = {}) {
|
|
435
521
|
const { body, meta } = await desiredRegion(root, flags);
|
|
436
|
-
|
|
522
|
+
return checkRegion(root, root, body, meta);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* One region, compared against what this simt-dev would write for it.
|
|
527
|
+
*
|
|
528
|
+
* `dir` is the projection root that owns the document: the repository for the root
|
|
529
|
+
* region, a package for a nested one. `regionOpts` is read from the repository
|
|
530
|
+
* either way, because placement is a repository-level decision.
|
|
531
|
+
*/
|
|
532
|
+
async function checkRegion(root, dir, body, meta) {
|
|
533
|
+
const path = join(dir, PRIMARY_TARGET);
|
|
437
534
|
let existing = '';
|
|
438
535
|
try {
|
|
439
536
|
existing = await readFile(path, 'utf8');
|
|
@@ -468,6 +565,28 @@ async function checkPrimary(root, flags = {}) {
|
|
|
468
565
|
expectedSha: meta.sha,
|
|
469
566
|
};
|
|
470
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* The bytes of always-on projection this repository carries, or null if it has not
|
|
570
|
+
* adopted at all.
|
|
571
|
+
*
|
|
572
|
+
* Delegates to the meter rather than counting here, and that is the whole of the
|
|
573
|
+
* design. A first version summed the region plus the rule files this function already
|
|
574
|
+
* compares, which sounds cheaper and is wrong: the meter also counts the managed skills
|
|
575
|
+
* and the `.mdc` files a reader loads, so the gate and the report came to different
|
|
576
|
+
* totals for one repository. Two definitions of one budget is the defect ADR-034 was
|
|
577
|
+
* filed about, rebuilt one layer up. The meter is the definition; the gate quotes it.
|
|
578
|
+
*
|
|
579
|
+
* The cost is that `check` now reads the documents the meter reads, which it did not
|
|
580
|
+
* do before. That is a real cost, paid knowingly: the alternative is a gate that fails
|
|
581
|
+
* on a number the report does not print, which is worse than a slower gate.
|
|
582
|
+
*/
|
|
583
|
+
async function alwaysOnBytes(root) {
|
|
584
|
+
const { exists } = await readManifest(root);
|
|
585
|
+
if (!exists) return null;
|
|
586
|
+
const measured = await measureRoot(root);
|
|
587
|
+
return { bytes: measured.generatedBytes };
|
|
588
|
+
}
|
|
589
|
+
|
|
471
590
|
/** `projection.place: "top"` puts the AGENTS.md region above the hand-written prefix. */
|
|
472
591
|
async function regionOpts(root) {
|
|
473
592
|
const { manifest } = await readManifest(root);
|
|
@@ -478,7 +597,11 @@ async function regionOpts(root) {
|
|
|
478
597
|
* A reader that stops at a byte offset never sees a region that ends past it.
|
|
479
598
|
* The finding names the reader, because "the file is long" does not say who stops.
|
|
480
599
|
*/
|
|
481
|
-
|
|
600
|
+
// Exported for the context meter (`simt-dev tokens`), which answers the same question
|
|
601
|
+
// about a region rather than about a diff. It reads the structured fields instead of
|
|
602
|
+
// re-deriving the limit, because a second implementation of "which reader stops, and
|
|
603
|
+
// where" is a second thing to keep right - and a second thing to quote in a message.
|
|
604
|
+
export function readLimitFindings(text, file) {
|
|
482
605
|
const state = parse(text);
|
|
483
606
|
if (state.status !== 'ok' || typeof state.endByte !== 'number') return [];
|
|
484
607
|
const findings = [];
|
|
@@ -489,6 +612,14 @@ function readLimitFindings(text, file) {
|
|
|
489
612
|
findings.push({
|
|
490
613
|
file,
|
|
491
614
|
status: 'beyond-read-limit',
|
|
615
|
+
// The numbers, not only the sentence. `check` prints the detail and the context
|
|
616
|
+
// meter puts the reader, the limit and the offset in its JSON, and a consumer
|
|
617
|
+
// that has to regex prose to get them is a consumer that breaks when the prose
|
|
618
|
+
// is edited.
|
|
619
|
+
reader: limit.reader,
|
|
620
|
+
limitBytes: limit.bytes,
|
|
621
|
+
endByte: state.endByte,
|
|
622
|
+
overBy: state.endByte - limit.bytes,
|
|
492
623
|
detail: `BEYOND-READ-LIMIT: the region ends at byte ${state.endByte}, past ${limit.reader}'s ${limit.bytes}-byte read of ${file}`,
|
|
493
624
|
remedy: `set projection.place to "top" in ${MANIFEST_FILE} and run: ${CLI} sync --yes, or raise ${limit.reader}'s project_doc_max_bytes`,
|
|
494
625
|
});
|