Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,8 @@ neither one's.
before touching `readyack.go`: the whole-tree presence scan described
above is now the WEAK tier, reached only when the entry graph cannot be
resolved, and the advisory it emits is a different string that discloses
the difference.
the difference — and, since #258, NAMES the resolver's own reason for
falling back instead of guessing at it. See item 20.

19. **img2img sends `workflow: "txt2img"` PLUS `images[]`, requires
`--ecosystem`, and uploads with NO credential — three things that each read
Expand Down Expand Up @@ -1030,6 +1031,79 @@ neither one's.
project shapes is exactly how the false pass above shipped, so the
strength is part of the output, not an implementation detail.
`TestReadyAckAdvisoriesStateTheirOwnStrength` pins both directions.
- 🔴 **AND THE WEAK TIER NAMES ITS REASON RATHER THAN GUESSING AT IT — THE
RESOLVER ALREADY KNEW, AND THE CHECK THREW IT AWAY.** Every gap kind
writes a precise, per-reference reason into `EntryGraph.Gaps`;
`readyAckChecks` returned the CONSTANT `readyAckAdvicePresenceOnly` and
discarded the slice, so the message offered a fixed list of plausible
causes instead — "there is no index.html at the project root, or it holds
a reference this CLI cannot follow — a bundler alias, a generated file, an
off-project URL". In the canonical #206 shape, a `static` scaffold whose
`civitai-host.js` has been deleted, **not one of those is true**: the
reason is that `<script src="./civitai-host.js">` points at a file that is
not there, and a five-file no-build app has no bundler to alias anything.
Issue #258. The gaps are now spliced between
`readyAckAdvicePresenceOnlyHead` and `…Tail` by `presenceOnlyAdvice`.
- **The fix is GENERAL, and that is the point.** It surfaces `Gaps`
wholesale rather than special-casing the dangling reference, so all six
kinds (dangling reference, bare specifier, off-project URL, unreadable
file, file budget, depth truncation) reach the author from one change.
`TestEveryGapKindReachesTheAuthor` covers four of them; a fifth would
have been a special case nobody wrote.
- 🔴 **THE TIERING DID NOT CHANGE, AND MUST NOT.** The bullet below —
a reference to a file that is not there is a GAP, not a decided
absence — is why #258's project is on the weak tier at all, and it
stands. **The message was the defect.** A reading of #258 as "promote
the dangling case to the strong tier" walks straight into the confident
finding built on a wrong model that this item exists to prevent.
- 🔴 **"NAMES THE CAUSE" IS ONLY HALF; THE SPECULATION HAS TO BE GONE,
AND A REPORT-SCOPED ASSERTION CANNOT SEE THAT.** Measured: the first
version asserted the absence of "bundler alias" within the GAP REPORT
only, and the most likely regression — restoring the guess to
`…PresenceOnlyHead` while keeping the real reasons — reddened **0**
subtests across `internal/validate` and `internal/cmd`.
`TestPresenceAdviceNoLongerSpeculates` reads the WHOLE emitted message
at a fixture where every quoted phrase is provably impossible, with a
positive control that the real cause is present so "says none of the
wrong things" is not satisfied by a message that says nothing.
- **The cap is 3 and the overflow is COUNTED OUT LOUD**
(`readyAckGapCap`). A silently truncated list reads as "that was all of
them" — the same class of lie as the guess it replaced: the author
fixes what they were shown, re-runs, and meets reasons that were there
the whole time.
- **The gap strings are AUTHOR-FACING NOW.** They used to be read only in
a test failure message, and one carried "this resolver's model of the
project is incomplete" — a fact about US. Word a new gap for the person
who has to fix it (referencing file, specifier, edit), and keep it to a
SINGLE LINE: it rides in a `--json` `message` field. `readyAckGapReport`
collapses whitespace rather than trusting a `%v`-interpolated OS error.
- **Layout is the PRINTER's, not the message's** — the inverse of item 23.
The advisory is ~2 kB and `app validate` printed it as ONE
1938-character line; `internal/cmd/validate_print.go`'s `printFinding`
now wraps EVERY finding to 79 columns with a hanging indent, which fixes
every long message rather than the one that provoked it. Wrapping in the
message would corrupt `--json` for exactly the consumers item 23 exists
to serve, so both directions are asserted:
`TestValidateJSONMessagesAreOneLine` DECODES the payload (a
`strings.Contains` over raw stdout cannot tell a real newline from the
`\n` escape) and `TestValidateTextOutputIsWrapped` requires the advisory
to occupy ≥10 lines, since a width assertion alone is satisfied by a
build that prints nothing long. Consequence for anyone adding a test: a
substring assertion on `validate`'s stderr is really an assertion about
where the layout broke — one in `app_validate_lockfile_test.go` had to
go through `unwrapFinding` the moment wrapping landed.
- **Mutation matrix**, `--- FAIL` leaf lines counted from output (never an
exit code), each mutation checksum-gated: discard the gaps 11; restore
the guess 1; remove the cap 2; truncate silently 2; drop the one-line
collapse 1; put a newline in the message 2 (including the `--json`
guard); revert the printer to one line 1; revert the gap wording to the
maintainer form 3; splice the report after the tail instead of before
24 (the bracketing matchers); weak tier loses its disclosure 2; a strong
tier gains it 1; the report leaks another tier's own literal 1
(`TestGapReportCannotSatisfyAnotherTiersStrengthAssertion`, the case the
strength test structurally cannot make because it reads the FIXED bases
and this text is appended at runtime). A comment-only null mutant
survives.
- 🔴 **AN HTML `src` IS A URL; A JS SPECIFIER IS A MODULE SPECIFIER. THE
FIRST VERSION CONFLATED THEM, AND THAT ONE MISTAKE PRODUCED THREE BUGS,
TWO OF THEM OPPOSITES.** Measured, each on a project one character from a
Expand Down
20 changes: 19 additions & 1 deletion internal/blockproto/entrygraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ type EntryGraph struct {
// must never be read as evidence that something is absent.
Complete bool
// Gaps explains, in author-readable terms, why Complete is false.
//
// 🔴 THESE STRINGS ARE PRINTED TO AUTHORS, not just to a test failure
// message. `internal/validate`'s presence-tier advisory renders them
// (issue #258: it used to GUESS at why it had fallen back — "a bundler
// alias, a generated file, an off-project URL" — while the real reason sat
// here and was discarded, so a five-file no-build app was sent hunting for
// a bundler alias that cannot exist in it). Word a new gap for the author
// who has to fix it: name the referencing file, the specifier, and the
// edit. Each one must be a SINGLE LINE — the advisory rides in a
// `--json` message field.
Gaps []string
// Trace lists every reference considered and what it resolved to, for
// error messages that name the near-miss instead of saying "nothing".
Expand Down Expand Up @@ -327,7 +337,15 @@ func ResolveEntryGraph(dir string, opts EntryGraphOptions) *EntryGraph {
// the project. That is a GAP, not "the browser 404s it": treating
// it as the latter would let a mis-modelled project produce a
// confident finding.
g.gap("%s %q resolves to %s, which does not exist — this resolver's model of the project is incomplete",
//
// 🔴 THE WORDING IS AUTHOR-FACING, because `Gaps` is rendered into
// the ready-ack advisory an author reads (issue #258). This is the
// gap the canonical #206 shape produces — a `static` scaffold whose
// `civitai-host.js` was deleted — so it must name the referencing
// file, the specifier and the missing target, and say what to do.
// "this resolver's model of the project is incomplete", the tail it
// used to carry, is a fact about US that an author cannot act on.
g.gap("%s %q points at %s, which does not exist — restore that file or fix the reference",
p.what, p.spec, relTo(dir, resolved))
continue
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/app_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Defaults to the current directory.`,
if !res.OK() {
fmt.Fprintln(errw, ui.For(errw).ErrorMsg(fmt.Sprintf("%d validation error(s) in %s:", len(res.Errors), dir)))
for _, e := range res.Errors {
fmt.Fprintf(errw, " - %s\n", e.Message)
printFinding(errw, e.Message)
}
// Surface warnings too — they're useful context even on a failure.
printWarnings(errw, res)
Expand Down Expand Up @@ -158,6 +158,6 @@ func printWarnings(w io.Writer, res validate.Result) {
}
fmt.Fprintln(w, ui.For(w).Warn(fmt.Sprintf("%d warning(s):", len(res.Warnings))))
for _, warn := range res.Warnings {
fmt.Fprintf(w, " - %s\n", warn.Message)
printFinding(w, warn.Message)
}
}
7 changes: 6 additions & 1 deletion internal/cmd/app_validate_lockfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ func TestAppValidateFailsOnPnpmLockWithNpmBuildCommand(t *testing.T) {
`"outputDir"`,
"npm install",
} {
if !strings.Contains(stderr, want) {
// unwrapFinding because the printer WRAPS a finding to the terminal
// width (validate_print.go) — the message is one line on the wire and
// several on screen, so a raw substring test here is really asserting
// where the layout chose to break. `"buildCommand": "pnpm run build"`
// straddled a break the moment wrapping landed.
if !strings.Contains(unwrapFinding(stderr), want) {
t.Errorf("validate stderr missing %q:\n%s", want, stderr)
}
}
Expand Down
176 changes: 176 additions & 0 deletions internal/cmd/app_validate_wrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package cmd

// app_validate_wrap_test.go pins the OTHER half of issue #258: the ready-ack
// advisory is a ~2 kB paragraph, and `app validate` printed it as ONE
// 1938-character line.
//
// 🔴 THE TWO SURFACES HAVE OPPOSITE REQUIREMENTS, AND ONE FIX CANNOT SERVE BOTH
// FROM THE PRODUCER. `--json` needs the message to stay a single line — it is a
// string field a consumer reads — while the terminal needs it broken to a width
// the producer cannot know. So the layout lives at the printer
// (validate_print.go) and the message stays flat, which is the inverse of
// AGENTS.md item 23's rule for a finding's `Field`. Both directions are asserted
// here, because either alone is satisfied by a broken fix: wrap in the message
// and the text test passes while `--json` is corrupted; wrap nowhere and the
// `--json` test passes while the terminal is unreadable.

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/civitai/cli/internal/blockproto"
"github.com/civitai/cli/internal/scaffold"
)

// prefixScaffold renders `static` and deletes the emitter — the canonical #206
// project, and the one that produces the longest message this CLI emits.
func prefixScaffold(t *testing.T) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "block")
if _, err := scaffold.Render(scaffold.Static, dir, scaffold.Data{Slug: "wrap-block", Name: "Wrap Block"}); err != nil {
t.Fatalf("render: %v", err)
}
if err := os.Remove(filepath.Join(dir, blockproto.ReadyAckFilename)); err != nil {
t.Fatal(err)
}
return dir
}

// TestValidateJSONMessagesAreOneLine is the wire contract.
//
// 🔴 IT DECODES. Per AGENTS.md item 23, a `strings.Contains(out, …)` over raw
// `--json` stdout cannot tell a real newline inside a message from the `\n`
// escape `encoding/json` writes for one — the escape is what a naive text test
// sees, and it looks identical either way. So the payload is unmarshalled and
// the decoded Go string is checked.
func TestValidateJSONMessagesAreOneLine(t *testing.T) {
dir := prefixScaffold(t)
stdout, _, err := run(t, "app", "validate", dir, "--json")
if err != nil {
t.Fatalf("validate --json: %v\n%s", err, stdout)
}
var payload map[string]any
if jerr := json.Unmarshal([]byte(stdout), &payload); jerr != nil {
t.Fatalf("--json output is not valid JSON: %v\n%s", jerr, stdout)
}

checked := 0
for _, key := range []string{"errors", "warnings"} {
list, _ := payload[key].([]any)
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
t.Fatalf("%s[] element is not an object: %#v", key, item)
}
msg, ok := m["message"].(string)
if !ok {
t.Fatalf("%s[] element has no string message: %#v", m, m)
}
if strings.ContainsAny(msg, "\n\r") {
t.Errorf("a %s message carries a line break — wrapping belongs at the printer, not in the "+
"message, or every --json consumer gets a multi-line string field:\n%q", key, msg)
}
checked++
}
}
// POSITIVE CONTROL. A zero here is indistinguishable from a project that
// produced no findings at all, and this fixture must produce the ready-ack
// advisory — the longest message the CLI emits, and the one that provoked
// this test.
if checked == 0 {
t.Fatal("the fixture produced NO findings, so every assertion above is vacuous — a `static` scaffold " +
"with its emitter deleted must warn")
}
if !strings.Contains(stdout, "BLOCK_READY") {
t.Fatalf("the ready-ack advisory is absent from --json; this test is checking the wrong findings:\n%s", stdout)
}
}

// TestValidateTextOutputIsWrapped is the terminal half.
func TestValidateTextOutputIsWrapped(t *testing.T) {
dir := prefixScaffold(t)
stdout, stderr, err := run(t, "app", "validate", dir)
if err != nil {
t.Fatalf("validate: %v\n%s\n%s", err, stdout, stderr)
}

longest, lines := 0, 0
advisoryLines := 0
inAdvisory := false
for _, line := range strings.Split(strings.TrimRight(stderr, "\n"), "\n") {
lines++
if n := len([]rune(line)); n > longest {
longest = n
}
if strings.HasPrefix(line, findingBullet) {
inAdvisory = strings.Contains(line, "page")
}
if inAdvisory && strings.HasPrefix(line, " ") {
advisoryLines++
}
}
if lines == 0 {
t.Fatal("validate printed nothing to stderr — the fixture is not warning and this test is vacuous")
}
if longest > findingWrapWidth {
t.Errorf("a stderr line is %d runes wide, over the %d-rune budget — the finding was not wrapped:\n%s",
longest, findingWrapWidth, stderr)
}
// 🔴 The WIDTH assertion alone is satisfied by a build that prints nothing
// long, so require the advisory to have actually been BROKEN UP. It is ~2 kB;
// at this width that is tens of lines, and any single-digit count means the
// message shrank rather than wrapped.
if advisoryLines < 10 {
t.Errorf("the ready-ack advisory occupies only %d line(s) — it is ~2 kB, so this is not a wrapped "+
"paragraph:\n%s", advisoryLines, stderr)
}

// The content survives the layout: unwrapping reproduces the logical message,
// including the issue-#258 gap report naming the reference that broke.
flat := unwrapFinding(stderr)
for _, want := range []string{
"did NOT check that the file is loaded",
`index.html <script src> "./civitai-host.js" points at civitai-host.js, which does not exist`,
} {
if !strings.Contains(flat, want) {
t.Errorf("the wrapped output no longer carries %q — layout must not lose content:\n%s", want, stderr)
}
}
}

// TestWrapRunesHangingIndent pins the shape at the function, where the input can
// be controlled: the continuation indent must be the same width as the bullet,
// or a wrapped finding stops reading as one list item.
func TestWrapRunesHangingIndent(t *testing.T) {
if len(findingBullet) != len(findingIndent) {
t.Fatalf("bullet %q and indent %q differ in width; the text column will not line up",
findingBullet, findingIndent)
}
var b strings.Builder
printFinding(&b, strings.Repeat("word ", 200))
got := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
if len(got) < 5 {
t.Fatalf("1000 characters wrapped to %d line(s) at width %d", len(got), findingWrapWidth)
}
if !strings.HasPrefix(got[0], findingBullet) {
t.Errorf("the first line does not carry the bullet: %q", got[0])
}
for i, line := range got[1:] {
if !strings.HasPrefix(line, findingIndent) || strings.HasPrefix(line, findingBullet) {
t.Errorf("continuation line %d is not hanging-indented: %q", i+1, line)
}
if len([]rune(line)) > findingWrapWidth {
t.Errorf("continuation line %d is %d runes: %q", i+1, len([]rune(line)), line)
}
}

// A short message stays on one line — wrapping must not shred every finding.
var short strings.Builder
printFinding(&short, "blockId: 'Bad_Id' does not match pattern")
if n := strings.Count(short.String(), "\n"); n != 1 {
t.Errorf("a short finding printed on %d lines, want 1:\n%s", n, short.String())
}
}
58 changes: 58 additions & 0 deletions internal/cmd/validate_print.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cmd

// validate_print.go owns the LAYOUT of a validation finding in the terminal.
//
// 🔴 THE SPLIT IS THE INVERSE OF AGENTS.md ITEM 23, AND IT IS LOAD-BEARING IN
// BOTH DIRECTIONS. Item 23 says a finding's `Field` must come from the PRODUCER
// and never be re-derived at the printer. Its layout is the other half of the
// same rule: `Finding.Message` is a `--json` string field, so it must stay ONE
// LINE on the wire, and the line breaking has to happen here. Wrapping inside
// the message would corrupt `--json` for exactly the consumers item 23 exists to
// serve — and hard-wrapping a message at the producer also fixes a width the
// producer cannot see.
//
// What drove it: the ready-ack advisory (internal/validate/readyack.go) is a
// single ~2 kB paragraph. Printed with `fmt.Fprintf(w, " - %s\n", …)` it was
// ONE 1938-character line — unreadable in any terminal, and unreadable in a
// scrollback. Wrapping HERE fixes that message and every other long one at the
// same time, including any added later, which is why the fix is at the shared
// print site rather than in the one message that provoked it.

import (
"fmt"
"io"
"strings"
)

const (
// findingWrapWidth is the total column width a finding is wrapped to. 79
// matches exitCodeHelpWidth: the CLI's two long-prose surfaces should not
// disagree about how wide a terminal is.
findingWrapWidth = 79
// findingBullet leads the first line; findingIndent is the hanging indent
// for continuations. They are the SAME WIDTH on purpose, so the text column
// is straight and a wrapped finding still reads as one list item.
findingBullet = " - "
findingIndent = " "
)

// printFinding writes one finding's message as a wrapped bulleted list item.
//
// It is deliberately whitespace-normalising (wrapRunes splits on
// `strings.Fields`), so a message that acquired a stray newline still prints as
// a clean block rather than breaking the indent.
func printFinding(w io.Writer, msg string) {
for i, line := range wrapRunes(msg, findingWrapWidth-len(findingIndent)) {
lead := findingIndent
if i == 0 {
lead = findingBullet
}
fmt.Fprintf(w, "%s%s\n", lead, line)
}
}

// unwrapFinding is the inverse, for tests: collapse the printed block back to
// the single logical line the producer emitted. It exists so an assertion about
// a message's CONTENT does not have to know where the layout chose to break —
// the failure mode a substring test hits the moment a width changes.
func unwrapFinding(s string) string { return strings.Join(strings.Fields(s), " ") }
Loading
Loading