222 lines
8 KiB
TypeScript
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);
|
|
}
|