// 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=/(?//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&&indexreaddirSync(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{ 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)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)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(); }