simt-dev 0.6.2 → 0.7.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 +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +82 -21
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -18,11 +18,15 @@
|
|
|
18
18
|
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
19
19
|
import { existsSync } from 'node:fs';
|
|
20
20
|
import { basename, join, resolve } from 'node:path';
|
|
21
|
+
import { journaledWriteFile } from '../journal.mjs';
|
|
21
22
|
// gates.mjs imports nothing, so this direction cannot cycle. It is the gate table
|
|
22
23
|
// this file already selects from; duplicating the runnability rules here would be two
|
|
23
24
|
// answers to "can this repo run that gate", which is precisely the kind of drift this
|
|
24
25
|
// file exists to prevent elsewhere.
|
|
25
26
|
import { getGate } from '../hooks/gates.mjs';
|
|
27
|
+
// Q31: the one Node runner table. This module and the card, capability,
|
|
28
|
+
// dispatcher and CI workflow all spell `<manager> run <script>` from it.
|
|
29
|
+
import { runScript } from '../ecosystem/node-runner.mjs';
|
|
26
30
|
|
|
27
31
|
// Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
|
|
28
32
|
// and the manifest name is used a dozen times below in remedy strings that name this
|
|
@@ -200,7 +204,7 @@ export const ECOSYSTEMS = [
|
|
|
200
204
|
* fail closed — the defect is declaring a gate this repo has no way to run.
|
|
201
205
|
*
|
|
202
206
|
* The check mirrors what the dispatcher actually attempts:
|
|
203
|
-
* - `script` gates run
|
|
207
|
+
* - `script` gates run `<manager> run <script>`, so the script must exist
|
|
204
208
|
* - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
|
|
205
209
|
* installed, not merely declared as a dependency
|
|
206
210
|
* - `command` gates run whatever the manifest's `gates.commands` declares, so a
|
|
@@ -225,18 +229,27 @@ export function gateRunnable(gate, capability = {}) {
|
|
|
225
229
|
: { runnable: true, why: `runs: ${declared}` };
|
|
226
230
|
}
|
|
227
231
|
|
|
232
|
+
if (gate.kind === 'builtin') {
|
|
233
|
+
// The one kind with no external requirement: the implementation ships inside
|
|
234
|
+
// the binary that is reading this manifest (ADR-100). Saying otherwise would
|
|
235
|
+
// make `check` report a gate as unrunnable that runs on every commit.
|
|
236
|
+
return { runnable: true, why: 'runs inside the tool itself; nothing to install' };
|
|
237
|
+
}
|
|
228
238
|
if (gate.kind === 'command') {
|
|
229
239
|
return { runnable: false, why: `no command is declared for it in gates.commands` };
|
|
230
240
|
}
|
|
231
241
|
if (gate.kind === 'script') {
|
|
232
242
|
const script = gate.script ?? gate.name;
|
|
233
|
-
|
|
243
|
+
// Q31: the runnable sentence names the repo's own runner, never a bare npm
|
|
244
|
+
// assumption — the card, the dispatcher and this check read one resolver.
|
|
245
|
+
const spell = runScript(capability.runner ?? 'npm', script);
|
|
246
|
+
if (capability.scripts?.[script]) return { runnable: true, why: `runs: ${spell}` };
|
|
234
247
|
// Saying "no script in package.json" about a repo with no package.json states a
|
|
235
248
|
// fact about a file that does not exist and implies a fix that cannot be taken.
|
|
236
249
|
return {
|
|
237
250
|
runnable: false,
|
|
238
251
|
why: foreign
|
|
239
|
-
? `this repo runs ${runnerName(capability.runner)}, not npm, so "
|
|
252
|
+
? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
|
|
240
253
|
: `no "${script}" script in package.json`,
|
|
241
254
|
};
|
|
242
255
|
}
|
|
@@ -270,11 +283,17 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
|
270
283
|
const name = gate?.name ?? 'the gate';
|
|
271
284
|
const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
|
|
272
285
|
const foreign = isForeignRunner(runner);
|
|
286
|
+
// Q31: a missing tool in a Node repo names the repo's own installer —
|
|
287
|
+
// `pnpm add -D`, not `npm i -D` — and anything elsewhere keeps the honest
|
|
288
|
+
// undo: name the program under `gates.commands`.
|
|
289
|
+
const addDev = { npm: 'npm i -D', pnpm: 'pnpm add -D', yarn: 'yarn add -D', bun: 'bun add -d' }[runner] ?? null;
|
|
273
290
|
const fix =
|
|
274
291
|
npmShaped && foreign
|
|
275
292
|
? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
|
|
276
293
|
: gate?.kind === 'staged'
|
|
277
|
-
?
|
|
294
|
+
? addDev
|
|
295
|
+
? `install ${gate.tools.join(', ')} as a dev dependency: ${addDev} ${gate.tools.join(' ')} (the gate runs from the pinned dependency)`
|
|
296
|
+
: `install ${gate.tools.join(', ')} as a dev dependency`
|
|
278
297
|
: gate?.kind === 'script'
|
|
279
298
|
? `add a "${gate.script ?? name}" script to package.json`
|
|
280
299
|
: gate?.kind === 'command'
|
|
@@ -295,11 +314,14 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
|
295
314
|
/**
|
|
296
315
|
* Is this runner one where `npm run` is not how a gate is spelled?
|
|
297
316
|
*
|
|
298
|
-
* `undefined` means "the caller did not report a runner", and
|
|
299
|
-
*
|
|
317
|
+
* `undefined` means "the caller did not report a runner", and every Node manager
|
|
318
|
+
* (`npm`, `pnpm`, `yarn`, `bun`) spells gates as `<manager> run <script>` with
|
|
319
|
+
* scripts living in the same `package.json`. Only a positively identified
|
|
320
|
+
* non-Node runner changes the wording. (Q31: pnpm is Node-shaped, not foreign.)
|
|
300
321
|
*/
|
|
301
322
|
function isForeignRunner(runner) {
|
|
302
|
-
|
|
323
|
+
if (runner == null) return false;
|
|
324
|
+
return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
|
|
303
325
|
}
|
|
304
326
|
|
|
305
327
|
/** How a reader would name the runner in a sentence. */
|
|
@@ -309,6 +331,9 @@ function runnerName(runner) {
|
|
|
309
331
|
none: 'no recognised runner',
|
|
310
332
|
gradle: 'Gradle',
|
|
311
333
|
python: 'Python',
|
|
334
|
+
npm: 'npm',
|
|
335
|
+
pnpm: 'pnpm',
|
|
336
|
+
yarn: 'yarn',
|
|
312
337
|
bun: 'bun',
|
|
313
338
|
cargo: 'Cargo',
|
|
314
339
|
go: 'Go',
|
|
@@ -376,14 +401,15 @@ export function planGates(stack = [], capability = null, facts = []) {
|
|
|
376
401
|
*/
|
|
377
402
|
function ecosystemCommands(facts = [], capability = null) {
|
|
378
403
|
if (!capability?.tools?.length) return {};
|
|
379
|
-
//
|
|
380
|
-
// scripts, and a seed only wins over those scripts when
|
|
381
|
-
// command of its own — which is exactly the mixed
|
|
382
|
-
// where the script is the deliberate answer.
|
|
383
|
-
// a default; the reader's escape hatch
|
|
384
|
-
// seed would lose to anyway. Pure
|
|
385
|
-
// seed normally.
|
|
386
|
-
|
|
404
|
+
// A Node runner (npm/pnpm/yarn/bun) already answers "how do I test and format"
|
|
405
|
+
// with its own package.json scripts, and a seed only wins over those scripts when
|
|
406
|
+
// the repo has not declared a command of its own — which is exactly the mixed
|
|
407
|
+
// repos (package.json AND Cargo.toml) where the script is the deliberate answer.
|
|
408
|
+
// Seeding there would replace a choice with a default; the reader's escape hatch
|
|
409
|
+
// is declaring gates.commands by hand, which a seed would lose to anyway. Pure
|
|
410
|
+
// ecosystem repos have runner 'cargo'/'go'/... and seed normally.
|
|
411
|
+
// Q31: every Node manager skips seeding — pnpm is not "foreign", it is Node.
|
|
412
|
+
if (['npm', 'pnpm', 'yarn', 'bun'].includes(capability.runner)) return {};
|
|
387
413
|
const present = new Set(facts);
|
|
388
414
|
const commands = {};
|
|
389
415
|
for (const eco of ECOSYSTEMS) {
|
|
@@ -721,6 +747,23 @@ export function declaredShared(manifest) {
|
|
|
721
747
|
|
|
722
748
|
export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
723
749
|
|
|
750
|
+
// The sub-keys of `projection` this tool writes. `projection` is a generated key, but
|
|
751
|
+
// not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
|
|
752
|
+
// one that shipped), and a repair that replaces the object deletes them (review 0003 Q26).
|
|
753
|
+
const PROJECTION_KEYS = new Set(['simtDevSha', 'version']);
|
|
754
|
+
|
|
755
|
+
/** The `projection` object a manifest carries; anything that is not an object is nothing. */
|
|
756
|
+
function projectionOf(value) {
|
|
757
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Only the generated sub-keys of a `projection` object, in the order this writer emits them. */
|
|
761
|
+
function generatedProjection(value) {
|
|
762
|
+
const out = {};
|
|
763
|
+
for (const key of PROJECTION_KEYS) if (key in projectionOf(value)) out[key] = value[key];
|
|
764
|
+
return out;
|
|
765
|
+
}
|
|
766
|
+
|
|
724
767
|
/**
|
|
725
768
|
* A manifest with this tool's own fields replaced by what this build writes, and
|
|
726
769
|
* nothing else touched.
|
|
@@ -731,13 +774,22 @@ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
|
731
774
|
* repo chose is never compared" is a promise about the repair too, not only about
|
|
732
775
|
* the report, and a repair is exactly where it would be broken.
|
|
733
776
|
*
|
|
777
|
+
* `projection` merges the same way, one level down: the object the repository has
|
|
778
|
+
* keeps every sub-key it chose, and the generated sub-keys take this build's values.
|
|
779
|
+
*
|
|
734
780
|
* @param {object} manifest the repository's manifest, as parsed
|
|
735
781
|
* @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
|
|
736
782
|
* @returns {object} a new manifest; the argument is not mutated
|
|
737
783
|
*/
|
|
738
784
|
export function withGenerated(manifest, expected) {
|
|
739
785
|
const patched = { ...manifest };
|
|
740
|
-
for (const key of GENERATED_KEYS)
|
|
786
|
+
for (const key of GENERATED_KEYS) {
|
|
787
|
+
if (!(key in expected)) continue;
|
|
788
|
+
patched[key] =
|
|
789
|
+
key === 'projection'
|
|
790
|
+
? { ...projectionOf(manifest?.projection), ...expected.projection }
|
|
791
|
+
: expected[key];
|
|
792
|
+
}
|
|
741
793
|
return patched;
|
|
742
794
|
}
|
|
743
795
|
|
|
@@ -780,9 +832,14 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
780
832
|
// `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
|
|
781
833
|
// fields are ours, and the answer is the set above. Iterating the expected object
|
|
782
834
|
// asks the same question of a different list and can only agree by coincidence.
|
|
783
|
-
const stale = [...GENERATED_KEYS].filter(
|
|
784
|
-
(key
|
|
785
|
-
|
|
835
|
+
const stale = [...GENERATED_KEYS].filter((key) => {
|
|
836
|
+
if (!(key in expected)) return false;
|
|
837
|
+
// `projection` compares on the sub-keys this tool writes: a sub-key the repo
|
|
838
|
+
// chose (`place`) is not a stale field, and reporting it is what made `sync`
|
|
839
|
+
// delete the one setting that was not stale (review 0003 Q26).
|
|
840
|
+
const actual = key === 'projection' ? generatedProjection(manifest[key]) : manifest[key];
|
|
841
|
+
return JSON.stringify(actual) !== JSON.stringify(expected[key]);
|
|
842
|
+
});
|
|
786
843
|
if (stale.length > 0) {
|
|
787
844
|
return {
|
|
788
845
|
file: MANIFEST,
|
|
@@ -851,13 +908,17 @@ export async function readManifest(root) {
|
|
|
851
908
|
}
|
|
852
909
|
}
|
|
853
910
|
|
|
854
|
-
export async function writeManifest(root, manifest) {
|
|
911
|
+
export async function writeManifest(root, manifest, journal = null) {
|
|
855
912
|
// A repository still holding the old name gets it moved, not shadowed. Two manifests
|
|
856
913
|
// in one repo is a manifest nobody can reason about, and the second would be the one
|
|
857
914
|
// that is never read - so the window closes by moving the file, not by leaving both.
|
|
858
915
|
const previous = join(root, MANIFEST_LEGACY);
|
|
859
916
|
const fresh = join(root, MANIFEST);
|
|
860
917
|
if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
|
|
861
|
-
|
|
918
|
+
// A journaled manifest is one `undo` can restore, like every other repo file a
|
|
919
|
+
// command writes (R49). Callers that span repos or the repo boundary (shared,
|
|
920
|
+
// bench) pass nothing and keep the old behaviour exactly.
|
|
921
|
+
if (journal) await journaledWriteFile(root, journal, fresh, manifestText(manifest));
|
|
922
|
+
else await writeFile(fresh, manifestText(manifest));
|
|
862
923
|
return fresh;
|
|
863
924
|
}
|
|
@@ -165,6 +165,43 @@ export function parse(text, style) {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The line spans of every generated region in `text`, across both marker styles.
|
|
170
|
+
*
|
|
171
|
+
* `parse` is the single reader: a malformed region is still a region (an edit
|
|
172
|
+
* anywhere in the file stays denied), so malformed counts as a span covering the
|
|
173
|
+
* whole file rather than as absent. Line numbers are 1-based and inclusive.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{malformed: boolean, spans: Array<{start: number, end: number}>}}
|
|
176
|
+
*/
|
|
177
|
+
export function regionSpans(text) {
|
|
178
|
+
const spans = [];
|
|
179
|
+
let malformed = false;
|
|
180
|
+
for (const style of ['html', 'hash']) {
|
|
181
|
+
const state = parse(text, style);
|
|
182
|
+
if (state.status === 'malformed') {
|
|
183
|
+
malformed = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (state.status !== 'ok') continue;
|
|
187
|
+
const start = state.before === '' ? 1 : state.before.split('\n').length + 1;
|
|
188
|
+
const bodyLines = state.body === '' ? 0 : state.body.split('\n').length;
|
|
189
|
+
spans.push({ start, end: start + bodyLines + 1 });
|
|
190
|
+
}
|
|
191
|
+
if (malformed) {
|
|
192
|
+
const total = text.split('\n').length;
|
|
193
|
+
return { malformed: true, spans: [{ start: 1, end: total }] };
|
|
194
|
+
}
|
|
195
|
+
return { malformed: false, spans };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Line range of `text.slice(index, index + length)`, 1-based and inclusive. */
|
|
199
|
+
export function lineRangeOf(text, index, length) {
|
|
200
|
+
const start = text.slice(0, index).split('\n').length;
|
|
201
|
+
const end = text.slice(0, index + length).split('\n').length;
|
|
202
|
+
return { start, end };
|
|
203
|
+
}
|
|
204
|
+
|
|
168
205
|
function malformed(lines, from, at, reason) {
|
|
169
206
|
return {
|
|
170
207
|
status: 'malformed',
|
|
@@ -359,9 +396,12 @@ export function inject(text, body, meta, opts = {}) {
|
|
|
359
396
|
// too. A body that matches with a seam that does not is red on `format:check`, and
|
|
360
397
|
// reporting `unchanged` there means `check` finds no drift and `sync` never repairs
|
|
361
398
|
// it - the repository stays red with no remedy the tool offers. Top placement is
|
|
362
|
-
// excluded because there the splice is a repair that
|
|
363
|
-
// outside the markers, so the two files differ for a
|
|
364
|
-
|
|
399
|
+
// excluded from the seam test because there the splice is a repair that
|
|
400
|
+
// legitimately moves bytes outside the markers, so the two files differ for a
|
|
401
|
+
// reason unrelated to the seam. Position is the test there instead: a region below
|
|
402
|
+
// the file's own settings has been moved, and the move is what `where: 'top'`
|
|
403
|
+
// exists to repair (review 0003 Q26).
|
|
404
|
+
const settled = unchanged && (where === 'top' ? state.before.trim() === '' : spliced === text);
|
|
365
405
|
if (settled) return { ...seen, text, action: 'unchanged' };
|
|
366
406
|
|
|
367
407
|
return { ...seen, text: spliced, action: 'updated' };
|
package/src/projection/mdc.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
|
|
30
30
|
import { parse } from './markers.mjs';
|
|
31
|
+
import { MANIFEST_FILE } from '../name.mjs';
|
|
31
32
|
|
|
32
33
|
// ROOT is computed here rather than imported: project.mjs is the caller of every
|
|
33
34
|
// function below, so importing it back would create a cycle for the sake of one
|
|
@@ -148,9 +149,18 @@ function parseSubstitutions(text) {
|
|
|
148
149
|
const scope = next ? tail.slice(0, next.index + 1) : tail;
|
|
149
150
|
const def = /^\s*default:\s*(.*)$/m.exec(scope);
|
|
150
151
|
const desc = /^\s*describe:\s*(.*)$/m.exec(scope);
|
|
152
|
+
// A per-signal default: `default-<signal>:` picks the value when the repository
|
|
153
|
+
// carries that signal (a stack tag or a detection fact). `default:` is the
|
|
154
|
+
// fallback. The two cannot collide: `default:` requires the colon immediately
|
|
155
|
+
// after the word, and a signal line has a `-` there.
|
|
156
|
+
const perSignal = {};
|
|
157
|
+
for (const pm of scope.matchAll(/^\s*default-([a-z0-9][a-z0-9-]*):\s*(.*)$/gm)) {
|
|
158
|
+
perSignal[pm[1]] = unquote(pm[2].trim());
|
|
159
|
+
}
|
|
151
160
|
out.push({
|
|
152
161
|
key: hit[1],
|
|
153
162
|
default: def ? unquote(def[1].trim()) : '',
|
|
163
|
+
perSignal,
|
|
154
164
|
describe: desc ? unquote(desc[1].trim()) : '',
|
|
155
165
|
});
|
|
156
166
|
}
|
|
@@ -197,6 +207,19 @@ function stripFrontmatter(text) {
|
|
|
197
207
|
.trim();
|
|
198
208
|
}
|
|
199
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The default a signal set selects: the first per-signal default whose signal is
|
|
212
|
+
* present, else the plain default. Signals carry detection facts as well as stack
|
|
213
|
+
* tags, because a Go repository is selected on a fact, not a stack (review 0003 Q32).
|
|
214
|
+
*/
|
|
215
|
+
function pickDefault(sub, signals) {
|
|
216
|
+
const perSignal = sub.perSignal ?? {};
|
|
217
|
+
for (const s of signals) {
|
|
218
|
+
if (Object.prototype.hasOwnProperty.call(perSignal, s)) return perSignal[s];
|
|
219
|
+
}
|
|
220
|
+
return sub.default;
|
|
221
|
+
}
|
|
222
|
+
|
|
200
223
|
/**
|
|
201
224
|
* Substitute declared placeholders.
|
|
202
225
|
*
|
|
@@ -209,9 +232,11 @@ function stripFrontmatter(text) {
|
|
|
209
232
|
* rendered rule would ship a literal `{{key}}`, which reads as a finished rule
|
|
210
233
|
* and is not one.
|
|
211
234
|
*/
|
|
212
|
-
export function renderMdc(rule, declared = {}) {
|
|
235
|
+
export function renderMdc(rule, declared = {}, signals = []) {
|
|
213
236
|
const problems = [];
|
|
214
|
-
|
|
237
|
+
// The rule text names the manifest with a token and the render substitutes it,
|
|
238
|
+
// so a rename of the file is a change in one place (MANIFEST_FILE).
|
|
239
|
+
let body = rule.body.replaceAll('{manifest}', MANIFEST_FILE);
|
|
215
240
|
|
|
216
241
|
for (const sub of rule.substitutions) {
|
|
217
242
|
const has = Object.prototype.hasOwnProperty.call(declared, sub.key);
|
|
@@ -226,17 +251,19 @@ export function renderMdc(rule, declared = {}) {
|
|
|
226
251
|
}
|
|
227
252
|
|
|
228
253
|
if (provided === undefined || provided === null || provided === '') {
|
|
229
|
-
// Fall back to the default. An empty
|
|
230
|
-
// which is how the same file serves a repo that
|
|
231
|
-
|
|
254
|
+
// Fall back to the default the signals select. An empty chosen default is
|
|
255
|
+
// also a drop instruction, which is how the same file serves a repo that
|
|
256
|
+
// has nothing to add.
|
|
257
|
+
const chosen = pickDefault(sub, signals);
|
|
258
|
+
if (chosen === '') {
|
|
232
259
|
body = dropOrReplace(body, sub.key, '');
|
|
233
260
|
continue;
|
|
234
261
|
}
|
|
235
|
-
if (
|
|
262
|
+
if (chosen === undefined) {
|
|
236
263
|
problems.push(`substitution ${sub.key} has no default and none declared`);
|
|
237
264
|
continue;
|
|
238
265
|
}
|
|
239
|
-
body = dropOrReplace(body, sub.key,
|
|
266
|
+
body = dropOrReplace(body, sub.key, chosen);
|
|
240
267
|
continue;
|
|
241
268
|
}
|
|
242
269
|
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// same matcher, so supporting an ecosystem nobody here has heard of requires no
|
|
17
17
|
// code change and no release.
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { constants as fsConstants } from 'node:fs';
|
|
20
|
+
import { access, readFile, readdir, stat } from 'node:fs/promises';
|
|
20
21
|
import { join } from 'node:path';
|
|
21
22
|
|
|
22
23
|
import { baselineTargets, misplaced } from './baselines.mjs';
|
|
@@ -31,8 +32,75 @@ import { measureRoot } from '../measure/context.mjs';
|
|
|
31
32
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
32
33
|
import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
|
|
33
34
|
import { pointerState, pointerTargets, ruleTargets, scopedTargets } from './targets.mjs';
|
|
34
|
-
import { CLI } from '../name.mjs';
|
|
35
|
-
import { MANIFEST_FILE } from '../name.mjs';
|
|
35
|
+
import { CLI, LOCK_FILE, LOCK_LEGACY, PACKAGE_NAME } from '../name.mjs';
|
|
36
|
+
import { MANIFEST_FILE, WORKFLOW_FILE_NAME } from '../name.mjs';
|
|
37
|
+
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
38
|
+
import { LOCAL_BIN } from '../distribute/release.mjs';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A checked-in gate workflow the locator cannot resolve (review 0003 Q31).
|
|
42
|
+
*
|
|
43
|
+
* Returns a finding, or null when some resolution path can still succeed. Only
|
|
44
|
+
* committed evidence counts: the pinned devDependency, the release binary, and
|
|
45
|
+
* `simt` on this machine's PATH (a global install CI may share — its presence
|
|
46
|
+
* here is a reason to stay silent, its absence is not proof CI lacks it, so the
|
|
47
|
+
* finding also requires the first two to be missing).
|
|
48
|
+
*/
|
|
49
|
+
async function workflowResolvable(root) {
|
|
50
|
+
let pkg = null;
|
|
51
|
+
try {
|
|
52
|
+
pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
pkg = null;
|
|
55
|
+
}
|
|
56
|
+
if (pkg !== null) {
|
|
57
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
58
|
+
if (Object.hasOwn(deps, PACKAGE_NAME)) return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await stat(join(root, LOCAL_BIN));
|
|
62
|
+
return null;
|
|
63
|
+
} catch {
|
|
64
|
+
// No release binary: keep checking the last path.
|
|
65
|
+
}
|
|
66
|
+
for (const dir of (process.env.PATH ?? '').split(':').filter(Boolean)) {
|
|
67
|
+
try {
|
|
68
|
+
await access(join(dir, 'simt'), fsConstants.X_OK);
|
|
69
|
+
return null;
|
|
70
|
+
} catch {
|
|
71
|
+
// Not here; keep looking.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const file = `.github/workflows/${WORKFLOW_FILE_NAME}`;
|
|
75
|
+
if (pkg !== null) {
|
|
76
|
+
const lockfiles = [];
|
|
77
|
+
for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
78
|
+
try {
|
|
79
|
+
await stat(join(root, name));
|
|
80
|
+
lockfiles.push(name);
|
|
81
|
+
} catch {
|
|
82
|
+
// Absent is the normal case.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const runner = resolveNodeRunner({
|
|
86
|
+
lockfiles,
|
|
87
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
file,
|
|
91
|
+
status: 'unresolvable',
|
|
92
|
+
detail: `the checked-in gate workflow looks for ${PACKAGE_NAME} in node_modules, but nothing it tries can succeed: no pinned devDependency, no ${LOCAL_BIN}, no simt on PATH`,
|
|
93
|
+
remedy: `run: ${runner.addDev} ${PACKAGE_NAME} (the gate runs from the pinned dependency)`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
file,
|
|
98
|
+
status: 'unresolvable',
|
|
99
|
+
detail: `the checked-in gate workflow cannot resolve ${PACKAGE_NAME}: no package.json to pin it in, no ${LOCAL_BIN}, no simt on PATH`,
|
|
100
|
+
remedy: `install the pinned gate binary into the repository, then re-run: simt init --yes`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
import { computeLock } from './lock.mjs';
|
|
36
104
|
import {
|
|
37
105
|
desiredRegion,
|
|
38
106
|
provenanceDigest,
|
|
@@ -53,6 +121,16 @@ import {
|
|
|
53
121
|
* most agents, including ones that ignore .cursor/ entirely. */
|
|
54
122
|
export const PRIMARY_TARGET = 'AGENTS.md';
|
|
55
123
|
|
|
124
|
+
/** The gate's verdict on a region `sync` has already written. `current` rows carry
|
|
125
|
+
* `detail: null`, not `undefined`: a key whose value is `undefined` survives into the
|
|
126
|
+
* structured MCP surface but is dropped by `JSON.stringify`, so the two surfaces of one
|
|
127
|
+
* answer no longer deep-equal. */
|
|
128
|
+
function regionVerdict(action) {
|
|
129
|
+
return action === 'unchanged'
|
|
130
|
+
? { status: 'current', detail: null }
|
|
131
|
+
: { status: 'drift', detail: `region is ${action}; run: ${CLI} sync --yes` };
|
|
132
|
+
}
|
|
133
|
+
|
|
56
134
|
|
|
57
135
|
/**
|
|
58
136
|
* @param {string} root repo root
|
|
@@ -62,7 +140,7 @@ export const PRIMARY_TARGET = 'AGENTS.md';
|
|
|
62
140
|
export async function plan(root, flags = {}) {
|
|
63
141
|
const desired = await desiredRegion(root, flags);
|
|
64
142
|
const { stack, facts, profiles, fallback, notes, body, meta, all, manifest } = desired;
|
|
65
|
-
const target = await targetFor(root, body, meta);
|
|
143
|
+
const target = await targetFor(root, root, body, meta);
|
|
66
144
|
|
|
67
145
|
// Per-rule content projects to every editor the repo selected, not only Cursor. The
|
|
68
146
|
// body is identical on each; only the frontmatter the editor reads before any rule
|
|
@@ -94,9 +172,39 @@ export async function plan(root, flags = {}) {
|
|
|
94
172
|
// root's would read as though the packages were the repository.
|
|
95
173
|
const packages = await packageRegions(root, flags, profiles);
|
|
96
174
|
const nested = await Promise.all(
|
|
97
|
-
packages.regions.map((r) => targetFor(join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
|
|
175
|
+
packages.regions.map((r) => targetFor(root, join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
|
|
98
176
|
);
|
|
99
177
|
|
|
178
|
+
// The lock records the two digests a frozen check compares (ADR-086). It is a target
|
|
179
|
+
// like every other: `plan` produces it, so preview, `--diff`, the journal, the idle
|
|
180
|
+
// test and `init` all inherit it. Computing it here rather than in `sync` is what makes
|
|
181
|
+
// a deleted lock show up in the plan as a write instead of vanishing from it — the
|
|
182
|
+
// `sync` that printed "wrote simt-dev.lock.json" and then "nothing written" (Q29).
|
|
183
|
+
const lock = await computeLock(root, flags);
|
|
184
|
+
const lockText = `${JSON.stringify(lock, null, 2)}\n`;
|
|
185
|
+
let previousLockText = '';
|
|
186
|
+
let lockExists = false;
|
|
187
|
+
for (const name of [LOCK_FILE, LOCK_LEGACY]) {
|
|
188
|
+
try {
|
|
189
|
+
previousLockText = await readFile(join(root, name), 'utf8');
|
|
190
|
+
lockExists = true;
|
|
191
|
+
break;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code !== 'ENOENT') throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const lockTarget = {
|
|
197
|
+
file: LOCK_FILE,
|
|
198
|
+
path: join(root, LOCK_FILE),
|
|
199
|
+
kind: 'lock',
|
|
200
|
+
surface: null,
|
|
201
|
+
exists: lockExists,
|
|
202
|
+
action: !lockExists ? 'create' : previousLockText === lockText ? 'unchanged' : 'update',
|
|
203
|
+
bytes: Buffer.byteLength(lockText),
|
|
204
|
+
previousBytes: Buffer.byteLength(previousLockText),
|
|
205
|
+
text: lockText,
|
|
206
|
+
};
|
|
207
|
+
|
|
100
208
|
return {
|
|
101
209
|
root,
|
|
102
210
|
stack,
|
|
@@ -107,15 +215,25 @@ export async function plan(root, flags = {}) {
|
|
|
107
215
|
surfaceSource: surfaces.source,
|
|
108
216
|
surfaceProblems: surfaces.problems,
|
|
109
217
|
rules: all,
|
|
110
|
-
targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets],
|
|
218
|
+
targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets, lockTarget],
|
|
111
219
|
packages: packages.regions.map((r) => ({ path: r.path, profiles: r.profiles })),
|
|
112
220
|
mdcProblems: mdc.problems,
|
|
221
|
+
crossRefs: desired.crossRefs,
|
|
113
222
|
notes: [...notes, ...mdc.notes, ...packages.notes],
|
|
114
223
|
};
|
|
115
224
|
}
|
|
116
225
|
|
|
117
|
-
|
|
118
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Build the write target for one AGENTS.md. `root` is the repository: `regionOpts`
|
|
228
|
+
* reads its manifest, because placement is a repository-level decision that holds for
|
|
229
|
+
* every package's region too. `dir` is where the file lives, which differs from
|
|
230
|
+
* `root` only for a nested monorepo package (review 0003 Q26).
|
|
231
|
+
*
|
|
232
|
+
* @param {string} root repo root
|
|
233
|
+
* @param {string} dir directory the target file lives in
|
|
234
|
+
*/
|
|
235
|
+
async function targetFor(root, dir, body, meta, label = PRIMARY_TARGET) {
|
|
236
|
+
const path = join(dir, PRIMARY_TARGET);
|
|
119
237
|
let existing = '';
|
|
120
238
|
let exists = true;
|
|
121
239
|
try {
|
|
@@ -186,8 +304,24 @@ export async function checkTargets(root, flags = {}) {
|
|
|
186
304
|
// Two `.github` files, one ownership rule, so one loop over both. The gate and the
|
|
187
305
|
// dependency-updates config fail for the same reason and are fixed the same way,
|
|
188
306
|
// and listing them separately is how the second copy of this rule gets written.
|
|
307
|
+
const workflowPlan = await planWorkflow(root);
|
|
308
|
+
// Q31: a workflow that exists but cannot resolve simt-dev by any path the
|
|
309
|
+
// locator tries is a green tick on a gate that never runs. The locator tries
|
|
310
|
+
// `$SIMT_DEV_BIN`, the pinned devDependency, the release binary and `simt`
|
|
311
|
+
// on PATH, in that order; only the middle two are committed evidence this
|
|
312
|
+
// check can see. When the repo pins nothing and carries no binary, the check
|
|
313
|
+
// can still see `simt` on its own PATH — a global install CI may share — so
|
|
314
|
+
// it stays silent then rather than crying wolf about a resolution it cannot
|
|
315
|
+
// disprove. The remedy names the repo's own manager, never a bare npm guess.
|
|
316
|
+
// simt-dev's own copy is exempt: it runs this checkout directly and never asks the
|
|
317
|
+
// locator to find a binary, so a machine without `simt` on PATH is not evidence that
|
|
318
|
+
// the gate cannot start. The consumer copy is not exempt, because its locator does.
|
|
319
|
+
if (!workflowPlan.own && workflowPlan.action !== 'create' && workflowPlan.action !== 'skip') {
|
|
320
|
+
const unresolvable = await workflowResolvable(root);
|
|
321
|
+
if (unresolvable) findings.push(unresolvable);
|
|
322
|
+
}
|
|
189
323
|
for (const [plan, what] of [
|
|
190
|
-
[
|
|
324
|
+
[workflowPlan, 'nothing gates it'],
|
|
191
325
|
[await planDependabot(root), 'nothing watches its dependencies'],
|
|
192
326
|
]) {
|
|
193
327
|
if (plan.action === 'create') {
|
|
@@ -258,7 +392,20 @@ export async function checkTargets(root, flags = {}) {
|
|
|
258
392
|
for (const p of resolved.problems) {
|
|
259
393
|
findings.push({ file: `${MANIFEST_FILE}`, status: 'invalid', detail: p, remedy: `known surfaces: ${SURFACE_IDS.join(', ')}` });
|
|
260
394
|
}
|
|
261
|
-
const
|
|
395
|
+
const signals = new Set([...stack, ...facts]);
|
|
396
|
+
const rules = all.filter((r) => isEligible(r, signals, declared.optIn));
|
|
397
|
+
|
|
398
|
+
// A dangling cross-reference is a rule-text defect: the region renders, but it points
|
|
399
|
+
// the reader at a profile they cannot reach. A gate failure, like any other rule defect.
|
|
400
|
+
for (const p of desired.crossRefs ?? []) {
|
|
401
|
+
const idx = p.indexOf(': ');
|
|
402
|
+
findings.push({
|
|
403
|
+
file: p.slice(0, idx),
|
|
404
|
+
status: 'invalid',
|
|
405
|
+
detail: p.slice(idx + 2),
|
|
406
|
+
remedy: `co-select the profile, or name it with ${CLI} rules show <id>`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
262
409
|
|
|
263
410
|
const sha = await provenanceDigest();
|
|
264
411
|
const notes = [];
|
|
@@ -286,8 +433,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
286
433
|
findings.push({
|
|
287
434
|
file: target.file,
|
|
288
435
|
surface: target.surface,
|
|
289
|
-
|
|
290
|
-
detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
|
|
436
|
+
...regionVerdict(action),
|
|
291
437
|
recordedSha: parse(target.previous).sha ?? null,
|
|
292
438
|
});
|
|
293
439
|
}
|
|
@@ -308,6 +454,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
308
454
|
const { body, problems } = renderMdc(
|
|
309
455
|
rule,
|
|
310
456
|
ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
|
|
457
|
+
signals,
|
|
311
458
|
);
|
|
312
459
|
if (problems.length > 0) {
|
|
313
460
|
findings.push({
|
|
@@ -359,8 +506,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
359
506
|
findings.push({
|
|
360
507
|
file: rel,
|
|
361
508
|
surface: target.surface,
|
|
362
|
-
|
|
363
|
-
detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
|
|
509
|
+
...regionVerdict(action),
|
|
364
510
|
recordedSha: state.sha ?? null,
|
|
365
511
|
});
|
|
366
512
|
}
|
|
@@ -395,8 +541,7 @@ export async function checkTargets(root, flags = {}) {
|
|
|
395
541
|
}
|
|
396
542
|
findings.push({
|
|
397
543
|
file: t.file,
|
|
398
|
-
|
|
399
|
-
detail: t.action === 'unchanged' ? undefined : `region is ${t.action}; run: ${CLI} sync --yes`,
|
|
544
|
+
...regionVerdict(t.action),
|
|
400
545
|
recordedSha: state.sha ?? null,
|
|
401
546
|
});
|
|
402
547
|
const off = misplaced(state, t.style);
|
|
@@ -566,8 +711,7 @@ async function checkRegion(root, dir, body, meta) {
|
|
|
566
711
|
const d = diff(existing, body, meta, await regionOpts(root));
|
|
567
712
|
return {
|
|
568
713
|
file: PRIMARY_TARGET,
|
|
569
|
-
|
|
570
|
-
detail: d.action === 'unchanged' ? undefined : `region is ${d.action}; run: ${CLI} sync --yes`,
|
|
714
|
+
...regionVerdict(d.action),
|
|
571
715
|
recordedSha: state.sha ?? null,
|
|
572
716
|
expectedSha: meta.sha,
|
|
573
717
|
};
|