@redue/cli 0.1.0-alpha.17 → 0.1.0-alpha.18

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,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.18 — 2026-10-23
4
+
5
+ Typecheck in more project shapes, and pnpm test reuse on Linux. In the census
6
+ of 108 public TypeScript repos, the ones `init` makes ready went from 8 to 11
7
+ for typecheck (the census clones each repo's current head, so one or two
8
+ repos move between runs) and stayed at 22 for tests.
9
+
10
+ - pnpm test checks are reused on Linux. A recorded run accepts an installed
11
+ file hard-linked into the pnpm store the install recorded, when the store
12
+ holds it at its content address (pnpm names store files by their sha512).
13
+ Links anywhere else stay UNVERIFIED. Each file is hashed once; REDUE keeps
14
+ its identity and store path in its state and hashes it again only when the
15
+ identity changes. A 46 MB install takes about 160 ms to check the first
16
+ time. Limit: an in-place edit of an installed file in another project that
17
+ shares the store file is not seen until the next recorded run.
18
+
19
+ - Typecheck scripts in more shapes. A script of `tsc --noEmit` runs with
20
+ `-p`/`--project`, `--skipLibCheck` or `--pretty`, in any argument order,
21
+ qualifies for the direct npm recipe and the pnpm contract. A chain of such
22
+ runs joined by `&&` becomes one check per run (`typecheck.1`,
23
+ `typecheck.2`); the hook skips the script only when every run is CURRENT
24
+ and otherwise runs the stale ones through `redue run`. `check-types` joins
25
+ the recognized script names.
26
+ - The hook acts on a typecheck script's form only while the script in
27
+ package.json still has exactly the configured `tsc` runs. Before, a script
28
+ changed to `tsc --noEmit && eslint .` was rewritten to `redue run
29
+ typecheck`, which ran tsc alone.
30
+ - Without a typecheck script, `init` proposes a `tsc --noEmit` check for a
31
+ root `tsconfig.json` (direct npm recipe, pnpm) and for each pnpm workspace
32
+ package with one, unless a script runs a TypeScript checker in another form
33
+ (`tsc -b`, `vue-tsc`, ...). package.json is not changed; the check is
34
+ written only when the contract holds.
35
+ - The pnpm contract no longer needs `include: ["src"]`. Its source inputs are
36
+ the directories the config's `include` covers, as TypeScript computes them
37
+ for new files, minus `node_modules`. `exclude` entries stay inputs, since an
38
+ import can still reach an excluded file. A config extended from an installed
39
+ package is an installed input.
40
+ - Fixed: a config with three or more pnpm or test checks never got a
41
+ synchronized answer. The observer dropped control requests over 4 KB, and a
42
+ sync request carries about 1.5 KB of context digests per check; status
43
+ showed "Unexpected end of JSON input" for every check. The limit is 1 MiB.
44
+
45
+ - The direct npm TypeScript recipe takes any TypeScript 5.x or 6.0, not only
46
+ the five reviewed releases. Another release qualifies when package-lock.json
47
+ records the registry's `typescript` at that version with an integrity
48
+ (sha512, or sha1 from registries that record only sha1), npm's cache holds
49
+ the tarball under it, and the installed package is exactly the tarball's
50
+ files. Nothing is downloaded; a missing tarball
51
+ keeps the check recording-only with `npm-tarball-uncached`. The check takes
52
+ about 100 ms per full probe (TypeScript 5.x and 6.0 tarballs, 120-140 files).
53
+ CI runs the direct contract suite against 5.0.4, 5.1.6, 5.2.2, 5.3.3, 5.4.5,
54
+ 5.5.4, 5.7.3 and 5.8.3.
55
+
56
+ - Contributors: `npm test` runs up to four test files at a time, keeping two
57
+ cores free (a 3-core CI macOS runner runs one at a time), and takes about
58
+ 6 minutes on the owner's Mac (was 19.6). The two TypeScript 6 fixture
59
+ suites are separate files so they run side by side.
60
+
3
61
  ## 0.1.0-alpha.17 — 2026-10-22
4
62
 
5
63
  Workspaces and package managers beyond npm. In the census of 108 public
package/README.md CHANGED
@@ -37,7 +37,7 @@ it; relevant source edits make it stale. [Run the reproducible demo](docs/contri
37
37
 
38
38
  ## Install the alpha
39
39
 
40
- The opt-in pilot uses **0.1.0-alpha.17**. Follow the
40
+ The opt-in pilot uses **0.1.0-alpha.18**. Follow the
41
41
  [fixed-version pilot guide](docs/user/pilot.md) for supported projects, setup,
42
42
  updates and voluntary feedback. No external usage results are claimed yet.
43
43
 
package/bin/redue.mjs CHANGED
@@ -4,6 +4,7 @@ import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decis
4
4
  traceBase({...traceContext(),request:traceId(),work:'client'});
5
5
  mark('client.loaded');
6
6
  import {directCompiler,directQualification,directEnvironment,directInterpretation} from '../src/direct-typescript.mjs';
7
+ import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
7
8
  import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
8
9
  import os from 'node:os';
9
10
  import path from 'node:path';
@@ -256,8 +257,9 @@ const checks=publicConfig.checks.map(check=>{
256
257
  const yarnWorkspace=check.qualification==='yarn-workspace-tsc-v1';
257
258
  if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect)
258
259
  error(`${check.name}: unknown qualification contract`);
259
- if(npmDirect&&(manager!=='npm'||!check.script||check.cwd&&check.cwd!=='.'||
260
- JSON.stringify(check.command)!==JSON.stringify(['@node','@typescript-compiler:.','--noEmit'])))
260
+ if(npmDirect&&(manager!=='npm'||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
261
+ check.command[0]!=='@node'||check.command[1]!=='@typescript-compiler:.'||
262
+ !tscRun({},{script:null,args:check.command.slice(2)}).run?.noEmit))
261
263
  error(`${check.name}: direct recipe requires its explicit local compiler command`);
262
264
  // The script's own arguments follow the runner; qualification compares them with the script.
263
265
  if(testsDirect&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.cwd&&check.cwd!=='.'&&!check.workspace||!Array.isArray(check.command)||
@@ -270,22 +272,29 @@ const checks=publicConfig.checks.map(check=>{
270
272
  check.command[1]!=='workspace'||check.command[2]!==check.workspace||
271
273
  check.command[3]!=='run'))
272
274
  error(`${check.name}: Yarn workspace qualification needs its discovered workspace command`);
273
- if(pnpmTypecheck&&(manager!=='pnpm'||!check.script||!check.command||
275
+ if(pnpmTypecheck&&(manager!=='pnpm'||!Array.isArray(check.command)||
274
276
  check.command[0]!=='@node'||
275
277
  check.command[1]!==`@typescript-bin:${check.cwd||'.'}`||
276
278
  check.workspace&&check.cwd==='.'||
277
- !check.command.slice(2).every(value=>typeof value==='string'&&value)))
279
+ !tscRun({},{script:null,args:check.command.slice(2)}).run))
278
280
  error(`${check.name}: pnpm qualification needs its discovered standalone TypeScript command`);
279
281
  const reason=check.kind==='test'?'test runtime, transforms and cache inputs need review':
280
282
  check.kind==='lint'?'lint plugins, config and resolver inputs need review':
281
283
  check.kind==='build'?'build environment and generated inputs need review':
282
284
  'check inputs need review';
283
- const autoProbes=npmDirect?[['@node','@vstate/direct-typescript-probe','@project',check.script]]:npmAutomatic?[['@node','@vstate/typescript-contract-probe',
285
+ // A chained script has one check per tsc run: each check's run counts them,
286
+ // so a run added to or removed from the script invalidates them all.
287
+ const runCount=(npmDirect||pnpmTypecheck)&&check.script?publicConfig.checks.filter(other=>
288
+ other.qualification===check.qualification&&other.script===check.script&&
289
+ (other.cwd||'.')===(check.cwd||'.')&&(other.workspace||null)===(check.workspace||null)).length:1;
290
+ if(runCount>16)error(`${check.name}: more than 16 checks for script ${check.script}`);
291
+ const run=npmDirect||pnpmTypecheck?runArgv({script:check.script??null,args:check.command.slice(2),count:runCount}):null;
292
+ const autoProbes=npmDirect?[['@node','@vstate/direct-typescript-probe','@project',...run]]:npmAutomatic?[['@node','@vstate/typescript-contract-probe',
284
293
  '@project',check.script,'@which:npm']]:yarnWorkspace?
285
294
  [['@node','@vstate/yarn-workspace-typecheck-probe','@project',
286
295
  check.workspace,check.command[4],'@which:yarn']]:pnpmTypecheck?
287
296
  [['@node','@vstate/pnpm-typecheck-probe','@project',
288
- check.workspace||'.',check.script]]:[];
297
+ check.workspace||'.',...run]]:[];
289
298
  const windowsContext=process.platform==='win32'?
290
299
  ['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
291
300
  const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
@@ -310,6 +319,7 @@ const checks=publicConfig.checks.map(check=>{
310
319
  coverageReasons:check.coverageReasons||Object.fromEntries(
311
320
  categories.map(category=>[category,reason])),
312
321
  qualification:check.qualification||null,script:check.script||null,
322
+ ...(runCount>1?{runCount}:{}),
313
323
  ...(npmAutomatic?{qualificationInterpretation:'npm-launcher-withheld@1'}:{}),
314
324
  ...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
315
325
  ...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
@@ -122,6 +122,17 @@ post-scripts. In every other case the command runs as typed: turbo caches its
122
122
  own results, so the hook never rewrites it. Filters (`--filter`) and nx are not
123
123
  matched.
124
124
 
125
+ For a typecheck check that runs the compiler, the script's forms act only when
126
+ the script in package.json still has exactly the configured runs: one `tsc`
127
+ run per check with the same arguments, and no pre- or post-script. Otherwise
128
+ the command runs as typed. A script that chains `tsc` runs with `&&` has one
129
+ check per run, `typecheck.1`, `typecheck.2` and so on (see
130
+ [direct TypeScript](direct-typescript.md#script-forms)); each run's own
131
+ `npx tsc ...` form matches its check. The hook skips the script when all its
132
+ runs are CURRENT, PASS and `reuse_eligible`, and otherwise rewrites it to
133
+ `redue run` of the runs that are not, in the script's order, joined by `&&`.
134
+ In a turbo or `pnpm -r` fan-out, a package's script counts only the same way.
135
+
125
136
  Each form may end with `2>&1` and then one `| tail -n N`, `| head -n N`,
126
137
  `| tail -N` or `| head -N`. It may also be the first command of a `;` or `&&`
127
138
  chain. Nothing else matches. Each form has its own `if` rule, so other Bash
@@ -56,6 +56,10 @@ Advanced explicit contracts may declare `generatedInputs`, `installedInputs`, na
56
56
  reviewed escape hatches, not onboarding requirements. A coverage rationale cannot
57
57
  override unsupported storage, unresolved links, missing inputs, or observation failure.
58
58
 
59
+ A chained typecheck script gets one generated check per `tsc` run, all with
60
+ the script's name (see [script forms](direct-typescript.md#script-forms)). A
61
+ proposed check for a project without a typecheck script has no `script`.
62
+
59
63
  Input patterns are relative to the project root. They cannot name a `.git`
60
64
  directory, because REDUE does not observe one; `!.git/**` is fine.
61
65
 
@@ -4,8 +4,10 @@ This check means: the reviewed local TypeScript compiler completed the configure
4
4
  check against declared, observed inputs and relevant execution context. It does
5
5
  not mean npm or npm lifecycle scripts completed.
6
6
 
7
- For a supported single-package npm project with an existing standalone
8
- `tsc --noEmit` script, installed dependencies, Node 22/24 and TypeScript 5.5.2/5.6.3/5.9.3/6.0.2/6.0.3:
7
+ For a supported single-package npm project with a typecheck script of `tsc`
8
+ runs (see [script forms](#script-forms)) or no typecheck script and a root
9
+ `tsconfig.json`, installed dependencies, Node 22/24 and TypeScript 5.x or 6.0
10
+ (see [compiler identity](#compiler-identity)):
9
11
 
10
12
  ```sh
11
13
  redue init --recipe typescript-direct --check typecheck --dry-run
@@ -33,9 +35,64 @@ A generated check contains:
33
35
 
34
36
  `script` identifies the reviewed source recipe; `command` is what actually runs:
35
37
  the resolved Node executable and local TypeScript implementation, with exact argv
36
- and no shell. TypeScript 5.5.2 and 5.6.3 use `lib/tsc.js`; 5.9.3, 6.0.2 and 6.0.3 use `lib/_tsc.js` directly,
37
- without its outer compile-cache-enabling shim. No compiler is fetched or bundled.
38
- The entire installed package must match reviewed official distribution bytes.
38
+ and no shell. The entry is `lib/_tsc.js` where the package has one (5.7 and
39
+ later), without its outer compile-cache-enabling shim, and `lib/tsc.js`
40
+ otherwise. No compiler is fetched or bundled.
41
+
42
+ ## Compiler identity
43
+
44
+ The entire installed `node_modules/typescript` must be an official package,
45
+ byte for byte, in one of two ways:
46
+
47
+ - TypeScript 5.5.2, 5.6.3, 5.9.3, 6.0.2 or 6.0.3: its files match the digest
48
+ REDUE ships for that release.
49
+ - Any other TypeScript 5.x or 6.0 release: package-lock.json records the
50
+ registry's `typescript` package at the installed version (no alias, an
51
+ `https` tarball URL, not a file or git source) with an integrity (sha512,
52
+ or sha1 where a registry records only sha1), npm's cache holds the tarball
53
+ under that integrity, and the installed files are exactly the tarball's
54
+ files. REDUE reads the cache only; it never downloads. The cache is the one
55
+ npm uses: `npm_config_cache`, else `cache` in the project's `.npmrc`, then
56
+ the user's, else `~/.npm` (`%LocalAppData%\npm-cache` on Windows); the
57
+ global npmrc is not read. `npm-shrinkwrap.json` is not read on this path.
58
+ `npm ci` fills the cache. When the tarball is missing the check stays
59
+ recording-only with the reason `npm-tarball-uncached`; `npm cache add
60
+ typescript@VERSION` adds it. The check's compiler identity is then the
61
+ lockfile integrity, so a lockfile change re-probes.
62
+
63
+ CI runs the same contract suite against the last release of each minor from
64
+ 5.0 to 5.8 that the reviewed releases do not cover. Later minors (6.1 and up)
65
+ wait until CI covers them. TypeScript 7 (the native compiler) is not
66
+ supported.
67
+
68
+ ## Script forms
69
+
70
+ A typecheck script qualifies when it is one or more `tsc` runs joined by `&&`,
71
+ each with `--noEmit` and only these arguments, in any order: `-p`/`--project`
72
+ with a config file or directory inside the project, `--skipLibCheck`, and
73
+ `--pretty` (optionally `true` or `false`). For example
74
+ `tsc -p tsconfig.app.json --noEmit` or
75
+ `tsc --noEmit && tsc --noEmit -p tsconfig.node.json`. `tsc -b`, `--watch`,
76
+ other options, quotes, variables and other commands keep the script
77
+ recording-only.
78
+
79
+ A chain gets one check per run, named `typecheck.1`, `typecheck.2` and so on,
80
+ each with the script's name and that run's arguments in its command. Each run
81
+ is checked as its own program against its own config, and is reused only while
82
+ the script still has as many runs as the config has checks for it, one of them
83
+ with exactly those arguments. Adding or removing a run, or a pre- or
84
+ post-script, makes every check of the script not reusable until `init` runs
85
+ again. The script as a whole
86
+ (`npm run typecheck`) is skipped only when its current runs and the configured
87
+ checks correspond one to one and all are CURRENT (see [agents](agents.md)).
88
+
89
+ With no typecheck script and a root `tsconfig.json`, init proposes a
90
+ `tsc --noEmit` check with no `script` field, unless a script already runs a
91
+ TypeScript checker in another form (`tsc -b`, `vue-tsc`, `svelte-check`,
92
+ `tsgo`, `astro check`, `nuxi typecheck`). package.json is not changed. The
93
+ check is written only when the contract holds; otherwise no typecheck check is
94
+ added. The pnpm contract does the same for each workspace package with a
95
+ `tsconfig.json` and no typecheck script.
39
96
 
40
97
  Existing configs are never overwritten. To compare choices, use a new project-local
41
98
  `--config redue.direct.config.json` with init; this explicitly selects separate
@@ -1,4 +1,4 @@
1
- # Opt-in development pilot: alpha.17
1
+ # Opt-in development pilot: alpha.18
2
2
 
3
3
  Use one fixed build for several ordinary development sessions. The owner will
4
4
  invite two or three willing testers and approve their projects; nothing is
@@ -9,8 +9,9 @@ controlled compiler/agent trials are acceptance evidence, not independent adopti
9
9
 
10
10
  Start with a trusted, single-package npm Git checkout, normally installed locked
11
11
  dependencies, and an existing standalone `tsc --noEmit` script without pre/post
12
- scripts. The direct recipe accepts only the official, unchanged TypeScript
13
- **5.5.2, 5.6.3, 5.9.3, 6.0.2 or 6.0.3** packages and Node **22 or 24**. No workspaces, named
12
+ scripts. The direct recipe accepts only an official, unchanged TypeScript **5.x or
13
+ 6.0** package (a reviewed release, or one matching the tarball package-lock.json
14
+ names in npm's cache) and Node **22 or 24**. No workspaces, named
14
15
  tsconfig selection, emitted/incremental/composite builds, custom loaders or
15
16
  outside-root inputs are added by this pilot. Do not downgrade or change your
16
17
  project to fit; record an unsupported setup as feedback.
@@ -35,7 +36,7 @@ are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
35
36
  For a new default npm installation:
36
37
 
37
38
  ```sh
38
- npm install --global @redue/cli@0.1.0-alpha.17
39
+ npm install --global @redue/cli@0.1.0-alpha.18
39
40
  redue --version
40
41
  cd my-project
41
42
  redue --config redue.config.json init --recipe typescript-direct --check typecheck --dry-run
@@ -89,7 +90,7 @@ using it; keep the same npm prefix, config and any explicit state selection.
89
90
 
90
91
  ```sh
91
92
  redue --config redue.config.json stop
92
- npm install --global @redue/cli@0.1.0-alpha.17
93
+ npm install --global @redue/cli@0.1.0-alpha.18
93
94
  redue --config redue.config.json agent setup codex --dry-run
94
95
  redue --config redue.config.json agent setup codex --apply
95
96
  redue --config redue.config.json start
@@ -18,8 +18,12 @@ matches are reported as ambiguous rather than guessed. It never creates a new
18
18
  project script. Automatic npm-launcher applicability was withdrawn in alpha.4;
19
19
  ordinary npm scripts remain recording-only. See the [maintainer notice](npm-launcher-notice.md).
20
20
  The direct recipe is opt-in, available since alpha.5. The opt-in `init --recipe typescript-direct` supports
21
- a standalone `tsc --noEmit` script in a single-package npm installation using
22
- reviewed TypeScript 5.5.2/5.6.3/5.9.3/6.0.2/6.0.3 and Node 22/24. It executes the local compiler,
21
+ a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
22
+ or no typecheck script at all (a proposed `tsc --noEmit`; see
23
+ [script forms](direct-typescript.md#script-forms)), in a single-package npm installation using
24
+ TypeScript 5.x or 6.0 (a reviewed release, or one whose installed files match the
25
+ tarball package-lock.json names in npm's cache; see
26
+ [compiler identity](direct-typescript.md#compiler-identity)) and Node 22/24. It executes the local compiler,
23
27
  not npm. Full installed compiler identity, config/membership, installed inputs
24
28
  and relevant Node environment are checked before reuse. See the
25
29
  [exact direct boundary](direct-typescript.md). Unsupported plugins, preloads,
@@ -62,9 +66,14 @@ the installed one. Only the project's files are read: settings from a user or
62
66
  global rc file, or `npm_config_*` variables during install, are not. The
63
67
  supported local layouts are pnpm's standard isolated and hoisted
64
68
  `node_modules` with a project-local `.pnpm` virtual store. `init` can discover
65
- root and workspace scripts. For a standalone `tsc --noEmit` or `tsc -p ...`
66
- script in the narrow TypeScript contract, it records the exact installed
67
- compiler invocation directly. This avoids pnpm's task-run bookkeeping
69
+ root and workspace scripts. For a typecheck script of `tsc` runs
70
+ ([script forms](direct-typescript.md#script-forms); `tsc -p .` without
71
+ `--noEmit` is also accepted), or a package with a `tsconfig.json` and no
72
+ typecheck script, it records the exact installed compiler invocation directly.
73
+ The source inputs are the directories the config's `include` covers, as
74
+ TypeScript itself computes them for new files, minus `node_modules` (`exclude`
75
+ entries stay inputs: an import can still reach an excluded file); a file the
76
+ program reads outside them keeps the check recording-only. This avoids pnpm's task-run bookkeeping
68
77
  writes during `pnpm run`; it does not alter the project's script. Test, lint,
69
78
  build, and ambiguous TypeScript scripts still execute through `pnpm run` and
70
79
  start recording-only. The generated `@typescript-bin:WORKSPACE` token resolves
@@ -79,6 +79,10 @@ kept but marked UNVERIFIED, with the reason, when the run:
79
79
  about a name other than localhost;
80
80
  - started a process that did not load the recorder: a shell, a binary, or a
81
81
  Node process with its own environment;
82
+ - had an installed file with another hard link, other than a link into the
83
+ pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
84
+ default on Linux). A change through a link elsewhere, such as Yarn's
85
+ hardlinks modes, is not seen.
82
86
 
83
87
  Lookups that can be asked again become run facts, checked before every CURRENT
84
88
  answer: whether a path outside the checkout exists, where a link resolves, the
@@ -119,4 +123,8 @@ Tests that write files git does not ignore change the run's inputs.
119
123
  are not indexed at start), makes the result STALE.
120
124
  - A config written by an earlier build of this recipe is UNVERIFIED with "run
121
125
  redue init again"; move `redue.config.json` aside and run init.
126
+ - pnpm's store is trusted not to change a file in place, as pnpm never does.
127
+ Editing an installed file in another project that shares the same store
128
+ file changes it here too, unseen. Use `pnpm patch`, or a copying install
129
+ (`package-import-method=copy`), if you edit installed files.
122
130
  - Windows is not supported; the check stays recording-only there.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.17",
4
+ "version": "0.1.0-alpha.18",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -11,7 +11,7 @@
11
11
  "redue": "./bin/redue.mjs"
12
12
  },
13
13
  "scripts": {
14
- "test": "node --test --test-concurrency=1 test/*.test.mjs",
14
+ "test": "node test/run-suite.mjs",
15
15
  "test:package": "node test/acceptance/package-smoke.mjs",
16
16
  "demo": "node test/acceptance/package-smoke.mjs --demo",
17
17
  "pretest": "npm --prefix test/fixtures/typescript-5-5-2 ci --no-audit --no-fund && npm --prefix test/fixtures/typescript-6-0-2 ci --no-audit --no-fund && npm --prefix test/fixtures/typescript-6-0-3 ci --no-audit --no-fund"
@@ -134,7 +134,7 @@ function earlierBlock(host,rows=[]){
134
134
  }
135
135
  // One form per check: the package manager's script form when there is one,
136
136
  // since that is what the project's own instructions usually name.
137
- const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
137
+ const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).filter(row=>!row.part).map(row=>({kind:row.kind,
138
138
  form:row.forms.find(form=>/^(?:npm run|pnpm|yarn) /.test(form))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
139
139
  const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
140
140
  `Before deciding whether configured verification needs repeating, use REDUE.\n`+
@@ -166,7 +166,7 @@ function knownBlock(host,relative,block){
166
166
  function skillFor(relative,host,config){
167
167
  // Double quotes work in POSIX shells, cmd and PowerShell for ordinary names.
168
168
  const redue=redueFor(relative)??`redue --config ${JSON.stringify(relative)}`,status=`${redue} status --sync --json`;
169
- const rows=host==='claude'?hookForms(config,relative):[];
169
+ const rows=host==='claude'?hookForms(config,relative).filter(row=>!row.part):[];
170
170
  const label=row=>row.kind==='test'?(row.check==='test'?'Unit tests':`Unit tests \`${row.check}\``):
171
171
  row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``;
172
172
  const commands=rows.map(row=>`- ${label(row)}: `+
@@ -10,6 +10,7 @@ import {spawn} from 'node:child_process';
10
10
  import {projectState} from './project-location.mjs';
11
11
  import {realObservedPath} from './path-identity.mjs';
12
12
  import {processAlive} from './process-liveness.mjs';
13
+ import {scriptRunChecks} from './typescript-invocation.mjs';
13
14
 
14
15
  export const hookCommand='redue agent hook claude';
15
16
  // No shell metacharacters; `@` starts scoped package names.
@@ -91,29 +92,65 @@ const checkScript=check=>typeof check.script==='string'?check.script:
91
92
  Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
92
93
  check.command[check.command.indexOf('run')+1]:null;
93
94
 
95
+ // A typecheck check whose command is the compiler: its arguments are one tsc
96
+ // run of its script (typescript-invocation.mjs).
97
+ function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(check)||'');}
94
98
  // Exact commands that rerun each configured typecheck or direct test check.
95
99
  // `forms` work from the project root; `local` ones work in the check's own
96
100
  // directory `dir`, after a `cd` into it. Names that would need shell quoting
97
- // get no form, so the hook never fires for them.
101
+ // get no form, so the hook never fires for them. The script forms of compiler
102
+ // checks name the script's checks as {checks, script}: the hook acts on them
103
+ // only when the script's tsc runs are those checks' runs, one each. With one
104
+ // check they stay on its row (`scriptForms`); a chain's checks get a row for
105
+ // the script, and their own rows (`part`) keep only their own forms.
98
106
  export function hookForms(config,configFile=defaultConfig){
99
- const rows=[],redue=redueFor(configFile),pm=config?.packageManager;
100
- for(const check of config?.checks||[]){
101
- if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
102
- const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),script=checkScript(check);
107
+ 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));
109
+ const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
110
+ JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
111
+ for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
112
+ const scriptRow=(dir,script,workspace)=>({forms:dir==='.'?scriptForms(pm,script):dir!==null&&safe.test(dir)?workspaceForms(pm,workspace,script):[],
113
+ local:dir!=='.'&&dir!==null&&safe.test(dir)?scriptForms(pm,script):[]});
114
+ for(const check of eligible){
115
+ const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
116
+ group=key?scripts.get(key):null,part=group?.length>1;
117
+ const own=group?null:checkScript(check);
103
118
  if(dir==='.'){
104
- forms.push(...scriptForms(pm,script),...toolForms(pm,toolText(check)));
119
+ forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check)));
105
120
  if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
106
121
  check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
107
122
  forms.push(check.command.join(' '));
108
123
  }else if(dir!==null&&safe.test(dir)){
109
- forms.push(...workspaceForms(pm,workspaceName(check),script));
110
- local.push(...scriptForms(pm,script),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
124
+ forms.push(...workspaceForms(pm,workspaceName(check),own));
125
+ local.push(...scriptForms(pm,own),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
111
126
  }
127
+ let extra={};
128
+ if(group&&!part){
129
+ const {forms:scriptOnly,local:scriptLocal}=scriptRow(dir,checkScript(check),workspaceName(check));
130
+ forms.splice(redue?1:0,0,...scriptOnly);local.unshift(...scriptLocal);
131
+ extra={checks:group,script:checkScript(check),scriptForms:[...scriptOnly,...scriptLocal]};
132
+ }
133
+ if(part)extra={part:true};
112
134
  if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
113
- forms:[...new Set(forms)],local:[...new Set(local)]});
135
+ forms:[...new Set(forms)],local:[...new Set(local)],...extra});
136
+ }
137
+ for(const [key,checks] of scripts){
138
+ if(checks.length<2)continue;
139
+ const [dir,script,workspace]=JSON.parse(key),{forms,local}=scriptRow(dir,script,workspace);
140
+ if(forms.length||local.length)rows.push({check:checks[0].replace(/\.\d+$/,''),checks,script,
141
+ scriptForms:[...forms,...local],kind:'typecheck',dir,forms,local});
114
142
  }
115
143
  return rows;
116
144
  }
145
+ // The checks a compiler script row runs, in the order of the script's runs in
146
+ // the package's package.json now; null when they no longer correspond (the
147
+ // script changed since init), so the hook leaves the command alone.
148
+ export function scriptChecks(row,config,projectRoot){
149
+ let scripts;try{scripts=JSON.parse(fs.readFileSync(path.join(projectRoot,row.dir,'package.json'),'utf8')).scripts;}catch{return null;}
150
+ if(scripts?.['pre'+row.script]||scripts?.['post'+row.script])return null;
151
+ const args=name=>config.checks.find(check=>check.name===name)?.command?.slice(2);
152
+ return scriptRunChecks(scripts?.[row.script],row.checks.map(name=>({name,args:args(name)})));
153
+ }
117
154
 
118
155
  // A turbo or `pnpm -r` command that runs one task in every workspace package:
119
156
  // {tool, task}. No filters and no nx: both can add packages or tasks that only
@@ -166,10 +203,17 @@ export async function fanoutChecks(fan,config,projectRoot){
166
203
  return null;
167
204
  const names=[];
168
205
  for(const item of packages){
169
- const check=config.checks.find(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
206
+ const own=config.checks.filter(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
170
207
  workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
171
- if(!check)return null;
172
- names.push(check.name);
208
+ if(!own.length)return null;
209
+ // A compiler script's checks: one per tsc run it has now.
210
+ if(own.every(tscCheck)){
211
+ const ordered=scriptRunChecks(item.scripts[fan.task],own.map(c=>({name:c.name,args:c.command.slice(2)})));
212
+ if(!ordered)return null;
213
+ names.push(...ordered);continue;
214
+ }
215
+ if(own.length!==1)return null;
216
+ names.push(own[0].name);
173
217
  }
174
218
  return names;
175
219
  }
@@ -199,18 +243,23 @@ function matched(command,config,configFile,dir='.',scripts={}){
199
243
  return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
200
244
  if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
201
245
  }
202
- const check=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
203
- row.dir===dir&&row.local.includes(parts.base))?.check;
204
- return check?{check,rest:parts.rest}:null;
246
+ const row=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
247
+ row.dir===dir&&row.local.includes(parts.base));
248
+ // A script form of compiler checks is checked against the script first.
249
+ return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
205
250
  }
206
- export function matchCheck(command,config,configFile,dir,scripts){return matched(command,config,configFile,dir,scripts)?.check||null;}
207
- // The same check through REDUE, keeping the output suffix; null if it already
208
- // is, or if more commands follow it.
251
+ export function matchCheck(command,config,configFile,dir,scripts){
252
+ const match=matched(command,config,configFile,dir,scripts);
253
+ return match?.row?match.row.checks.join(' && '):match?.check||null;
254
+ }
255
+ // The same check (or checks, in order) through REDUE, keeping the output
256
+ // suffix; null if it already is, or if more commands follow it.
209
257
  export function recordedCommand(command,name,configFile=defaultConfig){
258
+ const names=Array.isArray(name)?name:[name];
210
259
  const parts=split(command),redue=redueFor(configFile);
211
- if(!parts||parts.rest!==null||!safe.test(name)||!redue)return null;
212
- const run=`${redue} run ${name}`;
213
- return parts.base===run?null:run+parts.suffix;
260
+ if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
261
+ const run=names.map(value=>`${redue} run ${value}`).join(' && ');
262
+ return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
214
263
  }
215
264
 
216
265
  // A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
@@ -277,9 +326,11 @@ export function statusTimeout(status,name){
277
326
  Math.min(run.durationMs/2,10000):2000;
278
327
  }
279
328
  // From the observer's last published status, which is small, not the receipts.
280
- export function lastRunTimeout(config,parsed,name){
329
+ // The longest wait among `names`, read once.
330
+ export function lastRunTimeout(config,parsed,...names){
281
331
  try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
282
- return statusTimeout(JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8')),name);}
332
+ const status=JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8'));
333
+ return Math.max(...names.map(name=>statusTimeout(status,name)));}
283
334
  catch{return 2000;}
284
335
  }
285
336
 
@@ -329,10 +380,34 @@ export async function claudeHook(input,{entry,timeout}){
329
380
  if(match.fanout){
330
381
  const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
331
382
  if(!names)return null;
332
- const status=await synchronizedStatus(entry,config,root,timeout??Math.max(...names.map(name=>lastRunTimeout(config,parsed,name)))),
383
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
333
384
  reason=fanoutVerdict(status,names,match.rest);
334
385
  return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
335
386
  }
387
+ // The configured config path as the command's directory sees it.
388
+ // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
389
+ const configPath=!entered.prefix?record.config:
390
+ path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
391
+ // A compiler script: its checks in the order of its runs, or nothing when
392
+ // they no longer correspond. One run is an ordinary check. A chain is
393
+ // skipped when every run is CURRENT, else the runs that are not run through
394
+ // redue run, in order.
395
+ if(match.row){
396
+ const names=scriptChecks(match.row,parsed,project.projectRoot);
397
+ if(!names)return null;
398
+ if(names.length===1)match.check=names[0];
399
+ else{
400
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
401
+ reason=fanoutVerdict(status,names,match.rest);
402
+ if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
403
+ const pending=names.filter(name=>!verdict(status,name));
404
+ const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
405
+ names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
406
+ return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
407
+ permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
408
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
409
+ }
410
+ }
336
411
  const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
337
412
  reason=verdict(status,name,match.rest);
338
413
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
@@ -342,9 +417,7 @@ export async function claudeHook(input,{entry,timeout}){
342
417
  // stays too: the rewrite returns "allow", which would also skip the prompt
343
418
  // for the rest of the chain.
344
419
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
345
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
346
- // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
347
- !entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
420
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
348
421
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
349
422
  // to the rewritten command; "allow" skips only the prompt.
350
423
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
package/src/cli.mjs CHANGED
@@ -361,8 +361,8 @@ async function execute() {
361
361
  guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
362
362
  runner:runnerPid,environment:selected.environment||{},coverage});
363
363
  runFacts=guard.runFacts;
364
- const shared=sharedInstalledFile(root,testFacts);
365
- if(shared)guard.issues.push(`installed file ${shared} has another hard link, likely into a package store; a change through it is not observed`);
364
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
365
+ if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
366
366
  fs.rmSync(testRecord,{recursive:true,force:true});
367
367
  }
368
368
  // The daemon reads the recording from owned state; control requests are small.