From 2128cad236b63a5c75e2c729f5379ddbfffbb2de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 10:12:16 +0000 Subject: [PATCH 1/3] fix(devx): symlink-blind entry guards at the four sites outside scripts/ The three `import.meta.url === `file://${process.argv[1]}`` sites and the one regex-basename site that #10086's `check:entry-guard` deliberately does not scan. Both spellings are on that card's measured-broken list; both are replaced by the two-leg predicate (`resolve` fast path + `realpath` for symlinked checkouts) that `packages/cli/src/utils/invocation.ts` and `scripts/invoked-as.mjs` already carry. Also corrects `invocation.ts`'s header count: the #10086 census was ELEVEN spellings across 33 files, not the "~8" estimate from that card's body. Part of #10269 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- examples/embed-objectql/src/index.ts | 40 +++++++++++++++++- packages/cli/src/utils/invocation.ts | 18 ++++---- .../core/examples/kernel-features-example.ts | 40 +++++++++++++++++- packages/core/examples/phase2-integration.ts | 41 +++++++++++++++++-- .../objectql/scripts/dry-run-hash-compat.ts | 40 +++++++++++++++++- 5 files changed, 162 insertions(+), 17 deletions(-) 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..9fb438dbe8 100644 --- a/packages/cli/src/utils/invocation.ts +++ b/packages/cli/src/utils/invocation.ts @@ -60,14 +60,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..f77745b904 100644 --- a/packages/objectql/scripts/dry-run-hash-compat.ts +++ b/packages/objectql/scripts/dry-run-hash-compat.ts @@ -28,6 +28,9 @@ * the probe against synthetic fixtures covering legacy edge cases. */ +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { hashSpec } from '@objectstack/metadata-core'; export interface LegacyMetadataRow { @@ -257,8 +260,41 @@ 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`). 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()) { const path = process.argv[2]; if (!path) { console.error('Usage: pnpm tsx packages/objectql/scripts/dry-run-hash-compat.ts '); From ca022387f28e1cafc0a4afdd7253d32b478d47af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 10:29:40 +0000 Subject: [PATCH 2/3] docs(cli): invocation.ts header cited a parity test file that is not in the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI_NAME parity assertion is real, but it lives in `invocation.test.ts`, not in the `invocation.cli-name-parity.test.ts` the header named — a reader grepping for that filename finds nothing and could read the parity as unguarded. Bounded in-place fix: same file and same class as this card's "~8 spellings" header correction (a stale factual claim in the same module docstring), and the correct form is pinned by the existing case at invocation.test.ts:210. Part of #10269 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- packages/cli/src/utils/invocation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/invocation.ts b/packages/cli/src/utils/invocation.ts index 9fb438dbe8..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'; From 4d77499e1084a43bc254f528fe1def7bf3139f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:04:58 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(objectql):=20seed=20the=20entry=20guard?= =?UTF-8?q?=20from=20`=5F=5Ffilename`=20=E2=80=94=20this=20package=20compi?= =?UTF-8?q?les=20as=20CommonJS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/objectql/package.json` declares no `"type"`, so under the repo-wide `module: NodeNext` every file in the package has CommonJS format, and the `import.meta.url` seed the guard arrived with is a hard TS1470 compile error there. It surfaced as a ratchet failure rather than a build failure because the package's own `tsconfig.json` includes only `src/**/*`: the script is reached through `src/dry-run-hash-compat.test.ts`, which imports it, and only the TEST_DEBT re-measure (which drops the test globs from `exclude`) compiles that test. `@objectstack/objectql` measured 356 against a frozen 355. `__filename` is the CommonJS seed for the same predicate: node's CJS loader hands it an absolute path that is already symlink-resolved and percent-decoded, so both legs the guard rests on — realpath for symlinks, directory resolution for `node ` — are unchanged. The comment now states that the divergence from `packages/cli/src/utils/invocation.ts` is forced by the module format, so the next reader does not "restore consistency" and re-break the ledger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .../objectql/scripts/dry-run-hash-compat.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/objectql/scripts/dry-run-hash-compat.ts b/packages/objectql/scripts/dry-run-hash-compat.ts index f77745b904..3819ef6e4e 100644 --- a/packages/objectql/scripts/dry-run-hash-compat.ts +++ b/packages/objectql/scripts/dry-run-hash-compat.ts @@ -30,7 +30,6 @@ import { realpathSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { hashSpec } from '@objectstack/metadata-core'; export interface LegacyMetadataRow { @@ -269,14 +268,30 @@ export function formatReport(report: DryRunReport): string { // 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. +// `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(fileURLToPath(import.meta.url)); + 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')];