@redue/cli 0.1.0-alpha.18 → 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 +43 -0
- package/README.md +8 -4
- package/bin/redue.mjs +10 -3
- package/docs/user/configuration.md +2 -0
- package/docs/user/getting-started.md +7 -7
- package/docs/user/node-script.md +153 -0
- package/docs/user/supported-projects.md +12 -10
- package/docs/user/tests-direct.md +5 -3
- package/package.json +1 -1
- package/src/agent-integration.mjs +14 -6
- package/src/claude-hook.mjs +24 -12
- package/src/cli.mjs +32 -14
- package/src/daemon.mjs +3 -2
- package/src/declared-plan.mjs +6 -3
- package/src/index.mjs +10 -4
- package/src/node-onboarding.mjs +39 -7
- package/src/node-script.mjs +362 -0
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/presentation.mjs +8 -1
- package/src/test-direct.mjs +42 -9
- package/src/test-recorder.cjs +101 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.19 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
A general recipe for typecheck and lint scripts that the strict recipes do not
|
|
6
|
+
take. Test scripts stay on the direct test recipe (Vitest, Jest).
|
|
7
|
+
|
|
8
|
+
- `redue init` tries the strict recipes first, then writes a general check
|
|
9
|
+
(`"qualification": "node-script-v1"`) for a root typecheck or lint script.
|
|
10
|
+
`redue run` runs the script through the project's package manager (npm,
|
|
11
|
+
pnpm 9-12 or Yarn with the `node-modules` linker) with REDUE's recorder in
|
|
12
|
+
every Node process, on macOS and Linux. The input is the whole checkout but
|
|
13
|
+
`.git`, `node_modules` and runner caches, plus every workspace
|
|
14
|
+
`node_modules` observed as installed files, the package manager and its
|
|
15
|
+
version, and the environment. See docs/user/node-script.md.
|
|
16
|
+
- pnpm's launchers are trusted only when their text matches a template a pnpm
|
|
17
|
+
release writes (pnpm 8.0 to 12.14). The pnpm executable, Corepack's
|
|
18
|
+
pnpm-native included, is toolchain; its configuration files are kept by
|
|
19
|
+
content.
|
|
20
|
+
- A run that starts a program REDUE does not record is UNVERIFIED and the
|
|
21
|
+
reason names it: native programs from installed packages (esbuild, turbo,
|
|
22
|
+
tsgolint, biome), git, find, and shells that read startup files.
|
|
23
|
+
- `status` and the hook's skip message name the recipe that made the receipt
|
|
24
|
+
("general recipe"). The Claude Code hook recognizes lint commands as it does
|
|
25
|
+
typecheck and test commands: `npm run lint`, `pnpm lint`, and for a script
|
|
26
|
+
that is one plain command, that command and its `npx` form.
|
|
27
|
+
- In the census of 108 public TypeScript repos, the general recipe qualifies
|
|
28
|
+
23 typecheck and 21 lint scripts (both recorded runs clean, CURRENT after
|
|
29
|
+
the second). Of the scripts whose runs start only Node and the package
|
|
30
|
+
manager's own launchers (35 typecheck, 37 lint, measured with strace), init
|
|
31
|
+
proposes and observes about 21 and 22, and 18 of each qualify. The rest:
|
|
32
|
+
init does not pick script names such as `tsc:build`, fails in monorepos
|
|
33
|
+
over 50 packages, or refuses source directory links and custom install
|
|
34
|
+
setups; plans with submodules or dangling links are unavailable.
|
|
35
|
+
- Known limits: on macOS, pnpm 12's temporary files in the project root can
|
|
36
|
+
arrive as an event on the root and make a run UNVERIFIED. Writes into
|
|
37
|
+
`node_modules` during a run make it UNVERIFIED, except pnpm's workspace
|
|
38
|
+
state and vue-tsc's global types, which stay inputs and are kept as the run
|
|
39
|
+
left them. A workspace's recording checks now leave out nested
|
|
40
|
+
`node_modules`, so `redue init` rewrites them once.
|
|
41
|
+
|
|
42
|
+
- Contributors: `census.mjs --product` applies the product's own rule and
|
|
43
|
+
lists the files an unstable run touched; the eval kills a timed-out
|
|
44
|
+
command's whole process group; the agent eval has lint tasks.
|
|
45
|
+
|
|
3
46
|
## 0.1.0-alpha.18 — 2026-10-23
|
|
4
47
|
|
|
5
48
|
Typecheck in more project shapes, and pnpm test reuse on Linux. In the census
|
package/README.md
CHANGED
|
@@ -76,10 +76,14 @@ Start with ordinary `status`. When it cannot establish caller context, use
|
|
|
76
76
|
**Synchronization can cost more than rerunning a cheap check, particularly on
|
|
77
77
|
Windows.** REDUE does not choose or execute that tradeoff for you.
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
`init` tries the strict recipes first: the [direct compiler recipe](docs/user/direct-typescript.md)
|
|
80
|
+
for npm typechecks, the pnpm and Yarn TypeScript recipes, and the
|
|
81
|
+
[direct test recipe](docs/user/tests-direct.md). A root typecheck or lint
|
|
82
|
+
script none of them takes can use the [general recipe](docs/user/node-script.md),
|
|
83
|
+
which runs the script through the package manager and records every Node
|
|
84
|
+
process; a script that starts another program (git, esbuild, turbo) stays
|
|
85
|
+
UNVERIFIED and the reason names it. Test scripts use the direct test recipe
|
|
86
|
+
(Vitest, Jest); other test scripts stay recording-only.
|
|
83
87
|
|
|
84
88
|
## Connect your agent (experimental)
|
|
85
89
|
|
package/bin/redue.mjs
CHANGED
|
@@ -6,6 +6,7 @@ mark('client.loaded');
|
|
|
6
6
|
import {directCompiler,directQualification,directEnvironment,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
|
+
import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
|
|
9
10
|
import os from 'node:os';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import {createHash} from 'node:crypto';
|
|
@@ -27,7 +28,7 @@ const help=`REDUE ${version}
|
|
|
27
28
|
Usage: redue [--config FILE] [--state-dir DIR] COMMAND [OPTIONS]
|
|
28
29
|
|
|
29
30
|
Commands:
|
|
30
|
-
init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct]
|
|
31
|
+
init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script]
|
|
31
32
|
Discover existing scripts; optionally select one workspace/check
|
|
32
33
|
agent setup codex|claude [--dry-run|--apply] [--json]
|
|
33
34
|
agent doctor [codex|claude] [--json]
|
|
@@ -246,6 +247,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
246
247
|
const npmDirect=check.qualification===directQualification;
|
|
247
248
|
const pnpmTypecheck=check.qualification==='pnpm-tsc-v1';
|
|
248
249
|
const testsDirect=check.qualification===testsQualification;
|
|
250
|
+
const generalScript=check.qualification===scriptQualification;
|
|
249
251
|
if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect&&!testsDirect)
|
|
250
252
|
error(`${check.name}: choose script or command, not both`);
|
|
251
253
|
const manager=publicConfig.packageManager;
|
|
@@ -255,8 +257,12 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
255
257
|
[`@which:${manager}`,'run',check.script]:null;
|
|
256
258
|
const npmAutomatic=check.qualification==='typescript-noemit-v1';
|
|
257
259
|
const yarnWorkspace=check.qualification==='yarn-workspace-tsc-v1';
|
|
258
|
-
if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect)
|
|
260
|
+
if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect&&!generalScript)
|
|
259
261
|
error(`${check.name}: unknown qualification contract`);
|
|
262
|
+
// The general recipe runs the root package script through the package manager.
|
|
263
|
+
if(generalScript&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.command||check.cwd&&check.cwd!=='.'||
|
|
264
|
+
check.workspace||!scriptKinds.includes(check.kind)))
|
|
265
|
+
error(`${check.name}: the general recipe needs a root typecheck, test or lint script, without command or cwd`);
|
|
260
266
|
if(npmDirect&&(manager!=='npm'||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
|
|
261
267
|
check.command[0]!=='@node'||check.command[1]!=='@typescript-compiler:.'||
|
|
262
268
|
!tscRun({},{script:null,args:check.command.slice(2)}).run?.noEmit))
|
|
@@ -297,7 +303,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
297
303
|
check.workspace||'.',...run]]:[];
|
|
298
304
|
const windowsContext=process.platform==='win32'?
|
|
299
305
|
['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
|
|
300
|
-
const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
|
|
306
|
+
const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
|
|
301
307
|
'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
|
|
302
308
|
pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
|
|
303
309
|
{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
|
|
@@ -323,6 +329,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
323
329
|
...(npmAutomatic?{qualificationInterpretation:'npm-launcher-withheld@1'}:{}),
|
|
324
330
|
...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
|
|
325
331
|
...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
|
|
332
|
+
...(generalScript?{qualificationInterpretation:scriptInterpretation}:{}),
|
|
326
333
|
workspace:check.workspace||null,kind:check.kind||null};
|
|
327
334
|
});
|
|
328
335
|
const runtime={schema:1,provider:'declared-project@1',root,state,checks};
|
|
@@ -30,6 +30,8 @@ out of version-controlled configuration.
|
|
|
30
30
|
|
|
31
31
|
For the explicit direct compiler choice and generated exact argv, see
|
|
32
32
|
[direct TypeScript](direct-typescript.md). Existing script receipts cannot satisfy it.
|
|
33
|
+
A general check (`"qualification": "node-script-v1"`) has a `script` and no
|
|
34
|
+
`command`; see the [general recipe](node-script.md).
|
|
33
35
|
|
|
34
36
|
## Arbitrary commands
|
|
35
37
|
|
|
@@ -82,9 +82,10 @@ Use a check actually printed by `init`. For a workspace:
|
|
|
82
82
|
`redue init --workspace @example/package --check typecheck`.
|
|
83
83
|
Initialization previews detected commands and qualification confidence before writing
|
|
84
84
|
`redue.config.json`. An existing config is never overwritten. No checks are run and
|
|
85
|
-
no dependencies installed. A missing/unsupported boundary is explained.
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
no dependencies installed. A missing/unsupported boundary is explained. `init` tries
|
|
86
|
+
the strict recipes first and uses the [general recipe](node-script.md) for a root
|
|
87
|
+
typecheck or lint script none of them takes. Test scripts use the
|
|
88
|
+
[direct test recipe](tests-direct.md).
|
|
88
89
|
|
|
89
90
|
“Ready to qualify” means REDUE has a supported recipe whose prerequisites will be
|
|
90
91
|
validated. It is not a receipt. “Recording-only” can record PASS/FAIL without claiming
|
|
@@ -92,10 +93,9 @@ reusability. Qualification can change when configuration or installation changes
|
|
|
92
93
|
|
|
93
94
|
`run` streams normal output and preserves execution environment and cancellation.
|
|
94
95
|
For the narrow pnpm TypeScript contract, `init` explicitly shows the direct installed
|
|
95
|
-
compiler invocation it selects
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
the package manager. No existing configuration is silently migrated.
|
|
96
|
+
compiler invocation it selects, and for npm the
|
|
97
|
+
[local compiler recipe](direct-typescript.md). General checks and other scripts
|
|
98
|
+
run through the package manager. No existing configuration is silently migrated.
|
|
99
99
|
Only use configs and probes from trusted repositories: these execute local code.
|
|
100
100
|
|
|
101
101
|
`status` is cached and conservative. `status --sync` and `explain` revalidate caller
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# General recipe (typecheck and lint scripts)
|
|
2
|
+
|
|
3
|
+
The general recipe records a project's own typecheck or lint script, run
|
|
4
|
+
through its package manager, and reports later whether that result still
|
|
5
|
+
applies to the files you have. It is a lower tier than the strict recipes:
|
|
6
|
+
`redue init` tries the [direct TypeScript recipe](direct-typescript.md), the
|
|
7
|
+
pnpm and Yarn TypeScript recipes and the [direct test recipe](tests-direct.md)
|
|
8
|
+
first, and uses the general recipe for a root typecheck or lint script none of
|
|
9
|
+
them takes. Test scripts use the direct test recipe; `init` does not propose
|
|
10
|
+
the general recipe for them.
|
|
11
|
+
|
|
12
|
+
It supports a project installed by npm, pnpm 9-12 (isolated or hoisted
|
|
13
|
+
`node_modules`) or Yarn with the `node-modules` linker, with one lockfile kind
|
|
14
|
+
matching any `packageManager` declaration, on macOS and Linux with Node 22 or
|
|
15
|
+
24. Workspaces are supported for root scripts. `init --recipe node-script`
|
|
16
|
+
writes general checks only.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
redue init
|
|
22
|
+
redue start
|
|
23
|
+
redue run lint
|
|
24
|
+
redue status --sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The config gets one field for the recipe, written by `init`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"name": "lint", "script": "lint", "kind": "lint", "qualification": "node-script-v1", "inputs": ["**", "..."]}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`redue run lint` runs `npm run lint` (or `pnpm run lint`, `yarn run lint`) with
|
|
34
|
+
REDUE's recorder loaded in every Node process, and npm's and pnpm's update
|
|
35
|
+
checks turned off. With the Claude Code hook, `npm run lint`, `pnpm lint` and,
|
|
36
|
+
for a script that is one plain command such as `eslint .`, that command itself
|
|
37
|
+
and `npx eslint .` are recognized like typecheck and test commands.
|
|
38
|
+
`status` and the hook's skip message name the recipe that made the receipt.
|
|
39
|
+
|
|
40
|
+
## What REDUE claims
|
|
41
|
+
|
|
42
|
+
CURRENT means the last run's inputs are unchanged:
|
|
43
|
+
|
|
44
|
+
- every file in the checkout, except `.git`, every `node_modules`, the runner
|
|
45
|
+
caches listed below and the files the run itself wrote;
|
|
46
|
+
- the installed files in the root's and every workspace package's
|
|
47
|
+
`node_modules`, and Yarn's install state, by file identity;
|
|
48
|
+
- the lockfile and `package.json`, as files of the checkout;
|
|
49
|
+
- the package manager's executable and Node, by file identity, and the exact
|
|
50
|
+
script;
|
|
51
|
+
- every environment variable the run's JavaScript read directly, and always
|
|
52
|
+
`TZ`, `LANG`, `PATH`, `HOME`, `CI`, `XDG_CONFIG_HOME` and every `NODE_*`,
|
|
53
|
+
`LC_*`, `VITE_*`, `DYLD_*`, `LD_*`, `npm_config_*`, `pnpm_config_*`,
|
|
54
|
+
`PNPM_*`, `COREPACK_*` and `YARN_*` variable;
|
|
55
|
+
- the executables of npm, pnpm, Yarn and Corepack on PATH, by file identity,
|
|
56
|
+
since a script may run another package manager;
|
|
57
|
+
- small files read outside the checkout (up to 1 MB), by content, and lookups
|
|
58
|
+
outside it, asked again before each answer.
|
|
59
|
+
|
|
60
|
+
Runner caches are not inputs, and a run may read and write them: `.cache`,
|
|
61
|
+
`.vite`, `.vitest`, `.turbo`, `.nx`, `.swc`, `.jest-cache`, `.parcel-cache`,
|
|
62
|
+
`.angular/cache`, `.next/cache` and `.tmp` directories, `*.tsbuildinfo`,
|
|
63
|
+
`.eslintcache`, `.stylelintcache`, `.prettiercache`, and the
|
|
64
|
+
`_tmp_<pid>_<hex>` files pnpm writes and removes at every run. A file with
|
|
65
|
+
such a path that git does not ignore is source: reading it makes the run
|
|
66
|
+
UNVERIFIED. At the top of each `node_modules`, only `.cache`, `.vite`,
|
|
67
|
+
`.vite-temp`, `.vitest` and `.vitest-cache` are left out, and `*.tsbuildinfo`
|
|
68
|
+
anywhere in it.
|
|
69
|
+
|
|
70
|
+
Two installed files are rewritten by tools during a run: pnpm's
|
|
71
|
+
`node_modules/.pnpm-workspace-state-v1.json` at the first run after an
|
|
72
|
+
install, and the `node_modules/.vue-global-types/*.d.ts` files vue-tsc writes
|
|
73
|
+
before it reads them. They stay inputs. When they are the only files a run
|
|
74
|
+
changed, and the run wrote them (a recorded process wrote the file, or the
|
|
75
|
+
pnpm executable ran, for its state), the result is kept with the files as the
|
|
76
|
+
run left them, and a later change to them makes it STALE.
|
|
77
|
+
|
|
78
|
+
It does not mean a rerun would give the same result: a test that depends on
|
|
79
|
+
the time or on randomness can pass now and fail later on the same inputs.
|
|
80
|
+
|
|
81
|
+
## Programs
|
|
82
|
+
|
|
83
|
+
The recorder sees only Node processes. Every other program a run starts must be
|
|
84
|
+
one REDUE can account for:
|
|
85
|
+
|
|
86
|
+
- `sh` or `dash`, or `bash` without `BASH_ENV`, running a command that, read
|
|
87
|
+
as text, starts only Node programs, package managers and shell builtins
|
|
88
|
+
(`cd`, `echo`, `true`, `export`, `exec` and a few more). Words are looked up
|
|
89
|
+
on the PATH the shell got. A Node program is the Node executable, a file
|
|
90
|
+
whose `#!` line runs Node, or a pnpm launcher. A version manager's `node`
|
|
91
|
+
shim (asdf, nodenv) is a script and does not count. Other shells read
|
|
92
|
+
startup files and are refused. The shell must still have REDUE's recorder in
|
|
93
|
+
`NODE_OPTIONS`: npm's and pnpm's `node-options` setting replaces it, and a
|
|
94
|
+
run under such a setting is UNVERIFIED;
|
|
95
|
+
- a pnpm launcher in `node_modules/.bin`: the `#!/bin/sh` script pnpm writes,
|
|
96
|
+
which runs `sed`, `dirname`, `uname` or `readlink` before it starts Node. It
|
|
97
|
+
is trusted only when its text is exactly one of the templates pnpm 8.0 to
|
|
98
|
+
12.14 write (`src/pnpm-shim-templates.json`), with only the bin file and
|
|
99
|
+
`NODE_PATH` filled in;
|
|
100
|
+
- the pnpm executable (pnpm 12, also the copy Corepack downloads): REDUE cannot
|
|
101
|
+
see what it does, so the scripts it runs are read as text the same way,
|
|
102
|
+
their `pre` and `post` scripts included. Filters and recursive runs
|
|
103
|
+
(`--filter`, `-r`) are refused. At least one Node process it starts must
|
|
104
|
+
have loaded the recorder, and its configuration files outside the checkout
|
|
105
|
+
(`pnpm/rc`, `pnpm/config.yaml`, `~/.npmrc`, the global `npmrc`) are kept by
|
|
106
|
+
content;
|
|
107
|
+
- `ps -A -o ppid,pid`, which pnpm loaded into Corepack runs.
|
|
108
|
+
|
|
109
|
+
Anything else makes the result UNVERIFIED and names the program: `git`,
|
|
110
|
+
`sleep`, `find`, and native programs from installed packages such as esbuild,
|
|
111
|
+
turbo, tsgo, tsgolint and biome, whose reads, writes and network use REDUE
|
|
112
|
+
cannot see. `init` reads the script the same way and keeps a script that
|
|
113
|
+
starts such a program recording-only, with the program in the reason.
|
|
114
|
+
|
|
115
|
+
A command line may use `&&`, `||`, `;`, `|`, quotes without expansions,
|
|
116
|
+
globs in arguments and redirection to `/dev/null`. Variables, command
|
|
117
|
+
substitution, input redirection, subshells, here-documents and `command -p`
|
|
118
|
+
are refused. Setting or clearing `NODE_OPTIONS`, `PATH`, `REDUE_RUN_RECORD`,
|
|
119
|
+
`BASH_ENV`, `ENV`, or npm's or pnpm's `node-options` or `script-shell` is
|
|
120
|
+
refused, since it could stop a Node process from loading the recorder.
|
|
121
|
+
`node --env-file` and Node's config-file options are refused: Node reads those
|
|
122
|
+
files natively. `process.loadEnvFile` is recorded as a read.
|
|
123
|
+
|
|
124
|
+
## The guard
|
|
125
|
+
|
|
126
|
+
As in the [direct test recipe](tests-direct.md#the-guard), the result is kept
|
|
127
|
+
but marked UNVERIFIED, with the reason, when the run read a git-ignored file
|
|
128
|
+
REDUE does not observe and did not create first, changed an input during the
|
|
129
|
+
run (a formatter's `--write`, a generated `version.ts`), listed a directory or
|
|
130
|
+
read a large file outside the checkout, used the network, or started a program
|
|
131
|
+
REDUE does not account for. Reads through a workspace package's link in
|
|
132
|
+
`node_modules` count where the link leads.
|
|
133
|
+
|
|
134
|
+
The package managers' own installations, Corepack's downloads and npm's log
|
|
135
|
+
directory are toolchain, identified by the package manager's executable, not
|
|
136
|
+
inputs.
|
|
137
|
+
|
|
138
|
+
## Limits
|
|
139
|
+
|
|
140
|
+
- On macOS, a file created and removed directly in the project root during a
|
|
141
|
+
run (pnpm 12 does this) can be reported by the file watcher as an event on
|
|
142
|
+
the root itself, an observation gap that makes the run UNVERIFIED. Linux
|
|
143
|
+
reports the file, which is not an input.
|
|
144
|
+
- The pnpm executable and the pnpm launchers' helpers are trusted as
|
|
145
|
+
toolchain: what they read is not checked.
|
|
146
|
+
- Code that copies the environment and reads the copy, and native code that
|
|
147
|
+
reads files or variables (native addons such as Rust-based resolvers), is
|
|
148
|
+
not seen.
|
|
149
|
+
- A project whose `.npmrc` sets `node-options` stays UNVERIFIED: npm replaces
|
|
150
|
+
`NODE_OPTIONS` with it, which drops REDUE's recorder.
|
|
151
|
+
- A script that runs a build first (`npm run build && tsc --noEmit`) usually
|
|
152
|
+
starts esbuild or another native bundler and stays UNVERIFIED.
|
|
153
|
+
- Windows is not supported; the check stays recording-only there.
|
|
@@ -15,9 +15,11 @@ At the repository root, `init` looks for existing typecheck, test, lint, and
|
|
|
15
15
|
build scripts. It prefers familiar script names, then unique recognizable
|
|
16
16
|
`tsc`, Vitest/Jest, ESLint, or Vite commands under other names. Multiple
|
|
17
17
|
matches are reported as ambiguous rather than guessed. It never creates a new
|
|
18
|
-
project script.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
project script. Without `--recipe`, `init` tries the strict recipes below first,
|
|
19
|
+
then the [general recipe](node-script.md) for a root typecheck or lint
|
|
20
|
+
script: the script run through the package manager with every Node process
|
|
21
|
+
recorded and the whole checkout as its input. `--recipe typescript-direct`,
|
|
22
|
+
`tests-direct` or `node-script` selects one recipe alone. The direct TypeScript recipe supports
|
|
21
23
|
a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
|
|
22
24
|
or no typecheck script at all (a proposed `tsc --noEmit`; see
|
|
23
25
|
[script forms](direct-typescript.md#script-forms)), in a single-package npm installation using
|
|
@@ -41,11 +43,11 @@ When an internal dependency is consumed through built declarations, changing
|
|
|
41
43
|
only its source does not stale the selected check until the consumed output
|
|
42
44
|
changes. Unsupported custom resolution, project references, outside-root
|
|
43
45
|
source links, and installation modes remain recording-only. Other Yarn
|
|
44
|
-
typechecks, Vitest/Jest, ESLint, and builds
|
|
45
|
-
separately justified explicit contract is provided.
|
|
46
|
-
project whose test script runs Vitest 4/5 or Jest 29
|
|
47
|
-
`init
|
|
48
|
-
[direct tests](tests-direct.md).
|
|
46
|
+
typechecks, Vitest/Jest, ESLint, and builds in workspace packages start
|
|
47
|
+
recording-only unless a separately justified explicit contract is provided.
|
|
48
|
+
For a single-package project whose test script runs Vitest 4/5 or Jest 29
|
|
49
|
+
directly, `init` uses the direct test recipe, which records the run and guards
|
|
50
|
+
its inputs; see [direct tests](tests-direct.md).
|
|
49
51
|
In particular, test caches and build-generated inputs are not excluded to
|
|
50
52
|
make evidence green.
|
|
51
53
|
|
|
@@ -75,8 +77,8 @@ TypeScript itself computes them for new files, minus `node_modules` (`exclude`
|
|
|
75
77
|
entries stay inputs: an import can still reach an excluded file); a file the
|
|
76
78
|
program reads outside them keeps the check recording-only. This avoids pnpm's task-run bookkeeping
|
|
77
79
|
writes during `pnpm run`; it does not alter the project's script. Test, lint,
|
|
78
|
-
build, and ambiguous TypeScript scripts still execute through `pnpm run
|
|
79
|
-
|
|
80
|
+
build, and ambiguous TypeScript scripts still execute through `pnpm run`; root
|
|
81
|
+
typecheck and lint ones can use the [general recipe](node-script.md). The generated `@typescript-bin:WORKSPACE` token resolves
|
|
80
82
|
the local compiler when the check runs; it is not an absolute machine path.
|
|
81
83
|
|
|
82
84
|
The pnpm contract indexes consumed installed package contents inside the
|
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -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`+
|
|
@@ -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
|
|
@@ -168,6 +175,7 @@ function skillFor(relative,host,config){
|
|
|
168
175
|
const redue=redueFor(relative)??`redue --config ${JSON.stringify(relative)}`,status=`${redue} status --sync --json`;
|
|
169
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.
|
|
@@ -11,6 +11,7 @@ import {projectState} from './project-location.mjs';
|
|
|
11
11
|
import {realObservedPath} from './path-identity.mjs';
|
|
12
12
|
import {processAlive} from './process-liveness.mjs';
|
|
13
13
|
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
|
+
import {recipeLabel} from './presentation.mjs';
|
|
14
15
|
|
|
15
16
|
export const hookCommand='redue agent hook claude';
|
|
16
17
|
// No shell metacharacters; `@` starts scoped package names.
|
|
@@ -19,7 +20,11 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
|
19
20
|
// A direct test check: its command is the runner and the script's arguments.
|
|
20
21
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
21
22
|
/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
|
|
22
|
-
|
|
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';
|
|
23
28
|
|
|
24
29
|
export const defaultConfig='redue.config.json';
|
|
25
30
|
// The `redue` prefix for a config beside the project root; null when the name
|
|
@@ -53,8 +58,12 @@ function workspaceForms(pm,name,script){
|
|
|
53
58
|
`npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
|
|
54
59
|
return [];
|
|
55
60
|
}
|
|
56
|
-
// The tool a check runs, as typed: `tsc ARGS` or the test runner's command
|
|
57
|
-
|
|
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;}
|
|
58
67
|
const command=check.command;
|
|
59
68
|
if(!Array.isArray(command)||command[0]!=='@node')return null;
|
|
60
69
|
const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
|
|
@@ -103,9 +112,9 @@ function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(ch
|
|
|
103
112
|
// only when the script's tsc runs are those checks' runs, one each. With one
|
|
104
113
|
// check they stay on its row (`scriptForms`); a chain's checks get a row for
|
|
105
114
|
// the script, and their own rows (`part`) keep only their own forms.
|
|
106
|
-
export function hookForms(config,configFile=defaultConfig){
|
|
115
|
+
export function hookForms(config,configFile=defaultConfig,packageScripts=null){
|
|
107
116
|
const rows=[],redue=redueFor(configFile),pm=config?.packageManager,scripts=new Map();
|
|
108
|
-
const eligible=(config?.checks||[]).filter(check=>(
|
|
117
|
+
const eligible=(config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&safe.test(check.name));
|
|
109
118
|
const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
|
|
110
119
|
JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
|
|
111
120
|
for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
|
|
@@ -116,7 +125,7 @@ export function hookForms(config,configFile=defaultConfig){
|
|
|
116
125
|
group=key?scripts.get(key):null,part=group?.length>1;
|
|
117
126
|
const own=group?null:checkScript(check);
|
|
118
127
|
if(dir==='.'){
|
|
119
|
-
forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check)));
|
|
128
|
+
forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
|
|
120
129
|
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
121
130
|
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
122
131
|
forms.push(check.command.join(' '));
|
|
@@ -167,7 +176,7 @@ export function fanout(text){
|
|
|
167
176
|
// Bash `if` rules for fan-out commands of a task some workspace check runs,
|
|
168
177
|
// and for root scripts that run one; other turbo or nx commands never start the hook.
|
|
169
178
|
export function fanoutRules(config,scripts={}){
|
|
170
|
-
const tasks=new Set((config?.checks||[]).filter(check=>(
|
|
179
|
+
const tasks=new Set((config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&
|
|
171
180
|
![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
|
|
172
181
|
const rules=[];
|
|
173
182
|
for(const task of tasks){
|
|
@@ -203,7 +212,7 @@ export async function fanoutChecks(fan,config,projectRoot){
|
|
|
203
212
|
return null;
|
|
204
213
|
const names=[];
|
|
205
214
|
for(const item of packages){
|
|
206
|
-
const own=config.checks.filter(c=>(
|
|
215
|
+
const own=config.checks.filter(c=>eligibleCheck(c)&&typeof c.name==='string'&&
|
|
207
216
|
workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
|
|
208
217
|
if(!own.length)return null;
|
|
209
218
|
// A compiler script's checks: one per tsc run it has now.
|
|
@@ -235,6 +244,10 @@ function split(command){
|
|
|
235
244
|
function matched(command,config,configFile,dir='.',scripts={}){
|
|
236
245
|
const parts=split(command);
|
|
237
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};
|
|
238
251
|
if(dir==='.'){
|
|
239
252
|
// A root script that runs a fan-out is that fan-out, unless pre/post scripts
|
|
240
253
|
// run around it: then it matches nothing.
|
|
@@ -243,8 +256,6 @@ function matched(command,config,configFile,dir='.',scripts={}){
|
|
|
243
256
|
return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
|
|
244
257
|
if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
|
|
245
258
|
}
|
|
246
|
-
const row=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
247
|
-
row.dir===dir&&row.local.includes(parts.base));
|
|
248
259
|
// A script form of compiler checks is checked against the script first.
|
|
249
260
|
return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
|
|
250
261
|
}
|
|
@@ -292,7 +303,8 @@ export function verdict(status,name,rest=null){
|
|
|
292
303
|
const runId=row?.invocation?.runId;
|
|
293
304
|
if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
|
|
294
305
|
typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
|
|
295
|
-
|
|
306
|
+
const recipe=recipeLabel(row.recipe);
|
|
307
|
+
return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}${recipe?`, ${recipe}`:''}); `+
|
|
296
308
|
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
297
309
|
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
298
310
|
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|