diff --git a/package.json b/package.json index 9b78044814..ff0822166c 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations", "scripts": { "build": "turbo run build --filter=!@objectstack/docs", - "dev": "pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", - "dev:showcase": "pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", - "dev:crm": "pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev", - "dev:todo": "pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev", + "dev": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", + "dev:showcase": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", + "dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev", + "dev:todo": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev", "spec:rebuild": "turbo run build --filter=...@objectstack/spec", "test": "turbo run test", "test:e2e": "turbo run test:e2e", @@ -49,6 +49,7 @@ "check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs", "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", "check:console-sha": "node scripts/check-console-sha.mjs", + "check:dev-prereqs": "node scripts/check-dev-prereqs.mjs --self-test && node scripts/check-dev-prereqs.mjs", "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", "check:objectui-pin-fresh": "node scripts/check-objectui-pin-fresh.mjs --self-test && node scripts/check-objectui-pin-fresh.mjs", "check:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", diff --git a/scripts/check-dev-prereqs.mjs b/scripts/check-dev-prereqs.mjs new file mode 100644 index 0000000000..f93526e84a --- /dev/null +++ b/scripts/check-dev-prereqs.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check:dev-prereqs — confirm the workspace is BUILT before `pnpm dev` boots it. + * + * ONE precondition, ONE fix. That framing is the whole point of this gate: an + * unbuilt workspace is a single unmet precondition, and booting into it reports + * that one fact as anything but (#5726). + * + * What #5726 recorded, and what is left of it TODAY — measured on this branch, + * because a gate that describes a symptom nobody sees any more is the same + * misdirection it is trying to prevent: + * + * - GONE. 12 MODULE_NOT_FOUND segments for commands nobody ran (oclif's + * findCommand import()s every command module on every CLI invocation, so one + * broken import chain warned for all 6 commands sharing it, twice, because + * `dev` forks a child). PR #5789 made that import lazy; removing a driver's + * dist and booting now prints ZERO such segments (verified, not assumed). + * - STILL LIVE. The only actionable line suggests two fixes that are both + * WRONG for this cause: "Fix the datasource configuration, or set + * OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway" — the config is fine, + * and the flag merely boots a half-built workspace that serves errors. + * A separate services-lane issue off #5726 teaches that message to + * recognise the unbuilt cause; this gate stops `dev` before it is reached. + * - STILL LIVE. Hand-building the failing package prints 20+ TS errors that + * read exactly like real contract drift and are pure artefact of a stale + * `packages/spec/dist` (#5726 keeps that transcript as a vaccine). + * + * A 30-second build was packaged as an investigation. `dev` is the first local + * entry point, so the cheapest place to answer it is here, before boot. + * + * WHAT IT CHECKS (criterion: declared = enforced) + * Every workspace package whose resolved entry point — `exports["."]`, else + * `main` — points INTO `dist/` must have that file on disk. A package that + * declares `dist/index.js` and has no `dist/index.js` is unbuilt by its own + * declaration, and `pnpm build` is exactly the fix. No hand-maintained list + * of "important" packages: membership comes from pnpm-workspace.yaml, so a + * package added tomorrow is covered tomorrow (same reason check:i18n derives + * its coverage from a walk instead of a manifest). + * + * The criterion is calibrated on #5726's real blast radius: `packages/cli`'s + * dist (the oclif command table the `objectstack` bin loads), the driver dists + * under `packages/drivers/*` (the actual missing artifact there), and + * `packages/spec/dist` (the fake-drift amplifier). All three declare dist + * entry points, so all three are covered by the one rule. + * + * Reading membership out of pnpm-workspace.yaml rather than hardcoding those + * paths is load-bearing, not tidiness: `packages/drivers/*` is a workspace glob + * whose contents differ across branches, so a hand-written driver path would + * have silently stopped covering the one package #5726 was actually about. + * + * WHAT IT DELIBERATELY DOES NOT CHECK + * - Staleness. Existence only. Comparing src/ mtimes against dist/ would fire + * on every fresh `git worktree add` (checkout rewrites source mtimes), i.e. + * a false red for everyone, which is how gates get ignored. A dist that + * exists but is stale is still #5726's other half; AGENTS.md §9 is the + * standing remedy (`pnpm install --frozen-lockfile && pnpm build` after a + * merge) and is not something a cheap probe can see. + * + * That gap has a consequence this gate must not paper over: a green line here + * is reassurance, and on a STALE dist it is reassurance in the wrong + * direction — the developer has just been told the workspace is fine, so the + * fake drift that follows reads even more like a real bug. Hence the pass + * message says "artifacts present", never "the workspace is fine", and the + * gap is filed as #5864 rather than left implicit here. + * - Type declarations. Only the JS entry is probed, never `.d.ts`, so a fast + * `OS_SKIP_DTS=1` build stays green — dev boot needs JS, not declarations. + * - packages/console/dist. It is built by scripts/build-console.sh, NOT by + * `turbo run build`, so demanding it here would print a red whose fix + * (`pnpm build`) does not work. It is excluded structurally rather than by + * name: @objectstack/console declares no entry point under dist/ at all, + * and its freshness already has a gate on this same dev path + * (check:console-sha, whose fix line is `pnpm objectui:build`). + * Same structural exclusion covers @objectstack/docs (no entry point, and + * `pnpm build` filters it out) and the examples (their entry is a .ts + * source file, not a build artifact). + * + * CI IS UNAFFECTED. This is wired into the root `dev` / `dev:*` scripts only — + * never into a workflow. CI builds before it runs anything that could trip + * this, so the condition cannot occur there; #5726 and #5217 are both + * local/worktree-only shapes. + * + * No env escape hatch, deliberately (check:console-sha has none either). To + * boot a deliberately half-built workspace, call the underlying command + * directly: `pnpm --filter @objectstack/example-showcase dev`. + * + * Usage: + * node scripts/check-dev-prereqs.mjs # gate the workspace + * node scripts/check-dev-prereqs.mjs --self-test # prove it can go both ways + * pnpm check:dev-prereqs # self-test, then gate + * + * WHY THE `dev` CHAIN CALLS THIS WITH `node` AND NOT `pnpm check:dev-prereqs` + * A pnpm script hop costs ~0.7s before any of our code runs (measured on this + * repo: `pnpm check:console-sha` 0.74s total for ~0.04s of work). A guard whose + * entire value is being cheap should not spend that on the hot path, so the + * `dev` / `dev:*` chains invoke this file directly (~0.06s, no self-test) while + * `pnpm check:dev-prereqs` keeps the conventional self-test-then-run shape for + * humans and for any future non-dev caller. The existing `pnpm check:console-sha` + * link keeps its form — it is not this change's business. + * + * WHY IT RUNS BEFORE check:console-sha, NOT AFTER + * In an unbuilt worktree check:console-sha exits 0 with + * "ℹ No console dist … Build it with: pnpm objectui:build" — correct for its own + * subject, and a *competing wrong fix* for this cause: `objectui:build` will not + * make the workspace bootable. Running this gate first means the developer reads + * one precondition with one fix and stops, which is the entire lesson of #5726. + * + * Exit codes: + * 0 built (or nothing declares a dist entry point — nothing to verify) + * 1 not built; or the workspace layout could not be read (a gate that + * cannot enumerate members must fail loudly, not pass vacuously — #4690) + */ +import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** Thrown for conditions that must fail the gate rather than shrink its coverage. */ +class CoverageError extends Error {} + +/** + * Workspace member directories, from pnpm-workspace.yaml — the workspace's own + * declaration of what it contains. Only `/*` and literal paths are + * understood; anything else throws instead of silently covering less. + */ +function workspaceDirs(root) { + const file = path.join(root, 'pnpm-workspace.yaml'); + if (!existsSync(file)) throw new CoverageError(`${rel(root, file)} is missing — cannot enumerate workspace packages.`); + + const lines = readFileSync(file, 'utf-8').split('\n'); + const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); + if (start === -1) throw new CoverageError(`pnpm-workspace.yaml has no 'packages:' list — cannot enumerate workspace packages.`); + + const patterns = []; + for (const line of lines.slice(start + 1)) { + const item = /^\s+-\s+(.+?)\s*$/.exec(line); + if (!item) { + if (/^\S/.test(line)) break; // next top-level key ends the list + continue; // blank line or comment inside the list + } + patterns.push(item[1].replace(/^['"]|['"]$/g, '')); + } + + const dirs = []; + for (const pattern of patterns) { + if (pattern.startsWith('!')) continue; // exclusion: nothing to enumerate + if (pattern.includes('**') || pattern.slice(0, -2).includes('*')) { + throw new CoverageError( + `pnpm-workspace.yaml pattern '${pattern}' is not a shape this gate can expand.\n` + + ` Teach scripts/check-dev-prereqs.mjs the new pattern shape — silently covering fewer\n` + + ` packages would make this gate pass vacuously.`, + ); + } + if (!pattern.endsWith('/*')) { + dirs.push(path.join(root, pattern)); + continue; + } + const parent = path.join(root, pattern.slice(0, -2)); + if (!existsSync(parent)) continue; + for (const entry of readdirSync(parent, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== 'node_modules') dirs.push(path.join(parent, entry.name)); + } + } + return dirs; +} + +/** The entry point Node resolves for `import ''`: exports["."], else main. */ +function declaredEntry(pkg) { + const dot = pkg.exports && typeof pkg.exports === 'object' ? pkg.exports['.'] : pkg.exports; + const candidates = []; + if (typeof dot === 'string') candidates.push(dot); + else if (dot && typeof dot === 'object') candidates.push(dot.import, dot.default, dot.require); + candidates.push(pkg.main); + for (const c of candidates) { + if (typeof c === 'string' && c) return c; + } + return ''; +} + +const isBuildArtifact = (entry) => /(^|\/)dist\//.test(entry.replace(/^\.\//, '')); +const rel = (root, p) => path.relative(root, p) || '.'; + +/** + * The verdict, as data: which declared build artifacts are absent, and which + * single command fixes it. Split from the printing so --self-test can drive it. + */ +function inspect(root) { + const missing = []; + let checked = 0; + + for (const dir of workspaceDirs(root)) { + const manifest = path.join(dir, 'package.json'); + if (!existsSync(manifest)) continue; + let pkg; + try { + pkg = JSON.parse(readFileSync(manifest, 'utf-8')); + } catch { + continue; // unparseable manifest is another gate's business + } + if (!pkg.name) continue; + + const entry = declaredEntry(pkg); + if (!entry || !isBuildArtifact(entry)) continue; // not built by `pnpm build` — see header + + checked++; + const target = path.join(dir, entry.replace(/^\.\//, '')); + if (!existsSync(target)) missing.push({ name: pkg.name, artifact: rel(root, target) }); + } + + // A workspace with no dependencies installed cannot run `pnpm build` at all + // (turbo is not there), so the one-line fix has to include the install. + const installed = existsSync(path.join(root, 'node_modules')); + return { checked, missing, fix: installed ? 'pnpm build' : 'pnpm install && pnpm build' }; +} + +function report(verdict) { + const { checked, missing, fix } = verdict; + if (missing.length === 0) { + console.log(`✓ ${checked} package build artifacts present (existence, not freshness).`); + return 0; + } + + const shown = missing.slice(0, 3); + const width = Math.max(...shown.map((m) => m.name.length)); + const list = shown.map((m) => ` ${m.name.padEnd(width)} ${m.artifact}`).join('\n'); + const more = missing.length > shown.length ? `\n … and ${missing.length - shown.length} more` : ''; + + console.error( + `\n✗ The workspace is not built — 1 unmet precondition, not a list of problems.\n\n` + + ` ${missing.length} of ${checked} workspace packages declare an entry point under dist/ that is not on disk:\n\n` + + `${list}${more}\n\n` + + ` Booting anyway never names this. Where that led once it was chased\n` + + ` (objectstack-ai/objectstack#5726): a 'datasource: connect failed' whose two\n` + + ` suggested fixes are both wrong for this cause — neither editing the datasource\n` + + ` config nor OS_ALLOW_DRIVER_CONNECT_FAILURE=1 builds a missing artifact — and then\n` + + ` 20+ TS errors from hand-building the package, reading exactly like real contract\n` + + ` drift while being nothing but a stale dist.\n\n` + + ` Fix:\n\n` + + ` ${fix}\n`, + ); + return 1; +} + +/** + * --self-test — a gate only ever observed green is indistinguishable from a gate + * that matches nothing (#4690). These fixtures drive `inspect` to both verdicts + * and pin the exclusions that keep it from printing a red whose fix is wrong. + */ +function selfTest() { + const failures = []; + const expect = (label, actual, wanted) => { + if (actual !== wanted) failures.push(`${label}: expected ${JSON.stringify(wanted)}, got ${JSON.stringify(actual)}`); + }; + + /** Run `fn` with console output captured, so the fixtures stay quiet. */ + const capture = (fn) => { + const chunks = []; + const sink = (...args) => chunks.push(args.join(' ')); + const [log, error] = [console.log, console.error]; + console.log = sink; + console.error = sink; + try { + return { code: fn(), text: chunks.join('\n') }; + } finally { + console.log = log; + console.error = error; + } + }; + + const tmp = mkdtempSync(path.join(tmpdir(), 'check-dev-prereqs-')); + const write = (rootDir, file, body) => { + mkdirSync(path.join(rootDir, path.dirname(file)), { recursive: true }); + writeFileSync(path.join(rootDir, file), body); + }; + const fixture = (name, { members = ['packages/*'], installed = false } = {}) => { + const rootDir = path.join(tmp, name); + mkdirSync(rootDir, { recursive: true }); + writeFileSync(path.join(rootDir, 'pnpm-workspace.yaml'), `packages:\n${members.map((m) => ` - ${m}\n`).join('')}\nonlyBuiltDependencies:\n - esbuild\n`); + if (installed) mkdirSync(path.join(rootDir, 'node_modules'), { recursive: true }); + return rootDir; + }; + + try { + // 1. Built workspace → green, and the count reflects what was inspected. + const built = fixture('built', { installed: true }); + write(built, 'packages/a/package.json', JSON.stringify({ name: '@f/a', exports: { '.': { types: './dist/index.d.mts', default: './dist/index.mjs' } } })); + write(built, 'packages/a/dist/index.mjs', 'export {};'); + write(built, 'packages/b/package.json', JSON.stringify({ name: '@f/b', main: 'dist/index.js' })); + write(built, 'packages/b/dist/index.js', 'module.exports = {};'); + let v = inspect(built); + expect('built/missing', v.missing.length, 0); + expect('built/checked', v.checked, 2); + expect('built/fix', v.fix, 'pnpm build'); + + // 2. Unbuilt package → red, named, with the plain build fix. + const unbuilt = fixture('unbuilt', { installed: true }); + write(unbuilt, 'packages/a/package.json', JSON.stringify({ name: '@f/a', exports: { '.': './dist/index.mjs' } })); + write(unbuilt, 'packages/b/package.json', JSON.stringify({ name: '@f/b', main: 'dist/index.js' })); + write(unbuilt, 'packages/b/dist/index.js', 'module.exports = {};'); + v = inspect(unbuilt); + expect('unbuilt/missing', v.missing.length, 1); + expect('unbuilt/name', v.missing[0]?.name, '@f/a'); + expect('unbuilt/artifact', v.missing[0]?.artifact, path.join('packages', 'a', 'dist', 'index.mjs')); + expect('unbuilt/fix', v.fix, 'pnpm build'); + + // The reported shape is the contract, not just the exit code: one verdict + // line, the named artifact, and exactly one command to run. + const red = capture(() => report(v)); + expect('unbuilt/exit-code', red.code, 1); + expect('unbuilt/verdict-lines', red.text.split('\n').filter((l) => l.startsWith('✗')).length, 1); + expect('unbuilt/names-artifact', red.text.includes(path.join('packages', 'a', 'dist', 'index.mjs')), true); + expect('unbuilt/one-fix', (red.text.match(/pnpm build/g) || []).length, 1); + const green = capture(() => report(inspect(built))); + expect('built/exit-code', green.code, 0); + expect('built/one-line', green.text.trim().split('\n').length, 1); + + // 3. Dependencies absent → the one-line fix has to install first. + const fresh = fixture('fresh'); + write(fresh, 'packages/a/package.json', JSON.stringify({ name: '@f/a', main: 'dist/index.js' })); + expect('fresh/fix', inspect(fresh).fix, 'pnpm install && pnpm build'); + + // 4. Exclusions: a package is only judged on an entry point under dist/. + // Pins the three shapes that must NEVER produce a red here, because + // `pnpm build` is not their fix: @objectstack/console (dist built by + // build-console.sh; declares no entry but package.json), @objectstack/docs + // (no entry point, filtered out of `pnpm build`), and the examples + // (entry is a .ts source file). See header. + const excluded = fixture('excluded', { installed: true }); + write(excluded, 'packages/console/package.json', JSON.stringify({ name: '@f/console', exports: { './package.json': './package.json' }, files: ['dist'] })); + write(excluded, 'packages/docs/package.json', JSON.stringify({ name: '@f/docs', scripts: { build: 'next build' } })); + write(excluded, 'packages/example/package.json', JSON.stringify({ name: '@f/example', main: './objectstack.config.ts', exports: { '.': './objectstack.config.ts' } })); + v = inspect(excluded); + expect('excluded/checked', v.checked, 0); + expect('excluded/missing', v.missing.length, 0); + + // 5. Nested and literal member patterns both expand. + const nested = fixture('nested', { members: ['packages/plugins/*', 'packages/spec'], installed: true }); + write(nested, 'packages/plugins/driver-sql/package.json', JSON.stringify({ name: '@f/driver-sql', main: 'dist/index.js' })); + write(nested, 'packages/spec/package.json', JSON.stringify({ name: '@f/spec', main: 'dist/index.js' })); + v = inspect(nested); + expect('nested/checked', v.checked, 2); + expect('nested/missing', v.missing.length, 2); + + // 6. A member pattern this gate cannot expand must fail loudly, never + // silently cover fewer packages. + const opaque = fixture('opaque', { members: ['packages/**'], installed: true }); + let threw = ''; + try { + inspect(opaque); + } catch (err) { + threw = err instanceof CoverageError ? 'CoverageError' : 'other'; + } + expect('opaque/throws', threw, 'CoverageError'); + + // 7. No pnpm-workspace.yaml at all → same loud failure. + const rootless = path.join(tmp, 'rootless'); + mkdirSync(rootless, { recursive: true }); + threw = ''; + try { + inspect(rootless); + } catch (err) { + threw = err instanceof CoverageError ? 'CoverageError' : 'other'; + } + expect('rootless/throws', threw, 'CoverageError'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + if (failures.length > 0) { + console.error(`\n✗ check:dev-prereqs --self-test — ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` ${f}`); + console.error(''); + return 1; + } + console.log('✓ check:dev-prereqs --self-test — both verdicts reachable, exclusions pinned (7 cases).'); + return 0; +} + +if (process.argv.includes('--self-test')) { + process.exit(selfTest()); +} + +try { + process.exit(report(inspect(ROOT))); +} catch (err) { + if (err instanceof CoverageError) { + console.error(`\n✗ check:dev-prereqs cannot enumerate the workspace, so it cannot vouch for the build.\n\n ${err.message}\n`); + process.exit(1); + } + throw err; +}