@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 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
- Since alpha.4, ordinary npm scripts are recording-only after
80
- [an npm launcher applicability gap](docs/acceptance/npm-launcher-boundary.md).
81
- A separate explicit [direct compiler recipe](docs/user/direct-typescript.md) can
82
- qualify supported npm typechecks. It does not claim that npm/lifecycle scripts ran.
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. Npm-launcher typechecks are
86
- recording-only in alpha.4; a passed execution is historical PASS with UNVERIFIED
87
- applicability, not reusable CURRENT.
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. Npm scripts remain recording-only;
96
- use `redue init --recipe typescript-direct --dry-run` to preview a distinct,
97
- explicit [local compiler recipe](direct-typescript.md). Other scripts run through
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. Automatic npm-launcher applicability was withdrawn in alpha.4;
19
- ordinary npm scripts remain recording-only. See the [maintainer notice](npm-launcher-notice.md).
20
- The direct recipe is opt-in, available since alpha.5. The opt-in `init --recipe typescript-direct` supports
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 start recording-only unless a
45
- separately justified explicit contract is provided. For a single-package npm
46
- project whose test script runs Vitest 4/5 or Jest 29 directly, the opt-in
47
- `init --recipe tests-direct` records the run and guards its inputs; see
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` and
79
- start recording-only. The generated `@typescript-bin:WORKSPACE` token resolves
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` records a project's unit test
4
- run and reports later whether that result still applies to the files you have.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.18",
4
+ "version": "0.1.0-alpha.19",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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 test commands the hook matches, asks
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').map(row=>row.form),rows.filter(row=>row.kind==='test').map(row=>row.form)];
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())
@@ -1,4 +1,4 @@
1
- // Claude Code PreToolUse hook: skip an exact typecheck or test rerun when REDUE
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
- export const checkKind=check=>tests(check)?'test':'typecheck';
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
- function toolText(check){
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=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&safe.test(check.name));
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=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&
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=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
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
- return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}); `+
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}`);