Skip to content

fix(validate): an empty package-lock.json is not a lockfile (#255) - #263

Merged
ZacxDev merged 3 commits into
mainfrom
fix/255-empty-lockfile
Aug 10, 2026
Merged

fix(validate): an empty package-lock.json is not a lockfile (#255)#263
ZacxDev merged 3 commits into
mainfrom
fix/255-empty-lockfile

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #255.

The defect

civitai app validate printed ✓ … is valid and exited 0 for a project whose committed package-lock.json was 0 bytes. The platform build failed anyway.

internal/validate/lockfile.go's regularFileExists was os.Lstat + Mode().IsRegular() and read nothing, so the check asked whether the file exists, not whether it is a lockfile. Measured on npm 11.17.0, npm ci over an empty package-lock.json dies with EUSAGE"can only install with an existing package-lock.json or npm-shrinkwrap.json with lockfileVersion >= 1" — the same class of failure as a missing one.

🔴 And the old message invited the input that defeated it. The missing-lockfile error names the filename the build wants, so touch package-lock.json reads as the fix, produces a green validate, and lands the author in the identical opaque server-side "build failed".

Reproduced with real binaries, base (origin/main 8ed4d69) vs HEAD, on the issue's exact shape:

lockfile body base HEAD
0 bytes ✓ … is valid rc=0 ✗ 1 validation error(s) rc=1
{} (npm refuses this identically) ✓ … is valid rc=0 ✗ 1 validation error(s) rc=1
real lockfile (positive control) rc=0 rc=0
missing (control) the existing message byte-identical

Pre-empting the obvious objection

lockfile.go's SCOPE comment says this is deliberately a presence check and not a freshness check. That stands and is not changed here — the check still never runs a package manager, and npm ci / --frozen-lockfile still catch a stale lockfile server-side. An empty file is not a freshness question: it is "not a lockfile at all". The scope that widened is "presence of the FILE" → "presence of a LOCKFILE", which is the thing the platform recipe actually requires.

The rule

Per-manager, and deliberately asymmetric — no new dependency:

  • npm (package-lock.json): parse as JSON and require a numeric lockfileVersion >= 1. That is npm's own precondition, mirrored the way the rest of this file mirrors the build recipe (AGENTS item 3).
  • pnpm (pnpm-lock.yaml) / yarn (yarn.lock): non-empty after a whitespace trim, and nothing more. pnpm-lock.yaml needs a YAML parser (a new third-party dependency — "ask first" under Permission boundaries) and a yarn v1 lockfile has no version key at all. "Not empty" is the whole of what can be said without inventing authority, and it is exactly the reported defect.

Three properties are load-bearing rather than incidental:

  1. The Lstat/IsRegular gate stays IN FRONT of the read. os.ReadFile follows symlinks, and pkgzip.Build drops non-regular entries from the bundle — reading through a link would vouch for bytes the submitted zip does not carry. TestLockfileSymlinkToAValidLockfileIsStillAbsent pins the order by pointing the link at a valid lockfile, so only a check that reads through it can pass.
  2. This is a FATAL check, so an UNOBSERVABLE state degrades to today's presence-only PASS, never to an error. A read failure or a file over the size cap means we did not look; manufacturing a hard error that blocks a submit out of a gap is the expensive direction (item 18's "reading nothing is not finding nothing", applied to a check that can block).
  3. Only the REQUIRED lockfile's content is judged. A foreign lockfile is evidence of which package manager the project really uses, and that reading does not depend on its bytes — judging it too would stack a second confusing finding on the real one.

Size cap: 64 MiB. Real lockfiles are kilobytes to a few megabytes (a large npm monorepo lock is single-digit MB), so 64 MiB is ~2 orders of magnitude above anything a package manager writes and cannot be reached by a genuine lockfile — while still bounding what validate pulls into memory for a file whose only job is to be checked for one key. This package has been here before: before the ready-ack scan grew caps, one 88 MB .js took peak RSS to 316 MB (item 18).

The {} decision, stated deliberately

{"…"} with no lockfileVersionfails. npm ci rejects {} with the same EUSAGE as an empty file, so accepting it would leave the headline defect half-open: echo '{}' > package-lock.json substituting for touch. Pinned with a row and a comment in TestLockfileNpmContentRule.

One bug the table caught during development

json.Number is type Number string, so unmarshalling the value straight into one accepts the JSON string "3". {"lockfileVersion": "3"} sailed through the first draft; the fix decodes into any with UseNumber and type-asserts. Documented at the site.

Message

The exists-but-invalid case gets its own message, and the tests assert it does not reuse the missing-lockfile wording:

package-lock.json is committed but it is EMPTY (0 bytes), so it is not a lockfile the
platform build can install from — it will run `npm ci`, which hard-fails on it exactly as
if nothing were committed. A lockfile is GENERATED by the package manager, never
hand-written and never created with `touch`: delete package-lock.json, run `npm install`,
and commit the package-lock.json it writes. `npm ci` requires a package-lock.json that
parses as JSON and declares a numeric "lockfileVersion" of 1 or more (npm's own words: …).

Field stays FieldProject per item 23 (repository state, not a manifest key), built through newFinding, and pinned by a new bidirectional ledger row in findingFieldLedger() — the sentinel collapse (project)(root) is a mutant that item 23 records as having survived a green suite once.

Mutation matrix

# mutation result
1 Revert the fix — never report a content defect (the pre-#255 predicate) 🔴 RED: 3 top-level + 12 leaf subtests. TestLockfileEmptyNpmLockfileIsFatal fails with "a committed package-lock.json that is not a lockfile must be a hard error, got a clean pass"; 8 rows of TestLockfileNpmContentRule, 4 of TestLockfileNonJSONManagersRejectOnlyEmptiness, plus the size-cap test's own control and TestEveryFindingCarriesItsDocumentedField (the ledger row goes stale)
2 Reject every lockfile (positive control) 🔴 RED: 8 top-level + 25 leaf subtests, including every accepts … row, TestLockfileRealNpmLockfileStillPasses, TestLockfileMatchingLockfilePasses (all 7), TestLockfileMultipleWithRequiredPresentIsWarning, TestLockfileContentOfAForeignLockfileIsNotJudged
3 Unobservable → hard error (drop the degrade) 🔴 RED, and only the two rows that own it: …OverTheSizeCapDegradesToPresenceOnly, …UnreadableDegradesToPresenceOnly
4 os.Lstatos.Stat (read through a symlink) 🔴 RED: TestLockfileSymlinkToAValidLockfileIsStillAbsent + the two pre-existing symlink guards

Mutants 1 and 2 are the required both-directions pair: neither battery is satisfiable by the other's fix. Mutant 3 is targeted enough to prove the degrade is its own contract rather than a side effect.

make ci

rc=0--- FAIL count 0, grep -c 'build failed' 0, test timed out panics 0, 18 packages ok. (Counted from the output, not read off the exit code.)

⚠️ Two files outside my assigned ownership

I own internal/validate/lockfile.go + tests under internal/validate/. Two test-fixture helpers in internal/cmd had to move or CI is red — flagging for conflict resolution:

  • internal/cmd/app_create_cmd_test.gosimulateInstall wrote {} as the lockfile
  • internal/cmd/app_validate_lockfile_test.goscaffoldWithLockfiles wrote {} for every lockfile name

Both are one-helper changes replacing {} with a body an install actually writes. They are not cosmetic: a {} fixture standing in for "the author ran npm install" was asserting that a build-breaking project validates clean, which is precisely how this bug stayed invisible. internal/cmd/cmd_test.go and internal/cmd/app_submit_lockfile_test.go go green through those two helpers with no edit of their own. I did not touch internal/cmd/app_init.go or README.md.

Proposed follow-ups (not done here)

  • A new AGENTS.md item was deliberately NOT added (six parallel agents would collide on the number). I edited item 3 instead, which is what changed. If a maintainer wants this as its own item, the content is the 🔴 block now inside item 3.
  • README.md (~line 691) and internal/cmd/app_validate.go's long help both describe the lockfile check as presence-only. Both are owned by other agents in this batch and are left alone; they want a sentence about the content rule.
  • The two scaffold README templates (page-vite, page-money) say "without a committed package-lock.json the build hard-fails" — still true, now also true of an empty one.

ZacxDev and others added 2 commits August 7, 2026 15:03
`civitai app validate` printed `✓ … is valid` and exited 0 for a project
whose committed `package-lock.json` was 0 bytes, and the platform build
failed anyway. `regularFileExists` was `os.Lstat` + `IsRegular` and read
nothing, so the check asked whether the file EXISTS, not whether it is a
lockfile. Measured on npm 11.17.0: `npm ci` over an empty package-lock.json
dies with EUSAGE — "can only install with an existing package-lock.json or
npm-shrinkwrap.json with lockfileVersion >= 1" — the same class of failure
as a missing one.

Worse, the missing-lockfile message names the filename, which makes
`touch package-lock.json` a natural and silently-wrong response: the check
invited the input that defeated it. So the exists-but-invalid case gets its
own message, which says the file is there, says what is wrong with it, and
says a lockfile is GENERATED rather than created by hand.

The content rule is PER-MANAGER and deliberately asymmetric:

  - npm (package-lock.json): parse as JSON and require a NUMERIC
    `lockfileVersion` >= 1 — npm's own precondition, mirrored the way the
    rest of this file mirrors the build recipe.
  - pnpm / yarn: non-empty after a whitespace trim, and nothing more.
    `pnpm-lock.yaml` needs a YAML parser (a new dependency, "ask first")
    and a yarn v1 `yarn.lock` carries no version key at all.

Three properties are load-bearing rather than incidental:

  - The `Lstat`/`IsRegular` gate stays IN FRONT of the read. `os.ReadFile`
    follows symlinks and `pkgzip.Build` drops non-regular entries from the
    bundle, so reading through a link would vouch for bytes the submitted
    zip does not carry.
  - This is a FATAL check, so an UNOBSERVABLE state (read error, or a file
    over the 64 MiB cap) degrades to the old presence-only PASS. Blocking a
    submit on a gap is the expensive direction.
  - Only the REQUIRED lockfile's content is judged; a foreign one is
    evidence of which package manager the project uses, and that reading
    does not depend on its bytes.

This does not change the SCOPE note: still not a freshness check, still
never runs a package manager. An empty file is not a freshness question —
it is "not a lockfile at all".

Fixtures that wrote the literal `{}` as a stand-in for "the author ran the
install" were wrong about the platform in the direction that hid this bug
(`npm ci` refuses `{}` identically), and now carry a body an install writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m claims

Audit follow-ups on #263. All npm behaviour below re-measured independently on
npm 11.17.0 (node v26.5.0), offline, against BOTH a zero-dependency project and
one with a real local-tarball dependency.

1. 🔴 A UTF-8 BOM was a hard-blocking FALSE POSITIVE. npm's parser tolerates
   one; Go's encoding/json does not. Measured: a real package-lock.json
   prefixed with EF BB BF installs cleanly (`npm ci` rc 0, node_modules
   populated) while validate reported "does not parse as a JSON object" and
   exited 1 — and because the finding is fatal it blocked `app submit` too
   (app_submit.go gates on res.OK()). Stripped before parsing. A file holding
   only a BOM still fails, which is what npm does.

2. The "npm ci refuses {} with the SAME EUSAGE as an empty file" claim was
   wrong, and it had been replicated into AGENTS.md plus four test comments.
   Measured, with a real dependency, npm splits into TWO failures:
     - empty / whitespace / bare BOM / YAML / garbage -> "can only install with
       an existing package-lock.json or npm-shrinkwrap.json with
       lockfileVersion >= 1"
     - {} / no version / array / string version / version 0 PARSE, clear that
       gate, and fail the sync check: "…are in sync… Missing: <pkg> from lock
       file"
   Both rc 1, so every verdict stands, but the message no longer says
   "exactly as if nothing were committed" and all six sites now state the
   measured story.

   Residual, now documented rather than unsaid: on a ZERO-dependency project
   `npm ci` SUCCEEDS (rc 0) over {}, a version-less object, an array, a string
   version and version 0. The CLI still refuses them — npm writes none of them,
   and accepting {} reopens the headline defect with `echo '{}' >` in place of
   `touch`.

3. The size cap was unpinned and its guard SKIPPED itself: raising
   maxLockfileBytes to 1<<62 left the suite green, because f.Truncate failed
   and t.Skipf read as a pass. The constant is now asserted directly and the
   sparse-file helper FAILS instead of skipping.

4. The cap is not the memory ceiling — it is ~2.2x the cap. Measured peak RSS,
   3 runs each, on REALISTIC lockfiles (real packages entries with resolved
   URLs and sha512 integrity hashes; a whitespace-padded fixture measures
   nothing): 66 MB / 232,595 entries costs 146.9-147.3 MB vs 17.9-18.0 MB at
   base; 10 MB costs 37.4-37.7 MB; a 73 MB file (over the cap) costs the same
   as base, which is what proves the cap is applied before the read.

5. README and `civitai app validate --help` described the check as
   presence-only; both now state the content rule and the unobservable
   fallback.

6. `num.Float64()` overflow reported "below 1", which is wrong if it ever
   fires; it gets its own clause. And the message no longer quotes npm's EUSAGE
   sentence naming npm-shrinkwrap.json, because this CLI does not recognise
   that filename — `npm ci` installs from a shrinkwrap (measured rc 0) while
   validate calls it missing. That gap is pre-existing and now recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ZacxDev

ZacxDev commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Audit follow-ups — all six addressed

All npm behaviour re-measured independently on npm 11.17.0 / node v26.5.0, offline, against both a zero-dependency project and one with a real (local-tarball) dependency. Delta for re-audit: 8286f39..6564fed.

# status evidence
🔴 1 BOM fixed confirmed then closed
🟡 2 EUSAGE claim fixed — 6 sites your measurement reproduced exactly
🟡 3 cap unpinned fixed reproduced the self-skip
🟡 4 memory comment fixed reproduced, 2.23×
🟡 5 README/help fixed now mine, #268 merged
🟢 6, 7 fixed one correction to your finding 7 (below)

🔴 1 — BOM

Confirmed both halves myself. npm ci over a real lockfile prefixed EF BB BF: rc 0, node_modules/tiny-dep populated, file verified starting efbbbf7b. The same bytes through civitai app validate: does not parse as a JSON object, rc 1 — and app_submit.go:74 gates on the same res.OK(), so submit was blocked too. Stripped with bytes.TrimPrefix after the read. A file holding only a BOM still fails, matching npm (measured: bare BOM → the lockfileVersion >= 1 EUSAGE).

🟡 2 — the false claim

Reproduced your split precisely. With a dependency: empty / whitespace / bare-BOM / YAML / garbage → lockfileVersion >= 1 EUSAGE; {} / no-version / array / string-version / version-0 → a different EUSAGE, …are in sync… Missing: tiny-dep@1.0.0 from lock file. Zero-dependency: those five succeed, rc 0. Corrected all six sites; the message no longer says "exactly as if nothing were committed". The zero-dependency case is now a documented residual (kept, because npm writes none of those shapes and accepting {} reopens the defect with echo '{}' > for touch).

🟡 4 — memory, re-measured on my own fixtures

3 runs each, realistic lockfiles (real packages entries, resolved URLs, sha512 integrity):

lockfile base 8ed4d69 HEAD
104 B 18.0–18.3 MB 17.7–17.9 MB
10 MB (35,242 entries) 18.1–18.2 MB 37.4–37.7 MB
66 MB (232,595, under cap) 17.9–18.0 MB 146.9–147.3 MB
73 MB (258,675, over cap) 17.9–18.2 MB 17.9–18.1 MB

2.23× the cap. Table + fixture shape now in the code comment and AGENTS.md.

🟢 7 — one correction to your finding

My first shrinkwrap measurement contradicted you (rc 1, nothing installed). That was my script's bug — I copied the project directory while package-lock.json still held the previous probe's garbage body, then renamed that to npm-shrinkwrap.json. Re-measured cleanly: you are right — shrinkwrap-only is rc 0, node_modules populated, and npm shrinkwrap is itself what produces the file. Resolved by removing the quoted npm sentence (it named npm-shrinkwrap.json while we call such a project lockfile-less); the gap is recorded as pre-existing, with a test asserting the message never quotes it.

Mutation matrix, fully re-measured (checksum-gated)

# mutation result
1 revert the fix 🔴 5 top-level + 16 leaf
2 reject every lockfile 🔴 12 top-level + 33 leaf
3 unobservable → hard error 🔴 2 top-level (only its own rows)
4 os.Lstatos.Stat 🔴 3 top-level
5 remove the BOM strip 🔴 1 + 3 leaf (all three BOM rows)
6 cap → 1<<62 🔴 1 — was green before this round
7 cap → 1 KiB 🔴 1
8 BOM-only misreported 🔴 1 + 1 leaf
9 accept a string lockfileVersion 🔴 1 + 1 leaf
10 judge foreign lockfiles too 🔴 1
11 strip BOM anywhere not as prefix SURVIVED — equivalent

M11 is a genuine equivalent mutant, not a coverage gap: these bytes feed only a decoder asked for one key, so a U+FEFF inside a string value cannot change whether lockfileVersion is a number ≥ 1. Recorded in the source rather than papered over with a test for an unobservable difference. (An earlier M8 spelling didn't compile — reported as an invalid mutant, then re-run.)

Gate

make ci rc=0--- FAIL 0, build failed 0, test timed out 0, 18 packages ok, ^FAIL 0. Counting both mattered: my first --help edit put a backtick inside a raw string literal, which surfaced as a parse error with rc=2 and zero --- FAIL lines.

golangci-lint at v2.12.2 (exact CI pin, via nix-shell), instrument validated first: an injected ineffectual assignment + misspelling produced 2 issues (ineffassign, misspell); the real whole-module run is rc=0, 0 issues.

End-to-end, fresh binary

EMPTY → rc 1 · whitespace → rc 1 · {} → rc 1 · real → rc 0 · BOM + realrc 0 · BOM only → rc 1 · BOM + {} → rc 1 · missing → rc 1 (unchanged message).

…ce claim

Delta-audit follow-ups. Claim 6 of the previous round was REFUTED; every npm
behaviour below was re-measured independently on npm 11.17.0 / node v26.5.0,
offline, against a project with a real local-tarball dependency.

F1 — "strip-anywhere ReplaceAll is an EQUIVALENT mutant" was WRONG, and
recording it was the more dangerous half of the mistake: a recorded measurement
in this repo gets trusted instead of re-derived, so the note would have waved
through exactly the simplification that breaks it. The retracted reasoning was
scoped to a BOM inside a string VALUE — the one position where the two agree.
Measured, strip-anywhere ACCEPTS two shapes npm refuses and the run-strip
rejects: a BOM in a structural slot ("lockfileVersion"<BOM>:) and one straight
after the opening brace (both npm rc 1). Fixtures added; the mutant now dies.

F2 — the one-BOM TrimPrefix introduced a NEW false positive. Measured, npm
tolerates exactly ONE OR TWO leading BOMs (rc 0, node_modules populated) and
rejects 3+. So a DOUBLE-BOM lockfile was reported "does not parse as a JSON
object" — a fatal finding that also blocks `app submit`, on a project that
builds: the same class as the bug the strip was added to fix, one BOM further
out. Now strips a leading RUN, which closes F2 and is immune to F1's interior
hazard by construction. Its one cost is a knowing false negative at 3+ BOMs,
pinned as a fixture so it stays a choice.

F3 — "a numeric lockfileVersion >= 1 (what `npm ci` itself requires)" is npm's
STATED precondition, not its measured behaviour. Measured, editing only that key
on a real in-sync lockfile: 0, "3", null, -5, 1e999 AND THE KEY REMOVED
ENTIRELY all install fine (rc 0; several print `npm warn old lockfile`). npm's
lockfileVersion >= 1 EUSAGE fires only when the file fails to LOAD. So the CLI
is stricter than npm on any project whose lockfile is otherwise intact — a third
population, not just dependency-free ones. The rule is kept (npm writes none of
those shapes) but is now described as OURS rather than as a mirror, in
lockfile.go, AGENTS.md item 3 and the README.

F4 — the "not a number this tool can read" clause had zero coverage and was
wrong for -1e999, which is provably below 1. strconv returns ±Inf on ERANGE, so
the error is now ignored and the value ordered directly: -1e999 is "below 1",
1e999 is accepted (matching npm, rc 0). Both directions covered.

F5 — the cap comparison was unpinned: `>` -> `>=` survived, because the tests
used 4096 and cap+1 but never exactly the cap. Added a boundary fixture.

F6 — restoring "exactly as if nothing were committed" reddened nothing, while
its sibling correction was pinned. Added to the forbidden-wording assertion.

Mutation matrix re-measured, 15/15 killed (was 14/15, M02 surviving).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ZacxDev

ZacxDev commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Delta re-audit round 2 — F1–F6 addressed, and you were right on all three

New HEAD 9e1a6d1; scope for re-audit 6564fed..9e1a6d1. All npm behaviour re-measured independently on npm 11.17.0 / node v26.5.0, offline, against a project with a real local-tarball dependency.

🟡 F1 — the equivalence claim is retracted. Fixed.

Reproduced your table. The retracted note reasoned over a BOM inside a string value — the one position the two strategies agree on, which is exactly how it reached the wrong conclusion. Measured, strip-anywhere accepts shapes npm refuses:

bytes npm ci run-strip (HEAD) ReplaceAll
BOM ×2 + real rc 0 accept accept
BOM ×3/×4 + real rc 1 accept* accept
"lockfileVersion"<BOM>: rc 1 REJECT accept
{<BOM>"name":… rc 1 REJECT accept
BOM inside a string value rc 0 accept accept

The two REJECT rows are now fixtures and M02 dies on them. Your point that recording the false equivalence was the damage mechanism is the part I've written into the code and AGENTS.md, not just the correction.

🟡 F2 — my fix introduced a double-BOM false positive. Fixed.

Confirmed: npm tolerates exactly one or two leading BOMs (rc 0, node_modules populated) and rejects 3+. TrimPrefix strips one, so a double-BOM lockfile got a fatal does not parse as a JSON object — blocking app submit on a project that builds. Now strips a leading run, which closes F2 and is immune to F1's interior hazard by construction.

* The 3+ row is a knowing false negative, pinned as a fixture with its rationale so it stays a choice: mirroring npm's limit of two means vendoring a magic number for a shape nobody has measured in the wild.

🟡 F3 — the overclaim is corrected, and it is wider than reported. Fixed.

Editing only the version key of a real in-sync lockfile, project with a dependency: 0 → rc 0 (npm warn old lockfile), "3" → rc 0, null → rc 0, -5 → rc 0, 1e999 → rc 0 — and the key removed entirely → rc 0, which your report didn't list. npm's lockfileVersion >= 1 EUSAGE fires only when the file fails to load. Corrected in lockfile.go, AGENTS.md item 3 and README: the rule is now described as ours, kept because npm never writes those shapes — not as a mirror. The residual block no longer implies the sync check is what rejects them.

🟢 F4–F6 — all taken

  • F4: the uncovered clause is deleted. strconv returns ±Inf on ERANGE, so the error is ignored and the value ordered directly — -1e999 is now correctly "below 1" (it is below 1) and 1e999 is accepted, matching npm (rc 0). Both directions covered.
  • F5: added a fixture at exactly maxLockfileBytes. >>= now dies.
  • F6: "exactly as if nothing were committed" added to the forbidden-wording assertion, alongside its already-pinned sibling.

Mutation matrix — 15/15 killed (was 14/15)

mutation result
revert the fix 🔴 5 top / 19 leaf
reject every lockfile 🔴 12 top / 40 leaf
unobservable → hard error 🔴 2 top
os.Lstatos.Stat 🔴 3 top
remove the BOM strip 🔴 1 / 5 leaf
M02 strip-anywhere 🔴 1 / 2 leaf — was SURVIVING
strip exactly one BOM (F2) 🔴 1 / 2 leaf
cap → 1<<62 / → 1 KiB 🔴 1 each
cap >>= (F5) 🔴 1 — was surviving
restore the false claim (F6) 🔴 3 / 19 — was surviving
accept string version 🔴 1 / 1
version floor >=1>=0 🔴 1 / 1
judge foreign lockfiles 🔴 1
npm's JSON rule for pnpm/yarn 🔴 3 / 11

Gates

Merged tree (origin/main fbb36df + branch — main had moved 6 commits, not 4): make ci rc=0, --- FAIL 0, build failed 0, timeout panics 0, 18 ok. A clean textual merge is not proof, so I read the two overlapping files (AGENTS.md, README.md) in the merged result — coherent, no duplicate item numbers, and the only surviving "what npm ci itself requires" strings are the two that quote it to retract it.

Branch: make ci rc=0, same counts. golangci-lint v2.12.2, instrument validated first (injected defect → 2 issues: ineffassign, misspell), real run 0 issues.

End-to-end, fresh binary, CLI verdict vs measured npm ci across 11 lockfile shapes: 10 agree, 1 diverges — the documented, pinned 3+-BOM false negative.

@ZacxDev
ZacxDev merged commit fe7c7e7 into main Aug 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app validate passes an empty package-lock.json — a green check for a build that cannot succeed

1 participant