From dd83bf564c8156bc6734393f02c66ac82378f4dc Mon Sep 17 00:00:00 2001 From: E Ismail Date: Sun, 4 Oct 2026 21:46:41 +0300 Subject: [PATCH] pack: a public folder over the output file limit stops the deploy before anything is sent Astro copies the public folder into the built site whole, dotfiles included, so that folder's file count is a floor on the site's. A project holding more than 1,000 files there passed every local check, uploaded, built, and was then refused for producing more files than a site may hold. The answer was available locally, for nothing. The count is made inside Limits over the walk's own list under the public folder the deploy resolved from the config, so files the walk excluded are not counted and the disk is not read a second time. Which files sit under the folder is asked of the same function the name check uses, so the two cannot disagree about a sibling folder. It refuses strictly over the limit, as the server does, and it has its own check and failure id, limit-public-files, so that one id names one number. The fallback warning now says the default folder was also the one counted, and the README, the deploy tool's description and CLAUDE.md say the public folder is checked locally while everything else the build emits is still the server's to count. --- CLAUDE.md | 7 + README.md | 27 ++- catalog.json | 16 ++ internal/check/check.go | 27 ++- internal/check/check_test.go | 4 +- internal/check/combine_test.go | 8 +- internal/check/coverage_test.go | 2 +- internal/check/order.go | 8 +- internal/check/report_test.go | 1 + internal/flow/deploy.go | 2 +- internal/flow/deploy_test.go | 101 ++++++++- internal/mcp/deploy.go | 9 +- internal/pack/limits.go | 116 +++++++++- internal/pack/limits_test.go | 278 +++++++++++++++++++++--- internal/pack/readme_test.go | 6 +- internal/preflight/folders.go | 2 +- internal/preflight/folders_test.go | 2 +- internal/preflight/registration_test.go | 2 +- internal/ui/failureid.go | 5 +- 19 files changed, 539 insertions(+), 84 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 989f09d..3db160a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -663,6 +663,13 @@ archive adds bytes of its own for every entry it holds, so a great many small files that do not compress can weigh more packed than they do on disk. The message says so, and names the files worth removing. +One limit on what the BUILD produces is also checked here: a project +whose public folder alone holds more than **1,000 files** is refused, +because Astro copies that folder into the built site whole and the server +refuses a site over 1,000 files. That folder's count is a floor on the +site's, so once the folder is known the refusal is certain rather than a +guess; everything else the build emits only the server can count. + These are stated here because they are useful to know before you try. The client checks them so you get a fast, local, specific answer instead of a failed upload — but **the client is not the boundary.** Every one of them diff --git a/README.md b/README.md index 9a7b1f0..d8f5c6e 100644 --- a/README.md +++ b/README.md @@ -238,15 +238,19 @@ archive adds bytes of its own for every entry it holds, so a great many small, incompressible files can weigh more once packed than they do on disk — `curious` names the files worth removing when that happens. -Two more numbers bound what the build produces, and only the server can -check them, because the output does not exist until the build has run: +Two more numbers bound what the build produces, and the server checks +both once the build has run: - `MaxOutputFiles` — 1,000 files at most. - `MaxOutputTotalBytes` — 30 MB in total, counting the small badge added to each HTML page. -A site over either is refused when its build finishes, with a message -giving the size it reached and the limit. +One part of the first is known before anything is sent. Astro copies the +public folder into the site whole, so `curious` counts that folder +locally and refuses a project whose public folder alone holds more than +1,000 files. Everything else the build emits, only the server can count. +A site over either limit is refused with a message giving the size it +reached and the limit. ## Where the login lives @@ -488,6 +492,17 @@ It names how many files the project holds and how many one deploy may carry, then the handful of directories holding the most of them — so you have somewhere to point an ignore rule rather than a list to read. +### limit-public-files + +**pre-flight.** A person is having the project checked before anything leaves the machine. + +_The message here is assembled at run time, so there is no fixed sentence to quote — see "A note on composed messages," below._ + +It names the public folder, how many files it holds and the most a site +can hold, then the handful of directories inside it holding the most +files. The build copies that folder into the site whole, so a folder over +the limit is a site over the limit before anything else is added. + ### limit-packed **pre-flight.** A person is having the project checked before anything leaves the machine. @@ -843,7 +858,7 @@ and the message says so rather than blaming your network. ### A note on composed messages -Eighteen (id, stage) pairs build their message from the situation at run +Nineteen (id, stage) pairs build their message from the situation at run time rather than writing one fixed sentence, and are deliberately not templated above: a hand-typed guess at their shape would be wording nothing checks, which is the exact fragility this whole section exists @@ -856,7 +871,7 @@ to end. They are: `upload-refused-unexplained`, `upload-signature-mismatch`, `upload-stalled`. - **pre-flight** — `limit-file-size`, `limit-files`, `limit-packed`, - `limit-total`, `path-charset`. + `limit-public-files`, `limit-total`, `path-charset`. - **this machine** — `project-dir-missing`, `project-dir-unreadable`, `project-path-not-a-directory`. diff --git a/catalog.json b/catalog.json index 5eeaf76..937cdbf 100644 --- a/catalog.json +++ b/catalog.json @@ -391,6 +391,22 @@ "pre-flight" ] }, + { + "action": [ + "FreshDeploy" + ], + "family": "limit-public-files", + "headline": { + "pre-flight": null + }, + "headline_reason": { + "pre-flight": "composed" + }, + "id": "limit-public-files", + "stages": [ + "pre-flight" + ] + }, { "action": [ "FreshDeploy" diff --git a/internal/check/check.go b/internal/check/check.go index 02f7860..2c4ec88 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -87,9 +87,10 @@ const ( IDCaseCollision = "case-collision" IDPathCharset = "path-charset" - // The four the local limits own: how many files there are, how big - // the largest is, how big they are together, and how big the archive - // turned out once they were packed. + // The five the local limits own: how many files there are, how many + // of them sit in the public folder, how big the largest is, how big + // they are together, and how big the archive turned out once they were + // packed. // // THEY ARE IN THE UNIVERSE RATHER THAN OUTSIDE IT, and that was a // decision with a real alternative. Leaving them out would work — @@ -106,10 +107,11 @@ const ( // with no limit rows cannot be built at all, so a legitimate hard // stop found before them would be a hard stop the program could not // render. - IDLimitFiles = "limit-files" - IDLimitFileSize = "limit-file-size" - IDLimitTotal = "limit-total" - IDLimitPacked = "limit-packed" + IDLimitFiles = "limit-files" + IDLimitPublicFiles = "limit-public-files" + IDLimitFileSize = "limit-file-size" + IDLimitTotal = "limit-total" + IDLimitPacked = "limit-packed" ) // Finding is one pre-flight check's result: which check produced it, how @@ -399,9 +401,10 @@ const ( FamilyLockfileWorkspace FailureFamily = "lockfile-workspace" FamilyLockfileMissing FailureFamily = "lockfile-missing" - FamilyLimitFiles FailureFamily = "limit-files" - FamilyLimitFileSize FailureFamily = "limit-file-size" - FamilyLimitTotal FailureFamily = "limit-total" - FamilyLimitPacked FailureFamily = "limit-packed" - FamilyPathCharset FailureFamily = "path-charset" + FamilyLimitFiles FailureFamily = "limit-files" + FamilyLimitPublicFiles FailureFamily = "limit-public-files" + FamilyLimitFileSize FailureFamily = "limit-file-size" + FamilyLimitTotal FailureFamily = "limit-total" + FamilyLimitPacked FailureFamily = "limit-packed" + FamilyPathCharset FailureFamily = "path-charset" ) diff --git a/internal/check/check_test.go b/internal/check/check_test.go index c048df4..1f4a82c 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -221,7 +221,7 @@ func TestDeclaredOrderIsTheCompleteUniverseInReportOrder(t *testing.T) { want := []string{ IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDLocalhost, IDSymlinks, IDCaseCollision, IDPathCharset, - IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, + IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, } if got := DeclaredOrder(); !reflect.DeepEqual(got, want) { t.Errorf("DeclaredOrder() = %v, want %v", got, want) @@ -243,7 +243,7 @@ func TestDeclaredOrderHandsBackACopy(t *testing.T) { want := []string{ IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDLocalhost, IDSymlinks, IDCaseCollision, IDPathCharset, - IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, + IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, } if got := DeclaredOrder(); !reflect.DeepEqual(got, want) { t.Errorf("after a caller overwrote what it was given, DeclaredOrder() = %v, want %v", diff --git a/internal/check/combine_test.go b/internal/check/combine_test.go index bebaab1..fb481e7 100644 --- a/internal/check/combine_test.go +++ b/internal/check/combine_test.go @@ -46,6 +46,7 @@ func TestCombineMergesProducersIntoOneOrderedResult(t *testing.T) { limits := Results{ Manifest: Manifest{ {CheckID: IDLimitFiles}, + {CheckID: IDLimitPublicFiles}, {CheckID: IDLimitFileSize}, {CheckID: IDLimitTotal}, {CheckID: IDLimitPacked, Outcome: Declined, Kind: ByDesign, Reason: "nothing packed yet"}, @@ -81,7 +82,7 @@ func TestCombineMergesProducersIntoOneOrderedResult(t *testing.T) { wantManifest := []string{ IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDLocalhost, IDSymlinks, IDCaseCollision, IDPathCharset, - IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, + IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, } if rows := ids(got.Manifest()); !reflect.DeepEqual(rows, wantManifest) { t.Errorf("manifest = %v, want the declared order %v", rows, wantManifest) @@ -231,6 +232,7 @@ func TestCombineDoesNotDisturbTheProducersItWasGiven(t *testing.T) { {CheckID: IDCaseCollision}, {CheckID: IDPathCharset}, {CheckID: IDLimitFiles}, + {CheckID: IDLimitPublicFiles}, {CheckID: IDLimitFileSize}, {CheckID: IDLimitTotal}, {CheckID: IDLimitPacked}, @@ -247,7 +249,7 @@ func TestCombineDoesNotDisturbTheProducersItWasGiven(t *testing.T) { if rows := ids(producer.Manifest); !reflect.DeepEqual(rows, []string{ IDLocalhost, IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDSymlinks, IDCaseCollision, IDPathCharset, - IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, + IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, }) { t.Errorf("the caller's manifest was reordered: %v", rows) } @@ -273,7 +275,7 @@ func TestCoverageGapsReportsBothDirections(t *testing.T) { wantMissing := []string{ IDLockfile, IDPagesDir, IDBuildFormat, IDSymlinks, IDCaseCollision, IDPathCharset, - IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, + IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, } if !reflect.DeepEqual(missing, wantMissing) { t.Errorf("missing = %v, want %v in the declared order", missing, wantMissing) diff --git a/internal/check/coverage_test.go b/internal/check/coverage_test.go index 90133ef..dd5d95a 100644 --- a/internal/check/coverage_test.go +++ b/internal/check/coverage_test.go @@ -80,7 +80,7 @@ func TestCombinedCoverageEqualsTheDeclaredUniverse(t *testing.T) { t.Fatalf("walking an empty directory: %v", err) } - combined, err := check.Combine(engine, tree.Results, pack.Limits(tree.Files)) + combined, err := check.Combine(engine, tree.Results, pack.Limits(tree.Files, pack.DefaultNameScope.Public)) if err != nil { t.Fatalf("combining the producers: %v", err) } diff --git a/internal/check/order.go b/internal/check/order.go index 1d94372..b6d598a 100644 --- a/internal/check/order.go +++ b/internal/check/order.go @@ -27,12 +27,13 @@ import "sort" // among the three still appears above every warning — what this decides // is the sequence WITHIN a severity, and the order of the manifest. // -// THE FOUR LIMITS COME LAST, and the principle is the same one again. +// THE FIVE LIMITS COME LAST, and the principle is the same one again. // Everything above answers whether this project can BUILD; these answer // whether it can be SENT, which is a question about something already // established to be a project. Among themselves they run smallest -// question first — how many files there are, then how big one of them -// is, then how big they are together — and the packed size last of all, +// question first — how many files there are, then how many of them the +// public folder holds, then how big one of them is, then how big they are +// together — and the packed size last of all, // because it is the only one whose subject does not exist until every // other has passed. var declaredOrder = []string{ @@ -45,6 +46,7 @@ var declaredOrder = []string{ IDCaseCollision, IDPathCharset, IDLimitFiles, + IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked, diff --git a/internal/check/report_test.go b/internal/check/report_test.go index 5d4d33d..a797055 100644 --- a/internal/check/report_test.go +++ b/internal/check/report_test.go @@ -236,6 +236,7 @@ func TestCombineIsIndependentOfArgumentOrder(t *testing.T) { {CheckID: IDCaseCollision}, {CheckID: IDPathCharset}, {CheckID: IDLimitFiles}, + {CheckID: IDLimitPublicFiles}, {CheckID: IDLimitFileSize}, {CheckID: IDLimitTotal}, {CheckID: IDLimitPacked}, diff --git a/internal/flow/deploy.go b/internal/flow/deploy.go index 21e6898..9efe5b8 100644 --- a/internal/flow/deploy.go +++ b/internal/flow/deploy.go @@ -459,7 +459,7 @@ func Deploy(ctx context.Context, deps DeployDeps) (*Handoff, error) { // // The same gate is what makes the check list below checkable from // outside: forget one, and the report cannot be built at all. - limits := pack.Limits(tree.Files) + limits := pack.Limits(tree.Files, folders.Public) report, err := check.Combine( preflight.Run(deployChecks(tree.Files), preflight.OSFileSystem{}, root), tree.Results, diff --git a/internal/flow/deploy_test.go b/internal/flow/deploy_test.go index 15dc0e8..c44597a 100644 --- a/internal/flow/deploy_test.go +++ b/internal/flow/deploy_test.go @@ -19,6 +19,7 @@ import ( "github.com/curiouspub/cli/internal/config" "github.com/curiouspub/cli/internal/pack" "github.com/curiouspub/cli/internal/ui" + "github.com/curiouspub/cli/internal/units" "github.com/curiouspub/cli/pkg/wire" ) @@ -2024,7 +2025,7 @@ func TestTheDeployChecksNamesWhereTheSiteKeepsThem(t *testing.T) { _, err := run.run() events := run.journal.all() said := strings.Join(events, "\n") - if !strings.Contains(said, "checked under public/ instead") { + if !strings.Contains(said, "checked and files counted under public/ instead") { t.Errorf("the run never said where it checked:\n %s", strings.Join(events, "\n ")) } if indexOfEvent(events, "asked: Continue anyway?") < 0 { @@ -2198,6 +2199,104 @@ func TestWhatEachEndingCosts(t *testing.T) { // What a refused run leaves behind // ------------------------------------------------------------------- +// publicFolderFiles is n small files under dir, nested two deep, with one +// hidden file among them, shaped as astroProject's extra argument. +func publicFolderFiles(dir string, n int) map[string][]byte { + out := map[string][]byte{dir + "/.well-known": []byte("x")} + for i := 1; i < n; i++ { + out[fmt.Sprintf("%s/d%d/e%d/f%04d.txt", dir, i%2, i%3, i)] = []byte("x") + } + return out +} + +// whatThePublicStopSaid is everything a person would have read: the +// refusal as rendered, with its failure id line, and every journal event. +func whatThePublicStopSaid(t *testing.T, run *deployRun, err error) string { + t.Helper() + text, _ := renderedBytes(t, err) + return text + "\n" + rendered(err) + "\n" + strings.Join(run.journal.all(), "\n") +} + +// TestAPublicFolderOverTheOutputCapIsRefusedBeforeAnythingIsSent proves +// the flow hands the count the folder it resolved: the default folder, a +// configured one, and the fallback when the config cannot be read. Astro +// copies that folder into the site whole, and the server refuses a site +// over the file cap, so the answer is available locally and free. +// +// REQUIRED MUTATION, run 2026-10-04: have Deploy pass a fixed "public" to +// the limits. The configured-folder half reds; the others stay green. +// Also run: make the finding a warning. The default, configured and +// fallback halves red, and the control stays green. Also run: record the +// public-folder row as declined. The default and fallback halves red, and +// so does the control. +func TestAPublicFolderOverTheOutputCapIsRefusedBeforeAnythingIsSent(t *testing.T) { + refused := func(t *testing.T, run *deployRun) string { + t.Helper() + handoff, err := run.run() + if err == nil { + handoff.Release() + t.Fatal("a public folder over the cap deployed") + } + if handoff != nil { + t.Error("a refused run handed back a request") + } + if sent := run.script.sent(); sent != 0 { + t.Errorf("the refused project sent %d requests (%v), want none", sent, run.script.paths) + } + if left := run.leftBehind(); len(left) != 0 { + t.Errorf("the run left %v behind", left) + } + return whatThePublicStopSaid(t, run, err) + } + + t.Run("the default folder", func(t *testing.T) { + run := newDeployRun(t, writeProject(t, astroProject(publicFolderFiles("public", 1_001)))) + said := refused(t, run) + for _, want := range []string{"limit-public-files", units.Count(1_001), units.Count(wire.MaxOutputFiles)} { + if !strings.Contains(said, want) { + t.Errorf("the output does not contain %q:\n%s", want, said) + } + } + }) + + t.Run("a configured folder", func(t *testing.T) { + files := publicFolderFiles("static", 1_001) + files["astro.config.mjs"] = []byte("export default { publicDir: './static' };\n") + run := newDeployRun(t, writeProject(t, astroProject(files))) + said := refused(t, run) + if !strings.Contains(said, "limit-public-files") { + t.Errorf("the output does not name the public-folder stop:\n%s", said) + } + }) + + t.Run("a folder the config could not settle falls back, and says so", func(t *testing.T) { + files := publicFolderFiles("public", 1_001) + files["astro.config.mjs"] = []byte("const dir = './static';\nexport default { publicDir: dir };\n") + run := newDeployRun(t, writeProject(t, astroProject(files))) + said := refused(t, run) + for _, want := range []string{"limit-public-files", "checked and files counted under public/ instead"} { + if !strings.Contains(said, want) { + t.Errorf("the output does not contain %q:\n%s", want, said) + } + } + }) + + t.Run("control: a folder at the cap deploys and sends", func(t *testing.T) { + run := newDeployRun(t, writeProject(t, astroProject(publicFolderFiles("public", 1_000)))).scriptedLogin() + run.prompt.confirms = []answer{no()} + + handoff, err := run.run() + if err != nil { + t.Fatalf("Deploy: %v\n%s", err, rendered(err)) + } + defer handoff.Release() + if run.script.sent() == 0 { + t.Error("the control sent nothing either, so the zeros above say nothing " + + "about the stop and everything about the instrument") + } + }) +} + // TestARefusedRunPacksNothingAndLeavesNothingBehind covers every way a // run can stop before the pack, and its control is the run that does // pack. diff --git a/internal/mcp/deploy.go b/internal/mcp/deploy.go index 61165db..3a0acb4 100644 --- a/internal/mcp/deploy.go +++ b/internal/mcp/deploy.go @@ -100,7 +100,10 @@ func deploySiteDescription() string { "network call at all. The client refuses a project with more than %d files, "+ "any single file over %d bytes, more than %d bytes of source in total, or an "+ "archive over %d bytes once packed — and the server checks every one of them "+ - "again, so these are a fast local answer rather than the boundary. The build "+ + "again, so these are a fast local answer rather than the boundary. The client "+ + "also refuses, before any network call, a project whose public folder holds "+ + "more than %d files: the build copies that folder into the site whole, so the "+ + "server would refuse the site. The build "+ "must also produce no more than %d files and no more than %d bytes.\n\n"+ "WARNINGS DO NOT STOP A DEPLOY HERE. Anything the pre-flight checks found "+ "comes back in findings, with a severity on each, and the deploy goes ahead — "+ @@ -115,8 +118,8 @@ func deploySiteDescription() string { "The address starts answering a little after the deploy is published, so opening "+ "it immediately may show a placeholder page.", wire.MaxSourceFiles, wire.MaxSourceFileBytes, wire.MaxSourceTotalBytes, - wire.MaxPackedBytes, wire.MaxOutputFiles, wire.MaxOutputTotalBytes, - toolLoginStart) + wire.MaxPackedBytes, wire.MaxOutputFiles, wire.MaxOutputFiles, + wire.MaxOutputTotalBytes, toolLoginStart) } func deploySiteTool() Tool { diff --git a/internal/pack/limits.go b/internal/pack/limits.go index 425986e..d2db0b4 100644 --- a/internal/pack/limits.go +++ b/internal/pack/limits.go @@ -14,11 +14,12 @@ import ( "github.com/curiouspub/cli/pkg/wire" ) -// The local limits: how many files a project may send, how big any one -// of them may be, how big they may be together, and how big the archive -// may be once they are packed. +// The local limits: how many files a project may send, how many of them +// may sit in the public folder, how big any one of them may be, how big +// they may be together, and how big the archive may be once they are +// packed. // -// THE FIRST THREE ARE MEASURED BEFORE ANYTHING IS PACKED, and that +// THE FIRST FOUR ARE MEASURED BEFORE ANYTHING IS PACKED, and that // ordering is the product rather than an optimisation. Compressing 30 MB // to discover it was never going to be allowed spends the user's time at // the one point in the flow where they are watching a progress line, and @@ -64,6 +65,7 @@ const listedContributors = 5 // place. var limitIDs = []string{ check.IDLimitFiles, + check.IDLimitPublicFiles, check.IDLimitFileSize, check.IDLimitTotal, check.IDLimitPacked, @@ -85,8 +87,8 @@ type Prepared struct { Receipt string } -// Limits measures the three source limits over a walked file list and -// reports all four limit rows. +// Limits measures the four source limits over a walked file list and +// reports all five limit rows. // // IT REPORTS THE PACKED ROW TOO, as a decline, and that is what makes it // usable for the report a person sees before anything is packed. The @@ -99,10 +101,21 @@ type Prepared struct { // it costs: nothing outside the check went wrong, nothing about it is // the user's to fix, and a surface that stopped to ask somebody about it // would be asking them to decide about the passage of time. -func Limits(files []File) check.Results { +// +// publicDir is the project's public folder as the deploy flow resolved +// it from the project's config: project-relative and slash-separated, +// with "." meaning the project root. It is an argument rather than +// something read here because the folder is a fact about the config, +// which the caller has already settled, and a second reading in this +// package could disagree with the one the walk used. +func Limits(files []File, publicDir string) check.Results { findings := sourceFindings(files) + if f, over := publicCountFinding(files, publicDir); over { + findings = append(findings, f) + } manifest := check.Manifest{ answered(check.IDLimitFiles), + answered(check.IDLimitPublicFiles), answered(check.IDLimitFileSize), answered(check.IDLimitTotal), declined(check.IDLimitPacked, check.ByDesign, @@ -119,7 +132,7 @@ func Limits(files []File) check.Results { // // # Why the measuring moved out // -// This used to run the three source limits ITSELF and pack only if they +// This used to run the source limits ITSELF and pack only if they // came back clean — one function, so a caller could not get the order // wrong. That shape cannot serve the deploy sequence, where the login // sits between the limits and the pack, and the reason is not merely the @@ -137,7 +150,7 @@ func Limits(files []File) check.Results { // # What replaced it, and what it still guarantees // // The verdict arrives as an ARGUMENT. This refuses to pack unless the -// report it is handed both COVERS the three source limits and carries no +// report it is handed both COVERS the four source limits and carries no // finding — so the rule the one-function shape existed to protect is // still a property of the type rather than of a caller's memory: nothing // is packed that the limits refused, and no receipt is printed above a @@ -254,9 +267,11 @@ func declined(id string, kind check.DeclineKind, reason string) check.Status { return row } -// sourceFindings is the three pre-pack limits, in declared order. +// sourceFindings is the pre-pack limits that need no public folder, in +// declared order; the public-folder count is added beside them by Limits, +// which holds the folder. // -// ALL THREE ARE EVALUATED, ALWAYS. A project with four oversize files +// ALL OF THEM ARE EVALUATED, ALWAYS. A project with four oversize files // AND too many of them learns both facts from one run; reporting the // first and stopping is the round-trip this whole surface exists to // prevent. @@ -310,6 +325,83 @@ func fileCountFinding(files []File) (check.Finding, bool) { }, true } +// publicCountFinding is the too-many-files-in-the-public-folder hard stop. +// +// THE COUNT IS OVER THE WALK'S LIST UNDER THE PUBLIC FOLDER, which is the +// list that would be packed: files the walk excluded and links it skipped +// are not in it. That can only undercount what the build will find, and an +// undercount misses a refusal rather than inventing one — the direction +// this client is allowed to be wrong in, since the server counts the built +// site itself. +// +// THE STOP IS CERTAIN, not a guess, because Astro copies the public folder +// into the built site whole, dotfiles included. A site therefore holds at +// least as many files as that folder does, whatever else the build adds, +// and a folder over the most a site can hold is a deploy that can never +// land. +// +// STRICTLY GREATER, so a folder holding exactly the most a site can hold +// passes: the contract's number is a maximum, and refusing the maximum +// would turn a limit into a limit less one. +// +// WHICH FILES ARE "UNDER THE FOLDER" IS ASKED OF publishedVerbatim, the +// one definition the name checks already use, rather than answered by a +// second prefix test here: two predicates for one folder would disagree +// on a sibling such as public-old/ the day one of them changed. +// +// THE FOLDER COMES FROM THE CALLER for the reason Limits gives: it is a +// fact about the project's config, settled once before the walk. +func publicCountFinding(files []File, publicDir string) (check.Finding, bool) { + inside := make(map[string]bool) + for _, p := range publishedVerbatim(pathsOfFiles(files), publicDir) { + inside[p] = true + } + var public []File + for _, f := range files { + if inside[f.Path] { + public = append(public, f) + } + } + if len(public) <= wire.MaxOutputFiles { + return check.Finding{}, false + } + + // The project root as the public folder has no "out of it" to move + // files to, so its way past is a narrower folder rather than a move. + var headline, next string + if publicDir == "." { + headline = fmt.Sprintf("The project itself is the public folder, so every one of its %s files goes into the site, and a site can hold at most %s.", + units.Count(len(public)), units.Count(wire.MaxOutputFiles)) + next = "Set publicDir in your Astro config to a folder holding only what the site " + + "serves, or add what it does not need to .gitignore, then run `curious deploy` again." + } else { + headline = fmt.Sprintf("The public folder, %s/, holds %s files, and a site can hold at most %s.", + publicDir, units.Count(len(public)), units.Count(wire.MaxOutputFiles)) + next = "Files the site does not serve do not belong in the public folder. Move them " + + "out of it (into the source folder, if the build reads them) or add them to " + + ".gitignore, then run `curious deploy` again." + } + + var b strings.Builder + b.WriteString(headline) + b.WriteString("\n\nThe directories in it holding the most files:\n") + for _, d := range topDirectories(public) { + fmt.Fprintf(&b, "\n %8s %s", units.Count(d.count), d.dir) + } + + return check.Finding{ + CheckID: check.IDLimitPublicFiles, + FailureID: string(check.FamilyLimitPublicFiles), + Severity: check.SeverityHardStop, + Message: headline, + What: b.String(), + Why: "Astro copies the public folder into the built site whole, so the site would " + + "have at least that many files, and curious.pub refuses a site with more than " + + units.Count(wire.MaxOutputFiles) + ". " + alreadyExcluded(), + Next: next, + }, true +} + // fileSizeFinding is the oversize-file hard stop, and it names EVERY // offender in one finding rather than one finding each. // @@ -370,7 +462,7 @@ func fileSizeFinding(files []File) (check.Finding, bool) { // pays overhead it cannot earn back. TestAnArchiveOverTheCapIsRefused- // WithNoReceiptAboveIt builds the case — 3,000 files of 10,000 bytes is // exactly 30,000,000 bytes of source and packs to 32,257,024 — and the -// fourth limit below exists for precisely that gap. The alternative — measuring what +// packed-size limit below exists for precisely that gap. The alternative — measuring what // the upload would weigh — means packing first, which is exactly the // wait this check exists to spare somebody, and it means a project that // happens to compress well can be dozens of times over a limit the diff --git a/internal/pack/limits_test.go b/internal/pack/limits_test.go index 85684f8..6ed49fe 100644 --- a/internal/pack/limits_test.go +++ b/internal/pack/limits_test.go @@ -80,7 +80,7 @@ func TestTheFileCountBoundaryIsInclusive(t *testing.T) { {3_000, false}, {3_001, true}, } { - res := Limits(generatedFiles("src", row.count, 1)) + res := Limits(generatedFiles("src", row.count, 1), "public") found := findingsFor(res, check.IDLimitFiles) if refused := len(found) > 0; refused != row.refused { t.Errorf("%d files: refused = %v, want %v (%v)", row.count, refused, row.refused, found) @@ -103,7 +103,7 @@ func TestTheFileCountBoundaryIsInclusive(t *testing.T) { func TestTooManyFilesNamesTheCountTheLimitAndWhereTheyAre(t *testing.T) { files := append(generatedFiles("public/gallery", 3_400, 1), generatedFiles("src/pages", 12, 1)...) - res := Limits(files) + res := Limits(files, "public") found := findingsFor(res, check.IDLimitFiles) if len(found) != 1 { t.Fatalf("findings = %v, want exactly one", found) @@ -202,7 +202,7 @@ func TestTheFileSizeBoundaryIsInclusive(t *testing.T) { {5_000_001, true}, {5 * 1024 * 1024, true}, } { - res := Limits([]File{{Path: "public/asset.bin", Size: row.size}}) + res := Limits([]File{{Path: "public/asset.bin", Size: row.size}}, "public") found := findingsFor(res, check.IDLimitFileSize) if refused := len(found) > 0; refused != row.refused { t.Errorf("a file of %d bytes: refused = %v, want %v", row.size, refused, row.refused) @@ -258,7 +258,7 @@ func TestEveryOversizeFileIsReportedInOneRun(t *testing.T) { {Path: "public/a.bin", Size: 18_900_000}, {Path: "public/d.bin", Size: 5_200_000}, {Path: "public/b.bin", Size: 12_400_000}, - }) + }, "public") found := findingsFor(res, check.IDLimitFileSize) if len(found) != 1 { @@ -303,7 +303,7 @@ func TestTheOversizeListStopsAtTenAndSaysHowManyMore(t *testing.T) { }) } - found := findingsFor(Limits(files), check.IDLimitFileSize) + found := findingsFor(Limits(files, "public"), check.IDLimitFileSize) if len(found) != 1 { t.Fatalf("findings = %v, want one", found) } @@ -355,7 +355,7 @@ func TestTheTotalBoundaryIsInclusive(t *testing.T) { {Path: "b.bin", Size: 1_000_000}, {Path: "c.bin", Size: 1_000_000}, } - found := findingsFor(Limits(files), check.IDLimitTotal) + found := findingsFor(Limits(files, "public"), check.IDLimitTotal) if refused := len(found) > 0; refused != row.refused { t.Errorf("a total of %d bytes: refused = %v, want %v", row.total, refused, row.refused) } @@ -382,7 +382,7 @@ func TestTheTotalMessageNamesTheLargestFiles(t *testing.T) { files = append(files, File{Path: fmt.Sprintf("assets/%02d.bin", i), Size: int64(3_000_000 - i)}) } - found := findingsFor(Limits(files), check.IDLimitTotal) + found := findingsFor(Limits(files, "public"), check.IDLimitTotal) if len(found) != 1 { t.Fatalf("findings = %v, want one", found) } @@ -420,7 +420,7 @@ func TestMeasuringTheLimitsDoesNotReorderTheCallersList(t *testing.T) { } before := pathsOf(files) - Limits(files) + Limits(files, "public") if got := pathsOf(files); !reflect.DeepEqual(got, before) { t.Errorf("the caller's list was reordered: %v, was %v", got, before) @@ -468,7 +468,7 @@ func TestFilesTheWalkExcludedCountTowardNothing(t *testing.T) { "empty measurement and this row would prove nothing") } - res := Limits(tree.Files) + res := Limits(tree.Files, "public") if len(check.Advisories(res.Findings)) != 0 { t.Errorf("findings = %v, want none — an excluded directory counts toward nothing", res.Findings) @@ -514,7 +514,7 @@ func TestSkippedLinksAreNotCounted(t *testing.T) { t.Fatalf("the walk listed %d files, want exactly the limit", len(tree.Files)) } - if found := findingsFor(Limits(tree.Files), check.IDLimitFiles); len(found) != 0 { + if found := findingsFor(Limits(tree.Files, "public"), check.IDLimitFiles); len(found) != 0 { t.Errorf("findings = %v, want none — a skipped link is not a file that will be "+ "packed, so it is not a file that counts", found) } @@ -671,7 +671,7 @@ func TestTheForcedExcludeRulesStillMatchWhatTheyDidAsASwitch(t *testing.T) { // Limits' manifest. The claimed set reds and the coverage half reds with // it. func TestTheLimitsClaimExactlyTheirOwnIDs(t *testing.T) { - res := Limits(nil) + res := Limits(nil, "public") var got []string for _, row := range res.Manifest { @@ -711,7 +711,7 @@ func TestTheLimitsClaimExactlyTheirOwnIDs(t *testing.T) { // REQUIRED MUTATION, run 2026-09-08: build the packed row with answered // instead of declined in Limits. Reds here on the outcome. func TestTheUnpackedRunSaysTheArchiveWasNotMeasuredRatherThanAnsweringZero(t *testing.T) { - row := rowFor(t, Limits(nil), check.IDLimitPacked) + row := rowFor(t, Limits(nil, "public"), check.IDLimitPacked) if row.Outcome != check.Declined { t.Errorf("Outcome = %v, want a decline — nothing has been packed", row.Outcome) } @@ -723,7 +723,7 @@ func TestTheUnpackedRunSaysTheArchiveWasNotMeasuredRatherThanAnsweringZero(t *te } } -// TestAProjectThatFailsPreFlightStillCarriesAllFourLimitRows. +// TestAProjectThatFailsPreFlightStillCarriesAllItsLimitRows. // // THIS IS THE ROW THAT MAKES THE ALWAYS-RUN RULE A PROPERTY rather than // a sentence. The combiner refuses a report that does not cover every @@ -741,7 +741,7 @@ func TestTheUnpackedRunSaysTheArchiveWasNotMeasuredRatherThanAnsweringZero(t *te // says only what it found would take, and the shape a run that skipped // the limits would produce. Reds here with a coverage error naming the // rows that went missing. -func TestAProjectThatFailsPreFlightStillCarriesAllFourLimitRows(t *testing.T) { +func TestAProjectThatFailsPreFlightStillCarriesAllItsLimitRows(t *testing.T) { // The engine's own five, one of them a hard stop, as they would // arrive from a project with no lockfile. engine := check.Results{ @@ -762,7 +762,7 @@ func TestAProjectThatFailsPreFlightStillCarriesAllFourLimitRows(t *testing.T) { tree := mustWalk(t, OSFileSystem{}, t.TempDir()) oversize := generatedFiles("public/gallery", 3_400, 1) - report, err := check.Combine(engine, tree.Results, Limits(oversize)) + report, err := check.Combine(engine, tree.Results, Limits(oversize, "public")) if err != nil { t.Fatalf("combining a hard-stopped run with the limits: %v", err) } @@ -820,7 +820,7 @@ func TestARefusedRunPacksNothingAndUploadsNothing(t *testing.T) { dir := t.TempDir() tree := mustWalk(t, OSFileSystem{}, root) - prepared, res, err := Prepare(OSFileSystem{}, root, dir, tree.Files, Limits(tree.Files)) + prepared, res, err := Prepare(OSFileSystem{}, root, dir, tree.Files, Limits(tree.Files, "public")) if err != nil { t.Fatalf("Prepare on an ordinary project: %v", err) } @@ -848,7 +848,7 @@ func TestARefusedRunPacksNothingAndUploadsNothing(t *testing.T) { // or directory". refusedFS, refusedFiles := manyTinyFiles(wire.MaxSourceFiles + 1) refusedDir := t.TempDir() - refusedLimits := Limits(refusedFiles) + refusedLimits := Limits(refusedFiles, "public") if len(findingsFor(refusedLimits, check.IDLimitFiles)) == 0 { t.Fatalf("the fixture is not over the count limit, so this half measures nothing: %v", refusedLimits.Findings) @@ -913,7 +913,7 @@ func TestAVerdictThatNeverMeasuredAnythingIsRefused(t *testing.T) { }{ {"nothing was measured at all", check.Results{}, true}, {"every source limit declined", declinedEverything, true}, - {"control: a real verdict packs", Limits(tree.Files), false}, + {"control: a real verdict packs", Limits(tree.Files, "public"), false}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -944,16 +944,22 @@ func TestAVerdictThatNeverMeasuredAnythingIsRefused(t *testing.T) { } } -// TestARunThatNeverPacksStillReportsAllFourRows. The report a refused +// TestARunThatNeverPacksStillReportsAllItsRows. The report a refused // run produces has to pass the same gate as any other, and the packed // row nothing can answer yet says so rather than being absent. That is // Limits's row to carry, and it carries it on every run — including the -// ones that stop before a packer is ever reached. +// ones that stop before a packer is ever reached. The public-folder row +// is one of them: it is answered, and a tree this large says so in a +// finding of its own beside the whole-tree count. // // REQUIRED MUTATION, run 2026-09-08: drop the declined packed row from // Limits. Reds here on the row count. -func TestARunThatNeverPacksStillReportsAllFourRows(t *testing.T) { - res := Limits(generatedFiles("public", 3_400, 1)) +// +// REQUIRED MUTATION, run 2026-10-04: record the public-folder row as +// declined in place of answered. Reds here, and nowhere else in this +// package. +func TestARunThatNeverPacksStillReportsAllItsRows(t *testing.T) { + res := Limits(generatedFiles("public", 3_400, 1), "public") var got []string for _, row := range res.Manifest { @@ -965,9 +971,215 @@ func TestARunThatNeverPacksStillReportsAllFourRows(t *testing.T) { if row := rowFor(t, res, check.IDLimitPacked); row.Outcome != check.Declined { t.Errorf("the packed row says it answered, and nothing was packed") } + if row := rowFor(t, res, check.IDLimitPublicFiles); row.Outcome != check.Answered { + t.Errorf("the public-folder row did not answer: %v", row) + } if len(findingsFor(res, check.IDLimitFiles)) == 0 { t.Errorf("the refused run said nothing about the limit it broke: %v", res.Findings) } + if len(findingsFor(res, check.IDLimitPublicFiles)) == 0 { + t.Errorf("3,400 files under the public folder produced no public-folder finding: %v", + res.Findings) + } +} + +// nestedFiles is n files under dir spread over two subdirectories, plus +// one dotfile directly in it, so a count that skipped nesting or hidden +// names would come up short of n. +func nestedFiles(dir string, n int) []File { + out := []File{{Path: dir + "/.dotfile", Size: 1}} + rest := n - 1 + out = append(out, generatedFiles(dir+"/a", rest/2, 1)...) + out = append(out, generatedFiles(dir+"/b/c", rest-rest/2, 1)...) + return out +} + +// publicStops is the public-folder findings of one Limits run. +func publicStops(files []File, publicDir string) []check.Finding { + return findingsFor(Limits(files, publicDir), check.IDLimitPublicFiles) +} + +// TestAPublicFolderOverTheOutputCapIsAHardStopNamingItsOwnCount proves the +// count is made over the public folder alone, nested and hidden files +// included: 1,001 of them is a hard stop that states 1,001 against 1,000 +// and never the whole tree's 1,003. +// +// REQUIRED MUTATION, run 2026-10-04: count every walked file instead of +// those under the folder. Reds here, on the whole-tree number appearing +// in the message. Also run: state the whole tree's file count in the +// headline of a named folder. Reds here alone in this package. Also run: +// make the finding a warning. Reds here, on the severity. +func TestAPublicFolderOverTheOutputCapIsAHardStopNamingItsOwnCount(t *testing.T) { + files := append(nestedFiles("public", 1_001), + File{Path: "package.json", Size: 10}, File{Path: "src/pages/index.astro", Size: 10}) + + found := publicStops(files, "public") + if len(found) != 1 { + t.Fatalf("findings = %v, want exactly one", found) + } + f := found[0] + if f.FailureID != "limit-public-files" { + t.Errorf("FailureID = %q, want limit-public-files", f.FailureID) + } + if f.Severity != check.SeverityHardStop { + t.Errorf("Severity = %q, want a hard stop", f.Severity) + } + if !strings.Contains(f.Message, units.Count(1_001)) || !strings.Contains(f.Message, units.Count(wire.MaxOutputFiles)) { + t.Errorf("the message %q does not state %s against %s", + f.Message, units.Count(1_001), units.Count(wire.MaxOutputFiles)) + } + if strings.Contains(wholeFinding(f), units.Count(1_003)) { + t.Errorf("the finding states the whole tree's count:\n%s", wholeFinding(f)) + } + if !strings.Contains(f.Message+f.What, "public/") { + t.Errorf("the finding does not name the folder:\n%s", wholeFinding(f)) + } +} + +// TestAPublicFolderAtTheOutputCapIsNotRefused is the boundary: exactly +// 1,000 files is the most a site may hold, so the comparison is a strict +// "more than". +// +// REQUIRED MUTATION, run 2026-10-04: compare with >= in place of >. Reds +// here, and also on the root-folder row's 1,000-file half and the +// walked-directory row. +func TestAPublicFolderAtTheOutputCapIsNotRefused(t *testing.T) { + if found := publicStops(nestedFiles("public", 1_000), "public"); len(found) != 0 { + t.Errorf("findings = %v, want none at exactly the cap", found) + } +} + +// TestASiblingFolderSharingThePublicPrefixIsNotThePublicFolder. +// +// REQUIRED MUTATION, run 2026-10-04: match the folder by string prefix +// without the separator, in the one predicate the name checks share. Reds +// here and nowhere else in this package. Counting the whole tree reds +// here too. +func TestASiblingFolderSharingThePublicPrefixIsNotThePublicFolder(t *testing.T) { + if found := publicStops(nestedFiles("public-old", 1_001), "public"); len(found) != 0 { + t.Errorf("findings = %v, want none: public-old/ is not the public folder", found) + } +} + +// TestTheConfiguredPublicFolderIsTheOneCounted proves the folder argument +// is used rather than a fixed name, and that only it is counted. +// +// REQUIRED MUTATION, run 2026-10-04: ignore the argument and count +// "public". Reds on both halves, and on the root-folder row. Counting the +// whole tree instead reds the second half alone. +func TestTheConfiguredPublicFolderIsTheOneCounted(t *testing.T) { + t.Run("the configured folder over the cap is refused and named", func(t *testing.T) { + found := publicStops(nestedFiles("static", 1_001), "static") + if len(found) != 1 { + t.Fatalf("findings = %v, want exactly one", found) + } + if !strings.Contains(found[0].Message+found[0].What, "static/") { + t.Errorf("the finding does not name static/:\n%s", wholeFinding(found[0])) + } + }) + t.Run("the default folder over the cap is ignored when another is configured", func(t *testing.T) { + if found := publicStops(nestedFiles("public", 1_001), "static"); len(found) != 0 { + t.Errorf("findings = %v, want none: public/ is not the configured folder", found) + } + }) +} + +// TestAProjectRootPublicFolderCountsTheWholeWalkedTree. A public folder +// of "." holds everything the walk kept, whatever its directories are +// called. +// +// REQUIRED MUTATION, run 2026-10-04: ignore the argument and count +// "public", so a "." folder is not the whole tree. Reds here. Comparing +// with >= in place of > reds here as well, on the 1,000-file half. Also +// run: word the root's finding as a named folder's. Reds here alone. +func TestAProjectRootPublicFolderCountsTheWholeWalkedTree(t *testing.T) { + spread := func(n int) []File { + var out []File + for i, d := range []string{"alpha", "beta/gamma", "delta"} { + share := n / 3 + if i == 0 { + share = n - 2*(n/3) + } + out = append(out, generatedFiles(d, share, 1)...) + } + return out + } + found := publicStops(spread(1_001), ".") + if len(found) != 1 { + t.Fatalf("1,001 files, public folder \".\": findings = %v, want exactly one", found) + } + // The root has no "out of it" to move files to, so the finding has to + // say what the folder is and point at the config instead. + if !strings.Contains(found[0].Message, "project itself") { + t.Errorf("the message does not say the project itself is the public folder: %q", found[0].Message) + } + if !strings.Contains(found[0].Next, "publicDir") || strings.Contains(found[0].Next, "Move them out") { + t.Errorf("the next step offers a move the root cannot make, or no way past: %q", found[0].Next) + } + if found := publicStops(spread(1_000), "."); len(found) != 0 { + t.Errorf("1,000 files, public folder \".\": findings = %v, want none", found) + } +} + +// TestThePublicFolderFindingListsOnlyTheFolderOwnBusiestDirectories. A +// reader told the public folder is too big has to be pointed inside it; +// the 1,500 files under src/big/ are not what they can fix. +// +// REQUIRED MUTATION, run 2026-10-04: rank directories over the whole +// tree. Reds here alone. +func TestThePublicFolderFindingListsOnlyTheFolderOwnBusiestDirectories(t *testing.T) { + files := append(nestedFiles("public", 1_001), generatedFiles("src/big", 1_500, 1)...) + + found := publicStops(files, "public") + if len(found) != 1 { + t.Fatalf("findings = %v, want exactly one", found) + } + if strings.Contains(found[0].What, "src/") { + t.Errorf("What names a directory outside the public folder:\n%s", found[0].What) + } + if !strings.Contains(found[0].What, "public/a") && !strings.Contains(found[0].What, "public/b") { + t.Errorf("What names none of the public folder's own busiest directories:\n%s", found[0].What) + } +} + +// TestFilesTheWalkExcludedAreNotCountedTowardThePublicFolder walks a real +// directory: 1,000 files under public/ plus one the ignore file drops +// leaves exactly the cap, which is no refusal. Nothing here is a link, so +// no skip is involved. +// +// REQUIRED MUTATION, run 2026-10-04: refuse at the cap rather than over +// it. Reds here, at exactly 1,000. So does counting the whole walked +// tree, which holds more than the public folder does. +func TestFilesTheWalkExcludedAreNotCountedTowardThePublicFolder(t *testing.T) { + entries := []entry{ + {path: "package.json", body: "{}"}, + {path: ".gitignore", body: "public/ignored.txt\n"}, + {path: "public/ignored.txt", body: "x"}, + } + for i := 0; i < 1_000; i++ { + entries = append(entries, entry{path: fmt.Sprintf("public/d%d/f%04d.txt", i%7, i), body: "x"}) + } + root := writeTree(t, entries) + + tree, err := Walk(OSFileSystem{}, root, NameScope{Public: "public", Source: "src", Pages: "src/pages"}) + if err != nil { + t.Fatalf("Walk: %v", err) + } + inPublic := 0 + for _, f := range tree.Files { + if strings.HasPrefix(f.Path, "public/") { + inPublic++ + } + if f.Path == "public/ignored.txt" { + t.Fatal("the walk kept the ignored file, so this row measures nothing") + } + } + if inPublic != 1_000 { + t.Fatalf("the walk kept %d files under public/, want 1,000 — the fixture is wrong", inPublic) + } + if found := publicStops(tree.Files, "public"); len(found) != 0 { + t.Errorf("findings = %v, want none: an ignored file counts toward nothing", found) + } } // TestTheSuccessReceiptNamesFilesSourceAndArchive. It is the receipt for @@ -985,7 +1197,7 @@ func TestTheSuccessReceiptNamesFilesSourceAndArchive(t *testing.T) { }) tree := mustWalk(t, OSFileSystem{}, root) - prepared, _, err := Prepare(OSFileSystem{}, root, t.TempDir(), tree.Files, Limits(tree.Files)) + prepared, _, err := Prepare(OSFileSystem{}, root, t.TempDir(), tree.Files, Limits(tree.Files, "public")) if err != nil { t.Fatalf("Prepare: %v", err) } @@ -1037,12 +1249,12 @@ func TestAnArchiveOverTheCapIsRefusedWithNoReceiptAboveIt(t *testing.T) { "is only about a project that passed every source limit", total, wire.MaxSourceTotalBytes) } - if found := findingsFor(Limits(list), check.IDLimitFiles); len(found) != 0 { + if found := findingsFor(Limits(list, "public"), check.IDLimitFiles); len(found) != 0 { t.Fatalf("the fixture is already refused by an earlier limit: %v", found) } dir := t.TempDir() - prepared, res, err := Prepare(fsys, "root", dir, list, Limits(list)) + prepared, res, err := Prepare(fsys, "root", dir, list, Limits(list, "public")) if err != nil { t.Fatalf("Prepare: %v", err) } @@ -1153,7 +1365,7 @@ func TestAnOrdinaryProjectHasNothingToSay(t *testing.T) { }) tree := mustWalk(t, OSFileSystem{}, root) - res := Limits(tree.Files) + res := Limits(tree.Files, "public") if len(res.Findings) != 0 { t.Errorf("findings = %v, want none from an ordinary project", res.Findings) } @@ -1197,7 +1409,7 @@ func TestAnOrdinaryProjectHasNothingToSay(t *testing.T) { func TestAFourFigureCountIsGroupedWhereverAMessagePrintsOne(t *testing.T) { fsys, files := manyTinyFiles(1_200) - prepared, _, err := Prepare(fsys, "root", t.TempDir(), files, Limits(files)) + prepared, _, err := Prepare(fsys, "root", t.TempDir(), files, Limits(files, "public")) if err != nil { t.Fatalf("Prepare: %v", err) } @@ -1210,7 +1422,7 @@ func TestAFourFigureCountIsGroupedWhereverAMessagePrintsOne(t *testing.T) { for _, f := range files { oversize = append(oversize, File{Path: f.Path, Size: wire.MaxSourceFileBytes + 1}) } - found := findingsFor(Limits(oversize), check.IDLimitFileSize) + found := findingsFor(Limits(oversize, "public"), check.IDLimitFileSize) if len(found) != 1 { t.Fatalf("findings = %v, want one", found) } @@ -1397,9 +1609,9 @@ func everyLimitFinding(t *testing.T) map[string]check.Finding { out := map[string]check.Finding{} for _, res := range []check.Results{ - Limits(generatedFiles("public", 3_400, 1)), - Limits([]File{{Path: "public/big.bin", Size: 6_000_000}}), - Limits([]File{{Path: "a.bin", Size: 20_000_000}, {Path: "b.bin", Size: 20_000_000}}), + Limits(generatedFiles("public", 3_400, 1), "public"), + Limits([]File{{Path: "public/big.bin", Size: 6_000_000}}, "public"), + Limits([]File{{Path: "a.bin", Size: 20_000_000}, {Path: "b.bin", Size: 20_000_000}}, "public"), } { for _, f := range res.Findings { out[f.CheckID] = f @@ -1413,13 +1625,13 @@ func everyLimitFinding(t *testing.T) map[string]check.Finding { for _, id := range limitIDs { if _, ok := out[id]; !ok { t.Fatalf("no fixture produced a finding for %s — a row over this map would "+ - "pass while covering three of the four", id) + "pass while covering only some of the limits", id) } } return out } -// TestThePackedFindingNamesEachFileOnce is the fourth limit's half of the +// TestThePackedFindingNamesEachFileOnce is the packed limit's half of the // sized-path model. // // It used to lay its own table into the copy — a packed size, an on-disk diff --git a/internal/pack/readme_test.go b/internal/pack/readme_test.go index 14a8233..0e15057 100644 --- a/internal/pack/readme_test.go +++ b/internal/pack/readme_test.go @@ -138,8 +138,9 @@ func humanMB(n int) string { return strconv.Itoa(n / 1_000_000) } -// TestReadmeLimitsMatchWireConstants holds the four local limits the -// README states to the four wire.Max* constants they are read from, +// TestReadmeLimitsMatchWireConstants holds the four source limits the +// README states, and the output file count the client also checks locally +// for the public folder, to the wire.Max* constants they are read from, // rather than to a second, hand-typed copy of them. // // EACH CONSTANT IS BOUND TO ITS OWN FIGURE ON ITS OWN LINE, and the @@ -159,6 +160,7 @@ func TestReadmeLimitsMatchWireConstants(t *testing.T) { {"MaxSourceFileBytes", humanMB(wire.MaxSourceFileBytes) + " MB"}, {"MaxSourceTotalBytes", humanMB(wire.MaxSourceTotalBytes) + " MB"}, {"MaxPackedBytes", humanMB(wire.MaxPackedBytes) + " MB"}, + {"MaxOutputFiles", humanCount(wire.MaxOutputFiles) + " files"}, } lines := strings.Split(readme, "\n") for _, c := range cases { diff --git a/internal/preflight/folders.go b/internal/preflight/folders.go index 43b9cf7..3a6487f 100644 --- a/internal/preflight/folders.go +++ b/internal/preflight/folders.go @@ -145,7 +145,7 @@ func fallBack(reason, source string) Folders { Results: check.Results{Findings: []check.Finding{{ CheckID: check.IDPathCharset, Severity: check.SeverityWarning, - Message: fmt.Sprintf("%s, so file names were checked under %s/ instead.", + Message: fmt.Sprintf("%s, so file names were checked and files counted under %s/ instead.", reason, DefaultPublicDir), Why: "Astro copies the public folder into your site unchanged, so the names in " + "it are the ones that have to be servable. If your public folder is somewhere " + diff --git a/internal/preflight/folders_test.go b/internal/preflight/folders_test.go index 4e1aeb3..8ceca04 100644 --- a/internal/preflight/folders_test.go +++ b/internal/preflight/folders_test.go @@ -129,7 +129,7 @@ func TestResolveFoldersFallsBackAndSaysSo(t *testing.T) { if !strings.Contains(f.Message, "astro.config.mjs") || !strings.Contains(f.Message, tc.reason) { t.Errorf("Message = %q, want it to name the config and say %q", f.Message, tc.reason) } - if !strings.Contains(f.Message, "checked under public/ instead") { + if !strings.Contains(f.Message, "checked and files counted under public/ instead") { t.Errorf("Message = %q, want it to name the fallback", f.Message) } if len(got.Results.Manifest) != 0 { diff --git a/internal/preflight/registration_test.go b/internal/preflight/registration_test.go index b2095cc..d9e9305 100644 --- a/internal/preflight/registration_test.go +++ b/internal/preflight/registration_test.go @@ -61,7 +61,7 @@ func TestTheRegisteredChecksClaimExactlyThisPackagesIDs(t *testing.T) { missing, unexpected := check.CoverageGaps(res.Manifest) wantMissing := []string{ check.IDSymlinks, check.IDCaseCollision, check.IDPathCharset, - check.IDLimitFiles, check.IDLimitFileSize, check.IDLimitTotal, check.IDLimitPacked, + check.IDLimitFiles, check.IDLimitPublicFiles, check.IDLimitFileSize, check.IDLimitTotal, check.IDLimitPacked, } if !equalStrings(missing, wantMissing) { t.Errorf("missing = %v, want exactly the ids the file walk and the limits own %v", diff --git a/internal/ui/failureid.go b/internal/ui/failureid.go index f299a41..46ff531 100644 --- a/internal/ui/failureid.go +++ b/internal/ui/failureid.go @@ -124,6 +124,7 @@ const ( IDLockfileWorkspace FailureID = "lockfile-workspace" IDLockfileMissing FailureID = "lockfile-missing" IDLimitFiles FailureID = "limit-files" + IDLimitPublicFiles FailureID = "limit-public-files" IDLimitFileSize FailureID = "limit-file-size" IDLimitTotal FailureID = "limit-total" IDLimitPacked FailureID = "limit-packed" @@ -176,8 +177,8 @@ var ActiveFailureIDs = []FailureID{ IDAstroDepMissing, IDAstroDepUnreadable, IDAstroDepInvalidJSON, IDAstroDepNotObject, IDAstroDepAbsent, IDLockfileUnsupported, - IDLockfileWorkspace, IDLockfileMissing, IDLimitFiles, IDLimitFileSize, - IDLimitTotal, IDLimitPacked, IDPathCharset, IDProjectNotReady, + IDLockfileWorkspace, IDLockfileMissing, IDLimitFiles, IDLimitPublicFiles, + IDLimitFileSize, IDLimitTotal, IDLimitPacked, IDPathCharset, IDProjectNotReady, IDNeedsATerminal, IDAnswerNotUnderstood, IDInternalFault, }