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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -3,8 +3,9 @@ import {builtInProbe,probeFloorMs,publishedDeadline} from './probe-deadline.mjs'
3
3
  import fs from 'node:fs';
4
4
  import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
- import {runFactChange} from './typescript-list.mjs';
6
+ import {queryAnswer,runFactChange} from './typescript-list.mjs';
7
7
  import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
+ import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
8
9
  import path from 'node:path';
9
10
  import net from 'node:net';
10
11
  import os from 'node:os';
@@ -69,7 +70,8 @@ const readsFile=path.join(state,'environment-reads-v1.json');
69
70
  function environmentReads() {
70
71
  try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
71
72
  }
72
- function contextHashes() {const reads=config.checks.some(check=>check.qualification===testsQualification)?environmentReads():{};
73
+ const recordedRecipes=[testsQualification,scriptQualification];
74
+ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
73
75
  return Object.fromEntries(config.checks.map(check=>[
74
76
  check.name,Object.fromEntries([...(check.environment?.variables||[])
75
77
  .map(key=>[key,digest(process.env[key]??'<unset>')]),
@@ -271,20 +273,28 @@ async function execute() {
271
273
  const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
272
274
  const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
273
275
  ...command.slice(1)]:command;
274
- // A test run's processes record what the guard checks (test-recorder.cjs).
275
- const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
276
- let testRecord=null,launchEnv=process.env,testFacts={};
276
+ // A test run's processes record what the guard checks (test-recorder.cjs),
277
+ // and so do a general recipe run's, through the package manager.
278
+ const general=selected.qualification===scriptQualification;
279
+ const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
280
+ let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
277
281
  if(testRun){
278
282
  // The package directory and installed node_modules, as the plan has them.
279
- try{testFacts=testRunFacts(root,selected);}
280
- catch(e){captureIssues.push(`test run facts unavailable (${e.code||e.message})`);}
283
+ try{testFacts=general?scriptRunFacts(root,selected):testRunFacts(root,selected);}
284
+ catch(e){captureIssues.push(`${general?'script':'test'} run facts unavailable (${e.code||e.message})`);}
281
285
  const environmentIssue=testEnvironmentIssue();
282
286
  if(environmentIssue)captureIssues.push(environmentIssue);
283
287
  testRecord=path.join(state,`test-record-${runId}`);
284
288
  fs.mkdirSync(testRecord,{mode:0o700});
285
- const guardFile=path.join(testRecord,'guard.json');
286
- fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root,process.env,testFacts)),{mode:0o600});
289
+ const guardFile=path.join(testRecord,'guard.json'),guardConfig=testGuardConfig(testRecord,root,process.env,testFacts);
290
+ if(general){toolchain=toolchainDirs();
291
+ // The Node on PATH, which the environment identifies by its file.
292
+ Object.assign(guardConfig,{installed:scriptInstalledPaths(root,testFacts.dirs||[]),toolchain,
293
+ node:lookup('node',process.env.PATH,process.cwd())});}
294
+ fs.writeFileSync(guardFile,JSON.stringify(guardConfig),{mode:0o600});
287
295
  launchEnv=recordedEnvironment(guardFile);
296
+ // npm's and pnpm's update checks read state outside the checkout and the network.
297
+ if(general)launchEnv.npm_config_update_notifier='false';
288
298
  }
289
299
  let runnerPid=null;
290
300
  const record=[];
@@ -357,10 +367,16 @@ async function execute() {
357
367
  }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
358
368
  let guard=null;
359
369
  if(testRecord){
360
- let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
370
+ let coverage=null;if(!general)try{coverage=coverageReadable(root,testFacts);}catch{}
371
+ const programs=general?{runner:{file:command[0],args:command.slice(1),cwd:plan.cwd,searchPath:process.env.PATH,
372
+ env:{preload:true,bashEnv:Boolean(process.env.BASH_ENV)}},
373
+ judge:(child,main)=>childProgram(child,{toolchain,parentMain:main}),trusted:scriptCaches}:null;
361
374
  guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
362
- runner:runnerPid,environment:selected.environment||{},coverage});
375
+ runner:runnerPid,environment:selected.environment||{},coverage,programs});
363
376
  runFacts=guard.runFacts;
377
+ // The pnpm executable reads its configuration natively: kept by content.
378
+ if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
379
+ catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
364
380
  const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
365
381
  if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
366
382
  fs.rmSync(testRecord,{recursive:true,force:true});
@@ -392,7 +408,8 @@ async function execute() {
392
408
  const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
393
409
  const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
394
410
  const touched=[...new Set([...changed,...events])].sort();
395
- const ignored=touched.length&&touched.every(guard.isOutput)?gitIgnored(root,touched):new Set();
411
+ const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
412
+ const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
396
413
  if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
397
414
  }
398
415
  // The end certificate, recorded or probed, must cover what this compile received.
@@ -447,9 +464,10 @@ async function execute() {
447
464
  }
448
465
  // A recorded run's receipt carries its run facts, even none; observers that
449
466
  // predate run facts do not reuse version 2.
450
- const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
467
+ // The recipe that made the receipt, which status and the hook name.
468
+ const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
451
469
  ...(recorded||testRun?{runFacts}:{}),
452
- ...(testRun?{verificationRecipe:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
470
+ ...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
453
471
  environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
454
472
  resolutionLinks:plan.resolutionLinks||[],
455
473
  coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
package/src/daemon.mjs CHANGED
@@ -352,6 +352,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
352
352
  plan_id:row.plan.id,observed_at:lastObservation,
353
353
  invocation:r?.invocation||null,target_provenance:r?.target||null,
354
354
  ...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
355
+ ...(r?.recipe?{recipe:r.recipe}:{}),
355
356
  coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
356
357
  ...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
357
358
  if(receiptStateError){item.reason=receiptStateError;return item;}
@@ -794,8 +795,8 @@ function markInputEvent(name,rel,type,replay) {
794
795
  if(!hasHistory||historyDisabled||replay){
795
796
  const serial=(inputEventSerial.get(name)||0)+1;
796
797
  inputEventSerial.set(name,serial);
797
- // Kept only for test checks; trimmed in batches.
798
- if(index?.checks.get(name)?.plan.qualification==='npm-tests-direct-v1'){
798
+ // Kept only for recorded test and script checks; trimmed in batches.
799
+ if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
799
800
  const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
800
801
  if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
801
802
  }
@@ -7,6 +7,7 @@ import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
7
7
  import {qualifyTypeScript} from './typescript-qualification.mjs';
8
8
  import {directBuildInfo} from './direct-typescript.mjs';
9
9
  import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
+ import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
10
11
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
11
12
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
12
13
  import {checkRun} from './typescript-invocation.mjs';
@@ -82,6 +83,7 @@ export function discoverDeclared(config) {
82
83
  [...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
83
84
  timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
84
85
  const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
86
+ selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
85
87
  qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
86
88
  const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
87
89
  ...(workspace?.generated||[]),...excluded])].sort();
@@ -92,9 +94,10 @@ export function discoverDeclared(config) {
92
94
  ...(workspaceIssue?[workspaceIssue]:[])];
93
95
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
94
96
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
95
- // A workspace test check is qualified against the current package list: a
96
- // package manifest appearing or pnpm's package list changing re-plans.
97
- if(selected.qualification===testsQualification&&selected.workspace)try{
97
+ // A workspace test check, and a general check in a workspace, is qualified
98
+ // against the current package list: a package manifest appearing or pnpm's
99
+ // package list changing re-plans.
100
+ if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification)try{
98
101
  for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
99
102
  workspacePatterns.add(item);
100
103
  if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
package/src/index.mjs CHANGED
@@ -55,7 +55,7 @@ function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,r
55
55
  const target=fs.statSync(resolution.physical,{bigint:true});
56
56
  const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
57
57
  const directory=target.isDirectory()&&installed&&observedDir?.(physical);
58
- if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel}`);
58
+ if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel} -> ${physical}`);
59
59
  if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
60
60
  else{
61
61
  if(installed)racy=racyUntil(target);else bytes=Number(target.size);
@@ -192,10 +192,16 @@ export class InputIndex {
192
192
  const installed=this.installedPath(rel);
193
193
  try{const started=performance.now();
194
194
  // A linked directory counts as observed only strictly inside an
195
- // installed root, and only if every check matching the link also
196
- // observes the directory's own files (its package.json stands for them).
195
+ // installed root, or as a checkout directory outside node_modules (a
196
+ // workspace package the package manager links), and only if every
197
+ // check matching the link also observes the directory's own files
198
+ // (its package.json stands for them).
199
+ // The second form is for recorded checks only: their recorder
200
+ // classifies a read through the link where it leads.
201
+ const recorded=name=>['node-script-v1','npm-tests-direct-v1'].includes(this.checks.get(name).plan.qualification);
197
202
  const observedDir=installed?physical=>!this.includeInstalled&&
198
- [...this.installedRootSet].some(root=>physical.startsWith(root+'/'))&&
203
+ ([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
204
+ !physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
199
205
  matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
200
206
  inspection=fileHash(this.root,rel,installed,stat,observedDir);
201
207
  fresh=inspection.hash;
@@ -1,5 +1,9 @@
1
1
  import {directProject,directQualification} from './direct-typescript.mjs';
2
- import {testProject,testRecipeScript,testCheckInputs,testsQualification} from './test-direct.mjs';
2
+ import {testProject,testRecipeScript,testCheckInputs,testEnvironmentIssue,testsQualification} from './test-direct.mjs';
3
+ import {commandLineIssue,scriptCheckInputs,scriptPath,scriptProject,scriptQualification} from './node-script.mjs';
4
+ // Init proposes the general recipe for typecheck and lint scripts; test
5
+ // scripts use the direct test recipe (Vitest, Jest).
6
+ const generalKinds=['typecheck','lint'];
3
7
  import fs from 'node:fs';
4
8
  import path from 'node:path';
5
9
  import {spawnSync} from 'node:child_process';
@@ -130,7 +134,8 @@ function sourceInputs(root,kind,lockfiles) {
130
134
  '!node_modules/**','!.git/**'])];
131
135
  const dirs=likelyDirs.filter(name=>exists(path.join(root,name)))
132
136
  .map(name=>`${name}/**`);
133
- return [...new Set([...files,...(dirs.length?dirs:['package.json'])])];
137
+ // A workspace package's node_modules under them is installed, not source.
138
+ return [...new Set([...files,...(dirs.length?[...dirs,'!**/node_modules/**']:['package.json'])])];
134
139
  }
135
140
  function classify(kind,scriptName,script,pkg,managerInfo,root) {
136
141
  const value=script.trim();
@@ -191,8 +196,27 @@ export function workspacePackages(root){
191
196
  return workspaceManifests(root,patterns).map(({file,pkg:member})=>
192
197
  ({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
193
198
  }
199
+ // The general recipe for a root script, or why it does not apply: a
200
+ // supported package manager and platform, and a script (with its pre and post
201
+ // scripts) that, read as text, starts only Node programs.
202
+ function generalCheck(root,kind,script){
203
+ if(!['darwin','linux'].includes(process.platform))throw Error('the general recipe records runs on macOS and Linux only');
204
+ const environmentIssue=testEnvironmentIssue();
205
+ if(environmentIssue)throw Error(environmentIssue);
206
+ let project;
207
+ try{project=scriptProject(root,script);}catch(e){throw Error(e.code?.replace(/^test-/,'')||e.message);}
208
+ for(const name of ['pre'+script,script,'post'+script]){
209
+ if(typeof project.scripts[name]!=='string')continue;
210
+ const issue=commandLineIssue(project.scripts[name],{cwd:root,searchPath:scriptPath(root,process.env.PATH)});
211
+ if(issue)throw Error(name===script?issue:`${name}: ${issue}`);
212
+ }
213
+ return {config:{name:kind,script,kind,qualification:scriptQualification,...scriptCheckInputs(project.dirs)},manager:project.manager};
214
+ }
194
215
  export function discoverNodeProject(root,{recipe=null}={}) {
195
- if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
216
+ if(recipe&&!['typescript-direct','tests-direct','node-script'].includes(recipe))
217
+ throw Error('unknown recipe; use typescript-direct, tests-direct or node-script');
218
+ // Without --recipe, init tries the strict recipes first, then the general one.
219
+ const auto=recipe===null;
196
220
  root=realObservedPath(root);
197
221
  const packageFile=path.join(root,'package.json');
198
222
  if(!exists(packageFile))throw Error('run init from a repository containing package.json');
@@ -225,7 +249,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
225
249
  if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
226
250
  reason:'workspace task and installed-input closure need explicit qualification'};
227
251
  const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
228
- if(recipe==='typescript-direct'&&kind==='typecheck'){
252
+ if((recipe==='typescript-direct'||auto&&pm.name==='npm'&&!workspaces)&&kind==='typecheck'){
229
253
  try{
230
254
  if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
231
255
  let version;
@@ -236,7 +260,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
236
260
  reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
237
261
  }catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
238
262
  }
239
- if(recipe==='tests-direct'&&kind==='test'){
263
+ if((recipe==='tests-direct'||auto)&&kind==='test'){
240
264
  try{
241
265
  if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
242
266
  throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
@@ -250,7 +274,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
250
274
  reason:`explicit local ${project.runner} ${project.version} run of script ${JSON.stringify(chosen.script)}; the whole checkout is its input; npm and lifecycle scripts are not executed`};
251
275
  }catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
252
276
  }
253
- if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
277
+ if(recipe!=='node-script'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
254
278
  try{
255
279
  parts=tscChecks('typecheck',script??null,scripts,run=>{
256
280
  const contract=pnpmTypecheckInputs(root,'.',run);
@@ -263,6 +287,14 @@ export function discoverNodeProject(root,{recipe=null}={}) {
263
287
  qualification:'pnpm-tsc-v1'};}
264
288
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
265
289
  }
290
+ if((auto||recipe==='node-script')&&script&&generalKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue){
291
+ try{
292
+ const general=generalCheck(root,kind,script);
293
+ checks.push({config:general.config,level:'ready',qualification:scriptQualification,
294
+ reason:`general recipe: ${general.manager} runs script ${JSON.stringify(script)} with REDUE's recorder in every Node process; the whole checkout is its input`});
295
+ continue;
296
+ }catch(e){classification={...classification,reason:`General recipe unavailable (${e.message}); ${classification.reason}`};}
297
+ }
266
298
  if(!script&&classification.level!=='ready')continue;
267
299
  if(parts&&classification.level==='ready'){
268
300
  for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
@@ -302,7 +334,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
302
334
  ['@which:pnpm','--filter',name,'run',selected.script];
303
335
  const check={name:`${name}:${kind}`,kind,command,
304
336
  inputs:['package.json',...(pm.lockfiles||[]),`${dir}/**`,
305
- `!${dir}/node_modules/**`]};
337
+ `!${dir}/node_modules/**`,`!${dir}/**/node_modules/**`]};
306
338
  let level=pm.issue&&pm.name!=='pnpm'?'unsupported':'recording';
307
339
  let reason=pm.issue||'workspace script found; shared configuration and installed inputs need review';
308
340
  if(!pm.issue&&pm.name==='yarn'&&pm.layout==='node-modules/classic'&&