diff --git a/examples/embed-objectql/src/index.ts b/examples/embed-objectql/src/index.ts index a63baeaccd..4c448f98f1 100644 --- a/examples/embed-objectql/src/index.ts +++ b/examples/embed-objectql/src/index.ts @@ -13,6 +13,9 @@ // shape you would ship in a `*.object.ts` to a full ObjectStack backend. One // object model, two hosts; only the installed capability set differs. +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ObjectQL } from '@objectstack/objectql/core'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { ObjectSchema, Field, type ServiceObject } from '@objectstack/spec/data'; @@ -56,8 +59,41 @@ export async function runEmbeddedEngine(): Promise { }) as Promise; } -// Allow `node`/`tsx`-style direct execution to print the result. -if (import.meta.url === `file://${process.argv[1]}`) { +// ─── entry guard ─────────────────────────────────────────────────────── +// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves +// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and +// the template also skips the percent-encoding `pathToFileURL` applies — so that +// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path +// containing a character that needs encoding (a `#` in a parent directory name is +// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings. +// +// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and +// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because +// neither home is legally reachable from this file — the PR for #10269 carries the +// boundary measurement. ⚠️ Two predicates answering this question differently IS the +// defect this closes; change one, change all of them. +function isProcessEntry(): boolean { + const entryArg = process.argv[1]; + if (!entryArg) return false; // `node --eval` / the REPL + const self = resolve(fileURLToPath(import.meta.url)); + const entry = resolve(entryArg); + // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution. + const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')]; + if (candidates.includes(self)) return true; + const realSelf = realOrSelf(self); + return candidates.some((candidate) => realOrSelf(candidate) === realSelf); +} + +/** `realpathSync`, degrading to the input for a path that cannot be read. */ +function realOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +if (isProcessEntry()) { runEmbeddedEngine() .then((rows) => { // eslint-disable-next-line no-console diff --git a/packages/cli/src/utils/invocation.ts b/packages/cli/src/utils/invocation.ts index 5d2c8ff923..2ee4d10281 100644 --- a/packages/cli/src/utils/invocation.ts +++ b/packages/cli/src/utils/invocation.ts @@ -30,8 +30,11 @@ * budget is one small module and no side effects. Pulling `./format.js` for * {@link CLI_NAME} would drag chalk, zod and `@objectstack/spec` into a shim * whose whole job is to print one line and get out of the way, so the prefix is - * spelled locally and `invocation.cli-name-parity.test.ts` fails if the two - * spellings ever disagree. + * spelled locally and the parity case in `invocation.test.ts` (`INVOCATION_PREFIX` + * vs `CLI_NAME`) fails if the two spellings ever disagree. ⚠️ That case lives in + * `invocation.test.ts`, NOT in an `invocation.cli-name-parity.test.ts` — this + * header named the latter until #10269 and no such file is in the tree, so a + * reader grepping for it finds nothing and could read the parity as unguarded. */ import { realpathSync } from 'node:fs'; @@ -60,14 +63,16 @@ function realOrSelf(path: string): string { * at — rather than a module someone imported? * * ⚠️ The obvious spelling of this predicate is the bug it guards against. - * #10086 measured the `invokedDirectly` guard across `scripts/` in ~8 spellings, - * all of them some form of `resolve(argv[1]) === fileURLToPath(import.meta.url)`, - * and EVERY one of them answers **false** when the script is reached through a - * symlink — because node resolves symlinks for the module graph but leaves - * `process.argv[1]` exactly as the caller typed it. A guard used the usual way - * ("only run when invoked directly") then makes its script silently inert: exit - * 0, no output. That is precisely the defect this module exists to remove, so - * reproducing it here would have been the same bug wearing the fix's clothes. + * #10086 measured the `invokedDirectly` guard across `scripts/` in ELEVEN distinct + * spellings over 33 files — the measurement, not the "~8" estimate this header + * carried until #10269 — and NINE of the eleven were wrong. The dominant family is + * some form of `resolve(argv[1]) === fileURLToPath(import.meta.url)`, and every + * member of it answers **false** when the script is reached through a symlink — + * because node resolves symlinks for the module graph but leaves `process.argv[1]` + * exactly as the caller typed it. A guard used the usual way ("only run when + * invoked directly") then makes its script silently inert: exit 0, no output. That + * is precisely the defect this module exists to remove, so reproducing it here + * would have been the same bug wearing the fix's clothes. * * Two things follow, and both are load-bearing: * diff --git a/packages/core/examples/kernel-features-example.ts b/packages/core/examples/kernel-features-example.ts index 6088f9c467..bd3e9d65a6 100644 --- a/packages/core/examples/kernel-features-example.ts +++ b/packages/core/examples/kernel-features-example.ts @@ -13,6 +13,9 @@ * - Graceful shutdown */ +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ObjectKernel, PluginMetadata, @@ -300,8 +303,41 @@ async function main() { console.log('\n✅ Shutdown complete!\n'); } -// Run the example -if (import.meta.url === `file://${process.argv[1]}`) { +// ─── entry guard ─────────────────────────────────────────────────────── +// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves +// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and +// the template also skips the percent-encoding `pathToFileURL` applies — so that +// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path +// containing a character that needs encoding (a `#` in a parent directory name is +// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings. +// +// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and +// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because +// neither home is legally reachable from this file — the PR for #10269 carries the +// boundary measurement. ⚠️ Two predicates answering this question differently IS the +// defect this closes; change one, change all of them. +function isProcessEntry(): boolean { + const entryArg = process.argv[1]; + if (!entryArg) return false; // `node --eval` / the REPL + const self = resolve(fileURLToPath(import.meta.url)); + const entry = resolve(entryArg); + // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution. + const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')]; + if (candidates.includes(self)) return true; + const realSelf = realOrSelf(self); + return candidates.some((candidate) => realOrSelf(candidate) === realSelf); +} + +/** `realpathSync`, degrading to the input for a path that cannot be read. */ +function realOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +if (isProcessEntry()) { main().catch(error => { console.error('❌ Error:', error); process.exit(1); diff --git a/packages/core/examples/phase2-integration.ts b/packages/core/examples/phase2-integration.ts index 7fabe972d9..6c5d00bff4 100644 --- a/packages/core/examples/phase2-integration.ts +++ b/packages/core/examples/phase2-integration.ts @@ -7,6 +7,9 @@ * in a real-world scenario. */ +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ObjectKernel, PluginHealthMonitor, @@ -350,8 +353,40 @@ async function example() { }); } -// Run example if this file is executed directly (ES Module compatible) -// Note: In ES modules, use import.meta.url instead of require.main -if (import.meta.url === `file://${process.argv[1]}`) { +// ─── entry guard ─────────────────────────────────────────────────────── +// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves +// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and +// the template also skips the percent-encoding `pathToFileURL` applies — so that +// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path +// containing a character that needs encoding (a `#` in a parent directory name is +// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings. +// +// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and +// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because +// neither home is legally reachable from this file — the PR for #10269 carries the +// boundary measurement. ⚠️ Two predicates answering this question differently IS the +// defect this closes; change one, change all of them. +function isProcessEntry(): boolean { + const entryArg = process.argv[1]; + if (!entryArg) return false; // `node --eval` / the REPL + const self = resolve(fileURLToPath(import.meta.url)); + const entry = resolve(entryArg); + // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution. + const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')]; + if (candidates.includes(self)) return true; + const realSelf = realOrSelf(self); + return candidates.some((candidate) => realOrSelf(candidate) === realSelf); +} + +/** `realpathSync`, degrading to the input for a path that cannot be read. */ +function realOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +if (isProcessEntry()) { example().catch(console.error); } diff --git a/packages/objectql/scripts/dry-run-hash-compat.ts b/packages/objectql/scripts/dry-run-hash-compat.ts index 4702cd0301..3819ef6e4e 100644 --- a/packages/objectql/scripts/dry-run-hash-compat.ts +++ b/packages/objectql/scripts/dry-run-hash-compat.ts @@ -28,6 +28,8 @@ * the probe against synthetic fixtures covering legacy edge cases. */ +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import { hashSpec } from '@objectstack/metadata-core'; export interface LegacyMetadataRow { @@ -257,8 +259,57 @@ export function formatReport(report: DryRunReport): string { return lines.join('\n'); } -// CLI entrypoint — only runs when invoked directly. -if (typeof process !== 'undefined' && process.argv[1] && /dry-run-hash-compat\.ts$/.test(process.argv[1])) { +// ─── entry guard ─────────────────────────────────────────────────────── +// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves +// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and +// the template also skips the percent-encoding `pathToFileURL` applies — so that +// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path +// containing a character that needs encoding (a `#` in a parent directory name is +// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings. +// +// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and +// `scripts/invoked-as.mjs` (`invokedAs`) — both legs identical: realpath for the +// symlink, directory resolution for `node `. Spelled out rather than imported +// because neither home is legally reachable from this file — the PR for #10269 +// carries the boundary measurement. ⚠️ Two predicates answering this question +// differently IS the defect this closes; change one, change all of them. +// +// ⚠️ ONE spelling DIVERGES from those two, and the divergence is FORCED — do not +// "restore consistency" here: the self-path seed is `__filename`, NOT +// `fileURLToPath(import.meta.url)`. `packages/objectql/package.json` declares no +// `"type"`, so under the repo-wide `module: NodeNext` every file in this package +// compiles as COMMONJS, and `import.meta` in a CommonJS-format file is a hard +// compile error (TS1470). This file IS inside a tsc program despite the package's +// own `include` naming only `src/**/*`: `src/dry-run-hash-compat.test.ts` imports +// it, and the TEST_DEBT re-measure in `scripts/check-type-check-coverage.mjs` +// type-checks the tests — so the ESM seed costs a ratchet failure on a ledger that +// may only shrink. The PREDICATE is untouched by this: `invokedAs(entryArg, +// selfPath)` is the shared core and it takes a PATH, `isEntrypoint(import.meta.url)` +// is merely the ESM way to seed it, and `__filename` is the CommonJS way — node's +// CJS loader hands it an absolute path that is ALREADY symlink-resolved and +// percent-decoded, which is exactly the property the guard rests on. +function isProcessEntry(): boolean { + const entryArg = process.argv[1]; + if (!entryArg) return false; // `node --eval` / the REPL + const self = resolve(__filename); + const entry = resolve(entryArg); + // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution. + const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')]; + if (candidates.includes(self)) return true; + const realSelf = realOrSelf(self); + return candidates.some((candidate) => realOrSelf(candidate) === realSelf); +} + +/** `realpathSync`, degrading to the input for a path that cannot be read. */ +function realOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +if (isProcessEntry()) { const path = process.argv[2]; if (!path) { console.error('Usage: pnpm tsx packages/objectql/scripts/dry-run-hash-compat.ts ');