@redue/cli 0.1.0-alpha.25 → 0.1.0-alpha.27

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,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.27 — 2026-11-01
4
+
5
+ A build or test run no longer makes a sandboxed typecheck or lint STALE,
6
+ and `redue stats` shows what REDUE saved you.
7
+
8
+ - **Files git ignores are hidden from sandboxed runs.** A sandboxed
9
+ typecheck or lint now runs without the files git ignores (`dist/`,
10
+ `.next/`, coverage files, `.env`), except `node_modules`, `.venv` and
11
+ runner caches. Those files are not its inputs, so a build or test run that
12
+ writes them leaves the result CURRENT. The claim: the result holds for a
13
+ run that sees only the project's files that git doesn't ignore, its
14
+ installed packages and the toolchain. Only the project's `.gitignore`
15
+ files and `.git/info/exclude` count, not your global excludes file.
16
+ - **Checks that need ignored files keep working.** `init` tries the
17
+ sandbox with ignored files hidden first (`sandbox-v2`), then with the
18
+ whole checkout (`sandbox-v1`), then the general recipe. A hidden run that
19
+ fails on a hidden file runs again in the sandbox without hiding, and that
20
+ result is kept. Checks set up by alpha.26 stay `sandbox-v1` until you run
21
+ `redue init` again.
22
+ - **Measured.** In the product census, after each project's own build and
23
+ tests ran, checks that stayed CURRENT (alpha.26 in brackets, from the
24
+ measurement census): TypeScript on Linux 31 of 37 (5 of 43), on macOS 24
25
+ of 34 (5 of 38); Python on Linux 31 of 38 (24 of 37), on macOS 28 of 34
26
+ (20 of 33). Where a check qualifies with both, the sandbox stayed CURRENT
27
+ 16 times and the general recipe 9 of 23 (Linux). The strace cross-check
28
+ found no read outside the recorded inputs.
29
+ - **`redue stats`.** Per check: reruns the hook skipped, the time that
30
+ saved, recorded runs, and the top reasons a check was UNVERIFIED, from a
31
+ log in REDUE's local state.
32
+ - `redue remove-state` no longer fails on Windows while the observer is
33
+ still closing its files.
34
+ - Known limits: a sandboxed run can still see that a hidden file exists
35
+ (its name on macOS; listings, sizes and times on Linux), so a tool that
36
+ acts on existence alone is not covered; tests do not use hidden files for
37
+ that reason. On Linux, a file a run creates in a folder that holds hidden
38
+ files cannot be read back in the same run.
39
+ - Contributors: eval entry points that run third-party code refuse to start
40
+ unless `GITHUB_ACTIONS=true`, and importing any eval entry point throws
41
+ before it does anything.
42
+
43
+ ## 0.1.0-alpha.26 — 2026-10-31
44
+
45
+ Typecheck and lint checks run inside an OS sandbox, so REDUE can reuse them
46
+ for native tools and for Python. Python projects are supported for the first
47
+ time.
48
+
49
+ - **Sandboxed recipe.** On macOS (Seatbelt) and Linux 6.7+ (Landlock), a
50
+ root typecheck or lint script runs in a sandbox that sees only the
51
+ project, its installed packages and the toolchain, the way CI does. Its
52
+ inputs are the whole checkout, git-ignored files included, plus the
53
+ installed packages and a fingerprint of the environment it was given. A
54
+ result holds for a run that sees only those. `init` tries the precise
55
+ TypeScript recipes first, then the sandbox, then the general recipe. See
56
+ [sandboxed recipe](docs/user/sandbox.md).
57
+ - **Python.** `redue setup claude` and `init` find mypy, pyright, ty and
58
+ ruff in the project's `.venv`, from its CI workflows, Makefile, justfile,
59
+ tox, nox and scripts. The Claude Code hook recognizes `uv run`,
60
+ `poetry run`, `python -m` and `.venv/bin` forms.
61
+ - **What the sandbox changes.** Network is off. HOME and the temp folder
62
+ are private to the check; HOME is kept between runs so caches persist.
63
+ Tools can read `.git`, but git commands find no repository. A background
64
+ process the check leaves running is stopped when the run ends.
65
+ - **Checked at init.** `init` runs each check plainly and sandboxed, and
66
+ proposes the sandbox only when exit status and output (timings and paths
67
+ aside) match. Otherwise the general recipe applies. A failing run whose
68
+ output looks like a sandbox refusal is rerun plainly; if the plain run
69
+ exits differently, the result is UNVERIFIED. Other failures run once.
70
+ - **Coverage.** In a census of 120 public TypeScript repositories (86
71
+ installed on macOS), 35 have a qualifying check (alpha.24 census: 30):
72
+ typecheck 22, lint 25, tests 2. Of 120 Python repositories, 23 of the 75
73
+ that installed on Linux have one (typecheck 15, lint 22); 20 of 64 on
74
+ macOS. On Linux, every sandboxed run was traced with strace: each file
75
+ it read in the checkout was a recorded input, a cache or its own output.
76
+ - **Measured with Claude Code.** On starlette, graphon and
77
+ claude-agent-sdk-python, sessions took 58 s with REDUE and 125 s without.
78
+ REDUE skipped 10 typechecks and every one matched a plain rerun.
79
+ - Known limits: inside Claude Code's own macOS sandbox and on Windows, the
80
+ sandbox can't start and the general recipe applies. On Linux before 6.7,
81
+ sandboxed runs are UNVERIFIED.
82
+ A build or test run that writes into the checkout (`dist/`, `.next/`,
83
+ coverage files) makes a sandboxed check STALE, as it does a
84
+ general-recipe check: about 1 in 4 TypeScript and 2 in 3 Python checks
85
+ stayed CURRENT through the project's build and tests.
86
+
3
87
  ## 0.1.0-alpha.25 — 2026-10-29
4
88
 
5
89
  Running your tests no longer makes a typecheck STALE, and `status --sync`
package/README.md CHANGED
@@ -10,9 +10,11 @@ check that still holds is not run again and one that is stale is.
10
10
 
11
11
  ## Quickstart (about 5 minutes)
12
12
 
13
- You need macOS or Linux, Node 22 or 24, Git, Claude Code, and a JavaScript or
14
- TypeScript project with its dependencies installed (`npm install`,
15
- `pnpm install` or `yarn`). On Linux, also Python 3.
13
+ You need macOS or Linux, Node 22 or 24, Git, Claude Code, and either a
14
+ JavaScript or TypeScript project with its dependencies installed
15
+ (`npm install`, `pnpm install` or `yarn`), or a Python project with a `.venv`
16
+ in its root (`uv sync`, or Poetry with in-project virtualenvs). On Linux, also
17
+ Python 3.
16
18
 
17
19
  ```sh
18
20
  npm install --global @redue/cli@alpha
@@ -57,26 +59,39 @@ what the agent asks. Plain `redue status` is a fast cached answer and can say
57
59
  UNVERIFIED ("cannot confirm your execution context") where `--sync` would say
58
60
  CURRENT.
59
61
 
62
+ `redue stats` shows, per check, the reruns the hook skipped, the time saved,
63
+ the recorded runs and the top reasons a check was UNVERIFIED, from a log kept
64
+ on your machine.
65
+
60
66
  To undo everything setup did: `redue remove`.
61
67
 
62
68
  ## What it covers today
63
69
 
64
- In a census of 120 active public TypeScript repositories (October 2026, REDUE
65
- at commit 0fcd19b, after 0.1.0-alpha.23), each installed as its users would,
66
- with dependencies' install scripts on, 86 installed cleanly. A check counts
67
- when two recorded runs on the same code both came out CURRENT on the same
68
- inputs. 30 repositories had at least one such check:
70
+ In a census of 120 active public TypeScript repositories (November 2026, REDUE
71
+ at commit e264a67, before 0.1.0-alpha.27), each installed as its users would,
72
+ with dependencies' install scripts on, 90 installed cleanly on a macOS runner.
73
+ A check counts when two recorded runs on the same code both came out CURRENT
74
+ on the same inputs. 35 repositories had at least one such check:
69
75
 
70
76
  | Check | Repositories | How |
71
77
  |---|---|---|
72
- | Typecheck | 21 | 15 by running the project's script with every Node process recorded ([general recipe](docs/user/node-script.md)); 6 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)) |
73
- | Lint | 18 | general recipe |
78
+ | Typecheck | 22 | 16 by running the project's script in an OS sandbox ([sandboxed recipe](docs/user/sandbox.md)), 15 of them with the files git ignores hidden; 5 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)); 1 by running the script with every Node process recorded ([general recipe](docs/user/node-script.md)) |
79
+ | Lint | 24 | 21 sandboxed recipe (19 with ignored files hidden), 3 general recipe |
74
80
  | Tests | 2 | Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
75
81
 
82
+ In a census of 120 Python repositories, 74 installed on a Linux runner
83
+ and 23 had a qualifying check: typecheck (mypy, pyright, ty) 15, lint (ruff)
84
+ 22, all through the sandboxed recipe with ignored files hidden. A check whose
85
+ git-ignored files are hidden stays CURRENT when a build or test run rewrites
86
+ them: after each project's build and tests ran, 31 of 37 such TypeScript
87
+ checks and 31 of 38 Python checks were still CURRENT on Linux. On Linux, every sandboxed run was traced
88
+ with strace: each file it read in the checkout was a recorded input, a cache
89
+ or its own output.
90
+
76
91
  Everything else stays UNVERIFIED, with a reason you can read in
77
92
  `redue explain`. The usual ones: a script starts a program REDUE cannot see
78
- into (esbuild, turbo, nx, oxlint's tsgolint, TypeScript 7's native compiler),
79
- the project uses Bun, or a test suite writes into its own inputs.
93
+ into and the sandbox can't run (inside Claude Code's macOS sandbox, on
94
+ Windows), the project uses Bun, or a check writes into its own inputs.
80
95
  [Supported projects](docs/user/supported-projects.md) has the details.
81
96
 
82
97
  ## Measured with Claude Code
package/bin/redue.mjs CHANGED
@@ -7,6 +7,7 @@ import {directCompiler,directQualification,directEnvironment,directInstalledInpu
7
7
  import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
8
8
  import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
9
9
  import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
10
+ import {hiddenListIssue,hiddenQualification,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import {createHash} from 'node:crypto';
@@ -31,7 +32,7 @@ Commands:
31
32
  setup claude [--yes] Set up REDUE and Claude Code in this project in one step:
32
33
  init, agent setup claude, start, one recorded run per check
33
34
  remove [--yes] Undo setup: integration, observer state, and the config setup created
34
- init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script]
35
+ init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script|sandbox]
35
36
  Discover existing scripts; optionally select one workspace/check
36
37
  agent setup codex|claude [--dry-run|--apply] [--json]
37
38
  agent doctor [codex|claude] [--json]
@@ -45,6 +46,7 @@ Commands:
45
46
  detail [CHECK] [--json] Alias for explain
46
47
  run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
47
48
  only when its last pass does not apply to the files as they are now
49
+ stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
48
50
  remove-state Stop and remove only this configuration's owned local state
49
51
 
50
52
  No checks run in the background. Direct commands outside redue create no receipts.`;
@@ -94,7 +96,7 @@ if(command==='agent'){
94
96
  catch(e){error(e.message);}
95
97
  await exit(0);
96
98
  }
97
- if(!['init','start','stop','status','detail','explain','run','remove-state'].includes(command))
99
+ if(!['init','start','stop','status','detail','explain','run','stats','remove-state'].includes(command))
98
100
  error(`unknown command ${command||'<none>'}; use --help`);
99
101
  const json=args.includes('--json');
100
102
  // run --if-due: run the check only when its last pass does not apply now.
@@ -119,9 +121,28 @@ if(short&&(command!=='status'||json||sync))error('--short is a cached status opt
119
121
  if(dryRun&&command!=='init')error('--dry-run is for init');
120
122
 
121
123
  if(command==='init'){
122
- const {discoverNodeProject}=await import('../src/node-onboarding.mjs');
123
- let discovery;try{discovery=discoverNodeProject(process.cwd(),{recipe:recipeSelection});}
124
- catch(e){error(e.message);}
124
+ const {discoverNodeProject,sourceLinkIssue}=await import('../src/node-onboarding.mjs');
125
+ const {discoverPythonProject}=await import('../src/python-onboarding.mjs');
126
+ const {sandboxTrial}=await import('../src/sandbox.mjs');
127
+ // A Node project, a Python project, or both in one checkout.
128
+ const python=!recipeSelection||recipeSelection==='sandbox'?discoverPythonProject(process.cwd()):null;
129
+ let discovery;
130
+ if(fs.existsSync('package.json')||!python)try{discovery=discoverNodeProject(process.cwd(),{recipe:recipeSelection});}
131
+ catch(e){error(e.message);}
132
+ else{const root=realObservedPath(process.cwd());
133
+ const git=spawnSync('git',['rev-parse','--show-toplevel'],{cwd:root,encoding:'utf8',timeout:3000,stdio:['ignore','pipe','ignore']});
134
+ discovery={schema:1,root,manager:{name:null,version:null,layout:null},observationIssue:git.status===0?sourceLinkIssue(root):
135
+ 'Git checkout is unavailable; filesystem observation needs a checkout',workspaces:false,node:process.version,
136
+ checks:[],ambiguous:[],skipped:[],config:{schema:1,checks:[]}};}
137
+ if(python){
138
+ // A Python check takes its tool's name where the name is taken.
139
+ const taken=new Set(discovery.checks.map(row=>row.config.name));
140
+ for(const row of python.checks){if(taken.has(row.config.name))row.config.name=`${row.config.kind}:${row.config.command[0].split('/').at(-1)}`;
141
+ taken.add(row.config.name);}
142
+ if(!discovery.observationIssue)discovery.checks.push(...python.checks);
143
+ discovery.skipped.push(...python.skipped);
144
+ discovery.python=true;
145
+ }
125
146
  if(workspaceSelection||checkSelection){
126
147
  const chosen=discovery.checks.filter(row=>
127
148
  (!workspaceSelection||row.config.workspace===workspaceSelection||
@@ -130,6 +151,35 @@ if(command==='init'){
130
151
  if(!chosen.length)error('no discovered check matches the selected workspace and kind');
131
152
  discovery.checks=chosen;discovery.config.checks=chosen.map(row=>row.config);
132
153
  }
154
+ // A sandboxed check is proposed only when it gives the same exit status and
155
+ // output inside the sandbox as outside it: first with the checkout's
156
+ // git-ignored files hidden (sandbox-v2), else with the whole checkout
157
+ // (sandbox-v1), reusing the plain run. Otherwise init writes what it would
158
+ // have without the sandboxed recipe, with the reason.
159
+ const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
160
+ // A failure both ways that shows a refusal cannot be told from a real one.
161
+ trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
162
+ trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
163
+ `the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
164
+ let trials=0;
165
+ for(const [at,row] of [...discovery.checks.entries()]){
166
+ if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
167
+ if(!json)console.error(`Running ${row.config.name} inside and outside the sandbox to confirm it...`);
168
+ const hidden=await sandboxTrial(discovery.root,row.config,{hide:true});
169
+ if(!hidden.issue)trials++;
170
+ if(!hidden.issue&&hidden.same){
171
+ row.config={...row.config,qualification:hiddenQualification};row.qualification=hiddenQualification;
172
+ row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore are its input")+`; ran both ways at init, exit ${hidden.plain}`;continue;}
173
+ // Without git, nothing is hidden: the whole checkout is tried on its own.
174
+ const trial=hidden.plainRun?await sandboxTrial(discovery.root,row.config,{plain:hidden.plainRun}):
175
+ hidden.issue===hiddenListIssue?await sandboxTrial(discovery.root,row.config):hidden;
176
+ const issue=trialIssue(trial);
177
+ if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}; it needs files git ignores (${hidden.issue??trialIssue(hidden)})`;continue;}
178
+ if(row.fallback)discovery.checks[at]={...row.fallback,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
179
+ else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
180
+ }
181
+ discovery.checks=discovery.checks.filter(Boolean).map(({fallback,...row})=>row);
182
+ discovery.config.checks=discovery.checks.map(row=>row.config);
133
183
  const configured=fs.existsSync(configFile),unsupported=Boolean(discovery.observationIssue||
134
184
  discovery.manager.issue&&!['npm','yarn','pnpm'].includes(discovery.manager.name)||
135
185
  discovery.manager.name==='yarn'&&!discovery.manager.version);
@@ -144,8 +194,9 @@ if(command==='init'){
144
194
  workspace:check.workspace||null,level,reason})),config:configFile,
145
195
  written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
146
196
  if(!json) {
147
- console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
197
+ if(summary.package_manager||!discovery.python)console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
148
198
  `${summary.version?' '+summary.version:''}; ${summary.installation_layout} installation.`);
199
+ if(discovery.python)console.log('Python project; tools run from .venv.');
149
200
  if(summary.issue)console.log(`Boundary: ${summary.issue}`);
150
201
  for(const row of summary.checks){
151
202
  console.log(` ${row.name} (${row.script||row.command?.join(' ')}) — `+
@@ -167,7 +218,7 @@ if(command==='init'){
167
218
  if(!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
168
219
  error('init writes only at the repository root; use --config there or --dry-run');
169
220
  fs.writeFileSync(configFile,JSON.stringify(discovery.config,null,2)+'\n',{flag:'wx'});
170
- if(!json){console.log(`Created ${configFile}. No checks were executed.`);
221
+ if(!json){console.log(`Created ${configFile}. ${trials?'Sandboxed checks ran both ways to confirm them; no results were recorded.':'No checks were executed.'}`);
171
222
  console.log('Ready to qualify means the supported contract will be validated; it is not verification evidence.');
172
223
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
173
224
  console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
@@ -269,6 +320,7 @@ const checks=publicConfig.checks.map(check=>{
269
320
  const pnpmTypecheck=check.qualification==='pnpm-tsc-v1';
270
321
  const testsDirect=check.qualification===testsQualification;
271
322
  const generalScript=check.qualification===scriptQualification;
323
+ const sandboxed=isSandboxed(check.qualification);
272
324
  if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect&&!testsDirect)
273
325
  error(`${check.name}: choose script or command, not both`);
274
326
  const manager=publicConfig.packageManager;
@@ -278,12 +330,16 @@ const checks=publicConfig.checks.map(check=>{
278
330
  [`@which:${manager}`,'run',check.script]:null;
279
331
  const npmAutomatic=check.qualification==='typescript-noemit-v1';
280
332
  const yarnWorkspace=check.qualification==='yarn-workspace-tsc-v1';
281
- if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect&&!generalScript)
333
+ if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect&&!generalScript&&!sandboxed)
282
334
  error(`${check.name}: unknown qualification contract`);
283
335
  // The general recipe runs the root package script through the package manager.
284
336
  if(generalScript&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.command||check.cwd&&check.cwd!=='.'||
285
337
  check.workspace||!scriptKinds.includes(check.kind)))
286
338
  error(`${check.name}: the general recipe needs a root typecheck, test or lint script, without command or cwd`);
339
+ // The sandboxed recipe runs a root package script, or a tool from the project's .venv.
340
+ if(sandboxed&&(check.cwd&&check.cwd!=='.'||check.workspace||!sandboxKinds.includes(check.kind)||
341
+ (check.script?!['npm','pnpm','yarn'].includes(manager):!pythonTool(check))))
342
+ error(`${check.name}: the sandboxed recipe needs a root ${sandboxKinds.join(' or ')} script, or a command that runs a tool from .venv/bin, without cwd`);
287
343
  if(npmDirect&&(manager!=='npm'||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
288
344
  check.command[0]!=='@node'||check.command[1]!=='@typescript-compiler:.'||
289
345
  !tscRun({},{script:null,args:check.command.slice(2)}).run?.noEmit))
@@ -324,7 +380,8 @@ const checks=publicConfig.checks.map(check=>{
324
380
  check.workspace||'.',...run]]:[];
325
381
  const windowsContext=process.platform==='win32'?
326
382
  ['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
327
- const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
383
+ const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):
384
+ sandboxed?sandboxEnvironment(Boolean(pythonTool(check)),check.script?manager:null):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
328
385
  'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
329
386
  pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
330
387
  {variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
@@ -351,11 +408,18 @@ const checks=publicConfig.checks.map(check=>{
351
408
  ...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
352
409
  ...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
353
410
  ...(generalScript?{qualificationInterpretation:scriptInterpretation}:{}),
411
+ ...(sandboxed?{qualificationInterpretation:sandboxInterpretationOf(check.qualification)}:{}),
354
412
  workspace:check.workspace||null,kind:check.kind||null};
355
413
  });
356
414
  const runtime={schema:1,provider:'declared-project@1',root,state,checks};
357
415
  try{assertOwnedStatePlacement(runtime);}catch(e){error(e.message);}
358
416
  const serialized=JSON.stringify(runtime,null,2)+'\n';
417
+ if(command==='stats'){
418
+ const {readStats,renderStats}=await import('../src/stats.mjs');
419
+ const value=readStats(state,publicConfig.checks.map(item=>item.name));
420
+ console.log(json?JSON.stringify(value):renderStats(value));
421
+ await exit(0);
422
+ }
359
423
  const maintenance=command==='stop'||command==='remove-state';
360
424
  if(maintenance&&!fs.existsSync(runtimeFile)){
361
425
  console.log(`No REDUE state exists for ${configFile}.`);
@@ -395,11 +459,14 @@ const internal=command==='remove-state'?'uninstall':
395
459
  // Asked when this part of a command runs, after earlier parts may have
396
460
  // changed files. REDUE_FORCE=1 runs it anyway.
397
461
  if(ifDue&&process.env.REDUE_FORCE!=='1'){
398
- const {verdict}=await import('../src/claude-hook.mjs');
462
+ const [{verdict},{note,skipNote,dueNote}]=await Promise.all([import('../src/claude-hook.mjs'),import('../src/stats.mjs')]);
399
463
  const asked=spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,...(stateOverride?['--state-dir',stateOverride]:[]),
400
464
  'status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],env:process.env,timeout:600_000});
401
465
  let status=null;try{status=JSON.parse(asked.stdout);}catch{}
466
+ const due=dueNote(status,check);
467
+ if(due)note(state,due);
402
468
  if(verdict(status,check)){
469
+ note(state,skipNote(status,check,'if-due'));
403
470
  const row=status.checks.find(item=>item.name===check);
404
471
  if(json)console.log(JSON.stringify({check,skipped:true,result:'PASS',run_id:row.invocation.runId}));
405
472
  else console.error(`REDUE: ${check} passed on these exact inputs (run ${row.invocation.runId}); not run again. REDUE_FORCE=1 runs it anyway.`);
package/docs/README.md CHANGED
@@ -5,6 +5,7 @@ For users:
5
5
  - [Install, initialize, run, and remove](user/getting-started.md)
6
6
  - [Fixed-build opt-in development pilot](user/pilot.md)
7
7
  - [Supported projects and conservative boundaries](user/supported-projects.md)
8
+ - [Sandboxed recipe: typecheck and lint in any language, Python included](user/sandbox.md)
8
9
  - [Configuration](user/configuration.md)
9
10
  - [Connect coding agents and apply the decision policy](user/agents.md)
10
11
  - [JSON contract](user/json.md)
@@ -4,10 +4,12 @@ The general recipe records a project's own typecheck or lint script, run
4
4
  through its package manager, and reports later whether that result still
5
5
  applies to the files you have. It is a lower tier than the strict recipes:
6
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.
7
+ pnpm and Yarn TypeScript recipes, the [direct test recipe](tests-direct.md)
8
+ and, on macOS and Linux, the [sandboxed recipe](sandbox.md) first. It uses
9
+ the general recipe for a root typecheck or lint script none of them takes,
10
+ and where the sandbox cannot run (at init, or at run time inside Claude
11
+ Code's macOS sandbox). Test scripts use the direct test recipe; `init` does
12
+ not propose the general recipe for them.
11
13
 
12
14
  It supports a project installed by npm, pnpm 9-12 (isolated or hoisted
13
15
  `node_modules`) or Yarn with the `node-modules` linker, with one lockfile kind
@@ -121,8 +123,10 @@ recording-only.
121
123
  Anything else makes the result UNVERIFIED and names the program: `git`,
122
124
  `sleep`, `find`, and native programs from installed packages such as esbuild,
123
125
  turbo, tsgo, tsgolint and biome, whose reads, writes and network use REDUE
124
- cannot see. `init` reads the script the same way and keeps a script that
125
- starts such a program recording-only, with the program in the reason.
126
+ cannot see. `init` reads the script the same way. A typecheck or lint script
127
+ gets the [sandboxed recipe](sandbox.md) first where it can run, which covers
128
+ such programs; otherwise a script that starts one stays recording-only, with
129
+ the program in the reason.
126
130
 
127
131
  A command line may use `&&`, `||`, `;`, `|`, quotes without expansions,
128
132
  globs in arguments and redirection to `/dev/null`. Variables, command
@@ -0,0 +1,221 @@
1
+ # Sandboxed recipe (typecheck and lint, any language)
2
+
3
+ The sandboxed recipe runs a typecheck or lint command inside an operating
4
+ system sandbox and reports later whether that result still applies to the
5
+ files you have. The sandbox holds for every process the command starts, so it
6
+ covers programs REDUE cannot record: TypeScript 7's native compiler, esbuild,
7
+ oxlint and tsgolint, biome, turbo, and Python tools such as mypy, pyright, ty
8
+ and ruff.
9
+
10
+ **The claim.** A sandboxed result holds for a run that sees only the project's
11
+ files that git doesn't ignore, its installed packages and the toolchain, the
12
+ way CI does. It does not say that a run with your own configuration files, a
13
+ service on your machine or the network would give the same result.
14
+
15
+ A check that needs files git ignores (generated code, another package's
16
+ built declarations) gets the whole checkout instead: its result holds for a
17
+ run that sees the project, git-ignored files included, its installed packages
18
+ and the toolchain. See [files git ignores](#files-git-ignores).
19
+
20
+ ## Where it applies
21
+
22
+ - **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
23
+ with the `node-modules` linker). `init` tries the
24
+ [direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
25
+ TypeScript recipes first; they stay first where they apply. Then the
26
+ sandboxed recipe, before the [general recipe](node-script.md), since it
27
+ also covers the native programs a script starts. Where init's trials show
28
+ a difference, or the sandbox cannot run, `init` writes the general recipe
29
+ as before. Where the sandbox cannot start at run time (inside Claude Code's
30
+ macOS sandbox), a script the general recipe takes is recorded as it records
31
+ it.
32
+ - **Python projects:** mypy, pyright, ty and ruff installed in the project's
33
+ `.venv` (`uv sync` makes one; Poetry does with `virtualenvs.in-project`).
34
+ `init` uses the arguments your CI workflow, Makefile, tox, nox, justfile or
35
+ shell scripts in `scripts/` run the tool with, through `uv run`, `poetry run`,
36
+ `python -m` or `.venv/bin`. Without them it uses the tool's default:
37
+ `ruff check`, `ty check`, `pyright`, and `mypy` (with `.` when mypy's
38
+ configuration names no `files`).
39
+ - **Platforms:** macOS (Seatbelt, `sandbox-exec`) and Linux 6.7 or later
40
+ (Landlock; the launcher needs Python 3).
41
+
42
+ ```sh
43
+ redue init
44
+ redue start
45
+ redue run typecheck
46
+ redue status --sync
47
+ ```
48
+
49
+ A Python check in the config:
50
+
51
+ ```json
52
+ {"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v2",
53
+ "command": [".venv/bin/mypy", "src"], "inputs": ["**", "..."], "installedInputs": [".venv/**", "..."]}
54
+ ```
55
+
56
+ A Node check names its script, as the general recipe's do:
57
+ `{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v2", ...}`.
58
+ `sandbox-v2` hides the files git ignores from the run; `sandbox-v1` runs it on
59
+ the whole checkout.
60
+
61
+ With the Claude Code hook, `uv run mypy src`, `poetry run mypy src`,
62
+ `python -m mypy src`, `.venv/bin/mypy src` and `mypy src` are recognized as
63
+ that check. The last two count only when PATH finds the tool or Python in the
64
+ project's `.venv/bin` first. When the hook runs the check through REDUE, it
65
+ keeps `uv run` or `poetry run` in front, so the environment still syncs first.
66
+
67
+ ## Files git ignores
68
+
69
+ `init` runs each check three ways at most: plainly, sandboxed with the files
70
+ git ignores hidden, and, when those two differ, sandboxed with the whole
71
+ checkout (reusing the plain run). The first sandboxed way that gives the same
72
+ result is written: `sandbox-v2` or `sandbox-v1`.
73
+
74
+ With `sandbox-v2`, each run hides what `git ls-files --others --ignored
75
+ --exclude-standard --directory` lists, except `node_modules`, `.venv` and
76
+ runner caches: build output (`dist/`, `.next/`), coverage files, `.env` files.
77
+ Only the project's rules count: its `.gitignore` files and
78
+ `.git/info/exclude`, not your global excludes file. Those files are not
79
+ inputs, so a build or test run that writes them leaves the result CURRENT.
80
+
81
+ When a `sandbox-v2` run fails with a refusal that names a hidden file (a
82
+ missing file counts only when it names one, since Linux tools report an
83
+ unreadable file as missing), REDUE runs the check again in the sandbox
84
+ without hiding anything. That result is kept and compared on every file,
85
+ git-ignored ones included. Run `redue init` again to write `sandbox-v1` for a
86
+ check that keeps needing them.
87
+
88
+ ## What the run sees
89
+
90
+ It may read and execute only beneath:
91
+
92
+ - the checkout, without the files git ignores for `sandbox-v2`;
93
+ - the toolchain: Node's installation and the package managers' (with
94
+ Corepack's downloads), or the Python installation the `.venv` was made from;
95
+ - system directories (`/usr`, `/bin`, `/etc` and the like on Linux; `/usr`,
96
+ `/System`, `/Library`, `/opt/homebrew` and the like on macOS);
97
+ - a private home directory and a fresh temporary directory, in REDUE's state.
98
+
99
+ It has no network: no TCP connection, loopback included. On macOS its only
100
+ Unix sockets are in its own temporary directory, which is new for each run.
101
+ On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
102
+ run.
103
+
104
+ What the check leaves running is stopped when it exits, as at the end of a
105
+ CI job: its process group gets SIGTERM, and SIGKILL a second later. On Linux
106
+ the launcher also stops daemons that started their own session. On macOS
107
+ such a daemon outlives the run, but no later run can reach it: its socket
108
+ was in a temporary directory that is gone. The run gets no terminal input.
109
+
110
+ The checkout's `.git` is not an input. Tools still see it, as they do in a CI
111
+ checkout (a linter that applies `.gitignore` only inside a repository does),
112
+ but git commands find no repository: `GIT_DIR` points nowhere. A script that
113
+ runs git (`git diff --exit-code`) gives a different result inside the
114
+ sandbox, and `init` leaves it to the other recipes.
115
+
116
+ It gets a controlled environment, not yours:
117
+
118
+ - the variables the check declares (`TZ`, `LANG`, `CI`, `USER`, `LC_*`,
119
+ `DYLD_*`, `LD_*`, and `NODE_*`, `npm_config_*`, `PNPM_*`, `YARN_*`,
120
+ `COREPACK_*` for Node, or `PYTHON*`, `MYPY*`, `RUFF_*`, `PYRIGHT*`, `TY_*`
121
+ for Python), with your values;
122
+ - your PATH, without the project's `.venv/bin`, which a Python check gets
123
+ first instead, so `uv run` and an activated environment give the same PATH;
124
+ - `HOME`, `TMPDIR` and the XDG directories in its private directories;
125
+ `VIRTUAL_ENV` for a Python check; Corepack's home where Corepack keeps it.
126
+
127
+ ## What REDUE claims
128
+
129
+ CURRENT means the last sandboxed run's inputs are unchanged:
130
+
131
+ - every file in the checkout git doesn't ignore (`sandbox-v2`), or every file
132
+ in it, git-ignored files and git-ignored links to files in it included
133
+ (`sandbox-v1`), except `.git` and runner caches (the general recipe's, plus
134
+ `__pycache__`, `.mypy_cache`, `.ruff_cache` and `.pytest_cache`);
135
+ - `node_modules` and `.venv`, observed as installed files, by identity;
136
+ - the environment the run gets (without display variables such as `TERM`
137
+ and `COLUMNS`), and the places it may read;
138
+ - Node's and the package managers' executables, or the interpreter the
139
+ `.venv` was made from, by file identity;
140
+ - every directory on PATH, by identity: installing, removing or upgrading a
141
+ program in one makes the result STALE;
142
+ - `.git/info/exclude` and `.git/config`, by content: ruff and biome read the
143
+ ignore rules there, and they decide what `sandbox-v2` hides. For a project
144
+ in a subdirectory of its repository, also the `.gitignore` files above it.
145
+
146
+ ## Telling a refusal from a real failure
147
+
148
+ A run the sandbox refused something fails like any other. REDUE does not run
149
+ every failure twice to find out:
150
+
151
+ - `init` runs each proposed check twice, plainly and sandboxed, and proposes
152
+ it only when both give the same exit status and the same output, and a
153
+ failure shows no sign of a refusal. The output is compared without colors,
154
+ timings, the temporary and home directories, and lines about caches.
155
+ Otherwise `init` writes what it would have without this recipe and says why.
156
+ These are the only runs `init` makes.
157
+ - When a `sandbox-v2` run fails with a refusal that names a hidden file, it
158
+ runs again in the sandbox without hiding (see
159
+ [files git ignores](#files-git-ignores)).
160
+ - When a sandboxed run fails and its output has a line that looks like a
161
+ refusal ("Operation not permitted", "Permission denied", "Read-only file
162
+ system", a name lookup or a connection that failed), REDUE runs the check
163
+ again outside the sandbox. If that exits differently, the result is the
164
+ plain run's and UNVERIFIED: "the sandbox changed the result", with the
165
+ line. A failure without such a line is not run again.
166
+
167
+ ## The private home
168
+
169
+ The run's home directory is kept between runs, per check, so caches tools
170
+ build there (font caches, a type checker's cache) are not rebuilt every run.
171
+ It starts empty: your configuration files are not in it. What a run leaves
172
+ there can change a later run, and REDUE trusts it like a runner cache. Remove
173
+ it with `redue remove-state`.
174
+
175
+ ## Limits
176
+
177
+ - Inside Claude Code's own sandbox on macOS, `sandbox-exec` cannot start: the
178
+ run goes ahead unsandboxed and is UNVERIFIED ("the run was not sandboxed").
179
+ On Linux, Landlock works inside it.
180
+ - Windows is not supported. The check stays recording-only there.
181
+ - Linux before 6.7 (Landlock ABI below 4) cannot refuse TCP connections, so a
182
+ run there is UNVERIFIED. UDP is not refused on Linux.
183
+ - System files (time zones, certificates, `/etc`) are not inputs: a change to
184
+ them does not make a result STALE.
185
+ - A program that reads files in `.git` itself, without git (a commit hash for
186
+ a version string), reads something REDUE does not observe: a commit does
187
+ not make the result STALE.
188
+ - Runner caches in the checkout are trusted as the general recipe trusts them.
189
+ - On Linux, a run can still reach a daemon started outside it through a Unix
190
+ socket file (Landlock does not cover them), and what that daemon reads is
191
+ not seen. On macOS it cannot.
192
+ - On Linux, uv hard-links `.venv` files to its cache. The cache is
193
+ toolchain: a file rewritten through the cache, not through the `.venv`, is
194
+ noticed only when the observer next reads the `.venv` (when it starts).
195
+ - A `.venv` link that leads out of the checkout, other than the interpreter's,
196
+ keeps the check recording-only and names the link, and so does a `.venv`
197
+ that uses the system site-packages.
198
+ - A git-ignored link to a directory, or out of the checkout, is not observed,
199
+ and neither is a `node_modules` directory outside the installed ones (a
200
+ nested example project's).
201
+ - A script that changes files (`--fix`, `--write`) does not get this recipe:
202
+ `init` would run it twice.
203
+ - With `sandbox-v2`, a run cannot read a hidden file, but it can still see
204
+ that one exists. On macOS it sees the names of hidden files and folders at
205
+ the level where they start (`dist` in the checkout's root). On Linux it can
206
+ also list a hidden folder and see its files' sizes and times: Landlock
207
+ restricts neither beneath a folder the run may list. A tool that acts on
208
+ whether a git-ignored file exists, without reading it, is not covered: a
209
+ new or removed one does not make the result STALE. Typecheckers and
210
+ linters read what they act on; a test that skips itself when a built file
211
+ is missing does not, which is why hidden files are not used for tests yet.
212
+ - On Linux, a file a `sandbox-v2` run creates in a folder that holds hidden
213
+ files (the checkout's root, when anything in it is hidden) cannot be read
214
+ back in the same run. `init`'s trial and the rerun without hiding catch a
215
+ check that needs to.
216
+ - A file whose git status changes without a change to its content (`git add
217
+ -f` of an ignored file, `git rm --cached` of a tracked one) is noticed only
218
+ when its content next changes. A change to `.gitignore` or
219
+ `.git/info/exclude` makes the result STALE.
220
+ - A run that moves a hidden folder into a visible place can read it there.
221
+ That changes the checkout during the run, so the run is not reused.
@@ -18,11 +18,15 @@ build scripts. It prefers familiar script names (for typecheck: `typecheck`,
18
18
  (`typecheck:test`, `tsc:build`; not a watch script), then unique recognizable
19
19
  `tsc`, Vitest/Jest, ESLint, or Vite commands under other names. Multiple
20
20
  matches are reported as ambiguous rather than guessed. It never creates a new
21
- project script. Without `--recipe`, `init` tries the strict recipes below first,
22
- then the [general recipe](node-script.md) for a root typecheck or lint
23
- script: the script run through the package manager with every Node process
24
- recorded and the whole checkout as its input. `--recipe typescript-direct`,
25
- `tests-direct` or `node-script` selects one recipe alone. The direct TypeScript recipe supports
21
+ project script. Without `--recipe`, `init` tries the strict recipes below first, then,
22
+ for a root typecheck or lint script, the [sandboxed recipe](sandbox.md) on
23
+ macOS and Linux (the script run inside an OS sandbox, with the files git
24
+ ignores hidden when it gives the same result that way, else on the whole
25
+ checkout), then the [general recipe](node-script.md): the script run
26
+ through the package manager with every Node process recorded and the whole
27
+ checkout as its input. Python projects get the sandboxed recipe for mypy,
28
+ pyright, ty and ruff in their `.venv`. `--recipe typescript-direct`,
29
+ `tests-direct`, `node-script` or `sandbox` selects one recipe alone. The direct TypeScript recipe supports
26
30
  a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
27
31
  or no typecheck script at all (a proposed `tsc --noEmit`; see
28
32
  [script forms](direct-typescript.md#script-forms)), in a single-package npm installation using
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.25",
4
+ "version": "0.1.0-alpha.27",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"