morphit/scripts/workspace-membership-smoke.ts

222 lines
8 KiB
TypeScript

/**
* Morphit — workspace-membership sentinel smoke.
*
* Asserts that every `package.json` under `apps/` or `packages/`
* is registered in the root `package.json`'s `workspaces` array,
* AND that every workspace `tsconfig.json` is referenced by
* `scripts/typecheck-sweep.sh` (or in the documented exclusion
* set with a reason).
*
* Why this smoke exists: Audit Part 86 (B-1) caught a silent gap
* where `apps/ops-cli` had been scaffolded with its own deps,
* tests, smoke registrations, and tsconfig — but was missing from
* the root workspaces array. Consequence: CI's `npm ci` did NOT
* install ops-cli's unique deps, the typecheck-sweep's noise
* filter masked any unresolvable imports as "expected uninstalled
* module noise," and CI's `npm test --workspaces --if-present`
* did NOT run ops-cli's 24-test vitest suite at all.
*
* Local development worked only because sibling workspaces happened
* to hoist matching deps to root `node_modules/`. This smoke makes
* any future drift between "child package.json exists" and "child
* is in root workspaces" a CI failure instead of a silent leak.
*
* Part 89 extension (J-4 follow-on): Section 2 also asserts every
* workspace tsconfig is covered by the typecheck-sweep harness. A
* future workspace that ships a tsconfig but forgets to wire it
* into the sweep would silently lose typecheck coverage just like
* ops-cli silently lost test coverage in Part 86.
*
* What this smoke does:
* 1. Walk `apps/*` and `packages/*` looking for `package.json`
* files.
* 2. Cross-reference each found path against the root
* `workspaces` array.
* 3. Walk the same directories looking for `tsconfig.json`
* files.
* 4. Cross-reference each found tsconfig against
* `scripts/typecheck-sweep.sh`'s `project ...` declarations,
* or against the explicit EXCLUDE_FROM_SWEEP set below.
* 5. Fail loudly if any child package or tsconfig is missing
* from the appropriate registry.
*
* What this smoke does NOT do:
* - Validate that the registered workspaces actually exist
* (npm-install would catch that).
* - Validate workspace-internal dependency declarations.
* - Validate package-name uniqueness (npm install would surface
* a duplicate-name conflict at install time).
* - Validate that the tsconfig's `compilerOptions` make sense
* (the tsconfig owners do that themselves).
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..');
interface RootPackage {
workspaces?: string[];
}
/**
* Workspace tsconfigs that are intentionally NOT included in
* `scripts/typecheck-sweep.sh`. Each entry must have a reason
* tied to documented behavior somewhere — typically the
* typecheck-sweep.sh comment block.
*
* If a future maintainer needs to skip a tsconfig, they MUST add
* an entry here AND document the reason. An undocumented
* exclusion can't be added by simply commenting out the assertion
* — the smoke fails until the reason is captured.
*/
const EXCLUDE_FROM_SWEEP: Record<string, string> = {
// apps/web extends `.svelte-kit/tsconfig.json` which is
// generated by `svelte-kit sync` and not present in a fresh
// checkout. The CI `web` job runs `npm run check` instead,
// which does the correct svelte-aware typecheck via
// svelte-check. Including web in the typecheck-sweep would
// either double-fail (when sync hasn't run) or false-fail
// (with svelte-specific syntax that plain tsc rejects).
// Reference: typecheck-sweep.sh has a comment block at the
// frontend project marker explaining the same reasoning.
'apps/web/tsconfig.json':
'extends generated .svelte-kit/tsconfig.json; covered by `npm run check` (svelte-check) instead'
};
let failures = 0;
function fail(msg: string): void {
console.error(`${msg}`);
failures++;
}
function pass(msg: string): void {
console.log(`${msg}`);
}
function findChildPackages(dir: string): string[] {
const found: string[] = [];
const fullDir = path.join(REPO_ROOT, dir);
if (!existsSync(fullDir)) return found;
for (const entry of readdirSync(fullDir)) {
const full = path.join(fullDir, entry);
if (!statSync(full).isDirectory()) continue;
const pkgPath = path.join(full, 'package.json');
if (existsSync(pkgPath)) {
// Use forward slashes in the key for cross-platform stability
// — npm's workspaces field uses POSIX-style paths.
found.push(`${dir}/${entry}`);
}
}
return found.sort();
}
function findChildTsconfigs(dir: string): string[] {
const found: string[] = [];
const fullDir = path.join(REPO_ROOT, dir);
if (!existsSync(fullDir)) return found;
for (const entry of readdirSync(fullDir)) {
const full = path.join(fullDir, entry);
if (!statSync(full).isDirectory()) continue;
const tsconfigPath = path.join(full, 'tsconfig.json');
if (existsSync(tsconfigPath)) {
found.push(`${dir}/${entry}/tsconfig.json`);
}
}
return found.sort();
}
const rootPkgPath = path.join(REPO_ROOT, 'package.json');
const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')) as RootPackage;
const declared = new Set(rootPkg.workspaces ?? []);
console.log('=== workspace-membership sentinel ===');
console.log(`Root workspaces: ${[...declared].sort().join(', ')}`);
console.log();
// ====== Section 1: package.json membership ======
console.log('--- Section 1: package.json → root workspaces array ---');
const childPackages = [
...findChildPackages('apps'),
...findChildPackages('packages')
];
if (childPackages.length === 0) {
fail('found no child package.json files — the smoke harness is broken');
}
let scenarios = 0;
for (const child of childPackages) {
scenarios++;
if (declared.has(child)) {
pass(`${child} is in root workspaces`);
} else {
fail(
`${child}/package.json exists but ${child} is NOT in root workspaces — ` +
'add it to package.json #/workspaces or document the omission'
);
}
}
// ====== Section 2: tsconfig coverage in typecheck-sweep.sh ======
console.log();
console.log('--- Section 2: workspace tsconfig.json → typecheck-sweep.sh ---');
const sweepPath = path.join(REPO_ROOT, 'scripts', 'typecheck-sweep.sh');
if (!existsSync(sweepPath)) {
fail(`typecheck-sweep.sh not found at ${sweepPath} — harness is broken`);
} else {
const sweepText = readFileSync(sweepPath, 'utf8');
const childTsconfigs = [
...findChildTsconfigs('apps'),
...findChildTsconfigs('packages')
];
if (childTsconfigs.length === 0) {
fail('found no child tsconfig.json files — the smoke harness is broken');
}
for (const tsconfig of childTsconfigs) {
scenarios++;
// Look for the path as an argument to `project ...` in the
// sweep script, with a word-boundary check so a stale path
// like "apps/ops-cli/tsconfig.json.disabled" won't substring-
// match "apps/ops-cli/tsconfig.json". The sweep declares
// each path with whitespace after (filter argument or end of
// line), so we anchor against that.
const referencePattern = new RegExp(
`${tsconfig.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`,
'm'
);
const referenced = referencePattern.test(sweepText);
const excluded = Object.prototype.hasOwnProperty.call(EXCLUDE_FROM_SWEEP, tsconfig);
if (referenced) {
pass(`${tsconfig} is referenced by typecheck-sweep.sh`);
} else if (excluded) {
pass(`${tsconfig} is documented exclusion (${EXCLUDE_FROM_SWEEP[tsconfig]})`);
} else {
fail(
`${tsconfig} exists but is NEITHER referenced by typecheck-sweep.sh ` +
`NOR in the EXCLUDE_FROM_SWEEP set — add a project() call in ` +
`scripts/typecheck-sweep.sh, or add an entry to EXCLUDE_FROM_SWEEP ` +
`in this smoke with a documented reason.`
);
}
}
}
console.log();
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}