Skip to content

Commit 6130cb7

Browse files
claude[bot]claude
andauthored
fix(devx): the four symlink-blind entry guards outside scripts/, where check:entry-guard cannot see them (#10755)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * docs(cli): invocation.ts header cited a parity test file that is not in the tree 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * fix(objectql): seed the entry guard from `__filename` — this package compiles as CommonJS `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 <dir>` — 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 699132f commit 6130cb7

5 files changed

Lines changed: 182 additions & 19 deletions

File tree

‎examples/embed-objectql/src/index.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
// shape you would ship in a `*.object.ts` to a full ObjectStack backend. One
1414
// object model, two hosts; only the installed capability set differs.
1515

16+
import { realpathSync } from 'node:fs';
17+
import { join, resolve } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
1619
import { ObjectQL } from '@objectstack/objectql/core';
1720
import { InMemoryDriver } from '@objectstack/driver-memory';
1821
import { ObjectSchema, Field, type ServiceObject } from '@objectstack/spec/data';
@@ -56,8 +59,41 @@ export async function runEmbeddedEngine(): Promise<AccountRow[]> {
5659
}) as Promise<AccountRow[]>;
5760
}
5861

59-
// Allow `node`/`tsx`-style direct execution to print the result.
60-
if (import.meta.url === `file://${process.argv[1]}`) {
62+
// ─── entry guard ───────────────────────────────────────────────────────
63+
// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves
64+
// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and
65+
// the template also skips the percent-encoding `pathToFileURL` applies — so that
66+
// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path
67+
// containing a character that needs encoding (a `#` in a parent directory name is
68+
// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings.
69+
//
70+
// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and
71+
// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because
72+
// neither home is legally reachable from this file — the PR for #10269 carries the
73+
// boundary measurement. ⚠️ Two predicates answering this question differently IS the
74+
// defect this closes; change one, change all of them.
75+
function isProcessEntry(): boolean {
76+
const entryArg = process.argv[1];
77+
if (!entryArg) return false; // `node --eval` / the REPL
78+
const self = resolve(fileURLToPath(import.meta.url));
79+
const entry = resolve(entryArg);
80+
// `node <dir>` gives the ENTRY ARGUMENT, and only it, directory resolution.
81+
const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')];
82+
if (candidates.includes(self)) return true;
83+
const realSelf = realOrSelf(self);
84+
return candidates.some((candidate) => realOrSelf(candidate) === realSelf);
85+
}
86+
87+
/** `realpathSync`, degrading to the input for a path that cannot be read. */
88+
function realOrSelf(p: string): string {
89+
try {
90+
return realpathSync(p);
91+
} catch {
92+
return p;
93+
}
94+
}
95+
96+
if (isProcessEntry()) {
6197
runEmbeddedEngine()
6298
.then((rows) => {
6399
// eslint-disable-next-line no-console

‎packages/cli/src/utils/invocation.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@
3030
* budget is one small module and no side effects. Pulling `./format.js` for
3131
* {@link CLI_NAME} would drag chalk, zod and `@objectstack/spec` into a shim
3232
* whose whole job is to print one line and get out of the way, so the prefix is
33-
* spelled locally and `invocation.cli-name-parity.test.ts` fails if the two
34-
* spellings ever disagree.
33+
* spelled locally and the parity case in `invocation.test.ts` (`INVOCATION_PREFIX`
34+
* vs `CLI_NAME`) fails if the two spellings ever disagree. ⚠️ That case lives in
35+
* `invocation.test.ts`, NOT in an `invocation.cli-name-parity.test.ts` — this
36+
* header named the latter until #10269 and no such file is in the tree, so a
37+
* reader grepping for it finds nothing and could read the parity as unguarded.
3538
*/
3639

3740
import { realpathSync } from 'node:fs';
@@ -60,14 +63,16 @@ function realOrSelf(path: string): string {
6063
* at — rather than a module someone imported?
6164
*
6265
* ⚠️ The obvious spelling of this predicate is the bug it guards against.
63-
* #10086 measured the `invokedDirectly` guard across `scripts/` in ~8 spellings,
64-
* all of them some form of `resolve(argv[1]) === fileURLToPath(import.meta.url)`,
65-
* and EVERY one of them answers **false** when the script is reached through a
66-
* symlink — because node resolves symlinks for the module graph but leaves
67-
* `process.argv[1]` exactly as the caller typed it. A guard used the usual way
68-
* ("only run when invoked directly") then makes its script silently inert: exit
69-
* 0, no output. That is precisely the defect this module exists to remove, so
70-
* reproducing it here would have been the same bug wearing the fix's clothes.
66+
* #10086 measured the `invokedDirectly` guard across `scripts/` in ELEVEN distinct
67+
* spellings over 33 files — the measurement, not the "~8" estimate this header
68+
* carried until #10269 — and NINE of the eleven were wrong. The dominant family is
69+
* some form of `resolve(argv[1]) === fileURLToPath(import.meta.url)`, and every
70+
* member of it answers **false** when the script is reached through a symlink —
71+
* because node resolves symlinks for the module graph but leaves `process.argv[1]`
72+
* exactly as the caller typed it. A guard used the usual way ("only run when
73+
* invoked directly") then makes its script silently inert: exit 0, no output. That
74+
* is precisely the defect this module exists to remove, so reproducing it here
75+
* would have been the same bug wearing the fix's clothes.
7176
*
7277
* Two things follow, and both are load-bearing:
7378
*

‎packages/core/examples/kernel-features-example.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
* - Graceful shutdown
1414
*/
1515

16+
import { realpathSync } from 'node:fs';
17+
import { join, resolve } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
1619
import {
1720
ObjectKernel,
1821
PluginMetadata,
@@ -300,8 +303,41 @@ async function main() {
300303
console.log('\n✅ Shutdown complete!\n');
301304
}
302305

303-
// Run the example
304-
if (import.meta.url === `file://${process.argv[1]}`) {
306+
// ─── entry guard ───────────────────────────────────────────────────────
307+
// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves
308+
// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and
309+
// the template also skips the percent-encoding `pathToFileURL` applies — so that
310+
// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path
311+
// containing a character that needs encoding (a `#` in a parent directory name is
312+
// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings.
313+
//
314+
// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and
315+
// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because
316+
// neither home is legally reachable from this file — the PR for #10269 carries the
317+
// boundary measurement. ⚠️ Two predicates answering this question differently IS the
318+
// defect this closes; change one, change all of them.
319+
function isProcessEntry(): boolean {
320+
const entryArg = process.argv[1];
321+
if (!entryArg) return false; // `node --eval` / the REPL
322+
const self = resolve(fileURLToPath(import.meta.url));
323+
const entry = resolve(entryArg);
324+
// `node <dir>` gives the ENTRY ARGUMENT, and only it, directory resolution.
325+
const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')];
326+
if (candidates.includes(self)) return true;
327+
const realSelf = realOrSelf(self);
328+
return candidates.some((candidate) => realOrSelf(candidate) === realSelf);
329+
}
330+
331+
/** `realpathSync`, degrading to the input for a path that cannot be read. */
332+
function realOrSelf(p: string): string {
333+
try {
334+
return realpathSync(p);
335+
} catch {
336+
return p;
337+
}
338+
}
339+
340+
if (isProcessEntry()) {
305341
main().catch(error => {
306342
console.error('❌ Error:', error);
307343
process.exit(1);

‎packages/core/examples/phase2-integration.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
* in a real-world scenario.
88
*/
99

10+
import { realpathSync } from 'node:fs';
11+
import { join, resolve } from 'node:path';
12+
import { fileURLToPath } from 'node:url';
1013
import {
1114
ObjectKernel,
1215
PluginHealthMonitor,
@@ -350,8 +353,40 @@ async function example() {
350353
});
351354
}
352355

353-
// Run example if this file is executed directly (ES Module compatible)
354-
// Note: In ES modules, use import.meta.url instead of require.main
355-
if (import.meta.url === `file://${process.argv[1]}`) {
356+
// ─── entry guard ───────────────────────────────────────────────────────
357+
// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves
358+
// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and
359+
// the template also skips the percent-encoding `pathToFileURL` applies — so that
360+
// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path
361+
// containing a character that needs encoding (a `#` in a parent directory name is
362+
// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings.
363+
//
364+
// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and
365+
// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because
366+
// neither home is legally reachable from this file — the PR for #10269 carries the
367+
// boundary measurement. ⚠️ Two predicates answering this question differently IS the
368+
// defect this closes; change one, change all of them.
369+
function isProcessEntry(): boolean {
370+
const entryArg = process.argv[1];
371+
if (!entryArg) return false; // `node --eval` / the REPL
372+
const self = resolve(fileURLToPath(import.meta.url));
373+
const entry = resolve(entryArg);
374+
// `node <dir>` gives the ENTRY ARGUMENT, and only it, directory resolution.
375+
const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')];
376+
if (candidates.includes(self)) return true;
377+
const realSelf = realOrSelf(self);
378+
return candidates.some((candidate) => realOrSelf(candidate) === realSelf);
379+
}
380+
381+
/** `realpathSync`, degrading to the input for a path that cannot be read. */
382+
function realOrSelf(p: string): string {
383+
try {
384+
return realpathSync(p);
385+
} catch {
386+
return p;
387+
}
388+
}
389+
390+
if (isProcessEntry()) {
356391
example().catch(console.error);
357392
}

‎packages/objectql/scripts/dry-run-hash-compat.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
* the probe against synthetic fixtures covering legacy edge cases.
2929
*/
3030

31+
import { realpathSync } from 'node:fs';
32+
import { join, resolve } from 'node:path';
3133
import { hashSpec } from '@objectstack/metadata-core';
3234

3335
export interface LegacyMetadataRow {
@@ -257,8 +259,57 @@ export function formatReport(report: DryRunReport): string {
257259
return lines.join('\n');
258260
}
259261

260-
// CLI entrypoint — only runs when invoked directly.
261-
if (typeof process !== 'undefined' && process.argv[1] && /dry-run-hash-compat\.ts$/.test(process.argv[1])) {
262+
// ─── entry guard ───────────────────────────────────────────────────────
263+
// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves
264+
// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and
265+
// the template also skips the percent-encoding `pathToFileURL` applies — so that
266+
// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path
267+
// containing a character that needs encoding (a `#` in a parent directory name is
268+
// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings.
269+
//
270+
// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and
271+
// `scripts/invoked-as.mjs` (`invokedAs`) — both legs identical: realpath for the
272+
// symlink, directory resolution for `node <dir>`. Spelled out rather than imported
273+
// because neither home is legally reachable from this file — the PR for #10269
274+
// carries the boundary measurement. ⚠️ Two predicates answering this question
275+
// differently IS the defect this closes; change one, change all of them.
276+
//
277+
// ⚠️ ONE spelling DIVERGES from those two, and the divergence is FORCED — do not
278+
// "restore consistency" here: the self-path seed is `__filename`, NOT
279+
// `fileURLToPath(import.meta.url)`. `packages/objectql/package.json` declares no
280+
// `"type"`, so under the repo-wide `module: NodeNext` every file in this package
281+
// compiles as COMMONJS, and `import.meta` in a CommonJS-format file is a hard
282+
// compile error (TS1470). This file IS inside a tsc program despite the package's
283+
// own `include` naming only `src/**/*`: `src/dry-run-hash-compat.test.ts` imports
284+
// it, and the TEST_DEBT re-measure in `scripts/check-type-check-coverage.mjs`
285+
// type-checks the tests — so the ESM seed costs a ratchet failure on a ledger that
286+
// may only shrink. The PREDICATE is untouched by this: `invokedAs(entryArg,
287+
// selfPath)` is the shared core and it takes a PATH, `isEntrypoint(import.meta.url)`
288+
// is merely the ESM way to seed it, and `__filename` is the CommonJS way — node's
289+
// CJS loader hands it an absolute path that is ALREADY symlink-resolved and
290+
// percent-decoded, which is exactly the property the guard rests on.
291+
function isProcessEntry(): boolean {
292+
const entryArg = process.argv[1];
293+
if (!entryArg) return false; // `node --eval` / the REPL
294+
const self = resolve(__filename);
295+
const entry = resolve(entryArg);
296+
// `node <dir>` gives the ENTRY ARGUMENT, and only it, directory resolution.
297+
const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')];
298+
if (candidates.includes(self)) return true;
299+
const realSelf = realOrSelf(self);
300+
return candidates.some((candidate) => realOrSelf(candidate) === realSelf);
301+
}
302+
303+
/** `realpathSync`, degrading to the input for a path that cannot be read. */
304+
function realOrSelf(p: string): string {
305+
try {
306+
return realpathSync(p);
307+
} catch {
308+
return p;
309+
}
310+
}
311+
312+
if (isProcessEntry()) {
262313
const path = process.argv[2];
263314
if (!path) {
264315
console.error('Usage: pnpm tsx packages/objectql/scripts/dry-run-hash-compat.ts <snapshot.json>');

0 commit comments

Comments
 (0)