@redue/cli 0.1.0-alpha.29 → 0.1.0-alpha.30

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.30 — 2026-11-03
4
+
5
+ `init` runs a check fewer times, and tests try the sandbox first.
6
+
7
+ - **Fewer trial runs at init.** A check proposed for the sandboxed recipe
8
+ now runs once plainly and once in the sandbox with ignored files and
9
+ documentation hidden (`sandbox-v3`). If they differ, it runs once more
10
+ with the whole checkout (`sandbox-v1`), compared with the same plain run.
11
+ When the plain run passed and the sandboxed one failed only on reading a
12
+ hidden file, the check gets `sandbox-v1` without that run. `init` no
13
+ longer tries `sandbox-v2`; existing `sandbox-v2` checks keep working.
14
+ On the 13 census repos with the slowest checks, `init --dry-run` took
15
+ 4740 s in all before and 3870 s after on Linux, 6929 s and 5784 s on
16
+ macOS. OneUptime went from 1204 s to 903 s (Linux) and 1206 s to 605 s
17
+ (macOS). Nomi did not get faster (485 s to 383 s on Linux, 836 s to
18
+ 893 s on macOS): its typecheck agrees on the first trial, and each of its
19
+ two runs stops at init's 5-minute limit.
20
+ - **Tests try the sandbox first.** For a Node test script, `init` tries the
21
+ sandboxed recipe before the direct test recipe, which becomes its
22
+ fallback, when both would run the same script. In the census, on the repos where both qualified (2 on each OS),
23
+ the direct recipe's results went STALE when the project's build rewrote
24
+ its output, and the sandboxed results stayed CURRENT. A test script now
25
+ costs `init` one plain run and one or two sandboxed runs.
26
+ - Known limit: artsy/eigen's typecheck, which fails in the census, no
27
+ longer agrees with the whole checkout at init and falls back to the
28
+ general recipe (Linux) or recording (macOS).
29
+
3
30
  ## 0.1.0-alpha.29 — 2026-11-03
4
31
 
5
32
  Editing documentation no longer makes sandboxed checks STALE, a fast check
package/bin/redue.mjs CHANGED
@@ -7,7 +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 {docsQualification,hiddenDocsIssue,hiddenListIssue,hiddenQualification,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
10
+ import {docsQualification,hiddenDocsIssue,hiddenListIssue,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import {createHash} from 'node:crypto';
@@ -156,11 +156,15 @@ if(command==='init'){
156
156
  discovery.checks=chosen;discovery.config.checks=chosen.map(row=>row.config);
157
157
  }
158
158
  // A sandboxed check is proposed only when it gives the same exit status and
159
- // output inside the sandbox as outside it: first with the checkout's
160
- // git-ignored files and documentation hidden (sandbox-v3), then with the
161
- // git-ignored files hidden (sandbox-v2), else with the whole checkout
162
- // (sandbox-v1), reusing the plain run. Otherwise init writes what it would
163
- // have without the sandboxed recipe, with the reason.
159
+ // output inside the sandbox as outside it, with the checkout's git-ignored
160
+ // files and documentation hidden (sandbox-v3). When the plain run passed
161
+ // and the sandboxed one failed only because it was refused a file it hid
162
+ // (sandboxTrial's refusedHidden), the check gets the whole checkout
163
+ // (sandbox-v1) without another run: without the hiding it can only fail
164
+ // where the plain run passes. Any other difference is tried once with the
165
+ // whole checkout, reusing the plain run. Otherwise init writes what it
166
+ // would have without the sandboxed recipe, with the reason. Each check
167
+ // costs one plain run and one or two sandboxed runs.
164
168
  const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
165
169
  // A failure both ways that shows a refusal cannot be told from a real one.
166
170
  trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
@@ -175,18 +179,15 @@ if(command==='init'){
175
179
  if(!docs.issue&&docs.same){
176
180
  row.config={...row.config,qualification:docsQualification};row.qualification=docsQualification;
177
181
  row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore, except its documentation, are its input")+`; ran both ways at init, exit ${docs.plain}`;continue;}
178
- // Without a list of the documentation, the git-ignored files are still tried.
179
- const hidden=docs.plainRun?await sandboxTrial(discovery.root,row.config,{hide:true,plain:docs.plainRun}):
180
- docs.issue===hiddenDocsIssue?await sandboxTrial(discovery.root,row.config,{hide:true}):docs;
181
- if(!hidden.issue&&hidden.same){
182
- row.config={...row.config,qualification:hiddenQualification};row.qualification=hiddenQualification;
183
- row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore are its input")+
184
- `; ran both ways at init, exit ${hidden.plain}; it needs the documentation (${trialIssue(docs)})`;continue;}
185
- // Without git, nothing is hidden: the whole checkout is tried on its own.
186
- const trial=hidden.plainRun?await sandboxTrial(discovery.root,row.config,{plain:hidden.plainRun}):
187
- hidden.issue===hiddenListIssue?await sandboxTrial(discovery.root,row.config):hidden;
182
+ if(!docs.issue&&docs.refusedHidden){
183
+ row.reason+=`; at init it passed, then failed reading a file that was hidden (${docs.refusedHidden}), so the whole checkout is its input, not run that way at init`;continue;}
184
+ // Without a list of the ignored files or the documentation, the whole checkout is tried on its own.
185
+ const trial=docs.plainRun?await sandboxTrial(discovery.root,row.config,{plain:docs.plainRun}):
186
+ [hiddenListIssue,hiddenDocsIssue].includes(docs.issue)?await sandboxTrial(discovery.root,row.config):docs;
187
+ if(trial!==docs&&!trial.issue)trials++;
188
188
  const issue=trialIssue(trial);
189
- if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}; it needs files git ignores (${hidden.issue??trialIssue(hidden)})`;continue;}
189
+ if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}; the whole checkout is its input: `+
190
+ (docs.issue?docs.issue:`with the files git ignores and the documentation hidden, ${trialIssue(docs)}`);continue;}
190
191
  if(row.fallback)discovery.checks[at]={...row.fallback,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
191
192
  else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
192
193
  }
@@ -230,7 +231,7 @@ if(command==='init'){
230
231
  if(!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
231
232
  error('init writes only at the repository root; use --config there or --dry-run');
232
233
  fs.writeFileSync(configFile,JSON.stringify(discovery.config,null,2)+'\n',{flag:'wx'});
233
- if(!json){console.log(`Created ${configFile}. ${trials?'Sandboxed checks ran both ways to confirm them; no results were recorded.':'No checks were executed.'}`);
234
+ if(!json){console.log(`Created ${configFile}. ${trials?'Checks ran inside and outside the sandbox to choose their recipe; no results were recorded.':'No checks were executed.'}`);
234
235
  console.log('Ready to qualify means the supported contract will be validated; it is not verification evidence.');
235
236
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
236
237
  console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
@@ -100,11 +100,12 @@ redue explain typecheck
100
100
  Use a check actually printed by `init`. For a workspace:
101
101
  `redue init --workspace @example/package --check typecheck`.
102
102
  Initialization previews detected commands and qualification confidence before writing
103
- `redue.config.json`. An existing config is never overwritten. No checks are run and
104
- no dependencies installed. A missing/unsupported boundary is explained. `init` tries
103
+ `redue.config.json`. An existing config is never overwritten. No dependencies are
104
+ installed; a check proposed for the sandboxed recipe runs once plainly and once or
105
+ twice in the sandbox, and no results are recorded. A missing/unsupported boundary is explained. `init` tries
105
106
  the strict recipes first and uses the [general recipe](node-script.md) for a root
106
107
  typecheck or lint script none of them takes. Test scripts use the
107
- [direct test recipe](tests-direct.md).
108
+ [sandboxed recipe](sandbox.md) first, then the [direct test recipe](tests-direct.md).
108
109
 
109
110
  “Ready to qualify” means REDUE has a supported recipe whose prerequisites will be
110
111
  validated. It is not a receipt. “Recording-only” can record PASS/FAIL without claiming
@@ -20,8 +20,13 @@ built declarations) gets the whole checkout. See
20
20
 
21
21
  ## Where it applies
22
22
 
23
- - **Tests:** a root `test` script the [direct test recipe](tests-direct.md)
24
- does not take, and pytest in the project's `.venv`. See [tests](#tests).
23
+ - **Tests:** a root `test` script, and pytest in the project's `.venv`. See
24
+ [tests](#tests). For a Node test script, `init` tries the sandbox before the
25
+ [direct test recipe](tests-direct.md), which becomes the fallback, when
26
+ both would run the same script. In the
27
+ census, on the repos where both qualified, the direct recipe's results went
28
+ STALE when the project's build rewrote its output, and the sandboxed ones,
29
+ with ignored files hidden, stayed CURRENT.
25
30
  - **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
26
31
  with the `node-modules` linker). `init` tries the
27
32
  [direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
@@ -70,11 +75,17 @@ keeps `uv run` or `poetry run` in front, so the environment still syncs first.
70
75
 
71
76
  ## Files git ignores and documentation
72
77
 
73
- `init` runs each check four ways at most: plainly, then sandboxed with the
74
- files git ignores and the documentation hidden, with only the files git
75
- ignores hidden, and with the whole checkout (reusing the plain run). The first
76
- sandboxed way that gives the same result is written: `sandbox-v3`,
77
- `sandbox-v2` or `sandbox-v1`.
78
+ `init` runs each check plainly, then sandboxed with the files git ignores and
79
+ the documentation hidden. When both give the same result, it writes
80
+ `sandbox-v3`. When the plain run passed and the sandboxed one failed only
81
+ because it was refused a file it hid (no other refusal line, and not a line
82
+ the plain run printed too), it writes `sandbox-v1` (the whole checkout)
83
+ without running it again: without the hiding, a run can then only fail
84
+ where a plain one passes, never pass where it fails. When the runs differ
85
+ in another way, `init` runs the check once more with the whole checkout,
86
+ reusing the plain run, and writes `sandbox-v1` only if that agrees. So a
87
+ check runs two or three times at init, not four. `init` no longer writes
88
+ `sandbox-v2`; a check already set up with it keeps it.
78
89
 
79
90
  With `sandbox-v3`, each run also hides the documentation: the files git
80
91
  lists (and does not ignore) named `*.md`, `*.mdx`, `*.rst`, `LICENSE*` or
@@ -88,7 +99,7 @@ like one, or a change made while the run ran still counts. On macOS the
88
99
  files with those extensions are refused by name pattern, in any case and in
89
100
  `node_modules` too: `README.MD` or `Docs/logo.svg` is refused but not
90
101
  listed, so an edit to it counts, and a check that reads one gets
91
- `sandbox-v2` on macOS. `LICENSE*` and `CHANGELOG*` files are refused by the
102
+ `sandbox-v1` on macOS. `LICENSE*` and `CHANGELOG*` files are refused by the
92
103
  exact paths git lists, so `license-check.ts` stays readable. On Linux each
93
104
  listed file is hidden like a git-ignored one. In a checkout with more than
94
105
  100 `LICENSE*` and `CHANGELOG*` files (vendored packages), those files are
@@ -218,10 +229,13 @@ CURRENT means the last sandboxed run's inputs are unchanged:
218
229
  A run the sandbox refused something fails like any other. REDUE does not run
219
230
  every failure twice to find out:
220
231
 
221
- - `init` runs each proposed check twice, plainly and sandboxed, and proposes
222
- it only when both give the same exit status and the same output, and a
223
- failure shows no sign of a refusal. The output is compared without colors,
224
- timings, the temporary and home directories, and lines about caches.
232
+ - `init` runs each proposed check plainly and sandboxed, and proposes it
233
+ when both give the same exit status and the same output, and a failure
234
+ shows no sign of a refusal. The output is compared without colors,
235
+ timings, the temporary and home directories, and lines about caches. The
236
+ one exception is a check that passed plainly and failed only because it
237
+ was refused a hidden file: it gets the whole checkout without another run
238
+ (see [files git ignores](#files-git-ignores-and-documentation)).
225
239
  Otherwise `init` writes what it would have without this recipe and says why.
226
240
  These are the only runs `init` makes.
227
241
  - When a `sandbox-v2` or `sandbox-v3` run fails with a refusal that names a hidden file, it
@@ -57,11 +57,11 @@ changes. Unsupported custom resolution, project references, outside-root
57
57
  source links, and installation modes remain recording-only. Other Yarn
58
58
  typechecks, Vitest/Jest, ESLint, and builds in workspace packages start
59
59
  recording-only unless a separately justified explicit contract is provided.
60
- For a single-package project whose test script runs Vitest 4/5 or Jest 29
61
- directly, `init` uses the direct test recipe, which records the run and guards
62
- its inputs; see [direct tests](tests-direct.md). Other root test scripts get
63
- the sandboxed recipe when its trial gives the same exit status and test counts
64
- as a plain run.
60
+ A root test script gets the sandboxed recipe when its trial gives the same
61
+ exit status and test counts as a plain run. Otherwise, for a single-package
62
+ project whose test script runs Vitest 4/5 or Jest 29 directly, `init` uses the
63
+ direct test recipe, which records the run and guards its inputs; see
64
+ [direct tests](tests-direct.md).
65
65
  In particular, test caches and build-generated inputs are not excluded to
66
66
  make evidence green.
67
67
 
@@ -1,7 +1,7 @@
1
1
  # Direct test recipe (Vitest and Jest)
2
2
 
3
- `redue init` (or `redue init --recipe tests-direct --check test` for this recipe
4
- alone) records a project's unit test run and reports later whether that result
3
+ `redue init --recipe tests-direct --check test` (or `redue init`, where the
4
+ [sandboxed recipe](sandbox.md) cannot take the test script) records a project's unit test run and reports later whether that result
5
5
  still applies to the files you have.
6
6
  It supports a single-package project installed by npm, pnpm 9-12 (isolated or
7
7
  hoisted `node_modules`) or Yarn with the `node-modules` linker, whose test
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.29",
4
+ "version": "0.1.0-alpha.30",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -3,7 +3,8 @@ import {testProject,testRecipeScript,testCheckInputs,testsQualification} from '.
3
3
  import {generalIssue,scriptCheckInputs,scriptProject,scriptQualification} from './node-script.mjs';
4
4
  import {sandboxCheckInputs,sandboxKinds,sandboxQualification} from './sandbox.mjs';
5
5
  // Init proposes the general recipe for typecheck and lint scripts; test
6
- // scripts use the direct test recipe (Vitest, Jest).
6
+ // scripts use the sandboxed recipe first, then the direct test recipe
7
+ // (Vitest, Jest).
7
8
  const generalKinds=['typecheck','lint'];
8
9
  const perPackageLimit=50;
9
10
  import fs from 'node:fs';
@@ -302,7 +303,8 @@ export function discoverNodeProject(root,{recipe=null}={}) {
302
303
  // sandboxed recipe, which also covers programs the general recipe cannot
303
304
  // record. It is ready only once init has run it both ways (bin/redue.mjs);
304
305
  // `fallback` is the row init writes when that shows a difference or the
305
- // sandbox cannot run here: the general recipe's, when it takes the script.
306
+ // sandbox cannot run here: the general recipe's, when it takes the script,
307
+ // or the direct test recipe's.
306
308
  // Where the sandbox cannot start at run time, a script the general recipe
307
309
  // takes is recorded as it would record it (cli.mjs).
308
310
  // A script whose program is not installed gets none, and neither does one
@@ -310,7 +312,12 @@ export function discoverNodeProject(root,{recipe=null}={}) {
310
312
  const writes=script&&['pre'+script,script,'post'+script].map(name=>scripts[name]).find(text=>typeof text==='string'&&
311
313
  /(?:^|\s)--(?:fix|write|unsafe-fixes)(?=[\s=]|$)/.test(text));
312
314
  if(writes)classification={...classification,reason:`Sandboxed recipe unavailable (the script changes files: ${writes.match(/--(?:fix|write|unsafe-fixes)/)[0]}); ${classification.reason}`};
313
- if((auto||recipe==='sandbox')&&script&&sandboxKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue&&
315
+ // A test script gets it before the direct test recipe, which is then its
316
+ // fallback, when both run the same script: in the census the direct
317
+ // recipe's results went STALE when the build changed its output, and the
318
+ // sandboxed ones stayed CURRENT.
319
+ if((auto||recipe==='sandbox')&&script&&sandboxKinds.includes(kind)&&!observationIssue&&
320
+ (classification.level!=='ready'||auto&&kind==='test'&&check.script===script)&&
314
321
  !/REDUE cannot find/.test(generalIssue||'')&&!writes){
315
322
  try{
316
323
  const project=scriptProject(root,script);
package/src/sandbox.mjs CHANGED
@@ -583,7 +583,19 @@ export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
583
583
  child.on('close',(status,signal)=>finish({status,signal}));
584
584
  });
585
585
  }
586
- // {same, plain, sandboxed, line} or {issue} when the sandbox cannot run here.
586
+ // The line that shows a run that failed in the sandbox, and passed outside
587
+ // it, was refused a file it hid, when nothing else differs that a refusal
588
+ // could explain: no other refusal line, no supervisor issue, and not a line
589
+ // the plain run printed too. A run without the hiding then could only fail
590
+ // where the plain one passes, never pass where it fails.
591
+ function refusedHidden(plain,sandboxed,entries,supervisor){
592
+ if(plain.status!==0||supervisor||!entries.length)return null;
593
+ const line=hiddenRefusal(sandboxed.output,entries);
594
+ if(!line||plain.output.includes(line))return null;
595
+ const others=sandboxed.output.split(/\r?\n/).filter(text=>!hiddenRefusal(text,entries)).join('\n');
596
+ return denialLine(others)?null:line;
597
+ }
598
+ // {same, plain, sandboxed, line, refusedHidden} or {issue} when the sandbox cannot run here.
587
599
  // With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
588
600
  // With `hide`, the git-ignored entries hiddenEntries lists are kept from the
589
601
  // sandboxed run, and it also returns them, their count, the run times and, on
@@ -632,8 +644,10 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
632
644
  const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
633
645
  const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
634
646
  const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
635
- return {same:plain.status===sandboxed.status&&!denied&&!differs&&!supervisor,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
647
+ const same=plain.status===sandboxed.status&&!denied&&!differs&&!supervisor;
648
+ return {same,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
636
649
  line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
650
+ refusedHidden:same?null:refusedHidden(plain,sandboxed,entries,supervisor),
637
651
  ...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
638
652
  }finally{fs.rmSync(scratch,{recursive:true,force:true});}
639
653
  }