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
package/src/journal.mjs
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// The write journal and `undo` (plan 0003 R49, entry kinds review 0003 Q22).
|
|
2
|
+
//
|
|
3
|
+
// Every write a command performs is recorded as one entry in the run's set:
|
|
4
|
+
// `file` entries hold the pre-run bytes as base64 with the pre-run permission
|
|
5
|
+
// bits (binary-safe, unlike the version-1 utf8 spelling), and `git-config`
|
|
6
|
+
// entries hold one config key's before and after values, so `undo` restores
|
|
7
|
+
// the routing a run changed as well as the bytes. `commitJournal` folds each
|
|
8
|
+
// path and each key to a single entry — first before, last after — and `undo`
|
|
9
|
+
// restores entries in reverse, skipping the ones already at their pre-run
|
|
10
|
+
// state so a crashed undo resumes instead of refusing its own progress. The
|
|
11
|
+
// write-ahead marker records that intent; verification still precedes any
|
|
12
|
+
// restore on a fresh undo, so a refusal changes nothing.
|
|
13
|
+
//
|
|
14
|
+
// Three deliberate boundaries, each a failure this module refuses to be:
|
|
15
|
+
//
|
|
16
|
+
// - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
|
|
17
|
+
// rename (the same stale spelling survives in bench/probes.mjs comments).
|
|
18
|
+
// The journal lives under STATE_DIR like every other per-repo state.
|
|
19
|
+
// - Nothing under `.simt/` is journaled. The journal recording itself is
|
|
20
|
+
// recursion; the ledger, index and candidates are evidence and caches, and
|
|
21
|
+
// restoring those to an earlier byte-shape would corrupt R17 rather than
|
|
22
|
+
// repair anything. `journaledWriteFile` performs those writes unrecorded.
|
|
23
|
+
// - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
|
|
24
|
+
// the record shape is file bytes — pointer changes are reported by the
|
|
25
|
+
// command as before and are not undone. Removals are journaled as entries
|
|
26
|
+
// with afterDigest null (the run deleted the path; undo re-creates it).
|
|
27
|
+
// Writes outside the repository (`shared install` into $HOME, `plugin`
|
|
28
|
+
// into --out) are never journaled: undo must not reach past the repo root.
|
|
29
|
+
// Commands that span the boundary (`shared install` edits the repo manifest
|
|
30
|
+
// as part of a $HOME write) and multi-root runs (`bench`, `compare` write
|
|
31
|
+
// into several repos at once) are excluded as a unit — a set belongs to one
|
|
32
|
+
// repo, and restoring half a command would lie about the other half.
|
|
33
|
+
//
|
|
34
|
+
// A set file is written atomically (tmp + rename) and only when non-empty, so
|
|
35
|
+
// the last set on disk is always the last run that changed something. An undone
|
|
36
|
+
// set is renamed to `.undone.json` rather than deleted: the history survives,
|
|
37
|
+
// a second `undo` naturally targets the set before it, and the untouched-check
|
|
38
|
+
// refuses when the two sets overlap on a file the first undo already restored.
|
|
39
|
+
|
|
40
|
+
import { execFile } from 'node:child_process';
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
43
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
44
|
+
import { promisify } from 'node:util';
|
|
45
|
+
|
|
46
|
+
import { STATE_DIR } from './name.mjs';
|
|
47
|
+
import { LOCAL_DIR } from './state.mjs';
|
|
48
|
+
|
|
49
|
+
const exec = promisify(execFile);
|
|
50
|
+
|
|
51
|
+
const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
|
|
52
|
+
const JOURNAL_DIR_LEGACY = '.simt/journal';
|
|
53
|
+
const SET_SUFFIX = '.json';
|
|
54
|
+
const UNDONE_SUFFIX = '.undone.json';
|
|
55
|
+
/** A crashed `undo` leaves its intent behind under this suffix; the next undo
|
|
56
|
+
* of the same set resumes it instead of starting over (review 0003 Q22). A
|
|
57
|
+
* marker is history, not a set, so the set listing excludes it. */
|
|
58
|
+
const UNDOING_SUFFIX = '.undoing.json';
|
|
59
|
+
/** Sets that predate entry kinds store `beforeBytes` as utf8 text and carry no
|
|
60
|
+
* `kind`; they are still restorable, through the legacy branch of the restore
|
|
61
|
+
* below. `commitJournal` only writes version 2. */
|
|
62
|
+
const SET_VERSION = 2;
|
|
63
|
+
|
|
64
|
+
function digest(bytes) {
|
|
65
|
+
return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rel(root, path) {
|
|
69
|
+
return relative(root, path).split(sep).join('/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the current bytes and permission bits of a path, or null when it does
|
|
74
|
+
* not exist. Any other failure propagates: an unreadable file is fail-closed,
|
|
75
|
+
* not treated as absent.
|
|
76
|
+
*/
|
|
77
|
+
async function readOrNull(path) {
|
|
78
|
+
try {
|
|
79
|
+
const [bytes, st] = await Promise.all([readFile(path), stat(path)]);
|
|
80
|
+
return { bytes, mode: st.mode & 0o777 };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') return null;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Record a file the run created with an exclusive create (`O_EXCL`).
|
|
89
|
+
*
|
|
90
|
+
* The create itself is the proof nothing was there before, so `before` is
|
|
91
|
+
* null by construction rather than by re-reading: a check-then-record would
|
|
92
|
+
* reopen the race the exclusive flag just closed. For ordinary overwrites use
|
|
93
|
+
* `journaledWriteFile`, which snapshots `before` itself.
|
|
94
|
+
*/
|
|
95
|
+
export function recordCreatedFile(root, acc, path, text) {
|
|
96
|
+
if (relative(root, path).startsWith('..')) {
|
|
97
|
+
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
98
|
+
}
|
|
99
|
+
acc.push(fileEntry(rel(root, path), null, Buffer.from(text, 'utf8')));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A `file` entry: the pre-run bytes as base64 (binary-safe, unlike the
|
|
103
|
+
* version-1 utf8 spelling) with the pre-run permission bits, so `undo`
|
|
104
|
+
* restores both. Removals carry `afterDigest: null` and are re-created. */
|
|
105
|
+
function fileEntry(path, before, after) {
|
|
106
|
+
return {
|
|
107
|
+
kind: 'file',
|
|
108
|
+
path,
|
|
109
|
+
beforeDigest: before === null ? null : digest(before.bytes),
|
|
110
|
+
afterDigest: after === null ? null : digest(after),
|
|
111
|
+
beforeBytes: before === null ? null : before.bytes.toString('base64'),
|
|
112
|
+
modeBefore: before === null ? null : before.mode,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Write a repo file and record the write in the run's set.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} root the repository root; paths outside it are refused
|
|
120
|
+
* @param {Array} acc the run's set, created by the caller and committed once
|
|
121
|
+
* @param {string} path absolute path to write
|
|
122
|
+
* @param {string} text the bytes to write
|
|
123
|
+
* @param {{mode?: number}} [opts] permission bits to set after the write, recorded
|
|
124
|
+
* in the entry — a shim written then chmodded is one change, not two, and
|
|
125
|
+
* `undo` must restore the mode the run found, not just the bytes
|
|
126
|
+
*/
|
|
127
|
+
export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
|
|
128
|
+
if (relative(root, path).startsWith('..')) {
|
|
129
|
+
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
130
|
+
}
|
|
131
|
+
const before = await readOrNull(path);
|
|
132
|
+
// Internal state is written, not recorded — see the module header.
|
|
133
|
+
const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
|
|
134
|
+
await mkdir(dirname(path), { recursive: true });
|
|
135
|
+
// tmp + rename, the way this module writes its own set files: a crash
|
|
136
|
+
// mid-write leaves the old bytes in place, never a half-written file
|
|
137
|
+
// (review 0003 Q25). The mode lands on the tmp, so the rename carries it.
|
|
138
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
139
|
+
await writeFile(tmp, text, 'utf8');
|
|
140
|
+
if (mode !== undefined) await chmod(tmp, mode);
|
|
141
|
+
await rename(tmp, path);
|
|
142
|
+
if (!internal) {
|
|
143
|
+
acc.push(fileEntry(rel(root, path), before, Buffer.from(text, 'utf8')));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** List every regular file under a directory, absolute paths. Missing dir → []. */
|
|
148
|
+
async function treeFiles(dir) {
|
|
149
|
+
let st;
|
|
150
|
+
try {
|
|
151
|
+
st = await stat(dir);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.code === 'ENOENT') return [];
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (!st.isDirectory()) return [dir];
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const name of await readdir(dir)) {
|
|
159
|
+
out.push(...(await treeFiles(join(dir, name))));
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Copy a skill source tree into the repo and record every file landed, so
|
|
166
|
+
* `undo` removes what the copy created and restores what it overwrote. The
|
|
167
|
+
* before-snapshot is taken first: a `cp` that overwrites in place would
|
|
168
|
+
* otherwise destroy the bytes the entry needs.
|
|
169
|
+
*/
|
|
170
|
+
export async function journaledCopyDir(root, acc, src, dest) {
|
|
171
|
+
if (relative(root, dest).startsWith('..')) {
|
|
172
|
+
throw new Error(`journal refuses a copy outside the repository: ${dest}`);
|
|
173
|
+
}
|
|
174
|
+
const before = new Map();
|
|
175
|
+
for (const abs of await treeFiles(dest)) {
|
|
176
|
+
before.set(rel(root, abs), await readOrNull(abs));
|
|
177
|
+
}
|
|
178
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
179
|
+
await cp(src, dest, { recursive: true });
|
|
180
|
+
for (const abs of await treeFiles(dest)) {
|
|
181
|
+
const path = rel(root, abs);
|
|
182
|
+
const prev = before.get(path) ?? null;
|
|
183
|
+
before.delete(path);
|
|
184
|
+
const after = await readOrNull(abs);
|
|
185
|
+
if (prev !== null && after !== null && prev.bytes.equals(after.bytes)) continue;
|
|
186
|
+
acc.push(fileEntry(path, prev, after === null ? null : after.bytes));
|
|
187
|
+
}
|
|
188
|
+
// Files the copy removed (an overwrite that drops a name) are removals.
|
|
189
|
+
for (const [path, prev] of before) {
|
|
190
|
+
acc.push(fileEntry(path, prev, null));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Remove a repo file and record the removal: undo re-creates the path with its
|
|
196
|
+
* pre-run bytes, provided nothing has appeared there since. Removing what is
|
|
197
|
+
* already absent records nothing — there is no state to restore.
|
|
198
|
+
*/
|
|
199
|
+
export async function journaledRemoveFile(root, acc, path) {
|
|
200
|
+
if (relative(root, path).startsWith('..')) {
|
|
201
|
+
throw new Error(`journal refuses a removal outside the repository: ${path}`);
|
|
202
|
+
}
|
|
203
|
+
const before = await readOrNull(path);
|
|
204
|
+
if (before === null) return;
|
|
205
|
+
const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
|
|
206
|
+
await unlink(path);
|
|
207
|
+
if (!internal) {
|
|
208
|
+
acc.push(fileEntry(rel(root, path), before, null));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Read one git config value in a repo, or null when unset. */
|
|
213
|
+
export async function readGitValue(root, key) {
|
|
214
|
+
try {
|
|
215
|
+
const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
|
|
216
|
+
return stdout.replace(/\n$/, '');
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error.code === 1) return null;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Set (or, with `value: null`, unset) one git config key and record it as a
|
|
225
|
+
* `git-config` entry, so `undo` restores the routing a run changed — a shim
|
|
226
|
+
* without its `core.hooksPath` is a hook that no longer runs (review 0003
|
|
227
|
+
* Q22). A set to the value already there records nothing: there is no state
|
|
228
|
+
* to restore.
|
|
229
|
+
*
|
|
230
|
+
* @returns {Promise<object|null>} the entry, or null when nothing changed
|
|
231
|
+
*/
|
|
232
|
+
export async function journaledGitConfig(root, acc, key, value) {
|
|
233
|
+
if (typeof key !== 'string' || key === '' || /[\s]/.test(key)) {
|
|
234
|
+
throw new Error(`journal refuses an unparsable git config key: ${key}`);
|
|
235
|
+
}
|
|
236
|
+
const before = await readGitValue(root, key);
|
|
237
|
+
if (before === value) return null;
|
|
238
|
+
if (value === null) {
|
|
239
|
+
await exec('git', ['config', '--unset', key], { cwd: root });
|
|
240
|
+
} else {
|
|
241
|
+
await exec('git', ['config', key, value], { cwd: root });
|
|
242
|
+
}
|
|
243
|
+
const entry = { kind: 'git-config', key, before, after: value };
|
|
244
|
+
acc.push(entry);
|
|
245
|
+
return entry;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Add the journal directory to .gitignore, mirroring `ignoreIdentity`: match on
|
|
250
|
+
* the exact line, never append a duplicate. Called by commitJournal so every
|
|
251
|
+
* writer command is covered at once rather than each one remembering.
|
|
252
|
+
* Exported so `doctor --fix` repairs a missing entry with the same bytes a
|
|
253
|
+
* writer would have left (plan 0003 R47).
|
|
254
|
+
*/
|
|
255
|
+
/**
|
|
256
|
+
* The .gitignore bytes `ignoreJournal` would write for `text`, or null when
|
|
257
|
+
* `text` already ignores machine-local state. One owner for the line
|
|
258
|
+
* spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
|
|
259
|
+
* there, and `doctor --fix --diff` previews it — all through this function so
|
|
260
|
+
* the three cannot disagree about what counts as ignored (plan 0003 R50).
|
|
261
|
+
*
|
|
262
|
+
* The ignored line is the whole local area (review 0003 Q19), not just the
|
|
263
|
+
* journal: every machine-local write lives under it, and one line covers them.
|
|
264
|
+
*/
|
|
265
|
+
export function journalIgnoreNext(text) {
|
|
266
|
+
const line = `${LOCAL_DIR}/`;
|
|
267
|
+
const legacy = `${JOURNAL_DIR_LEGACY}/`;
|
|
268
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
269
|
+
if (lines.includes(line) || lines.includes(legacy)) return null;
|
|
270
|
+
const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
271
|
+
return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function ignoreJournal(root, journal = null) {
|
|
275
|
+
const ignorePath = join(root, '.gitignore');
|
|
276
|
+
let text;
|
|
277
|
+
try {
|
|
278
|
+
text = await readFile(ignorePath, 'utf8');
|
|
279
|
+
} catch {
|
|
280
|
+
text = '';
|
|
281
|
+
}
|
|
282
|
+
const next = journalIgnoreNext(text);
|
|
283
|
+
if (next === null) return;
|
|
284
|
+
// A caller-supplied journal records the edit so `undo` restores it (R47);
|
|
285
|
+
// without one the write stays unrecorded, as `commitJournal` needs it.
|
|
286
|
+
if (journal) await journaledWriteFile(root, journal, ignorePath, next);
|
|
287
|
+
else await writeFile(ignorePath, next);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function setName(command, at) {
|
|
291
|
+
const stamp = new Date(at).toISOString().replace(/[:.]/g, '-');
|
|
292
|
+
return `${stamp}-${command}-${process.pid}.json`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Fold one run's entries so each path (each config key) appears once: the
|
|
297
|
+
* first before, the last after. Two writes to one path used to refuse `undo`
|
|
298
|
+
* outright, although the run's net effect is a single restore. A create
|
|
299
|
+
* followed by a removal in the same run is no change at all and folds away;
|
|
300
|
+
* unequal ghosts — a removal folded with a write — fold to the write's after
|
|
301
|
+
* with the removal's before.
|
|
302
|
+
*/
|
|
303
|
+
function coalesceEntries(acc) {
|
|
304
|
+
const order = [];
|
|
305
|
+
const folded = new Map();
|
|
306
|
+
for (const entry of acc) {
|
|
307
|
+
const slot = entry.kind === 'git-config' ? `\0${entry.key}` : entry.path;
|
|
308
|
+
const prev = folded.get(slot);
|
|
309
|
+
if (prev === undefined) {
|
|
310
|
+
order.push(slot);
|
|
311
|
+
folded.set(slot, entry);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (entry.kind === 'git-config') {
|
|
315
|
+
folded.set(slot, { ...entry, before: prev.before });
|
|
316
|
+
} else {
|
|
317
|
+
folded.set(slot, { ...entry, beforeDigest: prev.beforeDigest, beforeBytes: prev.beforeBytes, modeBefore: prev.modeBefore });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return order.map((slot) => folded.get(slot)).filter((entry) => {
|
|
321
|
+
if (entry.kind === 'git-config') return entry.before !== entry.after;
|
|
322
|
+
// Created then removed (or set back byte-for-byte, mode aside): nothing to restore.
|
|
323
|
+
return entry.beforeDigest !== null || entry.afterDigest !== null;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Persist a run's set. Writes nothing when the run changed nothing, so the
|
|
329
|
+
* last set on disk is always the last run that wrote.
|
|
330
|
+
*
|
|
331
|
+
* @returns {Promise<string|null>} the set id (file name), or null
|
|
332
|
+
*/
|
|
333
|
+
export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
|
|
334
|
+
const entries = coalesceEntries(acc);
|
|
335
|
+
if (entries.length === 0) return null;
|
|
336
|
+
await ignoreJournal(root);
|
|
337
|
+
const dir = join(root, JOURNAL_DIR);
|
|
338
|
+
await mkdir(dir, { recursive: true });
|
|
339
|
+
const id = setName(command, at);
|
|
340
|
+
const body = `${JSON.stringify({ version: SET_VERSION, command, at, entries }, null, 2)}\n`;
|
|
341
|
+
const tmp = join(dir, `${id}.tmp-${process.pid}`);
|
|
342
|
+
await writeFile(tmp, body, 'utf8');
|
|
343
|
+
await rename(tmp, join(dir, id));
|
|
344
|
+
return id;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The `journal-unignored` finding's facts (plan 0003 R47): the sets on disk and
|
|
349
|
+
* whether .gitignore names the directory. One owner for the line spelling —
|
|
350
|
+
* `ignoreJournal` writes it, this reads it — so the two cannot disagree about
|
|
351
|
+
* what counts as ignored.
|
|
352
|
+
*/
|
|
353
|
+
export async function journalHygiene(root) {
|
|
354
|
+
const sets = await listJournalSets(root);
|
|
355
|
+
if (sets.length === 0) return { sets, ignored: true };
|
|
356
|
+
let text;
|
|
357
|
+
try {
|
|
358
|
+
text = await readFile(join(root, '.gitignore'), 'utf8');
|
|
359
|
+
} catch {
|
|
360
|
+
text = '';
|
|
361
|
+
}
|
|
362
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
363
|
+
return { sets, ignored: lines.includes(`${LOCAL_DIR}/`) || lines.includes(`${JOURNAL_DIR_LEGACY}/`) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Set ids newest-first, undone sets excluded. */
|
|
367
|
+
export async function listJournalSets(root) {
|
|
368
|
+
// The pre-move directory is a fallback for one release (review 0003 Q19).
|
|
369
|
+
let names;
|
|
370
|
+
try {
|
|
371
|
+
names = await readdir(join(root, JOURNAL_DIR));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error.code !== 'ENOENT') throw error;
|
|
374
|
+
try {
|
|
375
|
+
names = await readdir(join(root, JOURNAL_DIR_LEGACY));
|
|
376
|
+
} catch (legacyError) {
|
|
377
|
+
if (legacyError.code === 'ENOENT') return [];
|
|
378
|
+
throw legacyError;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// An undone set ends in `.undone.json`, which itself ends in `.json` — the
|
|
382
|
+
// suffix test alone would list history as current and `undo` would restore
|
|
383
|
+
// the same set twice. Exclude the undone (and the crash markers, which are
|
|
384
|
+
// intent, not sets) first.
|
|
385
|
+
return names
|
|
386
|
+
.filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX) && !n.endsWith(UNDOING_SUFFIX))
|
|
387
|
+
.sort()
|
|
388
|
+
.reverse();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The directory holding set `id`, preferring the post-move one. */
|
|
392
|
+
async function journalDirFor(root, id) {
|
|
393
|
+
try {
|
|
394
|
+
await stat(join(root, JOURNAL_DIR, id));
|
|
395
|
+
return JOURNAL_DIR;
|
|
396
|
+
} catch {
|
|
397
|
+
return JOURNAL_DIR_LEGACY;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function readJournalSet(root, id) {
|
|
402
|
+
const dir = await journalDirFor(root, id);
|
|
403
|
+
const raw = await readFile(join(root, dir, id), 'utf8');
|
|
404
|
+
const set = JSON.parse(raw);
|
|
405
|
+
if (set.version !== SET_VERSION) {
|
|
406
|
+
// Version 1 stored `beforeBytes` as utf8 text and knew no kinds: read it
|
|
407
|
+
// through the legacy branch of the restore rather than refusing history.
|
|
408
|
+
return { ...set, entries: set.entries.map((e) => ({ ...e, kind: e.kind ?? 'file-legacy' })) };
|
|
409
|
+
}
|
|
410
|
+
return set;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The pre-run bytes of an entry as a string, for previews and diffs. */
|
|
414
|
+
export function beforeText(entry) {
|
|
415
|
+
if (entry.kind === 'git-config' || entry.beforeBytes === null || entry.beforeBytes === undefined) return entry.before ?? null;
|
|
416
|
+
if (entry.kind === 'file-legacy') return entry.beforeBytes;
|
|
417
|
+
return Buffer.from(entry.beforeBytes, 'base64').toString('utf8');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The display name of an entry: a path for files, the key for config. */
|
|
421
|
+
export function entryLabel(entry) {
|
|
422
|
+
return entry.kind === 'git-config' ? `git config ${entry.key}` : entry.path;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The digest of a file's current bytes, or null when absent. */
|
|
426
|
+
function currentDigest(state) {
|
|
427
|
+
return state === null ? null : digest(state.bytes);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether an entry is already at its pre-run state — restored by an earlier
|
|
432
|
+
* attempt, or hand-set back to exactly what the run found. Restoring it again
|
|
433
|
+
* would be a no-op write, so a resumed undo skips it instead of refusing it.
|
|
434
|
+
*/
|
|
435
|
+
async function alreadyRestored(root, entry) {
|
|
436
|
+
if (entry.kind === 'git-config') {
|
|
437
|
+
return (await readGitValue(root, entry.key)) === entry.before;
|
|
438
|
+
}
|
|
439
|
+
const state = await readOrNull(join(root, entry.path));
|
|
440
|
+
return currentDigest(state) === entry.beforeDigest;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Whether an entry is still at the state the run left: the precondition for
|
|
444
|
+
* restoring it. Anything else is somebody's edit, and the undo is refused. */
|
|
445
|
+
async function untouchedSince(root, entry) {
|
|
446
|
+
if (entry.kind === 'git-config') {
|
|
447
|
+
return (await readGitValue(root, entry.key)) === entry.after;
|
|
448
|
+
}
|
|
449
|
+
const state = await readOrNull(join(root, entry.path));
|
|
450
|
+
return currentDigest(state) === entry.afterDigest;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function entryBytes(entry) {
|
|
454
|
+
if (entry.beforeBytes === null) return null;
|
|
455
|
+
if (entry.kind === 'file-legacy') return Buffer.from(entry.beforeBytes, 'utf8');
|
|
456
|
+
return Buffer.from(entry.beforeBytes, 'base64');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Restore one entry: bytes and permission bits for files, the value for config. */
|
|
460
|
+
async function restoreEntry(root, entry) {
|
|
461
|
+
if (entry.kind === 'git-config') {
|
|
462
|
+
await exec('git', entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before], {
|
|
463
|
+
cwd: root,
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const abs = join(root, entry.path);
|
|
468
|
+
if (entry.afterDigest === null) {
|
|
469
|
+
// The run removed this path: re-create it with its pre-run bytes.
|
|
470
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
471
|
+
await writeFile(abs, entryBytes(entry));
|
|
472
|
+
} else if (entry.beforeDigest === null) {
|
|
473
|
+
// The run created this path: remove it.
|
|
474
|
+
await unlink(abs);
|
|
475
|
+
} else {
|
|
476
|
+
await writeFile(abs, entryBytes(entry));
|
|
477
|
+
}
|
|
478
|
+
if (entry.modeBefore !== null && entry.modeBefore !== undefined) {
|
|
479
|
+
await chmod(abs, entry.modeBefore);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Restore one set: verify every entry is untouched since, then restore all in
|
|
485
|
+
* reverse — later writes in the run unwind first, so overlapping restores land
|
|
486
|
+
* in pre-run order.
|
|
487
|
+
*
|
|
488
|
+
* The intent lands on disk before the first restore (the write-ahead marker),
|
|
489
|
+
* so a crash mid-undo is resumable: entries already at their pre-run state
|
|
490
|
+
* are skipped, the rest are verified against the run's after-state and
|
|
491
|
+
* restored, and anything at neither refuses the whole undo. Verification
|
|
492
|
+
* still precedes any restore when there is no marker, so a refusal on a fresh
|
|
493
|
+
* undo changes nothing.
|
|
494
|
+
*
|
|
495
|
+
* @returns {Promise<{restored: string[], resumed: boolean}|{refused: {path: string, detail: string}}>}
|
|
496
|
+
*/
|
|
497
|
+
async function undoJournalSet(root, id) {
|
|
498
|
+
const set = await readJournalSet(root, id);
|
|
499
|
+
const dir = await journalDirFor(root, id);
|
|
500
|
+
const marker = join(root, dir, `${id}${UNDOING_SUFFIX}`);
|
|
501
|
+
const resumed = (await readOrNull(marker)) !== null;
|
|
502
|
+
for (const entry of set.entries) {
|
|
503
|
+
if (await alreadyRestored(root, entry)) continue;
|
|
504
|
+
if (!(await untouchedSince(root, entry))) {
|
|
505
|
+
const label = entryLabel(entry);
|
|
506
|
+
return {
|
|
507
|
+
refused: {
|
|
508
|
+
path: label,
|
|
509
|
+
detail:
|
|
510
|
+
entry.kind === 'git-config'
|
|
511
|
+
? `git config ${entry.key} changed after the journaled write`
|
|
512
|
+
: (await readOrNull(join(root, entry.path))) === null
|
|
513
|
+
? 'the file was deleted after the journaled write'
|
|
514
|
+
: 'the file changed after the journaled write',
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// The marker is the intent, written before anything is restored: a crash
|
|
520
|
+
// from here on resumes instead of refusing the files already done.
|
|
521
|
+
const tmp = `${marker}.tmp-${process.pid}`;
|
|
522
|
+
await writeFile(tmp, `${JSON.stringify({ version: 1, set: id, at: Date.now() }, null, 2)}\n`, 'utf8');
|
|
523
|
+
await rename(tmp, marker);
|
|
524
|
+
const restored = [];
|
|
525
|
+
for (const entry of [...set.entries].reverse()) {
|
|
526
|
+
if (await alreadyRestored(root, entry)) continue;
|
|
527
|
+
await restoreEntry(root, entry);
|
|
528
|
+
restored.push(entryLabel(entry));
|
|
529
|
+
}
|
|
530
|
+
await unlink(marker);
|
|
531
|
+
await rename(join(root, dir, id), join(root, dir, id.replace(/\.json$/, UNDONE_SUFFIX)));
|
|
532
|
+
return { restored, resumed };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Restore the most recent set that has not been undone.
|
|
537
|
+
*
|
|
538
|
+
* @returns {Promise<{empty: true}|{set: string, restored: string[]}|{set: string, refused: {path: string, detail: string}}>}
|
|
539
|
+
*/
|
|
540
|
+
export async function undoLastJournalSet(root) {
|
|
541
|
+
const [id] = await listJournalSets(root);
|
|
542
|
+
if (!id) return { empty: true };
|
|
543
|
+
const result = await undoJournalSet(root, id);
|
|
544
|
+
return { set: id, ...result };
|
|
545
|
+
}
|
package/src/kernel/card.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { ECOSYSTEMS } from '../projection/manifest.mjs';
|
|
26
|
+
import { resolveNodeRunner, runScript } from '../ecosystem/node-runner.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
|
|
@@ -93,10 +94,12 @@ const FOCUSED = [
|
|
|
93
94
|
// The repo's own runner decides the shape. A repository whose test script is vitest
|
|
94
95
|
// or jest takes a file argument through the script; one with no such script gets the
|
|
95
96
|
// built-in runner, which is the only answer that needs nothing installed to already
|
|
96
|
-
// be true.
|
|
97
|
-
template: (s, f, scripts) => {
|
|
97
|
+
// be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`).
|
|
98
|
+
template: (s, f, scripts, runner) => {
|
|
98
99
|
const test = scripts?.test ?? '';
|
|
99
|
-
|
|
100
|
+
if (!/vitest|jest/.test(test)) return 'node --test {file}';
|
|
101
|
+
const run = runner?.run ?? 'npm';
|
|
102
|
+
return `${run} test -- {file}`;
|
|
100
103
|
},
|
|
101
104
|
},
|
|
102
105
|
];
|
|
@@ -123,15 +126,32 @@ function text(value) {
|
|
|
123
126
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
/** The repository's own `package.json` scripts,
|
|
129
|
+
/** The repository's own `package.json` scripts, plus the manager that runs them. */
|
|
127
130
|
async function readScripts(root) {
|
|
128
131
|
try {
|
|
129
132
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
130
|
-
|
|
133
|
+
const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
|
|
134
|
+
// Q31: the runner is committed evidence (lockfile, then `packageManager`),
|
|
135
|
+
// never an assumption of npm — the card, the gates and CI read one answer.
|
|
136
|
+
const lockfiles = [];
|
|
137
|
+
for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
138
|
+
try {
|
|
139
|
+
await readFile(join(root, file), 'utf8');
|
|
140
|
+
lockfiles.push(file);
|
|
141
|
+
} catch {
|
|
142
|
+
// Absent is the normal case; only present files name the runner.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const runner = resolveNodeRunner({
|
|
146
|
+
facts: [],
|
|
147
|
+
lockfiles,
|
|
148
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
149
|
+
});
|
|
150
|
+
return { scripts, runner };
|
|
131
151
|
} catch {
|
|
132
152
|
// No package.json is a normal shape (a Python or Go repository), and an unreadable one
|
|
133
153
|
// is reported by the manifest path. Either way the card has no scripts to draw on.
|
|
134
|
-
return {};
|
|
154
|
+
return { scripts: {}, runner: null };
|
|
135
155
|
}
|
|
136
156
|
}
|
|
137
157
|
|
|
@@ -145,7 +165,7 @@ async function readScripts(root) {
|
|
|
145
165
|
* repository has declared nothing. The last is the same table the tool seeds gates from,
|
|
146
166
|
* so the card and the gate list cannot name two different commands for one repository.
|
|
147
167
|
*/
|
|
148
|
-
function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
168
|
+
function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
|
|
149
169
|
const declared = text(manifest?.gates?.commands?.[verb]);
|
|
150
170
|
if (declared) return declared;
|
|
151
171
|
|
|
@@ -154,11 +174,11 @@ function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
|
154
174
|
// is the one a hook and CI run, which is what the card is reporting.
|
|
155
175
|
const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
|
|
156
176
|
for (const name of scriptNames) {
|
|
157
|
-
if (text(scripts?.[name])) return
|
|
177
|
+
if (text(scripts?.[name])) return runScript(runner?.id ?? 'npm', name);
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
if (verb === 'build') {
|
|
161
|
-
if (text(scripts?.build)) return 'npm
|
|
181
|
+
if (text(scripts?.build)) return runScript(runner?.id ?? 'npm', 'build');
|
|
162
182
|
for (const row of BUILDS) {
|
|
163
183
|
if (row.fact && facts.has(row.fact)) return row.command;
|
|
164
184
|
if (row.stack && stack.has(row.stack)) return row.command;
|
|
@@ -217,11 +237,16 @@ function pickDroppable(lines) {
|
|
|
217
237
|
export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
|
|
218
238
|
const factSet = new Set(facts);
|
|
219
239
|
const stackSet = new Set(stack);
|
|
220
|
-
const scripts = await readScripts(root);
|
|
240
|
+
const { scripts, runner } = await readScripts(root);
|
|
241
|
+
// Q31: the lockfile facts name the runner even when `readScripts` saw no
|
|
242
|
+
// lockfile (a fixture with a bare package.json keeps npm; a pnpm checkout
|
|
243
|
+
// resolves pnpm from its fact). Facts win inside the resolver already.
|
|
244
|
+
const { resolveNodeRunner: resolveRunner } = await import('../ecosystem/node-runner.mjs');
|
|
245
|
+
const effective = runner ?? resolveRunner({ facts: factSet });
|
|
221
246
|
|
|
222
247
|
const lines = [];
|
|
223
248
|
for (const verb of VERBS) {
|
|
224
|
-
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
|
|
249
|
+
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective });
|
|
225
250
|
if (command) lines.push({ label: verb, command, verb });
|
|
226
251
|
}
|
|
227
252
|
for (const entry of FOCUSED) {
|
|
@@ -231,7 +256,7 @@ export async function commandCard({ root, manifest = null, stack = [], facts = [
|
|
|
231
256
|
if (lines.some((l) => l.kind === entry.kind)) continue;
|
|
232
257
|
lines.push({
|
|
233
258
|
label: `focused test (${entry.kind})`,
|
|
234
|
-
command: entry.template(stackSet, factSet, scripts),
|
|
259
|
+
command: entry.template(stackSet, factSet, scripts, effective),
|
|
235
260
|
kind: entry.kind,
|
|
236
261
|
verb: 'focused',
|
|
237
262
|
});
|