From 269ed3e3b5f0e3104ca4427b7681fdab5a6ace00 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 27 May 2026 20:21:39 -0400 Subject: [PATCH] perf(#313): dedupe intel export extraction with a Set (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit intelExtractExports deduped export names with `if (!arr.includes(x)) arr.push(x)` across ~8 extraction loops (one doubly-nested over an export block) — O(n^2). Accumulate into Sets (add/has/size) and materialize to an array once at return. Set dedups by value and preserves insertion order, so the returned export list and its first-seen order are identical. Adds behavior-lock tests for dedup + order. Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/swift-sets-dedupe.md | 5 ++ get-shit-done/bin/lib/intel.cjs | 32 +++++------ tests/intel.test.cjs | 99 +++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 .changeset/swift-sets-dedupe.md diff --git a/.changeset/swift-sets-dedupe.md b/.changeset/swift-sets-dedupe.md new file mode 100644 index 000000000..590e1b756 --- /dev/null +++ b/.changeset/swift-sets-dedupe.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 313 +--- +Dedupe extracted exports with a Set instead of O(n^2) array.includes scans in intelExtractExports (#313). diff --git a/get-shit-done/bin/lib/intel.cjs b/get-shit-done/bin/lib/intel.cjs index b4e52e9e5..d33affa42 100644 --- a/get-shit-done/bin/lib/intel.cjs +++ b/get-shit-done/bin/lib/intel.cjs @@ -509,7 +509,7 @@ function intelExtractExports(filePath) { if (content === null) { return { file: filePath, exports: [], method: 'none' }; } - let exports = []; + const exports = new Set(); let method = 'none'; // Try module.exports = { ... } pattern (handle multi-line) @@ -537,7 +537,7 @@ function intelExtractExports(filePath) { // Match identifier at start of line (before comma, colon, end of line) const keyMatch = trimmed.match(/^(\w+)\s*[,}:]/) || trimmed.match(/^(\w+)$/); if (keyMatch) { - exports.push(keyMatch[1]); + exports.add(keyMatch[1]); } } } @@ -546,46 +546,46 @@ function intelExtractExports(filePath) { const individualPattern = /^exports\.(\w+)\s*=/gm; let im; while ((im = individualPattern.exec(content)) !== null) { - if (!exports.includes(im[1])) { - exports.push(im[1]); + if (!exports.has(im[1])) { + exports.add(im[1]); if (method === 'none') method = 'exports.X'; } } - const hadCjs = exports.length > 0; + const hadCjs = exports.size > 0; // ESM patterns - const esmExports = []; + const esmExports = new Set(); // export default function X / export default class X const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm; let em; while ((em = defaultNamedPattern.exec(content)) !== null) { - if (!esmExports.includes(em[1])) esmExports.push(em[1]); + esmExports.add(em[1]); } // export default (without named function/class) const defaultAnonPattern = /^export\s+default\s+(?!function\s|class\s)/gm; - if (defaultAnonPattern.test(content) && esmExports.length === 0) { - if (!esmExports.includes('default')) esmExports.push('default'); + if (defaultAnonPattern.test(content) && esmExports.size === 0) { + esmExports.add('default'); } // export function X( / export async function X( const exportFnPattern = /^export\s+(?:async\s+)?function\s+(\w+)\s*\(/gm; while ((em = exportFnPattern.exec(content)) !== null) { - if (!esmExports.includes(em[1])) esmExports.push(em[1]); + esmExports.add(em[1]); } // export const X = / export let X = / export var X = const exportVarPattern = /^export\s+(?:const|let|var)\s+(\w+)\s*=/gm; while ((em = exportVarPattern.exec(content)) !== null) { - if (!esmExports.includes(em[1])) esmExports.push(em[1]); + esmExports.add(em[1]); } // export class X const exportClassPattern = /^export\s+class\s+(\w+)/gm; while ((em = exportClassPattern.exec(content)) !== null) { - if (!esmExports.includes(em[1])) esmExports.push(em[1]); + esmExports.add(em[1]); } // export { X, Y, Z } — strip "as alias" parts @@ -597,24 +597,24 @@ function intelExtractExports(filePath) { if (!trimmed) continue; // "foo as bar" -> extract "foo" const name = trimmed.split(/\s+as\s+/)[0].trim(); - if (name && !esmExports.includes(name)) esmExports.push(name); + if (name) esmExports.add(name); } } // Merge ESM exports into the result for (const e of esmExports) { - if (!exports.includes(e)) exports.push(e); + exports.add(e); } // Determine method - const hadEsm = esmExports.length > 0; + const hadEsm = esmExports.size > 0; if (hadCjs && hadEsm) { method = 'mixed'; } else if (hadEsm && !hadCjs) { method = 'esm'; } - return { file: filePath, exports, method }; + return { file: filePath, exports: [...exports], method }; } // ─── Exports ───────────────────────────────────────────────────────────────── diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index 354918dd4..10e55c3eb 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -558,6 +558,105 @@ describe('intelExtractExports', () => { assert.deepStrictEqual(result.exports, []); assert.strictEqual(result.method, 'none'); }); + + // ── Behavior-lock: dedup + order (green before AND after Set conversion) ── + + test('dedup: duplicate exports.X assignments yield each name exactly once', () => { + // exports.foo appears twice — result must contain 'foo' exactly once + const filePath = path.join(tmpDir, 'dedup-exports-x.cjs'); + fs.writeFileSync(filePath, [ + "'use strict';", + 'exports.foo = 1;', + 'exports.bar = 2;', + 'exports.foo = 3;', + ].join('\n'), 'utf8'); + + const result = intelExtractExports(filePath); + assert.strictEqual(result.method, 'exports.X'); + assert.deepStrictEqual(result.exports, ['foo', 'bar']); + }); + + test('order: CJS exports.X preserves first-seen insertion order', () => { + // Names appear in source order: charlie, alpha, bravo + const filePath = path.join(tmpDir, 'order-cjs.cjs'); + fs.writeFileSync(filePath, [ + "'use strict';", + 'exports.charlie = 1;', + 'exports.alpha = 2;', + 'exports.bravo = 3;', + ].join('\n'), 'utf8'); + + const result = intelExtractExports(filePath); + assert.strictEqual(result.method, 'exports.X'); + assert.deepStrictEqual(result.exports, ['charlie', 'alpha', 'bravo']); + }); + + test('dedup: ESM export block with repeated name yields name exactly once', () => { + // export { foo, foo } — foo must appear once + const filePath = path.join(tmpDir, 'dedup-esm-block.mjs'); + fs.writeFileSync(filePath, [ + 'function foo() {}', + 'export { foo, foo };', + ].join('\n'), 'utf8'); + + const result = intelExtractExports(filePath); + assert.strictEqual(result.method, 'esm'); + assert.deepStrictEqual(result.exports, ['foo']); + }); + + test('merge order: CJS exports appear before ESM exports, each name once', () => { + // exports.X = CJS side; export function / export const = ESM side + // Expected order: CJS-first then ESM additions + const filePath = path.join(tmpDir, 'merge-order.mjs'); + fs.writeFileSync(filePath, [ + "exports.cjsFirst = 1;", + "export function esmSecond() {}", + "export const esmThird = 3;", + ].join('\n'), 'utf8'); + + const result = intelExtractExports(filePath); + assert.strictEqual(result.method, 'mixed'); + assert.deepStrictEqual(result.exports, ['cjsFirst', 'esmSecond', 'esmThird']); + }); + + test('export default collapse: only export default (anon) yields ["default"]', () => { + // A file with only `export default ` — no named exports, no default fn/class + // The collapse guard (esmExports.length === 0 at time of check) produces ["default"] + const filePath = path.join(tmpDir, 'default-only.mjs'); + fs.writeFileSync(filePath, 'export default 42;', 'utf8'); + + const result = intelExtractExports(filePath); + assert.strictEqual(result.method, 'esm'); + assert.deepStrictEqual(result.exports, ['default']); + }); + + test('export default collapse: export default fn + named exports — no "default" collapse', () => { + // export default function myFunc() {} → myFunc is extracted (named default fn) + // export const named → also extracted + // "default" literal does NOT appear because esmExports is not empty when anon-default check runs + const filePath = path.join(tmpDir, 'default-fn-plus-named.mjs'); + fs.writeFileSync(filePath, [ + 'export default function myFunc() {}', + 'export const named = 1;', + ].join('\n'), 'utf8'); + + const result = intelExtractExports(filePath); + assert.strictEqual(result.method, 'esm'); + assert.deepStrictEqual(result.exports, ['myFunc', 'named']); + }); + + test('return shape: exports is a plain Array (callers use .includes/.length)', () => { + const filePath = path.join(tmpDir, 'shape-check.cjs'); + fs.writeFileSync(filePath, [ + "'use strict';", + 'exports.foo = 1;', + ].join('\n'), 'utf8'); + + const result = intelExtractExports(filePath); + assert.ok(Array.isArray(result.exports), 'exports must be a plain Array'); + assert.ok('file' in result, 'result must have file field'); + assert.ok('method' in result, 'result must have method field'); + }); }); // ─── CLI routing via gsd-tools ──────────────────────────────────────────────