From bdb2ceb230cd0dde9de22360d01476d410f0faae Mon Sep 17 00:00:00 2001 From: montjeffrey Date: Fri, 9 Oct 2026 17:48:27 -0400 Subject: [PATCH] fix(carriers): follow Node's CommonJS candidates, see regex after a block, ignore non-import from - An extensionless require now tries only the path, .js, .json, and a directory's index.js/index.json, as Node does; .cjs and .mjs must be named, so require('./helper') no longer passes on helper.cjs alone. - Treat `}` as a statement boundary, so a regex after a block is read as a regex and cannot swallow a later require. - Decide whether a paren opens a condition from the token before the keyword, ignoring whitespace, so `obj.\nif(x) / 2` and `obj?. while(x) / 2` stay method calls followed by division. - Record which slots came from template literals and skip them for static `from` and side-effect `import`, which accept only a quoted string; a tagged template named `from` is no longer an import. A template passed to import() is still a dependency. Rescanning all 802 repository JavaScript files against dd14493e changes no result, and the real registry still reports 35/35 resolved with zero failures or warnings. --- scripts/ci/validate-carrier-closure.js | 49 ++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/scripts/ci/validate-carrier-closure.js b/scripts/ci/validate-carrier-closure.js index 08114284c..cea037909 100644 --- a/scripts/ci/validate-carrier-closure.js +++ b/scripts/ci/validate-carrier-closure.js @@ -39,9 +39,9 @@ const CLOSE = `${MARK}${MARK}`; const ESCAPE_PATTERN = /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\s\S]))/g; const SIMPLE_ESCAPES = Object.freeze({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }); const LINE_CONTINUATIONS = new Set(['\n', '\r', '\r\n', '\u2028', '\u2029']); -const REGEX_PRECEDES = /(?:[([{,;:=!&|?+\-*/%~^<>]|\b(?:return|typeof|case|in|of|new|delete|void|instanceof|do|else|yield|await))$/; -// A `(` opening a statement condition; `a.if(` is a method call, not one. -const CONTROL_OPENS = /(?:^|[^.\w$])(?:if|while|for|with)$/; +// `}` ends a block, so a slash after it starts a statement: a regex. +const REGEX_PRECEDES = /(?:[([{},;:=!&|?+\-*/%~^<>]|\b(?:return|typeof|case|in|of|new|delete|void|instanceof|do|else|yield|await))$/; +const CONDITION_KEYWORD = /(?:^|[^\w$])(if|while|for|with)$/; /** Cook a raw string or template chunk the way the JavaScript parser would. */ function decodeEscapes(raw) { @@ -61,6 +61,16 @@ function isAbsoluteSpecifier(value) { return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value) || /^file:/i.test(value); } +/** + * Whether code ends with a keyword whose `(` opens a statement condition. + * `obj.if(`, `obj?. while(` and `obj.` + newline + `if(` are method calls, + * so the token before the keyword, ignoring whitespace, must not be a dot. + */ +function opensCondition(code) { + const match = CONDITION_KEYWORD.exec(code); + return Boolean(match) && !code.slice(0, code.length - match[1].length).trimEnd().endsWith('.'); +} + function readRegexLiteral(source, start) { let index = start + 1; let inClass = false; @@ -92,7 +102,13 @@ function scanSource(rawSource) { let closedCondition = false; let code = ''; let index = 0; - const slot = value => { code += `${MARK}${literals.push(decodeEscapes(value)) - 1}${MARK}`; }; + // Slots cut from template literals; `from` and `import` need a quoted string. + const templates = new Set(); + const slot = (value, fromTemplate = false) => { + const slotIndex = literals.push(decodeEscapes(value)) - 1; + if (fromTemplate) templates.add(slotIndex); + code += `${MARK}${slotIndex}${MARK}`; + }; const frame = () => (stack.length ? stack[stack.length - 1] : null); while (index < source.length) { @@ -102,9 +118,9 @@ function scanSource(rawSource) { if (open && open.type === 'template') { if (char === '\\') { open.text += source.substr(index, 2); index += 2; continue; } - if (char === '`') { stack.pop(); slot(open.text); index += 1; continue; } + if (char === '`') { stack.pop(); slot(open.text, true); index += 1; continue; } if (char === '$' && next === '{') { - slot(open.text); + slot(open.text, true); open.text = ''; stack.push({ type: 'interpolation', depth: 0 }); index += 2; @@ -148,13 +164,13 @@ function scanSource(rawSource) { open.depth -= 1; } } - if (char === '(') parens.push(CONTROL_OPENS.test(code.trimEnd())); + if (char === '(') parens.push(opensCondition(code.trimEnd())); else if (char === ')') closedCondition = parens.pop() === true; code += char; index += 1; } - if (frame() && frame().type === 'template') slot(frame().text); - return { code, literals }; + if (frame() && frame().type === 'template') slot(frame().text, true); + return { code, literals, templates }; } function collect(pattern, code, handler) { @@ -177,7 +193,7 @@ function renderExpression(text, literals) { * or static `import`/`from`, which Node resolves by exact path. */ function extractReferences(rawSource) { - const { code, literals } = scanSource(rawSource); + const { code, literals, templates } = scanSource(rawSource); const specifiers = new Set(); const imports = new Set(); const packages = new Set(); @@ -192,8 +208,12 @@ function extractReferences(rawSource) { } }; collect(CALL_PATTERN, code, match => record(literals[Number(match[2])], match[1] === 'import')); + // Static `from` and side-effect `import` take only a quoted string, so a + // template there (a tag named `from`, say) is not an import. for (const pattern of [FROM_PATTERN, SIDE_EFFECT_PATTERN]) { - collect(pattern, code, match => record(literals[Number(match[1])], true)); + collect(pattern, code, match => { + if (!templates.has(Number(match[1]))) record(literals[Number(match[1])], true); + }); } collect(DIRNAME_JOIN_PATTERN, code, match => { const segments = []; @@ -210,8 +230,9 @@ function extractReferences(rawSource) { /** * The planned paths that would satisfy a specifier, or the failure reason - * when none can. CommonJS `require` searches extensions and directory - * indexes; ESM (`import`, or any `.mjs` file) resolves only the exact path. + * when none can. CommonJS `require` tries the path, then `.js` and `.json`, + * then a directory's `index.js` and `index.json` (`.cjs` and `.mjs` must be + * named); ESM (`import`, or any `.mjs` file) resolves only the exact path. * A relative backslash is not a separator on Linux or macOS, so it is * rejected rather than normalized. */ @@ -222,7 +243,7 @@ function candidateDestinations(destinationPath, specifier, exact) { if (base === '..' || base.startsWith('../')) return { base, candidates: null, reason: 'outside-carrier-tree' }; const candidates = exact ? [base] - : [base, `${base}.js`, `${base}.cjs`, `${base}.mjs`, `${base}.json`, `${base}/index.js`, `${base}/index.json`]; + : [base, `${base}.js`, `${base}.json`, `${base}/index.js`, `${base}/index.json`]; return { base, candidates, reason: null }; }