simt-dev 0.6.3 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.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-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- 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/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- 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 +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- 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 +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- 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 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- 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 +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- 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 +31 -14
- package/src/projection/manifest.mjs +89 -53
- 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 +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
|
@@ -24,36 +24,25 @@ import { journaledWriteFile } from '../journal.mjs';
|
|
|
24
24
|
// answers to "can this repo run that gate", which is precisely the kind of drift this
|
|
25
25
|
// file exists to prevent elsewhere.
|
|
26
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';
|
|
27
30
|
|
|
28
31
|
// Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
|
|
29
32
|
// and the manifest name is used a dozen times below in remedy strings that name this
|
|
30
33
|
// file by name.
|
|
31
34
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
35
|
+
import { GENERATED_KEYS_OF, KEYS, loadManifest, manifestPath } from '../manifest/schema.mjs';
|
|
32
36
|
import { CLI } from '../name.mjs';
|
|
33
37
|
import { packageVersion } from '../package-version.mjs';
|
|
34
38
|
export const MANIFEST = MANIFEST_FILE;
|
|
35
39
|
|
|
40
|
+
// Still exported from here because this is where the manifest lived: every caller that
|
|
41
|
+
// resolved its path through this module keeps working, and the implementation moved.
|
|
42
|
+
export { manifestPath };
|
|
43
|
+
|
|
36
44
|
export { MANIFEST_LEGACY };
|
|
37
45
|
|
|
38
|
-
/**
|
|
39
|
-
* The manifest a repository has, which is not necessarily the one this tool would write.
|
|
40
|
-
*
|
|
41
|
-
* The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
|
|
42
|
-
* `join(root, MANIFEST)` and would have reported an adopted repository as unadopted the
|
|
43
|
-
* day the constant moved - and "no manifest" means "never adopted", which is the answer
|
|
44
|
-
* that offers to re-adopt a repo that is already done. Fourteen repositories carry the
|
|
45
|
-
* old name on disk.
|
|
46
|
-
*
|
|
47
|
-
* So the old name is read as a fallback and the new one is written. `writeManifest`
|
|
48
|
-
* renames rather than creating a second file, which is how the window closes: the first
|
|
49
|
-
* `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
|
|
50
|
-
*/
|
|
51
|
-
export async function manifestPath(root) {
|
|
52
|
-
const next = join(root, MANIFEST);
|
|
53
|
-
if (existsSync(next)) return next;
|
|
54
|
-
const previous = join(root, MANIFEST_LEGACY);
|
|
55
|
-
return existsSync(previous) ? previous : next;
|
|
56
|
-
}
|
|
57
46
|
|
|
58
47
|
|
|
59
48
|
/**
|
|
@@ -201,7 +190,7 @@ export const ECOSYSTEMS = [
|
|
|
201
190
|
* fail closed — the defect is declaring a gate this repo has no way to run.
|
|
202
191
|
*
|
|
203
192
|
* The check mirrors what the dispatcher actually attempts:
|
|
204
|
-
* - `script` gates run
|
|
193
|
+
* - `script` gates run `<manager> run <script>`, so the script must exist
|
|
205
194
|
* - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
|
|
206
195
|
* installed, not merely declared as a dependency
|
|
207
196
|
* - `command` gates run whatever the manifest's `gates.commands` declares, so a
|
|
@@ -237,13 +226,16 @@ export function gateRunnable(gate, capability = {}) {
|
|
|
237
226
|
}
|
|
238
227
|
if (gate.kind === 'script') {
|
|
239
228
|
const script = gate.script ?? gate.name;
|
|
240
|
-
|
|
229
|
+
// Q31: the runnable sentence names the repo's own runner, never a bare npm
|
|
230
|
+
// assumption — the card, the dispatcher and this check read one resolver.
|
|
231
|
+
const spell = runScript(capability.runner ?? 'npm', script);
|
|
232
|
+
if (capability.scripts?.[script]) return { runnable: true, why: `runs: ${spell}` };
|
|
241
233
|
// Saying "no script in package.json" about a repo with no package.json states a
|
|
242
234
|
// fact about a file that does not exist and implies a fix that cannot be taken.
|
|
243
235
|
return {
|
|
244
236
|
runnable: false,
|
|
245
237
|
why: foreign
|
|
246
|
-
? `this repo runs ${runnerName(capability.runner)}, not npm, so "
|
|
238
|
+
? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
|
|
247
239
|
: `no "${script}" script in package.json`,
|
|
248
240
|
};
|
|
249
241
|
}
|
|
@@ -277,11 +269,17 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
|
277
269
|
const name = gate?.name ?? 'the gate';
|
|
278
270
|
const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
|
|
279
271
|
const foreign = isForeignRunner(runner);
|
|
272
|
+
// Q31: a missing tool in a Node repo names the repo's own installer —
|
|
273
|
+
// `pnpm add -D`, not `npm i -D` — and anything elsewhere keeps the honest
|
|
274
|
+
// undo: name the program under `gates.commands`.
|
|
275
|
+
const addDev = { npm: 'npm i -D', pnpm: 'pnpm add -D', yarn: 'yarn add -D', bun: 'bun add -d' }[runner] ?? null;
|
|
280
276
|
const fix =
|
|
281
277
|
npmShaped && foreign
|
|
282
278
|
? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
|
|
283
279
|
: gate?.kind === 'staged'
|
|
284
|
-
?
|
|
280
|
+
? addDev
|
|
281
|
+
? `install ${gate.tools.join(', ')} as a dev dependency: ${addDev} ${gate.tools.join(' ')} (the gate runs from the pinned dependency)`
|
|
282
|
+
: `install ${gate.tools.join(', ')} as a dev dependency`
|
|
285
283
|
: gate?.kind === 'script'
|
|
286
284
|
? `add a "${gate.script ?? name}" script to package.json`
|
|
287
285
|
: gate?.kind === 'command'
|
|
@@ -302,11 +300,14 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
|
302
300
|
/**
|
|
303
301
|
* Is this runner one where `npm run` is not how a gate is spelled?
|
|
304
302
|
*
|
|
305
|
-
* `undefined` means "the caller did not report a runner", and
|
|
306
|
-
*
|
|
303
|
+
* `undefined` means "the caller did not report a runner", and every Node manager
|
|
304
|
+
* (`npm`, `pnpm`, `yarn`, `bun`) spells gates as `<manager> run <script>` with
|
|
305
|
+
* scripts living in the same `package.json`. Only a positively identified
|
|
306
|
+
* non-Node runner changes the wording. (Q31: pnpm is Node-shaped, not foreign.)
|
|
307
307
|
*/
|
|
308
308
|
function isForeignRunner(runner) {
|
|
309
|
-
|
|
309
|
+
if (runner == null) return false;
|
|
310
|
+
return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
|
|
310
311
|
}
|
|
311
312
|
|
|
312
313
|
/** How a reader would name the runner in a sentence. */
|
|
@@ -316,6 +317,9 @@ function runnerName(runner) {
|
|
|
316
317
|
none: 'no recognised runner',
|
|
317
318
|
gradle: 'Gradle',
|
|
318
319
|
python: 'Python',
|
|
320
|
+
npm: 'npm',
|
|
321
|
+
pnpm: 'pnpm',
|
|
322
|
+
yarn: 'yarn',
|
|
319
323
|
bun: 'bun',
|
|
320
324
|
cargo: 'Cargo',
|
|
321
325
|
go: 'Go',
|
|
@@ -383,14 +387,15 @@ export function planGates(stack = [], capability = null, facts = []) {
|
|
|
383
387
|
*/
|
|
384
388
|
function ecosystemCommands(facts = [], capability = null) {
|
|
385
389
|
if (!capability?.tools?.length) return {};
|
|
386
|
-
//
|
|
387
|
-
// scripts, and a seed only wins over those scripts when
|
|
388
|
-
// command of its own — which is exactly the mixed
|
|
389
|
-
// where the script is the deliberate answer.
|
|
390
|
-
// a default; the reader's escape hatch
|
|
391
|
-
// seed would lose to anyway. Pure
|
|
392
|
-
// seed normally.
|
|
393
|
-
|
|
390
|
+
// A Node runner (npm/pnpm/yarn/bun) already answers "how do I test and format"
|
|
391
|
+
// with its own package.json scripts, and a seed only wins over those scripts when
|
|
392
|
+
// the repo has not declared a command of its own — which is exactly the mixed
|
|
393
|
+
// repos (package.json AND Cargo.toml) where the script is the deliberate answer.
|
|
394
|
+
// Seeding there would replace a choice with a default; the reader's escape hatch
|
|
395
|
+
// is declaring gates.commands by hand, which a seed would lose to anyway. Pure
|
|
396
|
+
// ecosystem repos have runner 'cargo'/'go'/... and seed normally.
|
|
397
|
+
// Q31: every Node manager skips seeding — pnpm is not "foreign", it is Node.
|
|
398
|
+
if (['npm', 'pnpm', 'yarn', 'bun'].includes(capability.runner)) return {};
|
|
394
399
|
const present = new Set(facts);
|
|
395
400
|
const commands = {};
|
|
396
401
|
for (const eco of ECOSYSTEMS) {
|
|
@@ -414,7 +419,7 @@ function ecosystemCommands(facts = [], capability = null) {
|
|
|
414
419
|
*/
|
|
415
420
|
export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
|
|
416
421
|
const gates = defaultGates(stack, facts);
|
|
417
|
-
const limits = defaultLimits(
|
|
422
|
+
const limits = defaultLimits();
|
|
418
423
|
|
|
419
424
|
// A re-init must never silently weaken a repo's own declaration: existing
|
|
420
425
|
// limits and gates win over defaults, and only the projection stamp is refreshed.
|
|
@@ -726,7 +731,27 @@ export function declaredShared(manifest) {
|
|
|
726
731
|
return { level, rules };
|
|
727
732
|
}
|
|
728
733
|
|
|
729
|
-
|
|
734
|
+
// Derived from the table rather than restated: a key this tool writes is a `tool` key
|
|
735
|
+
// marked generated, and a second list here is the drift this module exists to end.
|
|
736
|
+
export const GENERATED_KEYS = GENERATED_KEYS_OF;
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
// The sub-keys of `projection` this tool writes. `projection` is a generated key, but
|
|
740
|
+
// not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
|
|
741
|
+
// one that shipped), and a repair that replaces the object deletes them (review 0003 Q26).
|
|
742
|
+
const PROJECTION_KEYS = new Set(['simtDevSha', 'version']);
|
|
743
|
+
|
|
744
|
+
/** The `projection` object a manifest carries; anything that is not an object is nothing. */
|
|
745
|
+
function projectionOf(value) {
|
|
746
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Only the generated sub-keys of a `projection` object, in the order this writer emits them. */
|
|
750
|
+
function generatedProjection(value) {
|
|
751
|
+
const out = {};
|
|
752
|
+
for (const key of PROJECTION_KEYS) if (key in projectionOf(value)) out[key] = value[key];
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
730
755
|
|
|
731
756
|
/**
|
|
732
757
|
* A manifest with this tool's own fields replaced by what this build writes, and
|
|
@@ -738,13 +763,22 @@ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
|
738
763
|
* repo chose is never compared" is a promise about the repair too, not only about
|
|
739
764
|
* the report, and a repair is exactly where it would be broken.
|
|
740
765
|
*
|
|
766
|
+
* `projection` merges the same way, one level down: the object the repository has
|
|
767
|
+
* keeps every sub-key it chose, and the generated sub-keys take this build's values.
|
|
768
|
+
*
|
|
741
769
|
* @param {object} manifest the repository's manifest, as parsed
|
|
742
770
|
* @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
|
|
743
771
|
* @returns {object} a new manifest; the argument is not mutated
|
|
744
772
|
*/
|
|
745
773
|
export function withGenerated(manifest, expected) {
|
|
746
774
|
const patched = { ...manifest };
|
|
747
|
-
for (const key of GENERATED_KEYS)
|
|
775
|
+
for (const key of GENERATED_KEYS) {
|
|
776
|
+
if (!(key in expected)) continue;
|
|
777
|
+
patched[key] =
|
|
778
|
+
key === 'projection'
|
|
779
|
+
? { ...projectionOf(manifest?.projection), ...expected.projection }
|
|
780
|
+
: expected[key];
|
|
781
|
+
}
|
|
748
782
|
return patched;
|
|
749
783
|
}
|
|
750
784
|
|
|
@@ -787,9 +821,14 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
787
821
|
// `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
|
|
788
822
|
// fields are ours, and the answer is the set above. Iterating the expected object
|
|
789
823
|
// asks the same question of a different list and can only agree by coincidence.
|
|
790
|
-
const stale = [...GENERATED_KEYS].filter(
|
|
791
|
-
(key
|
|
792
|
-
|
|
824
|
+
const stale = [...GENERATED_KEYS].filter((key) => {
|
|
825
|
+
if (!(key in expected)) return false;
|
|
826
|
+
// `projection` compares on the sub-keys this tool writes: a sub-key the repo
|
|
827
|
+
// chose (`place`) is not a stale field, and reporting it is what made `sync`
|
|
828
|
+
// delete the one setting that was not stale (review 0003 Q26).
|
|
829
|
+
const actual = key === 'projection' ? generatedProjection(manifest[key]) : manifest[key];
|
|
830
|
+
return JSON.stringify(actual) !== JSON.stringify(expected[key]);
|
|
831
|
+
});
|
|
793
832
|
if (stale.length > 0) {
|
|
794
833
|
return {
|
|
795
834
|
file: MANIFEST,
|
|
@@ -842,20 +881,17 @@ export function manifestText(manifest) {
|
|
|
842
881
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
843
882
|
}
|
|
844
883
|
|
|
845
|
-
/**
|
|
884
|
+
/**
|
|
885
|
+
* The manifest in the shape the callers already read, now answered by the one loader
|
|
886
|
+
* (review 0003 Q69). `error` is the unreadable-file case only: a manifest with a value
|
|
887
|
+
* the schema cannot use is still a manifest, and refusing it here would turn every
|
|
888
|
+
* reader into a second validator.
|
|
889
|
+
*
|
|
890
|
+
* @returns {Promise<{manifest: object|null, exists: boolean, error: string|null}>}
|
|
891
|
+
*/
|
|
846
892
|
export async function readManifest(root) {
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
try {
|
|
850
|
-
text = await readFile(path, 'utf8');
|
|
851
|
-
} catch {
|
|
852
|
-
return { manifest: null, exists: false, error: null };
|
|
853
|
-
}
|
|
854
|
-
try {
|
|
855
|
-
return { manifest: JSON.parse(text), exists: true, error: null };
|
|
856
|
-
} catch (error) {
|
|
857
|
-
return { manifest: null, exists: true, error: `unparseable: ${error.message}` };
|
|
858
|
-
}
|
|
893
|
+
const { manifest, exists, problems } = await loadManifest(root);
|
|
894
|
+
return { manifest, exists, error: exists && manifest === null ? (problems[0] ?? 'unparseable') : null };
|
|
859
895
|
}
|
|
860
896
|
|
|
861
897
|
export async function writeManifest(root, manifest, journal = null) {
|
|
@@ -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
|
|