@redue/cli 0.1.0-alpha.17 → 0.1.0-alpha.19
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 +101 -0
- package/README.md +9 -5
- package/bin/redue.mjs +26 -9
- package/docs/user/agents.md +11 -0
- package/docs/user/configuration.md +6 -0
- package/docs/user/direct-typescript.md +62 -5
- package/docs/user/getting-started.md +7 -7
- package/docs/user/node-script.md +153 -0
- package/docs/user/pilot.md +6 -5
- package/docs/user/supported-projects.md +26 -15
- package/docs/user/tests-direct.md +13 -3
- package/package.json +2 -2
- package/src/agent-integration.mjs +16 -8
- package/src/claude-hook.mjs +119 -34
- package/src/cli.mjs +34 -16
- package/src/daemon.mjs +23 -8
- package/src/declared-plan.mjs +9 -6
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/index.mjs +10 -4
- package/src/node-onboarding.mjs +117 -37
- package/src/node-script.mjs +362 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npmrc.mjs +16 -0
- package/src/plan-worker.mjs +4 -1
- package/src/pnpm-inputs.mjs +63 -35
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +8 -1
- package/src/test-direct.mjs +103 -21
- package/src/test-recorder.cjs +101 -14
- package/src/typescript-input-facts.mjs +7 -7
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +2 -2
- package/src/typescript-qualification.mjs +8 -9
- package/src/workspace-manifests.mjs +3 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Direct test recipe (Vitest and Jest)
|
|
2
2
|
|
|
3
|
-
`redue init --recipe tests-direct --check test`
|
|
4
|
-
run and reports later whether that result
|
|
3
|
+
`redue init` (or `redue init --recipe tests-direct --check test` for this recipe
|
|
4
|
+
alone) records a project's unit test run and reports later whether that result
|
|
5
|
+
still applies to the files you have.
|
|
5
6
|
It supports a single-package project installed by npm, pnpm 9-12 (isolated or
|
|
6
7
|
hoisted `node_modules`) or Yarn with the `node-modules` linker, whose test
|
|
7
8
|
script runs Vitest 4 or 5, or Jest 29, directly. It runs on macOS and Linux with
|
|
@@ -35,7 +36,8 @@ runner directly, otherwise the only script that does. The script must be one
|
|
|
35
36
|
runner invocation: no `&&`, quotes, globs or variable assignments. Watch mode,
|
|
36
37
|
`--changed`, `related`, snapshot updates (`-u`), shuffling, sharding and
|
|
37
38
|
Jest's `--onlyChanged` are refused. Vitest needs `run`, `--run` or
|
|
38
|
-
`--watch=false`. Other scripts stay recording-only
|
|
39
|
+
`--watch=false`. Other test scripts stay recording-only: the
|
|
40
|
+
[general recipe](node-script.md) covers typecheck and lint scripts, not tests.
|
|
39
41
|
|
|
40
42
|
REDUE runs the local runner with Node directly. The package manager, its
|
|
41
43
|
lifecycle scripts and `package.json` are not changed or executed.
|
|
@@ -79,6 +81,10 @@ kept but marked UNVERIFIED, with the reason, when the run:
|
|
|
79
81
|
about a name other than localhost;
|
|
80
82
|
- started a process that did not load the recorder: a shell, a binary, or a
|
|
81
83
|
Node process with its own environment;
|
|
84
|
+
- had an installed file with another hard link, other than a link into the
|
|
85
|
+
pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
|
|
86
|
+
default on Linux). A change through a link elsewhere, such as Yarn's
|
|
87
|
+
hardlinks modes, is not seen.
|
|
82
88
|
|
|
83
89
|
Lookups that can be asked again become run facts, checked before every CURRENT
|
|
84
90
|
answer: whether a path outside the checkout exists, where a link resolves, the
|
|
@@ -119,4 +125,8 @@ Tests that write files git does not ignore change the run's inputs.
|
|
|
119
125
|
are not indexed at start), makes the result STALE.
|
|
120
126
|
- A config written by an earlier build of this recipe is UNVERIFIED with "run
|
|
121
127
|
redue init again"; move `redue.config.json` aside and run init.
|
|
128
|
+
- pnpm's store is trusted not to change a file in place, as pnpm never does.
|
|
129
|
+
Editing an installed file in another project that shares the same store
|
|
130
|
+
file changes it here too, unseen. Use `pnpm patch`, or a copying install
|
|
131
|
+
(`package-import-method=copy`), if you edit installed files.
|
|
122
132
|
- Windows is not supported; the check stays recording-only there.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redue/cli",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.19",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"tag": "alpha"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"redue": "./bin/redue.mjs"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node
|
|
14
|
+
"test": "node test/run-suite.mjs",
|
|
15
15
|
"test:package": "node test/acceptance/package-smoke.mjs",
|
|
16
16
|
"demo": "node test/acceptance/package-smoke.mjs --demo",
|
|
17
17
|
"pretest": "npm --prefix test/fixtures/typescript-5-5-2 ci --no-audit --no-fund && npm --prefix test/fixtures/typescript-6-0-2 ci --no-audit --no-fund && npm --prefix test/fixtures/typescript-6-0-3 ci --no-audit --no-fund"
|
|
@@ -26,7 +26,7 @@ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
|
26
26
|
// Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
|
|
27
27
|
// prefix rules; the hook decides from the full command.
|
|
28
28
|
function hookFor(config,relative,scripts){
|
|
29
|
-
const rules=[...new Set([...hookForms(config,relative).flatMap(row=>[...row.forms,...row.local]),
|
|
29
|
+
const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
|
|
30
30
|
...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
|
|
31
31
|
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
|
|
32
32
|
}
|
|
@@ -99,17 +99,23 @@ function saved(root,host){const text=read(root,marker(host));if(text===null)retu
|
|
|
99
99
|
return value;
|
|
100
100
|
}
|
|
101
101
|
// The managed block is always in the agent's context, so it stays short. For
|
|
102
|
-
// Claude Code it names the typecheck and
|
|
102
|
+
// Claude Code it names the typecheck, test and lint commands the hook matches, asks
|
|
103
103
|
// the agent to run them rather than judge for itself that a rerun is not
|
|
104
104
|
// needed, and says what a skip means. The hook does the rest, so the block
|
|
105
105
|
// does not point to the skill; the skill stays for agents that look for it.
|
|
106
106
|
const quoted=forms=>forms.map(form=>`\`${form}\``).join(' and ');
|
|
107
|
-
const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'
|
|
107
|
+
const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'&&row.kind!=='lint').map(row=>row.form),
|
|
108
|
+
rows.filter(row=>row.kind==='test').map(row=>row.form),rows.filter(row=>row.kind==='lint').map(row=>row.form)];
|
|
108
109
|
const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
|
|
109
110
|
const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
|
|
110
111
|
function blockFor(host,rows=[]){
|
|
111
|
-
const [typechecks,tests]=commandsOf(rows);
|
|
112
|
+
const [typechecks,tests,lints]=commandsOf(rows);
|
|
112
113
|
if(!rows.length)return fence(host,skillPointer(host));
|
|
114
|
+
if(lints.length)return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
115
|
+
(tests.length?`Run the unit tests with ${quoted(tests)}.\n`:'')+`Lint with ${quoted(lints)}.\n`+
|
|
116
|
+
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
117
|
+
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
118
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`);
|
|
113
119
|
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
|
|
114
120
|
`edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
|
|
115
121
|
`applies to these exact files, a REDUE hook skips the run and says so. That\n`+
|
|
@@ -134,7 +140,7 @@ function earlierBlock(host,rows=[]){
|
|
|
134
140
|
}
|
|
135
141
|
// One form per check: the package manager's script form when there is one,
|
|
136
142
|
// since that is what the project's own instructions usually name.
|
|
137
|
-
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
|
|
143
|
+
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).filter(row=>!row.part).map(row=>({kind:row.kind,
|
|
138
144
|
form:row.forms.find(form=>/^(?:npm run|pnpm|yarn) /.test(form))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
|
|
139
145
|
const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
|
|
140
146
|
`Before deciding whether configured verification needs repeating, use REDUE.\n`+
|
|
@@ -157,7 +163,8 @@ function knownBlock(host,relative,block){
|
|
|
157
163
|
const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
|
|
158
164
|
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
159
165
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
160
|
-
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))
|
|
166
|
+
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
|
|
167
|
+
...forms(`Lint with ${list}\\.\\n`).map(form=>({kind:'lint',form}))];
|
|
161
168
|
return block===blockFor(host,rows)||block===earlierBlock(host,rows)||priorBlocks(host,relative).includes(block);
|
|
162
169
|
}
|
|
163
170
|
// The policy with the exact commands for this config, so an agent never builds
|
|
@@ -166,8 +173,9 @@ function knownBlock(host,relative,block){
|
|
|
166
173
|
function skillFor(relative,host,config){
|
|
167
174
|
// Double quotes work in POSIX shells, cmd and PowerShell for ordinary names.
|
|
168
175
|
const redue=redueFor(relative)??`redue --config ${JSON.stringify(relative)}`,status=`${redue} status --sync --json`;
|
|
169
|
-
const rows=host==='claude'?hookForms(config,relative):[];
|
|
176
|
+
const rows=host==='claude'?hookForms(config,relative).filter(row=>!row.part):[];
|
|
170
177
|
const label=row=>row.kind==='test'?(row.check==='test'?'Unit tests':`Unit tests \`${row.check}\``):
|
|
178
|
+
row.kind==='lint'?(row.check==='lint'?'Lint':`Lint \`${row.check}\``):
|
|
171
179
|
row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``;
|
|
172
180
|
const commands=rows.map(row=>`- ${label(row)}: `+
|
|
173
181
|
`${row.forms.map(form=>`\`${form}\``).join(' or ')}.\n`).join('')+
|
|
@@ -183,7 +191,7 @@ function skillFor(relative,host,config){
|
|
|
183
191
|
// typecheck needs only the CLAUDE.md block, so the description must not
|
|
184
192
|
// send the agent here for one.
|
|
185
193
|
const description=rows.length?'Rules and exact commands for redue status, redue run and other redue commands. '+
|
|
186
|
-
`A plain ${rows.some(row=>row.kind==='test')?'typecheck or test run':'typecheck'} needs only the REDUE section of CLAUDE.md.`:
|
|
194
|
+
`A plain ${rows.some(row=>row.kind==='lint')?'typecheck, test or lint run':rows.some(row=>row.kind==='test')?'typecheck or test run':'typecheck'} needs only the REDUE section of CLAUDE.md.`:
|
|
187
195
|
'Use before running, skipping or reporting a typecheck or other configured check.';
|
|
188
196
|
// Replacer functions, so `$` in a config name is never a replacement pattern.
|
|
189
197
|
return policy.replace('{{DESCRIPTION}}',()=>description).replace('{{CONFIG}}',()=>JSON.stringify(relative)).replace('{{COMMANDS}}',()=>commands.trimEnd())
|
package/src/claude-hook.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Claude Code PreToolUse hook: skip an exact typecheck or
|
|
1
|
+
// Claude Code PreToolUse hook: skip an exact typecheck, test or lint rerun when REDUE
|
|
2
2
|
// reports CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
|
|
3
3
|
// CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
|
|
4
4
|
// other outcome, including errors and timeouts, allows the command unchanged.
|
|
@@ -10,6 +10,8 @@ import {spawn} from 'node:child_process';
|
|
|
10
10
|
import {projectState} from './project-location.mjs';
|
|
11
11
|
import {realObservedPath} from './path-identity.mjs';
|
|
12
12
|
import {processAlive} from './process-liveness.mjs';
|
|
13
|
+
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
|
+
import {recipeLabel} from './presentation.mjs';
|
|
13
15
|
|
|
14
16
|
export const hookCommand='redue agent hook claude';
|
|
15
17
|
// No shell metacharacters; `@` starts scoped package names.
|
|
@@ -18,7 +20,11 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
|
18
20
|
// A direct test check: its command is the runner and the script's arguments.
|
|
19
21
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
20
22
|
/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
|
|
21
|
-
|
|
23
|
+
// A general recipe check: a root typecheck, test or lint script run through the package manager.
|
|
24
|
+
const general=check=>check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)&&
|
|
25
|
+
typeof check.script==='string';
|
|
26
|
+
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check);
|
|
27
|
+
export const checkKind=check=>tests(check)?'test':general(check)?check.kind:'typecheck';
|
|
22
28
|
|
|
23
29
|
export const defaultConfig='redue.config.json';
|
|
24
30
|
// The `redue` prefix for a config beside the project root; null when the name
|
|
@@ -52,8 +58,12 @@ function workspaceForms(pm,name,script){
|
|
|
52
58
|
`npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
|
|
53
59
|
return [];
|
|
54
60
|
}
|
|
55
|
-
// The tool a check runs, as typed: `tsc ARGS` or the test runner's command
|
|
56
|
-
|
|
61
|
+
// The tool a check runs, as typed: `tsc ARGS` or the test runner's command;
|
|
62
|
+
// for a general check, its script when the script is one plain command.
|
|
63
|
+
function toolText(check,scripts){
|
|
64
|
+
if(general(check)){const text=typeof scripts?.[check.script]==='string'?scripts[check.script].trim().replace(/\s+/g,' '):'';
|
|
65
|
+
return safe.test(text.replace(/ /g,''))&&!/^(?:npm|npx|pnpm|pnpx|yarn|node)(?: |$)/.test(text)&&
|
|
66
|
+
!scripts['pre'+check.script]&&!scripts['post'+check.script]?text:null;}
|
|
57
67
|
const command=check.command;
|
|
58
68
|
if(!Array.isArray(command)||command[0]!=='@node')return null;
|
|
59
69
|
const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
|
|
@@ -91,29 +101,65 @@ const checkScript=check=>typeof check.script==='string'?check.script:
|
|
|
91
101
|
Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
|
|
92
102
|
check.command[check.command.indexOf('run')+1]:null;
|
|
93
103
|
|
|
104
|
+
// A typecheck check whose command is the compiler: its arguments are one tsc
|
|
105
|
+
// run of its script (typescript-invocation.mjs).
|
|
106
|
+
function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(check)||'');}
|
|
94
107
|
// Exact commands that rerun each configured typecheck or direct test check.
|
|
95
108
|
// `forms` work from the project root; `local` ones work in the check's own
|
|
96
109
|
// directory `dir`, after a `cd` into it. Names that would need shell quoting
|
|
97
|
-
// get no form, so the hook never fires for them.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
// get no form, so the hook never fires for them. The script forms of compiler
|
|
111
|
+
// checks name the script's checks as {checks, script}: the hook acts on them
|
|
112
|
+
// only when the script's tsc runs are those checks' runs, one each. With one
|
|
113
|
+
// check they stay on its row (`scriptForms`); a chain's checks get a row for
|
|
114
|
+
// the script, and their own rows (`part`) keep only their own forms.
|
|
115
|
+
export function hookForms(config,configFile=defaultConfig,packageScripts=null){
|
|
116
|
+
const rows=[],redue=redueFor(configFile),pm=config?.packageManager,scripts=new Map();
|
|
117
|
+
const eligible=(config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&safe.test(check.name));
|
|
118
|
+
const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
|
|
119
|
+
JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
|
|
120
|
+
for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
|
|
121
|
+
const scriptRow=(dir,script,workspace)=>({forms:dir==='.'?scriptForms(pm,script):dir!==null&&safe.test(dir)?workspaceForms(pm,workspace,script):[],
|
|
122
|
+
local:dir!=='.'&&dir!==null&&safe.test(dir)?scriptForms(pm,script):[]});
|
|
123
|
+
for(const check of eligible){
|
|
124
|
+
const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
|
|
125
|
+
group=key?scripts.get(key):null,part=group?.length>1;
|
|
126
|
+
const own=group?null:checkScript(check);
|
|
103
127
|
if(dir==='.'){
|
|
104
|
-
forms.push(...scriptForms(pm,
|
|
128
|
+
forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
|
|
105
129
|
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
106
130
|
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
107
131
|
forms.push(check.command.join(' '));
|
|
108
132
|
}else if(dir!==null&&safe.test(dir)){
|
|
109
|
-
forms.push(...workspaceForms(pm,workspaceName(check),
|
|
110
|
-
local.push(...scriptForms(pm,
|
|
133
|
+
forms.push(...workspaceForms(pm,workspaceName(check),own));
|
|
134
|
+
local.push(...scriptForms(pm,own),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
|
|
111
135
|
}
|
|
136
|
+
let extra={};
|
|
137
|
+
if(group&&!part){
|
|
138
|
+
const {forms:scriptOnly,local:scriptLocal}=scriptRow(dir,checkScript(check),workspaceName(check));
|
|
139
|
+
forms.splice(redue?1:0,0,...scriptOnly);local.unshift(...scriptLocal);
|
|
140
|
+
extra={checks:group,script:checkScript(check),scriptForms:[...scriptOnly,...scriptLocal]};
|
|
141
|
+
}
|
|
142
|
+
if(part)extra={part:true};
|
|
112
143
|
if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
|
|
113
|
-
forms:[...new Set(forms)],local:[...new Set(local)]});
|
|
144
|
+
forms:[...new Set(forms)],local:[...new Set(local)],...extra});
|
|
145
|
+
}
|
|
146
|
+
for(const [key,checks] of scripts){
|
|
147
|
+
if(checks.length<2)continue;
|
|
148
|
+
const [dir,script,workspace]=JSON.parse(key),{forms,local}=scriptRow(dir,script,workspace);
|
|
149
|
+
if(forms.length||local.length)rows.push({check:checks[0].replace(/\.\d+$/,''),checks,script,
|
|
150
|
+
scriptForms:[...forms,...local],kind:'typecheck',dir,forms,local});
|
|
114
151
|
}
|
|
115
152
|
return rows;
|
|
116
153
|
}
|
|
154
|
+
// The checks a compiler script row runs, in the order of the script's runs in
|
|
155
|
+
// the package's package.json now; null when they no longer correspond (the
|
|
156
|
+
// script changed since init), so the hook leaves the command alone.
|
|
157
|
+
export function scriptChecks(row,config,projectRoot){
|
|
158
|
+
let scripts;try{scripts=JSON.parse(fs.readFileSync(path.join(projectRoot,row.dir,'package.json'),'utf8')).scripts;}catch{return null;}
|
|
159
|
+
if(scripts?.['pre'+row.script]||scripts?.['post'+row.script])return null;
|
|
160
|
+
const args=name=>config.checks.find(check=>check.name===name)?.command?.slice(2);
|
|
161
|
+
return scriptRunChecks(scripts?.[row.script],row.checks.map(name=>({name,args:args(name)})));
|
|
162
|
+
}
|
|
117
163
|
|
|
118
164
|
// A turbo or `pnpm -r` command that runs one task in every workspace package:
|
|
119
165
|
// {tool, task}. No filters and no nx: both can add packages or tasks that only
|
|
@@ -130,7 +176,7 @@ export function fanout(text){
|
|
|
130
176
|
// Bash `if` rules for fan-out commands of a task some workspace check runs,
|
|
131
177
|
// and for root scripts that run one; other turbo or nx commands never start the hook.
|
|
132
178
|
export function fanoutRules(config,scripts={}){
|
|
133
|
-
const tasks=new Set((config?.checks||[]).filter(check=>(
|
|
179
|
+
const tasks=new Set((config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&
|
|
134
180
|
![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
|
|
135
181
|
const rules=[];
|
|
136
182
|
for(const task of tasks){
|
|
@@ -166,10 +212,17 @@ export async function fanoutChecks(fan,config,projectRoot){
|
|
|
166
212
|
return null;
|
|
167
213
|
const names=[];
|
|
168
214
|
for(const item of packages){
|
|
169
|
-
const
|
|
215
|
+
const own=config.checks.filter(c=>eligibleCheck(c)&&typeof c.name==='string'&&
|
|
170
216
|
workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
|
|
171
|
-
if(!
|
|
172
|
-
|
|
217
|
+
if(!own.length)return null;
|
|
218
|
+
// A compiler script's checks: one per tsc run it has now.
|
|
219
|
+
if(own.every(tscCheck)){
|
|
220
|
+
const ordered=scriptRunChecks(item.scripts[fan.task],own.map(c=>({name:c.name,args:c.command.slice(2)})));
|
|
221
|
+
if(!ordered)return null;
|
|
222
|
+
names.push(...ordered);continue;
|
|
223
|
+
}
|
|
224
|
+
if(own.length!==1)return null;
|
|
225
|
+
names.push(own[0].name);
|
|
173
226
|
}
|
|
174
227
|
return names;
|
|
175
228
|
}
|
|
@@ -191,6 +244,10 @@ function split(command){
|
|
|
191
244
|
function matched(command,config,configFile,dir='.',scripts={}){
|
|
192
245
|
const parts=split(command);
|
|
193
246
|
if(!parts)return null;
|
|
247
|
+
const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
248
|
+
row.dir===dir&&row.local.includes(parts.base));
|
|
249
|
+
// A root script that a general check runs is that check, even when the script fans out.
|
|
250
|
+
if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
|
|
194
251
|
if(dir==='.'){
|
|
195
252
|
// A root script that runs a fan-out is that fan-out, unless pre/post scripts
|
|
196
253
|
// run around it: then it matches nothing.
|
|
@@ -199,18 +256,21 @@ function matched(command,config,configFile,dir='.',scripts={}){
|
|
|
199
256
|
return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
|
|
200
257
|
if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
|
|
201
258
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return check?{check,rest:parts.rest}:null;
|
|
259
|
+
// A script form of compiler checks is checked against the script first.
|
|
260
|
+
return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
|
|
205
261
|
}
|
|
206
|
-
export function matchCheck(command,config,configFile,dir,scripts){
|
|
207
|
-
|
|
208
|
-
|
|
262
|
+
export function matchCheck(command,config,configFile,dir,scripts){
|
|
263
|
+
const match=matched(command,config,configFile,dir,scripts);
|
|
264
|
+
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
265
|
+
}
|
|
266
|
+
// The same check (or checks, in order) through REDUE, keeping the output
|
|
267
|
+
// suffix; null if it already is, or if more commands follow it.
|
|
209
268
|
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
269
|
+
const names=Array.isArray(name)?name:[name];
|
|
210
270
|
const parts=split(command),redue=redueFor(configFile);
|
|
211
|
-
if(!parts||parts.rest!==null||!safe.test(
|
|
212
|
-
const run
|
|
213
|
-
return parts.base===run?null:run+parts.suffix;
|
|
271
|
+
if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
|
|
272
|
+
const run=names.map(value=>`${redue} run ${value}`).join(' && ');
|
|
273
|
+
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
214
274
|
}
|
|
215
275
|
|
|
216
276
|
// A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
|
|
@@ -243,7 +303,8 @@ export function verdict(status,name,rest=null){
|
|
|
243
303
|
const runId=row?.invocation?.runId;
|
|
244
304
|
if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
|
|
245
305
|
typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
|
|
246
|
-
|
|
306
|
+
const recipe=recipeLabel(row.recipe);
|
|
307
|
+
return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}${recipe?`, ${recipe}`:''}); `+
|
|
247
308
|
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
248
309
|
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
249
310
|
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
@@ -277,9 +338,11 @@ export function statusTimeout(status,name){
|
|
|
277
338
|
Math.min(run.durationMs/2,10000):2000;
|
|
278
339
|
}
|
|
279
340
|
// From the observer's last published status, which is small, not the receipts.
|
|
280
|
-
|
|
341
|
+
// The longest wait among `names`, read once.
|
|
342
|
+
export function lastRunTimeout(config,parsed,...names){
|
|
281
343
|
try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
|
|
282
|
-
|
|
344
|
+
const status=JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8'));
|
|
345
|
+
return Math.max(...names.map(name=>statusTimeout(status,name)));}
|
|
283
346
|
catch{return 2000;}
|
|
284
347
|
}
|
|
285
348
|
|
|
@@ -329,10 +392,34 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
329
392
|
if(match.fanout){
|
|
330
393
|
const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
|
|
331
394
|
if(!names)return null;
|
|
332
|
-
const status=await synchronizedStatus(entry,config,root,timeout??
|
|
395
|
+
const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
|
|
333
396
|
reason=fanoutVerdict(status,names,match.rest);
|
|
334
397
|
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
335
398
|
}
|
|
399
|
+
// The configured config path as the command's directory sees it.
|
|
400
|
+
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
401
|
+
const configPath=!entered.prefix?record.config:
|
|
402
|
+
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
403
|
+
// A compiler script: its checks in the order of its runs, or nothing when
|
|
404
|
+
// they no longer correspond. One run is an ordinary check. A chain is
|
|
405
|
+
// skipped when every run is CURRENT, else the runs that are not run through
|
|
406
|
+
// redue run, in order.
|
|
407
|
+
if(match.row){
|
|
408
|
+
const names=scriptChecks(match.row,parsed,project.projectRoot);
|
|
409
|
+
if(!names)return null;
|
|
410
|
+
if(names.length===1)match.check=names[0];
|
|
411
|
+
else{
|
|
412
|
+
const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
|
|
413
|
+
reason=fanoutVerdict(status,names,match.rest);
|
|
414
|
+
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
415
|
+
const pending=names.filter(name=>!verdict(status,name));
|
|
416
|
+
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
417
|
+
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
|
|
418
|
+
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
419
|
+
permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
|
|
420
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
336
423
|
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
337
424
|
reason=verdict(status,name,match.rest);
|
|
338
425
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
@@ -342,9 +429,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
342
429
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
343
430
|
// for the rest of the chain.
|
|
344
431
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
345
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
|
|
346
|
-
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
347
|
-
!entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
|
|
432
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
|
|
348
433
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
349
434
|
// to the rewritten command; "allow" skips only the prompt.
|
|
350
435
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
package/src/cli.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import {builtInProbe,probeFloorMs,publishedDeadline} from './probe-deadline.mjs'
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
5
5
|
import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
|
|
6
|
-
import {runFactChange} from './typescript-list.mjs';
|
|
6
|
+
import {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
7
7
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
8
|
+
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import net from 'node:net';
|
|
10
11
|
import os from 'node:os';
|
|
@@ -69,7 +70,8 @@ const readsFile=path.join(state,'environment-reads-v1.json');
|
|
|
69
70
|
function environmentReads() {
|
|
70
71
|
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
const recordedRecipes=[testsQualification,scriptQualification];
|
|
74
|
+
function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
|
|
73
75
|
return Object.fromEntries(config.checks.map(check=>[
|
|
74
76
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
75
77
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
@@ -271,20 +273,28 @@ async function execute() {
|
|
|
271
273
|
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
272
274
|
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
273
275
|
...command.slice(1)]:command;
|
|
274
|
-
// A test run's processes record what the guard checks (test-recorder.cjs)
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
// A test run's processes record what the guard checks (test-recorder.cjs),
|
|
277
|
+
// and so do a general recipe run's, through the package manager.
|
|
278
|
+
const general=selected.qualification===scriptQualification;
|
|
279
|
+
const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
|
|
280
|
+
let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
|
|
277
281
|
if(testRun){
|
|
278
282
|
// The package directory and installed node_modules, as the plan has them.
|
|
279
|
-
try{testFacts=testRunFacts(root,selected);}
|
|
280
|
-
catch(e){captureIssues.push(
|
|
283
|
+
try{testFacts=general?scriptRunFacts(root,selected):testRunFacts(root,selected);}
|
|
284
|
+
catch(e){captureIssues.push(`${general?'script':'test'} run facts unavailable (${e.code||e.message})`);}
|
|
281
285
|
const environmentIssue=testEnvironmentIssue();
|
|
282
286
|
if(environmentIssue)captureIssues.push(environmentIssue);
|
|
283
287
|
testRecord=path.join(state,`test-record-${runId}`);
|
|
284
288
|
fs.mkdirSync(testRecord,{mode:0o700});
|
|
285
|
-
const guardFile=path.join(testRecord,'guard.json');
|
|
286
|
-
|
|
289
|
+
const guardFile=path.join(testRecord,'guard.json'),guardConfig=testGuardConfig(testRecord,root,process.env,testFacts);
|
|
290
|
+
if(general){toolchain=toolchainDirs();
|
|
291
|
+
// The Node on PATH, which the environment identifies by its file.
|
|
292
|
+
Object.assign(guardConfig,{installed:scriptInstalledPaths(root,testFacts.dirs||[]),toolchain,
|
|
293
|
+
node:lookup('node',process.env.PATH,process.cwd())});}
|
|
294
|
+
fs.writeFileSync(guardFile,JSON.stringify(guardConfig),{mode:0o600});
|
|
287
295
|
launchEnv=recordedEnvironment(guardFile);
|
|
296
|
+
// npm's and pnpm's update checks read state outside the checkout and the network.
|
|
297
|
+
if(general)launchEnv.npm_config_update_notifier='false';
|
|
288
298
|
}
|
|
289
299
|
let runnerPid=null;
|
|
290
300
|
const record=[];
|
|
@@ -357,12 +367,18 @@ async function execute() {
|
|
|
357
367
|
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
358
368
|
let guard=null;
|
|
359
369
|
if(testRecord){
|
|
360
|
-
let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
|
|
370
|
+
let coverage=null;if(!general)try{coverage=coverageReadable(root,testFacts);}catch{}
|
|
371
|
+
const programs=general?{runner:{file:command[0],args:command.slice(1),cwd:plan.cwd,searchPath:process.env.PATH,
|
|
372
|
+
env:{preload:true,bashEnv:Boolean(process.env.BASH_ENV)}},
|
|
373
|
+
judge:(child,main)=>childProgram(child,{toolchain,parentMain:main}),trusted:scriptCaches}:null;
|
|
361
374
|
guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
|
|
362
|
-
runner:runnerPid,environment:selected.environment||{},coverage});
|
|
375
|
+
runner:runnerPid,environment:selected.environment||{},coverage,programs});
|
|
363
376
|
runFacts=guard.runFacts;
|
|
364
|
-
|
|
365
|
-
if(
|
|
377
|
+
// The pnpm executable reads its configuration natively: kept by content.
|
|
378
|
+
if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
|
|
379
|
+
catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
|
|
380
|
+
const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
|
|
381
|
+
if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
|
|
366
382
|
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
367
383
|
}
|
|
368
384
|
// The daemon reads the recording from owned state; control requests are small.
|
|
@@ -392,7 +408,8 @@ async function execute() {
|
|
|
392
408
|
const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
|
|
393
409
|
const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
|
|
394
410
|
const touched=[...new Set([...changed,...events])].sort();
|
|
395
|
-
const
|
|
411
|
+
const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
|
|
412
|
+
const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
|
|
396
413
|
if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
397
414
|
}
|
|
398
415
|
// The end certificate, recorded or probed, must cover what this compile received.
|
|
@@ -447,9 +464,10 @@ async function execute() {
|
|
|
447
464
|
}
|
|
448
465
|
// A recorded run's receipt carries its run facts, even none; observers that
|
|
449
466
|
// predate run facts do not reuse version 2.
|
|
450
|
-
|
|
467
|
+
// The recipe that made the receipt, which status and the hook name.
|
|
468
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
|
|
451
469
|
...(recorded||testRun?{runFacts}:{}),
|
|
452
|
-
...(testRun?{verificationRecipe:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
470
|
+
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
453
471
|
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
454
472
|
resolutionLinks:plan.resolutionLinks||[],
|
|
455
473
|
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
package/src/daemon.mjs
CHANGED
|
@@ -20,7 +20,9 @@ import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
|
|
|
20
20
|
import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
|
|
21
21
|
import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
|
|
22
22
|
import {statusSummary} from './cached-state.mjs';
|
|
23
|
-
import {directProject,directProbeValue
|
|
23
|
+
import {directProject,directProbeValue} from './direct-typescript.mjs';
|
|
24
|
+
import {npmCacheDir} from './npm-integrity.mjs';
|
|
25
|
+
import {runFromArgv} from './typescript-invocation.mjs';
|
|
24
26
|
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
25
27
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
26
28
|
|
|
@@ -130,7 +132,7 @@ function continuityBarrier() {
|
|
|
130
132
|
// The basis a full direct probe is checked against: plan guard, observation
|
|
131
133
|
// continuity, and the compiler tree and Node identity taken before the probe.
|
|
132
134
|
function certificateBasis(row) {
|
|
133
|
-
const argv=row.plan.probes[0],compiler=directProject(argv[2],argv
|
|
135
|
+
const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
|
|
134
136
|
return {guard:planGuard(index.bundle),gaps:metrics.gaps,
|
|
135
137
|
compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
|
|
136
138
|
toolchain:toolchainKey(compiler.tsRoot,argv[0])};
|
|
@@ -238,8 +240,18 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
238
240
|
.includes(row.plan.qualification)?
|
|
239
241
|
/VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
|
|
240
242
|
const explanations={
|
|
241
|
-
'direct-typescript-implementation-unreviewed':'installed TypeScript
|
|
242
|
-
'direct-typescript-version-unsupported'
|
|
243
|
+
'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
|
|
244
|
+
'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
|
|
245
|
+
'direct-typescript-lockfile-source-unsupported':'package-lock.json installs TypeScript from somewhere other than the registry\'s typescript package (an alias, a file or a git source)',
|
|
246
|
+
'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
|
|
247
|
+
'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
|
|
248
|
+
'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
|
|
249
|
+
'npm-lockfile-unreadable':'package-lock.json cannot be read',
|
|
250
|
+
'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
|
|
251
|
+
'npm-tarball-uncached':`the TypeScript tarball package-lock.json names is not in npm's cache (${npmCacheDir(root)}); run npm ci or npm cache add`,
|
|
252
|
+
'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
|
|
253
|
+
'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
|
|
254
|
+
'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
|
|
243
255
|
'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
|
|
244
256
|
'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
|
|
245
257
|
'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
|
|
@@ -340,6 +352,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
340
352
|
plan_id:row.plan.id,observed_at:lastObservation,
|
|
341
353
|
invocation:r?.invocation||null,target_provenance:r?.target||null,
|
|
342
354
|
...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
|
|
355
|
+
...(r?.recipe?{recipe:r.recipe}:{}),
|
|
343
356
|
coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
|
|
344
357
|
...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
|
|
345
358
|
if(receiptStateError){item.reason=receiptStateError;return item;}
|
|
@@ -492,7 +505,7 @@ function adopt(name,row,token) {
|
|
|
492
505
|
return done('inputs or certificate changed during the run');
|
|
493
506
|
const cache=pending.cache,argv=row.plan.probes[0];
|
|
494
507
|
let result;
|
|
495
|
-
try{result=recordedCertificate(text,cache,directProject(argv[2],argv
|
|
508
|
+
try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
|
|
496
509
|
catch{return done('recording unreadable');}
|
|
497
510
|
if(result.issue)return done(result.issue);
|
|
498
511
|
const identities=new Map(cache.identities);
|
|
@@ -782,8 +795,8 @@ function markInputEvent(name,rel,type,replay) {
|
|
|
782
795
|
if(!hasHistory||historyDisabled||replay){
|
|
783
796
|
const serial=(inputEventSerial.get(name)||0)+1;
|
|
784
797
|
inputEventSerial.set(name,serial);
|
|
785
|
-
// Kept only for test checks; trimmed in batches.
|
|
786
|
-
if(index?.checks.get(name)?.plan.qualification
|
|
798
|
+
// Kept only for recorded test and script checks; trimmed in batches.
|
|
799
|
+
if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
|
|
787
800
|
const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
|
|
788
801
|
if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
|
|
789
802
|
}
|
|
@@ -1217,7 +1230,9 @@ const server=net.createServer(client=>{
|
|
|
1217
1230
|
event('control_reply_unavailable',{classification:handled?'closed_without_reply':
|
|
1218
1231
|
'closed_before_request',request_bytes:text.length});});
|
|
1219
1232
|
client.on('data',d=>{
|
|
1220
|
-
|
|
1233
|
+
// A sync request carries each check's context digests, about 1.5 KB per
|
|
1234
|
+
// check; 1 MiB leaves room for hundreds of checks.
|
|
1235
|
+
if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
|
|
1221
1236
|
{classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
|
|
1222
1237
|
if(!text.includes('\n'))return;handled=true;
|
|
1223
1238
|
try{const message=JSON.parse(text);
|