/** * Canonical rule for which `node_modules` paths a module package bundles — * the single source of truth for module packaging (ISS-0046). * * Two packagers must agree on this: * - apps/celilo/src/module/packaging/build.ts (`celilo package` / publish) * - packages/registry-server/src/bootstrap.ts (the e2e registry-sim's * on-demand packager — what the e2e actually serves) * * They can't share this at RUNTIME: the registry-server ships in a standalone * Docker image with no `@celilo/*` deps, so it carries its own structural copy * of the rule. Lockstep is instead enforced by a test * (packages/registry-server/src/bootstrap-packaging.test.ts) that asserts the * registry-server's packaging output conforms to THIS function — so a drift in * either copy fails CI rather than shipping a broken `.netapp`. * * The rule itself: `scripts/node_modules` is the module's hook runtime, bundled * in full (minus `.bin` symlink shims) so hooks resolve their third-party deps * (tldts, drizzle-orm, …) on a target with no reachable registry; any OTHER * `node_modules` ships only `@celilo/capabilities` — the authored-against SDK. */ /** * Whether a module-relative path that contains a `node_modules` segment should * be INCLUDED in the package. Paths without a `node_modules` segment are not * this function's concern — it returns `true` for them (the caller applies its * own non-node_modules exclusions). */ export function includeNodeModulesPath(relPath: string): boolean { const segments = relPath.split('/'); const nmIdx = segments.indexOf('node_modules'); if (nmIdx < 0) return true; // scripts/node_modules: the hook runtime closure — bundle everything but .bin. if (nmIdx >= 1 && segments[nmIdx - 1] === 'scripts') { return segments[nmIdx + 1] !== '.bin'; } // Other node_modules: ship only @celilo/capabilities. if (nmIdx + 1 >= segments.length) return true; // node_modules dir itself if (segments[nmIdx + 1] !== '@celilo') return false; if (nmIdx + 2 >= segments.length) return true; // node_modules/@celilo dir itself return segments[nmIdx + 2] === 'capabilities'; } /** * What a module-relative path IS, for integrity purposes. * * - `package` the module's own content. Belongs in `checksums.json` and must * match it. A mismatch is a real finding. * - `derived` celilo writes or rewrites the on-disk copy, so its content is * not a stable integrity claim. Never a finding. * - `unknown` neither. Never packaged, never installed, and the only kind of * `[EXTRA]` worth printing. */ export type ModulePathClass = 'package' | 'derived' | 'unknown'; /** * The one answer to "what belongs to a module", replacing the four divergent * copies that used to decide it independently (`build.ts#shouldExclude`, * `audit.ts#FRAMEWORK_OWNED_PATHS`, `extract.ts#scanDirectory`, * `import.ts#copyModuleFiles`). Every disagreement between them became a * `module verify` violation — 72 of them across two healthy modules. * * `scripts/node_modules/**` is the interesting case: it is `derived`, because * `module import` runs `bun install` over it and the bytes on disk stop * matching the package's. It is nonetheless SHIPPED, because a target may have * no reachable registry (ISS-0046) — that carve-out lives in `build.ts`, which * composes this function with `includeNodeModulesPath` rather than restating * either rule. */ export const SUBMODULES_DIR = 'submodules'; export function classifyModulePath(relPath: string): ModulePathClass { const segments = relPath.split('/'); const name = segments[segments.length - 1] ?? ''; // A submodule's tree obeys exactly the same rules as a module's tree // (openspec/changes/submodules D1: a submodule declares everything an // ordinary module declares). Every rule below keys on `segments[0]`, so // without this a nested tree silently gets different answers: a submodule's // own `e2e/` would be checksummed where a module's is excluded, because // `segments[0]` reads `submodules` rather than `e2e`. // // Recursing on the remainder means one set of rules rather than two that // drift, and every rule applies at both levels: a submodule's `e2e/` is // excluded, its `celilo/types.d.ts` and `cookies.json` are derived, its // `.DS_Store` is a finding. `submodules/` and `submodules/` themselves // stay `package`, which is right: the directories ARE shipped bytes. if (segments[0] === SUBMODULES_DIR && segments.length > 2) { return classifyModulePath(segments.slice(2).join('/')); } // The module's own e2e/ tree is tests plus their deps, including a // node_modules of its own. Excluded whole, before anything below. if (segments[0] === 'e2e') return 'unknown'; // Anything under a `node_modules` segment is decided by the canonical rule // and by nothing else. Ordering matters: a vendored dependency ships files // named `*.test.ts` (`@celilo/capabilities/src/remote.test.ts` is on the // fleet right now) and those are the DEPENDENCY's, not the module's. if (segments.includes('node_modules')) { return includeNodeModulesPath(relPath) ? 'derived' : 'unknown'; } // Source-tree noise. A module's git repo has it, a module's install never // should, so on an installed tree it is a real finding. if (segments.some((s) => s === '.git' || s === '.next' || s === '.cache')) return 'unknown'; if (name === '.DS_Store') return 'unknown'; // scripts/tsconfig.json exists so tsc can check hooks in CI. Nothing on a // target ever runs tsc. if (name === 'tsconfig.json') return 'unknown'; if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown'; // Celilo's own output under the module's install root, plus the one directory // a MODULE may write to. `state/` is celilo#1000: hooks had nowhere sanctioned // to put anything, so whatever they wrote surfaced as an `extra` finding, and // the two entries beside it here (`screenshots/`, `cookies.json`) are what // that looked like being solved one filename at a time. `derived` already // means exactly what a scratch location needs (writable, survives `module // update`, not audited, not pruned), so this names a directory rather than // adding machinery. if (segments[0] === 'generated' || segments[0] === 'screenshots' || segments[0] === 'state') return 'derived'; // A checksum manifest cannot list itself, nor the signature over it. if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived'; // Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2). if (relPath === 'celilo/types.d.ts') return 'derived'; if (relPath === 'cookies.json') return 'derived'; return 'package'; }