test(row 333 follow-ups R2): refuse a dynamic import inside a template substitution, read division after ++ and -- (Vikunja #779)

The business-imports helper blanked a whole template literal, so a dynamic import inside a
${...} substitution was neither found nor refused and skipped the existence and containment
checks. The scanner now reads a substitution to its closing brace, refuses it with
"unsupported import inside a template substitution" when it holds a dynamic import, and drops
it otherwise, so a nested template and a brace inside a string no longer end it early.

A slash after a postfix ++ or -- was read as the start of a regular expression, so
`n++ / 2` threw "unterminated regular expression". After a ++ or -- pair that follows an
operand the slash is division; after one that follows an operator it still starts a regular
expression. The header names the rare form this still misreads.

Output on the real tree is byte-identical.
This commit is contained in:
jason.woltje committed 2026-11-02 20:59:35 -06:00
1 parent 2ba22b4cbf
commit d3b6ab11da
2 files changed
+122 -16

No files matched your search

+46 -16
View File
@@ -8,11 +8,18 @@
// it cannot read. Comments are blanked before matching, so an import in a comment is not one, and an // 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 // import form inside a string or a regular expression is not one either. A dynamic import written
// with a space, `import ('./x.mjs')`, counts. These throw: a dynamic import whose argument is not a // with a space, `import ('./x.mjs')`, counts. These throw: a dynamic import whose argument is not a
// plain string, a file with an unterminated string, comment, template or regular expression, a // plain string, a dynamic import inside a template substitution (${...}), a file with an unterminated
// target that is missing or not a regular file, and a target whose real path leaves the repository. // 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 // 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. A template literal with a // literals (by the usual guess from the previous token) and a hashbang. The guess is wrong for a
// backtick nested inside its ${} is outside that, and so is a regular expression it guesses wrong. // 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 {readdirSync,readFileSync,realpathSync,statSync} from 'node:fs';
import {dirname,join,relative,resolve,sep} from 'node:path'; import {dirname,join,relative,resolve,sep} from 'node:path';
@@ -20,16 +27,34 @@ const IMPORT=/(?:import|export)\s[^'"`;]*?\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]|\bi
const DYNAMIC=/(?<![.\w$])import\s*\(/g; const DYNAMIC=/(?<![.\w$])import\s*\(/g;
const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/; const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/;
const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^'; 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)]:[]); 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 // 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. // 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. // Throws on a string, comment, template or regular expression that never ends, and on a dynamic
// import inside a template substitution.
function scan(text){ function scan(text){
let code='',i=0,previous=''; let code='',i=0,previous='',before='';
const literals=[]; const literals=[],open=[];
const fail=(what)=>{throw new Error(`unterminated ${what}`);}; const fail=(what)=>{throw new Error(`unterminated ${what}`);};
const keep=(end,last)=>{literals.push([code.length,code.length+end-i]);code+=text.slice(i,end);i=end;previous=last;}; 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;} if(text.startsWith('#!')){const end=text.indexOf('\n');i=end<0?text.length:end;}
while(i<text.length){ while(i<text.length){
const c=text[i],next=text[i+1]; const c=text[i],next=text[i+1];
@@ -41,12 +66,7 @@ function scan(text){
if(j>=text.length)fail('string'); if(j>=text.length)fail('string');
keep(j+1,c);continue; keep(j+1,c);continue;
} }
if(c==='`'){ if(c==='`'){code+='``';i++;template();continue;}
let j=i+1;
for(;j<text.length&&text[j]!=='`';j++)if(text[j]==='\\')j++;
if(j>=text.length)fail('template');
code+='``';i=j+1;previous='`';continue;
}
if(c==='/'&&(previous===''||REGEX_AFTER.includes(previous))){ if(c==='/'&&(previous===''||REGEX_AFTER.includes(previous))){
let j=i+1,inClass=false; let j=i+1,inClass=false;
for(;j<text.length&&(inClass||text[j]!=='/');j++){ for(;j<text.length&&(inClass||text[j]!=='/');j++){
@@ -56,9 +76,19 @@ function scan(text){
if(j>=text.length)fail('regular expression'); if(j>=text.length)fail('regular expression');
keep(j+1,'/');continue; 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++; code+=c;i++;
if(!/\s/.test(c))previous=c; 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}; return {code,literals};
} }
@@ -68,7 +98,7 @@ function specifiers(file,text){
let scanned; let scanned;
try{scanned=scan(text);} try{scanned=scan(text);}
catch(error){throw new Error(`${file} cannot be read for imports: ${error.message}`);} catch(error){throw new Error(`${file} cannot be read for imports: ${error.message}`);}
const {code,literals}=scanned,live=(m)=>!literals.some(([from,to])=>m.index>=from&&m.index<to); const {code,literals}=scanned,live=(m)=>!inert(literals,m.index);
for(const m of code.matchAll(DYNAMIC)){ for(const m of code.matchAll(DYNAMIC)){
if(live(m)&&!DYNAMIC_LITERAL.test(code.slice(m.index,m.index+512)))throw new Error(`${file} has a dynamic import that is not a plain string literal`); if(live(m)&&!DYNAMIC_LITERAL.test(code.slice(m.index,m.index+512)))throw new Error(`${file} has a dynamic import that is not a plain string literal`);
} }
@@ -152,3 +152,79 @@ test('#779 a symlink that stays inside the repository, and a repository reached
symlinkSync(repo,via); symlinkSync(repo,via);
assert.deepEqual(businessImportedFiles(via),['packages/other/src/link.mjs']); assert.deepEqual(businessImportedFiles(via),['packages/other/src/link.mjs']);
}); });
const SUBSTITUTION=/packages\/business\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/;
test('#779 R2 a dynamic import inside a template substitution throws, whether the target exists, is missing or is outside', ()=>{
const exists=withBusiness("const v=`${await import('../../other/src/real.mjs')}`;\n",{[`${EXT}real.mjs`]:EMPTY});
assert.throws(()=>businessImportedFiles(exists),SUBSTITUTION);
const missing=withBusiness("const v=`${await import('../../other/src/real.mjs')}`;\n");
assert.throws(()=>businessImportedFiles(missing),SUBSTITUTION);
const outside=tree({[`${BIZ}a.mjs`]:"const v=`${await import('../../../../outside.mjs')}`;\n",'outside.mjs':EMPTY});
assert.throws(()=>businessImportedFiles(outside),SUBSTITUTION);
});
test('#779 R2 a dynamic import inside a substitution throws however the substitution is written', ()=>{
for(const source of [
"const v=`a ${b} ${await import ('../../other/src/real.mjs')} c`;",
"const v=`${await import(name)}`;",
"const v=`${/* c */ import('../../other/src/real.mjs') /* d */}`;",
"const v=`x ${`y ${await import('../../other/src/real.mjs')}`}`;",
"const v=`${ {a:1}.a + '}' + (await import('../../other/src/real.mjs')) }`;",
"const v=`${\n await import(\n '../../other/src/real.mjs'\n )\n}`;",
]){
const repo=withBusiness(`${source}\n`,{[`${EXT}real.mjs`]:EMPTY});
assert.throws(()=>businessImportedFiles(repo),SUBSTITUTION,source);
}
const external=withBusiness("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"const v=`${await import('./b.mjs')}`;\n",[`${EXT}b.mjs`]:EMPTY});
assert.throws(()=>businessImportedFiles(external),/packages\/other\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/);
});
test('#779 R2 a substitution without a dynamic import is allowed, and the import around it is found', ()=>{
const repo=withBusiness([
"const a=`plain ${b} text ${c.d({e:1})} end`;",
"const u=`${import.meta.url}/${x.import}/${loader.import('y')}`;",
"const s=`${'}'}${\"import('../../other/src/missing1.mjs')\"}${/import('..\\/x')/.source}`;",
"const t=`text import('../../other/src/missing2.mjs') ${a} import '../../other/src/missing3.mjs'`;",
"const e=`\\${await import('../../other/src/missing4.mjs')}`;",
"const n=`outer ${`inner ${a}`} tail`;",
"import '../../other/src/real.mjs';",
].join('\n'),{[`${EXT}real.mjs`]:EMPTY});
assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']);
});
test('#779 R2 a substitution or the template text after it that never ends throws', ()=>{
for(const text of ['const a=`${b','const a=`${b} open','const a=`${`${b}']){
const repo=withBusiness(text);
assert.throws(()=>businessImportedFiles(repo),/packages\/business\/src\/a\.mjs cannot be read for imports: unterminated template/,text);
}
});
test('#779 R2 a slash after a postfix ++ or -- is division, not a regular expression', ()=>{
for(const op of ['++','--']){
const repo=withBusiness([
'let n=2;',
`const half=n${op} / 2;`,
`const spaced=n ${op}/2;`,
`const member=arr[0]${op} / 2;`,
`const grouped=(n)${op} / 2 / 3;`,
"import '../../other/src/real.mjs';",
].join('\n'),{[`${EXT}real.mjs`]:EMPTY});
assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs'],op);
}
});
test('#779 R2 a regular expression after an operator, a bracket or a prefix ++ or -- is still a regular expression', ()=>{
// Each body holds a quote, which would start an unterminated string if the slash were read as division.
const repo=withBusiness([
"const a = /it's/;",
"if (/it's/.test(s)) {}",
"const b = f(1, /it's \\/ x/);",
"const c = a + /it's/.source;",
"const d = a++ + /it's/.source;",
"const e = ++/it's/.lastIndex;",
"const f = 1 + --/it's/.lastIndex;",
"import '../../other/src/real.mjs';",
].join('\n'),{[`${EXT}real.mjs`]:EMPTY});
assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']);
});