Files
stack/packages/cli/tests/organization-imports.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

154 lines
9.0 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 one computed import, allowed only in that file and only in this exact form. It 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.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/;
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();
}