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
package/src/projection/rules.mjs
CHANGED
|
@@ -27,10 +27,15 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
27
27
|
|
|
28
28
|
import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
29
29
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
|
-
import { foldsRules, frontmatterBlock, resolveSurfaces } from './surfaces.mjs';
|
|
30
|
+
import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
|
|
31
31
|
import { ruleTargets } from './targets.mjs';
|
|
32
|
-
import { manifestPath } from './manifest.mjs';
|
|
32
|
+
import { manifestPath, readManifest } from './manifest.mjs';
|
|
33
|
+
import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
|
|
34
|
+
import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
|
|
33
35
|
import { BASELINES } from './baselines.mjs';
|
|
36
|
+
import { CLI } from '../name.mjs';
|
|
37
|
+
import { PACK_DIR, resolvePacks } from '../packs.mjs';
|
|
38
|
+
import { discoverPackages } from '../monorepo.mjs';
|
|
34
39
|
|
|
35
40
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
41
|
export const ROOT = resolve(HERE, '..', '..');
|
|
@@ -123,12 +128,37 @@ async function walk(dir, prefix = '') {
|
|
|
123
128
|
/** Repo-local profile directory, relative to a repo root (ADR-013 section 2). */
|
|
124
129
|
export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
|
|
125
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Which origin outranks which when two profiles claim one `simt-id`.
|
|
133
|
+
*
|
|
134
|
+
* A pack is the repository's shared policy and the built-in set is the tool's
|
|
135
|
+
* opinion, so a repository can replace either and a pack can replace what the
|
|
136
|
+
* tool ships. Read in one place because the answer to "whose rule is this?" is
|
|
137
|
+
* asked on every rule of every projection, and a precedence that lives in two
|
|
138
|
+
* places is a precedence that will disagree with itself.
|
|
139
|
+
*/
|
|
140
|
+
const ORIGIN_RANK = { central: 0, pack: 1, local: 2 };
|
|
141
|
+
|
|
126
142
|
/** Every key a stack profile or core rule may declare is this tool's own, so every
|
|
127
143
|
* one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
|
|
128
144
|
* as an editor's instruction — Cursor, Copilot and Antigravity all parse a
|
|
129
145
|
* frontmatter block the same way — and would be silently inert. */
|
|
130
146
|
/** 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']);
|
|
147
|
+
export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback', 'simt-scope', 'simt-priority']);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Priority classes, packed in this order (plan 0003 R07).
|
|
151
|
+
*
|
|
152
|
+
* A class is not a severity, it is a promise about what a reader loses by not having
|
|
153
|
+
* the line. A hard constraint is one whose absence produces a defect; a command is one
|
|
154
|
+
* the reader cannot guess; a convention is right to follow and harmless to miss; a style
|
|
155
|
+
* is a preference. Anything after `command` is the first thing a kernel drops, and that
|
|
156
|
+
* ordering is the whole content of the mode — everything else about it is bookkeeping.
|
|
157
|
+
*/
|
|
158
|
+
export const PRIORITY_CLASSES = ['hard', 'command', 'convention', 'style'];
|
|
159
|
+
|
|
160
|
+
/** The classes a kernel keeps. `full` mode keeps all four and is the default. */
|
|
161
|
+
export const KERNEL_CLASSES = new Set(['hard', 'command']);
|
|
132
162
|
|
|
133
163
|
/** Report the first thing wrong with a profile's frontmatter, in the order a reader
|
|
134
164
|
* fixes it: no id to select it by, then a key that will not be read, then a tag the
|
|
@@ -147,14 +177,33 @@ function describeDefect(meta) {
|
|
|
147
177
|
const prefixed = `simt-${unknown.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
148
178
|
return PROFILE_KEYS.has(prefixed)
|
|
149
179
|
? `key "${unknown}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
|
|
150
|
-
: `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback`;
|
|
180
|
+
: `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback, simt-scope`;
|
|
151
181
|
}
|
|
152
182
|
const applies = meta['simt-applies'];
|
|
153
183
|
if (!Array.isArray(applies)) return null;
|
|
154
184
|
const undeclared = applies.find((tag) => !KNOWN_TAGS.has(tag));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
185
|
+
if (undeclared !== undefined) {
|
|
186
|
+
return `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
|
|
187
|
+
}
|
|
188
|
+
const priority = meta['simt-priority'];
|
|
189
|
+
if (priority !== undefined && !PRIORITY_CLASSES.includes(priority)) {
|
|
190
|
+
return `simt-priority is "${priority}"; the classes are ${PRIORITY_CLASSES.join(', ')}, in the order a kernel packs them`;
|
|
191
|
+
}
|
|
192
|
+
const scope = meta['simt-scope'];
|
|
193
|
+
if (scope === undefined) return null;
|
|
194
|
+
// A scope that matches the whole repository is the eager profile wearing a costume:
|
|
195
|
+
// the reader is told the rule narrows, and the rule loads on every file. `**` and
|
|
196
|
+
// `**/*` are the two spellings of that, and both are the default a surface applies
|
|
197
|
+
// when it is given no pattern at all.
|
|
198
|
+
const CATCH_ALL = new Set(['**', '**/*', '*']);
|
|
199
|
+
if (scope.length === 0) {
|
|
200
|
+
return 'simt-scope is empty; a profile with no pattern would load as eagerly as one with no simt-scope at all';
|
|
201
|
+
}
|
|
202
|
+
const bad = scope.find((glob) => typeof glob !== 'string' || glob === '' || CATCH_ALL.has(glob));
|
|
203
|
+
if (bad !== undefined) {
|
|
204
|
+
return `simt-scope needs at least one pattern that narrows; "${bad}" matches every file, so the profile would load exactly as eagerly as one with no simt-scope`;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
158
207
|
}
|
|
159
208
|
|
|
160
209
|
/**
|
|
@@ -361,6 +410,19 @@ async function ruleBodies(root, manifest, flags) {
|
|
|
361
410
|
return { resolved, bodies, problems, notes, skipped };
|
|
362
411
|
}
|
|
363
412
|
|
|
413
|
+
/**
|
|
414
|
+
* The command card for a repository and the evidence its markers may be proved by.
|
|
415
|
+
*
|
|
416
|
+
* Both the region and the package regions need the proofs, and they must be the same set:
|
|
417
|
+
* a rule dropped at the root and kept in a package would be one rule treated as two. The
|
|
418
|
+
* card comes back alongside because the caller that needs the proofs also needs the card's
|
|
419
|
+
* bytes, and building it twice would read the manifest twice for no second answer.
|
|
420
|
+
*/
|
|
421
|
+
async function inferableEvidence(root, { stack, facts, manifest }) {
|
|
422
|
+
const card = await commandCard({ root, manifest, stack, facts });
|
|
423
|
+
return { card, proofs: inferableProofs({ facts, stack, card, manifest }) };
|
|
424
|
+
}
|
|
425
|
+
|
|
364
426
|
/**
|
|
365
427
|
* The one place the desired region is computed. `plan` (which writes) and
|
|
366
428
|
* `checkTarget` (which gates) both go through this, so they cannot disagree
|
|
@@ -371,21 +433,130 @@ export async function desiredRegion(root, flags) {
|
|
|
371
433
|
const { stack, facts, manifest } = await fingerprint(root);
|
|
372
434
|
const all = await loadRules(root);
|
|
373
435
|
const { selected, fallback, notes } = select(all, stack, facts);
|
|
374
|
-
|
|
375
|
-
let body = render(selected);
|
|
376
|
-
let sources = selected.map((r) => r.source);
|
|
377
|
-
const folded = [];
|
|
378
|
-
|
|
379
|
-
// When every selected surface carries its rules inside the primary document, the
|
|
380
|
-
// per-rule bodies belong in the region too — otherwise a repo on the standard
|
|
381
|
-
// surface receives AGENTS.md and none of the always-on process rules, because
|
|
382
|
-
// those had nowhere else to live. One file, one region, one place a reader opens.
|
|
383
436
|
const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
|
|
384
437
|
root,
|
|
385
438
|
manifest,
|
|
386
439
|
flags ?? {},
|
|
387
440
|
);
|
|
388
441
|
notes.push(...ruleNotes);
|
|
442
|
+
|
|
443
|
+
// A profile that declares a scope leaves the region only when every selected surface
|
|
444
|
+
// can load it narrowly. The alternative is a profile written only as a scoped file for
|
|
445
|
+
// a reader whose surface cannot scope — a rule nobody loads, which is worse than one
|
|
446
|
+
// that loads eagerly (ADR-071). A surface that cannot scope it therefore keeps the
|
|
447
|
+
// profile in the region and says so, rather than letting the region quietly shrink.
|
|
448
|
+
const narrowing = resolved.surfaces.every((s) => canScope(s));
|
|
449
|
+
const scoped = [];
|
|
450
|
+
const inRegion = [];
|
|
451
|
+
for (const r of selected) {
|
|
452
|
+
const declares = Array.isArray(r.scope) && r.scope.length > 0 && r.error === null;
|
|
453
|
+
if (!declares) {
|
|
454
|
+
inRegion.push(r);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// A profile only leaves the region when a narrowed file can replace it. When any
|
|
458
|
+
// selected surface cannot narrow, the file would be the only home for a rule that
|
|
459
|
+
// reader cannot reach — so it stays, and says why.
|
|
460
|
+
if (narrowing) {
|
|
461
|
+
scoped.push(r);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
inRegion.push(r);
|
|
465
|
+
notes.push(
|
|
466
|
+
`${r.id} declares simt-scope, but ${resolved.source} selects a surface that cannot narrow a rule, so it stays in the region`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The kernel. In `kernel` mode only the hard and command classes are packed into the
|
|
471
|
+
// region and every other rule is deferred to `rules show <id>`, which is what makes
|
|
472
|
+
// R08's accept criterion reachable at all: a budget cannot be met by a projection that
|
|
473
|
+
// never tries. `full` is the default and stays it until R02 has measured that a
|
|
474
|
+
// kernel does not lose compliance, because nobody has measured it yet.
|
|
475
|
+
//
|
|
476
|
+
// A rule with no stated class is deferred, not packed. The two mistakes are not
|
|
477
|
+
// symmetric: packing a line that should have been deferred spends the budget the mode
|
|
478
|
+
// exists to save, while deferring a line that should have been packed costs a reader
|
|
479
|
+
// one `rules show`. The mode is a promise about bytes, so it is the bytes that win.
|
|
480
|
+
const mode = await projectionMode(root);
|
|
481
|
+
const packed = [];
|
|
482
|
+
const deferred = [];
|
|
483
|
+
for (const r of inRegion) {
|
|
484
|
+
if (mode === 'kernel' && (r.error !== null || !KERNEL_CLASSES.has(r.priority))) deferred.push(r);
|
|
485
|
+
else packed.push(r);
|
|
486
|
+
}
|
|
487
|
+
// The command card leads the region. The exact commands for this repository are the
|
|
488
|
+
// bytes a reader cannot infer from rules that are identical everywhere (plan 0003 R10),
|
|
489
|
+
// so they go first — above even the core rules. It is derived from committed evidence
|
|
490
|
+
// only, in one place, so `plan` and `checkTarget` cannot disagree about it and two
|
|
491
|
+
// machines render it byte-identically.
|
|
492
|
+
const { card, proofs } = await inferableEvidence(root, { stack, facts, manifest });
|
|
493
|
+
if (card.dropped.length > 0) {
|
|
494
|
+
// Reported where every other projection note is reported. A card that lost a command
|
|
495
|
+
// in silence is a card a reader cannot tell is incomplete.
|
|
496
|
+
notes.push(
|
|
497
|
+
`command card: dropped ${card.dropped.join(', ')} to stay within ${CARD_LIMIT_BYTES} bytes`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
// Inferability (R09). A marked line leaves when the repository proves the evidence it
|
|
501
|
+
// names. `shipped` is a separate list from `packed` because they are separate decisions:
|
|
502
|
+
// the kernel is about which classes cost a reader nothing to skip, and this is about
|
|
503
|
+
// which lines the reader already knows.
|
|
504
|
+
const inferable = [];
|
|
505
|
+
const shipped = [];
|
|
506
|
+
for (const r of packed) {
|
|
507
|
+
const filtered = filterInferable(r.body, proofs);
|
|
508
|
+
if (!filtered.changed) {
|
|
509
|
+
shipped.push(r);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
|
|
513
|
+
shipped.push({ ...r, body: filtered.body });
|
|
514
|
+
}
|
|
515
|
+
let body = card.text ? `${card.text}\n\n${render(shipped)}` : render(shipped);
|
|
516
|
+
let sources = shipped.map((r) => r.source);
|
|
517
|
+
if (inferable.length > 0) {
|
|
518
|
+
// Never silent, and never a heading. A line that left with no trace repeats the
|
|
519
|
+
// mistake ADR-073 rejected for deferred rules, and a `## Dropped` section spends region
|
|
520
|
+
// bytes on lines the reader is being told not to read. A count they can act on, plus —
|
|
521
|
+
// with `--explain` — the evidence behind each one, is the third option.
|
|
522
|
+
notes.push(
|
|
523
|
+
`inferability: dropped ${inferable.length} line(s) this repository already proves; ${CLI} sync --explain names each one and the evidence behind it`,
|
|
524
|
+
);
|
|
525
|
+
if (flags?.explain === true) {
|
|
526
|
+
for (const x of inferable) {
|
|
527
|
+
notes.push(`inferable: ${x.rule} dropped "${x.text}" — proved by ${x.expr}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const folded = [];
|
|
532
|
+
|
|
533
|
+
// The pointer. A reader who opens AGENTS.md and does not find a profile they know is
|
|
534
|
+
// there will look for it, and the honest answer is where it moved: the file a scoping
|
|
535
|
+
// surface loads it from, and the files it now matches. Without the line the region's
|
|
536
|
+
// source list is quietly short one entry, which reads as a rule that was deleted.
|
|
537
|
+
if (scoped.length > 0 && narrowing) {
|
|
538
|
+
const lines = scoped.map(
|
|
539
|
+
(r) =>
|
|
540
|
+
`- ${sourceTitle(r, 'profile')} — loads only when a matching file is in play (${r.scope.join(', ')}): ${scopedPaths(r, resolved).join(', ')}`,
|
|
541
|
+
);
|
|
542
|
+
body += `\n\n## Scoped rules\n\n${lines.join('\n')}`;
|
|
543
|
+
for (const r of scoped) sources.push(r.source);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (deferred.length > 0) {
|
|
547
|
+
// The pointer is the same shape as the scoped one above, and for the same reason:
|
|
548
|
+
// a reader who opens AGENTS.md and does not find a rule they know is there will
|
|
549
|
+
// look for it. What it costs to find it has to be one command, named per rule.
|
|
550
|
+
const lines = [...deferred]
|
|
551
|
+
.sort(byPriorityThenId)
|
|
552
|
+
.map(
|
|
553
|
+
(r) =>
|
|
554
|
+
`- ${sourceTitle(r, 'profile')} — ${r.priority ?? 'unclassified'}, deferred in kernel mode. Show it with: ${CLI} rules show ${r.id}`,
|
|
555
|
+
);
|
|
556
|
+
body += `\n\n## Deferred rules\n\n${lines.join('\n')}`;
|
|
557
|
+
for (const r of deferred) sources.push(r.source);
|
|
558
|
+
}
|
|
559
|
+
|
|
389
560
|
if (foldsRules(resolved)) {
|
|
390
561
|
for (const { rule, body: ruleBody } of bodies) {
|
|
391
562
|
// `## Process:`, not `### id`. The body already uses `##`, and a third-level
|
|
@@ -407,6 +578,17 @@ export async function desiredRegion(root, flags) {
|
|
|
407
578
|
all,
|
|
408
579
|
profiles: selected.map((r) => r.id),
|
|
409
580
|
folded,
|
|
581
|
+
mode,
|
|
582
|
+
deferred: deferred.map((r) => r.id),
|
|
583
|
+
// The marked lines that left, each with the evidence that dropped it. The region is
|
|
584
|
+
// written without them; `--explain` is what reads this list, so a removal is reportable
|
|
585
|
+
// without spending region bytes and without a second computation to find it.
|
|
586
|
+
inferable,
|
|
587
|
+
// The profiles that left the region, and the surfaces that accepted them as files.
|
|
588
|
+
// `plan` writes them and `checkTarget` gates them through this list, so a projected
|
|
589
|
+
// file and a drifted one are decided by the same computation.
|
|
590
|
+
scoped,
|
|
591
|
+
narrowing,
|
|
410
592
|
surfaces: resolved.ids,
|
|
411
593
|
surfaceSource: resolved.source,
|
|
412
594
|
problems,
|
|
@@ -417,6 +599,27 @@ export async function desiredRegion(root, flags) {
|
|
|
417
599
|
};
|
|
418
600
|
}
|
|
419
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Every per-rule file a scoped profile is written to, as paths a reader can open.
|
|
604
|
+
*
|
|
605
|
+
* Only the surfaces that can scope get one: a Cursor `.mdc` with `globs` and
|
|
606
|
+
* `alwaysApply: false`, an Antigravity file with `trigger: glob`, a Copilot
|
|
607
|
+
* `.instructions.md` with `applyTo`. A surface that reads the region already (opencode)
|
|
608
|
+
* has no file channel at all, and Claude's scoping key is the documented-defect one,
|
|
609
|
+
* so neither appears here.
|
|
610
|
+
*/
|
|
611
|
+
function scopedPaths(rule, resolved) {
|
|
612
|
+
const out = [];
|
|
613
|
+
for (const s of resolved.surfaces) {
|
|
614
|
+
if (!canScope(s) || !s.rule) continue;
|
|
615
|
+
// A folded surface already carries the whole region; it is the reason this profile
|
|
616
|
+
// is only a file when folding is off, which is the caller's question to answer.
|
|
617
|
+
if (s.foldRules === true && s.readsPrimary === true && foldsRules(resolved)) continue;
|
|
618
|
+
out.push(rulePath(s.id, rule.id));
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
420
623
|
/**
|
|
421
624
|
* Read every rule file once, with its frontmatter parsed, plus any repo-local
|
|
422
625
|
* profile under `.simt-dev/profiles/`.
|
|
@@ -444,7 +647,42 @@ export async function loadRules(root = null) {
|
|
|
444
647
|
collected.push({ file, dir: RULES_DIR, origin: 'central', source: `rules/${file}` });
|
|
445
648
|
}
|
|
446
649
|
|
|
650
|
+
// Packs go in before the repo's own profiles and after the built-ins, because
|
|
651
|
+
// that is the order they lose to. The collection order is the tie-break `select`
|
|
652
|
+
// reads for two profiles of equal rank, so "collected central, then pack, then
|
|
653
|
+
// local" is the precedence stated once rather than restated in the comparator.
|
|
447
654
|
if (root !== null) {
|
|
655
|
+
const { packs } = await resolvePacks(root);
|
|
656
|
+
for (const pack of packs) {
|
|
657
|
+
// `present`, not `ok`. A pack whose bytes disagree with its pin still projects:
|
|
658
|
+
// dropping it would let the next `sync` delete the rules it carries, so the
|
|
659
|
+
// remedy the finding names would cause the damage it exists to report.
|
|
660
|
+
if (!pack.present) continue;
|
|
661
|
+
const packRules = join(pack.dir, 'rules');
|
|
662
|
+
let names = [];
|
|
663
|
+
try {
|
|
664
|
+
names = (await readdir(packRules)).filter((n) => n.endsWith('.md')).sort();
|
|
665
|
+
} catch {
|
|
666
|
+
names = [];
|
|
667
|
+
}
|
|
668
|
+
let stackNames = [];
|
|
669
|
+
try {
|
|
670
|
+
stackNames = (await readdir(join(packRules, 'stacks')))
|
|
671
|
+
.filter((n) => n.endsWith('.md'))
|
|
672
|
+
.map((n) => `stacks/${n}`);
|
|
673
|
+
} catch {
|
|
674
|
+
stackNames.length = 0;
|
|
675
|
+
}
|
|
676
|
+
for (const file of [...names, ...stackNames].sort()) {
|
|
677
|
+
collected.push({
|
|
678
|
+
file,
|
|
679
|
+
dir: packRules,
|
|
680
|
+
origin: 'pack',
|
|
681
|
+
source: `${PACK_DIR}/${pack.name}/rules/${file}`,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
448
686
|
const localDir = join(root, LOCAL_PROFILE_DIR);
|
|
449
687
|
let localNames = [];
|
|
450
688
|
try {
|
|
@@ -466,6 +704,7 @@ export async function loadRules(root = null) {
|
|
|
466
704
|
for (const item of collected) {
|
|
467
705
|
const text = await readFile(join(item.dir, item.file), 'utf8');
|
|
468
706
|
const meta = parseFrontmatter(text);
|
|
707
|
+
const body = stripFrontmatter(text);
|
|
469
708
|
out.push({
|
|
470
709
|
file: item.file,
|
|
471
710
|
origin: item.origin,
|
|
@@ -474,14 +713,81 @@ export async function loadRules(root = null) {
|
|
|
474
713
|
title: meta['simt-title'] ?? null,
|
|
475
714
|
applies: meta['simt-applies'] ?? [],
|
|
476
715
|
fallback: meta['simt-fallback'] === true,
|
|
716
|
+
scope: Array.isArray(meta['simt-scope']) ? meta['simt-scope'] : null,
|
|
717
|
+
priority: meta['simt-priority'] ?? null,
|
|
477
718
|
bytes: Buffer.byteLength(text),
|
|
478
|
-
body
|
|
479
|
-
|
|
719
|
+
body,
|
|
720
|
+
// A marker naming evidence the tool cannot check is a broken rule like any other, so
|
|
721
|
+
// it travels the field that already reaches `check` and `rules list`. The
|
|
722
|
+
// frontmatter defect wins, because a rule that cannot say what it applies to cannot
|
|
723
|
+
// be reasoned about at all.
|
|
724
|
+
error: describeDefect(meta) ?? inferableDefect(body),
|
|
480
725
|
});
|
|
481
726
|
}
|
|
482
727
|
return out;
|
|
483
728
|
}
|
|
484
729
|
|
|
730
|
+
/**
|
|
731
|
+
* The regions a monorepo's packages each need, as bodies ready to be written.
|
|
732
|
+
*
|
|
733
|
+
* A repository with packages in it is projected twice over: the root carries the
|
|
734
|
+
* rules every package shares, and each package carries the profiles IT selects and
|
|
735
|
+
* the root does not. A reader working in `apps/web` opens two files by walking up,
|
|
736
|
+
* and gets the union; a reader who only reads the root still gets everything that
|
|
737
|
+
* is not package-specific. Nothing is repeated, so nothing goes stale twice
|
|
738
|
+
* (ADR-066).
|
|
739
|
+
*
|
|
740
|
+
* A package whose delta is empty gets no file. Writing an empty region into every
|
|
741
|
+
* directory of a large monorepo would be noise the reader has to learn to skip,
|
|
742
|
+
* and a file with nothing in it is not a projection, it is an absence with a
|
|
743
|
+
* generated header on it.
|
|
744
|
+
*
|
|
745
|
+
* The rule set is the ROOT's - local profiles and packs are the repository's, and
|
|
746
|
+
* a package that re-read them from its own directory would resolve a different
|
|
747
|
+
* set in a directory that has no manifest.
|
|
748
|
+
*
|
|
749
|
+
* @returns {Promise<{regions: {path: string, ecosystem: string, stack: string[],
|
|
750
|
+
* facts: string[], profiles: string[], body: string, meta: {sha: string, sources: string[]}}[],
|
|
751
|
+
* notes: string[]}>}
|
|
752
|
+
*/
|
|
753
|
+
export async function packageRegions(root, flags, rootProfiles = null) {
|
|
754
|
+
const shared = rootProfiles ?? (await desiredRegion(root, flags)).profiles;
|
|
755
|
+
const { packages, notes } = await discoverPackages(root);
|
|
756
|
+
if (packages.length === 0) return { regions: [], notes };
|
|
757
|
+
|
|
758
|
+
const rules = await loadRules(root);
|
|
759
|
+
const sha = await provenanceDigest();
|
|
760
|
+
// The ROOT's proofs, not the package's. The rule set is the root's, so which lines it
|
|
761
|
+
// already proves is a fact about the repository rather than about the directory a file
|
|
762
|
+
// lands in — filtering a package by the package's own facts would keep a rule here and
|
|
763
|
+
// drop it one directory up, which is the same rule treated as two.
|
|
764
|
+
const { proofs } = await inferableEvidence(root, await fingerprint(root));
|
|
765
|
+
const regions = [];
|
|
766
|
+
for (const pkg of packages) {
|
|
767
|
+
const fp = await fingerprint(join(root, pkg.path));
|
|
768
|
+
const { selected } = select(rules, fp.stack ?? [], fp.facts ?? []);
|
|
769
|
+
const delta = selected.filter((r) => !shared.includes(r.id));
|
|
770
|
+
if (delta.length === 0) continue;
|
|
771
|
+
const shipped = delta.map((r) => {
|
|
772
|
+
const filtered = filterInferable(r.body, proofs);
|
|
773
|
+
return filtered.changed ? { ...r, body: filtered.body } : r;
|
|
774
|
+
});
|
|
775
|
+
regions.push({
|
|
776
|
+
path: pkg.path,
|
|
777
|
+
ecosystem: pkg.ecosystem,
|
|
778
|
+
stack: fp.stack ?? [],
|
|
779
|
+
facts: fp.facts ?? [],
|
|
780
|
+
profiles: delta.map((r) => r.id),
|
|
781
|
+
// One sentence, because the file's whole purpose is to be found by a reader
|
|
782
|
+
// who does not know it exists: without it, a nested region reads as a
|
|
783
|
+
// complete set of rules and the shared half looks forgotten.
|
|
784
|
+
body: `The rules this package selects on its own. Everything the repository shares with its other packages is in the region at the root.\n\n${render(shipped)}`,
|
|
785
|
+
meta: { sha, sources: delta.map((r) => r.source) },
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return { regions, notes };
|
|
789
|
+
}
|
|
790
|
+
|
|
485
791
|
/**
|
|
486
792
|
* Selection is pure set intersection: core always, then every profile whose
|
|
487
793
|
* `simt-applies` intersects the repo's signals. `node` is the fallback, so it is only
|
|
@@ -492,7 +798,7 @@ export async function loadRules(root = null) {
|
|
|
492
798
|
* fact is the more durable choice because it survives the framework being
|
|
493
799
|
* replaced.
|
|
494
800
|
*
|
|
495
|
-
* `shadowed` lists
|
|
801
|
+
* `shadowed` lists profiles that replaced one they outrank, so a caller can
|
|
496
802
|
* report it rather than silently changing what a repo receives.
|
|
497
803
|
*/
|
|
498
804
|
export function select(rules, stack, facts = []) {
|
|
@@ -506,9 +812,14 @@ export function select(rules, stack, facts = []) {
|
|
|
506
812
|
ids.set(rule.id, rule);
|
|
507
813
|
continue;
|
|
508
814
|
}
|
|
509
|
-
// A
|
|
510
|
-
//
|
|
511
|
-
|
|
815
|
+
// A profile may replace one it outranks; two of the same origin are a defect
|
|
816
|
+
// the caller must be able to see.
|
|
817
|
+
//
|
|
818
|
+
// Ranked rather than `=== 'local'` because there is now a third origin. The
|
|
819
|
+
// ordering it encodes - the repository's own profile, then a pack it declares,
|
|
820
|
+
// then what the tool ships - is the whole precedence rule, in one comparison
|
|
821
|
+
// that cannot drift from the order the files were collected in.
|
|
822
|
+
const localWins = ORIGIN_RANK[rule.origin] > ORIGIN_RANK[prior.origin];
|
|
512
823
|
if (localWins) {
|
|
513
824
|
ids.set(rule.id, rule);
|
|
514
825
|
shadowed.push({
|
|
@@ -580,6 +891,32 @@ export function render(selected) {
|
|
|
580
891
|
.join('\n\n');
|
|
581
892
|
}
|
|
582
893
|
|
|
894
|
+
/**
|
|
895
|
+
* The projection mode this repository declared, or `full`.
|
|
896
|
+
*
|
|
897
|
+
* Absent means `full`, and `full` is the default rather than a fallback: no measurement
|
|
898
|
+
* yet says a kernel does not lose compliance (plan 0003 R02), so the mode that changes
|
|
899
|
+
* what a reader gets cannot be the one nobody chose.
|
|
900
|
+
*/
|
|
901
|
+
async function projectionMode(root) {
|
|
902
|
+
const { manifest } = await readManifest(root);
|
|
903
|
+
return manifest?.mode === 'kernel' ? 'kernel' : 'full';
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Deterministic kernel order: the class a rule declares, then its id.
|
|
908
|
+
*
|
|
909
|
+
* The tie-break is not decoration. Two rules of the same class packed in file order
|
|
910
|
+
* would move when a file is added to the directory, and a projection that reorders
|
|
911
|
+
* itself on an unrelated commit is a projection nobody can diff (ADR-013).
|
|
912
|
+
*/
|
|
913
|
+
function byPriorityThenId(a, b) {
|
|
914
|
+
const classA = PRIORITY_CLASSES.indexOf(a.priority ?? 'style');
|
|
915
|
+
const classB = PRIORITY_CLASSES.indexOf(b.priority ?? 'style');
|
|
916
|
+
if (classA !== classB) return classA - classB;
|
|
917
|
+
return String(a.id).localeCompare(String(b.id));
|
|
918
|
+
}
|
|
919
|
+
|
|
583
920
|
/**
|
|
584
921
|
* One source, one `##` heading, then its body with every heading demoted.
|
|
585
922
|
*
|
|
@@ -634,6 +971,12 @@ function parseFrontmatter(text) {
|
|
|
634
971
|
.slice(1, raw.lastIndexOf(']'))
|
|
635
972
|
.split(',')
|
|
636
973
|
.map((s) => s.trim())
|
|
974
|
+
// A quoted item is a quoted item: the glob inside is the value, not the quotes
|
|
975
|
+
// around it. A profile author quoting `\"src/**/*.ts\"` because their editor
|
|
976
|
+
// colouring complains about a bare `*` would otherwise project a pattern the
|
|
977
|
+
// surface matches nothing against.
|
|
978
|
+
.map((s) => (s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s))
|
|
979
|
+
.map((s) => (s.length >= 2 && s.startsWith("'") && s.endsWith("'") ? s.slice(1, -1) : s))
|
|
637
980
|
.filter(Boolean);
|
|
638
981
|
} else out[key] = raw.trim();
|
|
639
982
|
}
|