simt-dev 0.6.3 → 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 +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- 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-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 +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- 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 +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- 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 +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- 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 +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- 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 +296 -77
- 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 +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +69 -19
- 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/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 +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -146,10 +146,16 @@ export const LOADERS = [
|
|
|
146
146
|
nested: 'cumulative up to the project root at session start; files below the workspace load when that area is read',
|
|
147
147
|
skills: ['.agents/skills', '.opencode/skills', '.claude/skills'],
|
|
148
148
|
hooks: {
|
|
149
|
-
path: '.opencode/plugins/',
|
|
149
|
+
path: '.opencode/plugins/simt-dev.js',
|
|
150
150
|
verified:
|
|
151
151
|
'OpenCode v2 plugins docs: direct .ts and .js files and immediate plugin packages are loaded from every discovered .opencode/plugins/ directory, and the plugins array in opencode.json(c)',
|
|
152
152
|
},
|
|
153
|
+
agents: {
|
|
154
|
+
dir: '.opencode/agents',
|
|
155
|
+
ext: '.md',
|
|
156
|
+
verified:
|
|
157
|
+
'OpenCode v2 agents docs (opencode.ai/v2/docs/agents, read 2026-10-30): subagents are markdown files with a description frontmatter under .opencode/agents/',
|
|
158
|
+
},
|
|
153
159
|
mcp: {
|
|
154
160
|
path: 'opencode.json',
|
|
155
161
|
key: 'mcp.servers',
|
|
@@ -242,6 +248,12 @@ export const LOADERS = [
|
|
|
242
248
|
verified:
|
|
243
249
|
'Claude Code hooks docs, hook locations: project settings at .claude/settings.json and .claude/settings.local.json, user settings at ~/.claude/settings.json',
|
|
244
250
|
},
|
|
251
|
+
agents: {
|
|
252
|
+
dir: '.claude/agents',
|
|
253
|
+
ext: '.md',
|
|
254
|
+
verified:
|
|
255
|
+
'Claude Code subagents docs (code.claude.com/docs/en/sub-agents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .claude/agents/',
|
|
256
|
+
},
|
|
245
257
|
mcp: {
|
|
246
258
|
path: '.mcp.json',
|
|
247
259
|
key: null,
|
|
@@ -320,6 +332,12 @@ export const LOADERS = [
|
|
|
320
332
|
verified:
|
|
321
333
|
'Codex hooks docs: <repo>/.codex/hooks.json or ~/.codex/hooks.json, or inline [hooks] tables in config.toml',
|
|
322
334
|
},
|
|
335
|
+
agents: {
|
|
336
|
+
dir: '.codex/agents',
|
|
337
|
+
ext: '.toml',
|
|
338
|
+
verified:
|
|
339
|
+
'Codex custom agents docs (developers.openai.com/codex/subagents, read 2026-10-30): standalone TOML files under .codex/agents/ defining name, description and developer_instructions',
|
|
340
|
+
},
|
|
323
341
|
mcp: {
|
|
324
342
|
path: '.codex/config.toml',
|
|
325
343
|
key: 'mcp_servers',
|
|
@@ -386,6 +404,12 @@ export const LOADERS = [
|
|
|
386
404
|
path: '.cursor/hooks.json',
|
|
387
405
|
verified: 'Cursor agent hooks docs: <project-root>/.cursor/hooks.json and ~/.cursor/hooks.json',
|
|
388
406
|
},
|
|
407
|
+
agents: {
|
|
408
|
+
dir: '.cursor/agents',
|
|
409
|
+
ext: '.md',
|
|
410
|
+
verified:
|
|
411
|
+
'Cursor subagents docs (cursor.com/docs/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .cursor/agents/',
|
|
412
|
+
},
|
|
389
413
|
mcp: {
|
|
390
414
|
path: '.cursor/mcp.json',
|
|
391
415
|
key: null,
|
|
@@ -470,6 +494,12 @@ export const LOADERS = [
|
|
|
470
494
|
path: '.agents/hooks.json',
|
|
471
495
|
verified: 'Antigravity hooks docs: workspace level .agents/hooks.json, global ~/.gemini/config/hooks.json',
|
|
472
496
|
},
|
|
497
|
+
agents: {
|
|
498
|
+
dir: '.agents/agents',
|
|
499
|
+
ext: '.md',
|
|
500
|
+
verified:
|
|
501
|
+
'Google Antigravity subagents docs (antigravity.google/docs/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .agents/agents/',
|
|
502
|
+
},
|
|
473
503
|
mcp: {
|
|
474
504
|
path: '.agents/mcp_config.json',
|
|
475
505
|
key: null,
|
|
@@ -541,6 +571,12 @@ export const LOADERS = [
|
|
|
541
571
|
verified:
|
|
542
572
|
'Gemini CLI hooks reference (geminicli.com/docs/hooks/reference/, read 2026-10-26): a project .gemini/settings.json carries a top-level hooks object whose events are arrays of {matcher, hooks:[{type:"command",command,timeout? (milliseconds)}]} — the same shape Claude and Codex use; BeforeTool denies with {decision:"deny",reason}, BeforeAgent and AfterTool inject via hookSpecificOutput.additionalContext',
|
|
543
573
|
},
|
|
574
|
+
agents: {
|
|
575
|
+
dir: '.gemini/agents',
|
|
576
|
+
ext: '.md',
|
|
577
|
+
verified:
|
|
578
|
+
'Gemini CLI subagents docs (geminicli.com/docs/core/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .gemini/agents/',
|
|
579
|
+
},
|
|
544
580
|
mcp: {
|
|
545
581
|
path: '.gemini/settings.json',
|
|
546
582
|
key: 'mcpServers',
|
|
@@ -603,6 +639,8 @@ export const LOADERS = [
|
|
|
603
639
|
skillsNote: 'no project skill directory is documented for Copilot',
|
|
604
640
|
hooks: null,
|
|
605
641
|
hooksNote: 'no hooks file is documented for Copilot in any first-party page checked on 2026-10-14',
|
|
642
|
+
agents: null,
|
|
643
|
+
agentsNote: 'no file-based agents are documented for Copilot in any first-party page checked on 2026-10-30',
|
|
606
644
|
mcp: {
|
|
607
645
|
path: '.vscode/mcp.json',
|
|
608
646
|
key: null,
|
|
@@ -707,6 +745,8 @@ export const LOADERS = [
|
|
|
707
745
|
hooks: null,
|
|
708
746
|
hooksNote:
|
|
709
747
|
'DeepSeek harness hooks docs: the Claude Code and Codex bridges take an explicit configPath, and project-local hooks.json discovery is still marked TODO, so no repository path is loaded by default',
|
|
748
|
+
agents: null,
|
|
749
|
+
agentsNote: 'no file-based agents are documented for the DeepSeek harness in any first-party page checked on 2026-10-30',
|
|
710
750
|
mcp: {
|
|
711
751
|
path: null,
|
|
712
752
|
key: '@deepseek-ai/dsh-mcp-client',
|
package/src/projection/lock.mjs
CHANGED
|
@@ -13,11 +13,10 @@ import { join } from 'node:path';
|
|
|
13
13
|
|
|
14
14
|
import { readLockedVariants } from '../kernel/variants.mjs';
|
|
15
15
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
16
|
+
import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
|
|
16
17
|
import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
|
|
17
18
|
import { readManifest } from './manifest.mjs';
|
|
18
19
|
|
|
19
|
-
export const LOCK_FILE = 'simt-dev.lock.json';
|
|
20
|
-
|
|
21
20
|
/** Manifest keys that change what the region contains. Anything else in the file is
|
|
22
21
|
* the repository's own notes, and hashing it would make a comment fail the lock. */
|
|
23
22
|
const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
|
|
@@ -28,12 +27,15 @@ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
|
|
|
28
27
|
* @param {string} root
|
|
29
28
|
* @param {object} [flags] the same flags `sync` rendered with, so the output is the
|
|
30
29
|
* body that was written and not a body for a different set of surfaces
|
|
30
|
+
* @param {object} [manifest] the manifest to digest, in place of the one on disk. A
|
|
31
|
+
* writer that is about to write a manifest (a fresh `init`) passes it here so the
|
|
32
|
+
* lock records the state the repo will have, not the state it has (Q29).
|
|
31
33
|
*/
|
|
32
|
-
export async function computeLock(root, flags = {}) {
|
|
34
|
+
export async function computeLock(root, flags = {}, manifest = undefined) {
|
|
33
35
|
const variants =
|
|
34
36
|
flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
|
|
35
37
|
const desired = await desiredRegion(root, { ...flags, variants });
|
|
36
|
-
const inputs = await inputDigest(root, variants);
|
|
38
|
+
const inputs = await inputDigest(root, variants, manifest);
|
|
37
39
|
const outputs = createHash('sha256').update(desired.body).digest('hex');
|
|
38
40
|
const hasVariants =
|
|
39
41
|
variants && typeof variants === 'object' && Object.keys(variants).length > 0;
|
|
@@ -41,7 +43,7 @@ export async function computeLock(root, flags = {}) {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
|
-
* Record locked variant winners in `simt
|
|
46
|
+
* Record locked variant winners in `simt.lock.json` alongside updated digests.
|
|
45
47
|
*/
|
|
46
48
|
export async function writeLockVariants(root, nextVariants, flags = {}) {
|
|
47
49
|
const existing = await readLockedVariants(root);
|
|
@@ -53,15 +55,20 @@ export async function writeLockVariants(root, nextVariants, flags = {}) {
|
|
|
53
55
|
|
|
54
56
|
/** Read a lock, or null when the file is not there. A file that does not parse is an
|
|
55
57
|
* error the caller reports, not a missing lock: absence and damage are different
|
|
56
|
-
* repairs.
|
|
58
|
+
* repairs. A lock under the legacy name is a lock (ADR-110): it is read until the
|
|
59
|
+
* next write migrates it, and a permission failure on the new name is not an excuse
|
|
60
|
+
* to fall back to an older file. */
|
|
57
61
|
export async function readLock(root) {
|
|
58
|
-
let text;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
let text = null;
|
|
63
|
+
for (const name of [LOCK_FILE, LOCK_LEGACY]) {
|
|
64
|
+
try {
|
|
65
|
+
text = await readFile(join(root, name), 'utf8');
|
|
66
|
+
break;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code !== 'ENOENT') throw error;
|
|
69
|
+
}
|
|
64
70
|
}
|
|
71
|
+
if (text === null) return { lock: null, error: null };
|
|
65
72
|
try {
|
|
66
73
|
const lock = JSON.parse(text);
|
|
67
74
|
if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
|
|
@@ -79,13 +86,13 @@ export async function readLock(root) {
|
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
async function inputDigest(root, variants = {}) {
|
|
89
|
+
async function inputDigest(root, variants = {}, manifest = undefined) {
|
|
83
90
|
const hash = createHash('sha256');
|
|
84
91
|
hash.update(`provenance:${await provenanceDigest()}\n`);
|
|
85
92
|
const { stack, facts } = await fingerprint(root);
|
|
86
93
|
hash.update(`signals:${JSON.stringify({ stack, facts })}\n`);
|
|
87
|
-
const { manifest } = await readManifest(root);
|
|
88
94
|
const owned = {};
|
|
95
|
+
if (manifest === undefined) manifest = (await readManifest(root)).manifest;
|
|
89
96
|
if (manifest && typeof manifest === 'object') {
|
|
90
97
|
for (const key of MANIFEST_KEYS) {
|
|
91
98
|
if (manifest[key] !== undefined) owned[key] = manifest[key];
|
|
@@ -24,6 +24,9 @@ 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
|
|
@@ -201,7 +204,7 @@ export const ECOSYSTEMS = [
|
|
|
201
204
|
* fail closed — the defect is declaring a gate this repo has no way to run.
|
|
202
205
|
*
|
|
203
206
|
* The check mirrors what the dispatcher actually attempts:
|
|
204
|
-
* - `script` gates run
|
|
207
|
+
* - `script` gates run `<manager> run <script>`, so the script must exist
|
|
205
208
|
* - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
|
|
206
209
|
* installed, not merely declared as a dependency
|
|
207
210
|
* - `command` gates run whatever the manifest's `gates.commands` declares, so a
|
|
@@ -237,13 +240,16 @@ export function gateRunnable(gate, capability = {}) {
|
|
|
237
240
|
}
|
|
238
241
|
if (gate.kind === 'script') {
|
|
239
242
|
const script = gate.script ?? gate.name;
|
|
240
|
-
|
|
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}` };
|
|
241
247
|
// Saying "no script in package.json" about a repo with no package.json states a
|
|
242
248
|
// fact about a file that does not exist and implies a fix that cannot be taken.
|
|
243
249
|
return {
|
|
244
250
|
runnable: false,
|
|
245
251
|
why: foreign
|
|
246
|
-
? `this repo runs ${runnerName(capability.runner)}, not npm, so "
|
|
252
|
+
? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
|
|
247
253
|
: `no "${script}" script in package.json`,
|
|
248
254
|
};
|
|
249
255
|
}
|
|
@@ -277,11 +283,17 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
|
277
283
|
const name = gate?.name ?? 'the gate';
|
|
278
284
|
const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
|
|
279
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;
|
|
280
290
|
const fix =
|
|
281
291
|
npmShaped && foreign
|
|
282
292
|
? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
|
|
283
293
|
: gate?.kind === 'staged'
|
|
284
|
-
?
|
|
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`
|
|
285
297
|
: gate?.kind === 'script'
|
|
286
298
|
? `add a "${gate.script ?? name}" script to package.json`
|
|
287
299
|
: gate?.kind === 'command'
|
|
@@ -302,11 +314,14 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
|
|
|
302
314
|
/**
|
|
303
315
|
* Is this runner one where `npm run` is not how a gate is spelled?
|
|
304
316
|
*
|
|
305
|
-
* `undefined` means "the caller did not report a runner", and
|
|
306
|
-
*
|
|
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.)
|
|
307
321
|
*/
|
|
308
322
|
function isForeignRunner(runner) {
|
|
309
|
-
|
|
323
|
+
if (runner == null) return false;
|
|
324
|
+
return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
|
|
310
325
|
}
|
|
311
326
|
|
|
312
327
|
/** How a reader would name the runner in a sentence. */
|
|
@@ -316,6 +331,9 @@ function runnerName(runner) {
|
|
|
316
331
|
none: 'no recognised runner',
|
|
317
332
|
gradle: 'Gradle',
|
|
318
333
|
python: 'Python',
|
|
334
|
+
npm: 'npm',
|
|
335
|
+
pnpm: 'pnpm',
|
|
336
|
+
yarn: 'yarn',
|
|
319
337
|
bun: 'bun',
|
|
320
338
|
cargo: 'Cargo',
|
|
321
339
|
go: 'Go',
|
|
@@ -383,14 +401,15 @@ export function planGates(stack = [], capability = null, facts = []) {
|
|
|
383
401
|
*/
|
|
384
402
|
function ecosystemCommands(facts = [], capability = null) {
|
|
385
403
|
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
|
-
|
|
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 {};
|
|
394
413
|
const present = new Set(facts);
|
|
395
414
|
const commands = {};
|
|
396
415
|
for (const eco of ECOSYSTEMS) {
|
|
@@ -728,6 +747,23 @@ export function declaredShared(manifest) {
|
|
|
728
747
|
|
|
729
748
|
export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
730
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
|
+
|
|
731
767
|
/**
|
|
732
768
|
* A manifest with this tool's own fields replaced by what this build writes, and
|
|
733
769
|
* nothing else touched.
|
|
@@ -738,13 +774,22 @@ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
|
|
|
738
774
|
* repo chose is never compared" is a promise about the repair too, not only about
|
|
739
775
|
* the report, and a repair is exactly where it would be broken.
|
|
740
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
|
+
*
|
|
741
780
|
* @param {object} manifest the repository's manifest, as parsed
|
|
742
781
|
* @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
|
|
743
782
|
* @returns {object} a new manifest; the argument is not mutated
|
|
744
783
|
*/
|
|
745
784
|
export function withGenerated(manifest, expected) {
|
|
746
785
|
const patched = { ...manifest };
|
|
747
|
-
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
|
+
}
|
|
748
793
|
return patched;
|
|
749
794
|
}
|
|
750
795
|
|
|
@@ -787,9 +832,14 @@ export async function manifestDrift(root, stack, sha) {
|
|
|
787
832
|
// `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
|
|
788
833
|
// fields are ours, and the answer is the set above. Iterating the expected object
|
|
789
834
|
// asks the same question of a different list and can only agree by coincidence.
|
|
790
|
-
const stale = [...GENERATED_KEYS].filter(
|
|
791
|
-
(key
|
|
792
|
-
|
|
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
|
+
});
|
|
793
843
|
if (stale.length > 0) {
|
|
794
844
|
return {
|
|
795
845
|
file: MANIFEST,
|
|
@@ -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
|
|