@redue/cli 0.1.0-alpha.23 → 0.1.0-alpha.25
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 +58 -0
- package/README.md +6 -10
- package/bin/redue.mjs +36 -6
- package/docs/user/agents.md +20 -1
- package/docs/user/direct-typescript.md +8 -0
- package/docs/user/tests-direct.md +6 -2
- package/package.json +1 -1
- package/src/agent-integration.mjs +11 -5
- package/src/claude-hook.mjs +90 -6
- package/src/cli.mjs +43 -17
- package/src/daemon.mjs +1 -0
- package/src/direct-run-certificate.mjs +3 -1
- package/src/direct-typescript-probe.mjs +4 -1
- package/src/direct-typescript.mjs +23 -3
- package/src/installed-observation.mjs +23 -0
- package/src/node-script.mjs +5 -2
- package/src/pnpm-inputs.mjs +4 -1
- package/src/test-direct.mjs +19 -3
- package/src/typescript-list.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.25 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
Running your tests no longer makes a typecheck STALE, and `status --sync`
|
|
6
|
+
answers about twice as fast.
|
|
7
|
+
|
|
8
|
+
- **Vitest and the direct TypeScript recipe.** A Vitest run writes its
|
|
9
|
+
results under `node_modules/.vite/vitest`, which the recipe counted as an
|
|
10
|
+
installed input, so the typecheck turned STALE after every test run. Those
|
|
11
|
+
files are no longer inputs. Everything else under `node_modules/.vite`
|
|
12
|
+
still is. A program that asks the compiler about anything under
|
|
13
|
+
`node_modules/.vite`, or lists `node_modules`, is UNVERIFIED instead. A
|
|
14
|
+
check set up by an earlier alpha reports STALE ("input plan changed") once
|
|
15
|
+
after the upgrade.
|
|
16
|
+
- **Faster status.** `status --sync`, `detail` and `explain` no longer start
|
|
17
|
+
a second Node process on macOS and Linux, the YAML parser loads only for
|
|
18
|
+
pnpm projects, and each call to the observer no longer waits on a fresh
|
|
19
|
+
read of its status file. On the 800-file bench, warm `status --sync` adds
|
|
20
|
+
27 ms over cached status on a GitHub Linux runner (alpha.24: 120 ms) and
|
|
21
|
+
36 ms on a macOS runner (alpha.24: 137 ms).
|
|
22
|
+
- **Releases are benched.** The release smoke now benches the candidate
|
|
23
|
+
against the last release on macOS and Linux runners; a slower candidate,
|
|
24
|
+
or one over the warm-sync or run-overhead budget, blocks the release.
|
|
25
|
+
- Known limit: on the maintainer's Mac, which runs Microsoft Defender,
|
|
26
|
+
`redue run` still adds about 15-20% to a 2.7 s typecheck (3% on a GitHub
|
|
27
|
+
Linux runner).
|
|
28
|
+
|
|
29
|
+
## 0.1.0-alpha.24 — 2026-10-28
|
|
30
|
+
|
|
31
|
+
Checks keep working after a normal `npm install`, and agents that chain an
|
|
32
|
+
edit and a check get the same skip-or-record as a check run on its own.
|
|
33
|
+
|
|
34
|
+
- **Hard-linked installed files.** Without a pnpm store, any installed file
|
|
35
|
+
with a second hard link made a recorded run UNVERIFIED. esbuild's install
|
|
36
|
+
makes one (npm, not Yarn or Windows), so after an ordinary `npm install`
|
|
37
|
+
general-recipe and test checks in projects with esbuild stayed
|
|
38
|
+
UNVERIFIED. Such a file is now accepted when every one of its links is an
|
|
39
|
+
installed file the check observes; its link count is checked again before
|
|
40
|
+
every answer, so a link added later is noticed.
|
|
41
|
+
- **Checks inside a chain.** In a simple chain (`printf ... >> README.md &&
|
|
42
|
+
npm run lint`), the Claude Code hook rewrites each check to the new
|
|
43
|
+
`redue run CHECK --if-due`, which skips the check when its last pass still
|
|
44
|
+
applies at that moment and otherwise runs it through REDUE with the same
|
|
45
|
+
exit code. The hook changes the command without approving it, so your
|
|
46
|
+
permission rules still apply. Chains with subshells, substitutions,
|
|
47
|
+
here-documents, background jobs, shell keywords or builtins before a check
|
|
48
|
+
run untouched.
|
|
49
|
+
- The CLAUDE.md section asks agents to run each check as a command of its
|
|
50
|
+
own. Setups from earlier alphas are recognized and updated.
|
|
51
|
+
- The README's coverage numbers now come from a census that installs with
|
|
52
|
+
dependencies' install scripts on, as users do: of 120 public TypeScript
|
|
53
|
+
repositories, 86 installed and 30 have a qualifying check (typecheck 21,
|
|
54
|
+
lint 18, tests 2).
|
|
55
|
+
- Known limit, unchanged: a Vitest run writes under `node_modules/.vite`,
|
|
56
|
+
which makes a direct TypeScript typecheck STALE until it runs again.
|
|
57
|
+
|
|
58
|
+
- Contributors: census `--install-scripts`; the stranger test can install
|
|
59
|
+
from npm (`--npm`) and run a follow-up session that uses the hooks.
|
|
60
|
+
|
|
3
61
|
## 0.1.0-alpha.23 — 2026-10-26
|
|
4
62
|
|
|
5
63
|
One command to set up REDUE with Claude Code, and one to undo it.
|
package/README.md
CHANGED
|
@@ -62,25 +62,21 @@ To undo everything setup did: `redue remove`.
|
|
|
62
62
|
## What it covers today
|
|
63
63
|
|
|
64
64
|
In a census of 120 active public TypeScript repositories (October 2026, REDUE
|
|
65
|
-
at commit
|
|
66
|
-
|
|
67
|
-
same
|
|
65
|
+
at commit 0fcd19b, after 0.1.0-alpha.23), each installed as its users would,
|
|
66
|
+
with dependencies' install scripts on, 86 installed cleanly. A check counts
|
|
67
|
+
when two recorded runs on the same code both came out CURRENT on the same
|
|
68
|
+
inputs. 30 repositories had at least one such check:
|
|
68
69
|
|
|
69
70
|
| Check | Repositories | How |
|
|
70
71
|
|---|---|---|
|
|
71
|
-
| Typecheck |
|
|
72
|
+
| Typecheck | 21 | 15 by running the project's script with every Node process recorded ([general recipe](docs/user/node-script.md)); 6 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)) |
|
|
72
73
|
| Lint | 18 | general recipe |
|
|
73
74
|
| Tests | 2 | Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
|
|
74
75
|
|
|
75
76
|
Everything else stays UNVERIFIED, with a reason you can read in
|
|
76
77
|
`redue explain`. The usual ones: a script starts a program REDUE cannot see
|
|
77
78
|
into (esbuild, turbo, nx, oxlint's tsgolint, TypeScript 7's native compiler),
|
|
78
|
-
the project uses Bun, or a test suite writes into its own inputs.
|
|
79
|
-
meet right after setup: a Vitest run writes its results under
|
|
80
|
-
`node_modules/.vite`, which the direct TypeScript recipe counts as an
|
|
81
|
-
installed input, so the typecheck turns STALE until it runs again; and an
|
|
82
|
-
installed file with a second hard link (esbuild's install makes one) keeps
|
|
83
|
-
the test check UNVERIFIED.
|
|
79
|
+
the project uses Bun, or a test suite writes into its own inputs.
|
|
84
80
|
[Supported projects](docs/user/supported-projects.md) has the details.
|
|
85
81
|
|
|
86
82
|
## Measured with Claude Code
|
package/bin/redue.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decision-profile.mjs';
|
|
4
4
|
traceBase({...traceContext(),request:traceId(),work:'client'});
|
|
5
5
|
mark('client.loaded');
|
|
6
|
-
import {directCompiler,directQualification,directEnvironment,directInterpretation} from '../src/direct-typescript.mjs';
|
|
6
|
+
import {directCompiler,directQualification,directEnvironment,directInstalledInputs,directInterpretation} from '../src/direct-typescript.mjs';
|
|
7
7
|
import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
|
|
8
8
|
import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
|
|
9
9
|
import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
|
|
@@ -43,7 +43,8 @@ Commands:
|
|
|
43
43
|
status --sync Reconcile inputs and caller context before answering
|
|
44
44
|
explain [CHECK] [--json] Explain applicability (synchronized; may cost more than a rerun)
|
|
45
45
|
detail [CHECK] [--json] Alias for explain
|
|
46
|
-
run CHECK
|
|
46
|
+
run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
|
|
47
|
+
only when its last pass does not apply to the files as they are now
|
|
47
48
|
remove-state Stop and remove only this configuration's owned local state
|
|
48
49
|
|
|
49
50
|
No checks run in the background. Direct commands outside redue create no receipts.`;
|
|
@@ -96,6 +97,9 @@ if(command==='agent'){
|
|
|
96
97
|
if(!['init','start','stop','status','detail','explain','run','remove-state'].includes(command))
|
|
97
98
|
error(`unknown command ${command||'<none>'}; use --help`);
|
|
98
99
|
const json=args.includes('--json');
|
|
100
|
+
// run --if-due: run the check only when its last pass does not apply now.
|
|
101
|
+
const ifDue=command==='run'&&args.includes('--if-due');
|
|
102
|
+
if(ifDue)args.splice(args.indexOf('--if-due'),1);
|
|
99
103
|
const sync=args.includes('--sync');
|
|
100
104
|
const dryRun=args.includes('--dry-run');
|
|
101
105
|
const short=args.includes('--short');
|
|
@@ -333,7 +337,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
333
337
|
typeof check.cwd==='string'&&check.cwd&&!path.isAbsolute(check.cwd)&&!check.cwd.split('/').includes('..')?check.cwd:'.'),
|
|
334
338
|
cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
|
|
335
339
|
installedInputs:check.installedInputs||(
|
|
336
|
-
npmAutomatic||
|
|
340
|
+
npmDirect?[...directInstalledInputs]:npmAutomatic||testsDirect?['node_modules/**']:[]),
|
|
337
341
|
allowedExternalRoots:check.allowedExternalRoots||[],
|
|
338
342
|
probes:[...(check.probes||[]),...autoProbes]
|
|
339
343
|
.map((p,i)=>argv(p,`${check.name}.probes[${i}]`)),
|
|
@@ -388,6 +392,20 @@ const internal=command==='remove-state'?'uninstall':
|
|
|
388
392
|
// On macOS and Linux the engine runs in this process, which saves a Node startup
|
|
389
393
|
// on every run; its own handlers receive SIGINT and SIGTERM. Windows keeps a
|
|
390
394
|
// child engine because cancellation reaches it over IPC.
|
|
395
|
+
// Asked when this part of a command runs, after earlier parts may have
|
|
396
|
+
// changed files. REDUE_FORCE=1 runs it anyway.
|
|
397
|
+
if(ifDue&&process.env.REDUE_FORCE!=='1'){
|
|
398
|
+
const {verdict}=await import('../src/claude-hook.mjs');
|
|
399
|
+
const asked=spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,...(stateOverride?['--state-dir',stateOverride]:[]),
|
|
400
|
+
'status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],env:process.env,timeout:600_000});
|
|
401
|
+
let status=null;try{status=JSON.parse(asked.stdout);}catch{}
|
|
402
|
+
if(verdict(status,check)){
|
|
403
|
+
const row=status.checks.find(item=>item.name===check);
|
|
404
|
+
if(json)console.log(JSON.stringify({check,skipped:true,result:'PASS',run_id:row.invocation.runId}));
|
|
405
|
+
else console.error(`REDUE: ${check} passed on these exact inputs (run ${row.invocation.runId}); not run again. REDUE_FORCE=1 runs it anyway.`);
|
|
406
|
+
await exit(0);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
391
409
|
if(command==='run'&&process.platform!=='win32'){
|
|
392
410
|
process.argv=[process.argv[0],engine,runtimeFile,internal,check,...(!json?['--human']:[])];
|
|
393
411
|
await import(pathToFileURL(engine).href);
|
|
@@ -406,6 +424,15 @@ if(command==='run'&&process.platform!=='win32'){
|
|
|
406
424
|
process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
|
|
407
425
|
if(outcome.error)error(outcome.error.message);
|
|
408
426
|
process.exit(outcome.status??(outcome.signal?128+os.constants.signals[outcome.signal]:1));
|
|
427
|
+
}else if(internal==='sync'&&process.platform!=='win32'){
|
|
428
|
+
// As for run, the synchronized status engine runs in this process.
|
|
429
|
+
process.argv=[process.argv[0],engine,runtimeFile,internal,'','--value'];
|
|
430
|
+
mark('client.engine_submit',{inProcess:true});
|
|
431
|
+
const synchronized=await import(pathToFileURL(engine).href);
|
|
432
|
+
await synchronized.finished;
|
|
433
|
+
mark('client.engine_complete',{exit:process.exitCode??0});
|
|
434
|
+
if(synchronized.syncValue)showSynchronized(synchronized.syncValue);
|
|
435
|
+
await exit(process.exitCode??0);
|
|
409
436
|
}else{
|
|
410
437
|
mark('client.engine_submit');
|
|
411
438
|
const result=spawnSync(process.execPath,[engine,runtimeFile,internal,...(check?[check]:[])],
|
|
@@ -416,10 +443,13 @@ if(command==='run'&&process.platform!=='win32'){
|
|
|
416
443
|
if(result.stdout){
|
|
417
444
|
if(['status','detail','explain'].includes(command)){
|
|
418
445
|
let value;try{value=JSON.parse(result.stdout);}catch{error('status response could not be read; applicability is unknown');}
|
|
419
|
-
|
|
420
|
-
if(json)console.log(JSON.stringify(value));
|
|
421
|
-
else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
|
|
446
|
+
showSynchronized(value);
|
|
422
447
|
}else printLifecycle(result.stdout,command,json);
|
|
423
448
|
}
|
|
424
449
|
await exit(result.status??1);
|
|
425
450
|
}
|
|
451
|
+
function showSynchronized(value){
|
|
452
|
+
if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
|
|
453
|
+
if(json)console.log(JSON.stringify(value));
|
|
454
|
+
else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
|
|
455
|
+
}
|
package/docs/user/agents.md
CHANGED
|
@@ -85,7 +85,26 @@ desktop app or another shell still runs the right REDUE; run setup again
|
|
|
85
85
|
after moving or upgrading Node. `redue agent doctor` reports a hook whose
|
|
86
86
|
paths no longer exist. When the hook runs a check through REDUE for the
|
|
87
87
|
agent, it likewise writes plain `redue run` only if its own PATH finds this
|
|
88
|
-
install, else the absolute form.
|
|
88
|
+
install, else the absolute form.
|
|
89
|
+
|
|
90
|
+
Agents often chain an edit and a check (`printf … >> README.md && npm run
|
|
91
|
+
lint`). In a simple chain (commands joined by `&&`, `||` or `;`, with no
|
|
92
|
+
subshell, here-document, command substitution, background job or shell
|
|
93
|
+
keyword) where every part before a check is a program on PATH (not a
|
|
94
|
+
builtin such as `cd` or `export`, a shell function such as `nvm`, or a bare
|
|
95
|
+
assignment), the hook rewrites each check to `redue run CHECK --if-due`. That
|
|
96
|
+
command decides when its turn comes, after the earlier parts may have
|
|
97
|
+
changed files: it skips the check (exit 0, and says so) when its last pass
|
|
98
|
+
still applies, and otherwise runs it through REDUE with the exit code a plain
|
|
99
|
+
run gives. A script that runs several checks becomes `{ redue run a --if-due
|
|
100
|
+
&& redue run b --if-due; }`. The rest of the chain is unchanged. The hook
|
|
101
|
+
rewrites only while the observer is healthy and knows each check. It changes
|
|
102
|
+
such a command without approving it, so your permission rules apply to the
|
|
103
|
+
rewritten command; a rule that allows only `npm run lint` will ask about
|
|
104
|
+
`redue run lint --if-due`. Anything more complex runs untouched. Known
|
|
105
|
+
limit: if REDUE cannot start the check when its turn comes (another
|
|
106
|
+
`redue run` holds the project's run lock), that part exits with REDUE's
|
|
107
|
+
error instead of running the check. Other settings in the file are kept,
|
|
89
108
|
but the file is rewritten as 2-space JSON. `.claude/settings.json` is not touched.
|
|
90
109
|
`redue agent remove claude --apply` removes only the entries REDUE added.
|
|
91
110
|
|
|
@@ -141,6 +141,14 @@ changes invalidate or withdraw applicability normally. Installed files are obser
|
|
|
141
141
|
by identity, so reinstalling identical packages or restoring an installed file's
|
|
142
142
|
bytes also needs one rerun (see [configuration](configuration.md)).
|
|
143
143
|
|
|
144
|
+
Vitest writes its results under `node_modules/.vite/vitest` at every run.
|
|
145
|
+
They are not inputs, so a test run leaves the typecheck CURRENT. Everything
|
|
146
|
+
else under `node_modules/.vite` is still an input. A program that asks about
|
|
147
|
+
anything under `node_modules/.vite` (by any spelling or through a link that
|
|
148
|
+
resolves there), or lists `node_modules`, is UNVERIFIED: REDUE does not
|
|
149
|
+
observe what Vitest writes there. A check configured before 0.1.0-alpha.25 gets the new inputs on
|
|
150
|
+
its own and reports STALE once after the upgrade.
|
|
151
|
+
|
|
144
152
|
A root tsconfig with `incremental: true` is supported. tsc reads and writes
|
|
145
153
|
its build-info file (`tsBuildInfoFile`, or the default next to the config) on
|
|
146
154
|
each run, so REDUE leaves that file out of the inputs and lets the run write
|
|
@@ -90,8 +90,12 @@ kept but marked UNVERIFIED, with the reason, when the run:
|
|
|
90
90
|
`--expose-internals`: the recorder cannot follow those;
|
|
91
91
|
- had an installed file with another hard link, other than a link into the
|
|
92
92
|
pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
|
|
93
|
-
default on Linux)
|
|
94
|
-
|
|
93
|
+
default on Linux) or, without a pnpm store, links that are all installed
|
|
94
|
+
files the check observes (esbuild's install links its binary into its
|
|
95
|
+
platform package). The link count of each such file is checked again
|
|
96
|
+
before every answer, so a link added later is noticed. A change through a
|
|
97
|
+
link elsewhere, such as Yarn's hardlinks modes, is not seen. This applies
|
|
98
|
+
to the [general recipe](node-script.md) too.
|
|
95
99
|
|
|
96
100
|
Lookups that can be asked again become run facts, checked before every CURRENT
|
|
97
101
|
answer: whether a path outside the checkout exists, where a link resolves, the
|
package/package.json
CHANGED
|
@@ -183,23 +183,28 @@ const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'&&row.kind!=='lint').m
|
|
|
183
183
|
rows.filter(row=>row.kind==='test').map(row=>row.form),rows.filter(row=>row.kind==='lint').map(row=>row.form)];
|
|
184
184
|
const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
|
|
185
185
|
const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
|
|
186
|
-
|
|
186
|
+
// `own`: the line asking for each check as a command of its own, added in
|
|
187
|
+
// alpha.24; the alpha.15-alpha.23 wording without it is still recognized.
|
|
188
|
+
function blockFor(host,rows=[],own=true){
|
|
187
189
|
const [typechecks,tests,lints]=commandsOf(rows);
|
|
188
190
|
if(!rows.length)return fence(host,skillPointer(host));
|
|
191
|
+
const ownLine=own?`Run each check as a command of its own, not chained after an edit or another\n`+
|
|
192
|
+
`command.\n`:'';
|
|
189
193
|
if(lints.length)return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
190
194
|
(tests.length?`Run the unit tests with ${quoted(tests)}.\n`:'')+`Lint with ${quoted(lints)}.\n`+
|
|
191
195
|
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
192
196
|
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
193
|
-
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n
|
|
197
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`+ownLine);
|
|
194
198
|
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
|
|
195
199
|
`edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
|
|
196
200
|
`applies to these exact files, a REDUE hook skips the run and says so. That\n`+
|
|
197
|
-
|
|
201
|
+
(own?`skip counts as a pass. Run it as a command of its own, not chained after an\n`+
|
|
202
|
+
`edit or another command.\n`:`skip counts as a pass.\n`));
|
|
198
203
|
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
199
204
|
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
200
205
|
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
201
206
|
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
202
|
-
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n
|
|
207
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`+ownLine);
|
|
203
208
|
}
|
|
204
209
|
// The alpha.12-alpha.14 wording, recognized so setup can replace it.
|
|
205
210
|
function earlierBlock(host,rows=[]){
|
|
@@ -240,7 +245,8 @@ function knownBlock(host,relative,block){
|
|
|
240
245
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
241
246
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
|
|
242
247
|
...forms(`Lint with ${list}\\.\\n`).map(form=>({kind:'lint',form}))];
|
|
243
|
-
return block===blockFor(host,rows)||block===
|
|
248
|
+
return block===blockFor(host,rows)||block===blockFor(host,rows,false)||block===earlierBlock(host,rows)||
|
|
249
|
+
priorBlocks(host,relative).includes(block);
|
|
244
250
|
}
|
|
245
251
|
// The policy with the exact commands for this config, so an agent never builds
|
|
246
252
|
// a --config form the hook would not recognize. Claude Code gets the typecheck
|
package/src/claude-hook.mjs
CHANGED
|
@@ -276,6 +276,74 @@ function matched(command,config,configFile,dir='.',scripts={}){
|
|
|
276
276
|
// A script form of compiler checks is checked against the script first.
|
|
277
277
|
return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
|
|
278
278
|
}
|
|
279
|
+
// A simple chain: commands joined by &&, || or ;, outside quotes, with no
|
|
280
|
+
// line break, subshell, command or process substitution, backtick,
|
|
281
|
+
// here-document or background job. [{separator, text}] with the separator
|
|
282
|
+
// before each part ('' for the first), or null.
|
|
283
|
+
export function chainParts(command){
|
|
284
|
+
if(typeof command!=='string'||/[\r\n`]|\$\(|[<>]\(|<<|[()]/.test(command))return null;
|
|
285
|
+
const parts=[];let quote=null,start=0,separator='';
|
|
286
|
+
for(let i=0;i<command.length;i++){
|
|
287
|
+
const c=command[i];
|
|
288
|
+
if(quote){if(c===quote)quote=null;else if(c==='\\'&"e==='"')i++;continue;}
|
|
289
|
+
if(c==='\\'){i++;continue;}
|
|
290
|
+
if(c==="'"||c==='"'){quote=c;continue;}
|
|
291
|
+
const two=command.slice(i,i+2);
|
|
292
|
+
if(two==='&&'||two==='||'||c===';'){
|
|
293
|
+
parts.push({separator,text:command.slice(start,i)});separator=c===';'?';':two;
|
|
294
|
+
start=i+(c===';'?1:2);if(c!==';')i++;continue;}
|
|
295
|
+
// A lone & runs a job in the background; >&, &> and |& redirect.
|
|
296
|
+
if(c==='&'&&command[i-1]!=='>'&&command[i+1]!=='>')return null;
|
|
297
|
+
if(two==='|&')return null;
|
|
298
|
+
}
|
|
299
|
+
if(quote)return null;
|
|
300
|
+
parts.push({separator,text:command.slice(start)});
|
|
301
|
+
return parts.every(part=>part.text.trim())?parts:null;
|
|
302
|
+
}
|
|
303
|
+
// A simple chain with each check in it run through REDUE when its turn comes:
|
|
304
|
+
// `redue run CHECK --if-due` decides then, after earlier parts may have
|
|
305
|
+
// changed files. Null when the command is not such a chain or runs no check.
|
|
306
|
+
// The rest of the chain is unchanged, and so is its output and exit status
|
|
307
|
+
// when a check runs.
|
|
308
|
+
// Shell builtins that change the shell for the parts after them.
|
|
309
|
+
const shellBuiltins=new Set(['cd','pushd','popd','export','unset','set','source','.','eval','exec','alias','unalias','shopt',
|
|
310
|
+
'ulimit','umask','trap','builtin','command','declare','typeset','local','readonly','hash','enable','let','read','wait']);
|
|
311
|
+
// A part that cannot change the directory or environment of the parts after
|
|
312
|
+
// it: a program found on PATH (or by path), not a builtin, function or bare
|
|
313
|
+
// assignment.
|
|
314
|
+
function plainProgram(body){
|
|
315
|
+
const words=body.split(/\s+/).filter(Boolean);
|
|
316
|
+
while(words.length&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))words.shift();
|
|
317
|
+
const word=words[0];
|
|
318
|
+
return Boolean(word)&&!shellBuiltins.has(word)&&/^[A-Za-z0-9_./+@-]+$/.test(word)&&(word.includes('/')||Boolean(findExecutable(word)));
|
|
319
|
+
}
|
|
320
|
+
// A simple chain with each check in it run through REDUE when its turn comes:
|
|
321
|
+
// `redue run CHECK --if-due` decides then, after earlier parts may have
|
|
322
|
+
// changed files. {command, names}, or null when the command is not such a
|
|
323
|
+
// chain or runs no check. The rest of the chain is unchanged, and so are its
|
|
324
|
+
// output and exit status when a check runs. Every part before a check must be
|
|
325
|
+
// a plain program, so the check runs where and as it would have.
|
|
326
|
+
export function chainCommand(command,config,configFile,dir='.',scripts={},invoke='redue',runConfig=configFile,projectRoot=null){
|
|
327
|
+
const parts=chainParts(command);
|
|
328
|
+
if(!parts||parts.length<2)return null;
|
|
329
|
+
const redue=redueFor(runConfig)?.replace(/^redue/,()=>invoke);
|
|
330
|
+
if(!redue)return null;
|
|
331
|
+
const names=[];let plain=true;
|
|
332
|
+
const text=parts.map(({separator,text})=>{
|
|
333
|
+
const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
|
|
334
|
+
const match=matched(body,config,configFile,dir,scripts),split_=split(body);
|
|
335
|
+
// A compiler script is its checks in the order of its runs.
|
|
336
|
+
const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
|
|
337
|
+
if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
|
|
338
|
+
plain&&=plainProgram(body);return separator+text;}
|
|
339
|
+
if(!plain)return null;
|
|
340
|
+
names.push(...checks);
|
|
341
|
+
// Grouped, so a separator before it applies to all its runs.
|
|
342
|
+
const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
|
|
343
|
+
return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
|
|
344
|
+
});
|
|
345
|
+
return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
|
|
346
|
+
}
|
|
279
347
|
export function matchCheck(command,config,configFile,dir,scripts){
|
|
280
348
|
const match=matched(command,config,configFile,dir,scripts);
|
|
281
349
|
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
@@ -327,7 +395,8 @@ export function verdict(status,name,rest=null){
|
|
|
327
395
|
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
328
396
|
}
|
|
329
397
|
|
|
330
|
-
|
|
398
|
+
// The status answer, synchronized unless `sync` is false (the cached one).
|
|
399
|
+
function synchronizedStatus(entry,config,root,timeout,sync=true){
|
|
331
400
|
return new Promise(resolve=>{
|
|
332
401
|
let child,out='',done=false;
|
|
333
402
|
const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
|
|
@@ -337,7 +406,7 @@ function synchronizedStatus(entry,config,root,timeout){
|
|
|
337
406
|
// Claude Code ends a hook that outlives its own timeout; end the status call with it.
|
|
338
407
|
const stop=()=>finish(null);process.once('SIGTERM',stop);
|
|
339
408
|
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
340
|
-
try{child=spawn(process.execPath,[entry,'--config',config,'status'
|
|
409
|
+
try{child=spawn(process.execPath,[entry,'--config',config,'status',...(sync?['--sync']:[]),'--json'],
|
|
341
410
|
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
342
411
|
catch{return finish(null);}
|
|
343
412
|
child.on('error',()=>finish(null));
|
|
@@ -402,8 +471,27 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
402
471
|
if(!entered)return null;
|
|
403
472
|
let scripts={};
|
|
404
473
|
if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
|
|
474
|
+
// The configured config path as the command's directory sees it.
|
|
475
|
+
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
476
|
+
const configPath=!entered.prefix?record.config:
|
|
477
|
+
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
478
|
+
// A simple chain: each check in it runs through REDUE when its turn comes,
|
|
479
|
+
// after the parts before it (an edit) have run. The command is changed,
|
|
480
|
+
// not approved: Claude Code's permission rules apply to it as rewritten.
|
|
481
|
+
// A chain with no check of its own (a leading fan-out) goes on below.
|
|
482
|
+
// Rewritten only on a healthy answer: without an observer, redue run
|
|
483
|
+
// cannot run the check, and the chain would not run as written.
|
|
484
|
+
// The observer's cached health is enough here: each check asks again when it runs.
|
|
485
|
+
const chained=chainCommand(entered.command,parsed,record.config,entered.dir,scripts,invoke??redueInvocation(entry),configPath,project.projectRoot);
|
|
486
|
+
if(chained){
|
|
487
|
+
const cached=await synchronizedStatus(entry,config,root,timeout??5000,false);
|
|
488
|
+
return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
|
|
489
|
+
{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
|
|
490
|
+
}
|
|
405
491
|
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
406
492
|
if(!match)return null;
|
|
493
|
+
// A check followed by more than a simple chain runs untouched.
|
|
494
|
+
if(match.rest!==null&&!match.fanout)return null;
|
|
407
495
|
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
408
496
|
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
409
497
|
if(match.fanout){
|
|
@@ -413,10 +501,6 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
413
501
|
reason=fanoutVerdict(status,names,match.rest);
|
|
414
502
|
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
415
503
|
}
|
|
416
|
-
// The configured config path as the command's directory sees it.
|
|
417
|
-
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
418
|
-
const configPath=!entered.prefix?record.config:
|
|
419
|
-
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
420
504
|
// A compiler script: its checks in the order of its runs, or nothing when
|
|
421
505
|
// they no longer correspond. One run is an ordinary check. A chain is
|
|
422
506
|
// skipped when every run is CURRENT, else the runs that are not run through
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ 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
6
|
import {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
7
|
+
import {patternMatcher} from './glob.mjs';
|
|
8
|
+
import {observedBy} from './installed-observation.mjs';
|
|
7
9
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
8
10
|
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
9
11
|
import path from 'node:path';
|
|
@@ -53,7 +55,17 @@ function publishedProbeDeadline() {
|
|
|
53
55
|
try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
|
|
54
56
|
catch{return 0;}
|
|
55
57
|
}
|
|
56
|
-
|
|
58
|
+
// The wait for a decision, read when it starts: the base at once, and the
|
|
59
|
+
// published deadline when status.json has been read. That file is rewritten
|
|
60
|
+
// at every decision, and a first read of a fresh file is slow where a scanner
|
|
61
|
+
// checks files as they are opened, so it is read off the main thread while
|
|
62
|
+
// the call runs. On Windows it is read before the call, as before: the
|
|
63
|
+
// observer replaces it during the call, and a replacement fails there while a
|
|
64
|
+
// reader holds the file open. An override is exact.
|
|
65
|
+
const decisionWait=override=>override?{timeout:Number(override),deadline:null}:
|
|
66
|
+
process.platform==='win32'?{timeout:Math.max(decisionBaseMs,publishedProbeDeadline()+10000),deadline:null}:{timeout:decisionBaseMs,
|
|
67
|
+
deadline:fs.promises.readFile(path.join(state,'status.json'),'utf8')
|
|
68
|
+
.then(text=>Math.max(decisionBaseMs,publishedDeadline(JSON.parse(text).probe_deadline_ms)+10000),()=>decisionBaseMs)};
|
|
57
69
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
58
70
|
function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
|
|
59
71
|
return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
@@ -118,20 +130,26 @@ function owner(){
|
|
|
118
130
|
atomicJson(ownerFile,expected);
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
|
-
|
|
133
|
+
// deadline, when given, resolves to the whole wait in ms, which replaces
|
|
134
|
+
// timeout when it is longer.
|
|
135
|
+
function call(message,timeout=5000,deadline=null) {
|
|
122
136
|
return new Promise((resolve,reject)=>{
|
|
123
137
|
let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
|
|
124
|
-
const
|
|
138
|
+
const started=Date.now(),expire=()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));};
|
|
139
|
+
let timer=setTimeout(expire,timeout);
|
|
140
|
+
deadline?.then(total=>{if(done||total<=timeout)return;mark('client.control_deadline',{deadlineMs:total});
|
|
141
|
+
clearTimeout(timer);timer=setTimeout(expire,Math.max(0,total-(Date.now()-started)));});
|
|
125
142
|
client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
|
|
126
143
|
client.on('data',chunk=>data+=chunk);
|
|
127
144
|
client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
|
|
128
145
|
try{resolve(JSON.parse(data));}catch(e){reject(e);}});
|
|
129
|
-
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
|
|
146
|
+
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);done=true;reject(e);});
|
|
130
147
|
});
|
|
131
148
|
}
|
|
132
|
-
async function decisionCall(message,timeout) {
|
|
133
|
-
|
|
134
|
-
|
|
149
|
+
async function decisionCall(message,{timeout,deadline}) {
|
|
150
|
+
let total=timeout;deadline?.then(value=>{total=Math.max(total,value);});
|
|
151
|
+
const started=Date.now(),remaining=()=>Math.max(1,total-(Date.now()-started));
|
|
152
|
+
try{return await call(message,timeout,deadline);}
|
|
135
153
|
catch(error){
|
|
136
154
|
if(process.platform!=='win32'||!['ENOENT','ECONNREFUSED','ECONNRESET'].includes(error.code))throw error;
|
|
137
155
|
const pidFile=path.join(state,'observer.lock','pid');
|
|
@@ -231,8 +249,7 @@ async function execute() {
|
|
|
231
249
|
const environmentBefore=contextHashes()[name];
|
|
232
250
|
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
233
251
|
timing('cli.context_before',contextStarted);
|
|
234
|
-
const
|
|
235
|
-
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
|
|
252
|
+
const captureWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
236
253
|
let before=null,after=null;
|
|
237
254
|
// Expensive state probes can take seconds. Keep the filesystem checkpoint
|
|
238
255
|
// adjacent to process launch so a late notification from earlier work
|
|
@@ -242,7 +259,7 @@ async function execute() {
|
|
|
242
259
|
let probeBefore=callerCertificate?null:probeHash(probePlan);
|
|
243
260
|
const startSnapshotStarted=performance.now();
|
|
244
261
|
try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
245
|
-
...(callerCertificate?{runCertificate:1}:{})},
|
|
262
|
+
...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
|
|
246
263
|
catch(error){captureIssues.push('start observation unavailable'+
|
|
247
264
|
(typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
|
|
248
265
|
timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
|
|
@@ -305,7 +322,7 @@ async function execute() {
|
|
|
305
322
|
await new Promise(resolve=>setTimeout(resolve,100));
|
|
306
323
|
const prelaunchStarted=performance.now();
|
|
307
324
|
try{before=await call({action:'prelaunch',name,
|
|
308
|
-
contextHashes:contextHashes()},Math.min(
|
|
325
|
+
contextHashes:contextHashes()},Math.min(captureWait.timeout,5000));}
|
|
309
326
|
catch{before=null;captureIssues.push('prelaunch observation unavailable');}
|
|
310
327
|
timing('cli.prelaunch_checkpoint',prelaunchStarted,
|
|
311
328
|
{available:before?.snapshots?.[name]?1:0});
|
|
@@ -377,8 +394,13 @@ async function execute() {
|
|
|
377
394
|
// The pnpm executable reads its configuration natively: kept by content.
|
|
378
395
|
if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
|
|
379
396
|
catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
|
|
380
|
-
|
|
397
|
+
// Hard links count only where the check observes them.
|
|
398
|
+
// As the index matches: source patterns, or additive (installed) ones.
|
|
399
|
+
const source=patternMatcher(plan.sourcePatterns||plan.patterns||[]),additive=patternMatcher(plan.additivePatterns||[]);
|
|
400
|
+
const linkFacts=[],observed=observedBy(root,rel=>source(rel)||additive(rel));
|
|
401
|
+
const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'),{observed,facts:linkFacts});
|
|
381
402
|
if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
|
|
403
|
+
else runFacts.push(...linkFacts);
|
|
382
404
|
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
383
405
|
}
|
|
384
406
|
// The daemon reads the recording from owned state; control requests are small.
|
|
@@ -386,11 +408,12 @@ async function execute() {
|
|
|
386
408
|
if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
|
|
387
409
|
const endSnapshotStarted=performance.now();
|
|
388
410
|
if(!unobservedDirectRun){
|
|
411
|
+
// The start checkpoint's probe may have raised the deadline.
|
|
412
|
+
const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
389
413
|
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
390
414
|
...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
391
415
|
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
392
|
-
|
|
393
|
-
Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
|
|
416
|
+
endWait.timeout,endWait.deadline);}
|
|
394
417
|
catch{captureIssues.push('end observation unavailable');}
|
|
395
418
|
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
396
419
|
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
@@ -594,7 +617,7 @@ async function main(){
|
|
|
594
617
|
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
595
618
|
}
|
|
596
619
|
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
597
|
-
|
|
620
|
+
decisionWait(process.env.VSTATE_SYNC_TIMEOUT_MS));
|
|
598
621
|
// A client upgrade can encounter an older running observer. Its successful
|
|
599
622
|
// control response does not establish that it evaluated the latest receipt.
|
|
600
623
|
if(value.receipt_revision===null){
|
|
@@ -608,7 +631,9 @@ async function main(){
|
|
|
608
631
|
}}
|
|
609
632
|
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
610
633
|
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
611
|
-
|
|
634
|
+
// In the front's process (bin/redue.mjs), the value is handed back.
|
|
635
|
+
if(action==='sync'&&process.argv.includes('--value'))syncValue=value;
|
|
636
|
+
else if(action==='sync')console.log(JSON.stringify(value));
|
|
612
637
|
else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
|
|
613
638
|
return;
|
|
614
639
|
}
|
|
@@ -616,4 +641,5 @@ async function main(){
|
|
|
616
641
|
if(action==='run'){await execute();return;}
|
|
617
642
|
throw Error(`unknown action ${action}`);
|
|
618
643
|
}
|
|
619
|
-
|
|
644
|
+
export let syncValue=null;
|
|
645
|
+
export const finished=main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
|
package/src/daemon.mjs
CHANGED
|
@@ -269,6 +269,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
269
269
|
'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
|
|
270
270
|
'typescript-input-list-unavailable':'TypeScript input listing did not complete',
|
|
271
271
|
'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
|
|
272
|
+
'typescript-vite-cache-consumed':'TypeScript asks about node_modules/.vite or lists node_modules; Vitest rewrites files there and this recipe does not observe them',
|
|
272
273
|
'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
|
|
273
274
|
'npm-installation-unobservable':'npm installation contains unobservable entries',
|
|
274
275
|
'npm-configuration-unsupported':'npm script configuration needs review',
|
|
@@ -7,7 +7,7 @@ import {createHash} from 'node:crypto';
|
|
|
7
7
|
import {inflateSync} from 'node:zlib';
|
|
8
8
|
import {fileURLToPath} from 'node:url';
|
|
9
9
|
import {timing} from './decision-profile.mjs';
|
|
10
|
-
import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
|
|
10
|
+
import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue,viteCacheQuestion} from './direct-typescript.mjs';
|
|
11
11
|
import {withinPath} from './path-identity.mjs';
|
|
12
12
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
13
13
|
// Whether a direct run is recorded at all: on macOS and Linux, always, so its
|
|
@@ -158,6 +158,8 @@ export function runRecordIssue(record,certificate,runFacts=null) {
|
|
|
158
158
|
if(kept.issue)return kept.issue;
|
|
159
159
|
if(record.main!==compiler.entry||!Array.isArray(record.node)||
|
|
160
160
|
directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
|
|
161
|
+
const vite=viteCacheQuestion(compiler.root,kept.facts);
|
|
162
|
+
if(vite)return `the compiler asked about ${vite}, which Vitest rewrites and REDUE does not observe`;
|
|
161
163
|
const inCompiler=withinCompiler(compiler);
|
|
162
164
|
const facts=new Map();
|
|
163
165
|
for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {directProject,directInterpretation,directContextHash,directProbeOutput} from './direct-typescript.mjs';
|
|
2
|
+
import {directProject,directInterpretation,directContextHash,directProbeOutput,viteCacheQuestion} from './direct-typescript.mjs';
|
|
3
3
|
import {typeScriptInputFacts} from './typescript-input-facts.mjs';
|
|
4
4
|
import {contextOnly,probeResult} from './probe-result.mjs';
|
|
5
5
|
import {realObservedPath} from './path-identity.mjs';
|
|
@@ -18,6 +18,9 @@ try{
|
|
|
18
18
|
const contextHash=directContextHash(compiler,context.node);
|
|
19
19
|
if(!contextOnly(contextHash)){
|
|
20
20
|
const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
|
|
21
|
+
// Captured questions include every config and program file read.
|
|
22
|
+
if(viteCacheQuestion(root,queries||[...configs,...files].map(([file])=>['readFile',file])))
|
|
23
|
+
throw Object.assign(Error('typescript-vite-cache-consumed'),{code:'typescript-vite-cache-consumed'});
|
|
21
24
|
const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
|
|
22
25
|
probeResult(directProbeOutput(layout),contextHash,queries,layout);
|
|
23
26
|
}
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {createHash} from 'node:crypto';
|
|
5
5
|
import {createRequire} from 'node:module';
|
|
6
|
-
import {racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
|
|
6
|
+
import {normalizedPath,racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
|
|
7
7
|
import {cachedTarball,lockedPackage,matchTarball,npmCacheDir,registryPackage,tarballFiles} from './npm-integrity.mjs';
|
|
8
8
|
import {runConfig,tscRun} from './typescript-invocation.mjs';
|
|
9
9
|
export const directInterpretation='npm-direct-typescript@1';
|
|
@@ -79,7 +79,7 @@ export function directCompiler(root,{verify=true}={}){
|
|
|
79
79
|
for(const [name,file] of files.sort(([a],[b])=>a<b?-1:a>b?1:0))digest.update(name+'\0'+hash(fs.readFileSync(file))+'\0');
|
|
80
80
|
if(digest.digest('hex')!==spec.digest)fail('direct-typescript-implementation-unreviewed');
|
|
81
81
|
}
|
|
82
|
-
return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
|
|
82
|
+
return {root,tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
|
|
83
83
|
}
|
|
84
84
|
// A TypeScript 5.x or 6.0 the lockfile records: the installed package must be
|
|
85
85
|
// the registry's `typescript` at that version, holding exactly the files of
|
|
@@ -115,7 +115,7 @@ function lockedCompiler(root,tsRoot,version,verify){
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
const entry=fs.existsSync(path.join(tsRoot,'lib','_tsc.js'))?'lib/_tsc.js':'lib/tsc.js';
|
|
118
|
-
return {tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
|
|
118
|
+
return {root,tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
|
|
119
119
|
}
|
|
120
120
|
// The direct probe's output: root, script, configuration and program files with
|
|
121
121
|
// their byte digests, and the execution context. A recorded run rebuilds it from
|
|
@@ -162,6 +162,26 @@ export function directContextHash(compiler,node){
|
|
|
162
162
|
return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
|
|
163
163
|
entry:compiler.entry,node}));
|
|
164
164
|
}
|
|
165
|
+
// Vitest writes its results under node_modules/.vite/vitest at every run. The
|
|
166
|
+
// recipe does not observe them. A compiler that asks about anything under
|
|
167
|
+
// node_modules/.vite, is told a path there (realpath), or lists node_modules,
|
|
168
|
+
// could see them, so its result is UNVERIFIED.
|
|
169
|
+
export const directInstalledInputs=Object.freeze(['node_modules/**','!node_modules/.vite/vitest/**']);
|
|
170
|
+
const listings=new Set(['readdir','listing','directories','entries','readDirectory']);
|
|
171
|
+
// The first path of [kind, file, answer] questions that does, or null. Kinds
|
|
172
|
+
// are a recorded run's (readdir) or a probe's. Compared without case: a
|
|
173
|
+
// case-insensitive mount reaches .vite under any spelling.
|
|
174
|
+
export function viteCacheQuestion(root,questions){
|
|
175
|
+
const fold=value=>normalizedPath(value).toLowerCase();
|
|
176
|
+
const modules=fold(path.join(root,'node_modules')),cache=path.join(modules,'.vite');
|
|
177
|
+
for(const [kind,file,answer] of questions){
|
|
178
|
+
if(typeof file!=='string')continue;
|
|
179
|
+
const at=fold(file);
|
|
180
|
+
if(withinPath(at,cache)||at===modules&&listings.has(kind))return file;
|
|
181
|
+
if(kind==='realpath'&&typeof answer==='string'&&withinPath(fold(answer),cache))return answer;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
165
185
|
// The compiler for one check run {script, part, args}: script null for a
|
|
166
186
|
// check init proposed without one. The run must pass --noEmit and name a
|
|
167
187
|
// config inside the root.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Whether a check observes an installed file: the file is in the checkout
|
|
2
|
+
// and matched by the check's patterns, and a link it goes through that leads
|
|
3
|
+
// elsewhere in the checkout leads to a matched path too. A recorded run's
|
|
4
|
+
// hard-linked installed files are accepted only when every link is one the
|
|
5
|
+
// check observes, so a pattern that leaves a folder out (a runner cache in
|
|
6
|
+
// node_modules) cannot hide one.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {observedRelative} from './path-identity.mjs';
|
|
10
|
+
|
|
11
|
+
// `matches(rel)`: the check's pattern matcher over checkout-relative paths.
|
|
12
|
+
// A link that leads outside the checkout is the plan's to resolve (a
|
|
13
|
+
// permitted root or a refusal).
|
|
14
|
+
export function observedBy(root,matches){
|
|
15
|
+
let realRoot=root;try{realRoot=fs.realpathSync(root);}catch{}
|
|
16
|
+
return file=>{
|
|
17
|
+
const logical=observedRelative(root,path.resolve(file));
|
|
18
|
+
if(logical===null||!matches(logical))return false;
|
|
19
|
+
let real;try{real=fs.realpathSync(file);}catch{return false;}
|
|
20
|
+
const physical=observedRelative(realRoot,real);
|
|
21
|
+
return physical===null||physical===logical||matches(physical);
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/node-script.mjs
CHANGED
|
@@ -35,12 +35,15 @@ const modulesDirs=dirs=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)]
|
|
|
35
35
|
// Inputs: the whole checkout but node_modules, .git and runner caches. Every
|
|
36
36
|
// node_modules of the workspace is observed as installed files, without the
|
|
37
37
|
// caches tools keep there.
|
|
38
|
+
// Runner caches at the top of a node_modules, which Vite, Vitest and build
|
|
39
|
+
// loaders rewrite as they run: not installed inputs.
|
|
40
|
+
export const installedRunnerCaches=['.cache','.vite','.vite-temp','.vitest','.vitest-cache'];
|
|
38
41
|
export function scriptCheckInputs(dirs=[]){
|
|
39
42
|
const modules=modulesDirs(dirs);
|
|
40
43
|
return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**',...installState.map(file=>`!${file}`),
|
|
41
44
|
...scriptCaches.map(pattern=>`!${pattern}`)],
|
|
42
|
-
installedInputs:[...modules.flatMap(at=>[`${at}
|
|
43
|
-
`!${at}
|
|
45
|
+
installedInputs:[...modules.flatMap(at=>[`${at}/**`,...installedRunnerCaches.map(dir=>`!${at}/${dir}/**`),
|
|
46
|
+
`!${at}/**/*.tsbuildinfo`]),...installState]};
|
|
44
47
|
}
|
|
45
48
|
// Installed files a tool rewrites during a run. pnpm writes its workspace
|
|
46
49
|
// state in the root node_modules at the first run after an install, through
|
package/src/pnpm-inputs.mjs
CHANGED
|
@@ -8,7 +8,6 @@ import path from 'node:path';
|
|
|
8
8
|
import {createRequire} from 'node:module';
|
|
9
9
|
import {execFileSync,spawnSync} from 'node:child_process';
|
|
10
10
|
import micromatch,{patternMatcher} from './glob.mjs';
|
|
11
|
-
import YAML from 'yaml';
|
|
12
11
|
import {resolveLinks} from './installed-inputs.mjs';
|
|
13
12
|
import {withinPath as within,realObservedPath,samePath} from './path-identity.mjs';
|
|
14
13
|
import {timing} from './decision-profile.mjs';
|
|
@@ -18,7 +17,11 @@ import {runConfig,tscRun,tscRuns} from './typescript-invocation.mjs';
|
|
|
18
17
|
const rel=(root,file)=>path.relative(root,file).split(path.sep).join('/');
|
|
19
18
|
const read=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
20
19
|
const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
|
|
20
|
+
// Loaded on first use: the parser takes about 40 ms to load, and every
|
|
21
|
+
// command imports this module.
|
|
22
|
+
let YAML=null;
|
|
21
23
|
export function yaml(file){
|
|
24
|
+
YAML??=createRequire(import.meta.url)('yaml');
|
|
22
25
|
const doc=YAML.parseDocument(fs.readFileSync(file,'utf8'),{uniqueKeys:true});
|
|
23
26
|
if(doc.errors.length)throw Error(`${path.basename(file)} cannot be interpreted safely`);
|
|
24
27
|
return doc.toJS()||{};
|
package/src/test-direct.mjs
CHANGED
|
@@ -430,13 +430,18 @@ function storePath(files,file,st){
|
|
|
430
430
|
// files, which is a change REDUE sees. `cache` (in owned state) keeps each
|
|
431
431
|
// file found in the store with its identity and store path, so an unchanged
|
|
432
432
|
// file is not hashed again; its store link is still checked.
|
|
433
|
-
|
|
433
|
+
// Without a pnpm store, a hard-linked installed file is accepted only when
|
|
434
|
+
// every one of its links is an installed file the check observes (`observed`,
|
|
435
|
+
// installed-observation.mjs): esbuild links its binary into its platform
|
|
436
|
+
// package. Each accepted file's link count becomes a run fact in `facts`, so
|
|
437
|
+
// a link added later is noticed before the next answer.
|
|
438
|
+
export function sharedInstalledFile(root,project={},cache=null,{observed=()=>false,facts=[]}={}){
|
|
434
439
|
const stack=modulesDirs(project).map(dir=>path.join(root,dir));
|
|
435
440
|
const files=pnpmStoreFiles(root),now=Date.now();
|
|
436
441
|
let known={},changed=false;
|
|
437
442
|
if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
|
|
438
443
|
if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
|
|
439
|
-
const found={};
|
|
444
|
+
const found={},links=new Map();
|
|
440
445
|
const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
|
|
441
446
|
while(stack.length){
|
|
442
447
|
const dir=stack.pop();let entries;
|
|
@@ -447,7 +452,14 @@ export function sharedInstalledFile(root,project={},cache=null){
|
|
|
447
452
|
if(!entry.isFile())continue;
|
|
448
453
|
let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
|
|
449
454
|
if(st.nlink<=1n)continue;
|
|
450
|
-
if(!files)
|
|
455
|
+
if(!files){
|
|
456
|
+
// Distinct observed links by real path: a directory walked twice
|
|
457
|
+
// through a link counts once.
|
|
458
|
+
const key=`${st.dev}:${st.ino}`,group=links.get(key)??{file,nlink:st.nlink,seen:new Set()};
|
|
459
|
+
if(group.nlink!==st.nlink)return shared(file,'had its hard links change while REDUE checked them');
|
|
460
|
+
if(observed(file))try{group.seen.add(fs.realpathSync(file));}catch{}
|
|
461
|
+
links.set(key,group);continue;
|
|
462
|
+
}
|
|
451
463
|
const key=`${st.dev}:${st.ino}`,cached=known[key];
|
|
452
464
|
let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
|
|
453
465
|
sameFile(path.join(files,cached[1]),st)?cached[1]:null;
|
|
@@ -463,6 +475,10 @@ export function sharedInstalledFile(root,project={},cache=null){
|
|
|
463
475
|
else changed=true;
|
|
464
476
|
}
|
|
465
477
|
}
|
|
478
|
+
for(const group of links.values()){
|
|
479
|
+
if(BigInt(group.seen.size)<group.nlink)return shared(group.file,'has another hard link that is not an installed file this check observes, and the install records no pnpm store');
|
|
480
|
+
facts.push(['linkCount',group.file,Number(group.nlink)]);
|
|
481
|
+
}
|
|
466
482
|
if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
|
|
467
483
|
try{atomicJson(cache,{files,entries:found});}catch{}
|
|
468
484
|
return null;
|
package/src/typescript-list.mjs
CHANGED
|
@@ -15,6 +15,8 @@ export function queryAnswer(kind,file,options) {
|
|
|
15
15
|
if(kind==='readFile')return hash(fs.readFileSync(file,'utf8'));
|
|
16
16
|
if(kind==='fileExists')return fs.statSync(file).isFile();
|
|
17
17
|
if(kind==='directoryExists')return fs.statSync(file).isDirectory();
|
|
18
|
+
// How many hard links a file has: a link added elsewhere changes it.
|
|
19
|
+
if(kind==='linkCount')return fs.lstatSync(file).nlink;
|
|
18
20
|
// The compiler resolves through realpathSync.native outside Windows.
|
|
19
21
|
if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
|
|
20
22
|
if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
|
|
@@ -44,7 +46,7 @@ export function queryAnswer(kind,file,options) {
|
|
|
44
46
|
export function runFactChange(facts) {
|
|
45
47
|
if(!Array.isArray(facts))return 'an unreadable run fact';
|
|
46
48
|
for(const fact of facts){
|
|
47
|
-
if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries'].includes(fact[0])||
|
|
49
|
+
if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries','linkCount'].includes(fact[0])||
|
|
48
50
|
typeof fact[1]!=='string')return 'an unreadable run fact';
|
|
49
51
|
const [kind,file,answer]=fact;
|
|
50
52
|
let now;try{now=queryAnswer(kind,file);}catch{return file;}
|