You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Four symlink-blind entry guards live outside scripts/, where #10086's class-closing gate cannot see them — three are the file://${argv[1]} template that also breaks on percent-encoding #10269
Measured while implementing #10086 (PR for claude/issue-10086-invoked-directly-entry-guard). Not repaired there: that card's file surface is scripts/**, and the gate it lands (check:entry-guard) deliberately scans scripts/ only. Filed unassigned, severity for triage.
The measurement
#10086 removed every hand-typed invokedDirectly guard from scripts/ — 11 distinct spellings across 33 files — and closed the class with a gate that allows only scripts/invoked-as.mjs to read process.argv[1].
Sweeping the rest of the tree with the same predicate finds four more sites the gate's scope does not reach:
Both shapes are on the measured-broken list from #10086:
The template form (three sites) is spelling S11 in that card's census. It goes inert through a symlink, and it bypasses the encoder pathToFileURL applies, so it also goes inert on any checkout path containing a character needing percent-encoding — measured on a synthetic probe, a # in any parent directory name is enough, with no symlink involved. (A space is not enough: URL normalises that one. So the failure is sporadic in exactly the way that makes it hard to attribute.)
The regex-basename form (dry-run-hash-compat.ts) is the S3/S10 family: it survives a symlink that keeps the basename, but it answers true for any entry script whose path ends in that name, so it can fire on import as well as go inert.
Why it is not just "examples"
packages/objectql/scripts/dry-run-hash-compat.ts is a compatibility checker, not a demo — the failure direction there is the same silent one #10086 documents: reached the wrong way it does nothing, exits 0, and a caller holding the status reads a pass.
The three template-form sites are example/demo entry points, so the severity is genuinely lower — but they are also the files a reader copies from, which is how a spelling that was removed 33 times comes back.
Note on the sibling predicate
packages/cli/src/utils/invocation.ts already exports isProcessEntry, the symlink-correct predicate for package code, and its header cites #10086. So the fix for at least the packages/** sites likely needs no new code — it is isProcessEntry, or the example equivalent. What is missing is anything that makes them use it.
Two directions for whoever takes this, in ascending cost:
point the four sites at an existing correct predicate;
Also worth correcting while someone is in that file: invocation.ts's header says #10086 measured the guard "in ~8 spellings". The census on origin/main at 923c424 is 11 distinct spellings across 33 files; ~8 was the estimate in the card body, not the measurement.
Dedupe
search_issues for "entry guard process.argv[1] symlink inert in packages and examples, isProcessEntry, file:// template literal guard outside scripts" returns 0 results. #10086 covers scripts/** only and its dispatch fenced the surface explicitly.
Unlock scan (triage seat): released back to pm:queue. Two-check release: ① block condition met — #10086 (the class-closing check:entry-guard gate for scripts/) is closed; ② no newer merged PR on this card. Premise re-verified on origin/main: the three file://${argv[1]} template sites are still present (examples/embed-objectql/src/index.ts:60, packages/core/examples/kernel-features-example.ts:304, packages/core/examples/phase2-integration.ts:355).
Dispatch note: direction 1 (point the four sites at isProcessEntry / the example equivalent) is the bounded deliverable. Direction 2 (widening the gate's population beyond scripts/, accepting isProcessEntry as a second legal spelling) is a real gate-design question — the dev may propose it in the same PR or file it, but must not silently widen the gate. The invocation.ts header count correction ("~8 spellings" → the measured 11/33) rides along. Size/model suggestion: S–M, opus.
Blocker cleared: #10086 is closed, and the triage seat's two-check release already re-verified the
three template sites. I have now re-verified all four independently at a later tip.
⚠️Two hazards written into the brief, both of which could make a naive fix wrong:
scripts/invoked-as.mjs may not be importable from these files. Three of the four are .ts under packages/** and examples/**; importing a root scripts/ helper from published
package source would create a package→scripts/ dependency that almost certainly violates
this repo's boundaries. The dev must measure whether that import is legitimate here before
reaching for it, and if it is not, say so rather than forcing it.
Severity is genuinely split, per the card: dry-run-hash-compat.ts is a compatibility checker,
where reached-the-wrong-way means it does nothing, exits 0, and a caller holding the status reads
a pass. The other three are demo entry points — lower severity, but they are the files a reader
copies from, which is how a broken spelling propagates.
{
"issue": 10269,
"status": "done",
"branch": "claude/issue-10269-symlink-blind-entry-guards",
"pr": "https://github.com/objectstack-ai/objectstack/pull/10755",
"premise_still_valid": true,
"summary": "All four sites re-verified at merge base 8f04d9a4ff and repaired; a whole-tree sweep for process.argv[1] / require.main / import.meta.main outside scripts/ confirms the census of four is complete (the other hits are packages/cli/src/index.ts, which already uses isProcessEntry, five binPath reads in cli commands that are not entry guards, and two spec test fixtures carrying argv[1] inside a shell string payload). HAZARD 1 -- the answer is stronger than 'probably not allowed': there is NO package all three consumers can legally reach that could host this predicate, so the remedy is four inline spellings, byte-identical, each carrying the divergence rule. Evidence: (a) @objectstack/cli, the home of isProcessEntry, DEPENDS ON both @objectstack/core and @objectstack/objectql, so importing it from either is a workspace dependency CYCLE; it also declares no `exports` map at all and packs files:[\"dist\"], so src/utils/invocation.ts is unreachable by subpath from anywhere. (b) The INTERSECTION of the dependency sets of objectql, core and example-embed-objectql is exactly one package, @objectstack/spec, whose runtime deps are zod + pg-connection-string with no node: import in any non-test source (ADR-0076 D5 ratifies that shape) -- a node:fs/node:url predicate cannot live there. (c) examples/embed-objectql does not depend on cli, and adding it would pull a 40-dependency CLI into an example whose stated purpose (ADR-0076) is 'no kernel, no plugins'. (d) Root scripts/invoked-as.mjs's own header states the non-import rule; the packages/spec/scripts/build-docs.ts precedent does NOT transfer, because dry-run-hash-compat.ts is imported by packages/objectql/src/dry-run-hash-compat.test.ts, so the edge would put a root-scripts/ module into a package TEST's input graph -- invisible to turbo, which is exactly the class check:cross-package-test-inputs exists for (its self-test pins that escape shape). HAZARD 2 -- check:examples-live-imports is unmoved: 0 invisible (declared), 6 inputs-declared, 70 graph-visible, before AND after, and the reason is structural, not luck (see `tests`). Riders: invocation.ts's '~8 spellings' corrected to the measured 11-across-33; and a bounded in-place fix for a second stale fact in the same docstring -- it cited invocation.cli-name-parity.test.ts, which is not in the tree (the parity case is at invocation.test.ts:210). Changeset: skip-changeset label, because NONE of the four guard files ship (objectql and core both pack files:[\"dist\"] with tsup entries src/index.ts+src/core.ts and src/index.ts+src/logger.ts; embed-objectql is private:true), and the one published file touched, packages/cli/src/utils/invocation.ts, is comment-only -- every changed line in its diff begins with ' *'. Label added additively via POST and READ BACK after the bots ran: ['size/m','skip-changeset'].",
"tests": "Gate union re-run AFTER the final commit, at HEAD ca022387f2 (git rev-parse --short HEAD from that run). `node scripts/pm/dispatch-gates.mjs` with no paths derived 5 changed paths and named 6 families; I ran those 6 plus 6 more I reasoned about (the derivation reports check:examples-live-imports and check:parse-guard as unreachable-by-construction, so it can never name them). Each gate's own verdict line, exit code captured before any pipe: error-code-casing exit=0 'no lowercase error codes in 4354 scanned file(s) (ADR-0112)'; slot-lookup exit=0 'baseline key set verified against 8f04d9a: no files added'; test-source-alias exit=0 '72 packages with tests scanned'; type-source-resolution exit=0 '76 packages with a tsconfig.json scanned'; engine-split-ratio exit=0 'ADR-0076 D7 ratio 97.5%' (first run refused with exit 2 -- shallow clone, not my change; fixed by the remedy it printed, git fetch --shallow-since=2026-05-16); affected-docs exit=0 '308 self-test cases pass'; examples-live-imports exit=0 'examples/** live-import inventory OK -- 0 invisible (declared), 6 inputs-declared, 70 graph-visible'; entry-guard exit=0 '125 scripts/ file(s) -- every entry guard goes through invoked-as.mjs'; parse-guard exit=0 '124 scripts/ file(s) -- every TypeScript parse goes through ts-parse.mjs'; nul-bytes exit=0 '6231 text file(s) ... no raw ASCII control bytes'; cross-package-test-inputs exit=0 '13 package(s) read outside themselves, all declared'; driver-memory-census exit=0 'every declaration is ledgered, every ledger entry is live'. || HAZARD 2 RECONCILIATION: baseline on origin/main 8f04d9a4ff was '0 invisible (declared), 6 inputs-declared, 70 graph-visible'; after ca022387f2 it is identical. Not assumed inert -- the gate inventories packages/** TESTS that reach into examples/**, and this change adds only node: builtin imports to an example's own source, creating no coupling in that direction; the gate's self-test explicitly does not flag node: builtins. || DEFECT REPRODUCED ON THE REAL FILES, not asserted. examples/embed-objectql/src/index.ts copied verbatim into a REAL directory named 'a#b' with the package's node_modules reachable: old template guard on an ordinary path exit=0/750 bytes (control, prints 'Active accounts (2): Acme, Initech'); old guard under 'a#b' exit=0/0 BYTES (inert, NO symlink involved); new guard under 'a#b' exit=0/750 bytes; old guard via a symlink exit=0/0 BYTES; new guard via a symlink exit=0/750 bytes. packages/objectql/scripts/dry-run-hash-compat.ts through a RENAMING symlink on a snapshot the direct run calls INCOMPATIBLE: before exit=0 with 0 bytes of stdout+stderr (a caller holding result.status reads a PASS), after exit=1 with 1044 bytes byte-identical to the direct run. FIRE-ON-IMPORT on the real file: an unrelated entry script whose own path ends in 'dry-run-hash-compat.ts' importing runDryRun -- before, the tool's CLI ran inside the importer's process and killed it at exit=2 with 'Usage: pnpm tsx ...' so the importer's own code never ran; after, exit=0 'IMPORTER ALIVE, rows= 0'. A synthetic probe with the byte-exact guard texts pins the same five directions plus the different-name-symlink case. FAMILY NOTE: the regex-basename form is IMMUNE to percent-encoding (it builds no URL) and the template form is immune to fire-on-import -- my first attempt to probe encoding against dry-run-hash-compat.ts came back exit=1/1044 bytes on an 'a#b' path, which confirms the card's two-family split rather than contradicting it, and I re-ran the encoding leg against a template site. || CLI UNCHANGED: dry-run-hash-compat.ts's three invocations before vs after are byte-for-byte identical with identical exit codes -- compatible snapshot exit=0/522 bytes, incompatible exit=1/1044 bytes, no-argument usage exit=2/81 bytes (diff -q reports IDENTICAL for all three). || SUITES: pnpm --filter @objectstack/objectql test -> 'Test Files 225 passed (225) / Tests 3983 passed (3983)'; pnpm --filter @objectstack/example-embed-objectql test -> 'Test Files 1 passed (1) / Tests 2 passed (2)' and its typecheck clean; pnpm --filter @objectstack/objectql typecheck clean; packages/cli invocation.test.ts -> 'Test Files 1 passed (1) / Tests 17 passed (17)'. || ABLATION (for the filed finding), with both legs stated: no build is involved because tsc reads source, so the rebuild question is moot here and the load-bearing proof is the on-disk one. MUTATION LEG: injected 'const OS_ABLATION_TYPE_ERROR: number = \"definitely not a number\";' into packages/objectql/scripts/dry-run-hash-compat.ts and CONFIRMED IT LANDED by grep -c of the INJECTED text = 1 (not by the editor's exit code, and not by a bare git diff --stat); then pnpm --filter @objectstack/objectql typecheck exited 0 with ZERO 'error TS' lines, and tsc --noEmit --listFiles contains the file 0 times. RESTORE LEG: git checkout -- <file>, confirmed on disk by grep -c of the injected text = 0 and git status --porcelain empty, so no mutated bytes survived into any later measurement. Predicted direction was 'stays green' and that is what happened. || TWO HONEST LIMITS, both closed by CI: (1) packages/cli's FULL suite is not a clean measurement in this worktree -- 66 of 139 files fail with 'Cannot find module .../@objectstack/client/dist/index.mjs', i.e. the cli dependency closure is unbuilt here (I built only objectql's and the embed example's). The cli change is comment-only and the file's own pin passes 17/17. (2) @objectstack/core has NO typecheck script (ledgered DEBT, #4311), and three of the five edited files sit outside every tsconfig; those three are verified by RUNNING them under tsx instead, which is the stronger evidence for an entry guard.",
"open_questions": [
{
"question": "Should check:entry-guard's population widen beyond scripts/ to cover the four sites this PR just fixed? Raised as the PROPOSAL the dispatch asked for, not done here -- the gate's scripts/-only scope is a deliberate #10086 decision and widening it sweeps a much larger population than this card measured. Two facts make it a real design question rather than a constant change: the walk only collects .mjs/.js/.cjs, so three of these four .ts files would still be invisible even after the scope constant changed; and the gate's rule is 'process.argv[1] anywhere but scripts/invoked-as.mjs is a failure', which every correct inline spelling in this PR violates by construction, because no importable predicate exists for them (see the boundary evidence in `summary`).",
"options": [
"A. Leave the gate at scripts/ and accept that these four are pinned by prose + this PR's measurements only. Zero new surface; the honest cost is that a fifth site typed tomorrow outside scripts/ is invisible again, exactly as these four were.",
"B. Widen the population to packages/** and examples/**, extend the walk to .ts, and teach the gate a SECOND legal spelling (packages/cli's isProcessEntry) plus a third for files that can reach neither home. The third clause is the problem: it is 'an inline copy that matches this exact shape', i.e. the gate would have to pin a text template, which is a different kind of rule from 'route through the one predicate'.",
"C. Narrow middle: widen only to the directories that HAVE an importable predicate (packages/** that may legally reach @objectstack/cli), leaving examples/** and the cycle-blocked packages out. Closes part of the class with no template-matching, but the excluded set is precisely the four sites in this card.",
"D. Attack the cause instead: give the predicate a home that packages/**, examples/** and scripts/ can all reach (a new tiny zero-dependency workspace package), then widen the gate to require it everywhere. Only option that actually closes the class, and the only one that adds a published-surface decision."
],
"recommendation": "A for now, and file D as its own card if a fifth site ever appears. Real business need: nobody is reaching these four through a symlink today -- the pull is correctness of a compatibility checker, which this PR already delivers, and the marginal pull for a gate is one hypothetical future site. Long-term soundness: D is the only option that closes the class rather than describing it, but a new workspace package to host a 16-line predicate is exactly the kind of surface ADR-0076-era discipline pushes back on, and B's text-template clause would make the gate assert a SPELLING rather than a ROUTE, which is a weaker invariant than the one #10086 landed. Hard-to-get-wrong for AI-written code: B actively hurts -- a gate that accepts 'an inline copy matching this template' teaches the next author that copying is blessed, which is the drift #10086 spent 33 files removing. Startup scope: A and D are the only coherent ends, and D costs a package for four non-published files. Maintainer's call if they disagree on the fifth-site threshold."
}
],
"out_of_scope_findings": [
"filed as #10756: a package can pass check:type-check-coverage while a whole source directory sits outside tsc -- packages/objectql/scripts/** is in no tsc program (file appears 0 times in tsc --listFiles; an injected `const x: number = \"string\"` leaves typecheck at exit 0), yet objectql is in neither the DEBT nor the EXEMPT ledger. domain:devx + finding, unassigned, no pm:queue."
]
}
{
"issue": 10269,
"status": "done",
"branch": "claude/issue-10269-symlink-blind-entry-guards",
"pr": "https://github.com/objectstack-ai/objectstack/pull/10755",
"premise_still_valid": true,
"summary": "Repair of the existing PR #10755, not a new one: pushed one commit (4d77499e10) onto its head branch, no force-push, PR left non-draft and untouched otherwise. The dispatched diagnosis is confirmed by measurement, exactly as stated. packages/objectql/package.json declares no \"type\", so under the root tsconfig's module: NodeNext every file in that package has CommonJS format, and the entry guard's import.meta.url self-path is a hard TS1470 there; the other four guard sites sit in packages whose package.json says \"type\": \"module\", which is why exactly one package drifted. The fix seeds the same predicate from __filename in that one file and nothing else: node's CJS loader hands __filename an absolute path that is already symlink-resolved and percent-decoded, so both legs the guard rests on (realpath for symlinks, directory resolution when node is pointed at a directory) are unchanged, and the guard is neither weakened nor disabled. The comment at the guard now states that the divergence from packages/cli/src/utils/invocation.ts is FORCED by the package's module format, so the next reader does not restore consistency and re-break the ledger. TEST_DEBT was NOT raised. \"type\": \"module\" was NOT added, and I did not file it as a finding either, because I measured it to be wrong rather than merely wide: objectql's tsup emits format ['esm','cjs'] with the CJS output at dist/index.js, which the exports map serves under the require condition, so declaring the package ESM would make every CJS consumer's require() read CJS output as ESM. It is not a long-term fix without also renaming that output. The #10756 correction is delivered both ways: a correction comment quoting the import at src/dry-run-hash-compat.test.ts:5, and an in-place body correction; the issue stays open and unassigned, and its title was already accurate. PR #10755's body carried the same over-broad claim in its verification section and is corrected there too.",
"tests": "ALL measurements below ran against a TEST-INCLUSIVE program, never `pnpm --filter @objectstack/objectql typecheck` -- that command is exit 0 in BOTH states and proves nothing (it is the blindness #10756 is about; I ran it only to confirm it stays 0, which it does).\n\nREPRODUCTION SHAPE. The re-measure project was rebuilt exactly as remeasureProject (scripts/check-type-check-coverage.mjs:1885) generates it for this package: extends packages/objectql/tsconfig.json, exclude = the package's exclude with the test globs dropped (node_modules, dist), include = the package's own include (src/**/*, unwidened -- all 225 objectql test files are already under src/, so `unreachable` is empty and rootDir is correctly NOT neutralised), plus the reconstructed default typeRoots. Dependency closure built first: `pnpm --workspace-concurrency=2 --filter '@objectstack/objectql^...' build` -> CLOSURE_BUILD_EXIT=0. Command, both legs identical:\n ./node_modules/.bin/tsc --noEmit --pretty false -p TMPDIR/tsconfig.debt-remeasure.json\nExit codes captured before any pipe (TSC_BEFORE_EXIT=2, TSC_AFTER_EXIT=2 -- tsc exits 2 with diagnostics in both, which is why the COUNT is the measurement, not the status).\n\nBEFORE (ca022387f2, the PR head as dispatched): 356 error lines -- `grep -cE 'error TS[0-9]+'` = 356. That is CI's number reproduced exactly, which is what makes the hand-built project trustworthy. The TS1470, verbatim:\n packages/objectql/scripts/dry-run-hash-compat.ts(279,40): error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.\n\nAFTER (4d77499e10): 355 error lines -- exactly the frozen TEST_DEBT['@objectstack/objectql']. TS1470 count on that file 1 -> 0; `grep -n TS1470` in the after run returns only the pre-existing packages/objectql/src/core-boundary.ratchet.test.ts(57,35), which is part of the frozen 355. `diff` of the two runs sorted, complete output:\n 88d87\n < packages/objectql/scripts/dry-run-hash-compat.ts(279,40): error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.\nOne line left; nothing else moved in either direction.\n\nNOT RUN, and declared: `node scripts/check-type-check-coverage.mjs --re-measure` with the full lint.yml closure (turbo build over every package, then 34 sequential tsc runs). The container's shared verify lock was contended all session (one acquisition waited 4m27s behind another agent's full-workspace build), and the cheaper measurement already agrees with CI to the error on the BEFORE leg, which is the fidelity evidence that run would have provided. CI's own debt-ledger job re-runs it on the new head.\n\nRUNTIME, `pnpm tsx` (the documented invocation): direct run with a snapshot -> exit 0, full report; through a RENAMED symlink whose parent directory is named `sym#dir` (symlink AND percent-encoding in one) -> exit 0, byte-identical report; no argument -> exit 2, 'Usage: pnpm tsx ...'; imported by another module -> exit 0, CLI body did not run ('IMPORT-PROBE: module imported, CLI body did NOT run; runDryRun typeof = function'). So the guard still fires and is still inert on import.\n\nABLATION (does that symlink leg discriminate?). No dist involved -- tsx compiles from source at run time, so there is no built artifact to go stale; the leg that matters is the on-disk mutation, and it was confirmed by anchored grep rather than by an editor exit code. Fix committed FIRST (4d77499e10) so restoration was a checkout, never a stash. Mutation: the pre-#10269 predicate put back (`if (isProcessEntry())` count 0, injected `test(process.argv[1])` count 1). Result: the renamed-symlink invocation returned exit=0 and 0 bytes of output -- the silent-inert defect -- while the direct run still printed the report. Restored with `git checkout --` on that path and re-confirmed by grep (isProcessEntry 1, injected regex 0, resolve(__filename) 1) before any further measurement.\n\nTESTS. `pnpm --filter @objectstack/objectql test -- --maxWorkers=2` -> OBJECTQL_TEST_EXIT=0, 'Test Files 225 passed (225) / Tests 3983 passed (3983)' -- includes src/dry-run-hash-compat.test.ts, the importer of the changed file. `pnpm --filter @objectstack/objectql typecheck` -> exit 0 (green before and after; recorded, not relied on).\n\nGATES. `node scripts/pm/dispatch-gates.mjs` with no paths (exit 0) derived the change set from the merge base itself -- 5 committed paths, 6 families. Union re-run AFTER the final commit, at 4d77499e10, on a clean tree; exit codes captured by redirecting to a file before reading it, and each verdict is the gate's own printed line:\n check:error-code-casing EXIT=0 'no lowercase error codes in 4354 scanned file(s) (ADR-0112)'\n check:slot-lookup EXIT=0 'slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new'\n check:test-source-alias EXIT=0 'check-test-source-alias OK -- 72 packages with tests scanned'\n check:type-source-resolution EXIT=0 'check-type-source-resolution OK -- 76 packages with a tsconfig.json scanned'\n check-engine-split-ratio.mjs EXIT=0 'ratio: 97.5%'\n check-affected-docs.mjs EXIT=0 'affected-docs self-test: 308 cases pass'\n Plus two I added because I reasoned about them rather than because the derivation named them (it reports check:entry-guard's population as scripts/**, which does not cover a .ts file under packages/*/scripts/):\n check:entry-guard EXIT=0 '125 scripts/ file(s) -- every entry guard goes through invoked-as.mjs' + '47 self-test cases pass'\n check:nul-bytes EXIT=0 '6231 text file(s) ... no raw ASCII control bytes'\n Note the gate that would have caught this class does not reach the file: check-entry-guard.mjs walks the repo-root scripts/ only and only .mjs/.js/.cjs, so packages/objectql/scripts/*.ts is outside it in two independent ways. That is #10269's own subject and is left as the PR's stated proposal.\n\nCI on the new head, ONE look, no polling (the contract's convergence wait is the PM's): 31 check runs -- 20 success, 3 skipped, 8 still in_progress, ZERO failures so far. 'Type Check · debt ledger' and 'Type Check · workspace' are among the in_progress; 'Type Check · source gates' already completed success.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #10779: the TEST_DEBT re-measure bills @objectstack/objectql for a TS6059 about its own generated config -- remeasureProject neutralises rootDir only when it widens include, so a file reached TRANSITIVELY from a test (scripts/dry-run-hash-compat.ts, via src/dry-run-hash-compat.test.ts:5) sits outside rootDir 'src' and is counted; the function's own docstring calls exactly that diagnostic 'measuring the tape measure'. It is frozen inside the 355, so fixing it is a --lower opportunity, never a ratchet break. Labels finding + domain:devx, unassigned, no pm:queue. Deduped against all 301 open issues fetched via REST (search API unavailable).",
"not filed, deliberately: adding \"type\": \"module\" to packages/objectql/package.json. The dispatch offered it as a finding if I believed it the right long-term fix. I measured that it is not one as stated -- tsup emits format ['esm','cjs'] with the CJS build at dist/index.js and the exports map serves it under the require condition, so the declaration would make every CJS consumer read CJS output as ESM. A finding proposing it would be noise; a card for it would have to be about renaming the CJS output, which nothing in this repair motivates."
]
}
Activity
os-zhuang commentedon Aug 21, 2026
Unlock scan (triage seat): released back to
pm:queue. Two-check release: ① block condition met — #10086 (the class-closingcheck:entry-guardgate forscripts/) is closed; ② no newer merged PR on this card. Premise re-verified onorigin/main: the threefile://${argv[1]}template sites are still present (examples/embed-objectql/src/index.ts:60,packages/core/examples/kernel-features-example.ts:304,packages/core/examples/phase2-integration.ts:355).Dispatch note: direction 1 (point the four sites at
isProcessEntry/ the example equivalent) is the bounded deliverable. Direction 2 (widening the gate's population beyondscripts/, acceptingisProcessEntryas a second legal spelling) is a real gate-design question — the dev may propose it in the same PR or file it, but must not silently widen the gate. Theinvocation.tsheader count correction ("~8 spellings" → the measured 11/33) rides along. Size/model suggestion: S–M, opus.Generated by Claude Code
claude commentedon Aug 21, 2026
Claim — PM dispatch (devx seat)
c970724d-303c-5614-9d20-a3f92205cfadclaude/issue-10269-symlink-blind-entry-guardsAll four sites re-verified on
origin/main=8f04d9a4ff(fetched 2026-08-21T09:55:34Z), notadopted from the card:
packages/objectql/scripts/dry-run-hash-compat.ts:261process.argv[1] && /dry-run-hash-compat\.ts$/.test(process.argv[1])— regex-basename (S3/S10)packages/core/examples/kernel-features-example.ts:304import.meta.url === \file://${process.argv[1]}`` — template (S11)packages/core/examples/phase2-integration.ts:355examples/embed-objectql/src/index.ts:60Blocker cleared: #10086 is closed, and the triage seat's two-check release already re-verified the
three template sites. I have now re-verified all four independently at a later tip.
scripts/invoked-as.mjsmay not be importable from these files. Three of the four are.tsunderpackages/**andexamples/**; importing a rootscripts/helper from publishedpackage source would create a package→
scripts/dependency that almost certainly violatesthis repo's boundaries. The dev must measure whether that import is legitimate here before
reaching for it, and if it is not, say so rather than forcing it.
examples/embed-objectql/src/index.tsis inside the populationcheck:examples-live-importsinventories, and PR fix(scripts): let dispatch-gates name check:examples-live-imports for an examples card #10727 (finding: dispatch-gates cannot name check:examples-live-imports — its root literal is the bare word
examples, the same class #10114 fixed for check-role-word #10314) — which just taughtdispatch-gatesto name that gatefor
examples/**paths — is open and unmerged. Adding an import to an example changes thatexample's import graph, which can move the gate's
graph-visiblecount. The dev must re-runcheck:examples-live-importsand reconcile its numbers, not assume a guard edit is inert to it.Severity is genuinely split, per the card:
dry-run-hash-compat.tsis a compatibility checker,where reached-the-wrong-way means it does nothing, exits 0, and a caller holding the status reads
a pass. The other three are demo entry points — lower severity, but they are the files a reader
copies from, which is how a broken spelling propagates.
Generated by Claude Code
fix(devx): symlink-blind entry guards at the four sites outside scripts/
docs(cli): invocation.ts header cited a parity test file that is not …
claude commentedon Aug 21, 2026
{ "issue": 10269, "status": "done", "branch": "claude/issue-10269-symlink-blind-entry-guards", "pr": "https://github.com/objectstack-ai/objectstack/pull/10755", "premise_still_valid": true, "summary": "All four sites re-verified at merge base 8f04d9a4ff and repaired; a whole-tree sweep for process.argv[1] / require.main / import.meta.main outside scripts/ confirms the census of four is complete (the other hits are packages/cli/src/index.ts, which already uses isProcessEntry, five binPath reads in cli commands that are not entry guards, and two spec test fixtures carrying argv[1] inside a shell string payload). HAZARD 1 -- the answer is stronger than 'probably not allowed': there is NO package all three consumers can legally reach that could host this predicate, so the remedy is four inline spellings, byte-identical, each carrying the divergence rule. Evidence: (a) @objectstack/cli, the home of isProcessEntry, DEPENDS ON both @objectstack/core and @objectstack/objectql, so importing it from either is a workspace dependency CYCLE; it also declares no `exports` map at all and packs files:[\"dist\"], so src/utils/invocation.ts is unreachable by subpath from anywhere. (b) The INTERSECTION of the dependency sets of objectql, core and example-embed-objectql is exactly one package, @objectstack/spec, whose runtime deps are zod + pg-connection-string with no node: import in any non-test source (ADR-0076 D5 ratifies that shape) -- a node:fs/node:url predicate cannot live there. (c) examples/embed-objectql does not depend on cli, and adding it would pull a 40-dependency CLI into an example whose stated purpose (ADR-0076) is 'no kernel, no plugins'. (d) Root scripts/invoked-as.mjs's own header states the non-import rule; the packages/spec/scripts/build-docs.ts precedent does NOT transfer, because dry-run-hash-compat.ts is imported by packages/objectql/src/dry-run-hash-compat.test.ts, so the edge would put a root-scripts/ module into a package TEST's input graph -- invisible to turbo, which is exactly the class check:cross-package-test-inputs exists for (its self-test pins that escape shape). HAZARD 2 -- check:examples-live-imports is unmoved: 0 invisible (declared), 6 inputs-declared, 70 graph-visible, before AND after, and the reason is structural, not luck (see `tests`). Riders: invocation.ts's '~8 spellings' corrected to the measured 11-across-33; and a bounded in-place fix for a second stale fact in the same docstring -- it cited invocation.cli-name-parity.test.ts, which is not in the tree (the parity case is at invocation.test.ts:210). Changeset: skip-changeset label, because NONE of the four guard files ship (objectql and core both pack files:[\"dist\"] with tsup entries src/index.ts+src/core.ts and src/index.ts+src/logger.ts; embed-objectql is private:true), and the one published file touched, packages/cli/src/utils/invocation.ts, is comment-only -- every changed line in its diff begins with ' *'. Label added additively via POST and READ BACK after the bots ran: ['size/m','skip-changeset'].", "tests": "Gate union re-run AFTER the final commit, at HEAD ca022387f2 (git rev-parse --short HEAD from that run). `node scripts/pm/dispatch-gates.mjs` with no paths derived 5 changed paths and named 6 families; I ran those 6 plus 6 more I reasoned about (the derivation reports check:examples-live-imports and check:parse-guard as unreachable-by-construction, so it can never name them). Each gate's own verdict line, exit code captured before any pipe: error-code-casing exit=0 'no lowercase error codes in 4354 scanned file(s) (ADR-0112)'; slot-lookup exit=0 'baseline key set verified against 8f04d9a: no files added'; test-source-alias exit=0 '72 packages with tests scanned'; type-source-resolution exit=0 '76 packages with a tsconfig.json scanned'; engine-split-ratio exit=0 'ADR-0076 D7 ratio 97.5%' (first run refused with exit 2 -- shallow clone, not my change; fixed by the remedy it printed, git fetch --shallow-since=2026-05-16); affected-docs exit=0 '308 self-test cases pass'; examples-live-imports exit=0 'examples/** live-import inventory OK -- 0 invisible (declared), 6 inputs-declared, 70 graph-visible'; entry-guard exit=0 '125 scripts/ file(s) -- every entry guard goes through invoked-as.mjs'; parse-guard exit=0 '124 scripts/ file(s) -- every TypeScript parse goes through ts-parse.mjs'; nul-bytes exit=0 '6231 text file(s) ... no raw ASCII control bytes'; cross-package-test-inputs exit=0 '13 package(s) read outside themselves, all declared'; driver-memory-census exit=0 'every declaration is ledgered, every ledger entry is live'. || HAZARD 2 RECONCILIATION: baseline on origin/main 8f04d9a4ff was '0 invisible (declared), 6 inputs-declared, 70 graph-visible'; after ca022387f2 it is identical. Not assumed inert -- the gate inventories packages/** TESTS that reach into examples/**, and this change adds only node: builtin imports to an example's own source, creating no coupling in that direction; the gate's self-test explicitly does not flag node: builtins. || DEFECT REPRODUCED ON THE REAL FILES, not asserted. examples/embed-objectql/src/index.ts copied verbatim into a REAL directory named 'a#b' with the package's node_modules reachable: old template guard on an ordinary path exit=0/750 bytes (control, prints 'Active accounts (2): Acme, Initech'); old guard under 'a#b' exit=0/0 BYTES (inert, NO symlink involved); new guard under 'a#b' exit=0/750 bytes; old guard via a symlink exit=0/0 BYTES; new guard via a symlink exit=0/750 bytes. packages/objectql/scripts/dry-run-hash-compat.ts through a RENAMING symlink on a snapshot the direct run calls INCOMPATIBLE: before exit=0 with 0 bytes of stdout+stderr (a caller holding result.status reads a PASS), after exit=1 with 1044 bytes byte-identical to the direct run. FIRE-ON-IMPORT on the real file: an unrelated entry script whose own path ends in 'dry-run-hash-compat.ts' importing runDryRun -- before, the tool's CLI ran inside the importer's process and killed it at exit=2 with 'Usage: pnpm tsx ...' so the importer's own code never ran; after, exit=0 'IMPORTER ALIVE, rows= 0'. A synthetic probe with the byte-exact guard texts pins the same five directions plus the different-name-symlink case. FAMILY NOTE: the regex-basename form is IMMUNE to percent-encoding (it builds no URL) and the template form is immune to fire-on-import -- my first attempt to probe encoding against dry-run-hash-compat.ts came back exit=1/1044 bytes on an 'a#b' path, which confirms the card's two-family split rather than contradicting it, and I re-ran the encoding leg against a template site. || CLI UNCHANGED: dry-run-hash-compat.ts's three invocations before vs after are byte-for-byte identical with identical exit codes -- compatible snapshot exit=0/522 bytes, incompatible exit=1/1044 bytes, no-argument usage exit=2/81 bytes (diff -q reports IDENTICAL for all three). || SUITES: pnpm --filter @objectstack/objectql test -> 'Test Files 225 passed (225) / Tests 3983 passed (3983)'; pnpm --filter @objectstack/example-embed-objectql test -> 'Test Files 1 passed (1) / Tests 2 passed (2)' and its typecheck clean; pnpm --filter @objectstack/objectql typecheck clean; packages/cli invocation.test.ts -> 'Test Files 1 passed (1) / Tests 17 passed (17)'. || ABLATION (for the filed finding), with both legs stated: no build is involved because tsc reads source, so the rebuild question is moot here and the load-bearing proof is the on-disk one. MUTATION LEG: injected 'const OS_ABLATION_TYPE_ERROR: number = \"definitely not a number\";' into packages/objectql/scripts/dry-run-hash-compat.ts and CONFIRMED IT LANDED by grep -c of the INJECTED text = 1 (not by the editor's exit code, and not by a bare git diff --stat); then pnpm --filter @objectstack/objectql typecheck exited 0 with ZERO 'error TS' lines, and tsc --noEmit --listFiles contains the file 0 times. RESTORE LEG: git checkout -- <file>, confirmed on disk by grep -c of the injected text = 0 and git status --porcelain empty, so no mutated bytes survived into any later measurement. Predicted direction was 'stays green' and that is what happened. || TWO HONEST LIMITS, both closed by CI: (1) packages/cli's FULL suite is not a clean measurement in this worktree -- 66 of 139 files fail with 'Cannot find module .../@objectstack/client/dist/index.mjs', i.e. the cli dependency closure is unbuilt here (I built only objectql's and the embed example's). The cli change is comment-only and the file's own pin passes 17/17. (2) @objectstack/core has NO typecheck script (ledgered DEBT, #4311), and three of the five edited files sit outside every tsconfig; those three are verified by RUNNING them under tsx instead, which is the stronger evidence for an entry guard.", "open_questions": [ { "question": "Should check:entry-guard's population widen beyond scripts/ to cover the four sites this PR just fixed? Raised as the PROPOSAL the dispatch asked for, not done here -- the gate's scripts/-only scope is a deliberate #10086 decision and widening it sweeps a much larger population than this card measured. Two facts make it a real design question rather than a constant change: the walk only collects .mjs/.js/.cjs, so three of these four .ts files would still be invisible even after the scope constant changed; and the gate's rule is 'process.argv[1] anywhere but scripts/invoked-as.mjs is a failure', which every correct inline spelling in this PR violates by construction, because no importable predicate exists for them (see the boundary evidence in `summary`).", "options": [ "A. Leave the gate at scripts/ and accept that these four are pinned by prose + this PR's measurements only. Zero new surface; the honest cost is that a fifth site typed tomorrow outside scripts/ is invisible again, exactly as these four were.", "B. Widen the population to packages/** and examples/**, extend the walk to .ts, and teach the gate a SECOND legal spelling (packages/cli's isProcessEntry) plus a third for files that can reach neither home. The third clause is the problem: it is 'an inline copy that matches this exact shape', i.e. the gate would have to pin a text template, which is a different kind of rule from 'route through the one predicate'.", "C. Narrow middle: widen only to the directories that HAVE an importable predicate (packages/** that may legally reach @objectstack/cli), leaving examples/** and the cycle-blocked packages out. Closes part of the class with no template-matching, but the excluded set is precisely the four sites in this card.", "D. Attack the cause instead: give the predicate a home that packages/**, examples/** and scripts/ can all reach (a new tiny zero-dependency workspace package), then widen the gate to require it everywhere. Only option that actually closes the class, and the only one that adds a published-surface decision." ], "recommendation": "A for now, and file D as its own card if a fifth site ever appears. Real business need: nobody is reaching these four through a symlink today -- the pull is correctness of a compatibility checker, which this PR already delivers, and the marginal pull for a gate is one hypothetical future site. Long-term soundness: D is the only option that closes the class rather than describing it, but a new workspace package to host a 16-line predicate is exactly the kind of surface ADR-0076-era discipline pushes back on, and B's text-template clause would make the gate assert a SPELLING rather than a ROUTE, which is a weaker invariant than the one #10086 landed. Hard-to-get-wrong for AI-written code: B actively hurts -- a gate that accepts 'an inline copy matching this template' teaches the next author that copying is blessed, which is the drift #10086 spent 33 files removing. Startup scope: A and D are the only coherent ends, and D costs a package for four non-published files. Maintainer's call if they disagree on the fifth-site threshold." } ], "out_of_scope_findings": [ "filed as #10756: a package can pass check:type-check-coverage while a whole source directory sits outside tsc -- packages/objectql/scripts/** is in no tsc program (file appears 0 times in tsc --listFiles; an injected `const x: number = \"string\"` leaves typecheck at exit 0), yet objectql is in neither the DEBT nor the EXEMPT ledger. domain:devx + finding, unassigned, no pm:queue." ] }Generated by Claude Code
claude commentedon Aug 21, 2026
{ "issue": 10269, "status": "done", "branch": "claude/issue-10269-symlink-blind-entry-guards", "pr": "https://github.com/objectstack-ai/objectstack/pull/10755", "premise_still_valid": true, "summary": "Repair of the existing PR #10755, not a new one: pushed one commit (4d77499e10) onto its head branch, no force-push, PR left non-draft and untouched otherwise. The dispatched diagnosis is confirmed by measurement, exactly as stated. packages/objectql/package.json declares no \"type\", so under the root tsconfig's module: NodeNext every file in that package has CommonJS format, and the entry guard's import.meta.url self-path is a hard TS1470 there; the other four guard sites sit in packages whose package.json says \"type\": \"module\", which is why exactly one package drifted. The fix seeds the same predicate from __filename in that one file and nothing else: node's CJS loader hands __filename an absolute path that is already symlink-resolved and percent-decoded, so both legs the guard rests on (realpath for symlinks, directory resolution when node is pointed at a directory) are unchanged, and the guard is neither weakened nor disabled. The comment at the guard now states that the divergence from packages/cli/src/utils/invocation.ts is FORCED by the package's module format, so the next reader does not restore consistency and re-break the ledger. TEST_DEBT was NOT raised. \"type\": \"module\" was NOT added, and I did not file it as a finding either, because I measured it to be wrong rather than merely wide: objectql's tsup emits format ['esm','cjs'] with the CJS output at dist/index.js, which the exports map serves under the require condition, so declaring the package ESM would make every CJS consumer's require() read CJS output as ESM. It is not a long-term fix without also renaming that output. The #10756 correction is delivered both ways: a correction comment quoting the import at src/dry-run-hash-compat.test.ts:5, and an in-place body correction; the issue stays open and unassigned, and its title was already accurate. PR #10755's body carried the same over-broad claim in its verification section and is corrected there too.", "tests": "ALL measurements below ran against a TEST-INCLUSIVE program, never `pnpm --filter @objectstack/objectql typecheck` -- that command is exit 0 in BOTH states and proves nothing (it is the blindness #10756 is about; I ran it only to confirm it stays 0, which it does).\n\nREPRODUCTION SHAPE. The re-measure project was rebuilt exactly as remeasureProject (scripts/check-type-check-coverage.mjs:1885) generates it for this package: extends packages/objectql/tsconfig.json, exclude = the package's exclude with the test globs dropped (node_modules, dist), include = the package's own include (src/**/*, unwidened -- all 225 objectql test files are already under src/, so `unreachable` is empty and rootDir is correctly NOT neutralised), plus the reconstructed default typeRoots. Dependency closure built first: `pnpm --workspace-concurrency=2 --filter '@objectstack/objectql^...' build` -> CLOSURE_BUILD_EXIT=0. Command, both legs identical:\n ./node_modules/.bin/tsc --noEmit --pretty false -p TMPDIR/tsconfig.debt-remeasure.json\nExit codes captured before any pipe (TSC_BEFORE_EXIT=2, TSC_AFTER_EXIT=2 -- tsc exits 2 with diagnostics in both, which is why the COUNT is the measurement, not the status).\n\nBEFORE (ca022387f2, the PR head as dispatched): 356 error lines -- `grep -cE 'error TS[0-9]+'` = 356. That is CI's number reproduced exactly, which is what makes the hand-built project trustworthy. The TS1470, verbatim:\n packages/objectql/scripts/dry-run-hash-compat.ts(279,40): error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.\n\nAFTER (4d77499e10): 355 error lines -- exactly the frozen TEST_DEBT['@objectstack/objectql']. TS1470 count on that file 1 -> 0; `grep -n TS1470` in the after run returns only the pre-existing packages/objectql/src/core-boundary.ratchet.test.ts(57,35), which is part of the frozen 355. `diff` of the two runs sorted, complete output:\n 88d87\n < packages/objectql/scripts/dry-run-hash-compat.ts(279,40): error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.\nOne line left; nothing else moved in either direction.\n\nNOT RUN, and declared: `node scripts/check-type-check-coverage.mjs --re-measure` with the full lint.yml closure (turbo build over every package, then 34 sequential tsc runs). The container's shared verify lock was contended all session (one acquisition waited 4m27s behind another agent's full-workspace build), and the cheaper measurement already agrees with CI to the error on the BEFORE leg, which is the fidelity evidence that run would have provided. CI's own debt-ledger job re-runs it on the new head.\n\nRUNTIME, `pnpm tsx` (the documented invocation): direct run with a snapshot -> exit 0, full report; through a RENAMED symlink whose parent directory is named `sym#dir` (symlink AND percent-encoding in one) -> exit 0, byte-identical report; no argument -> exit 2, 'Usage: pnpm tsx ...'; imported by another module -> exit 0, CLI body did not run ('IMPORT-PROBE: module imported, CLI body did NOT run; runDryRun typeof = function'). So the guard still fires and is still inert on import.\n\nABLATION (does that symlink leg discriminate?). No dist involved -- tsx compiles from source at run time, so there is no built artifact to go stale; the leg that matters is the on-disk mutation, and it was confirmed by anchored grep rather than by an editor exit code. Fix committed FIRST (4d77499e10) so restoration was a checkout, never a stash. Mutation: the pre-#10269 predicate put back (`if (isProcessEntry())` count 0, injected `test(process.argv[1])` count 1). Result: the renamed-symlink invocation returned exit=0 and 0 bytes of output -- the silent-inert defect -- while the direct run still printed the report. Restored with `git checkout --` on that path and re-confirmed by grep (isProcessEntry 1, injected regex 0, resolve(__filename) 1) before any further measurement.\n\nTESTS. `pnpm --filter @objectstack/objectql test -- --maxWorkers=2` -> OBJECTQL_TEST_EXIT=0, 'Test Files 225 passed (225) / Tests 3983 passed (3983)' -- includes src/dry-run-hash-compat.test.ts, the importer of the changed file. `pnpm --filter @objectstack/objectql typecheck` -> exit 0 (green before and after; recorded, not relied on).\n\nGATES. `node scripts/pm/dispatch-gates.mjs` with no paths (exit 0) derived the change set from the merge base itself -- 5 committed paths, 6 families. Union re-run AFTER the final commit, at 4d77499e10, on a clean tree; exit codes captured by redirecting to a file before reading it, and each verdict is the gate's own printed line:\n check:error-code-casing EXIT=0 'no lowercase error codes in 4354 scanned file(s) (ADR-0112)'\n check:slot-lookup EXIT=0 'slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new'\n check:test-source-alias EXIT=0 'check-test-source-alias OK -- 72 packages with tests scanned'\n check:type-source-resolution EXIT=0 'check-type-source-resolution OK -- 76 packages with a tsconfig.json scanned'\n check-engine-split-ratio.mjs EXIT=0 'ratio: 97.5%'\n check-affected-docs.mjs EXIT=0 'affected-docs self-test: 308 cases pass'\n Plus two I added because I reasoned about them rather than because the derivation named them (it reports check:entry-guard's population as scripts/**, which does not cover a .ts file under packages/*/scripts/):\n check:entry-guard EXIT=0 '125 scripts/ file(s) -- every entry guard goes through invoked-as.mjs' + '47 self-test cases pass'\n check:nul-bytes EXIT=0 '6231 text file(s) ... no raw ASCII control bytes'\n Note the gate that would have caught this class does not reach the file: check-entry-guard.mjs walks the repo-root scripts/ only and only .mjs/.js/.cjs, so packages/objectql/scripts/*.ts is outside it in two independent ways. That is #10269's own subject and is left as the PR's stated proposal.\n\nCI on the new head, ONE look, no polling (the contract's convergence wait is the PM's): 31 check runs -- 20 success, 3 skipped, 8 still in_progress, ZERO failures so far. 'Type Check · debt ledger' and 'Type Check · workspace' are among the in_progress; 'Type Check · source gates' already completed success.", "open_questions": [], "out_of_scope_findings": [ "filed as #10779: the TEST_DEBT re-measure bills @objectstack/objectql for a TS6059 about its own generated config -- remeasureProject neutralises rootDir only when it widens include, so a file reached TRANSITIVELY from a test (scripts/dry-run-hash-compat.ts, via src/dry-run-hash-compat.test.ts:5) sits outside rootDir 'src' and is counted; the function's own docstring calls exactly that diagnostic 'measuring the tape measure'. It is frozen inside the 355, so fixing it is a --lower opportunity, never a ratchet break. Labels finding + domain:devx, unassigned, no pm:queue. Deduped against all 301 open issues fetched via REST (search API unavailable).", "not filed, deliberately: adding \"type\": \"module\" to packages/objectql/package.json. The dispatch offered it as a finding if I believed it the right long-term fix. I measured that it is not one as stated -- tsup emits format ['esm','cjs'] with the CJS build at dist/index.js and the exports map serves it under the require condition, so the declaration would make every CJS consumer read CJS output as ESM. A finding proposing it would be noise; a card for it would have to be about renaming the CJS output, which nothing in this repair motivates." ] }Generated by Claude Code
fix(devx): the four symlink-blind entry guards outside scripts/, wher…