mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
e50d122cb2e37fefceb2cdff8f99264a3d5d2502
56 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
233d0aa8be |
fix(nav): scroll the sub nav tab row, and keep the filters on its line (#4834)
The row is content-width and never collapses — `useResolvedNav` derives the bar/More split from the user's saved config, not the viewport — so it measures a fixed ~1334px signed in at every width from 1024 to 1400. `@md:overflow-visible` overrode both overflow axes above the `md` container breakpoint (1024px), removing the row's only escape: above 1024 it could not scroll, and an ancestor `overflow-hidden` clipped whatever exceeded the viewport. Measured at 1136px signed in, on `/`, `/models` and `/leaderboard/overall`: row right edge 1334, scrollable overflow 0, and neither Shop nor More hit-testable. `document.scrollWidth` equalled the viewport at every width, which is why a page-level overflow check finds nothing here. Dropping the override restores the scroll the row already relies on below 1024. `overflow-y` cannot be `visible` beside `overflow-x: auto` — it computes to `auto` — so the row clips on both axes, and an outline contributes no scrollable overflow. The row pays the focus ring's 4px of ink on the axis that scrolls, sized from the ink and carrying `var(--mantine-scale)` as Mantine's own rule does, rather than from a rem scale that only matches at a 16px root. The padding is horizontal ONLY, and that is a trade rather than an oversight. Padding all four sides also unclipped the ring top and bottom, including below 1024 where it is clipped today, but it made the bar taller on every page at every width. Justin saw the rendered result and declined it. Not "unchanged behaviour", though: the 36px More button used to set the row's height and leave a 32px pill 2px of slack, so half the ring showed. Shrinking More to pill height took that. The mechanism is unchanged; the amount is not. Second problem, same bar: on feed routes the filters and the settings gear wrapped to a second line, doubling the bar's height from 44px to 88px. `SubNav2` wraps, and a wrapping container places items at their flex BASIS before shrinking any of them, so at `basis: auto` the row's content width does not fit and the siblings wrap. `flex-1` gives it `basis: 0`: both share the line, and the row absorbs the shortfall by scrolling. `shrink-0` on the More button is the other half, because that shrink then lands on the children and More is the one that collapses, to an empty 28px circle. `min-w-0` is deliberately absent: `overflow-x: auto` already zeroes a flex item's automatic minimum size, and removing it changed nothing at any of eight widths. So is `lg:flex-nowrap`, measured the same way. Measured on /images, sub nav height, against a control built by reverting only these classes on the same dev server: 88px to 44px at 1440/1280/1184/1136/1024/900, and byte-identical child geometry at 768/640/390. The band is route-dependent and those figures are one route: 40px on /models where every control is `h-8`, 44px on /images where one filter control is 36px, and 36px on /comics, where `FilterButton`'s `compact-sm` takes the `h-9` branch. The rest is one size for the whole row, all of it measured rather than eyeballed: `SubNav2` top-aligns its children, because on platforms that draw classic scrollbars the scroller is taller than its pills and centring put the filters half a scrollbar low; the More button is 32px at 14px/600, matching the pills, where it was 36px at 16px/500; and the settings gear is 32px, circular, with a 16px icon in `--mantine-color-bright`. The gear's icon was never smaller than its neighbours — every icon in the bar is 16x16 with a 2px stroke — it was rgb(222,226,230) against their rgb(254,254,254), and at a matched box that reads as smaller rather than dimmer. `bright` is the same #222/#fefefe pair the pills use, so the gear matches the pills in both schemes; against the globe specifically it is exact in dark and slightly darker in light, where the globe is `text-gray-8`. The scrollbar is deliberately left visible: it is the only thing telling anyone the row scrolls, and "it doesn't look like it scrolls" was the original report. Guarded at two tiers, and every guard was mutated: Shell's provider deleted RED expected false to be true scale-95 planted (whitelist) RED to deeply equal [...7 items] relative on the row RED expected false to be true flex-1 removed RED expected 32 to be +0 shrink-0 removed from More RED expected 28 to be close to 81.796875 padding to px-0 RED expected -4 to be >= 0 @md:overflow-visible back RED expected 'visible' to be 'auto' The hit-test is the one that needed building twice. The trap it guards — a `position: sticky` or `relative` ancestor becoming the unportalled dropdown's containing block — produces a dropdown whose rect is IDENTICAL to a working one while its items stop being hit-testable, so a rect assertion and a `textContent` assertion both pass against the broken state. The first version of that test still passed with `relative` planted, because the geometry harness mounts a bare `MantineProvider` and never receives `ThemeProvider`'s `Popover.withinPortal: false`: the menu portalled in the test while rendering inline in the app. It now nests a provider carrying that default. The source gate strips comments before matching, so deleting the live row and leaving a commented-out copy fails loudly instead of passing silently, and it rejects positioning tokens on the row so the prohibition above is checked rather than merely written down. `.moreButton`'s height moved from an explicit `32px` to `h-8`, beside the pills' own. Measured either side on the same route and browser rather than argued from the cascade, because that cascade misled two reviewers on this file today: 32px / 16px / 10px / 14px / 600 in both states, identical on every axis. A second tidy-up — dropping `variant="subtle"`, which `LegacyActionIcon` and `ThemeProvider` each already set — is deliberately NOT here. The gear does not render for the probe session, so it could not be measured, and an unverifiable change whose only benefit is tidiness is not worth carrying. `.moreButton` no longer states a font either: measured with the declarations removed, the button still renders 14px/600, because that is what a Mantine `size="sm"` Button already produces. Two lines of framework default, deleted. The More button's height is now pinned in the geometry tier against a PILL's rather than a literal — deleting `h-8` reddens with `expected 36 to be 32`, where before it passed both tiers green while growing the bar 4px site-wide. The More button's height and typography are pinned in the geometry tier against a PILL's and against the literal 32, because parity alone passes when both move: `h-8` to `h-9` on both controls leaves them equal at Mantine's 36px and grows the bar 4px on every page. Mutants: 36-vs-32 either side, and 20px-vs-14px on the font. The pill's `text-base font-medium` does NOT render. `globals.css`'s unlayered `.mantine-Button-label *` sets both to `inherit` and sits after `@tailwind utilities`, so the span takes Mantine's `size="sm"` 14px/600. Three reviewers read those classes as 16px/500 in one day, so it is written beside them. The `geometry` project is `continue-on-error` on pull requests, so everything it measures informs rather than gates. The height claim is therefore asserted in the `unit` tier too — a 0.3s check that the More button's `clsx` still carries `h-8`. Paired control: removing it gives `expected [ 'shrink-0' ] to include 'h-8'`, reordering the class list stays green. The font and geometry claims stay advisory and the PR body says so. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0b92ab649c |
docs: drop package and component counts that had gone stale
`@civitai/ui` gaining a vitest config took the packages/* suites to 13, and the primitives under components/ui/ are at 54 — while the prose still said nine and 24. The counts are removed rather than corrected: CI's ledger script already asserts every workspace suite ran, so a number in prose only rots, and both of these had rotted twice. The schema-drift README also said apps/* had no CI job and that the ledger script hardcoded `packages/`. Both stopped being true when the App unit tests + typecheck job landed; it takes the workspace as an argument. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e92cf5fe4a |
test(geometry): a browser tier that loads the real cascade at a phone viewport (#4601)
* test(geometry): a browser tier that loads the real cascade at a phone viewport
Adds a fourth Vitest project, `geometry`, and demonstrates it catching a defect
whose own source comment records that nothing rendered can see it.
WHAT THE GAP IS, AND WHAT IT IS NOT. The `component` project is NOT jsdom — it is
real headless Chromium via @vitest/browser-playwright, `page.viewport()` moves
`window.innerWidth`, and `getBoundingClientRect()` returns real boxes. What it is
missing is the STYLESHEET and the VIEWPORT. `test/component-setup.tsx` injects
only the `:root` custom properties parsed out of globals.css, so the document
holds 24 CSS rules: Mantine classes are styleless, Tailwind utilities are inert,
and any `getComputedStyle` assertion whose expected value is the CSS initial
value passes against a broken component. And nothing sets a viewport, so files
inherit the runner's silent 414x896.
THE SAME FIXTURE, THE SAME CORRECT SOURCE, IN BOTH TIERS (PageBlockHost in its
production shell chain):
`component` `geometry`
viewport 414 x 896 390 x 844 (default vs set)
CSS rules in the document 24 3,677
box-sizing on a bare div content-box border-box
`className="flex"` block flex
chrome bar height 200 31
host frame height 350 844
APP COLUMN HEIGHT 150 813
That last row is the argument. 150 is ALSO what the app column measures once the
recorded `flex: 1` defect is planted, so a threshold written in the `component`
tier would have to expect the number the DEFECT produces.
THE HARNESS. `test/geometry-setup.tsx` loads the production cascade in production
order — the `@layer tailwind-preflight, theme, mantine, modules;` statement first
(as _document.tsx emits it), then globals.css, then every `@mantine/*` layer
stylesheet _app.tsx imports. It defaults to a 390x844 phone and THROWS unless the
window reports back the size it asked for; tests assert `observed` against their
own literal on top of that. It exports measurement helpers: `box`,
`childrenUnionBox` (the union of child rects — `scrollHeight` is clamped to the
padding box and cannot see a parent taller than its content), `flexAxis`,
`flexLonghands` (longhands, because `getComputedStyle(el).flex` serialises
`1 1 220px` and `1 1 0%` identically), and `cascadeEvidence`.
WHY A PROJECT AND NOT A CHANGE TO THE SHARED SETUP. Loading the cascade in
`component-setup.tsx` moves existing numbers — measured, the same chrome bar is
200px there and 31px with the cascade, a 169px move on one element, under 212
files / 2,362 tests of which 14 read getBoundingClientRect and 20 read
getComputedStyle. The per-file import pattern (15 files do it today, 3 also take
globals.css) stays available and is not deprecated; what it cannot give is a
guarantee — those files each picked their own subset, none declares the @layer
order, none sets a viewport, and the "did my stylesheet load" guard is
re-hand-rolled per file. The `geometry` glob (`src/**/*.geometry.test.tsx`) is
disjoint from every other project's, so nothing that runs today changes project
and no file is collected twice.
DEMONSTRATED RED. `PageBlockHostFillHeight.geometry.test.tsx` asserts that the
app column reaches the bottom of the host frame at 390x844 and at 390x640.
Dropping `flex: 1` from `app-page-content` (the mutation PageBlockHost.tsx's own
comment records as invisible to every rendered tier) fails it:
the app column ends at y=181 inside a frame that ends at y=844 — 663px of the
phone is blank below a running App Block. The column measured 150px of the
frame's 844px, with flex longhands {"grow":"0","shrink":"1","basis":"auto"}.
Restoring the property returns it to 10/10. A second mutant — dropping `flex: 1`
from the frame's `fit === 'fill'` branch — collapses the column to 269px and is
caught by the literal floor rather than by the frame/content comparison, which
both mutations keep satisfied.
BOTH MUTANTS ARE ALSO CAUGHT IN THE NODE TIER TODAY, by verbatim source pins in
pageBlockHostMaxWidth.test.ts and pageRunScrollContract.test.ts. Stated plainly
so this is not read as claiming otherwise. The difference is what each guard can
SEE: a source pin is a claim about the text of one file, blind to a collapse
arriving from the cascade, from an ancestor or from a viewport, and it has to be
rewritten every time the block is legitimately reformatted.
WHAT RUNS THIS TODAY: NOTHING. lint.yml selects `--project 'unit*'`,
`'@civitai/*'` and `'app:*'`; no pattern matches `component` or `geometry`,
because the Actions runners install no Chromium. `component` has the preview
pipeline's report-only status; `geometry` has no CI home at all. Wiring one is a
pipeline change and deliberately not in this PR — but a harness nothing runs
rots, so it is said out loud in the setup file rather than left to be discovered.
Verification: typecheck 0 errors · geometry 2 files / 10 tests passed · component
212 files / 2,362 tests passed (unchanged) · scripts unit tier 24 files / 521
tests passed · eslint clean on both new test files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(geometry): run the geometry tier, and refuse a green that collected nothing
Adds a `Geometry tests` job to lint.yml. Without it this harness runs in no gate
at all — the workflow selects projects by name (`unit*`, `@civitai/*`, `app:*`)
and none of those patterns matches `geometry`.
ALIGNED WITH THE `unit` JOB'S COMMENT, NOT AN OVERRIDE OF IT. That comment gives
exactly one reason for keeping browser tests out — "they need Chromium, which
this job does not install. That is the whole reason." — which is a statement
about what that job provides, not a ban on providing it. The same comment then
retracts the only other objection on the record ("Don't cite a cold-cache flake
as a reason to keep this job Chromium-free") and names vitest.config.mts's
dedupe + optimizeDeps pre-bundling as the canonical fix. A job that DOES install
Chromium satisfies the stated condition.
GEOMETRY ONLY. `component` stays ungated and that is now visible rather than
fixed. Measured on a 16-core box: `geometry` is 2 files / 10 tests in 9.07s;
`component` is 212 files / 2,362 tests in 112.92s wall, of which 334s is test
time spread across workers — so a 2-core runner (browser pool `min(12, cpus-1)`
= 1 instance) does not divide it. That is an order of magnitude more expensive,
with 212 files of pass/fail history this workflow has never seen. It belongs in
its own PR.
REPORT-ONLY, mirroring `unit` and for its stated reason: blocking a brand-new
tier from day one would red unrelated PRs and the job would be switched off
within a week. `main` has no required_status_checks, so nothing here blocks a
merge either way; `continue-on-error` only decides whether a red renders as red
or as red-but-ignored. The FLIP TO BLOCKING note says concretely what would make
that safe. The `unit` job's selectors and its `continue-on-error` are untouched.
CHROMIUM comes from `pnpm exec playwright install --with-deps chromium` — the
workspace-local playwright, so the revision follows this repo's own pin rather
than a second version written into the workflow. Desynchronising those two is
the documented failure mode (CLAUDE.md records 59 preview specs dying on a
revision mismatch with zero specs run). A local NixOS bundle mismatch is a
property of that host and is handled by PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; the
pin is not moved to accommodate it.
A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the hazard the `packages` job's
ledger exists for, one project over. `--project` matching nothing exits 0, and
this tier's glob is deliberately narrow, which is exactly the kind of pattern
that can quietly stop matching. The new step asserts floors of 2 files and 10
tests from the JSON report, with `if: always()` so it also fires when the tests
fail or the runner aborts without writing a report.
Two things measured rather than assumed while writing it: the file count comes
from `testResults.length`, NOT `numTotalTestSuites` — against a real report this
run is 2 files while that field reads 4, because it counts `describe` blocks.
And the gate script was extracted back out of the parsed YAML and executed on
all three arms before commit: real report -> exit 0 (2 files, 10 tests); a
report with `testResults: []` -> exit 1; a missing report -> exit 1 with its own
message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): the geometry job must be report-only on PRs ONLY, not on pushes to main
Caught by this repo's own gate on the previous commit's run:
`scripts/__tests__/main-branch-ci-coverage.test.ts` > "has no unconditionally
report-only job on the push path" went red (Unit tests shard 2, 1 failed / 5,624
passed).
The job was written with a literal `continue-on-error: true`. That is report-only
on EVERY event, including a push to `main`, where it produces a run reporting
`success` while the step underneath failed — the stale-TRUE shape that guard
exists to forbid, and the exact hazard the `unit` job's own comment spells out:
"A green that has to be disbelieved is worse than no run at all."
Now `${{ github.event_name == 'pull_request' }}`, byte-identical to `unit`'s.
Report-only on PRs (a new tier should not red unrelated work while it settles),
honest verdict on `main` (where the merge has already happened and there is no
unrelated work to protect). The guard accepts a conditional precisely because a
conditional can differ between a PR and a push; a literal cannot.
The comment now records this so the next reader does not "simplify" it back.
Red at
|
||
|
|
20e5c21c07 |
ci(cache): reclaim 9.65 GB of the cache budget and cache the vitest transform cache (#4415)
The repo's Actions cache holds 21 entries / 10,878,231,374 bytes against a
10 GB per-repo limit, so LRU eviction is live and is throwing away useful
entries.
There are exactly two cache keys, same content hash, differing only by OS:
node-cache-Linux-x64-pnpm-428a04cc... 1 copy, on refs/heads/main, 511 MB
node-cache-Windows-x64-pnpm-428a04cc... 20 copies, each on a different
refs/pull/N/merge, ~518 MB each
= 9.65 GB, 95.3% of the budget
Mechanism: an Actions cache entry is scoped to a ref. A PR run can read an
entry saved on its base branch, but not one saved by another PR, and it saves
into its own refs/pull/N/merge scope. windows-dev-env.yml triggers on
pull_request only, so there was no entry on refs/heads/main for a PR to
restore from: every PR missed, and every PR then saved its own ~518 MB copy
under the same key in its own dead scope. lint.yml does not have this problem
precisely because it has a push-to-main leg.
Two changes:
1. windows-dev-env.yml gains push: branches: [main]. One base-branch entry
then serves every PR and no PR saves its own. Two side effects the guard
test in scripts/__tests__/main-branch-ci-coverage.test.ts pins, both fixed
here: the concurrency group must vary per commit on main (it was
github.ref, one group for the whole branch), and a job on the push path
may not be unconditionally continue-on-error (a run reporting success over
a failed step). The canary stays report-only on PRs, which is what its
header actually argues for, and renders honestly on main. No step in that
workflow reads github.base_ref or github.event.pull_request, so nothing
needed gating.
2. lint.yml's unit job caches node_modules/.vite. Measured on a warm local
checkout: 302 MB, all of it esbuild pre-bundles under
.vite/vitest/<hash>/{deps,deps_ssr}. All four shards rebuild it cold on
every PR today. Restore always, save only on main, so this can never
repeat the per-PR-scope leak above: four entries total, 1.21 GB worst
case, restorable by every PR from its base branch.
Verified: actionlint clean on both files (and red on a deliberately broken
copy, as a negative control); both parse as YAML; the main-branch CI coverage
guard is 6/6 green, and goes red on exactly the two assertions above when the
concurrency group and continue-on-error are reverted. The unit, packages and
apps jobs keep no job-level if:, so they still run on every PR to main and
release.
The 20 orphaned Windows entries are being deleted separately by the operator;
nothing here reaps them.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
afe84cf7c8 |
ci: shard the unit suite across 4 runners, 10.4m -> 3.8m (#4392)
Measured from the Actions API (n=12): the job was 555.5s of test work plus 66.5s of fixed overhead = 622s, the 10.4m median, and 3.4x the next-slowest job. T(N) = 66.5 + 555.5/N puts N=4 at ~3.4m. Measured after: 10.3m -> 3.8m on the critical path (2.7x) at +36% runner-minutes, reproduced across two runs with a 1.12-1.14x shard spread. N=4 rather than more because `App unit tests + typecheck` is 2.9m — past N=5 sharding optimises something that is no longer the bottleneck. Ships with a positive control, `scripts/ci/assert-shard-ran.mjs`. Sharding adds a failure this job did not have: a `--shard=i/N` selecting no files exits 0 and reports green, and four green checks look identical whether they ran 22,000 tests or none. The guard asserts per shard that work happened, counting EXECUTED assertions rather than `numTotalTests` (which includes skipped, so an all-self-skipping shard would pass a total-based floor), and its bounds scale with `total` so a change to the matrix does not trip them. It earned its keep immediately: on the first run it failed all four shards while `Unit tests` reported SUCCESS. `pnpm run … -- --shard=…` forwards the `--` literally, vitest DISCARDS everything after it, and so `--shard` and `--outputFile` were both silently dropped — each runner executed the entire suite (7m30s-9m54s per step) and wrote no report. Without the control this would have merged as four green checks that tested nothing verifiable. A subsequent adversarial audit found three further claims that measurement did not support, all fixed here: bounds hardcoded off a ten-day-stale suite size that would have tripped within weeks and blamed the wrong cause; a comment asserting post-`--` args become filename filters when they are discarded; and a "mutation-verified" claim that five of twelve mutants walked through. Four are now covered by named cases; the fifth was unreachable dead code and is removed rather than papered over with a test that could not kill it. Final sweep, re-run after the Prettier pass: 10 mutants, 10 killed, each by its own named test, with a no-op negative control that correctly survived. Suites 20/20. Verified live across two runs; the typecheck-scripts gate was confirmed to have executed rather than assumed green. Not verified: behaviour on a push to `main`, where `continue-on-error` is false and a red shard renders as a genuine failure. That path only exercises on merge. |
||
|
|
5770b83f4e |
ci: give a commit on main a CI verdict again, and pin that it keeps one (#4234)
* ci: give a commit on `main` a CI verdict again, and pin that it keeps one Nothing has produced a CI verdict for a commit on `main` since 2026-06-14. The cause was not a decision: `.github/workflows/pr-check.yml` was the only workflow this repo has ever had with `push: branches: [main]`, and #2547 deleted it while moving PR checks to Tekton. Every workflow written since — lint (#3362), submodule-pin-guard, schema-drift (#3643), windows-dev-env (#4162) — is `pull_request`-only, so the main-push trigger was never rebuilt and nothing said it was gone. Tekton does not close the gap. Its `pr-check` pipeline is driven by a resource that watches open pull requests targeting `main`; the only main-branch trigger builds a container image for the staging deployment, runs no test suite and posts no commit status. "Tekton covers main" is the assumption that let this persist for two months. The old June runs stayed `success`, so "is main green?" went on answering yes. That is the expensive half: the signal was not wrong, it was stale, and staleness is invisible unless you read the dates. |
||
|
|
6e61e1821c |
test(preview): fail a stranded /moderator probe in the local suite, not only in a job nobody reads
#3573 migrated the moderator surfaces to the standalone app and four preview specs kept probing paths that now 302 off-origin. `preview / smoke-tests` went red and STAYED red on every PR based after it, and because that job is report-only nothing stopped: three PRs merged through it in the four hours before someone looked, and an agent nearly attributed a genuine `main` breakage to their own PR because it arrived inside an already-red set. #4179 fixed the four failures. This is the recurrence guard. It scans the preview specs for `/moderator/*` literals and fails when one has migrated or resolves to no page, so the next migration reddens the machine of whoever performs it — `test:unit:run` is on the before-committing list — as one named test carrying the path and the fix, rather than as browser assertions in a job whose red is ambient. Be precise about what that buys, because the obvious reading is wrong: it does NOT make the fact blocking. The `unit` job is `continue-on-error: true` and `main` has no required status checks, so this is report-only too. What changes is where and how the failure appears. The three specs that assert the redirect on purpose carry an inline `@migrated-route-probe` marker, checked in both directions — a path that comes BACK to this app strands the assertion the same way. A marked line may hold only one probe, or the marker would excuse the others silently. Two properties the scan needs and did not get for free: the positive control asserts the ENFORCED partition rather than the total, because `it.each([])` registers zero tests and exits 0 (measured), so marking every line would empty the guard with nothing to show for it; and the capture stops before `?`, since `/moderator/reports?status=Pending` is the natural shape of a queue probe and the exact route the incident was about. Documentation this turned up as stale: the convention-guard list named four of seven, `test:lint- rules` is invoked by no workflow (those guards run because they match the `unit` project), the root test-command list omitted the packages and apps suites entirely, and the SvelteKit standard had no testing section at all despite all three apps having one. The `unit` job's flip-to-blocking note now carries what was measured today rather than leaving it to be rediscovered: 8 of 39 recent runs had a red `Unit tests` STEP across 8 distinct branches, which reads as flake but was trunk-red from a ledger test failing on every PR (fixed by #4191); 5 of 5 green after it. Plus the two traps — the run-level `conclusion` says success while the step under it failed, and this workflow is `pull_request`-only, so a trunk-red test shows up as every PR reddening at once. Refs 868kubuz6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b8f84204d7 |
fix(auth): fix the two type errors gating apps/auth, and bring it into the CI typecheck gate (#4188)
apps/auth was the last app excluded from `scripts/ci/typecheck-apps.mjs` (#4156), on two pre-existing errors. Both are fixed at the root, not silenced, and the exclusion is gone — the gate now covers all 7 apps. providers.ts:165 — Parameter 'p' implicitly has an 'any' type The stub entry's key is a COMPUTED property (`['stub' as ProviderId]`) whose type is the whole ProviderId union rather than one literal, so TS cannot match it to a member of `Record<ProviderId, ProviderDef>` and drops contextual typing for the value. `satisfies ProviderDef` restores it. Measured, both arms: with `satisfies`, misspelling a required field (`scope` -> `scopes`) is caught; with it removed, the same misspelling is silently accepted and only the implicit-any resurfaces. So the whole entry was unchecked against ProviderDef, not just that one parameter. establish-session.test.ts:100 — Property '_store' does not exist on type 'never' The Cookies stub was cast `as never` to satisfy establishSession's parameter, but `never` has no properties, so reading `_store` back in an assertion was itself an error. Cookies has exactly five members, so the stub now implements all of them and needs no cast at all. Removing the exclusion breaks the guard suite, which is the real work here: six of its eight cases put an `auth` app in every fixture purely to satisfy the stale-exclusion guard, so an empty map made them fail for reasons unrelated to what they test. Rather than delete those guards, the exclusion map is now a parameter of an exported `runTypecheckApps({ excluded })`, and the tests drive it with a synthetic app. Deliberately a function parameter and not an env var: an env-var override would be a live way to un-gate an app in CI, which is the failure class the script exists to close. All ten cases now pass regardless of what the shipped map contains. Also neutralises the "six apps" counts in the script header and workflow comment, which this change would otherwise make wrong, and applies prettier to two pre-existing unformatted lines in the touched files. |
||
|
|
23cecb57c0 |
ci: run per-app typecheck scripts in CI (#4156)
* ci: run per-app typecheck scripts in CI
The root tsconfig.json include is:
scripts/local-dev/*.ts src packages/*/src tests .next/types/**/*.ts
No apps/ entry, so `pnpm run typecheck` does not reach any sibling app.
Each app defines its own typecheck script but none were run by any CI job.
Six of seven apps pass today; wire them up as steps in the existing `apps`
(App unit tests) job, which already has the workspace installed.
app script result
event-engine tsc --noEmit -p tsconfig... clean
notifications tsc --noEmit clean
orchestrator-gateway tsc --noEmit clean
storage tsc --noEmit clean
creator-studio svelte-check --tsconfig ./... clean
moderator svelte-check --tsconfig ./... clean
auth svelte-check --tsconfig ./... 2 errors + 1 warning
apps/auth is excluded from the CI wiring. Its pre-existing errors:
src/lib/server/auth/providers.ts:165
Parameter 'p' implicitly has an 'any' type.
src/lib/server/auth/__tests__/establish-session.test.ts:100
Property '_store' does not exist on type 'nev
src/routes/login/+page.svelte:28
state_referenced_locally (warning)
Ticket: 868kt8pfu
* ci: make the app typecheck prove it ran, and report every failing app
Review follow-up on the six `pnpm --filter <pkg> run typecheck` steps. Two ways they could
report SUCCESS while checking nothing or hiding work, plus a blame problem.
🔴 `pnpm --filter <name> run <script>` EXITS 0 WHEN THE FILTER MATCHES NOTHING. Measured on
pnpm 10.28.1: a bogus package name prints "No projects matched the filters" and returns 0.
Six hardcoded package names are six chances for a rename to turn a gate into a green no-op
— the same shape as `prettier --check "$FILES"` reporting "All matched files use Prettier
code style!" across zero files. Nothing in the six steps could notice.
🔴 A NEW app under `apps/` is simply absent from a hardcoded list. The gate stays green and
the app is unchecked — this PR's own hole, reopened by the next person to add an app.
So the app set is now a LEDGER READ FROM DISK: every `apps/*` with a `typecheck` script must
run, each run must be proven to have selected a real package, and `auth`'s exemption is an
explicit entry that hard-errors if it goes stale. Adding an app wires it automatically. This
mirrors `scripts/ci/assert-workspace-suites-ran.mjs`, which exists for the same reason on
the vitest side.
Failures are COLLECTED rather than fatal on the first. Six sequential steps abort the job at
the first red app, so a shared type change breaking four of them reports one.
The job is renamed `App unit tests` -> `App unit tests + typecheck`. The typecheck steps were
added under the old name, so a type error would have rendered in the checks list as a failing
unit-test job, pointing the reader at the wrong suite. Safe: neither `main` nor `release` has
any required_status_checks — re-verified 2026-08-20 against the branch-protection API rather
than inherited from the note already in this file.
Kept in the `apps` job on purpose: `pnpm install` is the expensive part and this job has
already paid for it. A matrix would give parallel legs and per-app check names for the price
of six installs; the rename plus collected failures buys most of the legibility for free.
Eight guard tests in `scripts/__tests__/typecheck-apps.test.ts`, each driven against a stub
apps/ tree with a fake `pnpm` on PATH so the no-match and failure paths are exercised for
real: happy path, stale filter caught, red app, failures collected not masked, empty
discovery refused, new app auto-wired, stale exclusion hard-errors, auth excluded while the
rest still run. All 8 pass; the first draft had six failing for the wrong reason (fixtures
omitted `auth`, so the stale-exclusion guard fired first) — fixtures fixed, not the guard.
Two things deliberately NOT changed:
- The Svelte apps' bare `typecheck` script is fine as-is. Their tsconfig extends
`./.svelte-kit/tsconfig.json`, absent from a fresh checkout, which looks like a vacuous
green waiting to happen. It is not: `"prepare": "svelte-kit sync"` runs during
`pnpm install`. And if it ever stops, svelte-check FAILS LOUDLY rather than passing —
verified by moving `.svelte-kit` aside, which gave exit 1, "Cannot read file
.svelte-kit/tsconfig.json", `1 FILES 1 ERRORS`. An earlier review comment of mine claimed
it would go silently vacuous; that was wrong, and switching to `check` is unnecessary.
- `scripts/__tests__/` is outside every typecheck program (root `include` carries only
`scripts/local-dev/*.ts`), so this new test file is unchecked — as are the 12 already
there. Not introduced here; it is the `scripts/` half of this ticket's widening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style: prettier the two new files
The `Prettier (added files)` step is BLOCKING by design — a new file has no pre-existing
findings, so holding it to the rules is free. Both new files failed it and I did not run the
formatter before pushing. Reproduced locally with `prettier --list-different` (both listed),
fixed with `--write`, and re-ran the 8 guard tests after the reformat: still 8/8, so the
formatting did not disturb the fixture strings or the fake-pnpm heredocs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: DevPod Agent <agent@devpod.local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3d828eaadd |
ci: validate the default Windows contributor setup, non-blocking (#4162)
Every job in this repo runs on ubuntu-latest, so nothing validates Windows — while Windows support is real and maintained: defender-exclusions.ps1, win32 branches in test-unit-run.mjs / bench.mjs / console.mjs / worktree.mjs, and it.skipIf(win32) guards in scripts/__tests__. A supported platform verified by nothing is how a contributor's first hour goes to a break nobody else can reproduce. The specific exposure this targets: `pnpm install` runs `preinstall` (npx only-allow pnpm) and `postinstall` (pnpm run db:generate). The Makefile carried `# TODO fix postinstall on git bash` against that path — and the target it sat on was DEAD for everyone, because `npm i` exits 1 under only-allow. So the hook path has gone a long time unexercised on Windows. Un-breaking the target (4107) means the next Windows contributor is the first to walk it in a while. This walks it first. Both shells on purpose. pwsh is the Windows default; bash is Git Bash, and the TODO was Git-Bash-specific. A pwsh-only job would report green over the single case we have written evidence about. Non-blocking (continue-on-error) to start. A gate that lands red on day one and stops unrelated PRs trains everyone to click through, which is worse than no gate. Flip it once it has been green long enough to mean something. Does NOT start the compose stack: those are Linux containers and Docker on Windows runners is slow and flaky — noise, not signal. Everything up to `pnpm dev` is covered; the services half is Linux-identical and already exercised by the ubuntu jobs. Costs nothing: windows-latest is a standard GitHub-hosted runner, free for public repositories. The install step carries a positive control, because `pnpm install` exiting 0 is not evidence the postinstall hook ran — a hook that silently no-ops also exits 0, and the missing client then surfaces much later as a confusing runtime error. The job asserts packages/civitai-db-schema/prisma/schema.prisma exists afterwards. That file is a genuine control precisely because it is GITIGNORED: it cannot arrive from the checkout, so its presence proves the hook executed on this runner. The guard was tested against its own failure modes before being trusted, rather than assumed to work: artifact absent -> rc=1, "postinstall did not run" artifact 3 lines -> rc=1, "slim schema looks truncated" artifact 200 lines -> rc=0 Each failure fires for its OWN reason with a distinct title, so a red run names which thing broke. The 50-line floor sits against a source schema of 8,087 lines — two orders of magnitude of headroom, so ordinary churn cannot trip it while a stub still fails. Also restores the git-bash TODO to the Makefile rather than leaving it deleted. Nothing has verified it either way; dropping the note would have retired the only written record of a known hazard on a path that had stopped being walked. It now points at this workflow as the thing that will answer it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7562c2633a |
fix(event-engine): make lint and test actually run (#4031)
* fix(event-engine): make lint and test actually run Both scripts existed and both were broken. `lint` had no eslint config of its own, so it cascaded to the repo-root `eslint-config-next` config and crashed in `consistent-type-imports` (TypeError, exit 2) without linting a file. Adds a package-local `.eslintrc.js` with `root: true`, mirroring the one already in `src/common`, and clears the 26 `no-unused-vars` errors it surfaces. The 202 `no-explicit-any` warnings are left as warnings. `test` ran `jest` with no jest config and no TS transform, so the one test file failed to parse: 1 suite failed, 0 tests. That file is a `node:test` suite; it is now a vitest suite, and the package gets a `vitest.config.ts` named `app:event-engine`. That name is what makes the root config's `apps/*/vitest.config.ts` glob and `test:apps:run` pick it up, so CI runs it — `assert-workspace-suites-ran.mjs` now ledgers 6 apps/ members instead of 5. Two WIP stub handlers (`image-scanned`, `outbox/image-scan`) keep their unused bindings behind a file-scoped disable rather than being renamed: their bodies are commented out pending the old ingestion service's removal and reference exactly those names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(event-engine): resolve `@/` in vitest, narrow the WIP lint disables Review findings on the first commit. The vitest config had no `@/` alias. Runtime gets that from `tsconfig-paths/register` and the build from `tsc-alias`; vitest has neither, so any test of the 19 source files that import through `@/` would have failed at collection with `Cannot find package '@/...'` — reading as a broken test rather than as missing config. The one existing test passed only because it imports relatively. Matched as `/^@\//` so it cannot swallow `@civitai/*`. The two WIP stub handlers had file-scoped disables, which turned `no-unused-vars` — the only error-level rule here — off for whole files. Narrowed to the specific lines, so new dead code in them is still caught. `.github/workflows/lint.yml` carried a second copy of the claim that event-engine has no vitest config and is a `node:test` suite. The first commit corrected that in `vitest.config.mts` and missed this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(event-engine): enforce the lint in CI, move the test out of src/common Second review round. The lint fixes had no enforcement. `.github/workflows/lint.yml` filters changed files to `^(src|packages)/`, so nothing in CI lints `apps/event-engine` — the next unused import would merge green and put the package back to exit 1 unnoticed, which is the failure this PR exists to fix, one level up. The filter now includes `apps/event-engine/`. The other apps/ members stay out: they have no config of their own and would fall through to the root Next config, whose typed rules crash outside `src/`. The suite lived in `src/common`, a vendored copy that `scripts/sync-submodule.ts` re-syncs from event-engine-common — which has no tests. A sync would have deleted it, and because the CI ledger derives its expectations from disk, event-engine would have dropped out of the apps job silently instead of turning red. Moved to `src/__tests__/`, importing through the `@/` alias added last commit. `formatMessage`'s rest parameter is deleted rather than `_`-renamed: no call site passes it and the body ignored it, so the rename was cementing dead API surface that read as if the args were formatted. The reason written above the `search<T>` disable was wrong — `T` checks nothing. Corrected to say so, with what was tried: `Promise<{ hits: T[] }>` makes it real but breaks the interface, since meilisearch's own `Index` stops satisfying it. Dropping `T` is TS2558 at the images.feed.ts call site. Both verified, not assumed. Also corrected the reason on the `image-scanned.ts` disables: the point is that the handler is absent from the registry in `handlers/index.ts` and nothing publishes `orchestrator.imageScanned`, so none of it runs — not merely that the TODOs are unwritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(event-engine): give the lint real rules and typecheck the test file Third review round. The eslint config had no `extends`, so `no-unused-vars` was the only error-level rule in it. Now that CI's blocking "ESLint (added files)" gate covers this package, a green annotation on an event-engine file would have meant materially less than the same annotation on a src/ or packages/ file. Adding `eslint:recommended` and `plugin:@typescript-eslint/recommended` surfaced two errors, both fixed: a stray semicolon, and a lazy `require('kafkajs')` in the health check that bought nothing — kafkajs is already imported statically by `index.ts` and `debezium-manager.ts`. `tsconfig.json` excludes `**/*.test.ts` so the build does not emit tests into dist/, which left the suite typechecked by nothing: vitest transpiles without checking. `typecheck` now runs against `tsconfig.typecheck.json`, the same config with the tests put back. Verified both ways — a planted `const x: number = "s"` in the test file is now TS2322, and `build` still emits zero test files. The reason I wrote above the moved test last commit was wrong. `src/common` is not a submodule — `.gitmodules` declares only `event-engine-common` at the repo root — and `scripts/sync-submodule.ts` pushes *out* of that directory rather than re-syncing into it, so it could not delete a test living there. The real risk is a manual re-vendor of a copy that has already diverged; the comment now says that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(event-engine): delete two scripts that assume src/common is a submodule `src/common` was a git submodule once. It is not now — `.gitmodules` declares only `event-engine-common`, at the repo root — and both scripts still assume otherwise. Neither is in `package.json` scripts or CI, so each only runs if someone types it by hand, which the filenames invite. `sync-submodule.ts` chdirs into `src/common` and runs `git add -A`, `git commit` and `git push origin main`. With that directory no longer its own repo, all three resolve to `model-share`: it stages every uncommitted file in the tree, including other people's in-flight work, and pushes it. `install-git-hooks.ts` guards on `git submodule status src/common`, which exits 0 for a path that is not a submodule, so the guard never fires. It then `mkdirSync`s `apps/event-engine/.git/hooks` recursively — creating a `.git` directory inside the package, which makes git treat `apps/event-engine` as its own repository root and drop it from `model-share`'s tracking. The hook it writes is inert regardless: its body is gated on a `.gitmodules` grep that no longer matches. Deleting rather than repairing, because neither does the job its name implies. `sync-submodule` pushes out of `src/common` and never syncs content in, so it is not the tool someone reaching for it wants either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(event-engine): lint the whole package, widen the vitest glob Fourth review round. The package script was `eslint src` while the CI filter widened last commit covers `apps/event-engine/` entirely, so a file under `scripts/` was blocking-linted in CI and invisible locally — green here, red there. Justin's call was to close it by widening rather than narrowing: `lint` is now `eslint . --ext .ts`, and the 13 errors that surfaced in `scripts/` are fixed. Same trivial class as the 26 before: unused imports and args, one `require()`, one `prefer-const`. Those four scripts are typechecked by nothing (`tsconfig.json` includes only `src/**`), so the edits were checked against a throwaway config including `scripts/`: 8 pre-existing type errors before, the same 8 after, none in the files touched. The vitest `include` was `src/**/*.{test,spec}.ts`, narrower than the CI ledger's own detection regex, which scans the whole package. A test at `scripts/x.test.ts` would have run nowhere while the job stayed green — the ledger asserts the member is present, not that all its tests ran. Widened to match, with node_modules/dist excluded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(event-engine): exclude nested node_modules from the vitest project Setting `exclude` REPLACES Vitest's defaults rather than adding to them, so the root-anchored `node_modules/**` left every nested one collected once `include` was widened to the whole package last commit. Controlled both ways with a throwaway test at `src/__probe_nm/node_modules/evil.test.ts`: root-anchored collects it (2 files / 5 tests), `**/node_modules/**` does not (1 file / 4 tests). Nothing nested exists today, so this is a trap rather than a live bug — but `src/common` re-acquiring one, or any subdirectory getting its own install, would have put third-party tests into `app:event-engine`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
193324c640 |
perf(db): port the hottest per-user read lookups to Kysely over pg (#3820)
* perf(db): port the hottest per-user read lookups to Kysely over pg
The Prisma query engine runs in-process and its CPU cost scales with call
volume, so the highest-frequency read statements pay it most. Routing those
through Kysely over the app's own `pg.Pool` bypasses the engine entirely for
that query. (`$queryRaw` on the Prisma client does NOT: it still goes through
the engine. Only the separate-pool path removes it.)
Ported the two highest-volume read paths that are read-only, transaction-free
and stable in shape:
- the per-user vote overlay behind the votable-tag endpoint —
`listImageTagVotes`, `listImageTagVotesMany`, `listModelTagVotes`. The tag
rows themselves already come from a cache; only the caller's own votes are
read per request.
- the per-visible-set engagement membership read — `listModelEngagements`.
Each is a 1:1 swap onto the same read tier: `dbRead` (Prisma) and `kyselyRead`
ride the same underlying pool, so no routing, pagination or ordering changes.
Both were already reachable from `kyselyDb`, so no new module edges.
Behaviour equivalence is the whole point of the change, so it is pinned by a
suite that runs BOTH implementations against the SAME rows in the SAME database
and deep-compares, rather than asserting the new result against a hand-written
expectation: `src/server/db/__tests__/kysely-prisma-parity.test.ts` (opt-in via
`KYSELY_PARITY_DATABASE_URL`, throwaway DB, schema fixture alongside). It covers
empty results, cross-user and cross-entity isolation, both vote polarities and
every engagement type.
Two ports-specific hazards handled explicitly:
- `IN ()`. Kysely compiles `where(col, 'in', [])` to a Postgres syntax error
where Prisma silently returned []. Both bulk queries short-circuit an empty
array, and it is reachable from a request — the votable-tag input schema
accepts an empty `ids`. Verified by removing the guard and watching the
parity test fail with `syntax error at or near ")"`.
- enum arrays. pg has no parser for an array of a user-defined enum (dynamic
oid), so such a column arrives as the raw `{a,b}` literal unless
`registerEnumArrayTypeParsers` has run. None of the columns selected here is
an array type — `ModelEngagement.type` is a SCALAR enum, which needs no
registration. A seam guard in the parity suite checks that against the
catalog of the fixture database the suite itself creates.
`listImageTagVotesMany` deliberately keeps the source query's narrow select
(`tagId`/`vote`, no `imageId`): the caller keys votes by tagId alone, so
widening it would change what the endpoint returns.
The two existing suites that mocked the Prisma client for these tables now mock
the query-function seam instead, and additionally assert the Prisma path is no
longer called — so a regression back onto the engine fails the test.
Gate: typecheck 0 errors; unit 14149 passed / 0 failed (920 files); db-queries
23 passed with no DB, 27 passed + 4 new DB-backed EXPLAIN checks against a real
Postgres; lint-rules 215 passed; parity 16 passed. The `IN ()` guards, the
`userId` predicates and the select-shape guard were each mutation-tested and
watched to fail for their own specific reason.
* test(db): kill two surviving mutants in the Kysely-port guards
An adversarial audit of this PR confirmed the ported queries behave correctly
and found two guards that are claimed to work and do not. Both are fixed here
and both were re-verified with the exact mutant that survived them.
1. The enum-array seam guard could not fail for the hazard it advertised.
Its `selected` set was a HAND-WRITTEN literal, disconnected from the actual
`.select()` calls, so it only ever checked six columns somebody remembered to
type. The audit added a real `"ModelEngagementType"[]` column to
`ModelEngagement`, widened `listModelEngagements` onto it, and the guard PASSED
— reproduced here before the fix. That is worse than no guard, because the
commit message and the test's own comment both promised a widening would fail
loudly, so a maintainer who believed it would skip the check that matters.
It is now structural: `PORTED_QUERIES` runs each ported function against the
package's `compileHarness` and the select list is read back off the COMPILED
SQL, so it tracks the real query. `selectedColumns()` THROWS on any statement
shape it does not fully understand rather than returning an empty set — a
silent zero is how the previous version became vacuous — and a positive control
asserts the derivation produced at least the 8 columns the four ports select
today. The pg catalog probe is unchanged in substance but now runs through the
same helper the assertion uses, so the positive control exercises the real
query path. `@civitai/db-queries` gains a `./test-harness` export for this.
Wording: the guard checks the catalog of the FIXTURE database this suite
creates, not "the live catalog". Corrected in the test, the commit message and
the PR body.
2. The moved test seam no longer pinned the read tier.
Before the port these reads were `dbRead.tagsOn*Vote.findMany` /
`dbRead.modelEngagement.findMany`, and the mock lived on `dbRead` SPECIFICALLY,
so a wrong-tier read had no mock to hit and blew up. Mocking the query-function
seam instead moved that property out of the suites, which asserted the client
argument as `expect.anything()`. Measured by the audit: routing all four ported
reads at `kyselyWrite` fails 1 test on pre-port code and passes all 35 after.
The client is now pinned to `kyselyRead`. Note the matcher: `kyselyRead` and
`kyselyWrite` are distinct objects that compare DEEP-EQUAL, so
`toHaveBeenCalledWith(kyselyRead, …)` still passes under the wrong-tier mutant
— the first cut of this fix used it and the mutant survived. Only `toBe`
discriminates, and each file carries a negative control asserting exactly that
(`not.toBe` + `toEqual`), which fails if the two clients ever become one
object. Coverage also widened from 2 of the 4 ported reads to all 4:
`getVotableTags(image)`, `getVotableTags(model)`, `getVotableImageTags` and
`getUserEngagedModelsByIds`.
3. Over-claims in durable text.
- the commit message said "6 new DB-backed EXPLAIN checks"; it is 4.
- lint.yml said 4 DB-backed tests skip across two files. Measured with no DB:
8 skipped across three (6 tag.db.explain, 1 model.db.explain, 1
enum-array-parsers.explain). It now also states plainly that those EXPLAIN
checks and the 16-case parity suite have never run in CI.
Post-fix mutant battery, all three tiers per mutant (parity / db-queries
package / the two service suites), each mutant applied to a restored tree:
control parity 16 pass · pkg 23 pass, 8 skip · svc 40 pass
drop userId predicate parity 2 fail (cross-user leak) + pkg SQL assertion
widen onto an enum array parity 4 fail, incl. the seam guard by its OWN
message: "ported query selects ARRAY-typed
column(s) [ModelEngagement.types]"
route reads at the writer svc 5 fail, all "ported read was handed a client
other than kyselyRead"
drop the engagement IN() guard parity: syntax error at or near ")"
drop the votes IN() guard parity: syntax error at or near ")"
off-by-one on the id list parity 2 fail + pkg SQL assertion
break the guard's own derivation parity: "expected 0 to be greater than or
equal to 8" (the positive control fires)
typecheck 0 errors (instrument validated: a deliberate error in model.db.ts is
reported by file and line). eslint on the changed files, all three revisions
measured in ONE tree so a single plugin resolution applies: base 12 problems
(2 errors), PR head 10 (2 errors), here 10 (2 errors) — no new error or
warning. Full unit project: 14139 passed, 17 skipped, 15 failed in 2 files
(hiddenBlocks, recent-source-images) that fail identically on the merge-base —
a local jsdom/environment break, not this PR.
* test(db): close two silent-degradation paths in the enum-array seam guard
A delta re-audit of the previous round confirmed both its fixes land, and found
that the replacement seam guard has two ways to report green while the hazard it
exists to catch is present — the same class of defect it was written to remove.
Both were reproduced end to end before being fixed, and both fixes are pinned by
the mutant that survived them.
1. The guard inspected only the LAST compiled statement.
It read `harness.lastQuery()`, so a ported query that issues more than one
statement was checked on its final statement alone. Reproduced: give
`listModelEngagements` a first statement selecting a real
`"ModelEngagementType"[]` column and the whole suite — seam guard included —
passes 16/16. The same column as the LAST statement fails 4 tests, which is what
makes this a blind spot rather than a dead test. It also contradicted the
guard's own docblock, which promises it "may not degrade quietly".
It now iterates `harness.queries`, so every statement a ported call compiles is
parsed. Post-fix the two-statement mutant fails, ALONE, with the guard's own
message: `ported query selects ARRAY-typed column(s) [ModelEngagement.types]`.
2. Neither positive control covered the derivation -> catalog NAME seam.
`expect(selected.length).toBeGreaterThanOrEqual(8)` proves 8 items were derived,
not that any of them is a name the catalog can match, and the catalog control
asserted a hardcoded `_ParityArrayProbe.x` rather than a derived name. So the
gap between the two controls was exactly where a mangled name lives. Reproduced
with a ONE-CHARACTER edit — `item.slice(1, -1)` -> `item.slice(1)` in
`selectedColumns` — after which every derived name carries a trailing quote,
matches nothing, leaves `offenders` permanently `[]`, and both controls stay
green. Stacked with a genuine enum-array widening, the seam guard drops out of
the failure set entirely (4 failures become 3) while the hazard is present.
The guard now asserts that every derived `(table, column)` pair exists in
`pg_attribute`, failing with `derived column name(s) match no catalog column`.
One catalog query now feeds both the existence check and the array check, so the
control cannot certify a path the assertion does not use. Post-fix the
one-character mutant fails for that reason and no other — notably the `>= 8`
yield control does NOT fire, so this is not a mutant killed by a neighbouring
guard.
3. The `./test-harness` subpath exposed the DB-backed helpers.
The subpath added last round pointed at `test/harness.ts`, which also exports
`explainHarness()` and `testDbUrl()`; the latter falls back to
`process.env.DATABASE_URL` — a real environment's writer in any checkout that
has one, and precisely the fallback the parity suite refuses on purpose. Nothing
imports it today, but a production import of `explainHarness()` would open a
second pool against the real database.
`compileHarness` moved to its own module, which is what the subpath now points
at; `harness.ts` re-exports it for the package's own tests. Nothing reachable
from the public subpath resolves a connection string, so the parity suite's
refusal to fall back is now a property of its whole import graph rather than
just of its own code. `harness-exports.test.ts` pins it as a ledger that fails
in BOTH directions, with a positive control asserting the excluded helpers
actually exist (otherwise the exclusion is unfalsifiable).
Post-fix battery. Every mutant applied to a freshly restored tree, tiers run per
mutant, control first and again at the end. Each failure is quoted by its own
reason, since a mutant killed by a different guard's error would still be green
with the intended guard deleted.
control parity 16 pass | pkg 26 pass, 8 skip | svc 40 pass
drop the userId predicate parity 2 fail ("to have a length of 2 but got 3")
+ pkg compiled-SQL assertion
comment out the engagement
empty-array guard parity 1 fail: syntax error at or near ")"
comment out the votes
empty-array guard parity 1 fail: syntax error at or near ")"
off-by-one on the id slice parity 2 fail ("length of 4 but got 2")
+ pkg compiled-SQL assertion
route reads at kyselyWrite svc 5 fail, all "ported read was handed a client
other than kyselyRead"
widen onto an enum array,
LAST statement parity 4 fail incl. the seam guard by its own
"selects ARRAY-typed column(s)" message
widen onto an enum array,
FIRST of two parity 1 fail — the seam guard ALONE, same message
(was 16/16 green before this commit)
one-character slice break
in the derivation parity 1 fail: "derived column name(s) match no
catalog column" (was 16/16 green before)
subpath repointed at the
DB-backed harness pkg 1 fail: expected './src/test/harness.ts' to be
'./src/test/compile-harness.ts'
testDbUrl added to the
public module pkg 2 fail: "expected [ 'compileHarness',
'testDbUrl' ] to deeply equal [ 'compileHarness' ]"
typecheck 0 errors. Note `tsconfig.json` excludes `src/**/__tests__/**`, so
`pnpm typecheck` does not see the parity suite at all (`--listFilesOnly` puts it
in 0 of 11,552 program files); it was typechecked separately under a scoped
config, and both instruments were validated by watching a deliberate error get
reported by file and line. The other four changed files ARE in the root program.
eslint unchanged: 6 errors / 0 warnings in the `packages` scope and 0/0 on the
changed `src` file, measured at base and at head IN ONE TREE so a single plugin
resolution applies. Both scopes carry a positive control — an added `~/...`
import raises packages 6 -> 7 naming the new file, an unused local raises the
src file 0 -> 1 warning — because `packages/.eslintrc.cjs` is `root: true` and
enables only two rules, so a "0 problems" there is otherwise unreadable.
prettier clean on all five files.
Deliberately out of scope, recorded not fixed: the statement-shape docblock
slightly overstates what the regex rejects; the structural half of the guard is
gated behind a DB it does not need; two service functions share one mock; and
`PORTED_QUERIES` is still hand-maintained.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(db): make three guard docblocks match what the guards enforce
Each of these was a docblock claiming a property the adjacent test did not
check. Two were real gaps, not wording.
1. compile-harness.ts said nothing reachable from it touches a connection
string, and called that "a property of the whole module graph". Only the
export NAMES were pinned. `harness-exports.test.ts` now walks the transitive
import graph, asserts the reachable first-party files and third-party
packages against an exact ledger (`pg` is not in it), and scans each
first-party file for pool/connection-string tokens. The walker is
positive-controlled against `harness.ts`, which it must flag for reaching
`packages/civitai-db/src/kysely.ts`; a walker that finds nothing everywhere
would certify everything. Proven: `process.env.DATABASE_URL` added inside
`compileHarness()` now fails with
"compile-harness.ts: DATABASE_URL" — the export ledger still passes, which is
exactly why the graph check had to exist.
2. The exports ledger asserted one key while claiming to cover "the surface".
A second subpath (`./test-harness-full` -> `./src/test/harness.ts`) re-exposed
`explainHarness`/`testDbUrl` with everything green. The whole `exports` map is
now ledgered, and the reachability test reads the map off disk rather than
from the ledger constant, so that mutant fails twice — once as a ledger
mismatch, once as a real reachability violation.
3. DummyDriver resolves every `.execute()` to zero rows, so a two-step read
(`ids = await select...; if (!ids.length) return []; select ... in ids`)
compiles only its first statement and the second is never parsed. The
fleet-wide `selected.length >= 8` floor cannot see that on a newly added port,
because the existing four already satisfy it. `PORTED_QUERIES` entries now
declare their expected compiled-statement count and it is asserted per port.
The comment that over-promised is corrected to state the real limitation.
Verified with the full mutant battery (control run first and last, both green:
pkg 29, parity 16, services 40). Each mutant failed for its own reason: dropped
`userId`; each empty-array guard commented out; off-by-one and one-char `slice`
derivation breaks; reads routed at `kyselyWrite`; enum-array widen as the last
statement and as the first of two; ledger repoint; ledger grow; plus the three
new proving mutants above. Re-confirmed the parity suite still refuses to fall
back to `DATABASE_URL` (16 skipped with only `DATABASE_URL` set).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
018f9a0e7f |
fix(og): unblock the SVG loader Next 16.3.0 leaves blocked, so /api/og stops 500ing (#3777)
Next 16.3.0's image optimizer applies a process-global libvips loader allowlist that omits SVG. next/og rasterizes satori's SVG through the same sharp instance, so every ImageResponse threw 'Input buffer contains unsupported image format' once the optimizer initialized — making /api/og return 500 on 100% of requests. Carries upstream vercel/next.js#96681 verbatim as a version-pinned pnpm patch (both the CJS and ESM image-optimizer copies), plus a CI guard that fails the build if the patch stops applying. Verified on the standalone preview build: /api/og returns a valid PNG on both the FallbackCard and OgCard paths after the optimizer is warmed to a cache miss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
28e3ff98ab |
fix(db-schema): unblock the drift gate on two cosmeticId findings the stale snapshot invents (#3747)
* fix(db-schema): accept two cosmeticId findings the stale snapshot invents The gate compares the schema against a COMMITTED CATALOG SNAPSHOT, not a live database. That snapshot is from 2026-08-03; the packs migration landed on 2026-08-04 and made `CosmeticShopItem.cosmeticId` and `UserCosmeticShopPurchases.cosmeticId` nullable. The schema says optional, the frozen snapshot still says NOT NULL, so the gate blocks every PR. Production has the migration applied - both columns are NULLABLE there and both pack join tables exist. There is no real drift. The schema declarations are correct and unchanged. The actual remedy is a catalog recapture, which is a deliberate maintenance act: it would also pull in every other change since 2026-08-03 and need its findings triaged. Until someone does that, record these two as accepted so the gate stops blocking unrelated work. drift:baseline takes it 61 -> 63, both entries `enforced`, no escalations absorbed. Delete both entries at the next recapture - they describe the snapshot's age, not the database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(db-schema): correct the stale finding count in the gate doc comment The re-baseline moved main from 61 to 63 accepted findings and updated the same sentence in the workflow and README; gate.ts was missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f22e45f184 |
ci(lint): narrow Typecheck to the paths our other CI does not reach (#3721)
The in-cluster Tekton `pr-check` pipeline runs `tsc --noEmit` on every PR and has posted the `tekton / typecheck` commit status since 2026-08-06. Until now this job typechecked the same tree a second time on every internal PR. The bake window that gated this is satisfied: the status has posted a real `success` (PR #3706) and a real `failure` (a deliberately planted TS2322 on a throwaway PR, since closed) — a gate seen only green is not a gate shown to work. The job is NARROWED, not deleted. Its `if:` is the complement of what the other system covers, and both arms were measured rather than assumed: * fork PRs — gated away there by author association, on purpose. * base != main — only PRs targeting `main` are watched there, so a PR targeting `release` would otherwise get nothing. 4 PRs have ever targeted `release`; rare, not never. Also corrects the header. It claimed typecheck "runs on fork PRs TOO" — true of the configuration, false of the outcome. Measured over all 16 fork-originated PRs among the ~500 most recent: 11 have a retained `Lint` run and all 11 sit at `conclusion=action_required`, i.e. awaiting maintainer approval with zero repository code executed; the other 5 have aged out. No fork PR has ever produced a Typecheck result. Both this claim and the one it replaced were read off the config; only running the query settled it. Verification: * actionlint clean on the result (0 findings), and RED with rc=1 pointing at the new `if:` when a doubled `||` is planted in it — the linter was shown to be able to fail before its pass was believed. * Neither `main` nor `release` has any required_status_checks, so renaming the job cannot break a merge gate. Refs civitai/talos-infra#855 |
||
|
|
5d1e44551d |
docs(ci): correct the lint workflow header — typecheck is NOT skipped on forks (#3714)
The file header and the typecheck job's own comment said opposite things:
header (line 5-7): "It is skipped on fork PRs (see the job comment), so a fork
gets no gating from this workflow at all."
job (line 154): "Runs on forks now."
The job comment is the correct one. Verified structurally: no job in this file
carries a top-level `if:`, so none of them are gated on the PR's origin.
This is worth fixing rather than leaving as a stale line, because it is wrong in
a costly direction. Typecheck is currently the only typechecking a fork PR gets,
and it is deliberately secret-free so it can run there — the ssh-agent step was
REMOVED rather than left unused precisely to keep that path working. A maintainer
who believed the header could reasonably delete the job as dead weight for forks,
silently removing the one place untrusted code gets type-checked in a sandbox we
would otherwise have to build ourselves.
Comment-only. No job, step, or condition changes.
Not verified: that a fork PR actually succeeds end to end — no fork PR appears in
the last 60 runs of this workflow, so what is confirmed is the absence of a gate
excluding them, not a green fork run.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ca699f5def |
ci(test): run the apps/* vitest suites — 369 tests CI had never executed (#3694)
The root vitest.config.mts registered `packages/*/vitest.config.*` as projects and nothing for `apps/*`, and CI runs only `--project unit` (root-relative `include`) and `--project '@civitai/*'`. So no test under apps/ has ever run in CI: 43 files / 369 tests across five apps, green only for whoever remembered `pnpm --filter <app> test` by hand. This is the same gap #3591 documented for packages/, in the sibling directory, missed when that one was closed. Register `apps/*/vitest.config.{ts,mts}` — globbed on the CONFIG FILE for the reason the packages comment already gives — and give each app project an explicit `app:` name. That name is load-bearing: every app is also published as `@civitai/*` (`@civitai/auth-app`, and `@civitai/orchestrator-gateway` with no suffix), so under default naming no pattern separates apps from packages and `--project '@civitai/*'` would have silently swept the apps into the packages job. Verified the packages selector is unchanged: same 9 packages, same 957 tests, zero app files in the report. apps/creator-studio gets a config it never had — it declared a `test` script and owned a vitest test file that no runner could reach. Generalize scripts/ci/assert-package-suites-ran.mjs to take the workspace dir and rename it to assert-workspace-suites-ran.mjs, so one ledger serves both jobs, and wire an `App unit tests` job that runs it. Per-app counts: auth 29 files/237, notifications 10/101, storage 1/17, orchestrator-gateway 2/6, creator-studio 1/8. All green. Positive controls (both with vitest exiting 0, assertion exiting 1 on its own `missing` branch): breaking one app's `include` so it collects nothing, and dropping one app's `test.name` so the selector no longer matches it. Also corrects an inherited premise in the script header — on vitest 4.0.18 a `--project` filter matching nothing fails startup and exits 1, it does not exit 0 as the comment claimed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ebe9aa278b |
ci(db-schema): gate a PR on NEW schema<->database drift, not on the backlog (#3643)
* ci(db-schema): gate a PR on NEW schema<->database drift, not on the backlog The drift detector (#3591) reports the gap that exists. A gate has to answer a narrower question — did THIS change make it worse? `--strict` fails on any finding, and there are 61 on `main`; a gate red on every run teaches everyone to click through it and gets switched off within a week. Same reasoning as the report-only ESLint and Prettier steps in lint.yml. So: block only a PASS->FAIL regression the change introduces, warn about everything already broken, never block on a pre-existing finding. Modelled on the delta gate this org already runs on its infrastructure repo. `drift-baseline.json` records the 61 accepted findings, keyed by a fingerprint that deliberately EXCLUDES the `declared`/`actual` prose — #3589 corrected eight declared referential actions and touched no constraint, and a fingerprint that folded that in would have retired eight entries and raised eight identical-looking new ones. Nullability is the one exception: its `declared` is a single word and that word IS the finding, so a field flipped from optional to required against a NULLABLE column cannot inherit the old entry's pass. TWO SEVERITIES, decided structurally rather than by taste. Migrations here are applied by hand, so a declaration being ahead of the database is a normal intermediate state; a gate that could not tell that from real drift would block every PR that adds a column. The discriminator is whether the finding concerns database surface that already exists: enforced columns are in the catalog, the constraint is not -> BLOCKS pending the column is not in the catalog at all -> warns `missing-column` is always pending by construction, `nullability` and `uniqueness` always enforced by construction; `missing-foreign-key` is the only kind decided at runtime. On today's backlog that is 49 enforced, 12 pending. NOT MEASURED, and it says so rather than printing a clean zero: referential actions. The committed snapshot carries no ON DELETE/UPDATE data, so all 408 comparable foreign keys read "not comparable". A live run found 45 — every one an ON UPDATE Cascade-vs-NoAction on a hand-written App Blocks foreign key, zero ON DELETE mismatches, inert because `id` is never updated. They are absorbed by being structurally unmeasurable here, not by being waved through. READ-ONLY BY CONSTRUCTION. gate-cli.ts has no database code path — no `pg` import, no flag that opens a connection. This repo is public and so are these logs, so that is a requirement, not a convenience. Unknown arguments are redacted before being echoed, as in cli.ts. Positive controls, because "0 new drift" and "wired to nothing" print the same page: the verdict always reports matched-vs-baseline as a PAIR; an empty baseline or a run that reproduced none of its baseline exits 2, not 0; a catalog that covered nothing exits 2; and the workflow greps for the verdict line because `pnpm --filter` exits 0 when it matches no project. 25 tests, and every guard was mutation-tested — seven mutations, each killed by the test whose intent matches it. * fix(drift-gate): audit round 1 — NUL byte, snapshot decay, tier escalation Addresses the adversarial audit of #3643. A literal NUL byte in gate.ts made the file BINARY to git, to grep and to GitHub: all 293 lines of the gate's logic were absent from the PR diff, and `grep -c` returned rc=1, which reads as "0 matches". It is written as the escape now, matching compare.ts:15. This is the second independent occurrence of the exact defect (see #3647's plan.ts:53) — the class is a composite-key separator emitted as a raw byte rather than an escape. The gate compares against a FROZEN snapshot, so every column created after the capture can only ever be tiered pending/warn: blocking coverage shrinks monotonically while the check stays green, and nothing said so. The snapshot now carries `capturedAt` (stamped by --dump-catalog), the verdict prints its age on EVERY run rather than only past a threshold, and 90 days triggers an explicit STALE note. Advisory, never fatal — a gate that reddened because a date passed would be red for everyone at once, for a reason no PR author can fix. This makes the decay VISIBLE; it does not remove it. Only recapturing against a live database does that. Tier escalation was invisible. `tier` was written to the baseline and never read back, so a finding accepted as `pending` ("no such column yet") that became enforceable when its migration landed WITHOUT its constraint — the shape of 37 of the 61 baseline findings — was absorbed into `matched` and exited 0. `evaluateGate` now compares the live tier against the recorded one and blocks on `pending -> enforced`. A no-op `drift:baseline` produced a ~250-line reformat, because Prettier and JSON.stringify disagree about the file by 246 lines. That buried the one entry a reviewer needed to see, defeating the reason for committing a baseline at all. The generator now owns the format (.prettierignore): a no-op refresh is a zero-line diff, and accepting one finding is a twelve-line one. The fingerprint excluded the referenced table, so repointing a relation from Image to Post — same model, same field, same constrained column — inherited the old entry's pass silently. It is folded in now; the ON DELETE/UPDATE prose stays out, which is what #3589 required. The cross-module parse of compare.ts's `declared` string is pinned by a test over all 37 real missing-FK findings. The "this catalog carries no ON DELETE/UPDATE data" parenthetical was hardcoded and printed even against a catalog that did carry it. It is conditional now. Two mutation survivors closed. `.every -> .some` in the FK tier was structurally undetectable because every fixture used single-column keys — a composite case now distinguishes them. `assertCatalogSanity`'s CALL SITE was untested (the function was): the only exit-2 CLI case used an empty catalog, which trips assessCoverage instead, so deleting the call survived. A uniform-notNull catalog now exercises it. Sweep re-run against the changed source rather than citing the old table: 13 mutants, 13 killed, each by the test whose intent matches it, with a clean-tree control. Also: "blocks" softened throughout. `main` has branch protection but no required_status_checks, so this check is advisory and a PR can merge with it red. The README now states plainly what the gate can catch (a schema edit promising what the captured database lacked), what it cannot (anything the database itself does), and what degrades (any column younger than the capture). * fix(drift-gate): stamp the snapshot without reformatting 21,520 lines The capturedAt stamp was added by a JSON round-trip, which collapsed the prettier-formatted fixture to a single line: 1 insertion, 21,520 deletions. That is precisely the defect the audit raised against drift-baseline.json one finding earlier — a mechanical rewrite burying the one line a reviewer needs — so shipping it here while fixing it there would have been a straight regression. Inserting the key textually keeps the file's existing formatting: 1 insertion, 0 deletions, and prettier still agrees with the result. Generated JSON in this package now has two owners, deliberately and differently. The CATALOG fixture stays prettier-formatted: it is written once and read by a human when a finding is disputed. drift-baseline.json is prettierignored and owned by its generator: it is rewritten on every acceptance, so the generator has to be able to reproduce it byte-for-byte or the diff stops being reviewable. Also bounds a flake before it lands. Every case in the gate's CLI suite spawns the real entry point as a process, paying a cold tsx transpile plus a Node start before its first assertion. The slowest measures 3.5s on an idle machine against Vitest's 5s default — a 1.4x margin a 2-core runner will not honour, which is a test that goes red on ambient machine speed, a different case each run. 60s still bounds a genuine hang. Verified load-bearing rather than assumed: a deliberate 6s probe passes inside the describe and fails with "Test timed out in 5000ms" once the option is removed. Set on the describe rather than in the package's vitest.config.ts because that config is a shared file with a concurrent change already in flight for the sibling cli.test.ts, which has the same shape and the same problem. * fix(drift-gate): audit round 2 — test the capturedAt producer, close the refresh seam N1 (blocking). The staleness signal had a tested consumer and an untested producer. `snapshotAge` was exercised exhaustively; the line that WRITES the stamp had no test at all, so mutating it to `catalog.capturedAt` — which makes every fresh capture carry no date and silently disables the whole signal round 1 added — survived with PASS=44 FAIL=0. The only assertion touching the stamp checked that the committed FIXTURE has one, and that value is a hand-written midnight instant the command would never emit, so it never exercised the producer. A new suite drives `drift --dump-catalog` as a process and asserts the stamp is a real instant from this run, that an existing stamp is preserved rather than refreshed (a re-dump must not launder a stale snapshot young), and that the catalog is otherwise unchanged. The exact surviving mutant now dies. N2. `--dump-catalog` emitted single-line JSON, so a content-identical re-dump of the committed fixture was a 21,522-line diff — the same unreviewable-diff class this PR fixed for drift-baseline.json, and worse, because the gate's own STALE message instructs the operator to run precisely that command. Prettier cannot be the owner instead: it collapses short arrays in a way JSON.stringify will not reproduce (measured: 3,598 lines apart), so the artefact would only stay clean if every operator remembered a formatter afterwards, which nothing enforces — `prettier --check` on a MODIFIED file is report-only here. The generator now emits indented JSON and the fixture is prettierignored, matching how the baseline is handled. A re-dump is now byte-identical (sha256 verified). The fixture is reformatted once, +2,746/-852, content deep-equal before and after. N3. The escalation comment and README claimed the guard covers "the exact shape of 37 of the 61 baseline findings". Measured, escalation needs an entry that is both `pending` and `missing-foreign-key`, and the baseline has ZERO of those — all 12 pending entries are `missing-column`, hardcoded `pending`, which can never rise. The guard is purely forward-looking and now says so. N4. That guard also had a bypass: a catalog only gains a column via a recapture, and the documented recapture procedure refreshes the baseline in the same commit, turning the escalation into a tier flip inside a regenerated file rather than a failure. `drift:baseline` now reports every pending -> enforced transition it absorbs, so it lands in the recapture commit's log instead of nowhere. It does not fail; a refresh is deliberate. N5. A STALE note is advisory, so the check renders green and the one signal about shrinking coverage was a line inside a passing job's log. Promoted to a `::warning::` annotation on the PR. N6. The pending-tier text told developers a pending finding "will stop being reported once the snapshot is recaptured". True for missing-column; for missing-foreign-key a recapture makes it ENFORCED. Now spells out both, since this is the text read at the moment someone decides to accept a pending finding. Smaller: the `>=` staleness boundary is tested at the threshold, not one past it; a future-dated capture is treated as a problem rather than as maximally fresh (a negative age could never reach the threshold, disabling the signal indefinitely); the NUL tuple separator is pinned by a collision case that a printable separator gets wrong in the failing direction; the referencedTarget comment claimed a parse failure would "quietly merge" findings, when measured it gives 0 collisions and a loud 37-resolved/37-new block; README says eleven-line, not twelve; and remaining unqualified "blocks" wording is now "fails the check". Sweep re-run against changed source with the harness validated FIRST, which mattered: the initial runner counted reporter glyphs, and this reporter prints a tick per FILE rather than per test, so it read a real failure as FAIL=0. Rewritten to parse the summary line, with NO-SUMMARY treated as an unmeasured result rather than a kill — one mutant made the tool emit a single 226 KB line and blew ARG_MAX in the harness itself, which the first version scored as a kill. Controls: clean tree PASS=56 FAIL=0, deliberate break PASS=51 FAIL=5. 13 mutants, 13 killed. Also caught by that validation: STALE_AFTER_DAYS could be changed to 999999 with no test failing, because every staleness assertion was written in terms of the constant itself. The policy is now pinned in absolute terms. * fix(drift-gate): audit round 3 — finish the round-2 correction, cover --update-baseline Two blocking items, both instances of a fix landing in one place and being reported as landing in two. The round-2 correction to the escalation claim reached gate.ts and not the README, which then contradicted itself four lines apart: "the shape of 37 of the 61 baseline findings" at :372 against "the current baseline has zero of those" at :378. Re-measured: all 37 missing-foreign-key entries are already tier: enforced and cannot rise. The false sentence is gone; the PR body carried the same sentence and is corrected too. --update-baseline had no test of any kind — not the write, not the escalation report, not the previous-baseline read. Two mutants passed the full suite green: `absorbed = []`, which makes the entire N4 remedy inert, and reading `previous` AFTER the write instead of before, which is the likeliest real edit to that block. Both are now killed by a CLI case that seeds a pending tier, refreshes, and asserts the transition is named and the file rewritten. Six cases in total, all against a COPY in a temp dir so none can touch the committed artefact. This is the isolation seam reproduced inside a round-2 fix: the pure function was tested, its only caller was not. Folded in: A corrupt previous baseline printed nothing about escalations, so "0 absorbed" and "could not read the previous baseline" were byte-identical output — a reassuring zero indistinguishable from a probe wired to nothing, in a tool built against exactly that everywhere else. The refresh now prints either the pair (absorbed N, compared against M entries) or an explicit SKIPPED note. A malformed-but-parseable previous baseline (`{}`) crashed the refresh AFTER it had already written the file: exit 2, "Cannot read properties of undefined (reading 'map')", 677 lines on disk. The previous baseline is now validated at read time, before the write, and an unusable one degrades to the SKIPPED note. `set -euo pipefail` in the workflow aborted the step the moment the gate exited non-zero, skipping the no-verdict guard and the STALE annotation PRECISELY when the gate fires — and the comment beneath claimed the guard runs "including when it blocks", describing an unreachable path. The step now captures PIPESTATUS, runs both checks, and propagates the verdict last. Verified by extracting the step and running it against stubbed gates across five paths: pass, pass+STALE, FAIL+STALE, no-verdict-but-exit-0, and exit 2. The failing-with-STALE case emits 0 annotations under the old form and 1 under the new. --dump-catalog returned before assertCatalogSanity, so the recapture command the STALE message recommends could emit a catalog the next drift run would reject. The check now runs first: a uniform-notNull catalog exits 2 having written zero bytes, and a healthy one still dumps sha256-identically. cli.ts said the prettier/generator disagreement is 3,598 lines; re-measured, 3,589. The sibling figures (246, 21,522) re-derive exactly. The two-kind pending-tier message was unpinned prose next to a line that a CLI test pins; it now has its own assertion. Sweep re-run against changed source over the WHOLE package suite, with the harness asserting 17 files as well as both summary lines: 17 mutants, 17 killed (16 in the batch, G3's NUL-separator anchor run standalone because the escape sequence cannot be passed through the shell harness). Controls: clean tree PASS=377 FAIL=0, deliberate break PASS=371 FAIL=6. * fix(drift-gate): the errexit fix was inert in CI; restore the correct prettier figure Both findings this round have one root cause: a measurement taken in an environment that is not the one it targets. GitHub runs a `run:` step with no `shell:` key as `bash -e {0}`, so errexit is already on before the first line. `set -uo pipefail` does not clear it, and adding pipefail made it strictly worse: the pipeline now returns the gate's non-zero status, errexit fires on it, and the step dies at `tee` before `rc=${PIPESTATUS[0]}` is ever reached. The STALE annotation was still skipped precisely when the gate fires, and the comments describing that path could not run. `set +e -uo pipefail` explicitly. The reason this shipped is the finding itself: under plain `bash` both forms emit the annotation, and plain `bash` is what the previous round measured. Re-measured under `bash -e` with a stubbed gate exiting 1 and printing STALE: old form 0 annotations, new form 1. Positive control on a PASSING gate: all four combinations emit 1, so the harness can observe one under `-e`. All five paths re-verified under `bash -e` against the step extracted from the shipped file: pass 0/0, pass+STALE 0/1, FAIL+STALE 1/1, no-verdict 1/error, exit-2 2. cli.ts's prettier-disagreement figure goes back to 3,598. 3,589 is the count at prettier's DEFAULT printWidth 80 — the number you get by copying the file to /tmp, where `.prettierrc` cannot resolve. In the repo, where it resolves to printWidth 100, every method agrees on 3,598: 852 insertions + 2,746 deletions, plain diff, -u, -U0 and git --numstat, with a self-diff control of 0. The comment now records how the number must be measured, since getting it wrong is one `cp` away. Three guards that a differently-built sweep found surviving: assertCatalogSanity's move ahead of the --dump-catalog return had no regression coverage — deleting the call, and moving it back behind the return, both passed 382/0. Now pinned by a case asserting a uniform-notNull catalog exits 2 having written zero bytes. Same "only caller untested" shape the --update-baseline work existed to close. The NUL-separator guard was spelled to `_`: its fixture only collided under that one character, so mutating the separator to `|` or to empty survived. Rewritten as a property over five candidate separators — empty, underscore, pipe, dot, space — each building the pair that collides under ITS own separator, so any character legal in a Postgres identifier fails at least one. Second instance of the spelled-guard class in this file, and the reason the rewrite asserts the invariant rather than a character. The original `_` mutant still dies. A describe-scoped `let workBaseline` was assigned by one test and never read; it is a local const. Verified: 382 tests, 0 failures; typecheck 0 errors; gate 61 of 61 at exit 0; re-dump of the committed snapshot still sha256-identical. Four mutants that survived the independent sweep now die, each to the test whose name states that guard's intent. * test(drift-gate): derive the separator alphabet; the "property" was five spellings Round 4 rewrote the NUL-separator guard and claimed it made "any character legal in a Postgres identifier fail at least one" case. That was false, and the mechanism is exact: each case builds `A{S}B`/`C` against `A`/`B{S}C`, and those two keys are equal IFF S equals the implementation separator — so a case only ever catches its own. Reproduced: `-`, `#`, `::`, `--` and `~!~` all survived at 382/0. `-` and `#` are legal inside a QUOTED Postgres identifier, so this is the hazard class the guard exists for rather than a hypothetical. The case list is now derived from an alphabet instead of hand-picked: empty, space, all 32 ASCII punctuation characters, five multi-character candidates, and five non-ASCII ones. The non-ASCII entries were added because `§` survived the first version of this enumeration — an ASCII-only alphabet excludes exactly the characters a quoted identifier makes legal. Added alongside it, and constructed differently on purpose: a deterministic seeded fuzz that splits one character run at two different points, so the pairs are ambiguous BY CONSTRUCTION and no separator is named anywhere in it. It carries its own positive control on the loop counter, because a `continue` that swallowed every case would leave it green having asserted nothing. Ten separators that previously survived now die, each to its own named case: `-`, `#`, `::`, `--`, `~!~`, `_`, `|`, `@`, `^`, `§`. Single-character ones die twice — the enumerated case and the fuzz independently. The claim is now stated at its true scope in the test comment as well as the PR body. A finite enumeration cannot prove a property over an infinite alphabet: what is proven is every separator in that alphabet plus whatever the fuzz catches, and what is NOT proven is an arbitrary unenumerated multi-character run — `ZZQ` still survives it, and the comment says so rather than implying otherwise. Overstating the scope is what produced this round. Also recorded, as a known gap rather than a fix: the workflow's STALE and no-verdict logic has no automated test in this repo, and the live check only ever exercises the passing path, so it structurally cannot observe the FAIL+STALE branch. That is precisely why the round-3 errexit fix shipped inert. It is verified by hand each round against the step extracted from the shipped file, under `bash -e`. 422 tests, 0 failures. |
||
|
|
902d222ed1 |
ci: run the nine packages/* test suites, which nothing has ever executed (#3642)
* ci: run the nine packages/* test suites, which nothing has ever executed The root `unit` Vitest project's `include` is root-relative (`src/**`, `scripts/**`), and the `Unit tests` job runs `vitest run --project unit`. So no CI job in this repo has ever invoked a workspace package's suite: 616 tests across nine `packages/*` packages ran only for whoever remembered `pnpm --filter <pkg> test` by hand. That is not theoretical. The schema-drift detector (#3591) shipped 81 tests into this gap, and two of them had been RED on `main` since #3592 landed — with nothing anywhere to say so. Root `vitest.config.mts` now globs each package's OWN config file, so every package keeps the config it was written against. The new `Package unit tests` job is BLOCKING: the reasoning that made `Unit tests` report-only does not transfer, because this is 616 tests with no browser, no database and no Next module graph, and the whole run is ~15s. Three pre-existing failures the job surfaced, all fixed here rather than skipped: * civitai-db-queries/src/infra/enum-array-parsers.explain.test.ts could never pass without a database. `describe.skipIf(!url)` skips the tests but Vitest still EXECUTES the describe callback during collection, so the eager `new Pool({ connectionString: noVerify(undefined) })` threw `TypeError: Invalid URL` and failed the whole FILE to import — which reports as "1 failed test file" with no test count, not as a failing test. The Pool moves into `beforeAll`, which genuinely does not run when skipped. * production-snapshot.test.ts pinned 235 nullability findings on seven `*Rank` tables. #3592 marked those columns optional to match the database and the findings correctly went away. The assertion now guards the REMEDIATION (Rank family at zero) with a positive control on that zero — the 11 findings that remain. * parse-prisma-schema.test.ts pinned Prisma's defaulted `onDelete` at one named site, `TagsOnImageNew.image`, and #3589 gave that relation an explicit `onDelete`. Re-anchored to the POPULATION of bare relations, which an ordinary schema edit cannot retire. A green vitest run is a claim, not evidence: `--project` matching nothing exits 0, and so does a config whose globs stopped resolving. scripts/ci/assert- package-suites-ran.mjs therefore asserts a ledger — every package with a vitest config and a test file on disk must appear in the results — so the job fails when the executed set SHRINKS, which the totals cannot see. Validated against three known-bad reports (empty run, one package dropped, missing report file) before being trusted. Test count, measured in CI: 811 files / 12,031 tests before, all from `src/`; 61 files / 616 tests added by the new job, of which 81 are the drift detector's. * fix(ci): correct the "blocking" claim, and close three ledger blind spots Audit follow-ups on the packages/* CI job. The job was described as BLOCKING. It is not, and the distinction is load-bearing for whoever next decides whether to flip `unit`: `main` has branch protection but no required_status_checks at all, so a red check here does not prevent a merge. What it actually buys is rendering RED instead of red-but-ignored. Corrected in the workflow comment, the README and the PR body, along with what would make it a real interlock. The Rank positive control was green for the wrong reason. Asserting the Rank family is at zero nullability findings is satisfied just as well by a differ that never visits a Rank table — measured: making compare.ts `continue` on every model whose table ends in `Rank` left that test GREEN and reddened only two neighbouring ones. The `nullability.length >= 11` control could not see it, because it proves the KIND is still emitted, not that those seven tables are in scope. Now pinned via findings of a different kind that the same seven models must still produce; re-running the identical mutation kills the test itself. Three ledger blind spots, each demonstrated with a probe before and after: - It only looked in `src/`. A package with its tests in a top-level `__tests__/` was invisible to the EXPECTED set, so it could never be noticed going missing. Now scans the whole package directory. - It only matched `.test.ts`/`.spec.tsx`. `.mts`, `.cts` and `.js` tests were invisible the same way. - It counted SKIPPED tests as having run, summing assertionResults.length. A package that self-skipped in its entirety still satisfied the ledger — and self-skipping on a missing DATABASE_URL is exactly the pattern these suites use, so that failure mode is live, not hypothetical. It now requires a non-zero EXECUTED count and reports skips beside it, so a package quietly turning itself off is visible rather than absent from the arithmetic. Both directions verified by exit code, not by reading output: real report 0; tests-outside-src probe 1; .mts probe 1; wholly-skipped package 1; empty run 1; one package dropped 1; missing report 1. Known remaining gap, deliberately not widened into this change: `apps/*` has four more vitest configs and ~43 test files that no CI job runs. Same one-line fix in the same projects array, plus teaching the ledger about `apps/` — it hardcodes `packages/` today. Follow-up PR. Also: the CI job's JSON report is gitignored (the documented command left an untracked artifact at the repo root), and the workflow comment's "four EXPLAIN tests" is corrected to 3 + 1 across two files. |
||
|
|
35a9598dc9 |
docs(app-blocks,ci): record the owns-nothing analytics decision, and correct a stale flake claim (#3653)
* docs(app-blocks): record the owns-nothing analytics decision, and pin it `getMyAppAnalytics` returns an UNFLAGGED all-zero payload when the caller owns no apps, while a caller who asks for an app they do not own gets `unavailable: 'notOwned'`. Two reviewers have flagged that asymmetry as "defensible but wants a recorded decision"; it was still unrecorded. The reasoning, now written at the `unavailable` field: the discriminator's test is whether the zeros were MEASURED. `notEntitled` and `notOwned` are both cases where no aggregate ran, so their zeros are fabricated. Owning nothing is not — an aggregate over an empty owned set genuinely is zero, so the short-circuit only skips work whose answer is already known. Flagging it would put every honest new author behind an `unavailable` branch on their first visit and train clients to skip the flag that exists to stop them believing a fabricated zero. Precedent cited: #3581, which settled the same question for the sibling `getMyRevenue` with ONE value rather than two, because `getRevenueForOwner` never computes ownership (it scopes by `appOwnerUserId` in the WHERE clause) so `notOwned` is unproducible there. Analytics does compute ownership, hence the second value; the underlying rule is the same one. #3613 then applied that rule to `views`, whose own flag tracks the payload's — so the owns-nothing path leaves views unflagged too. No behaviour change. The added test pins the decision at the SERVICE boundary, which the existing coverage did not: every `unavailable` assertion today is against the `emptyAnalytics` helper, and the one owns-nothing test through `getMyAppAnalytics` asserts only `notOwned === false` and `installs.total`. Mutation-verified — flipping the branch to `emptyAnalytics(range, false, 'notOwned')` reds ONLY the new test ("expected 'notOwned' to be undefined"), and forcing `unavailableViews()` reds its views assertion ("expected true to be undefined") alongside the existing helper test. Both mutations restored byte-identically (sha256-verified). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj1HosDFkP6LgxMAtJdDmJ * docs(ci): the unit job's cold-optimizeDeps citation points at the FIX The unit job excluded component tests "because they need Chromium and carry the cold-optimizeDeps flake documented at vitest.config.mts:98-124". Half of that was wrong. vitest.config.mts:109-127 documents the FIX for that flake, not an open one: it dedupes React ("Canonical fix; protects every component test from this class of dual-React crash") and pre-bundles `vitest-browser-react` plus the JSX runtimes so "the optimize pass happen[s] BEFORE the run starts, so there's no mid-run reload". The cited range was also off by a block — 98-102 is a separate hookTimeout fix for cold `await import()` in beforeAll/beforeEach, not the optimizeDeps narrative at all. The Chromium half is still true and stays: this job installs no browser. Comment-only; no job, trigger or step changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj1HosDFkP6LgxMAtJdDmJ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c318ef1fb8 |
fix(typecheck): make a crashed typecheck report as crashed, not as clean (#3619)
* fix(typecheck): make a crashed typecheck report as crashed, not as clean
`pnpm run typecheck` was `cross-env NODE_OPTIONS="--max_old_space_size=8192"
tsc --noEmit`. When the heap cap is too small for the program graph, V8 aborts
part way through checking, so tsc emits ZERO diagnostics and dies. cross-env
normalises the SIGABRT to exit 1, and V8's explanation goes to stderr — so a
caller that captures stdout gets an empty log, a bare non-zero exit, and no
type errors anywhere in it.
That is indistinguishable from a clean pass to anything that judges the run by
its output, which is what people and scripts actually do (a clean run also
prints nothing). Reproduced with a deliberate `const x: number = 'nope'` in
`src/`: at a 4096 MB cap the run reported 0 errors and hid it completely; the
same tree at 8192 MB reported it.
Measured cold on a clean checkout, with that error in place as a visibility
control:
node 24.18.1 4096 -> OOM/0 diags 4608 -> OOM/0 diags
5120 -> pass/found 8192 -> pass/found
node 22.22.2 6144 -> pass/found 8192 -> pass/found
So the current 8192 is NOT at the cliff — the cliff is between 4608 and 5120,
and 8192 carries ~1.6x headroom. The number is left alone deliberately: the CI
runner has 16 GB, and a cap near that trades a self-describing V8 abort for a
kernel OOM-kill, which says less. Raising it would only move the cliff anyway.
What changes is that crossing the cliff becomes loud. `scripts/typecheck.mjs`
runs tsc and classifies the outcome:
- clean -> prints an explicit "typecheck: OK" line, so silence is
no longer what a pass looks like
- type errors -> passed through untouched, exit code preserved
- crashed -> a CRASHED banner naming the cause, on stdout AND stderr
(the original blind spot was a stdout-only capture),
plus a ::error:: annotation under Actions
- exit 0 w/ diags -> treated as a crash rather than trusted
Heap exhaustion, an outside kill (out of system RAM / a container limit) and an
unexplained abort are named separately, because the fix differs — an outside
kill wants a LOWER cap, not a higher one. The cap is passed as an argv flag
rather than via NODE_OPTIONS so an inherited NODE_OPTIONS cannot override it.
Override per-run with TYPECHECK_HEAP_MB=<mb>.
Covered by scripts/__tests__/typecheck.test.ts, which drives the classifier with
stub typecheckers (sub-second, vs minutes for a real run). Each of the five
cases was mutation-checked against the wrapper: 6/6 mutations killed, each by
its own test. One mutation initially SURVIVED and exposed a real gap in the
test — the crash banner is written to stderr, so asserting only on stdout let a
grep-poisoning regression through; both streams are asserted now.
CI already invoked this via `pnpm run typecheck` and so inherits the wrapper;
the step carries a comment against being "simplified" back to a bare tsc.
* fix(husky): stop the pre-push hook echoing success over a failed typecheck
The hook was:
npm run typecheck
echo "Typecheck successful"
`sh` without `set -e` runs the next line regardless of what the previous one
returned, and a script's exit status is its last command's — so the `echo`
became the hook's verdict. A failing typecheck on `main` printed "Typecheck
successful" and the push went through.
Measured against the real hook in a throwaway repo on `main`, with a stub `npm`
whose exit code is controlled:
npm exit hook exit (before) hook exit (after)
0 0 0
1 0 1
134 0 134
Before, all three printed "Typecheck successful". The 134 row is the case this
matters most for: that is V8 aborting on heap exhaustion, which emits no
diagnostics at all, so the hook was echoing success over a typecheck that had
not merely failed but never finished. The failure message points at
scripts/typecheck.mjs, which distinguishes the two.
The branch/username guard above is unchanged, and still makes the hook a no-op
off `main`.
|
||
|
|
04f3cf67c6 |
ci: run typecheck on forks and add a unit test job (#3440)
* ci: run typecheck on forks and add a unit test job Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: drop ssh-agent from the pin guard Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: sync submodule url in new worktrees Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: start the unit job report-only A full local run passed 8,937/8,943 but timed out 5 tests under load; all passed in isolation. Blocking on that from day one would red unrelated PRs. Flip once the pass rate is known. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b0a1cdef7a |
chore: keep migrations in the schema package (#3438)
* docs: point migrations at the schema package Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: fail PRs that add migrations outside the schema package Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d96a8a284e |
chore(lint): add CI job, drop type-aware override and prettier plugin (#3362)
* chore(lint): add CI job, drop type-aware override and prettier plugin
`pnpm lint` took ~2h40m and 61% of its findings duplicated `prettier:check`.
Nothing enforced lint, typecheck, or formatting in CI, which is how a
config-load crash survived ~2 months unnoticed.
- Remove the `*.ts`/`*.tsx` `parserOptions.project` override. Its own `rules`
block was empty; the only type-aware rule anywhere was root-level
`@typescript-eslint/restrict-template-expressions` (warn), which `tsc
--noEmit` largely subsumes. Both are gone. Measured on src/utils (82 files):
4m34s -> 8.4s. Full `pnpm lint`: ~2h40m -> 1m07s.
- Drop eslint-plugin-prettier and the `prettier/prettier` rule.
`pnpm prettier:check` already globs `**/*.{ts,tsx}` with no .prettierignore,
a strict superset of lint's `src/`-only scope, with matching options.
`eslint-config-prettier` stays in `extends`.
- Add .github/workflows/lint.yml: ESLint + Prettier on PR-changed files only,
full typecheck in a parallel job.
- Delete unreferenced devDeps (airbnb, airbnb-typescript, mantine configs,
eslint-import-resolver-typescript, lint-staged) and the lint-staged config
block, which invoked `tsc-files` — a package that was never installed.
- Pin eslint-config-next to major 15 via pnpm.overrides; v16 is flat-config-only
and silently kills lint when extended from eslintrc.
- Delete the top-level npm-style `overrides.openai.zod` block. pnpm reads
pnpm.overrides, so it never applied; openai@4 declares zod ^3.23.8 as an
optional peer while the repo is on zod 4, so honoring it would assert a
compatibility that does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(lint): make CI lint steps report-only, skip typecheck on forks
Review fixes for the CI job.
- ESLint and Prettier steps get `continue-on-error`. For a formatter,
"changed file" means the whole file: 789 of 4,116 src files fail
`prettier --check` today. Across the 98 src files touched by the last 30
commits, 39 fail Prettier and 10 carry a pre-existing ESLint error - 44
(45%) would red the job for reasons unrelated to the PR, forcing 3-line
bugfixes to ship as 200-line reformats. Annotations still surface. Both
flip to blocking once the backlog clears with the Prettier 2->3 upgrade.
Typecheck stays blocking; it is clean today and is the real guard.
- Skip the whole typecheck job on fork PRs. civitai/civitai is public and a
fork gets no secrets, so ssh-agent hard-fails on an empty key. Gating only
the ssh/submodule steps trades that for a wall of missing-module errors,
so the job is skipped rather than half-run.
- Drop `--depth=1` from the base fetch; `fetch-depth: 0` already has the ref
and the graft can break the merge-base walk on a conflicting PR.
- Bump typecheck timeout 15 -> 25 minutes.
- Fix the header comment, which conflated changed-file with changed-line.
- Remove the commented-out lint-staged block and tsc-files note from
.husky/pre-commit, left over from the dep removed in the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(lint): annotate findings, block on newly added files
Re-review fixes.
- Add .github/problem-matchers/eslint-unix.json and register it, so ESLint
findings become file annotations on the PR diff. The previous header
claimed "annotations still surface" - they did not. There was no matcher
anywhere in .github/, and neither setup-node nor action-setup registers
one, so findings only ever reached a step log inside a green check.
Prettier annotates via ::warning / ::error commands.
- Split the lint steps by diff filter. Newly ADDED files (--diff-filter=A)
are BLOCKING; modified/renamed (CMR) stay report-only. This stops the
backlog growing while the full flip waits on the Prettier 2->3 upgrade.
Added files are checked for errors only, without --max-warnings: the repo
carries 3,470 warnings and 1,762 uses of `any`, so failing on warnings
would hold new files to a stricter bar than anything already merged.
- Qualify the header claim about typecheck being the real gate; it is
skipped on fork PRs, so a fork gets no gating from this workflow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
eaeca777c0 |
chore(auth): add release:auth scripts + sync version; remove redundant GH Actions build (#2781)
* chore(auth): add release:auth scripts + sync version; remove redundant GH Actions build Wire a per-app release flow for the auth hub (apps/auth), mirroring the main app's `pnpm run release` ergonomics: - Add root `release:auth[:patch|:minor|:major]` scripts. They bump apps/auth/package.json via `npm --prefix apps/auth version <bump> --tag-version-prefix=auth-app-v`, create an `auth-app-vX.Y.Z` tag, and push --follow-tags. No `release:base` — the hub deploys off the tag (ghcr+Flux), not the `release` branch. - Sync apps/auth/package.json 0.0.0 -> 0.1.0 to match the manually-cut deployed 0.1.0, so the first scripted release is 0.1.1 (above the deployed semver that Flux's highest-semver ImagePolicy selects). - Remove .github/workflows/auth-app.yml: the hub now builds in-cluster via the Tekton tag-webhook on the same auth-app-v* tags; keeping the GH Actions workflow would double-build (paid runner) and push a competing image. - Docs: add docs/auth/releasing.md, link it from auth-index.md, and add a Releasing section to apps/auth/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth-release): commit+tag via release-app.mjs (npm --prefix skips git at root) Audit (PR #2781) found the release:auth scripts were inert: npm version --prefix apps/auth only creates the commit/tag when .git is in apps/auth, but .git is at the monorepo root — so it silently bumped the version field and skipped the tag (exit 0), releasing nothing + leaving a dirty tree. Replace with scripts/release-app.mjs: guards on-main + clean-tree, git pull --rebase, bumps with --no-git-tag-version, then commits ONLY apps/auth/package.json + annotated tag + push --follow-tags. Verified in a throwaway monorepo (tag created+pushed, root untouched, tree clean). Docs: prerequisites + rollback + one-at-a-time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
df785325a1 |
ci(auth): add GitHub Actions build+push for ghcr.io/civitai/civitai-auth
First per-app CI in the monorepo. Replaces the hand-built hub image with a tag-triggered Actions workflow that publishes a semver-tagged image, slotting into the existing Flux ImagePolicy (semver >=0.0.1) in datapacket-talos. - .github/workflows/auth-app.yml: buildx + gha cache build of apps/auth/Dockerfile (context = repo root, linux/amd64). Push ghcr.io/civitai/civitai-auth:<semver> (+ :sha-<short>) on a `auth-app-v*` tag; PR-paths gate compiles without pushing. No :latest (Flux selects by semver). permissions: contents:read, packages:write. - apps/auth/.env.example: fix stale cookie comment — the session cookie is civ-token / __Secure-civ-token (distinct from legacy civitai-token), not the shared civitai-token. Matches SESSION_COOKIE_BASE in packages/civitai-auth. - apps/auth/docs/ci.md: scoping note (Actions vs Tekton evidence, tag scheme, Flux tie-in, release flow). Authored + actionlint-clean; not run-verified (tag builds need the workflow on the default branch — first run is post-merge). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ab7030b0bd |
ci: remove deploy-release/branch/stage workflows (move off GitHub Actions runners) (#2551)
* ci: remove deploy-release.yml (failing Tekton-deploy tracker) The "Release Deploy" workflow parks a runner up to 45min on every `release` push polling for a GitHub deployment object — which the Tekton-based deploy no longer creates, so it times out and fails every run (4/4 recent = 40min failures). Tekton builds + deploys civitai-prod independently on the `release` push; this workflow only *tracked* (read deployment statuses) and now tracks nothing. Removing it eliminates the largest remaining GitHub Actions runner consumer with zero effect on the actual deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: remove deploy-branch.yml + deploy-stage.yml (dead dev/stage deploy triggers) These fired dev/stage builds in civitai/civitai-deployment on deploy-dev / deploy-stage labels but have been dormant (last 30 runs of each = all skipped; dev/stage deploys retired/moved). Together with deploy-release.yml (this PR), pr-check.yml (#2547), and the disabled CodeQL default-setup, this removes civitai's billable GitHub Actions runner usage. Intentionally retained: submodule-pin-guard.yml (the "event-engine-common pin" check) — a cheap (~22s, only when the pin changes) guard against an accidental backward submodule-pin move that already shipped a prod regression once. Worth its trivial runner cost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e29a606955 |
test(component): Vitest browser-mode component-testing scaffold (runs in Tekton, removes GH Actions pr-check) (#2547)
* test(component): add Vitest browser-mode component-testing scaffold + SeedInput Adds a second Vitest project (`component`, browser mode / real Chromium via Playwright) alongside the unchanged 857-test `unit` suite. Includes a renderWithProviders scaffold (Mantine + QueryClient + next/router mock), a process.env shim for browser mode, a report-only GH Actions `component-tests` job, and the first test on the high-churn, e2e-impossible SeedInput generation leaf (6 cases, mutation-proven). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(component): address audit findings (typecheck test/, seed-test teeth, optimizeDeps) - tsconfig: add `test` to include so the load-bearing browser-process-shim + component-setup are actually typechecked (were only checked transitively/not at all). (audit H1) - SeedInput test: stub Math.random for an exact-value assertion bounded by MAX_RANDOM_SEED (was a loose MAX_SEED range that survived a constant-seed mutation). (audit M1) - vitest component project: optimizeDeps.include next/router to stop the "Vite unexpectedly reloaded a test" flake warning. (audit L4) - drop the stale "857 tests" count from the config comment. (audit M2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: remove GitHub Actions pr-check.yml — consolidate PR checks onto Tekton typecheck + unit-tests already run in the Tekton PR-preview pipeline (author- gated on MEMBER/OWNER/COLLABORATOR), and component-tests now run there too (talos-infra: report-only npm-component-tests task). Removing this workflow stops paying for GitHub Actions runner time. Tradeoff: external-contributor PRs (not author-authorized) no longer get automated checks — accepted per the "pure Tekton" decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
21c115b9af |
ci: guard event-engine-common submodule pin against accidental regressions (#2506)
Fails a PR that moves the event-engine-common gitlink BACKWARD (to an
ancestor of the base pin). Catches the stale-submodule-checkout clobber
that silently reverts the pin alongside unrelated changes — which shipped
an image:tagIds cache TTL regression to prod (
|
||
|
|
de6712ec63 |
ci(bundle): report-only First Load JS budget in the Dockerfile build (Tekton) (#2511)
* ci(bundle): add report-only size-limit bundle budget job Next 16 removed per-route build stats, leaving no bundle-size regression signal. Adds a `bundle-budget` job to pr-check.yml that builds the app (SKIP_ENV_VALIDATION, no secrets) and runs size-limit over the shared client chunks (framework/main/webpack/_app + a coarse total) defined in .size-limit.json. Report-only for now: continue-on-error + intentionally loose limits. This is also the first GH Actions job to run a full `next build` (~8GB heap vs ~7GB standard runner) so early runs probe feasibility. Once a baseline is observed: tighten limits to baseline+headroom, drop continue-on-error, and make "Bundle Budget" a required check to gate. If Build OOMs, move to a larger runner label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * ci(bundle): run size-limit in the Dockerfile build, not GH Actions Switch the bundle-size check from a separate GH Actions job (which would duplicate the full ~8GB next build) to a stage in the Dockerfile builder, right after `pnpm run build` where .next already exists. The Tekton buildkit build (preview + prod) now reports the size-limit numbers with no extra build — consistent with where app builds live. Report-only during the soak via `|| true` (numbers print to the build log). To gate later: drop `|| true` so a bundle regression fails the image build. Reverts the pr-check.yml bundle-budget job; keeps .size-limit.json + the size-limit deps + the `size` script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): fix size-limit globs for Turbopack output The live preview build (next 16.2.7 Turbopack) revealed the webpack-era globs match nothing — Turbopack emits opaque hashed chunks (0--619vzepha0.js, turbopack-*.js), no framework-/main-/webpack-/_app- files. Those 4 entries errored ("can't find files"); only the recursive total worked. Baseline from the build: total client JS = 38.29 MB brotli (3615 chunks). Drop the 4 broken named-chunk entries; keep the working total with a 42 MB limit (~10% headroom). Still report-only (|| true in Dockerfile). Note: the coarse total is a weak regression signal under Turbopack's heavy code-splitting; a per-page First Load JS budget needs parsing .next/build-manifest.json (follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): manifest-based First Load JS budget (replaces size-limit) size-limit's globs can't see Turbopack's opaque hashed chunks, so it could only report a coarse 38 MB total (weak signal). Replace it with scripts/bundle-budget.mjs, which parses .next/build-manifest.json to reconstruct the metric Next used to print: First Load JS(route) = brotli(union(pages[route], pages["/_app"], polyfills)) shared-by-all-pages = brotli(pages["/_app"] + polyfills) Reports shared + total + the heaviest routes, checks .bundle-budget.json (report-only; `--gate` exits non-zero on a breach). No deps (Node stdlib zlib/fs), no extra build — still runs in the Dockerfile builder stage. Removes size-limit + @size-limit/file + .size-limit.json. Budgets are loose placeholders; tighten to baseline+headroom from the first build's printed First Load JS numbers, then add --gate + drop the `|| true` to enforce. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): tighten First Load JS budgets to baseline + headroom From build pr-preview-2511-fzrjd: shared-by-all = 425.9 kB, heaviest route (/user/[username]/models) = 1.13 MB. Set shared 470 kB (~10%) and routeMax 1.3 MB (~15%) so the report-only check is meaningful instead of passing trivially at the 1 MB/3 MB placeholders. Still report-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): bake bundle-budget report into the image for PR surfacing Write the size report to /app/bundle-budget.txt (still report-only, still printed to the build log) and COPY it into the runner image. A new Tekton bundle-comment task surfaces it on the PR via `kubectl exec ... cat` — no duplicate build. Uses redirect+cat instead of `| tee` so the script's exit code is preserved for the future --gate flip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview build (bundle-comment task now live) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (bundle-comment rollout-race fix live) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (pr-deployer exec RBAC now granted) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): drop pnpm preamble from the bundle report Invoke `node scripts/bundle-budget.mjs` directly instead of `pnpm run size` so pnpm's lifecycle echo (`> model-share@… size /app`) stays out of /app/bundle-budget.txt and the PR comment. The `size` script stays in package.json for local use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (collapsible bundle comment) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> |
||
|
|
6431a7579c |
ci(pr-check): gate the full vitest unit suite (857 tests) (#2489)
The full test:unit:run suite was made CI-green by #2478 but pr-check.yml still ran only typecheck + the no-io-in-transaction lint-rule subset; the suite ran report-only in the DP Tekton preview pipeline. Add a unit-tests job that runs the full suite on every PR so it actually blocks GitHub checks (pending branch-protection making it required). No DB/Redis needed — it is unit-level; Prisma client comes from the install postinstall. |
||
|
|
1730a4dc2a |
feat(lint): no-io-in-transaction rule + clear existing violations (#2382)
* feat(lint): no-io-in-transaction rule + clear existing violations
Adds a custom ESLint rule (eslint-local-rules.js) that flags awaited
external/non-DB I/O inside a Prisma interactive `$transaction(async (tx) =>
…)` callback. Such calls (HTTP fetch, image scanner, Buzz API, Axiom logging,
Redis cache busts, search-index queueing) add their latency to the txn's
wall-clock timeout budget and, when slow, blow it ("Transaction already
closed"). This is the recurring class behind #2375 / #2377 / #2379 — the rule
turns "sweep it again" into "caught in review/IDE".
Detection is a curated denylist of known I/O call names (low false-positive);
calls on the tx client itself (`tx.*`, `tx.$queryRaw`/`$executeRaw`) are
always allowed. Validated with a RuleTester suite (10 cases).
Wiring is conditional in .eslintrc.js: the rule activates automatically once
`eslint-plugin-local-rules` is installed (`pnpm add -D eslint-plugin-local-
rules`) and is skipped until then, so `next lint` keeps working and CI's
`pnpm install --frozen-lockfile` is unaffected (no package.json/lockfile
change in this PR — pnpm wasn't available to regenerate the lockfile).
Brings the codebase to a clean baseline for the rule:
Fixed (moved external work after commit / made fire-and-forget):
- collection/article(x2)/model(x2)/bountyEntry: userXCountCache.refresh()
(Redis) moved to after the txn commits, using the returned row's id.
- referral/redeemableCode: error-branch logToAxiom() de-awaited (Axiom HTTP),
matching the #2379 pattern (.catch retained / added).
Ratchet-disabled with TODO(tx-io) (intentional / needs careful change):
- bounty.createBounty + bountyEntry.awardBountyEntry: Buzz charge/settlement
inside the txn — moving needs charge→tx→refund-on-failure compensation
(a PG rollback can't undo an external Buzz charge); left for a domain owner.
- report.createReport CSAM branch: search-index delete inside the txn —
moving needs hoisting the CSAM guard post-commit on a sensitive path.
tsc --noEmit error-neutral vs baseline across all touched files (pre-existing
Prisma-client-drift errors unchanged; CI regenerates the client).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lint): audit follow-ups — install plugin, warn-level, create-only refresh, tests
Addresses the audit of #2382:
- H1: install `eslint-plugin-local-rules` (devDep + lockfile via pnpm
--lockfile-only) and wire the rule unconditionally. Previously the rule was
only activated when the plugin happened to resolve, but the 6
`// eslint-disable-next-line local-rules/no-io-in-transaction` directives
error with "Definition for rule not found" in ESLint 8 when the rule is
unconfigured — so `next lint` broke in the plugin-absent state. With the
plugin now a real dependency the rule is configured and the directives are
valid.
- Rule severity set to `warn` (not `error`): surfaces in the editor / next lint
as a guardrail without failing lint or the build; escalate later.
- M1: bountyEntry.upsertBountyEntry count-cache refresh is now gated to the
create path (`!id`). The pre-move code only refreshed in the create branch;
the first move ran it on updates too (extra primary-DB COUNT + Redis on every
description edit). Restored create-only semantics.
- Test coverage: add src/server/services/__tests__/no-io-in-transaction.test.ts
(RuleTester via vitest, 20 cases incl. FP/FN regression guards). Runs with
`pnpm test:unit:run`; 20/20 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(pr-check): gate the no-io-in-transaction rule via its RuleTester suite
Adds a `test:lint-rules` script (vitest run of the rule's test) and a
"Test lint rules" step to the pr-check workflow, right after typecheck (reuses
the job's already-installed deps).
Scoped to the rule's own test rather than the full `test:unit:run` suite: the
full suite currently has pre-existing failures (e.g. a timezone-dependent
redeemableCode date assertion) and isn't CI-green, so wiring it wholesale would
block all PRs. This step deterministically gates the custom rule's correctness;
broadening to the full suite is a separate cleanup once those failures are fixed.
Note: the rule itself is `warn`-level, so `next lint` surfaces violations
without failing the build — this CI step gates the RULE (regressions in
eslint-local-rules.js), not new violations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: retrigger preview (prior build hit transient pnpm-install network timeouts)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
7cd8e3b3c2 |
fix: replace broken deploy-release workflow with Tekton status tracker
The old workflow called civitai-deployment/deploy.yml which no longer exists (migrated to Tekton). Replace with a status poller that watches the GitHub Deployments API for Tekton pipeline status updates. Devs get a green/red check on release branch commits. The actual build/deploy is handled by Tekton on the gpu-fleet cluster. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
cc12913373 | Remove pr bot | ||
|
|
3f23c04f7d |
fix: Follow redirects in PR bot webhook curl
Add -L and --post301 flags to handle nginx 301 redirects without losing the POST body. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> |
||
|
|
d5c967de3e | Merge pull request #1982 from civitai/chore/migrate-to-pnpm | ||
|
|
322792055b |
Add PR Bot webhook workflow for automated PR analysis
Triggers on PR open/sync/reopen events and sends webhook to PR Bot service which uses Claude to analyze changes and post summaries to Discord. Requires secrets: - PR_BOT_WEBHOOK_SECRET: HMAC signing key - PR_BOT_WEBHOOK_URL: PR Bot endpoint URL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> |
||
|
|
3c8e8ab41a |
chore: update pr-check workflow to use pnpm
- Use corepack to install pnpm@10.28.1 - Add pnpm store caching for faster CI runs - Replace npm ci with pnpm install --frozen-lockfile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> |
||
|
|
0f483eedef |
Remove lint job from PR check workflow
The lint job was failing due to 31 pre-existing prettier/eslint errors in the codebase. Removing it to focus on type checking only for now. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> |
||
|
|
68bdfb2a16 |
Add PR check workflow for typecheck and lint
Adds GitHub Actions workflow that runs on all PRs to main: - Type checking with `npm run typecheck` - Linting with `npm run eslint` This prevents TypeScript errors from being merged to main and breaking CI builds. Note: Requires SUBMODULE_SSH_KEY secret for event-engine-common submodule. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Fix SSH key handling for submodule checkout Use webfactory/ssh-agent to properly configure SSH for submodules while using HTTPS (default token) for the main repo checkout. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> |
||
|
|
51827aaf64 |
- stage deployment update
- hide auction titles that have bad words in them - show tooltip on truncated titles |
||
|
|
cbef49752c | add workflow to deploy the stage site | ||
|
|
0f7d7fca1c | enable manual deploys for main and release | ||
|
|
b468e2c0c7 | extract source branch name from PRs | ||
|
|
21963f8189 | remove assocation check | ||
|
|
345ead9877 | include more roles | ||
|
|
0882d5f10e | use PR branch name | ||
|
|
5b850d2fb6 | move branch patterns into condition | ||
|
|
8bfffc7a05 | deploy from feature and fix branches if PR was opened by a team member | ||
|
|
94c4974ae3 | add workflow for branch deploys |