#!/usr/bin/env node // build-jetbrains-fixtures.mjs — Deterministic fixture-jar builder. // // Walks `tests/fixtures/ide-extensions/root-jetbrains/` and for every plugin // directory containing a `source/` subtree, produces `lib/.jar` by // packing the source files into a real ZIP with JetBrains-plugin layout: // // source/plugin.xml → META-INF/plugin.xml (inside jar) // source/MANIFEST.MF → META-INF/MANIFEST.MF (inside jar) // source/native/foo.so → native/foo.so (inside jar) // source/ (inside jar) // // Invariants: // - Deterministic: stored-method, fixed DOS timestamp, sorted entries. // Same input → byte-identical output across Node versions / platforms. // - Idempotent + race-safe: each jar write is atomic (temp-then-rename). // If the target file already exists with the expected SHA-256 (computed // from the intended bytes), the write is skipped. Concurrent callers are // safe because rename is atomic on the same filesystem and every writer // produces identical bytes. // - Zero dependencies. Uses `createZip` from `../helpers/zip-writer.mjs`. // // Public API: // export function buildJetBrainsFixtures({ fixtureRoot? }) → { fixtureRoot, builtJars } // export function writeJar(finalPath, entries) → { path, sha256, skipped } // // CLI: `node tests/helpers/build-jetbrains-fixtures.mjs` — walks the default // fixture root, builds all jars, prints a one-line summary per jar. // // Called by: // - tests/scanners/ide-extension-scanner.test.mjs (Step 14 integration) // - any future test that needs the synthetic JetBrains plugin tree // // See: plan step 13 (`ultraplan-2026-04-17-jetbrains-ide-scan.md`). import { readdir, mkdir, rename, writeFile, readFile, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { createZip } from './zip-writer.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_FIXTURE_ROOT = join( __dirname, '..', 'fixtures', 'ide-extensions', 'root-jetbrains', ); function sha256(buf) { return createHash('sha256').update(buf).digest('hex'); } /** * Recursively walk a directory, returning relative paths of regular files. * Relative paths use forward slashes (ZIP spec). */ async function walkFiles(root, rel = '') { const out = []; const full = rel ? join(root, rel) : root; const entries = await readdir(full, { withFileTypes: true }); for (const ent of entries) { const sub = rel ? `${rel}/${ent.name}` : ent.name; if (ent.isDirectory()) { out.push(...(await walkFiles(root, sub))); } else if (ent.isFile()) { out.push(sub); } } return out; } /** * Map a `source/`-relative path to the in-jar path. * * source/plugin.xml → META-INF/plugin.xml * source/MANIFEST.MF → META-INF/MANIFEST.MF * source/native/foo.so → native/foo.so * source/ */ function sourceToJarPath(srcRelPath) { const p = srcRelPath.replace(/\\/g, '/'); if (p === 'plugin.xml') return 'META-INF/plugin.xml'; if (p === 'MANIFEST.MF') return 'META-INF/MANIFEST.MF'; return p; } /** * Atomically write a file by staging into `.tmp.` and renaming. * Rename is atomic on the same filesystem; if two writers race, the last * rename wins — and since callers here pass identical bytes for a given * fixture input, the final file is byte-identical regardless of winner. */ async function atomicWrite(finalPath, bytes) { await mkdir(dirname(finalPath), { recursive: true }); const tmp = `${finalPath}.tmp.${process.pid}`; await writeFile(tmp, bytes); await rename(tmp, finalPath); } /** * Build a single jar from a list of `{ name, data }` entries and write it to * `finalPath`. Skips the write if `finalPath` already exists with the * expected SHA-256 — makes the function idempotent and safe to call * repeatedly (including concurrently from multiple test.before hooks). * * @returns {{ path: string, sha256: string, skipped: boolean }} */ export async function writeJar(finalPath, entries) { const bytes = createZip(entries); const expected = sha256(bytes); if (existsSync(finalPath)) { try { const existing = await readFile(finalPath); if (sha256(existing) === expected) { return { path: finalPath, sha256: expected, skipped: true }; } } catch { // Fall through and rewrite. } } await atomicWrite(finalPath, bytes); return { path: finalPath, sha256: expected, skipped: false }; } /** * Build fixture jars for every plugin directory under the fixture root. * A plugin directory is detected by the presence of a `source/plugin.xml`. * The produced jar lands at `/lib/.jar`. * * @param {{ fixtureRoot?: string }} [opts] * @returns {Promise<{ fixtureRoot: string, builtJars: Array<{ path: string, sha256: string, skipped: boolean, pluginId: string }> }>} */ export async function buildJetBrainsFixtures(opts = {}) { const fixtureRoot = opts.fixtureRoot || DEFAULT_FIXTURE_ROOT; if (!existsSync(fixtureRoot)) { throw new Error(`fixture root does not exist: ${fixtureRoot}`); } const builtJars = []; // Walk two levels deep: //plugins//source/plugin.xml async function walkIDEs() { const ideDirs = await readdir(fixtureRoot, { withFileTypes: true }); for (const ide of ideDirs) { if (!ide.isDirectory()) continue; const pluginsDir = join(fixtureRoot, ide.name, 'plugins'); if (!existsSync(pluginsDir)) continue; const pluginEntries = await readdir(pluginsDir, { withFileTypes: true }); for (const p of pluginEntries) { if (!p.isDirectory()) continue; const pluginDir = join(pluginsDir, p.name); const sourceDir = join(pluginDir, 'source'); if (!existsSync(join(sourceDir, 'plugin.xml'))) continue; // Build jar entries from source tree. const files = await walkFiles(sourceDir); const entries = []; for (const rel of files) { const abs = join(sourceDir, rel); const data = await readFile(abs); entries.push({ name: sourceToJarPath(rel), data }); } const jarPath = join(pluginDir, 'lib', `${p.name}.jar`); const result = await writeJar(jarPath, entries); builtJars.push({ ...result, pluginId: p.name }); } } } await walkIDEs(); return { fixtureRoot, builtJars }; } // CLI mode — invoked via `node tests/helpers/build-jetbrains-fixtures.mjs`. if (import.meta.url === `file://${process.argv[1]}`) { buildJetBrainsFixtures() .then(({ fixtureRoot, builtJars }) => { const root = relative(process.cwd(), fixtureRoot); for (const j of builtJars) { const rel = relative(process.cwd(), j.path); const tag = j.skipped ? 'skip' : ' new'; process.stdout.write(`[${tag}] ${rel}\n`); } process.stdout.write(`built ${builtJars.length} jar(s) under ${root}\n`); }) .catch((err) => { process.stderr.write(`build-jetbrains-fixtures: ${err.stack || err.message}\n`); process.exit(1); }); }