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, }