ci: check the packed package without a checkout - #1767
Conversation
The check Stage 2 exists for. A new `package-check` job downloads the tarball uploaded by node26, installs it as a real dependency, and type-checks every declaration the package ships. Deliberately not built through `toSteps`: that helper injects `actions/checkout`, and the missing checkout is the whole point. With no repository on the runner there is no tsconfig.json up the tree to inherit, no node_modules to resolve into, and no source file that can stand in for a declaration the tarball omits, so the job sees what a consumer sees. Four properties decide whether it can fail at all, and each is asserted: declarations are enumerated from the installed artifact rather than a written list, so a module that gains a private type module later is checked without the job being edited; skipLibCheck stays at its false default; the file list is non-empty, which is the one way the job could look healthy while checking nothing; and the compiler is the package's own pin, read out of the packed package.json, since without a checkout there is no lockfile and the registry would otherwise decide the verdict. Ran the emitted script verbatim against a real tarball rather than trusting the generator: 395 declarations, TypeScript 7.0.2 resolved from the packed pin, exit 0. Removing the 16 private.d.ts leaves 379 and still exits 0, so the exclusion in the next step is safe; appending a dangling private import to one packed declaration exits 2 with TS2307. The job is green today because nothing is excluded yet — its value appears when the negation lands, which is why the guard comes first. The proof kills five mutants: a checkout step, a dropped needs edge, a needs edge pointing at a job that does not produce the artifact, skipLibCheck true, and a dropped empty-list guard. Two existing assertions moved, both by design rather than accommodation. The job count is 13 -> 14, and jobNeeds asserted no job ordered itself; that guard fired exactly when its first consumer arrived and now pins one ordering edge, so a second stays a deliberate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
|
You have reached your Codex usage limits for security reviews. Please try again later. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | a194225 | Commit Preview URL Branch Preview URL |
Aug 29 2026, 12:15 AM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff9f8f92ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Three review findings, all correct. The script hard-coded node_modules/functionalscript. `fjs ci` is a public command that generates workflows for other projects, and the proof already exercises a package named other-package, so for them the check would fail on a missing directory — or worse, silently validate a `functionalscript` dependency that happens to be installed instead of the artifact just built. That is a plausible wrong answer where DESIGN.md §10 requires a refusal. The package directory is now derived from the tarball, by reading back the single dependency `npm install ./*.tgz` writes. Verified by building a second tarball named other-package and running the emitted script against it: resolves other-package, finds its declaration, exit 0. Against this repository's own tarball: 395 declarations, TypeScript 7.0.2, exit 0. A package with no compiler pin exits non-zero rather than installing a floating compiler. The regex `/^=/` is gone, and not by rewriting it: npm accepts the `=7.0.2` range verbatim, so the stripping was never needed. fjs/AGENTS.md forbids regular expressions; the fix removes the reason for one rather than the syntax. Adding a job changes the workflow `fjs ci` emits for every caller, which is a behavior change for users of the package rather than an internal refactor, so this adds the changelog entry it needs. The proof now pins the derivation and asserts the hard-coded name is absent, so the regression cannot come back quietly. Changelog: changelog/unreleased/1767.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 837eda6ab5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Three more review findings, all correct. The module header lacked @module. fjs/AGENTS.md §2 requires it on every module.* entry point, and all five sibling fjs/ci modules carry one; #1756 removed the tag from non-module files, not from these. The proof lived in the parent. fjs/ci/deno, fjs/ci/nix and fjs/ci/node all have co-located proofs, so the precedent is the opposite of what I assumed. The job's own shape is now proved in fjs/ci/package/proof.f.mjs, and the parent keeps only what the assembled workflow can show: that the job is wired in, and that the job it waits for is really the one that uploads — an edge pointing at a job that never produces the artifact would satisfy the ordering and still never run. The compiler pin was the substantive one. Reading devDependencies.typescript gives a dependency specification, not a resolved version: this repository pins =7.0.2, but a project writing ^7.0.0 would have had npm choose, which is exactly the determinism the checkout-less job exists to preserve. The script now compares the installed version against the literal pin and refuses when they differ. That case is worse than it looks, and the test shows why: ^7.0.0 installs 7.0.2 today, so a range is indistinguishable from an exact pin until the day the registry publishes 7.0.3 and the verdict moves with no change to the package. Verified: ^7.0.0 exits non-zero, =7.0.2 exits 0 at 396 declarations, and a second tarball named other-package still resolves and passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
The check was a single run: step, so a failure arrived as "the script failed" rather than naming which part. It is now four steps — install the artifact, install the pinned compiler, enumerate the declarations, type-check them — so the GitHub UI attributes a failure to the stage that produced it. Shell variables do not survive between steps. Only one value actually crosses a boundary: pkg, the artifact's package name, which now travels through $GITHUB_ENV. ts, installed and exact stay local to the compiler step, and declarations.txt and node_modules cross as files, which need no mechanism. Verified by running the four emitted scripts in sequence with $GITHUB_ENV emulated: all pass, pkg carries across, 396 declarations, TypeScript 7.0.2 — the same result the single script produced. The attribution works too: a range pin fails at the compiler step, and a dangling declaration fails at the type-check step with TS2307, rather than both surfacing as one opaque failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 389c443c4e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Simplicity over the narrower case. `npm install "packed@file:$(ls *.tgz)"` installs the tarball under a fixed directory name, so every later step names it literally: the name derivation and the $GITHUB_ENV hand-off both go away, and no value crosses a step boundary any more. Nothing is baked into the generated workflow that package.json can change, so a version bump still regenerates nothing — which the derived name also achieved, but this achieves it with less machinery. What an alias gives up is recorded next to it rather than left in a review thread: a package that imports itself by name, legal once `exports` is declared, does not resolve under a different directory name, so such a package would fail a check a real consumer passes. Nothing here self-references, and the comment says to revisit if that changes. Re-verified end to end on the four emitted steps: this repository's tarball passes at 396 declarations, a tarball named other-package passes at 1 — the alias makes genericity structural rather than derived — and failures still attribute per step, a range pin at the compiler step and a dangling declaration at the type-check step with TS2307. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c1ee71c5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The steps were unreadable because they did work that belongs in the generator. Thirty lines of run: script become eight. set -eu is gone. GitHub runs run: steps as `bash -e`, so -e was already there, and -u guarded unset variables only while a value crossed steps through $GITHUB_ENV; nothing crosses now. Verified by running the emitted steps under `bash -e`: failures still propagate and still land in the right step. The compiler version moves to fjs/ci/config, beside the bun, deno, node and wasmtime pins. That deletes the whole of the second step: reading the pin out of the packed package.json at run time, installing it, re-reading what was actually installed, and stripping a leading `=` through an environment variable to compare the two. All of it existed to establish a version this repository already states, and now states in the one place tool versions live. One line remains. This changes what the pin means, which is worth saying plainly: the check now installs the version this repository pins rather than the one the artifact declares. For a package generated by `fjs ci` elsewhere that is the CI tool's compiler, exactly as the node, deno and bun versions in the same file already are. It also removes the range hole by construction — a config constant cannot be a floating range without that being a visible change here. Also enumerates *.d.cts, so "every declaration the package ships" is true for a package that ships CommonJS declarations, and requires exactly one archive, so which package is under test is never ambiguous. Verified end to end on the emitted steps: 396 declarations and TypeScript 7.0.2 for this package, a .d.cts-only package enumerated and checked, two archives refused, and a dangling declaration still failing at the type-check step with TS2307. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26a6c2fb78
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // a hand-written import list cannot see a module that gains a private type | ||
| // module later, which is the case this check exists to catch. An empty list | ||
| // would type-check nothing and pass. | ||
| const enumerateDeclarations = /** @type {const} */ (`find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) > declarations.txt |
There was a problem hiding this comment.
Avoid compiling mutually exclusive declaration trees together
When a valid package ships version-specific declarations selected through typesVersions, this find supplies both the selected and fallback trees as roots in the same TypeScript program. For example, root and ts4.8/ declarations of the same global compile independently for their intended compiler versions but fail here with duplicate identifiers, breaking generated CI for such packages; honor the package's declaration selection or check mutually exclusive trees separately.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not doing this one, and I want to be explicit that it is a decision rather than an oversight.
The finding is technically correct: a package using typesVersions ships mutually exclusive declaration trees, and enumerating all of them as roots of one program would collide on duplicate identifiers.
It does not apply to this repository. package.json has no typesVersions, no ts*/ tree, and one declaration per module. Honouring version selection means reading typesVersions, matching the compiler against its ranges, and resolving the mapping — a resolver's worth of shell for a shape we do not have.
The reason I'm declining rather than deferring: this generator exists to build our CI. fjs ci working for other projects is a side effect, not a product commitment, so "breaks generated CI for such packages" is not a cost we are carrying today. Several findings on this PR were right precisely because they were about our repo — the hard-coded package name, the compiler pin, the response-file quoting — and I fixed those. This one is about a package shape nobody here publishes.
If that changes, the failure is loud (duplicate-identifier errors, not a silent pass), which is the right way for an unsupported input to surface.
Generated by Claude Code
There was a problem hiding this comment.
Filed, so the decline survives the merge: fjs/ci/todo/package-check-unsupported-package-shapes.md,`` in f0364d3 — issue 1.
Writing it down sharpened the answer I gave you. typesVersions does not affect coverage, because the job checks every declaration the tarball ships, which is a superset of what any entry point reaches. What it does affect is whether the map itself is exercised: a package whose typesVersions points at a path it does not ship gets a green check today. That is a narrower and more accurate statement of the gap than "declined as out of scope", and it is in the file.
Generated by Claude Code
tsc splits an @response file on whitespace, so a declaration under a directory with a space in its name was read as two root files. Reproduced before fixing: a package shipping "space dir/a.d.ts" failed with TS6053 "File 'dir/a.d.ts' not found" — a valid package rejected by the check rather than by its own contents. find -printf '"%p"\n' quotes each path. One flag, no extra command: the same package now passes, this repository's 396 declarations are unchanged, and a dangling private import is still caught with TS2307. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
o2alexanderfedin
left a comment
There was a problem hiding this comment.
The redesign is cleaner and the quoting fix is real. One thing I think should land before merge rather than after.
The compiler pin now exists in two unlinked places, and the new assertion cannot see them disagree. fjs/ci/config/module.f.mjs carries typescript = '=7.0.2'; package.json carries "typescript": "=7.0.2". Nothing derives one from the other. The proof reads assert(scriptHas("typescript@${typescript}")) — importing the same constant the generator interpolated, so it compares the value with itself and holds for any value.
Setting config to =5.9.3 leaves the suite at 3538/3538, and the job then installs 5.9.3 and type-checks clean: exit 0, green CI, wrong compiler. That is the first gap in this series silent at both layers — every earlier one (alias mismatch, cross-step wiring) still went red on a real runner. Note it is not reliably loud even by accident: =7.0.1 happens not to exist and fails at install, so whether drift is noticed depends on whether the drifted version was ever published.
Two smaller pieces of the same shape: nothing requires the config constant to be an exact pin, so ^7.0.2 would install whatever the registry serves that day with the proof still green; and the accept/refuse matrix I verified at f8b224d13 describes a mechanism that no longer exists — the installed-vs-declared comparison is deleted, not moved. An assertion in proof.f.mjs reading the repo's own package.json and comparing it to the constant closes the first; a shape check on the constant closes the second.
The rest verifies clean. The quoting fix is genuine: a declaration path containing a space fails the old response file with real tsc 7.0.2 (TS6053, TS6231, exit 2) and passes the new one, and every line comes from the single -printf '"%p"', so it is complete. All three of my standing findings retire with the code they were about — including set -eu, correctly, since the runner's implicit bash -e supplies errexit and no unset dereference remains; the one pipe still trips its guard. Proof changes map cleanly with nothing orphaned. Round-trip empty, gates tsc 0 and npm test 3538/3538.
Still true, and still the right order to build in: the defect class this job exists for is not reproducible on HEAD until files gains !**/private.d.ts.
Review found the compiler pin living in two unlinked places — fjs/ci/config and package.json — with a proof that could not see them disagree, because it imported the same constant the generator interpolated and so compared a value with itself. Setting the config constant to =5.9.3 left the suite green and the job installing the wrong compiler: silent at both layers, unlike every earlier gap in this series. The fix is not a better assertion. It is having one place. The generator already reads Cargo.toml, so it now reads package.json and takes devDependencies .typescript from there. The config constant is gone, and with it the thing that could drift. Changing the pin now changes ci.yml, which node26's existing regeneration check already guards. The proof supplies its own pin, one no configuration holds, so finding it proves the value came from the parameter rather than from a constant compared with itself. Without a pin the check cannot run deterministically, so it is not generated rather than run against a compiler nobody chose; a proof covers each shape that means "no pin", which is also what keeps branch coverage at 100%. Comments no longer travel into the emitted commands: ci.yml is output, and the reasoning belongs in the generator that a maintainer reads. The archive-count guard goes too — two archives already make npm's spec malformed and fail loudly, so it only restated a failure that happens anyway. Verified: 396 declarations and TypeScript 7.0.2 from package.json's own pin, a dangling private import still TS2307 at the type-check step, 3478/3478, coverage 100%, round-trip clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
|
The tautological assertion is the best finding on this PR. Fixed in 5688ffa — but not the way you proposed, and the difference matters. You suggested a proof reading The proof now supplies its own pin — Your point about it being the first gap silent at both layers is what made me stop patching and look at the shape instead. Every earlier one still went red on a real runner; this one would have shipped a green check running a compiler nobody chose. That difference is worth more than the individual fix. Two consequences worth recording: Absent pin. If Your accept/refuse matrix at Also acted on your other observations: the emitted commands no longer carry explanatory comments ( And your closing line still holds — the defect class this job exists for is not reproducible on HEAD until Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5688ffa2a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| deno: ubuntuArm(denoSteps(functionalscript)), | ||
| bun: ubuntuArm(bunSteps(functionalscript)), | ||
| ...nodeVersionJobs(functionalscript), | ||
| ...(pin === undefined ? {} : { [packageCheckJobId]: packageCheckJob(pin) }), |
There was a problem hiding this comment.
Refuse projects without a compiler pin
When an external project has no string-valued devDependencies.typescript, fjs ci still succeeds but silently emits no package-check job, despite the changelog promising that job; such a project's CI can therefore remain green without checking its packed declarations. Fresh evidence after the earlier compiler-version comment is this new conditional omission, so fail generation with an actionable error rather than quietly removing the validation.
AGENTS.md reference: AGENTS.md:L112-L118
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Keeping the omission, and I want to be clear that it is a decision rather than an unaddressed finding.
Your premise is right: silently dropping a check is worse than failing. Where I disagree is that it is silent. The generated workflow is committed to the repository, so losing the job appears as a package-check deletion in ci.yml — in the diff, in review — and node26's regeneration check makes an unregenerated workflow red. For this repository that is louder than an error at generation time, because it is visible to a reviewer rather than only to whoever ran the command.
Failing generation instead has a cost I do not want to pay: fjs ci would stop working for any project without an exact TypeScript pin, including ones that do not want this job at all. That trades a real regression for a hypothetical one.
On the changelog: fair point, it promised the job unconditionally. Since 9f6da87 the pin must also be exact, so I will make the entry say the job is generated when the project pins TypeScript exactly, rather than implying it is unconditional.
I should also flag the framing behind this and the typesVersions thread, since it applies to both: this generator exists to build our CI. fjs ci working for other projects is a side effect, not a product commitment — so "an external project's CI can remain green" is not a cost we are carrying. Findings about our repo on this PR were right and got fixed; this one is about someone else's.
Generated by Claude Code
There was a problem hiding this comment.
Filed: fjs/ci/todo/package-check-unsupported-package-shapes.md,`` in f0364d3 — issue 2.
The todo is honest that this one is cheap to build — a Result from ci(setup) and an error path through fjs ci — so the open question is whether refusing is right, not whether it is hard. My reason for not refusing is that the generator's other jobs do not depend on a pin, and a project without one still wants them.
The contract it replaces is now written down where a consumer will find it, in fjs/ci/README.md: an exact devDependencies.typescript generates the job, anything else generates none. Silently producing one fewer job was the real complaint, and documenting the rule answers it without a refusal.
Generated by Claude Code
Two commands shared a step for no reason: `npm init` and `npm install` are separate things, and the split exists so a failure names what failed. Every step is now one command, six in all. The response file is gone with it. `find -print0` and `xargs -0` hand the paths to tsc as arguments, so a space or a quote in a path survives without quoting or escaping — review found both cases, and this removes the mechanism rather than patching it twice. The empty-list guard stays as its own step: tsc does exit non-zero on no arguments, but by printing usage, which says nothing about why. The compiler pin must now be exact. A range such as ^7.0.0 lets a later registry release change the verdict with no change here, which is the one thing running without a checkout is meant to prevent. Relaxing the pin drops the job from the generated workflow — a visible diff in review rather than a quiet loss of checking. Verified on the emitted steps: 396 declarations and TypeScript 7.0.2, a directory named `we"ird dir` with both a space and a quote now passes where the response file would have split it, and a dangling private import still fails the type-check step. 3480/3480, coverage 100%, round-trip clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
The entry promised the job unconditionally, which stopped being true when the pin had to be exact for the job to be generated. Review caught the mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 652ec49ed6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d2de42ca3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `npm install "typescript@${pin}"`, | ||
| // `-print0` rather than a text list: the paths reach `tsc` as arguments, so | ||
| // a space or a quote in one survives without quoting or escaping. | ||
| `find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -print0 > ${declarations}`, |
There was a problem hiding this comment.
Obtain approval before adding the external-tool pipeline
The new generated job shells out to ls, find, test, and xargs in addition to npm and TypeScript, but repository policy requires approval before code, CI steps, scripts, or generators call any external tool. Obtain that approval before retaining this pipeline; otherwise the generated workflow violates the repository's explicit tooling policy.
AGENTS.md reference: AGENTS.md:L128-L132
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, and the right rule to raise — §6 landed in main via #1765 while this PR was in flight, so I built the pipeline before the policy existed and had not re-read the file since merging.
Rather than ask to keep it, I removed most of it in 5f90cda. find, xargs and test are gone: tsc reads a tsconfig.json whose include names the artifact directory and enumerates the declarations itself. ls is gone too — the glob was always expanded by the shell, so $(echo *.tgz) gets the same string from a builtin instead of a process. What remains is npm, npx and tsc.
That is not just compliance-shaped; it is the change §6 is arguing for. The section's objection is to a pattern that approximates analysis, and its prescribed answer is "an established tool that parses what it checks — ESLint for JavaScript, Clippy for Rust". Here the tool that does the checking now also does the finding, so there is no second, weaker model of the file tree to disagree with the compiler's. Concretely it fixed two of your other findings by construction: a .d.cts cannot be omitted from a list that does not exist, and a path with a quote in it cannot be mis-serialised when it is never serialised.
The remaining three are, I think, what the section endorses rather than what it prohibits: tsc is the parser, and npm is the thing under test — the job's whole purpose is to install the artifact the way a consumer would, which cannot be done without the package manager a consumer uses.
I am not treating that reading as settled. §6 says approval first, and the judgement of whether npm/npx/tsc in a generated job need explicit sign-off belongs to the repository owner, not to me — flagged for @sergey-shandar, and I will act on whichever way it goes.
Generated by Claude Code
startsWith('=') was the wrong shape rather than the wrong constant: `=7.x`,
`=7.0`, `=7.0.2 || 8.x` all pass a prefix test and are all ranges npm resolves
against the registry, which is exactly what running without a checkout is meant
to rule out.
The pin must now be `=MAJOR.MINOR.PATCH` and nothing else — three dot-separated
runs of digits after the sign. No regex; the repository forbids them and the
check is two lines without one.
It rejects a prerelease pin too, which is stricter than npm needs. That is the
right way to be wrong here: the cost is the job disappearing from ci.yml, a
visible diff in review, rather than a check that silently stops meaning
anything.
The validator caught the proof's own fixture, which used =9.9.9-run. Nine
rejection cases now cover the shapes that pass a prefix test, which is also what
keeps branch coverage at 100%.
This closes the class rather than patching another instance of it: the previous
three rounds each fixed one spelling of "not really pinned".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
…ydyc9' into claude/private-ts-todo-partial-bydyc9
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42bd0699fc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The package check walked the artifact with `find`, guarded the result with `test -s`, and fed it to `tsc` through `xargs -0`. A `tsconfig.json` with an `include` pattern does all three, and does them better: - No path passes through the shell, so a space or a quote in a directory name has nothing to survive. The `-print0`/`xargs -0` pairing existed only to carry bytes safely across that boundary; there is no boundary now. - An empty match is TS18003, which names the pattern that found nothing. The `test -s` guard could only say that a file was empty. - `.d.cts` and `.d.mts` need no enumerating: `include` selects every extension the compiler reads, so a package shipping a form we did not list is covered rather than skipped. Six steps become five, and three external tools become none. Root AGENTS.md §6 asks for an established tool that parses what it checks rather than a pattern that approximates one; here the tool that does the checking also does the finding. `exclude` is emptied because the default excludes `node_modules`, the only place the artifact exists. `skipLibCheck` is stated rather than left at its default — it is the one option whose flip leaves the job green having opened nothing, and the proof pins it. `fjs/ci/README.md` said the built-in command does not read `package.json`. Since the compiler pin moved out of the config constant, it does; the paragraph now says what it reads and what an inexact pin costs. Verified by running the emitted steps verbatim against real tarballs: 396 declarations checked, identical to the previous pipeline; a declaration referencing a module the tarball does not carry fails with TS2307; the same break passes silently under skipLibCheck true; a `.d.cts` under a directory named `we"ird dir` is enumerated; a package shipping no declarations exits 2 with TS18003. 3479/3479, coverage 100%, workflow regenerates clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f90cda8da
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * opening these declarations at all, and the job would still pass. | ||
| */ | ||
| const tsconfig = /** @type {const} */ ({ | ||
| include: [`node_modules/${alias}/**/*`], |
There was a problem hiding this comment.
Enumerate declaration files instead of all TypeScript roots
When a generated-workflow consumer ships a .ts, .tsx, .mts, or .cts source file but no .d.* files, this pattern still gives TypeScript a nonempty root set, so npx tsc can succeed without checking any declaration and the intended TS18003 empty-declaration guard never fires. Enumerate declaration extensions explicitly and reject an empty declaration set; otherwise package-check can report a plausible green result for exactly the missing-declarations case it is meant to detect.
AGENTS.md reference: AGENTS.md:L113-L119
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not doing this one, and it is a decision rather than an oversight. The mechanism you describe is real; the package shape that reaches it cannot occur here.
package.json files is an allowlist, not a denylist: ["**/*.js","**/*.d.ts","**/*.mjs","**/*.d.mts"]. No pattern in it matches a .ts, .tsx, .mts or .cts source, so a raw source file cannot be published no matter what lands in the working tree. I checked the packed tarball rather than reasoning from the field: 282 .mjs, 282 .d.mts, 114 .d.ts, one .md, one .json, and zero non-declaration TypeScript files. So the nonempty-for-the-wrong-reason root set is structurally unreachable, not merely absent today.
The direction of travel matters too. Stage 2's next step tightens files further — !**/private.d.ts — and every plausible mistake in that direction removes declarations, which is the case TS18003 does catch. Drop both declaration patterns and the tarball is .mjs and .js only, include matches nothing, and the job is red. I verified that path with a package shipping only index.js: exit 2, TS18003, message naming the pattern that found nothing.
The fix you suggest costs more than it buys here. "Enumerate declaration extensions explicitly" is the hand-written list I removed in 5f90cda — and it is the same list you correctly flagged three rounds ago for omitting .d.cts. Restoring it trades an unreachable failure for one that already happened once. "Reject an empty declaration set" separately from include needs something to count declarations, which means find again, against §6 and against the change that closed your xargs finding.
Worth being clear about what the empty-set guard is and is not. It is a backstop for "shipped nothing"; it was never the mechanism for "shipped less than before". That one is TS2307 on a reference the tarball no longer carries, it fires on partial loss where TS18003 cannot, and it is what Stage 2 actually rests on.
If a consumer ever ships sources rather than declarations, the honest answer is that fjs ci does not support that package shape — same position as the typesVersions thread — not a guard bolted on for a case this repository's own files field forbids.
Generated by Claude Code
There was a problem hiding this comment.
Filed rather than left in this thread: fjs/ci/todo/package-check-unsupported-package-shapes.md,`` in f0364d3.
The reason it can't fire here — files is an allowlist with no pattern matching a source — is now a comment on tsconfig in the generator, next to the ** that depends on it. The todo carries the rest: what building it would cost, and the condition that makes it worth building, which is the day root package.json files admits a .ts source.
Per REVIEW.md, a review thread is the one place an answer does not survive the merge.
Generated by Claude Code
There was a problem hiding this comment.
The link in my previous reply did not render. The file is fjs/ci/todo/package-check-unsupported-package-shapes.md, added in f0364d3 and visible in this PR's Files changed:
Generated by Claude Code
REVIEW.md: "where does this knowledge live once the pull request is merged? In the diff, in the design document, or in a todo/ issue. Only 'in the review thread' is wrong: it is the one place the answer will not survive." Nine findings on #1767 were answered only in threads. The answers move here. In the generator, next to the decision each explains: - Why tsc enumerates rather than a shell walk, with the three defects the find/test/xargs version had — including xargs' finite command buffer splitting a large package into separate programs, which was silent. Written as "do not go back", because the pipeline reads like the more explicit choice. - Why `**` rather than a list of declaration extensions, and what that costs: a package shipping sources and no declarations has a nonempty root set, so TS18003 cannot fire. - Why npm, npx and tsc are the tools that remain under AGENTS.md §6. - Why there is no guard against a second .tgz. In `fjs/ci/README.md`, the `package/` module joins the file list, stating the question a reader asks first: why the one job built without `toSteps`, and what a checkout would do to it. In a new `fjs/ci/todo/package-check-unsupported-package-shapes.md`, the three declined findings — typesVersions, refusing a project with no exact pin, and a package shipping sources — with what each would cost to build and what has to be true before it is worth building. They are one question, and it is the question `node26-typedef-gate-reaches-consumers.md` already asks about another job, so the two are linked. No generated output changes: `ci.yml` is byte-identical, which is the point of keeping the explanations in the source rather than in the emitted commands. 3479/3479, coverage 100%, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0364d3934
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
npm's `**` walks into a dot-prefixed name and TypeScript's does not. `files` therefore publishes `.d/x.d.ts` and a lone `include` of `**/*` left it unchecked — silently, and even when it is the package's own `types` entry point. Reproduced with a package whose `types` is a broken `.hidden/b.d.ts`: installed, never loaded, job green. Two patterns naming a dot segment explicitly close the shapes anyone ships: `**/.*` for a dot-named file, `**/.*/**/*` for anything under a dot-named directory at any depth. Verified all four arrangements enumerated — visible, dot-named file, top-level dot directory, nested dot directory — and the reproduction now fails with TS2307 on the file it was skipping. The real package is unchanged at 396 declarations, exit 0. Unlike the other package-shape findings this one was reachable rather than structural: nothing here ships a dot-prefixed path today, but `files` would publish one. The fix is honest about its limit rather than claiming completeness. Two dot segments in a row still escape, because the inner `**` has to cross the second, and a pattern per arrangement of dots does not converge. Closing it properly means reading the installed tree's real names, which is a tool walking the tree and so the AGENTS.md §6 conversation. Recorded as issue 3 in fjs/ci/todo/package-check-unsupported-package-shapes.md, with that condition stated. 3479/3479, coverage 100%, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approved. My finding is closed structurally rather than patched, which is the better outcome.
fjs/ci/config/module.f.mjs no longer carries a typescript value at all; the pin is read from package.json. I checked that by changing the manifest to =5.9.3 and regenerating — the new value propagates correctly into ci.yml, and there is no second place left that could disagree with it. The mutation I reported is not merely caught now, it is unexpressible.
cefa0d38f also closes something I had not separated out: the intermediate pin.startsWith('=') was a first-character test, so =7.x and =7.0.2 || 8.x would have passed it. The validator now requires three digit-only segments and the proof enumerates 17 rejections. On bare 7.0.2 being refused — your reading is right and my brief was an assumption, not a requirement: refusing a valid-but-differently-spelled pin surfaces as a visible diff in review, while a weakened check surfaces as nothing.
The dot-path fix is real and I confirmed both halves. At 5f90cda8d a broken declaration under .hidden/ never enters the file list and tsc exits 0 over it; at this head it is enumerated and reported. Nested dot segments (.a/.b/) still escape — which your own commit message says and package-check-unsupported-package-shapes.md tracks, so it is a bounded and recorded gap rather than a silent one.
The empty-list guard survived the move to a tsconfig include: a package with no declarations exits 2 on TS18003 naming the patterns that matched nothing, so test -s became unnecessary rather than being dropped. Gates: npm test 3541/3541, tsc 0.
Not re-verified at this head after the redesign: the full proof-assertion mapping and step-isolation end-to-end. Earlier rounds covered those against shapes that have since been replaced.
|
Thanks — and you named the one thing I had asserted rather than checked. The PR body claimed eight mutants against a proof that has been rewritten twice since I last verified that claim, so I ran them at this head instead of leaving it:
Unmutated baseline passes, so none of these is an assertion that fails regardless. I also tried a ninth outside the claim — On step isolation: the strongest evidence is the CI log rather than a local run. Each command appears as its own Your Two things stay open and both are yours: the §6 sign-off on the remaining Generated by Claude Code |
Step 3 of Stage 2, and the one the design exists for. Steps 1 (#1762, the ordering edge) and 2 (#1763, the artifact) are both in main; this is their first consumer.
A new
package-checkjob downloads the tarball the Node job uploads, installs it as a real dependency, and type-checks every declaration the package ships.Why it is built by hand
Deliberately not built through
toSteps: that helper injectsactions/checkout, and the missing checkout is the whole point. With no repository on the runner there is notsconfig.jsonup the tree to inherit, nonode_modulesto resolve into, and no source file that can stand in for a declaration the tarball omits. The job sees what a consumer sees, and nothing else.The job
One command per step, so a failure names what failed rather than arriving as an opaque script.
tscenumerates what it checks. An earlier revision walked the tree withfind, guarded the result withtest -s, and passed it throughxargs -0; review found three defects in that mechanism — a missing.d.cts, a quoting hazard, andxargssilently partitioning a large package into several separate programs.includehas none of them, and rootAGENTS.md§6 asks for exactly this: the tool that parses what it checks, rather than a pattern that approximates one.find,xargs,testandlsare all gone.The three patterns are one rule npm and TypeScript disagree about: npm's
**walks into a dot-prefixed name and TypeScript's does not, sofilespublishes.d/x.d.tsand a lone**leaves it unchecked — even when it is the package'stypesentry point. Naming a dot segment explicitly does match. Two dot segments in a row still escape; the generator's comment says so rather than implying completeness.Four properties decide whether it can fail at all
Each is asserted, because each has a silent failure mode:
tscenumerates the installed artifact, dot-prefixed paths includedfunctionalscript, a hard-coded path checks that dependency and reports green having never seen the artifactskipLibCheckstated asfalsetruesilently stops the checking — the job passes having opened nothingpackage.json, and only an exact pinThe last one is a gate, not a default: an exact
devDependencies.typescriptgenerates the job, and anything else — a range, a prerelease, no entry — generates no job at all.fjs/ci/README.mddocuments that contract.Verified by running it, not by reading it
I extracted the emitted steps verbatim from
ci.ymland ran them against real tarballs:package.jsonpin, exit 0;private.d.tsleaves 380 and still exits 0 — so the exclusion in step 5 is safe;./gone.jsreference in a packed declaration exits 2 withTS2307— and the same break passes silently underskipLibCheck: true, which is why it is stated;other-package, shipping a single.d.ctsunder a directory namedwe"ird dir: enumerated and checked, exit 0;typesis a broken.hidden/b.d.ts: exit 2. Before the dot patterns it exited 0, having installed that file and never opened it;TS18003, naming the pattern that found nothing;In real CI on this branch,
package-checkstarted five seconds afternode26uploaded the artifact, and the whole job ran in twelve seconds.The job is green today because nothing is excluded yet. Its value appears when the
filesnegation lands in step 5 — which is exactly why the guard comes first.Proof
Eight mutants: a checkout step, a dropped
needsedge, aneedsedge pointing at a job that does not produce the artifact,skipLibCheck true, a hard-coded package name, anincludethat does not reach intonode_modules, and either dot pattern removed.The proof supplies its own compiler pin rather than importing the generator's, so an assertion that finds it has found the value passed in. An earlier revision compared a constant with itself and held for any value.
Where the review went
Ten findings, most of them real defects. Per REVIEW.md, none of the answers is left in a thread:
tscenumerates rather than a shell walk (and why not to go back), why**rather than a list of extensions, why the dot patterns exist and what they still miss, whyskipLibCheckis stated, why there is no guard against a second.tgz, which external tools remain and why.fjs/ci/README.md: thepackage.jsoncontract, and thepackage/module in the file list, answering the first question a reader has — why this job has no checkout.fjs/ci/todo/package-check-unsupported-package-shapes.md: the declines and the one partial fix, each with what completing it would cost and the condition that makes it worth doing.Two existing assertions moved, both by design
jobNeedsasserted that no job ordered itself. That guard fired exactly when its first consumer arrived — it now pins one ordering edge, so a second stays a deliberate change.Neither was loosened to accommodate the diff; both now pin the new truth.
Checks
npx tscclean; 3479/3479; coverage 100%;npm run ci-updateround-trips. All 20 checks green on the head.Open for the repository owner
§6 sign-off. What remains is
npm,npxandtsc. My reading is that these are what §6 endorses —tscis the parser,npmis the thing under test — but §6 says approval first, so the thread is open rather than closed by me. It also gates the complete fix for the dot-in-dot gap, which needs something that reads the installed tree's real names.Next
Step 4 is making this a required check — a branch-protection setting, and not something I can do. Until then the job runs but does not block a merge, which is the point of it.
Changelog
changelog/unreleased/1767.md🤖 Generated with Claude Code
https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n