156 lines
9.2 KiB
JavaScript
156 lines
9.2 KiB
JavaScript
// Row 333 R2 (#1831): the files outside the organization package roots that its code imports, found
|
|
// by reading its imports. Rename r2 stage B: the roots are packages/organization/src, the
|
|
// implementation, and packages/business/src, the old-path shims of it; both must exist. A copy
|
|
// fixture carries these beside both roots, so a new import of
|
|
// a plug-in source file (row 333's model-quota.mjs) does not break the release tests the way it did
|
|
// when each fixture named kinds.mjs alone. A real copy ships all of packages/plugins (scripts/
|
|
// stack-copy.sh COPY_PATHS); a fixture models a smaller one and takes only what the loader reads.
|
|
//
|
|
// Vikunja #779: the helper reads imports with a regex, not a parser, so it stays strict about what
|
|
// it cannot read. Comments are blanked before matching, so an import in a comment is not one, and an
|
|
// import form inside a string or a regular expression is not one either. A dynamic import written
|
|
// with a space, `import ('./x.mjs')`, counts, and so does one after a spread, `{...import('./x.mjs')}`;
|
|
// a property call such as `x.import(...)` or `x?.import(...)` does not. `1..import(...)`, a property
|
|
// call on a number, reads as an import too, which errs toward refusal. These throw: a dynamic import
|
|
// whose argument is not a plain string, a dynamic import inside a template substitution (${...}), a
|
|
// file with an unterminated string, comment, template or regular expression, a target that is missing
|
|
// or not a regular file, and a target whose real path leaves the repository. A substitution with no
|
|
// dynamic import is blanked with the rest of the template, as is `${import.meta.url}`; the text of a
|
|
// template and a substitution that holds a template of its own are read to their ends.
|
|
// The scanner knows line and block comments, quoted strings, template literals, regular expression
|
|
// literals (by the usual guess from the previous token) and a hashbang. The guess is wrong for a
|
|
// regular expression after `)` or a keyword, as in `return /x/.test(y)`. After `++` or `--` a slash
|
|
// is division when the character before the pair ends an operand (`n++ / 2`) and starts a regular
|
|
// expression when it does not (`x = ++/re/.lastIndex`). That misreads one rare form as division: a
|
|
// prefix `++` or `--` applied to a regular expression's member right after `)`, `]`, a word or a
|
|
// line break, as in `if (c) ++/re/.lastIndex`.
|
|
import {readdirSync,readFileSync,realpathSync,statSync} from 'node:fs';
|
|
import {dirname,join,relative,resolve,sep} from 'node:path';
|
|
|
|
// `import` as a word that is not a property name. The one rule for both patterns below: a word or $
|
|
// before it, or a single dot (x.import, x?.import), makes it a property call; a dot that follows
|
|
// another dot is the end of a spread (...import), which evaluates the call, so it is not excluded.
|
|
const IMPORT_WORD=/(?<![\w$])(?<!(?<!\.)\.)import/.source;
|
|
const DYNAMIC=new RegExp(`${IMPORT_WORD}\\s*\\(`,'g');
|
|
const IMPORT=new RegExp([
|
|
/(?:import|export)\s[^'"`;]*?\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]/.source,
|
|
/\bimport\s*['"](\.{1,2}\/[^'"]+)['"]/.source,
|
|
`${DYNAMIC.source}\\s*['"](\\.{1,2}\\/[^'"]+)['"]\\s*\\)`,
|
|
].join('|'),'g');
|
|
const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/;
|
|
// The plug-in loader's computed imports, allowed only in that file and these exact forms. Core loads
|
|
// packages/plugins/<kind>/<name>/index.mjs after checking that path, and a real copy ships all of
|
|
// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it.
|
|
// PL1 adds the checked instance package entry with its verified hash as a cache key. Installed
|
|
// bytes belong to the instance, not to a release fixture's import closure.
|
|
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)|^import\(pathToFileURL\(entry\)\.href \+ '\?pin=' \+ checked\.manifest\.sha256\)/;
|
|
const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^';
|
|
const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&index<to);
|
|
const sources=(dir)=>readdirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]);
|
|
|
|
// The text with every comment blanked and every template literal emptied, and the ranges of the
|
|
// quoted strings and regular expressions left in it, as [from,to) offsets into the returned code.
|
|
// Throws on a string, comment, template or regular expression that never ends, and on a dynamic
|
|
// import inside a template substitution.
|
|
function scan(text){
|
|
let code='',i=0,previous='',before='';
|
|
const literals=[],open=[];
|
|
const fail=(what)=>{throw new Error(`unterminated ${what}`);};
|
|
const mark=(token)=>{before=previous;previous=token;};
|
|
const keep=(end,last)=>{literals.push([code.length,code.length+end-i]);code+=text.slice(i,end);i=end;mark(last);};
|
|
// Template text from i to the closing backtick, or to the ${ that opens a substitution, which is then read as code.
|
|
const template=()=>{
|
|
for(;i<text.length;i++){
|
|
if(text[i]==='\\')i++;
|
|
else if(text[i]==='`'){i++;mark('`');return;}
|
|
else if(text[i]==='$'&&text[i+1]==='{'){i+=2;open.push({braces:0,start:code.length});mark('{');return;}
|
|
}
|
|
fail('template');
|
|
};
|
|
// A substitution is read as code to its closing brace, refused if it holds a dynamic import, then dropped.
|
|
const close=({start})=>{
|
|
for(const m of code.slice(start).matchAll(DYNAMIC))if(!inert(literals,start+m.index))throw new Error('unsupported import inside a template substitution');
|
|
code=code.slice(0,start);
|
|
while(literals.length&&literals[literals.length-1][0]>=start)literals.pop();
|
|
};
|
|
if(text.startsWith('#!')){const end=text.indexOf('\n');i=end<0?text.length:end;}
|
|
while(i<text.length){
|
|
const c=text[i],next=text[i+1];
|
|
if(c==='/'&&next==='/'){const end=text.indexOf('\n',i);code+=' ';i=end<0?text.length:end;continue;}
|
|
if(c==='/'&&next==='*'){const end=text.indexOf('*/',i+2);if(end<0)fail('comment');code+=' ';i=end+2;continue;}
|
|
if(c==='"'||c==="'"){
|
|
let j=i+1;
|
|
for(;j<text.length&&text[j]!==c;j++){if(text[j]==='\\')j++;else if(text[j]==='\n')fail('string');}
|
|
if(j>=text.length)fail('string');
|
|
keep(j+1,c);continue;
|
|
}
|
|
if(c==='`'){code+='``';i++;template();continue;}
|
|
if(c==='/'&&(previous===''||REGEX_AFTER.includes(previous))){
|
|
let j=i+1,inClass=false;
|
|
for(;j<text.length&&(inClass||text[j]!=='/');j++){
|
|
if(text[j]==='\\')j++;else if(text[j]==='\n')fail('regular expression');
|
|
else if(text[j]==='[')inClass=true;else if(text[j]===']')inClass=false;
|
|
}
|
|
if(j>=text.length)fail('regular expression');
|
|
keep(j+1,'/');continue;
|
|
}
|
|
if(open.length&&(c==='{'||c==='}')){
|
|
const top=open[open.length-1];
|
|
if(c==='{')top.braces++;
|
|
else if(top.braces===0){close(open.pop());i++;template();continue;}
|
|
else top.braces--;
|
|
}
|
|
code+=c;i++;
|
|
if(!/\s/.test(c)){
|
|
const postfix=(c==='+'||c==='-')&&previous===c&&text[i-2]===c&&before!==''&&!REGEX_AFTER.includes(before);
|
|
mark(postfix?c+c:c);
|
|
}
|
|
}
|
|
if(open.length)fail('template');
|
|
return {code,literals};
|
|
}
|
|
|
|
// The specifiers a source file imports by a relative path. A dynamic import of anything but a plain
|
|
// string throws, as does a file the scanner cannot read to its end.
|
|
function specifiers(file,text){
|
|
let scanned;
|
|
try{scanned=scan(text);}
|
|
catch(error){throw new Error(`${file} cannot be read for imports: ${error.message}`);}
|
|
const {code,literals}=scanned,live=(m)=>!inert(literals,m.index);
|
|
for(const m of code.matchAll(DYNAMIC)){
|
|
const at=code.slice(m.index,m.index+512);
|
|
if(file===LOADER_FILE&&LOADER_IMPORT.test(at))continue;
|
|
if(live(m)&&!DYNAMIC_LITERAL.test(at))throw new Error(`${file} has a dynamic import that is not a plain string literal`);
|
|
}
|
|
return [...code.matchAll(IMPORT)].filter(live).map((m)=>m[1]??m[2]??m[3]);
|
|
}
|
|
|
|
// The package roots a copy fixture carries whole. A missing root throws (readdirSync), so a tree
|
|
// with only one of them is refused rather than walked by half.
|
|
const ROOTS=[['packages','organization','src'],['packages','business','src']];
|
|
|
|
// Repo-relative paths, sorted: every file that a relative import from either root reaches outside
|
|
// both, following the imports of those files in turn. An import of a missing file, of anything
|
|
// but a regular file, or of a file whose real path is outside the repository throws, wherever the
|
|
// importing file sits, so an import of a missing file inside a root throws too.
|
|
export function organizationImportedFiles(repo){
|
|
const owns=ROOTS.map((parts)=>join(repo,...parts)+sep),found=new Set(),checked=new Set(),todo=owns.flatMap((own)=>sources(own)),root=realpathSync(repo)+sep;
|
|
for(let file;(file=todo.pop())!==undefined;){
|
|
for(const spec of specifiers(relative(repo,file),readFileSync(file,'utf8'))){
|
|
const target=resolve(dirname(file),spec),from=`${relative(repo,file)} imports ${spec}`;
|
|
if(!checked.has(target)){
|
|
let info,real;
|
|
try{info=statSync(target);real=realpathSync(target);}
|
|
catch(error){throw error.code==='ENOENT'||error.code==='ENOTDIR'?new Error(`${from}, which does not exist`):error;}
|
|
if(!info.isFile())throw new Error(`${from}, which is not a regular file`);
|
|
if(!real.startsWith(root))throw new Error(`${from}, which is outside the repository`);
|
|
checked.add(target);
|
|
}
|
|
if(owns.some((own)=>target.startsWith(own))||found.has(target))continue;
|
|
found.add(target);
|
|
if(target.endsWith('.mjs'))todo.push(target);
|
|
}
|
|
}
|
|
return [...found].map((f)=>relative(repo,f)).sort();
|
|
}
|