@redue/cli 0.1.0-alpha.3 → 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.
Files changed (67) hide show
  1. package/CHANGELOG.md +806 -0
  2. package/README.md +157 -99
  3. package/bin/redue.mjs +267 -61
  4. package/docs/README.md +3 -1
  5. package/docs/user/agents.md +333 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +219 -0
  8. package/docs/user/getting-started.md +49 -7
  9. package/docs/user/json.md +4 -2
  10. package/docs/user/node-script.md +177 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/sandbox.md +312 -0
  14. package/docs/user/shell.md +3 -3
  15. package/docs/user/supported-projects.md +69 -23
  16. package/docs/user/tests-direct.md +143 -0
  17. package/package.json +4 -3
  18. package/src/agent-integration.mjs +467 -0
  19. package/src/agent-policy.md +28 -0
  20. package/src/cached-state.mjs +24 -1
  21. package/src/claude-hook.mjs +616 -0
  22. package/src/cli.mjs +449 -54
  23. package/src/daemon.mjs +442 -93
  24. package/src/decision-profile.mjs +15 -1
  25. package/src/decision-validation.mjs +12 -1
  26. package/src/declared-plan.mjs +38 -6
  27. package/src/direct-run-certificate.mjs +254 -0
  28. package/src/direct-typescript-probe.mjs +27 -0
  29. package/src/direct-typescript.mjs +205 -0
  30. package/src/glob.mjs +8 -0
  31. package/src/history-worker.mjs +21 -13
  32. package/src/index.mjs +108 -21
  33. package/src/installed-inputs.mjs +7 -1
  34. package/src/installed-observation.mjs +23 -0
  35. package/src/landlock.py +527 -0
  36. package/src/linux-inotify.py +39 -10
  37. package/src/node-onboarding.mjs +203 -54
  38. package/src/node-script.mjs +406 -0
  39. package/src/npm-integrity.mjs +124 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npmrc.mjs +16 -0
  42. package/src/observation.mjs +34 -12
  43. package/src/path-identity.mjs +8 -0
  44. package/src/plan-worker.mjs +17 -17
  45. package/src/pnpm-inputs.mjs +122 -57
  46. package/src/pnpm-shim-templates.json +180 -0
  47. package/src/pnpm-shims.mjs +54 -0
  48. package/src/pnpm-typecheck-probe.mjs +11 -6
  49. package/src/presentation.mjs +13 -4
  50. package/src/probe-deadline.mjs +37 -0
  51. package/src/probe-result.mjs +7 -3
  52. package/src/project-location.mjs +34 -0
  53. package/src/python-onboarding.mjs +89 -0
  54. package/src/run-recorder.cjs +91 -0
  55. package/src/sandbox.mjs +653 -0
  56. package/src/setup.mjs +205 -0
  57. package/src/stats.mjs +72 -0
  58. package/src/test-direct.mjs +608 -0
  59. package/src/test-recorder.cjs +435 -0
  60. package/src/typescript-contract-probe.mjs +4 -187
  61. package/src/typescript-input-facts.mjs +92 -0
  62. package/src/typescript-invocation.mjs +92 -0
  63. package/src/typescript-list.mjs +118 -13
  64. package/src/typescript-qualification.mjs +29 -49
  65. package/src/workspace-manifests.mjs +24 -0
  66. package/src/yarn-workspace-inputs.mjs +3 -0
  67. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
@@ -1,15 +1,29 @@
1
1
  // Opt-in, bounded local diagnostics. Never include paths, command arguments,
2
2
  // environment values, source, or probe output in a timing record.
3
3
  import fs from 'node:fs';
4
+ import {AsyncLocalStorage} from 'node:async_hooks';
5
+ import {randomUUID} from 'node:crypto';
4
6
 
5
7
  const file=process.env.REDUE_DECISION_PROFILE_FILE;
6
8
  export const profiling=process.env.REDUE_DECISION_PROFILE==='1'&&!!file;
7
9
 
10
+ const traces=new AsyncLocalStorage();
11
+ const safe=value=>typeof value==='string'&&/^[a-zA-Z0-9_-]{1,64}$/.test(value)?value:undefined;
12
+ const clean=value=>Object.fromEntries(['request','generation','work'].map(key=>[key,safe(value?.[key])]).filter(([,v])=>v));
13
+ let base={};
14
+ try{base=clean(JSON.parse(process.env.REDUE_PROFILE_CONTEXT||'{}'));}catch{}
15
+ export const traceContext=()=>profiling?{...base,...traces.getStore()}:{};
16
+ export function traceBase(value){if(profiling)base=clean(value);}
17
+ export const traceId=()=>randomUUID();
18
+ export const traceScope=(value,fn)=>profiling?traces.run({...traceContext(),...clean(value)},fn):fn();
19
+ export const traceEnvironment=()=>profiling?{...process.env,REDUE_PROFILE_CONTEXT:JSON.stringify(traceContext())}:process.env;
20
+ export function mark(phase,fields={}){timing(phase,performance.now(),fields);}
21
+
8
22
  export function timing(phase,started,counts={}) {
9
23
  if(!profiling)return;
10
24
  const memory=process.memoryUsage();
11
25
  try{fs.appendFileSync(file,'REDUE_TIMING '+JSON.stringify({
12
- at:Date.now(),pid:process.pid,phase,
26
+ at:Date.now(),monoNs:process.hrtime.bigint().toString(),pid:process.pid,phase,...traceContext(),
13
27
  ms:Math.round((performance.now()-started)*10)/10,
14
28
  rssMiB:Math.round(memory.rss/1048576*10)/10,
15
29
  ...counts
@@ -17,7 +17,8 @@ export function planGuard(bundle) {
17
17
  ...bundle.workspaceManifests||[],...bundle.configurationInputs||[],
18
18
  ...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[])]);
19
19
  const patterns=bundle.workspacePatterns?.length?bundle.workspacePatterns:
20
- (()=>{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
20
+ (()=>{let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));}
21
+ catch(e){if(e.code==='ENOENT')return [];throw e;}
21
22
  return Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages||[];})();
22
23
  if(patterns.length){
23
24
  const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
@@ -39,3 +40,13 @@ export function planGuard(bundle) {
39
40
  const rootStat=fs.statSync(root,{bigint:true});
40
41
  return sha(JSON.stringify([String(rootStat.dev),String(rootStat.ino),values]));
41
42
  }
43
+ // Identity of the reviewed compiler tree and the probe's Node executable. A full
44
+ // probe verifies their bytes after this is taken, so any later write changes it.
45
+ export function toolchainKey(tsRoot,executable) {
46
+ const values=[];
47
+ const visit=file=>{const st=fs.lstatSync(file,{bigint:true});
48
+ values.push([file,...['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'].map(key=>String(st[key]))]);
49
+ if(st.isDirectory())for(const name of fs.readdirSync(file).sort())visit(path.join(file,name));};
50
+ visit(tsRoot);visit(fs.realpathSync(executable));
51
+ return sha(JSON.stringify(values));
52
+ }
@@ -5,8 +5,13 @@ 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';
9
+ import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
+ import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
11
+ import {isSandboxed,qualifySandbox,sandboxInstalledExclusions} from './sandbox.mjs';
8
12
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
9
13
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
14
+ import {checkRun} from './typescript-invocation.mjs';
10
15
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
11
16
  import {timing} from './decision-profile.mjs';
12
17
 
@@ -16,6 +21,8 @@ const safePattern=value=>{
16
21
  const raw=value.startsWith('!')?value.slice(1):value;
17
22
  if(!raw||path.isAbsolute(raw)||raw.split(/[\\/]/).includes('..'))
18
23
  throw Error(`unsafe repository-relative input pattern: ${value}`);
24
+ // The observer never watches .git directories; excluding one is fine.
25
+ if(raw===value&&raw.split(/[\\/]/).includes('.git'))throw Error(`input pattern names a .git directory, which is not observed: ${value}`);
19
26
  return value;
20
27
  };
21
28
 
@@ -60,19 +67,32 @@ export function discoverDeclared(config) {
60
67
  catch(e){workspaceIssue=e.message;}
61
68
  }
62
69
  if(selected.qualification==='pnpm-tsc-v1'){
63
- try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',
64
- selected.script||selected.command.at(-1));}
70
+ try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',checkRun(selected));}
65
71
  catch(e){workspaceIssue=e.message;}
66
72
  }
67
73
  timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
74
+ // An incremental direct check writes its build-info file during each run.
75
+ // It is compiler state, not an input, so the plan leaves it out. Read when
76
+ // the plan is built: a later change to it needs an observer restart, and
77
+ // until then the run reports the unexpected write and is not reusable.
78
+ let buildInfo=null;
79
+ if(selected.qualification==='npm-typescript-direct-v1')
80
+ try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
81
+ const excluded=buildInfo?['!'+buildInfo]:[];
82
+ // A sandboxed check leaves out the .venv's links that lead out of the
83
+ // checkout; its qualification names them.
84
+ const sandboxed=isSandboxed(selected.qualification);
85
+ if(sandboxed)excluded.push(...sandboxInstalledExclusions(root));
68
86
  const installedStarted=performance.now();
69
87
  const installed=resolveDeclaredInstalled(root,
70
- [...(selected.installedInputs||[]),...(workspace?.installed||[])],allowedRoots);
88
+ [...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
71
89
  timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
72
- const automatic=qualifyTypeScript(root,selected,
73
- {workspace,issue:workspaceIssue});
90
+ const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
91
+ selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
92
+ sandboxed?qualifySandbox(root,selected):
93
+ qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
74
94
  const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
75
- ...(workspace?.generated||[])])].sort();
95
+ ...(workspace?.generated||[]),...excluded])].sort();
76
96
  const additivePatterns=[...new Set([...(selected.generatedInputs||[]),
77
97
  ...installed.internalPatterns])].sort();
78
98
  const unresolved=[...installed.unresolved,...(automatic?.issues||[]),
@@ -80,6 +100,15 @@ export function discoverDeclared(config) {
80
100
  ...(workspaceIssue?[workspaceIssue]:[])];
81
101
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
82
102
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
103
+ // A workspace test check, and a general check in a workspace, is qualified
104
+ // against the current package list: a package manifest appearing or pnpm's
105
+ // package list changing re-plans.
106
+ if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification||
107
+ sandboxed&&typeof selected.script==='string')try{
108
+ for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
109
+ workspacePatterns.add(item);
110
+ if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
111
+ }catch{}
83
112
  for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
84
113
  if(!automatic)for(const category of ['source','generated','installedDependencies',
85
114
  'environment','toolchain','runtime'])if(!selected.coverage?.[category])
@@ -113,6 +142,9 @@ export function discoverDeclared(config) {
113
142
  installedMappings:installed.mappings,effectiveEnvironmentGlobs:{},
114
143
  probes:selected.probes||[],environment:selected.environment||{},
115
144
  qualification:selected.qualification||null,
145
+ ...(buildInfo?{buildInfo}:{}),
146
+ // A sandboxed run's reads are not recorded: git-ignored files are inputs too.
147
+ ...(sandboxed?{ignoredInputs:true}:{}),
116
148
  executionProvenance:'declared-command',
117
149
  unresolved:[...new Set(unresolved)].sort()};
118
150
  plan.resolutionCandidates=workspace?.resolutionCandidates||[];
@@ -0,0 +1,254 @@
1
+ // A caller validates discovery, never an execution outcome. No persistent cache.
2
+ // The direct compiler run records the answers it received (run-recorder.cjs);
3
+ // those answers, not a separate probe, show it consumed the certified inputs.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {createHash} from 'node:crypto';
7
+ import {inflateSync} from 'node:zlib';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {timing} from './decision-profile.mjs';
10
+ import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue,viteCacheQuestion} from './direct-typescript.mjs';
11
+ import {withinPath} from './path-identity.mjs';
12
+ const hash=value=>createHash('sha256').update(value).digest('hex');
13
+ // Whether a direct run is recorded at all: on macOS and Linux, always, so its
14
+ // run facts are known even when full probes check it.
15
+ export function directRunRecordable(selected) {
16
+ return ['darwin','linux'].includes(process.platform)&&selected.qualification==='npm-typescript-direct-v1'&&
17
+ selected.probes?.length===1&&
18
+ selected.probes[0][1]===fileURLToPath(new URL('direct-typescript-probe.mjs',import.meta.url));
19
+ }
20
+ // Whether a recorded run may replace the probes (tests can force full probes).
21
+ export function directRunCertificateAllowed(selected) {
22
+ return directRunRecordable(selected)&&
23
+ !(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_RUN_PROBES==='1');
24
+ }
25
+ // The incremental build-info file the plan leaves out of the inputs, if any.
26
+ export const buildInfoPath=plan=>typeof plan?.buildInfo==='string'?path.join(plan.root,plan.buildInfo):null;
27
+ // The start snapshot's discovery certificate, checked without a child process.
28
+ // Returns what a recorded run is compared against, or null for full probes.
29
+ export function directRunCertificate(plan,selected,snapshot,root) {
30
+ const started=performance.now(),c=snapshot?.discoveryCertificate;
31
+ const fallback=reason=>{timing('cli.run_certificate',started,{outcome:'fallback',reason});return null;};
32
+ if(!directRunCertificateAllowed(selected))return fallback('unsupported');
33
+ if(!snapshot?.observationHealthy||!c||c.schema!==2||c.planId!==plan.id||
34
+ c.planId!==snapshot.planId||c.cwd!==plan.cwd||
35
+ c.probeDefinition!==hash(JSON.stringify(selected.probes))||
36
+ ![c.output,c.context,c.stderrHash].every(s=>typeof s==='string'&&/^[a-f0-9]{64}$/.test(s))||
37
+ typeof c.queryData!=='string'||c.queryData.length>1024*1024)
38
+ return fallback('certificate_unavailable');
39
+ // The compile runs in this process's environment; the daemon's is never substituted.
40
+ if(directEnvironmentIssue())return fallback('environment_unsupported');
41
+ try{
42
+ const argv=selected.probes[0];
43
+ const value=directProbeValue(argv,c.output,c.stderrHash);
44
+ if(value!==snapshot.probeHash)return fallback('probe_identity_changed');
45
+ // Bytes were verified by the daemon's full probe; its toolchain identity
46
+ // check at the start and end snapshots covers later changes.
47
+ const compiler=directCompiler(root,{verify:false});
48
+ if(selected.command[1]!==compiler.entry)return fallback('command_unsupported');
49
+ const queries=JSON.parse(inflateSync(Buffer.from(c.queryData,'base64'),{maxOutputLength:8*1024*1024}));
50
+ if(!Array.isArray(queries)||!queries.length)return fallback('certificate_invalid');
51
+ timing('cli.run_certificate',started,{outcome:'available',queries:queries.length});
52
+ return {value,queries,context:c.context,compiler,buildInfo:buildInfoPath(plan)};
53
+ }catch{return fallback('certificate_invalid');}
54
+ }
55
+ // The last probe's questions, sent for a run checked by full probes. They only
56
+ // show which of the run's lookups are run facts; the full probes, not these,
57
+ // establish the result.
58
+ export function probeQuestions(plan,selected,snapshot,root) {
59
+ const q=snapshot?.probeQuestions;
60
+ if(!directRunRecordable(selected)||typeof q?.queryData!=='string'||
61
+ q.queryData.length>1024*1024||typeof q.context!=='string')return null;
62
+ try{const queries=JSON.parse(inflateSync(Buffer.from(q.queryData,'base64'),{maxOutputLength:8*1024*1024}));
63
+ if(!Array.isArray(queries)||!queries.length)return null;
64
+ return {queries,context:q.context,compiler:directCompiler(root,{verify:false}),buildInfo:buildInfoPath(plan)};}
65
+ catch{return null;}
66
+ }
67
+ // Without a start certificate, the daemon may offer to build one from this run's
68
+ // recording at the end checkpoint. Returns its token, or null for full probes.
69
+ export function directRunRecording(plan,selected,snapshot,root) {
70
+ const token=snapshot?.recordedCertificate;
71
+ if(!directRunCertificateAllowed(selected)||!snapshot.observationHealthy||
72
+ typeof token!=='string'||!/^[0-9a-f-]{36}$/.test(token)||snapshot.planId!==plan.id||directEnvironmentIssue())
73
+ return null;
74
+ try{if(selected.command[1]!==directCompiler(root,{verify:false}).entry)return null;}catch{return null;}
75
+ return token;
76
+ }
77
+ // Answers derived from a recorded raw listing, exactly as queryAnswer computes them.
78
+ function derived(kind,entries,options) {
79
+ if(entries===null)return null;
80
+ if(kind==='entries')return entries.map(([name,type])=>[name,type==='d'?'d':type==='l'?'l':'f']).sort();
81
+ const resolved=entries.flatMap(([name,type,target])=>type!=='l'?[[name,type]]:target?[[name,target]]:[]);
82
+ if(kind==='directories'){
83
+ if(entries.some(([,type,target])=>type==='l'&&!target))return null;
84
+ return resolved.filter(([,type])=>type==='d').map(([name])=>name).sort();
85
+ }
86
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
87
+ return resolved.flatMap(([name,type])=>type==='d'?[[name,'d']]:
88
+ type==='f'&&(!suffixes||suffixes.some(value=>name.toLowerCase().endsWith(value)))?[[name,'f']]:[]).sort();
89
+ }
90
+ // What one certificate fact says about a recorded stat answer: true, false, or
91
+ // null when it says nothing. A parent listing that does not name the path only
92
+ // rules some answers out, so it must agree but does not count as cover.
93
+ function statAgrees([kind,file,answer,options],target,result) {
94
+ if(file===target){
95
+ // queryAnswer reports a missing path as null, not false.
96
+ if(answer===null)return ['fileExists','directoryExists','readFile'].includes(kind)?result===null:null;
97
+ if(kind==='fileExists')return result!==null&&(result==='f')===answer;
98
+ if(kind==='directoryExists')return result!==null&&(result==='d')===answer;
99
+ if(kind==='readFile')return result==='f';
100
+ return null;
101
+ }
102
+ if(file!==path.dirname(target)||!Array.isArray(answer))return null;
103
+ const name=path.basename(target);
104
+ if(kind==='directories')return answer.includes(name)?result==='d':result==='d'?false:undefined;
105
+ if(kind==='entries'){const entry=answer.find(row=>row[0]===name);
106
+ return !entry?result===null:entry[1]==='l'?null:entry[1]===result;}
107
+ if(kind==='listing'){const entry=answer.find(row=>row[0]===name);
108
+ if(entry)return entry[1]===result;
109
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
110
+ const listed=!suffixes||suffixes.some(value=>name.toLowerCase().endsWith(value));
111
+ return result==='d'||listed&&result==='f'?false:undefined;}
112
+ return null;
113
+ }
114
+ const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
115
+ file!==compiler.entry&&file.toLowerCase()===compiler.entry.toLowerCase();
116
+ // The certificate question that asks again what a run's stat, realpath or
117
+ // package.json read answered, in queryAnswer's terms; null when it cannot be asked again.
118
+ export function runFact(kind,file,answer) {
119
+ if(kind==='stat')return answer===null||answer==='f'?['fileExists',file,answer&&true]:
120
+ answer==='d'?['directoryExists',file,true]:null;
121
+ if(kind==='realpath')return answer===null||typeof answer==='string'?['realpath',file,answer]:null;
122
+ // Declaration checks read the package.json of each dependency they resolve.
123
+ // Its content hash is asked again like any other run fact; other reads are not.
124
+ if(kind==='read'&&path.basename(file)==='package.json')
125
+ return answer===null||typeof answer==='string'?['readFile',file,answer]:null;
126
+ return null;
127
+ }
128
+ // Null when every answer the run received agrees with the certified state,
129
+ // otherwise why not. The compiler is deterministic given its context and its
130
+ // answers, so a run whose answers all agree computed the certified result.
131
+ // Each answer needs at least one certificate fact about it. Paths inside the
132
+ // compiler tree are covered by the daemon's toolchain identity instead. tsc
133
+ // also stats a case-swapped copy of its own path to detect case sensitivity;
134
+ // only that one stat is exempt.
135
+ // With a `facts` array, a stat or realpath the probe never asked about is
136
+ // added to it instead (declaration diagnostics resolve module paths that
137
+ // building the program does not), as is a read of a package.json. The receipt
138
+ // keeps those run facts, and status asks them again before every CURRENT
139
+ // answer. Any other uncovered read, and any directory listing, is an issue.
140
+ // The record's facts without the build-info file an incremental run reads and
141
+ // writes, or {issue} when the run wrote anything else. That file is compiler
142
+ // state, not an input. A directory tsc created for it was missing when tsc
143
+ // checked it, so that check goes too; every other check stays.
144
+ export function withoutBuildInfo(facts,buildInfo) {
145
+ const created=new Set(facts.filter(([kind])=>kind==='mkdir').map(([,file])=>file));
146
+ for(const [kind,file] of facts){
147
+ if(kind==='write'&&file!==buildInfo)return {issue:`the compiler wrote ${file}`};
148
+ if(kind==='mkdir'&&!buildInfo?.startsWith(file+path.sep))return {issue:`the compiler created ${file}`};
149
+ }
150
+ return {facts:facts.filter(([kind,file])=>kind!=='write'&&kind!=='mkdir'&&file!==buildInfo&&
151
+ !(kind==='stat'&&created.has(file)))};
152
+ }
153
+ export function runRecordIssue(record,certificate,runFacts=null) {
154
+ if(record?.schema!==1||!Array.isArray(record.facts))return 'record unavailable';
155
+ if(record.unsupported)return `unsupported ${record.unsupported}`;
156
+ if(record.conflict)return `answer changed during the run: ${record.conflict}`;
157
+ const {compiler,queries}=certificate,kept=withoutBuildInfo(record.facts,certificate.buildInfo||null);
158
+ if(kept.issue)return kept.issue;
159
+ if(record.main!==compiler.entry||!Array.isArray(record.node)||
160
+ directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
161
+ const vite=viteCacheQuestion(compiler.root,kept.facts);
162
+ if(vite)return `the compiler asked about ${vite}, which Vitest rewrites and REDUE does not observe`;
163
+ const inCompiler=withinCompiler(compiler);
164
+ const facts=new Map();
165
+ for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
166
+ for(const [kind,file,answer] of kept.facts){
167
+ if(inCompiler(file))continue;
168
+ const known=facts.get(file)||[];
169
+ let checks=[];
170
+ if(kind==='read')checks=known.filter(q=>q[0]==='readFile')
171
+ .map(q=>JSON.stringify(q[2])===JSON.stringify(answer));
172
+ else if(kind==='realpath')checks=known.filter(q=>q[0]==='realpath')
173
+ .map(q=>q[2]===answer);
174
+ else if(kind==='readdir')checks=known.filter(q=>['listing','directories','entries'].includes(q[0]))
175
+ .map(q=>JSON.stringify(derived(q[0],answer,q[3]))===JSON.stringify(q[2]));
176
+ else if(kind==='stat')checks=[...known,...(facts.get(path.dirname(file))||[])]
177
+ .map(q=>statAgrees(q,file,answer)).filter(value=>value!==null);
178
+ else return `unknown answer ${kind}`;
179
+ if(!checks.some(value=>value!==undefined)){
180
+ const fact=runFacts&&runFact(kind,file,answer);
181
+ if(fact){runFacts.push(fact);continue;}
182
+ return `${kind} ${file} is not in the discovery certificate`;
183
+ }
184
+ if(checks.includes(false))return `${kind} ${file} differs from the discovery certificate`;
185
+ // Covered only by a listing of its directory: a later probe may keep the
186
+ // same program while that listing changes, so the run's own lookup stays
187
+ // a run fact.
188
+ if(kind==='stat'&&runFacts&&!known.length){const fact=runFact(kind,file,answer);if(fact)runFacts.push(fact);}
189
+ }
190
+ return null;
191
+ }
192
+ // Compares the record run-recorder.cjs wrote to fd 3. Run facts go to `facts`.
193
+ export function validateRunRecord(text,certificate,facts=[]) {
194
+ const started=performance.now();
195
+ let record=null;
196
+ try{record=JSON.parse(text);}catch{}
197
+ let issue;
198
+ try{issue=runRecordIssue(record,certificate,facts);}catch{issue='record unreadable';}
199
+ timing('cli.run_recording',started,{outcome:issue?'rejected':'validated',
200
+ facts:record?.facts?.length||0,runFacts:facts.length,...(issue?{reason:issue}:{})});
201
+ return issue;
202
+ }
203
+ // A certificate for the current inputs, built from a recorded run and the
204
+ // certificate of the last full probe, or {issue}. The run must read the same
205
+ // files, with the same configuration, and every answer it received must be
206
+ // covered by the old questions or be a run fact; only file contents may differ.
207
+ // Run facts stay out of this certificate: the receipt keeps them, and status
208
+ // asks them again for every answer. The program
209
+ // layout is then the same, so the output is rebuilt as the probe computes it.
210
+ // Limit: a file whose role changes (a package.json newly imported as a module)
211
+ // keeps its old role, so a later full probe can report the check STALE.
212
+ export function recordedCertificate(text,cache,compiler,buildInfo=null) {
213
+ let record=null;
214
+ try{record=JSON.parse(text);}catch{}
215
+ if(record?.schema!==1||!Array.isArray(record.facts))return {issue:'record unavailable'};
216
+ if(!cache?.layout||!cache.queries?.length)return {issue:'no previous layout'};
217
+ const kept=withoutBuildInfo(record.facts,buildInfo);
218
+ if(kept.issue)return {issue:kept.issue};
219
+ const inCompiler=withinCompiler(compiler),reads=new Map();
220
+ const before=new Map(cache.queries.filter(q=>q[0]==='readFile'&&!inCompiler(q[1])).map(q=>[q[1],q[2]]));
221
+ for(const [kind,file,answer] of kept.facts)if(kind==='read'&&!inCompiler(file)){
222
+ if(answer!==null&&typeof answer!=='string')return {issue:`read ${file} failed`};
223
+ // A package.json only declaration checks read is a run fact, not a program read.
224
+ if(!before.has(file)&&path.basename(file)==='package.json')continue;
225
+ reads.set(file,answer);
226
+ }
227
+ if(reads.size!==before.size||[...before.keys()].some(file=>!reads.has(file)))
228
+ return {issue:'the run read different files'};
229
+ if(cache.layout.configs.some(([file])=>!reads.has(file)||reads.get(file)!==before.get(file)))
230
+ return {issue:'configuration changed'};
231
+ // Lib files are in the compiler tree: their bytes are covered by its identity,
232
+ // but which of them the program loads must not change.
233
+ const libs=list=>[...new Set(list.filter(file=>inCompiler(file)&&file.endsWith('.d.ts')))].sort();
234
+ if(!Array.isArray(record.compilerReads)||JSON.stringify(libs(record.compilerReads))!==
235
+ JSON.stringify(libs(cache.queries.filter(q=>q[0]==='readFile').map(q=>q[1]))))
236
+ return {issue:'the run loaded different lib files'};
237
+ const changed=new Set([...reads].filter(([file,answer])=>before.get(file)!==answer).map(([file])=>file));
238
+ const queries=cache.queries.map(q=>q[0]==='readFile'&&changed.has(q[1])?[q[0],q[1],reads.get(q[1]),...q.slice(3)]:q);
239
+ // Run facts belong to the receipt, which the caller builds; they do not
240
+ // change the program layout.
241
+ const issue=runRecordIssue({...record,facts:kept.facts},{compiler,queries,context:cache.context},[]);
242
+ if(issue)return {issue};
243
+ const files=[];
244
+ for(const [file,digest] of cache.layout.files){
245
+ if(!changed.has(file)){files.push([file,digest]);continue;}
246
+ let bytes;
247
+ try{bytes=fs.readFileSync(file);}catch{return {issue:`${file} unreadable`};}
248
+ // The same answer the compiler received (queryAnswer's readFile).
249
+ if(hash(JSON.stringify(bytes.toString('utf8')))!==reads.get(file))return {issue:`${file} changed after the run`};
250
+ files.push([file,hash(bytes)]);
251
+ }
252
+ const layout={...cache.layout,files};
253
+ return {queries,layout,output:directProbeOutput(layout),changed};
254
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import {directProject,directInterpretation,directContextHash,directProbeOutput,viteCacheQuestion} from './direct-typescript.mjs';
3
+ import {typeScriptInputFacts} from './typescript-input-facts.mjs';
4
+ import {contextOnly,probeResult} from './probe-result.mjs';
5
+ import {realObservedPath} from './path-identity.mjs';
6
+ import {timing} from './decision-profile.mjs';
7
+ import {runArgv,runFromArgv} from './typescript-invocation.mjs';
8
+ try{
9
+ const [rootArg,...rest]=process.argv.slice(2).filter(value=>!value.startsWith('--redue-')),root=realObservedPath(rootArg);
10
+ const run=runFromArgv(rest),script=runArgv(run).join(' ');
11
+ // Verify all installed implementation bytes before loading compiler API/code,
12
+ // including the optimized context-only path. A version label is insufficient.
13
+ const implementationStarted=performance.now();
14
+ const compiler=directProject(root,run);
15
+ timing('direct_probe.implementation',implementationStarted,{version:compiler.version});
16
+ const context={interpretation:directInterpretation,compiler:compiler.digest,
17
+ entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
18
+ const contextHash=directContextHash(compiler,context.node);
19
+ if(!contextOnly(contextHash)){
20
+ const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
21
+ // Captured questions include every config and program file read.
22
+ if(viteCacheQuestion(root,queries||[...configs,...files].map(([file])=>['readFile',file])))
23
+ throw Object.assign(Error('typescript-vite-cache-consumed'),{code:'typescript-vite-cache-consumed'});
24
+ const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
25
+ probeResult(directProbeOutput(layout),contextHash,queries,layout);
26
+ }
27
+ }catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:'typescript-contract-unavailable'}`);process.exitCode=2;}
@@ -0,0 +1,205 @@
1
+ // Versioned, explicit offline compiler recipe. No package manager is executed.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {createHash} from 'node:crypto';
5
+ import {createRequire} from 'node:module';
6
+ import {normalizedPath,racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
7
+ import {cachedTarball,lockedPackage,matchTarball,npmCacheDir,registryPackage,tarballFiles} from './npm-integrity.mjs';
8
+ import {runConfig,tscRun} from './typescript-invocation.mjs';
9
+ export const directInterpretation='npm-direct-typescript@1';
10
+ export const directQualification='npm-typescript-direct-v1';
11
+ const reviewed={
12
+ '5.5.2':{entry:'lib/tsc.js',digest:'953b4844b5f6edc745be844b6c367c227b59029b73c4182f508f90cc47e21816'},
13
+ '5.6.3':{entry:'lib/tsc.js',digest:'e674b5dc4dac50f6e91be30094aeea983a1f340231717e62b0b6199ae3049eb2'},
14
+ '5.9.3':{entry:'lib/_tsc.js',digest:'d1b32e75475710fdc85102b1365254d84ceb192d3639d962c53fe1355ce274ca'},
15
+ '6.0.2':{entry:'lib/_tsc.js',digest:'273e16620fe23362e085980d6dd822e1315d81ba792221c9b6008755fdafccdd'},
16
+ '6.0.3':{entry:'lib/_tsc.js',digest:'e0a6519f0d0a90125b7fd3bdbbcf700e167656beb3baf8aca8ed8bc037494537'},
17
+ };
18
+ export const reviewedTypeScriptVersions=Object.keys(reviewed);
19
+ const fail=code=>{throw Object.assign(Error(code),{code});};
20
+ const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
21
+ export const directEnvironment={variables:['NODE_ENV','NODE_OPTIONS','NODE_PATH','NODE_COMPILE_CACHE','VSCODE_INSPECTOR_OPTIONS'],
22
+ prefixes:['NODE_','DYLD_','LD_','TSGO_','TSC_','TS_ETW_'],executableIdentity:true};
23
+ // NODE_OPTIONS flags that only size the V8 heap or pick TLS/DNS behavior; the
24
+ // reviewed compilers open no network connections. Anything else, including any
25
+ // code loader (--require, --import, --loader), is unclassified.
26
+ const inertNodeOptions=[/^--max-(?:old|semi)-space-size=\d+$/,/^--use-(?:openssl|bundled|system)-ca$/,
27
+ /^--dns-result-order=(?:ipv4first|ipv6first|verbatim)$/];
28
+ // One variable's effect on the direct compiler: null when it cannot change what
29
+ // tsc reads or reports. Accepted values stay fingerprinted as NODE_ context.
30
+ export function directVariableIssue(key,value){
31
+ const unsupported='direct-execution-environment-unsupported';
32
+ // The 5.5.2 API can require this unreviewed module during probe loading.
33
+ // Reject even empty/case aliases; do not mutate the caller's environment.
34
+ if(/^TS_ETW_MODULE_PATH$/i.test(key)||/^VSCODE_INSPECTOR_OPTIONS$/i.test(key)&&value)return unsupported;
35
+ if(/^NODE_/i.test(key)){
36
+ // Node 22.13.0 predates env-proxy initialization. This injected flag is
37
+ // inert there, but remains strictly fingerprinted as NODE_ context.
38
+ if(key==='NODE_USE_ENV_PROXY'&&value==='1'&&process.version==='v22.13.0'&&
39
+ !process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))return null;
40
+ if(key==='NODE_ENV'&&['','production','test'].includes(value))return null;
41
+ // PEM data for TLS roots, not code.
42
+ if(key==='NODE_EXTRA_CA_CERTS')return null;
43
+ // Only bare CommonJS requires consult it; the reviewed compilers make one,
44
+ // source-map-support, and only under NODE_ENV=development.
45
+ if(key==='NODE_PATH')return null;
46
+ // No quotes or escapes, so Node splits the value exactly as this does.
47
+ if(key==='NODE_OPTIONS'&&/^[A-Za-z0-9=._ -]*$/.test(value)&&
48
+ value.split(' ').filter(Boolean).every(flag=>inertNodeOptions.some(re=>re.test(flag))))return null;
49
+ if(value!=='')return unsupported;
50
+ }
51
+ if(/^(DYLD_|LD_|TSGO_|TSC_)/i.test(key)&&value!=='')return unsupported;
52
+ return null;
53
+ }
54
+ export function directEnvironmentIssue(env=process.env){
55
+ if(![22,24].includes(Number(process.versions.node.split('.')[0])))return 'direct-node-version-unsupported';
56
+ for(const [key,value] of Object.entries(env)){const issue=directVariableIssue(key,value);if(issue)return issue;}
57
+ return null;
58
+ }
59
+ export function directCompiler(root,{verify=true}={}){
60
+ root=realObservedPath(root);
61
+ const manifest=createRequire(path.join(root,'package.json')).resolve('typescript/package.json');
62
+ const tsRoot=path.dirname(realObservedPath(manifest));
63
+ if(!withinPath(tsRoot,root)||tsRoot!==realObservedPath(path.join(root,'node_modules/typescript')))
64
+ fail('direct-typescript-layout-unsupported');
65
+ const version=JSON.parse(fs.readFileSync(manifest)).version;
66
+ if(!reviewed[version])return lockedCompiler(root,tsRoot,version,verify);
67
+ const spec=reviewed[version];
68
+ if(verify){
69
+ const files=[];
70
+ const walk=(dir,relative='')=>{
71
+ for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
72
+ const name=relative?relative+'/'+entry.name:entry.name,full=path.join(dir,entry.name);
73
+ if(entry.isDirectory())walk(full,name);
74
+ else if(entry.isFile())files.push([name,full]);
75
+ else fail('direct-typescript-implementation-unreviewed');
76
+ }
77
+ };walk(tsRoot);
78
+ const digest=createHash('sha256');
79
+ for(const [name,file] of files.sort(([a],[b])=>a<b?-1:a>b?1:0))digest.update(name+'\0'+hash(fs.readFileSync(file))+'\0');
80
+ if(digest.digest('hex')!==spec.digest)fail('direct-typescript-implementation-unreviewed');
81
+ }
82
+ return {root,tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
83
+ }
84
+ // A TypeScript 5.x or 6.0 the lockfile records: the installed package must be
85
+ // the registry's `typescript` at that version, holding exactly the files of
86
+ // the tarball npm cached under the lockfile's integrity. Its identity is that
87
+ // integrity. The entry is lib/_tsc.js where the package has one (5.7 and
88
+ // later), as for the reviewed versions, else lib/tsc.js. A verified tree is
89
+ // remembered per process while its files keep their identity.
90
+ const verifiedTrees=new Map();
91
+ function treeIdentity(tsRoot){
92
+ const parts=[],now=Date.now();
93
+ const walk=folder=>{for(const entry of fs.readdirSync(folder,{withFileTypes:true})){
94
+ const full=path.join(folder,entry.name),st=fs.lstatSync(full,{bigint:true});
95
+ if(racyUntil(st)>now)return null;
96
+ parts.push([full,st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':'));
97
+ if(entry.isDirectory()&&walk(full)===null)return null;
98
+ }};
99
+ try{if(walk(tsRoot)===null)return null;}catch{return null;}
100
+ return hash(parts.sort().join('\n'));
101
+ }
102
+ function lockedCompiler(root,tsRoot,version,verify){
103
+ if(!/^(?:5\.\d+|6\.0)\.\d+$/.test(version))fail('direct-typescript-version-unsupported');
104
+ let locked;try{locked=lockedPackage(root,'typescript');}
105
+ catch(e){fail(['npm-lockfile-unreadable','npm-shrinkwrap-unsupported'].includes(e.code)?e.code:'direct-typescript-lockfile-integrity-missing');}
106
+ if(locked.version!==version)fail('direct-typescript-lockfile-version-differs');
107
+ if(!registryPackage(locked,'typescript',version)||
108
+ JSON.parse(fs.readFileSync(path.join(tsRoot,'package.json'),'utf8')).name!=='typescript')
109
+ fail('direct-typescript-lockfile-source-unsupported');
110
+ if(verify){
111
+ const tree=treeIdentity(tsRoot);
112
+ if(!tree||verifiedTrees.get(locked.integrity)!==tree){
113
+ matchTarball(tsRoot,tarballFiles(cachedTarball(npmCacheDir(root),locked.integrity)));
114
+ if(tree)verifiedTrees.set(locked.integrity,tree);
115
+ }
116
+ }
117
+ const entry=fs.existsSync(path.join(tsRoot,'lib','_tsc.js'))?'lib/_tsc.js':'lib/tsc.js';
118
+ return {root,tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
119
+ }
120
+ // The direct probe's output: root, script, configuration and program files with
121
+ // their byte digests, and the execution context. A recorded run rebuilds it from
122
+ // the same layout (direct-run-certificate.mjs).
123
+ export function directProbeOutput({root,script,configs,files,context,buildInfo}){
124
+ const named=list=>list.map(([file,digest])=>[hash(file),digest]);
125
+ return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context,
126
+ ...(buildInfo?{buildInfo}:{})}));
127
+ }
128
+ // The build-info file an incremental root config makes tsc read and write,
129
+ // as {file} relative to the root, {} when not incremental, or {issue}. The
130
+ // probe and the plan both use this rule. It must be inside the checkout, end
131
+ // in .tsbuildinfo (the only write the run recorder allows) and contain no glob
132
+ // characters (the plan excludes it with a pattern). An option that makes the
133
+ // result depend on the build-info history is refused.
134
+ export function buildInfoFor(ts,options,root) {
135
+ if(!options?.incremental)return {};
136
+ if(options.assumeChangesOnlyAffectDirectDependencies)return {issue:'typescript-incremental-unsound'};
137
+ const file=ts.getTsBuildInfoEmitOutputFilePath?.(options);
138
+ if(!file||!withinPath(path.resolve(file),root))return {issue:'typescript-buildinfo-outside-repository'};
139
+ const relative=path.relative(root,path.resolve(file)).split(path.sep).join('/');
140
+ if(!relative.endsWith('.tsbuildinfo')||/[*?[\]{}()!+@,]/.test(relative))return {issue:'typescript-buildinfo-name-unsupported'};
141
+ return {file:relative};
142
+ }
143
+ // The config a run's arguments name, relative to the root, or null.
144
+ export const directConfig=(root,args)=>runConfig(root,args);
145
+ // The plan's build-info file, or null. The compiler is loaded only when the
146
+ // run's config sets incremental or extends another config.
147
+ export function directBuildInfo(root,run) {
148
+ const name=directConfig(root,run.args);if(!name)return null;
149
+ const config=path.join(root,name);
150
+ let raw;try{raw=fs.readFileSync(config,'utf8');}catch{return null;}
151
+ if(!/"(?:incremental|extends)"\s*:/.test(raw))return null;
152
+ const compiler=directProject(root,run,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
153
+ const parsed=ts.getParsedCommandLineOfConfigFile(config,{},{...ts.sys,onUnRecoverableConfigFileDiagnostic(){}});
154
+ return parsed?.options?.composite?null:buildInfoFor(ts,parsed?.options,root).file??null;
155
+ }
156
+ // The probe value the daemon stores for one direct probe's checkpoint output.
157
+ export function directProbeValue(argv,output,stderrHash){
158
+ return hash(JSON.stringify([[argv,0,hash(output),stderrHash]]));
159
+ }
160
+ // The execution context a direct probe certifies and a recorded run must match.
161
+ export function directContextHash(compiler,node){
162
+ return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
163
+ entry:compiler.entry,node}));
164
+ }
165
+ // Vitest writes its results under node_modules/.vite/vitest at every run. The
166
+ // recipe does not observe them. A compiler that asks about anything under
167
+ // node_modules/.vite, is told a path there (realpath), or lists node_modules,
168
+ // could see them, so its result is UNVERIFIED.
169
+ export const directInstalledInputs=Object.freeze(['node_modules/**','!node_modules/.vite/vitest/**']);
170
+ const listings=new Set(['readdir','listing','directories','entries','readDirectory']);
171
+ // The first path of [kind, file, answer] questions that does, or null. Kinds
172
+ // are a recorded run's (readdir) or a probe's. Compared without case: a
173
+ // case-insensitive mount reaches .vite under any spelling.
174
+ export function viteCacheQuestion(root,questions){
175
+ const fold=value=>normalizedPath(value).toLowerCase();
176
+ const modules=fold(path.join(root,'node_modules')),cache=path.join(modules,'.vite');
177
+ for(const [kind,file,answer] of questions){
178
+ if(typeof file!=='string')continue;
179
+ const at=fold(file);
180
+ if(withinPath(at,cache)||at===modules&&listings.has(kind))return file;
181
+ if(kind==='realpath'&&typeof answer==='string'&&withinPath(fold(answer),cache))return answer;
182
+ }
183
+ return null;
184
+ }
185
+ // The compiler for one check run {script, part, args}: script null for a
186
+ // check init proposed without one. The run must pass --noEmit and name a
187
+ // config inside the root.
188
+ export function directProject(root,run,{verify=true}={}){
189
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
190
+ if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
191
+ !['package-lock.json','npm-shrinkwrap.json'].some(file=>fs.existsSync(path.join(root,file)))||
192
+ ['yarn.lock','pnpm-lock.yaml','bun.lock','bun.lockb'].some(file=>fs.existsSync(path.join(root,file))))
193
+ fail('direct-npm-project-unsupported');
194
+ const own=tscRun(pkg.scripts,run).run;
195
+ if(!own?.noEmit)fail('typecheck-script-changed');
196
+ const config=directConfig(root,run.args);
197
+ if(!config||!fs.existsSync(path.join(root,config)))fail('typescript-config-unavailable');
198
+ const issue=directEnvironmentIssue();if(issue)fail(issue);
199
+ return {...directCompiler(root,{verify}),config};
200
+ }
201
+ // Provenance only: retain a digest, never proxy URLs/credentials or a raw dump.
202
+ // No npm process runs in this recipe; these keys are not compiler applicability.
203
+ export function observedNpmContext(env=process.env){
204
+ return hash(JSON.stringify(Object.entries(env).filter(([key])=>/^npm_/i.test(key)||/^(?:https?_proxy|all_proxy|no_proxy)$/i.test(key)).sort(([a],[b])=>a<b?-1:a>b?1:0)));
205
+ }
package/src/glob.mjs CHANGED
@@ -7,3 +7,11 @@ export default {
7
7
  matcher(pattern,options){return picomatch(pattern,options);},
8
8
  isMatch(file,patterns,options){return picomatch(patterns,options)(file);}
9
9
  };
10
+ // A matcher for a pattern list with `!` exclusions: a path matches when a
11
+ // positive pattern does and no excluded one does. A plain list match would
12
+ // take any `!` pattern as matching everything else.
13
+ export function patternMatcher(patterns){
14
+ const positive=patterns.filter(p=>!p.startsWith('!')).map(p=>picomatch(p,{dot:true}));
15
+ const negative=patterns.filter(p=>p.startsWith('!')).map(p=>picomatch(p.slice(1),{dot:true}));
16
+ return file=>positive.some(fn=>fn(file))&&!negative.some(fn=>fn(file));
17
+ }