@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.20

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +130 -49
  4. package/docs/README.md +2 -1
  5. package/docs/user/agents.md +299 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +209 -0
  8. package/docs/user/getting-started.md +23 -2
  9. package/docs/user/json.md +2 -0
  10. package/docs/user/node-script.md +153 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/receipt-selection-notice.md +60 -0
  14. package/docs/user/supported-projects.md +49 -21
  15. package/docs/user/tests-direct.md +132 -0
  16. package/package.json +4 -3
  17. package/src/agent-integration.mjs +388 -0
  18. package/src/agent-policy.md +28 -0
  19. package/src/cached-state.mjs +46 -8
  20. package/src/claude-hook.mjs +449 -0
  21. package/src/cli.mjs +263 -40
  22. package/src/daemon.mjs +433 -94
  23. package/src/decision-profile.mjs +15 -1
  24. package/src/decision-validation.mjs +10 -0
  25. package/src/declared-plan.mjs +29 -6
  26. package/src/direct-run-certificate.mjs +252 -0
  27. package/src/direct-typescript-probe.mjs +24 -0
  28. package/src/direct-typescript.mjs +185 -0
  29. package/src/glob.mjs +8 -0
  30. package/src/history-worker.mjs +21 -13
  31. package/src/index.mjs +72 -19
  32. package/src/installed-inputs.mjs +7 -1
  33. package/src/linux-inotify.py +39 -10
  34. package/src/node-onboarding.mjs +157 -48
  35. package/src/node-script.mjs +362 -0
  36. package/src/npm-integrity.mjs +124 -0
  37. package/src/npm-typecheck-environment.mjs +36 -0
  38. package/src/npmrc.mjs +16 -0
  39. package/src/observation.mjs +34 -12
  40. package/src/path-identity.mjs +8 -0
  41. package/src/plan-worker.mjs +17 -17
  42. package/src/pnpm-inputs.mjs +114 -54
  43. package/src/pnpm-shim-templates.json +180 -0
  44. package/src/pnpm-shims.mjs +54 -0
  45. package/src/pnpm-typecheck-probe.mjs +11 -6
  46. package/src/presentation.mjs +9 -1
  47. package/src/probe-deadline.mjs +37 -0
  48. package/src/probe-result.mjs +7 -3
  49. package/src/project-location.mjs +34 -0
  50. package/src/run-recorder.cjs +91 -0
  51. package/src/state-store.mjs +37 -1
  52. package/src/test-direct.mjs +560 -0
  53. package/src/test-recorder.cjs +406 -0
  54. package/src/typescript-contract-probe.mjs +4 -187
  55. package/src/typescript-input-facts.mjs +92 -0
  56. package/src/typescript-invocation.mjs +92 -0
  57. package/src/typescript-list.mjs +108 -13
  58. package/src/typescript-qualification.mjs +29 -49
  59. package/src/workspace-manifests.mjs +22 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
@@ -0,0 +1,54 @@
1
+ // pnpm's launchers. On macOS and Linux, pnpm writes each node_modules/.bin
2
+ // entry as a `#!/bin/sh` script that finds its own directory (with sed,
3
+ // dirname, readlink, uname or printf, depending on the pnpm release), sets
4
+ // NODE_PATH and execs Node on the package's bin file. A launcher is trusted only
5
+ // when its text is one of the templates pnpm releases write
6
+ // (pnpm-shim-templates.json, built by eval/pnpm-shim-templates.mjs), with only
7
+ // the NODE_PATH value, the bin file and the trailing target comment filled in.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const templates=JSON.parse(fs.readFileSync(new URL('pnpm-shim-templates.json',import.meta.url),'utf8')).templates;
12
+ const known=new Set(templates.map(row=>row.template));
13
+ // A value with no quote, expansion, escape or line break: it stays literal in
14
+ // the shell.
15
+ const literal=/^[^"$`\\\n]*$/;
16
+
17
+ // The launcher's text as a template, with the values it fills in, or null
18
+ // when it does not have pnpm's form.
19
+ export function shimTemplate(text){
20
+ if(typeof text!=='string'||!text.startsWith('#!/bin/sh\n'))return null;
21
+ let template=text,nodePath=null;
22
+ // NODE_PATH, set alone or in front of the caller's.
23
+ const alone=/\n export NODE_PATH="([^"\n]*)"\n/.exec(text);
24
+ if(alone){
25
+ nodePath=alone[1];
26
+ // Each entry is literal, or relative to the launcher's own directory
27
+ // ($basedir_abs, which pnpm 12.6 and later write).
28
+ if(!nodePath.split(':').every(entry=>literal.test(entry.replace(/^\$basedir(?:_abs)?\//,'')))||
29
+ !text.includes(`\n export NODE_PATH="${nodePath}:$NODE_PATH"\n`))return null;
30
+ template=template.split(`\n export NODE_PATH="${nodePath}:$NODE_PATH"\n`).join('\n export NODE_PATH="@@NODE_PATH@@:$NODE_PATH"\n')
31
+ .split(`\n export NODE_PATH="${nodePath}"\n`).join('\n export NODE_PATH="@@NODE_PATH@@"\n');
32
+ }
33
+ // The bin file, relative to the launcher's directory, after $basedir,
34
+ // $basedir_abs or $basedir_win; `node` and `node.exe` there are Node itself.
35
+ const targets=new Set([...template.matchAll(/"\$basedir(?:_abs|_win)?\/([^"\n]+)"/g)].map(match=>match[1])
36
+ .filter(value=>value!=='node'&&value!=='node.exe'));
37
+ if(targets.size!==1)return null;
38
+ const [target]=targets;
39
+ if(!literal.test(target))return null;
40
+ for(const prefix of ['$basedir','$basedir_abs','$basedir_win'])
41
+ template=template.split(`"${prefix}/${target}"`).join(`"${prefix}/@@TARGET@@"`);
42
+ // pnpm 11 and later end with a comment naming the target.
43
+ template=template.replace(/\n# cmd-shim-target=[^\n]*\n?$/,'\n# cmd-shim-target=@@COMMENT@@\n');
44
+ return {template,nodePath,target};
45
+ }
46
+ // The bin file a pnpm launcher runs with Node, as an absolute path, or null
47
+ // when `file` is not a launcher pnpm writes.
48
+ export function pnpmShimTarget(file,text=null){
49
+ try{
50
+ text??=fs.readFileSync(file,'utf8');
51
+ const shim=shimTemplate(text);
52
+ return shim&&known.has(shim.template)?path.resolve(path.dirname(file),shim.target):null;
53
+ }catch{return null;}
54
+ }
@@ -7,14 +7,17 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {createHash} from 'node:crypto';
9
9
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
10
+ import {patternMatcher} from './glob.mjs';
10
11
  import {withinPath as within,realObservedPath} from './path-identity.mjs';
12
+ import {runFromArgv} from './typescript-invocation.mjs';
11
13
 
12
- const [rootArg,workspace,script]=process.argv.slice(2);
14
+ const [rootArg,workspace,...rest]=process.argv.slice(2).filter(value=>!value.startsWith('--redue-'));
13
15
  const hash=value=>createHash('sha256').update(value).digest('hex');
14
16
  const digest=file=>hash(fs.readFileSync(file));
15
17
  const fail=code=>{throw Object.assign(Error(code),{code});};
16
18
  try{
17
- if(!rootArg||!workspace||!script)fail('probe-arguments');
19
+ if(!rootArg||!workspace)fail('probe-arguments');
20
+ const run=runFromArgv(rest);
18
21
  const root=realObservedPath(rootArg);
19
22
  if(process.env.NODE_OPTIONS||process.env.NODE_PATH||process.env.BASH_ENV||
20
23
  process.env.ENV||Object.entries(process.env).some(([key,value])=>{
@@ -24,23 +27,25 @@ try{
24
27
  return /^(PNPM_|COREPACK_|npm_config_|DYLD_|TSGO_)/i.test(key);
25
28
  }))
26
29
  fail('pnpm-execution-environment-unsupported');
27
- const contextHash=hash(JSON.stringify([root,workspace,script,process.version]));
30
+ const contextHash=hash(JSON.stringify([root,workspace,run,process.version]));
28
31
  if(contextOnly(contextHash))process.exit(0);
29
- const contract=pnpmTypecheckInputs(root,workspace,script,{captureQueries:process.argv.includes('--redue-checkpoint')});
32
+ const contract=pnpmTypecheckInputs(root,workspace,run,{captureQueries:process.argv.includes('--redue-checkpoint')});
30
33
  if(contract.limitations.length)fail('pnpm-input-coverage-unavailable');
31
34
  const selected=path.join(root,contract.workspace.dir),tsc=contract.tsc;
32
35
  const inputs=contract.listedFiles;
33
36
  if(!inputs.length)fail('typescript-input-list-unavailable');
34
- const inputRoots=[path.join(selected,'src'),...contract.generated.map(pattern=>
37
+ const inputRoots=[...contract.generated.map(pattern=>
35
38
  path.join(root,pattern.slice(0,-3))),...contract.installed.filter(p=>!p.startsWith('!'))
36
39
  .map(pattern=>path.join(root,pattern.slice(0,-3)))];
40
+ const isSource=patternMatcher(contract.sourcePatterns);
37
41
  for(const file of inputs)if(!within(file,root)||
42
+ !isSource(path.relative(root,file).split(path.sep).join('/'))&&
38
43
  !inputRoots.some(boundary=>within(file,boundary)))
39
44
  fail('typescript-input-outside-pnpm-contract');
40
45
  const files=[...new Set(inputs)].sort().map(file=>
41
46
  [hash(path.relative(root,file)),digest(file)]);
42
47
  const configs=contract.configs.map(file=>[hash(file),digest(path.join(root,file))]);
43
- probeResult(hash(JSON.stringify({schema:1,workspace,script,
48
+ probeResult(hash(JSON.stringify({schema:1,workspace,run,
44
49
  files,configs,node:process.version,tsc:digest(tsc),
45
50
  packageManager:contract.install.version,linker:contract.install.linker})),contextHash,contract.discoveryQueries);
46
51
  }catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:
@@ -1,4 +1,9 @@
1
1
  // Presentation consumes evidence; it never grants applicability.
2
+ // The recipe that made a receipt, by the qualification it names.
3
+ const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
4
+ 'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
5
+ 'typescript-noemit-v1':'npm TypeScript recipe'};
6
+ export const recipeLabel=recipe=>recipes[recipe]??null;
2
7
  export function checkLabel(row) {
3
8
  return row.result==='FAIL'?'FAILED':row.freshness;
4
9
  }
@@ -21,7 +26,8 @@ export function renderStatus(value,{short=false}={}) {
21
26
  const label=checkLabel(row),symbol=label==='CURRENT'&&row.result==='PASS'?'✓':label==='FAILED'?'✗':'?';
22
27
  const mark=label==='STALE'?'⚠':symbol;
23
28
  const historical=row.result?` / ${row.result}`:'';
24
- lines.push(`${mark} ${row.name} ${label}${historical}`);
29
+ const recipe=label==='CURRENT'?recipeLabel(row.recipe):null;
30
+ lines.push(`${mark} ${row.name} ${label}${historical}${recipe?` (${recipe})`:''}`);
25
31
  if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
26
32
  }
27
33
  if(!lines.length)lines.push('UNVERIFIED — observation is initializing; no check state is available yet.');
@@ -33,6 +39,8 @@ export function renderExplain(value,name) {
33
39
  return rows.map(row=>{
34
40
  const lines=[`${row.name}: ${checkLabel(row)}${row.result?` / ${row.result}`:''}`];
35
41
  if(row.result==='FAIL')lines.push(`Historical failure; applicability: ${row.freshness}.`);
42
+ if(row.verification_recipe==='npm-direct-typescript@1')lines.push('Recorded recipe: direct local TypeScript compiler; npm and lifecycle scripts were not executed.');
43
+ else if(recipeLabel(row.recipe))lines.push(`Recorded recipe: ${recipeLabel(row.recipe)}.`);
36
44
  lines.push(explanation(row));
37
45
  if(row.changed_inputs?.length)lines.push('Relevant inputs changed:',...row.changed_inputs.map(file=>` ${file}`));
38
46
  if(row.freshness==='STALE')lines.push(`Due again when verification is needed: redue run ${JSON.stringify(row.name)}`);
@@ -0,0 +1,37 @@
1
+ // How long a full probe may run before it is killed and the check stays
2
+ // UNVERIFIED. It grows with the project: the larger of the hint from earlier
3
+ // probes (twice the last successful one, or twice the deadline that last timed
4
+ // out) and the last completed run, between the platform floor and a ceiling.
5
+ import {fileURLToPath} from 'node:url';
6
+ export const probeCeilingMs=process.env.VSTATE_TEST_FAULTS==='1'&&
7
+ Number(process.env.VSTATE_TEST_PROBE_CEILING_MS)||60000;
8
+ // Supported TypeScript probes perform bounded input/toolchain hashing.
9
+ // Native NTFS runs have exceeded the generic 5 s cap under load, despite
10
+ // completing normally and with the duplicated work already removed.
11
+ export const probeFloorMs=qualification=>qualification==='pnpm-tsc-v1'||
12
+ (process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
13
+ .includes(qualification))?10000:5000;
14
+ // REDUE's own probes, which accept --redue-deadline; a user's probe never gets it.
15
+ const builtIn=new Set(['typescript-contract-probe.mjs','direct-typescript-probe.mjs',
16
+ 'yarn-workspace-typecheck-probe.mjs','pnpm-typecheck-probe.mjs'].map(name=>fileURLToPath(new URL(name,import.meta.url))));
17
+ export const builtInProbe=argv=>builtIn.has(argv?.[1]);
18
+ // A deadline read from status.json, as a whole number of milliseconds no
19
+ // longer than a decision could need (0 when absent or unusable).
20
+ export function publishedDeadline(value) {
21
+ return Number.isFinite(value)&&value>0?Math.min(Math.round(value),10*60*1000):0;
22
+ }
23
+ export function probeDeadline({floor,hint=0,runMs=0,ceiling=probeCeilingMs}) {
24
+ const finite=value=>Number.isFinite(value)&&value>0?value:0;
25
+ return Math.round(Math.min(ceiling,Math.max(floor,finite(hint),finite(runMs))));
26
+ }
27
+ // The hint the next probe starts from.
28
+ export function nextProbeHint({durationMs,deadlineMs,timedOut}) {
29
+ return 2*(timedOut?deadlineMs:durationMs);
30
+ }
31
+ // Inside a probe process: what is left of the deadline its caller passed with
32
+ // --redue-deadline=MS, or the fallback when none was passed. An inner call
33
+ // always ends before its parent is killed, so it never outlives the probe.
34
+ export function probeTimeLeft(fallback) {
35
+ const deadline=Number(/^--redue-deadline=(\d+)$/.exec(process.argv.find(arg=>arg.startsWith('--redue-deadline='))||'')?.[1]);
36
+ return deadline?Math.max(1,Math.round(deadline-performance.now()-250)):fallback;
37
+ }
@@ -9,11 +9,15 @@ export function contextOnly(context) {
9
9
  }
10
10
  return false;
11
11
  }
12
- export function probeResult(output,context,queries=null) {
12
+ // A layout lets a recorded run rebuild the output after a content-only edit.
13
+ export function probeResult(output,context,queries=null,layout=null) {
13
14
  if(!process.argv.includes('--redue-checkpoint')){process.stdout.write(output);return;}
14
15
  const raw=queries?JSON.stringify(queries):null;
15
- let value=JSON.stringify({output,context,queryData:raw&&Buffer.byteLength(raw)<=8*1024*1024?
16
- deflateSync(raw).toString('base64'):null});
16
+ const queryData=raw&&Buffer.byteLength(raw)<=8*1024*1024?deflateSync(raw).toString('base64'):null;
17
+ let value=JSON.stringify({output,context,queryData,
18
+ ...(layout?{layoutData:deflateSync(JSON.stringify(layout)).toString('base64')}:{})});
19
+ // The layout is dropped first: it only shortens the run after an edit.
20
+ if(Buffer.byteLength(value)>512*1024)value=JSON.stringify({output,context,queryData});
17
21
  // Optimization metadata must not overflow the ordinary probe channel.
18
22
  // Larger contracts keep the normal complete probe, without result reuse.
19
23
  if(Buffer.byteLength(value)>512*1024)value=JSON.stringify({output,context,queries:null});
@@ -0,0 +1,34 @@
1
+ // Shared project selection only; evidence and caller-context semantics stay in the kernel.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import {createHash} from 'node:crypto';
6
+ import {realObservedPath} from './path-identity.mjs';
7
+ import {stateIdentity,userStateBase} from './platform-state.mjs';
8
+
9
+ export function discoverConfig(cwd=process.cwd()) {
10
+ let dir=path.resolve(cwd);const found=[];
11
+ for(;;){
12
+ for(const name of ['redue.config.json','vstate.config.json']) {
13
+ const file=path.join(dir,name);
14
+ if(fs.existsSync(file))found.push(file);
15
+ }
16
+ if(fs.existsSync(path.join(dir,'.git'))||path.dirname(dir)===dir)break;
17
+ dir=path.dirname(dir);
18
+ }
19
+ if(found.length>1)throw Error('multiple REDUE configurations apply; select the intended one with --config FILE');
20
+ return found[0]||path.resolve(cwd,'redue.config.json');
21
+ }
22
+ export function canonicalConfig(configFile) {
23
+ return path.join(realObservedPath(path.dirname(configFile)),path.basename(configFile));
24
+ }
25
+ export function projectState(root,configFile) {
26
+ return path.join(userStateBase(process.platform,process.env,os.homedir()),'vstate',
27
+ `vstate-${createHash('sha256').update(stateIdentity(root)+'\0'+stateIdentity(canonicalConfig(configFile))).digest('hex').slice(0,16)}`);
28
+ }
29
+ export function readProject(configFile) {
30
+ const config=JSON.parse(fs.readFileSync(configFile,'utf8'));
31
+ if(config.schema!==1||!Array.isArray(config.checks)||!config.checks.length)
32
+ throw Error('config needs schema 1 and at least one check');
33
+ return {config,root:realObservedPath(path.resolve(path.dirname(configFile),config.root||'.'))};
34
+ }
@@ -0,0 +1,91 @@
1
+ // Preload for a direct compiler run: node --require <this> <tsc> ... with a
2
+ // pipe on fd 3. Records the answers the compiler received from node:fs, in the
3
+ // terms of queryAnswer, and writes them to fd 3 at exit. It never changes an
4
+ // answer, an output or the exit code; any failure only makes the record unusable.
5
+ 'use strict';
6
+ const fs=require('node:fs'),path=require('node:path'),{createHash}=require('node:crypto');
7
+ const original={readFileSync:fs.readFileSync,statSync:fs.statSync,readdirSync:fs.readdirSync,
8
+ realpathSync:fs.realpathSync,native:fs.realpathSync.native,writeSync:fs.writeSync,openSync:fs.openSync};
9
+ const real=file=>(process.platform==='win32'?original.native:original.realpathSync)(file);
10
+ const facts=new Map(),compilerReads=new Set();let unsupported=null,conflict=null,main=null,compilerRoot=null;
11
+ try{main=real(process.argv[1]);compilerRoot=path.dirname(path.dirname(main));}
12
+ catch{unsupported='main module unavailable';}
13
+ const kindOf=st=>st.isFile()?'f':st.isDirectory()?'d':'o';
14
+ const missing=error=>['ENOENT','ENOTDIR'].includes(error?.code);
15
+ // The compiler tree (lib/<entry>/../..) is covered by the daemon's toolchain
16
+ // identity, not by this record. tsc also stats a case-swapped copy of its own path.
17
+ const inCompiler=full=>full===compilerRoot||full.startsWith(compilerRoot+path.sep)||
18
+ full!==main&&full.toLowerCase()===main.toLowerCase();
19
+ function observe(kind,call,answer) {
20
+ return function(file,...rest){
21
+ let value,full=null;
22
+ // Compiler-tree reads are named only: which lib files a program loads.
23
+ try{if(typeof file==='string'){full=path.resolve(file);
24
+ if(inCompiler(full)){if(kind==='read')compilerReads.add(full);full=null;}}
25
+ else unsupported??=`${kind} of a non-string path`;}
26
+ catch{unsupported??='recorder failure';}
27
+ if(full===null)return call.call(this,file,...rest);
28
+ let recorded;
29
+ try{value=call.call(this,file,...rest);}
30
+ catch(error){recorded=missing(error)?null:{error:error?.code||'error'};note(kind,full,recorded);throw error;}
31
+ try{recorded=answer(value,file);}catch{unsupported??='recorder failure';return value;}
32
+ note(kind,full,recorded);
33
+ return value;
34
+ };
35
+ }
36
+ function note(kind,full,answer) {
37
+ try{const key=kind+'\0'+full,value=JSON.stringify(answer),seen=facts.get(key);
38
+ if(seen===undefined)facts.set(key,value);else if(seen!==value)conflict??=`${kind} ${full}`;}
39
+ catch{unsupported??='recorder failure';}
40
+ }
41
+ fs.readFileSync=observe('read',original.readFileSync,value=>
42
+ createHash('sha256').update(JSON.stringify(typeof value==='string'?value:value.toString('utf8'))).digest('hex'));
43
+ fs.statSync=observe('stat',original.statSync,value=>value?kindOf(value):null);
44
+ fs.readdirSync=observe('readdir',original.readdirSync,(value,dir)=>value.map(entry=>{
45
+ if(typeof entry==='string')return [entry,'?'];
46
+ if(!entry.isSymbolicLink())return [entry.name,kindOf(entry)];
47
+ let target=null;try{target=kindOf(original.statSync(path.join(dir,entry.name)));}catch{}
48
+ return [entry.name,'l',target];
49
+ }).sort((a,b)=>a[0]<b[0]?-1:a[0]>b[0]?1:0));
50
+ const realpath=observe('realpath',original.realpathSync,value=>value);
51
+ realpath.native=observe('realpath',original.native,value=>value);
52
+ fs.realpathSync=realpath;
53
+ // readFileSync opens through fs.openSync; only a writable open is a write.
54
+ // The one write a direct run may make is its build-info file (incremental);
55
+ // it is recorded, and the caller checks it is the configured one.
56
+ const buildInfo=file=>typeof file==='string'&&file.endsWith('.tsbuildinfo');
57
+ fs.openSync=function(file,flags,...rest){
58
+ if(!(flags===undefined||flags==='r'||flags==='rs'||flags===fs.constants.O_RDONLY)){
59
+ if(flags==='w'&&buildInfo(file))note('write',path.resolve(file),null);
60
+ else unsupported??='openSync';
61
+ }
62
+ return original.openSync.call(this,file,flags,...rest);
63
+ };
64
+ // tsc creates the build-info directory one level at a time; each is recorded.
65
+ const mkdirSync=fs.mkdirSync;
66
+ fs.mkdirSync=function(file,options,...rest){
67
+ if(typeof file==='string'&&options===undefined)note('mkdir',path.resolve(file),null);
68
+ else unsupported??='mkdirSync';
69
+ return mkdirSync.call(this,file,options,...rest);
70
+ };
71
+ // Writes, and reads this record cannot answer for, make it unusable. readSync
72
+ // and fstatSync stay open: readFileSync reads through them.
73
+ for(const name of ['writeFileSync','appendFileSync','unlinkSync','utimesSync',
74
+ 'renameSync','rmSync','watch','watchFile','existsSync','lstatSync','accessSync','opendirSync',
75
+ 'readlinkSync','readFile','stat','lstat','readdir','realpath','access','exists','open','opendir',
76
+ 'readlink','createReadStream']){
77
+ const call=fs[name];
78
+ fs[name]=Object.assign(function(...args){unsupported??=name;return call.apply(this,args);},call);
79
+ }
80
+ for(const name of ['readFile','stat','lstat','readdir','realpath','access','open','opendir','readlink']){
81
+ const call=fs.promises[name];
82
+ fs.promises[name]=function(...args){unsupported??=`promises.${name}`;return call.apply(this,args);};
83
+ }
84
+ process.on('exit',()=>{
85
+ try{
86
+ const buffer=Buffer.from(JSON.stringify({schema:1,node:[real(process.execPath),process.version],main,
87
+ unsupported,conflict,compilerReads:[...compilerReads],facts:[...facts].map(([key,value])=>{const at=key.indexOf('\0');
88
+ return [key.slice(0,at),key.slice(at+1),JSON.parse(value)];})}));
89
+ for(let at=0;at<buffer.length;)at+=original.writeSync(3,buffer,at,buffer.length-at);
90
+ }catch{}
91
+ });
@@ -19,19 +19,55 @@ export function readReceipts(state){
19
19
  let value;
20
20
  try{value=JSON.parse(fs.readFileSync(path.join(state,'receipts-v1.json'),'utf8'));}
21
21
  catch(e){if(e.code==='ENOENT')return {schema:STATE_SCHEMA,checks:{}};throw e;}
22
- if(value.schema!==STATE_SCHEMA||!value.checks||typeof value.checks!=='object')
22
+ if(!value||typeof value!=='object'||Array.isArray(value)||value.schema!==STATE_SCHEMA||
23
+ !value.checks||typeof value.checks!=='object'||Array.isArray(value.checks))
23
24
  throw Error(`incompatible receipt state in ${state}; inspect or remove only this vstate state directory`);
24
25
  return value;
25
26
  }
26
27
 
28
+ // This identity detects replacement of REDUE's owned, atomically written receipt
29
+ // selector. It is not an input-content fingerprint or permission to reuse inputs.
30
+ export function receiptRevision(state){
31
+ // A failed/crashed owned writer may have persisted a new immutable outcome
32
+ // without replacing the selector. Never certify its older selection.
33
+ try {fs.lstatSync(path.join(state,'receipt-selection-pending-v1.json'));
34
+ throw Error('receipt selection update incomplete; inspect the failed run, then record a new execution to recover');
35
+ }catch(error){if(error.code!=='ENOENT')throw error;}
36
+ try {const stat=fs.statSync(path.join(state,'receipts-v1.json'),{bigint:true});
37
+ if(!stat.isFile())throw Error('receipt selector is not a file');
38
+ return JSON.stringify(['dev','ino','size','mtimeNs','ctimeNs'].map(key=>String(stat[key])));
39
+ }catch(error){if(error.code==='ENOENT')return 'absent';throw error;}
40
+ }
41
+
42
+ export function readReceiptSnapshot(state){
43
+ const revision=receiptRevision(state),value=readReceipts(state);
44
+ if(receiptRevision(state)!==revision)
45
+ throw Error('receipt selection changed during read; retry status');
46
+ return {revision,value};
47
+ }
48
+
27
49
  export function commitReceipt(state,name,receipt){
28
50
  const prior=readReceipts(state),runId=receipt.invocation?.runId;
29
51
  if(!/^[0-9a-f-]{36}$/.test(runId||''))throw Error('receipt lacks a valid run ID');
30
52
  const runs=path.join(state,'runs-v1');
31
53
  fs.mkdirSync(runs,{recursive:true,mode:0o700});
54
+ // The caller holds the state run lock. Leave this marker on any write failure;
55
+ // only a later successful owned commit may clear it. No journal scan guesses
56
+ // which incomplete execution should become the selected evidence.
57
+ const pending=path.join(state,'receipt-selection-pending-v1.json');
58
+ let updates={schema:STATE_SCHEMA,checks:{}};
59
+ try {updates=JSON.parse(fs.readFileSync(pending,'utf8'));
60
+ if(updates?.schema!==STATE_SCHEMA||!updates.checks||typeof updates.checks!=='object'||Array.isArray(updates.checks))
61
+ throw Error('incompatible incomplete receipt update; inspect owned state before recording');
62
+ }catch(error){if(error.code!=='ENOENT')throw error;}
63
+ updates.checks[name]={runId};
64
+ atomicJson(pending,updates);
32
65
  // One immutable outcome per invocation. Never overwrite an existing run.
33
66
  fs.writeFileSync(path.join(runs,runId+'.json'),
34
67
  JSON.stringify({schema:STATE_SCHEMA,check:name,receipt}),{flag:'wx',mode:0o600});
35
68
  prior.checks[name]=receipt;
36
69
  atomicJson(path.join(state,'receipts-v1.json'),prior);
70
+ delete updates.checks[name];
71
+ if(Object.keys(updates.checks).length)atomicJson(pending,updates);
72
+ else fs.unlinkSync(pending);
37
73
  }