@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 +58 -0
- package/README.md +1 -1
- package/bin/redue.mjs +16 -6
- package/docs/user/agents.md +11 -0
- package/docs/user/configuration.md +4 -0
- package/docs/user/direct-typescript.md +62 -5
- package/docs/user/pilot.md +6 -5
- package/docs/user/supported-projects.md +14 -5
- package/docs/user/tests-direct.md +8 -0
- package/package.json +2 -2
- package/src/agent-integration.mjs +2 -2
- package/src/claude-hook.mjs +100 -27
- package/src/cli.mjs +2 -2
- package/src/daemon.mjs +20 -6
- package/src/declared-plan.mjs +3 -3
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/node-onboarding.mjs +79 -31
- package/src/npm-integrity.mjs +124 -0
- package/src/npmrc.mjs +16 -0
- package/src/plan-worker.mjs +4 -1
- package/src/pnpm-inputs.mjs +63 -35
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/test-direct.mjs +61 -12
- package/src/typescript-input-facts.mjs +7 -7
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +2 -2
- package/src/typescript-qualification.mjs +8 -9
- package/src/workspace-manifests.mjs +3 -4
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.
|
|
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'
|
|
260
|
-
|
|
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'||!
|
|
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).
|
|
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
|
-
|
|
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||'.'
|
|
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}:{}),
|
package/docs/user/agents.md
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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.
|
|
37
|
-
without its outer compile-cache-enabling shim
|
|
38
|
-
|
|
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
|
package/docs/user/pilot.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Opt-in development pilot: alpha.
|
|
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
|
|
13
|
-
|
|
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.
|
|
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.
|
|
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
|
|
22
|
-
|
|
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
|
|
66
|
-
script
|
|
67
|
-
|
|
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.
|
|
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
|
|
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)}: `+
|
package/src/claude-hook.mjs
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
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),
|
|
110
|
-
local.push(...scriptForms(pm,
|
|
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
|
|
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(!
|
|
172
|
-
|
|
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
|
|
203
|
-
row.dir===dir&&row.local.includes(parts.base))
|
|
204
|
-
|
|
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){
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
212
|
-
const run
|
|
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
|
-
|
|
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
|
-
|
|
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??
|
|
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}
|
|
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.
|