diff --git a/scripts/add-candidate-metadata.js b/scripts/add-candidate-metadata.js index 2576c55..cd28b83 100644 --- a/scripts/add-candidate-metadata.js +++ b/scripts/add-candidate-metadata.js @@ -63,6 +63,9 @@ function main() { throw new Error('add-candidate-metadata: esm-chunked candidate is missing cycle_hoists field (audit incomplete)'); } versionEntry.cycle_hoists = offsets.cycle_hoists; + if (Object.prototype.hasOwnProperty.call(offsets, 'cycle_hoists_skipped_assets')) { + versionEntry.cycle_hoists_skipped_assets = offsets.cycle_hoists_skipped_assets; + } } else { if (!(offsets.entry_js_offset > 0) || !(offsets.entry_end_offset > offsets.entry_js_offset)) { throw new Error('legacy-cjs offsets missing entry_js_offset/entry_end_offset'); diff --git a/scripts/add-candidate-metadata.test.js b/scripts/add-candidate-metadata.test.js new file mode 100644 index 0000000..0e447e9 --- /dev/null +++ b/scripts/add-candidate-metadata.test.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const cp = require('child_process'); + +function makeTempDir(prefix) { + const baseDir = process.env.TMPDIR || (process.env.PREFIX ? path.join(process.env.PREFIX, 'tmp') : os.tmpdir()); + return fs.mkdtempSync(path.join(baseDir, prefix)); +} + +test('add-candidate-metadata: esm-chunked with cycle_hoists_skipped_assets', () => { + const tempRoot = makeTempDir('add-candidate-metadata-test-'); + try { + // Copy script to temp directory + const scriptSourcePath = path.join(__dirname, 'add-candidate-metadata.js'); + const scriptTempDir = path.join(tempRoot, 'scripts'); + fs.mkdirSync(scriptTempDir, { recursive: true }); + const scriptDestPath = path.join(scriptTempDir, 'add-candidate-metadata.js'); + fs.copyFileSync(scriptSourcePath, scriptDestPath); + + // Create directory structure + const configDir = path.join(tempRoot, 'config'); + const packageConfigDir = path.join(tempRoot, 'packages', 'claude-code', 'config'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(packageConfigDir, { recursive: true }); + + // Create initial config files (both must have identical version keys) + const initialConfig = { versions: {} }; + fs.writeFileSync(path.join(configDir, 'claude-native-audited-versions.json'), JSON.stringify(initialConfig, null, 2) + '\n'); + fs.writeFileSync(path.join(packageConfigDir, 'claude-native-audited-versions.json'), JSON.stringify(initialConfig, null, 2) + '\n'); + + // Create package.json + fs.mkdirSync(path.join(tempRoot, 'packages', 'claude-code'), { recursive: true }); + fs.writeFileSync(path.join(tempRoot, 'packages', 'claude-code', 'package.json'), JSON.stringify({ version: '0.0.0', name: '@anthropic-ai/claude-code' }, null, 2) + '\n'); + + // Create offset file with cycle_hoists_skipped_assets + const offsetFile = path.join(tempRoot, 'offsets.json'); + const offsets = { + entry_format: 'esm-chunked', + tarball_integrity: 'sha512-x', + tarball_sha256: 'y', + num_modules: 100, + byte_count: 1000, + cycle_hoists: [], + cycle_hoists_skipped_assets: ['vendor1.js', 'vendor2.js'], + }; + fs.writeFileSync(offsetFile, JSON.stringify(offsets, null, 2) + '\n'); + + // Run add-candidate-metadata + const result = cp.spawnSync('node', [scriptDestPath, '9.9.9', offsetFile], { + cwd: tempRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + assert.equal(result.status, 0, `Script failed: ${result.stderr}`); + + // Verify both config files have cycle_hoists_skipped_assets + const configRoot = JSON.parse(fs.readFileSync(path.join(configDir, 'claude-native-audited-versions.json'), 'utf8')); + const configPackage = JSON.parse(fs.readFileSync(path.join(packageConfigDir, 'claude-native-audited-versions.json'), 'utf8')); + + assert.deepEqual(configRoot.versions['9.9.9'].cycle_hoists_skipped_assets, ['vendor1.js', 'vendor2.js']); + assert.deepEqual(configPackage.versions['9.9.9'].cycle_hoists_skipped_assets, ['vendor1.js', 'vendor2.js']); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('add-candidate-metadata: esm-chunked without cycle_hoists_skipped_assets', () => { + const tempRoot = makeTempDir('add-candidate-metadata-test-'); + try { + // Copy script to temp directory + const scriptSourcePath = path.join(__dirname, 'add-candidate-metadata.js'); + const scriptTempDir = path.join(tempRoot, 'scripts'); + fs.mkdirSync(scriptTempDir, { recursive: true }); + const scriptDestPath = path.join(scriptTempDir, 'add-candidate-metadata.js'); + fs.copyFileSync(scriptSourcePath, scriptDestPath); + + // Create directory structure + const configDir = path.join(tempRoot, 'config'); + const packageConfigDir = path.join(tempRoot, 'packages', 'claude-code', 'config'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(packageConfigDir, { recursive: true }); + + // Create initial config files (both must have identical version keys) + const initialConfig = { versions: {} }; + fs.writeFileSync(path.join(configDir, 'claude-native-audited-versions.json'), JSON.stringify(initialConfig, null, 2) + '\n'); + fs.writeFileSync(path.join(packageConfigDir, 'claude-native-audited-versions.json'), JSON.stringify(initialConfig, null, 2) + '\n'); + + // Create package.json + fs.mkdirSync(path.join(tempRoot, 'packages', 'claude-code'), { recursive: true }); + fs.writeFileSync(path.join(tempRoot, 'packages', 'claude-code', 'package.json'), JSON.stringify({ version: '0.0.0', name: '@anthropic-ai/claude-code' }, null, 2) + '\n'); + + // Create offset file WITHOUT cycle_hoists_skipped_assets + const offsetFile = path.join(tempRoot, 'offsets.json'); + const offsets = { + entry_format: 'esm-chunked', + tarball_integrity: 'sha512-x', + tarball_sha256: 'y', + num_modules: 100, + byte_count: 1000, + cycle_hoists: [], + }; + fs.writeFileSync(offsetFile, JSON.stringify(offsets, null, 2) + '\n'); + + // Run add-candidate-metadata + const result = cp.spawnSync('node', [scriptDestPath, '9.9.9', offsetFile], { + cwd: tempRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + assert.equal(result.status, 0, `Script failed: ${result.stderr}`); + + // Verify both config files do NOT have cycle_hoists_skipped_assets key + const configRoot = JSON.parse(fs.readFileSync(path.join(configDir, 'claude-native-audited-versions.json'), 'utf8')); + const configPackage = JSON.parse(fs.readFileSync(path.join(packageConfigDir, 'claude-native-audited-versions.json'), 'utf8')); + + assert.equal( + Object.prototype.hasOwnProperty.call(configRoot.versions['9.9.9'], 'cycle_hoists_skipped_assets'), + false, + 'cycle_hoists_skipped_assets should not exist in root config' + ); + assert.equal( + Object.prototype.hasOwnProperty.call(configPackage.versions['9.9.9'], 'cycle_hoists_skipped_assets'), + false, + 'cycle_hoists_skipped_assets should not exist in package config' + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('add-candidate-metadata: legacy-cjs ignores cycle_hoists_skipped_assets', () => { + const tempRoot = makeTempDir('add-candidate-metadata-test-'); + try { + // Copy script to temp directory + const scriptSourcePath = path.join(__dirname, 'add-candidate-metadata.js'); + const scriptTempDir = path.join(tempRoot, 'scripts'); + fs.mkdirSync(scriptTempDir, { recursive: true }); + const scriptDestPath = path.join(scriptTempDir, 'add-candidate-metadata.js'); + fs.copyFileSync(scriptSourcePath, scriptDestPath); + + // Create directory structure + const configDir = path.join(tempRoot, 'config'); + const packageConfigDir = path.join(tempRoot, 'packages', 'claude-code', 'config'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(packageConfigDir, { recursive: true }); + + // Create initial config files (both must have identical version keys) + const initialConfig = { versions: {} }; + fs.writeFileSync(path.join(configDir, 'claude-native-audited-versions.json'), JSON.stringify(initialConfig, null, 2) + '\n'); + fs.writeFileSync(path.join(packageConfigDir, 'claude-native-audited-versions.json'), JSON.stringify(initialConfig, null, 2) + '\n'); + + // Create package.json + fs.mkdirSync(path.join(tempRoot, 'packages', 'claude-code'), { recursive: true }); + fs.writeFileSync(path.join(tempRoot, 'packages', 'claude-code', 'package.json'), JSON.stringify({ version: '0.0.0', name: '@anthropic-ai/claude-code' }, null, 2) + '\n'); + + // Create offset file for legacy-cjs WITH cycle_hoists_skipped_assets (should be ignored) + const offsetFile = path.join(tempRoot, 'offsets.json'); + const offsets = { + entry_format: 'legacy-cjs', + tarball_integrity: 'sha512-x', + tarball_sha256: 'y', + entry_js_offset: 10, + entry_end_offset: 20, + cycle_hoists_skipped_assets: ['vendor1.js'], + }; + fs.writeFileSync(offsetFile, JSON.stringify(offsets, null, 2) + '\n'); + + // Run add-candidate-metadata + const result = cp.spawnSync('node', [scriptDestPath, '9.9.9', offsetFile], { + cwd: tempRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + assert.equal(result.status, 0, `Script failed: ${result.stderr}`); + + // Verify both config files do NOT have cycle_hoists_skipped_assets key (legacy-cjs should ignore it) + const configRoot = JSON.parse(fs.readFileSync(path.join(configDir, 'claude-native-audited-versions.json'), 'utf8')); + const configPackage = JSON.parse(fs.readFileSync(path.join(packageConfigDir, 'claude-native-audited-versions.json'), 'utf8')); + + assert.equal( + Object.prototype.hasOwnProperty.call(configRoot.versions['9.9.9'], 'cycle_hoists_skipped_assets'), + false, + 'legacy-cjs should not have cycle_hoists_skipped_assets' + ); + assert.equal( + Object.prototype.hasOwnProperty.call(configPackage.versions['9.9.9'], 'cycle_hoists_skipped_assets'), + false, + 'legacy-cjs should not have cycle_hoists_skipped_assets' + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/termux-prepare-claude-native-version.js b/scripts/termux-prepare-claude-native-version.js index 4a5d5c9..c92598f 100755 --- a/scripts/termux-prepare-claude-native-version.js +++ b/scripts/termux-prepare-claude-native-version.js @@ -129,13 +129,20 @@ function analyzeCycleHoists(ownedDirForAnalysis, options = {}) { const asts = new Map(); let parseFailureCount = 0; const parseFailureFiles = []; + const skippedAssets = []; for (const f of files) { - const src = fs.readFileSync(path.join(ownedDirForAnalysis, f), 'utf8'); + const buf = fs.readFileSync(path.join(ownedDirForAnalysis, f)); + const src = buf.toString('utf8'); let ast; try { ast = acorn.parse(src, { ecmaVersion: 'latest', sourceType: 'module', allowImportExportEverywhere: true }); } catch (e) { + const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); + if (!f.startsWith('chunk-') && buf.length >= 4 && buf.subarray(0, 4).equals(ZSTD_MAGIC)) { + skippedAssets.push(f); + continue; + } parseFailureCount += 1; parseFailureFiles.push(f); continue; @@ -280,7 +287,7 @@ function analyzeCycleHoists(ownedDirForAnalysis, options = {}) { } } - return cycleHoists; + return { cycleHoists, skippedAssets: skippedAssets.sort() }; } function discoverCycleHoists(binary, ownedDirForAnalysis) { @@ -310,12 +317,13 @@ function discoverEsmChunkedOffsets(binary, packDir) { throw new Error('entry module is legacy-cjs wrapped, not esm-chunked'); } const cycleAnalysisDir = path.join(packDir, 'cycle-analysis'); - const cycleHoists = discoverCycleHoists(binary, cycleAnalysisDir); + const { cycleHoists, skippedAssets } = discoverCycleHoists(binary, cycleAnalysisDir); return { entry_format: 'esm-chunked', num_modules: graph.numModules, byte_count: graph.byteCount, cycle_hoists: cycleHoists, + cycle_hoists_skipped_assets: skippedAssets, }; } finally { fs.closeSync(graph.fd); diff --git a/scripts/termux-prepare-claude-native-version.test.js b/scripts/termux-prepare-claude-native-version.test.js index 5677c4b..874c6ff 100644 --- a/scripts/termux-prepare-claude-native-version.test.js +++ b/scripts/termux-prepare-claude-native-version.test.js @@ -29,14 +29,15 @@ test('analyzeCycleHoists: structural cycle + eager call', () => { 'var x = import.meta.require("/$bunfs/root/A.js").someExport;\nexport const y = "B";\n' ); - const result = analyzeCycleHoists(tempDir); - assert.equal(result.length, 1); - assert.deepEqual(result[0], { + const { cycleHoists, skippedAssets } = analyzeCycleHoists(tempDir); + assert.equal(cycleHoists.length, 1); + assert.deepEqual(cycleHoists[0], { file: 'B.js', targetModule: 'A.js', expectedOccurrences: 1, assertProperties: ['someExport'], }); + assert.deepEqual(skippedAssets, []); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -57,8 +58,9 @@ test('analyzeCycleHoists: structural cycle + all-delayed calls', () => { 'function f() { var x = import.meta.require("/$bunfs/root/A.js").someExport; }\nexport const y = "B";\n' ); - const result = analyzeCycleHoists(tempDir); - assert.equal(result.length, 0); + const { cycleHoists, skippedAssets } = analyzeCycleHoists(tempDir); + assert.equal(cycleHoists.length, 0); + assert.deepEqual(skippedAssets, []); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -79,8 +81,9 @@ test('analyzeCycleHoists: no cycle', () => { 'export const z = "B";\n' ); - const result = analyzeCycleHoists(tempDir); - assert.equal(result.length, 0); + const { cycleHoists, skippedAssets } = analyzeCycleHoists(tempDir); + assert.equal(cycleHoists.length, 0); + assert.deepEqual(skippedAssets, []); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -109,6 +112,53 @@ test('analyzeCycleHoists: parse failure throws', () => { } }); +test('analyzeCycleHoists: chunk file parse failure still throws', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + fs.writeFileSync(path.join(tempDir, 'A.js'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(tempDir, 'chunk-bad.js'), 'this is {{{ invalid syntax'); + assert.throws(() => analyzeCycleHoists(tempDir), /failed to parse/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: non-chunk zstd assets are skipped and sorted', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + fs.writeFileSync(path.join(tempDir, 'A.js'), 'export const a = 1;\n'); + const zstd = Buffer.concat([Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), Buffer.from('xxxxxx')]); + fs.writeFileSync(path.join(tempDir, 'z.js'), zstd); + fs.writeFileSync(path.join(tempDir, 'a.js'), zstd); + const { skippedAssets } = analyzeCycleHoists(tempDir); + assert.deepEqual(skippedAssets, ['a.js', 'z.js']); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: non-chunk non-zstd parse failure still throws', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + fs.writeFileSync(path.join(tempDir, 'A.js'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(tempDir, 'g.js'), Buffer.concat([Buffer.from([0x1f, 0x8b, 0x08, 0x00]), Buffer.from('data')])); + assert.throws(() => analyzeCycleHoists(tempDir), /failed to parse/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: chunk file with zstd magic still throws', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + fs.writeFileSync(path.join(tempDir, 'A.js'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(tempDir, 'chunk-z.js'), Buffer.concat([Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), Buffer.from('xxxxxx')])); + assert.throws(() => analyzeCycleHoists(tempDir), /failed to parse/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('analyzeCycleHoists: acorn version mismatch throws', () => { const tempDir = makeTempDir('cycle-hoist-test-'); try {