@redue/cli 0.1.0-alpha.11 → 0.1.0-alpha.12
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 +53 -0
- package/README.md +1 -1
- package/docs/user/agents.md +31 -14
- package/docs/user/configuration.md +17 -0
- package/docs/user/direct-typescript.md +44 -6
- package/docs/user/json.md +1 -0
- package/docs/user/pilot.md +4 -4
- package/docs/user/supported-projects.md +5 -5
- package/package.json +2 -2
- package/src/agent-integration.mjs +63 -19
- package/src/agent-policy.md +24 -66
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +59 -26
- package/src/cli.mjs +27 -12
- package/src/daemon.mjs +65 -33
- package/src/declared-plan.mjs +14 -2
- package/src/direct-run-certificate.mjs +84 -17
- package/src/direct-typescript-probe.mjs +2 -2
- package/src/direct-typescript.mjs +31 -2
- package/src/index.mjs +41 -11
- package/src/linux-inotify.py +10 -0
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +3 -15
- package/src/run-recorder.cjs +15 -2
- package/src/typescript-input-facts.mjs +29 -6
- package/src/typescript-list.mjs +28 -7
package/src/claude-hook.mjs
CHANGED
|
@@ -6,68 +6,84 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {spawn} from 'node:child_process';
|
|
9
|
+
import {projectState} from './project-location.mjs';
|
|
10
|
+
import {realObservedPath} from './path-identity.mjs';
|
|
9
11
|
|
|
10
12
|
export const hookCommand='redue agent hook claude';
|
|
11
13
|
const safe=/^[A-Za-z0-9_./:=,+-]+$/;
|
|
12
14
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
13
15
|
|
|
16
|
+
export const defaultConfig='redue.config.json';
|
|
17
|
+
// The `redue` prefix for a config beside the project root; null when the name
|
|
18
|
+
// would need shell quoting.
|
|
19
|
+
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
20
|
+
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
14
21
|
// Exact commands that rerun each configured typecheck. Names that would need
|
|
15
22
|
// shell quoting get no form, so the hook never fires for them.
|
|
16
|
-
export function hookForms(config){
|
|
17
|
-
const rows=[];
|
|
23
|
+
export function hookForms(config,configFile=defaultConfig){
|
|
24
|
+
const rows=[],redue=redueFor(configFile);
|
|
18
25
|
for(const check of config?.checks||[]){
|
|
19
26
|
if(!typecheck(check)||typeof check.name!=='string'||!safe.test(check.name))continue;
|
|
20
|
-
const forms=[
|
|
27
|
+
const forms=redue?[`${redue} run ${check.name}`]:[];
|
|
21
28
|
// Other forms run from the project root, so they match only a root check.
|
|
22
|
-
if(check.cwd!==undefined&&check.cwd!=='.'){rows.push({check:check.name,forms});continue;}
|
|
29
|
+
if(check.cwd!==undefined&&check.cwd!=='.'){if(forms.length)rows.push({check:check.name,forms});continue;}
|
|
23
30
|
if(config.packageManager==='npm'&&typeof check.script==='string'&&safe.test(check.script))
|
|
24
31
|
forms.push(`npm run ${check.script}`);
|
|
25
32
|
if(check.qualification==='npm-typescript-direct-v1')forms.push('npx tsc --noEmit');
|
|
26
33
|
if(Array.isArray(check.command)&&check.command.length&&
|
|
27
34
|
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
28
35
|
forms.push(check.command.join(' '));
|
|
29
|
-
rows.push({check:check.name,forms:[...new Set(forms)]});
|
|
36
|
+
if(forms.length)rows.push({check:check.name,forms:[...new Set(forms)]});
|
|
30
37
|
}
|
|
31
38
|
return rows;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
// Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
|
|
35
|
-
// or `-N
|
|
42
|
+
// or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
|
|
43
|
+
// no shell metacharacters, so the first separator after it ends it. Newlines
|
|
44
|
+
// separate commands, so they never match. The rest keeps its own spacing.
|
|
36
45
|
function split(command){
|
|
37
46
|
if(typeof command!=='string'||/[\r\n]/.test(command))return null;
|
|
38
|
-
const [,base,suffix]=command.trim()
|
|
39
|
-
.match(/^(.*?)((?: 2>&1)?(?:
|
|
40
|
-
|
|
47
|
+
const [,base,suffix,rest=null]=command.trim()
|
|
48
|
+
.match(/^(.*?)((?:[ \t]+2>&1)?(?:[ \t]+\|[ \t]+(?:tail|head)[ \t]+(?:-n[ \t]+\d+|-\d+))?)(?:[ \t]*(?:;|&&)[ \t]*([^\s;&|].*))?$/);
|
|
49
|
+
const one=text=>text.replace(/[ \t]+/g,' ');
|
|
50
|
+
return {base:one(base),suffix:one(suffix),rest};
|
|
41
51
|
}
|
|
42
|
-
|
|
43
|
-
const parts=split(command);
|
|
44
|
-
return parts
|
|
52
|
+
function matched(command,config,configFile){
|
|
53
|
+
const parts=split(command),check=parts&&hookForms(config,configFile).find(row=>row.forms.includes(parts.base))?.check;
|
|
54
|
+
return check?{check,rest:parts.rest}:null;
|
|
45
55
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
56
|
+
export function matchCheck(command,config,configFile){return matched(command,config,configFile)?.check||null;}
|
|
57
|
+
// The same check through REDUE, keeping the output suffix; null if it already
|
|
58
|
+
// is, or if more commands follow it.
|
|
59
|
+
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
60
|
+
const parts=split(command),redue=redueFor(configFile);
|
|
61
|
+
if(!parts||parts.rest!==null||!safe.test(name)||!redue)return null;
|
|
62
|
+
const run=`${redue} run ${name}`;
|
|
63
|
+
return parts.base===run?null:run+parts.suffix;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
export function verdict(status,name){
|
|
66
|
+
export function verdict(status,name,rest=null){
|
|
55
67
|
const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
|
|
56
68
|
status.checks.find(item=>item?.name===name):null;
|
|
57
69
|
const runId=row?.invocation?.runId;
|
|
58
70
|
if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
|
|
59
71
|
typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
|
|
60
72
|
return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}); `+
|
|
61
|
-
'rerun skipped.
|
|
73
|
+
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
74
|
+
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
75
|
+
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
function synchronizedStatus(entry,config,root,timeout){
|
|
65
79
|
return new Promise(resolve=>{
|
|
66
80
|
let child,out='',done=false;
|
|
67
|
-
const finish=value=>{if(done)return;done=true;clearTimeout(timer);
|
|
81
|
+
const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
|
|
68
82
|
if(child?.pid&&child.exitCode===null)try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}
|
|
69
83
|
resolve(value);};
|
|
70
84
|
const timer=setTimeout(()=>finish(null),timeout);
|
|
85
|
+
// Claude Code ends a hook that outlives its own timeout; end the status call with it.
|
|
86
|
+
const stop=()=>finish(null);process.once('SIGTERM',stop);
|
|
71
87
|
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
72
88
|
try{child=spawn(process.execPath,[entry,'--config',config,'status','--sync','--json'],
|
|
73
89
|
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
@@ -79,8 +95,22 @@ function synchronizedStatus(entry,config,root,timeout){
|
|
|
79
95
|
});
|
|
80
96
|
}
|
|
81
97
|
|
|
98
|
+
// Waiting for status never costs more than half the check: half the last
|
|
99
|
+
// completed run's duration, at most 10 s. With no completed run, 2 s.
|
|
100
|
+
export function statusTimeout(status,name){
|
|
101
|
+
const run=Array.isArray(status?.checks)?status.checks.find(row=>row?.name===name)?.invocation:null;
|
|
102
|
+
return run?.status==='exited'&&Number.isFinite(run.durationMs)&&run.durationMs>=0?
|
|
103
|
+
Math.min(run.durationMs/2,10000):2000;
|
|
104
|
+
}
|
|
105
|
+
// From the observer's last published status, which is small, not the receipts.
|
|
106
|
+
export function lastRunTimeout(config,parsed,name){
|
|
107
|
+
try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
|
|
108
|
+
return statusTimeout(JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8')),name);}
|
|
109
|
+
catch{return 2000;}
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
// Returns the hook's stdout object, or null to allow the command.
|
|
83
|
-
export async function claudeHook(input,{entry,timeout
|
|
113
|
+
export async function claudeHook(input,{entry,timeout}){
|
|
84
114
|
if(input?.tool_name!=='Bash')return null;
|
|
85
115
|
const command=input.tool_input?.command,root=input.cwd;
|
|
86
116
|
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
@@ -89,13 +119,16 @@ export async function claudeHook(input,{entry,timeout=2000}){
|
|
|
89
119
|
if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
|
|
90
120
|
!/^[^/\\]+\.json$/.test(record.config))return null;
|
|
91
121
|
const config=path.join(root,record.config);
|
|
92
|
-
const
|
|
93
|
-
if(!
|
|
94
|
-
const status=await synchronizedStatus(entry,config,root,timeout
|
|
122
|
+
const parsed=JSON.parse(fs.readFileSync(config,'utf8')),match=matched(command,parsed,record.config);
|
|
123
|
+
if(!match)return null;
|
|
124
|
+
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
125
|
+
reason=verdict(status,name,match.rest);
|
|
95
126
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
96
127
|
permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
97
128
|
// Rewrite only on a healthy answer for this check: without an observer,
|
|
98
|
-
// `redue run` cannot record, so the command stays as Claude sent it.
|
|
129
|
+
// `redue run` cannot record, so the command stays as Claude sent it. A chain
|
|
130
|
+
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
131
|
+
// for the rest of the chain.
|
|
99
132
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
100
133
|
status.checks.some(item=>item?.name===name)?recordedCommand(command,name,record.config):null;
|
|
101
134
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
4
|
-
import {directRunCertificateAllowed,directRunCertificate,directRunRecording,validateRunRecord} from './direct-run-certificate.mjs';
|
|
4
|
+
import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
|
|
5
|
+
import {runFactChange} from './typescript-list.mjs';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import net from 'node:net';
|
|
7
8
|
import os from 'node:os';
|
|
@@ -245,7 +246,10 @@ async function execute() {
|
|
|
245
246
|
probeBefore=certificate?.value??(recording?null:probeHash(probePlan));
|
|
246
247
|
}
|
|
247
248
|
// The record arrives on fd 3, so the compiler's environment is unchanged.
|
|
248
|
-
|
|
249
|
+
// Every direct run is recorded: even one checked by full probes needs its
|
|
250
|
+
// lookups outside the certificate, which no probe makes.
|
|
251
|
+
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
252
|
+
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
249
253
|
...command.slice(1)]:command;
|
|
250
254
|
const record=[];
|
|
251
255
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
@@ -270,7 +274,7 @@ async function execute() {
|
|
|
270
274
|
try{runLock.phase('launching');
|
|
271
275
|
const launch=windowsLaunch(launched);
|
|
272
276
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
273
|
-
stdio:
|
|
277
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:process.env,
|
|
274
278
|
detached:process.platform==='linux',...launch.options});
|
|
275
279
|
child.stdio[3]?.on('data',chunk=>record.push(chunk));}
|
|
276
280
|
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
@@ -308,8 +312,10 @@ async function execute() {
|
|
|
308
312
|
timing('cli.context_after',afterContextStarted);
|
|
309
313
|
let probeAfter=null;
|
|
310
314
|
const recordText=Buffer.concat(record).toString('utf8');
|
|
315
|
+
// Lookups the certificate does not cover, kept with the receipt.
|
|
316
|
+
let runFacts=[];
|
|
311
317
|
if(certificate){
|
|
312
|
-
const issue=validateRunRecord(recordText,certificate);
|
|
318
|
+
const issue=validateRunRecord(recordText,certificate,runFacts);
|
|
313
319
|
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
314
320
|
probeAfter=certificate.value;
|
|
315
321
|
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
@@ -319,7 +325,7 @@ async function execute() {
|
|
|
319
325
|
const endSnapshotStarted=performance.now();
|
|
320
326
|
if(!unobservedDirectRun){
|
|
321
327
|
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
322
|
-
...(recording?{runCertificate:1,adopt:recording}:{})},captureTimeout);}
|
|
328
|
+
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},captureTimeout);}
|
|
323
329
|
catch{captureIssues.push('end observation unavailable');}
|
|
324
330
|
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
325
331
|
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
@@ -327,16 +333,22 @@ async function execute() {
|
|
|
327
333
|
const end=after?.snapshots?.[name];
|
|
328
334
|
// The end certificate, recorded or probed, must cover what this compile received.
|
|
329
335
|
// With no input event, gap or content change in between, it also holds at the start.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const endCertificate=directRunCertificate(plan,selected,end,root)
|
|
333
|
-
|
|
336
|
+
// A run checked by full probes is compared with it too, for its run facts.
|
|
337
|
+
if(recorded&&!certificate){
|
|
338
|
+
const endCertificate=directRunCertificate(plan,selected,end,root),
|
|
339
|
+
questions=endCertificate||(!recording&&probeQuestions(plan,selected,end,root));
|
|
340
|
+
if(!questions)captureIssues.push('no discovery certificate at the end checkpoint');
|
|
334
341
|
else{
|
|
335
|
-
const issue=validateRunRecord(recordText,
|
|
342
|
+
const issue=validateRunRecord(recordText,questions,runFacts=[]);
|
|
336
343
|
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
337
|
-
probeBefore=probeAfter=endCertificate.value;
|
|
344
|
+
if(recording)probeBefore=probeAfter=endCertificate.value;
|
|
338
345
|
}
|
|
339
346
|
}
|
|
347
|
+
// The receipt holds only run facts that still answer as they did during the run.
|
|
348
|
+
runFacts.sort();
|
|
349
|
+
if(runFacts.length>10000)captureIssues.push('too many lookups outside the discovery certificate');
|
|
350
|
+
else{const changed=runFactChange(runFacts);
|
|
351
|
+
if(changed)captureIssues.push(`a lookup outside the discovery certificate changed during execution: ${changed}`);}
|
|
340
352
|
if(recording&&snap?.observationGaps!==end?.observationGaps)
|
|
341
353
|
captureIssues.push('observation gap during execution');
|
|
342
354
|
if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
|
|
@@ -355,7 +367,10 @@ async function execute() {
|
|
|
355
367
|
if(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
356
368
|
snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
|
|
357
369
|
const stable=captureIssues.length===0;
|
|
358
|
-
|
|
370
|
+
// A recorded run's receipt carries its run facts, even none; observers that
|
|
371
|
+
// predate run facts do not reuse version 2.
|
|
372
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded?2:1,
|
|
373
|
+
...(recorded?{runFacts}:{}),
|
|
359
374
|
resolutionLinks:plan.resolutionLinks||[],
|
|
360
375
|
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
|
361
376
|
checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
|
package/src/daemon.mjs
CHANGED
|
@@ -8,18 +8,19 @@ import {Worker} from 'node:worker_threads';
|
|
|
8
8
|
import {fileURLToPath} from 'node:url';
|
|
9
9
|
import {subscribe,hasHistory,backend} from './platform-observation.mjs';
|
|
10
10
|
import {controlEndpoint} from './control-endpoint.mjs';
|
|
11
|
-
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
11
|
+
import {observedRelative,withinPath,realObservedPath,racyWindowMs} from './path-identity.mjs';
|
|
12
12
|
import micromatch from './glob.mjs';
|
|
13
13
|
import {sha} from './plan.mjs';
|
|
14
|
-
import {InputIndex} from './index.mjs';
|
|
14
|
+
import {InputIndex,dehydrateIndex} from './index.mjs';
|
|
15
15
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
16
16
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
17
17
|
import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
|
|
18
18
|
import {history,closeHistory} from './observation.mjs';
|
|
19
19
|
import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
|
|
20
|
-
import {queriesHold} from './typescript-list.mjs';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
20
|
+
import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
|
|
21
|
+
import {statusSummary} from './cached-state.mjs';
|
|
22
|
+
import {directProject,directProbeValue,reviewedTypeScriptVersions} from './direct-typescript.mjs';
|
|
23
|
+
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
23
24
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
24
25
|
|
|
25
26
|
const configFile=path.resolve(process.argv[2]);
|
|
@@ -223,7 +224,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
223
224
|
/VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
|
|
224
225
|
const explanations={
|
|
225
226
|
'direct-typescript-implementation-unreviewed':'installed TypeScript bytes or membership differ from the reviewed compiler',
|
|
226
|
-
'direct-typescript-version-unsupported'
|
|
227
|
+
'direct-typescript-version-unsupported':`direct compiler recipe supports reviewed TypeScript ${reviewedTypeScriptVersions.join(', ')} only`,
|
|
227
228
|
'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
|
|
228
229
|
'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
|
|
229
230
|
'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
|
|
@@ -232,7 +233,11 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
232
233
|
'typescript-executable-changed':'local TypeScript executable changed or is missing',
|
|
233
234
|
'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
|
|
234
235
|
'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
|
|
235
|
-
'typescript-incremental-unsupported':'TypeScript
|
|
236
|
+
'typescript-incremental-unsupported':'TypeScript composite compilation needs explicit review',
|
|
237
|
+
'typescript-buildinfo-outside-repository':'TypeScript incremental build-info file is outside the observed repository',
|
|
238
|
+
'typescript-incremental-unsound':'TypeScript assumeChangesOnlyAffectDirectDependencies makes an incremental result depend on build history',
|
|
239
|
+
'typescript-buildinfo-name-unsupported':'TypeScript build-info file needs a plain name ending in .tsbuildinfo',
|
|
240
|
+
'typescript-project-reference-consumed':'TypeScript program uses files of a referenced project, which plain tsc does not build',
|
|
236
241
|
'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
|
|
237
242
|
'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
|
|
238
243
|
'typescript-input-list-unavailable':'TypeScript input listing did not complete',
|
|
@@ -319,7 +324,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
319
324
|
plan_id:row.plan.id,observed_at:lastObservation,
|
|
320
325
|
invocation:r?.invocation||null,target_provenance:r?.target||null,
|
|
321
326
|
...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
|
|
322
|
-
coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false
|
|
327
|
+
coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
|
|
328
|
+
...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
|
|
323
329
|
if(receiptStateError){item.reason=receiptStateError;return item;}
|
|
324
330
|
if(!healthy||planning||recovering||index.unavailable){item.reason=
|
|
325
331
|
(planning||recovering)?'input observation reconciliation pending':
|
|
@@ -330,7 +336,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
330
336
|
if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
|
|
331
337
|
if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
|
|
332
338
|
r.fingerprint===combinedSummary(name).fingerprint;
|
|
333
|
-
if(r
|
|
339
|
+
if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
|
|
340
|
+
item.reason='receipt uses earlier input-resolution format';return item;}
|
|
334
341
|
if(row.plan.unresolved.length){
|
|
335
342
|
const checkpoint=item.declared_inputs_match===false?
|
|
336
343
|
r.planId!==row.plan.id?'; observed input plan changed':
|
|
@@ -340,6 +347,9 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
340
347
|
return item;
|
|
341
348
|
}
|
|
342
349
|
if(row.probeError){item.reason=row.probeError;return item;}
|
|
350
|
+
// The plan leaves out the build-info file it saw at start; the probe sees the current one.
|
|
351
|
+
if(row.probeCache?.layout&&(row.probeCache.layout.buildInfo||null)!==(row.plan.buildInfo||null)){
|
|
352
|
+
item.reason='the incremental build-info setting changed after the observer started; restart the observer';return item;}
|
|
343
353
|
// An expired probe or missing execution context cannot confirm CURRENT, but
|
|
344
354
|
// observed input content that already differs from the receipt is STALE.
|
|
345
355
|
let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
|
|
@@ -396,11 +406,24 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
396
406
|
if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
|
|
397
407
|
item.reason=unconfirmed;item.changed_inputs=[];}
|
|
398
408
|
} else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
|
|
409
|
+
// Lookups the run made outside the certificate are asked again for every
|
|
410
|
+
// answer, cached or synchronized, before it can say CURRENT.
|
|
411
|
+
// A direct run recorded before run facts may have made lookups no probe
|
|
412
|
+
// asks; only Windows, which never records direct runs, keeps reusing them.
|
|
413
|
+
} else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
|
|
414
|
+
item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
|
|
415
|
+
} else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
|
|
399
416
|
} else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
|
|
400
417
|
item.reuse_eligible=result==='PASS';}
|
|
401
418
|
return item;
|
|
402
419
|
});
|
|
403
420
|
}
|
|
421
|
+
// Null when every run fact in a receipt answers as it did during the run.
|
|
422
|
+
function runFactReason(facts) {
|
|
423
|
+
const started=performance.now(),reason=runFactChanged(facts);
|
|
424
|
+
timing('daemon.run_facts',started,{facts:facts.length,changed:reason?1:0});
|
|
425
|
+
return reason;
|
|
426
|
+
}
|
|
404
427
|
// The indexed inputs already differ from the latest receipt, so no probe result
|
|
405
428
|
// can make this check CURRENT. A sync answers STALE without a full probe. A run
|
|
406
429
|
// probes, or builds the certificate from its recording (adopt), and a revert
|
|
@@ -439,7 +462,7 @@ function adopt(name,row,token) {
|
|
|
439
462
|
return done('inputs or certificate changed during the run');
|
|
440
463
|
const cache=pending.cache,argv=row.plan.probes[0];
|
|
441
464
|
let result;
|
|
442
|
-
try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}));}
|
|
465
|
+
try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}),buildInfoPath(row.plan));}
|
|
443
466
|
catch{return done('recording unreadable');}
|
|
444
467
|
if(result.issue)return done(result.issue);
|
|
445
468
|
const identities=new Map(cache.identities);
|
|
@@ -454,14 +477,8 @@ function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem
|
|
|
454
477
|
// Reload notifications accelerate visibility but are not required for it: a
|
|
455
478
|
// wrapper may persist its outcome even when its control connection is denied.
|
|
456
479
|
refreshReceipts();
|
|
457
|
-
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem)
|
|
458
|
-
|
|
459
|
-
observedFailed=checks.filter(r=>r.result==='FAIL').length,
|
|
460
|
-
stale=checks.filter(r=>r.freshness==='STALE').length,
|
|
461
|
-
unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
|
|
462
|
-
const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
|
|
463
|
-
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
|
|
464
|
-
stale,unverified,checks,
|
|
480
|
+
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
|
|
481
|
+
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
|
|
465
482
|
observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
|
|
466
483
|
generation,observed_at:lastObservation,
|
|
467
484
|
events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
|
|
@@ -496,16 +513,10 @@ function hydrate(bundle,saved) {
|
|
|
496
513
|
}
|
|
497
514
|
next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
|
|
498
515
|
next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
|
|
516
|
+
next.racy=new Map(saved.racy||[]);
|
|
499
517
|
return next;
|
|
500
518
|
}
|
|
501
|
-
|
|
502
|
-
return {files:[...value.files],
|
|
503
|
-
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
504
|
-
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
505
|
-
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
506
|
-
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
507
|
-
rehashedBytes:value.rehashedBytes,profile:value.profile};
|
|
508
|
-
}
|
|
519
|
+
const dehydrate=dehydrateIndex;
|
|
509
520
|
function retainIndex() {
|
|
510
521
|
if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
|
|
511
522
|
!fs.existsSync(snapshotPath))return;
|
|
@@ -958,7 +969,7 @@ async function request(message) {
|
|
|
958
969
|
mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
|
|
959
970
|
timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
|
|
960
971
|
plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
|
|
961
|
-
probe:'reuse_only_with_identical_content_and_context',installed:'
|
|
972
|
+
probe:'reuse_only_with_identical_content_and_context',installed:'current_identity_required',
|
|
962
973
|
planPhase:metrics.planPhase||'initializing',pending:pending.size,
|
|
963
974
|
notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
|
|
964
975
|
if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
|
|
@@ -1065,6 +1076,20 @@ async function request(message) {
|
|
|
1065
1076
|
}
|
|
1066
1077
|
timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
|
|
1067
1078
|
lastObservation=Date.now();publish();
|
|
1079
|
+
// A run checkpoint never holds an installed identity from the racy window:
|
|
1080
|
+
// a same-size write in the same timestamp tick would keep it. Wait the
|
|
1081
|
+
// window out (at most 2 s), record those files again, and mark the
|
|
1082
|
+
// checkpoint unusable if one is still inside it.
|
|
1083
|
+
let racy=false;
|
|
1084
|
+
if(message.action==='snapshot'||message.action==='prelaunch'){
|
|
1085
|
+
const names=message.name?[message.name]:null;
|
|
1086
|
+
const indexes=()=>[index,...[...external.values()].map(e=>e.index)].filter(Boolean);
|
|
1087
|
+
const wait=Math.min(racyWindowMs,Math.max(0,...indexes().map(i=>i.racyUntil(names)))-Date.now());
|
|
1088
|
+
if(wait>0){metrics.racyWaits=(metrics.racyWaits||0)+1;await new Promise(resolve=>setTimeout(resolve,wait+5));}
|
|
1089
|
+
for(const i of indexes())i.settleRacy();
|
|
1090
|
+
racy=indexes().some(i=>i.racyUntil(names)>Date.now());
|
|
1091
|
+
if(racy)metrics.racyCheckpoints=(metrics.racyCheckpoints||0)+1;
|
|
1092
|
+
}
|
|
1068
1093
|
const applicabilityStarted=performance.now();
|
|
1069
1094
|
const data=status(message.contextHashes,synchronizedInstalled,
|
|
1070
1095
|
process.platform==='win32'&&healthy&&!planning&&!planDirty);
|
|
@@ -1075,18 +1100,16 @@ async function request(message) {
|
|
|
1075
1100
|
planGeneration:row.plan.id,
|
|
1076
1101
|
inputEventSerial:inputEventSerial.get(name)||0,
|
|
1077
1102
|
probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
|
|
1078
|
-
observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
|
|
1103
|
+
observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
|
|
1079
1104
|
// Export only a currently guarded, check-specific discovery certificate.
|
|
1080
1105
|
// The caller compares it with the answers its own compile received. Schema 2
|
|
1081
1106
|
// certifies the realpath of every directory readDirectory visits. The
|
|
1082
1107
|
// snapshot stays the start of the observed interval, including validation.
|
|
1083
1108
|
if(message.runCertificate===1&&continuityBarrier()){
|
|
1084
1109
|
const row=index?.checks.get(message.name),cache=row?.probeCache;
|
|
1085
|
-
//
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
data.snapshots[message.name].recordingAccepted=true;
|
|
1089
|
-
else if(row?.plan.qualification==='npm-typescript-direct-v1'&&
|
|
1110
|
+
// Also after an accepted recording: the caller compares its record with
|
|
1111
|
+
// this certificate and finds its run facts with it.
|
|
1112
|
+
if(row?.plan.qualification==='npm-typescript-direct-v1'&&
|
|
1090
1113
|
!row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
|
|
1091
1114
|
cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
|
|
1092
1115
|
cache.output&&cache.queryData&&data.snapshots[message.name])
|
|
@@ -1095,6 +1118,15 @@ async function request(message) {
|
|
|
1095
1118
|
context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
|
|
1096
1119
|
queryData:cache.queryData};
|
|
1097
1120
|
}
|
|
1121
|
+
// For a run checked by full probes: the last probe's questions, only to
|
|
1122
|
+
// find the run's lookups outside them. Never evidence for reuse.
|
|
1123
|
+
if(message.runFacts===1&&data.snapshots[message.name]){
|
|
1124
|
+
const row=index?.checks.get(message.name),cache=row?.probeCache;
|
|
1125
|
+
if(row?.plan.qualification==='npm-typescript-direct-v1'&&cache?.queryData&&
|
|
1126
|
+
typeof cache.context==='string'&&cache.input===inputKey(row)&&cache.hash===row.probeHash&&
|
|
1127
|
+
cache.guard===planGuard(index.bundle))
|
|
1128
|
+
data.snapshots[message.name].probeQuestions={context:cache.context,queryData:cache.queryData};
|
|
1129
|
+
}
|
|
1098
1130
|
const row=index?.checks.get(message.name);
|
|
1099
1131
|
if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
|
|
1100
1132
|
data.snapshots[message.name].recordedCertificate=row.adoption.token;
|
package/src/declared-plan.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import {createHash} from 'node:crypto';
|
|
6
6
|
import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
|
|
7
7
|
import {qualifyTypeScript} from './typescript-qualification.mjs';
|
|
8
|
+
import {directBuildInfo} from './direct-typescript.mjs';
|
|
8
9
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
9
10
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
10
11
|
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
@@ -16,6 +17,8 @@ const safePattern=value=>{
|
|
|
16
17
|
const raw=value.startsWith('!')?value.slice(1):value;
|
|
17
18
|
if(!raw||path.isAbsolute(raw)||raw.split(/[\\/]/).includes('..'))
|
|
18
19
|
throw Error(`unsafe repository-relative input pattern: ${value}`);
|
|
20
|
+
// The observer never watches .git directories; excluding one is fine.
|
|
21
|
+
if(raw===value&&raw.split(/[\\/]/).includes('.git'))throw Error(`input pattern names a .git directory, which is not observed: ${value}`);
|
|
19
22
|
return value;
|
|
20
23
|
};
|
|
21
24
|
|
|
@@ -65,14 +68,22 @@ export function discoverDeclared(config) {
|
|
|
65
68
|
catch(e){workspaceIssue=e.message;}
|
|
66
69
|
}
|
|
67
70
|
timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
|
|
71
|
+
// An incremental direct check writes its build-info file during each run.
|
|
72
|
+
// It is compiler state, not an input, so the plan leaves it out. Read when
|
|
73
|
+
// the plan is built: a later change to it needs an observer restart, and
|
|
74
|
+
// until then the run reports the unexpected write and is not reusable.
|
|
75
|
+
let buildInfo=null;
|
|
76
|
+
if(selected.qualification==='npm-typescript-direct-v1')
|
|
77
|
+
try{buildInfo=directBuildInfo(root,selected.script);}catch{}
|
|
78
|
+
const excluded=buildInfo?['!'+buildInfo]:[];
|
|
68
79
|
const installedStarted=performance.now();
|
|
69
80
|
const installed=resolveDeclaredInstalled(root,
|
|
70
|
-
[...(selected.installedInputs||[]),...(workspace?.installed||[])],allowedRoots);
|
|
81
|
+
[...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
|
|
71
82
|
timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
|
|
72
83
|
const automatic=qualifyTypeScript(root,selected,
|
|
73
84
|
{workspace,issue:workspaceIssue});
|
|
74
85
|
const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
|
|
75
|
-
...(workspace?.generated||[])])].sort();
|
|
86
|
+
...(workspace?.generated||[]),...excluded])].sort();
|
|
76
87
|
const additivePatterns=[...new Set([...(selected.generatedInputs||[]),
|
|
77
88
|
...installed.internalPatterns])].sort();
|
|
78
89
|
const unresolved=[...installed.unresolved,...(automatic?.issues||[]),
|
|
@@ -113,6 +124,7 @@ export function discoverDeclared(config) {
|
|
|
113
124
|
installedMappings:installed.mappings,effectiveEnvironmentGlobs:{},
|
|
114
125
|
probes:selected.probes||[],environment:selected.environment||{},
|
|
115
126
|
qualification:selected.qualification||null,
|
|
127
|
+
...(buildInfo?{buildInfo}:{}),
|
|
116
128
|
executionProvenance:'declared-command',
|
|
117
129
|
unresolved:[...new Set(unresolved)].sort()};
|
|
118
130
|
plan.resolutionCandidates=workspace?.resolutionCandidates||[];
|