diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index a21dc77de..9373110b8 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -55,7 +55,12 @@ copied from abcd's own; a workflow that renders the site from each published release with abcd's checksum- and attestation-verified binary and deploys the rendered archive from a second job; and the provider's host configuration. The composition and the static inputs are the repository's own once they exist, so -a later run keeps them as they are. The workflow and the host configuration are +a later run keeps them as they are, with one exception +([adr-2609301720596683](../../decisions/adrs/2609301720596683-abcd-adds-a-missing-site-label-to-an-existing-ui-json-and.md)): +an interface-string file that lacks a label the allowlist declares, because it +was written before the label existed, gains that label with abcd's own words +for it, and the run names each added label. Nothing the file already says is +rewritten. The workflow and the host configuration are abcd's: a copy that differs refuses the whole run, with nothing written and no remote change attempted, unless the run is told to replace it. @@ -133,6 +138,15 @@ block carries a `data-src` attribute naming the file and heading it came from, s each block names its own source in the markup. And the interface-string file is decoded against a closed struct with unknown fields refused, so a word added there which no field reads fails the build rather than reaching a reader unreviewed. +A label the struct declares and the file leaves blank fails the build by name. A +label the file does not carry at all, which is how a file written before that +label existed reads, is added to the file by the build and by setting up, with +the words abcd's own interface-string file gives it: each added label is named +on standard error, every byte already in the file stays, and a file carrying a +key no field reads is left untouched and refused as before. The render the +site gate makes of an empty output directory writes only inside that directory, +so it never completes the file and refuses an incomplete one by name +([adr-2609301720596683](../../decisions/adrs/2609301720596683-abcd-adds-a-missing-site-label-to-an-existing-ui-json-and.md)). Every picture is a committed asset under `docs/assets/img/`, referenced from a docs page like any other image. SVGs are inlined so their colours follow the @@ -176,7 +190,9 @@ behind the contributors page; and `docs/` with its committed assets. It writes the landing page, the record explorer, the machine-readable record export, the install script from its committed template, the redirect and header maps, the stylesheets and scripts, every referenced raster, and its own -build marker. Nothing else, nowhere else. +build marker. The one write outside the output directory is the missing-label +completion of the interface-string file above, made only when a declared label +is absent. Nothing else, nowhere else. One input reaches past the durable record into the working tier, and it is off unless a repository asks for it. The composition declaration carries an diff --git a/.abcd/development/decisions/adrs/2609301720596683-abcd-adds-a-missing-site-label-to-an-existing-ui-json-and.md b/.abcd/development/decisions/adrs/2609301720596683-abcd-adds-a-missing-site-label-to-an-existing-ui-json-and.md new file mode 100644 index 000000000..ed2d7b5ec --- /dev/null +++ b/.abcd/development/decisions/adrs/2609301720596683-abcd-adds-a-missing-site-label-to-an-existing-ui-json-and.md @@ -0,0 +1,90 @@ +--- +id: adr-2609301720596683 +slug: abcd-adds-a-missing-site-label-to-an-existing-ui-json-and +status: accepted +date: 2026-09-30 +supersedes: null +superseded_by: null +refines: [adr-47] +related_intents: [itd-2609212103568351, itd-2609212103572513] +related_rfcs: [] +related_adrs: [adr-47] +--- + +# ADR-2609301720596683: abcd adds a missing site label to an existing ui.json and never rewrites one + +Typed links: `refines` [adr-47](0047-abcdev-app-rendered-from-this-repository-alone.md) +(decision 2's closed allowlist is untouched: this adds a declared label to the +file, and adds no fallback at render time). + +## Context + +`site-src/ui.json` is the closed allowlist of interface strings adr-47 +decision 2 permits the site generator to add. `LoadUI` decodes it with unknown +keys refused and refuses a declared label the file leaves empty or absent, +naming it (`no text for status.target`), so a blank button never reads as a +rendering fault. `abcd site setup` seeds the file once and never rewrites it, +under the rule its chapter states: a file the repository owns once it exists +is kept. + +Two intents of this release cycle each declared new required labels: +itd-2609212103568351 the six `status.*` labels of the Now / Next / Later +block, and itd-2609212103572513 `status.target`. Both carry +`impact: additive`, but a managed repository whose `ui.json` predates them +would see a green `site build` refuse on upgrade until it added the lines by +hand, which is breaking for that surface. The review of the second intent's +lane raised it as a ruling owed before the v0.12.0 cut. + +## Decision + +The product thinker ruled on 2026-09-30 (ruling TG1), verbatim as relayed: +"(b) ABCD ADDS THE MISSING LABELS: on the next site setup or site build, abcd +adds only the missing required labels (with the default words); the +project's own wording elsewhere in ui.json is never changed. No failure, no +manual step; both intents stay impact: additive." + +We therefore make one exception to "a file the repository owns once it exists +is kept": `abcd site setup` and `abcd site build` add to an existing +`ui.json` each label the allowlist declares and the file does not carry, with +the words abcd's own bundled `ui.json` gives it, and name each added label on +stderr, one line per label. Nothing else in the file changes: + +- A label the file carries keeps its wording byte for byte. A label declared + blank is the project's declaration, so it is not rewritten and `LoadUI` + still refuses it by name. +- The added members go at the end of their block, in the block's own + indentation and line style; a whole declared block the file lacks is added + with every label in it. Every byte already in the file stays where it was. +- A file that does not decode against the allowlist, an unknown key included, + is not touched: the closed allowlist refuses it exactly as before, and + adding applies to declared keys only. The `forge_names` map is not required, + so nothing is added to it, and `_purpose` is never rendered, so it is never + added. +- The file is read as the site's other reads are, so a symlinked or + non-regular `ui.json` is refused, and it is written atomically through the + canonical writer, keeping its mode. + +## Alternatives Considered + +- **(a) A breaking impact.** Keep the refusal and declare both intents + `breaking`, so the cut derives a major-shaped version and every adopter adds + the lines by hand. Rejected by the ruling: a manual step for words abcd + already knows. +- **(b) abcd adds the missing labels.** Chosen: no failure and no manual step, + both intents stay additive, and the project's wording is never touched. +- **A fallback at render time.** Render a missing label from the bundled + words without writing the file. Rejected: adr-47 decision 2 keeps the + allowlist closed and the file the one place the site's added words live; a + silent fallback would render words the repository's file does not hold. + +## Consequences + +- An older `ui.json` keeps building across a release that declares a new + label, and the change it takes is visible: the verb says which labels it + added, and the file is left modified in the working tree for the person to + commit (`site setup` names it in its commit step). +- `site build` writes one file outside its output directory, the repository's + `ui.json`, and only when a declared label is absent. +- A future label is additive for adopters by construction, provided the + bundled `ui.json` declares its words; a test holds the bundled file to + declaring every label. diff --git a/.abcd/development/intents/shipped/itd-2609212103568351-the-bare-abcd-status-board-and-the-site-s-status-page-show.md b/.abcd/development/intents/shipped/itd-2609212103568351-the-bare-abcd-status-board-and-the-site-s-status-page-show.md index 45ee4eb97..42a50d0bf 100644 --- a/.abcd/development/intents/shipped/itd-2609212103568351-the-bare-abcd-status-board-and-the-site-s-status-page-show.md +++ b/.abcd/development/intents/shipped/itd-2609212103568351-the-bare-abcd-status-board-and-the-site-s-status-page-show.md @@ -77,6 +77,8 @@ Fidelity review OWED (receipt rcp-4d55b6f29ab8). Changed on 2026-09-29 by the product thinker's rulings BV1 and BV2 of that day (DECISIONS.md entry landing with lane recRulings), recorded as iss-2609292011569133: the text board gives Later as a count alone while `--json` and the site's Status page keep its rows (criterion 1), and an intent in a lane is listed under Now only, never also under Next or Later, so without the state file that intent returns to the list the gate places it in (criteria 1 and 3). The criterion text above stands as shipped; adr-2609292012006845 supersedes adr-2609212115255771 and restates decision 2. +Changed on 2026-09-30 by the product thinker's ruling TG1 of that day, recorded as adr-2609301720596683: a managed repository's `site-src/ui.json` written before this intent's `status.*` labels existed keeps building, because `abcd site setup` and `abcd site build` add each declared label the file lacks with abcd's default words, name it on stderr, and change nothing else in the file. The impact stays `additive`. + ## Grounds - pursued: phases are retired today and the record needs a place a person looks to see what is next; we expect the computed block to be read where the phase documents were not; shown wrong if Now is found naming an intent neither in a lane nor next diff --git a/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md b/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md index 4ea923160..0f8925311 100644 --- a/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md +++ b/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md @@ -73,6 +73,8 @@ _None open._ Fidelity review OWED (receipt rcp-47e25ab4498e). +Changed on 2026-09-30 by the product thinker's ruling TG1 of that day, recorded as adr-2609301720596683: a managed repository's `site-src/ui.json` written before this intent's `status.target` label existed keeps building, because `abcd site setup` and `abcd site build` add each declared label the file lacks with abcd's default words, name it on stderr, and change nothing else in the file. The impact stays `additive`. + ## Grounds - pursued: milestones are retired today and this is the only place must-land-by survives; we expect the cut's report to be read and the moved target to be acted on; shown wrong if targets are never set or carried past two cuts unremarked diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 82a51b46d..86bd090e7 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2622,3 +2622,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-30 — The drain reads the drained repository's own eligibility record, which may loosen abcd's floors loudly, and it hands back every record still waiting on a person (the product thinker's rulings BX2 and H11 of 2026-09-29, applied by lane drainOwnRule of autonomous run A; partial of itd-82, whose spec stays open for the host judgement, the lane, the hand-back writes and the pace). BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does." H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." As built: the record is the one accepted decision record in the repository's `.abcd/development/decisions/adrs/` whose frontmatter carries `drain_categories` (an inline list, a subset of the fixable set), `drain_severities` (an inline list of severities), `drain_security` (`handback` or `take`) and `drain_remedy` (`required`, its only value, since the remedy is the brief a lane works from); abcd's own adr-2609291342092738 carries the strict baseline, which the binary also bundles as the measure a loosening is named against, and a test fails if abcd's record loosens anything. A repository without such a record, with one that is only proposed or superseded, with two accepted, or with one that misses, misspells, repeats or mis-values a field, is refused by `drain --dry-run` and bare `drain` alike, exit 2 and nothing written, never falling back to the baseline or a looser rule; widening the categories past the fixable set is refused as a decision by kind, which H11 does not name. Every loosened floor (`severity major`, `severity critical`, `security`) is named in the dry run's text, on stderr in both output modes, in `--json` as `loosened`, and in the start's refusal. `ahoy install` offers the baseline as an accepted record, written through the decision store's mint only on an answered yes; `--yes` skips it and reports `drain_rule.offered` under `optional_skipped`, as the routing offers are. The gap the remedy lanes found (50 of 54 dry-run-eligible records waiting on a ruling) is closed by BOTH hand-backs, each its own rule: a remedy opening "Waits on" (compared case-folded) is handed back as `waits-on-ruling`, because taking it would make the ruling the remedy waits on; and a record whose `deferred_after` names the current anchor tag is handed back as `deferred`, because a person carried it past this release and the waiver is that person's decision for the cycle. Both hold whatever the repository's record says. `capture defer` writes a deferral only onto a `major` or `critical` record, which H11 now lets a record take, but this ledger also carries hand-written deferrals on minor records (62 of the 231 open records on this branch are handed back as `deferred`), and the rule holds them back the same way. They are asked after the category and severity hand-backs, whose fix a ruling or a lapse would not change, and the ruling before the deferral, because it names which decision is owed; a record carrying both waits on both. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The threat is stated in the drain brief chapter: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; what guards it is that the record is committed history reviewed like code, a loosening is loud on every run, abcd's own repository keeps the baseline under a test, and the store is read inside the checkout so a symlink leaving it is refused. - 2026-09-30 — Correcting three points of the entry above after its review (lane fix-drainOwnRule of autonomous run A). The drain-rule offer of `ahoy install` is asked only of a person at a terminal, the itd-131 precedent the git identity question set, rather than behind a named opt-in flag: off a terminal neither its category question nor the offer is asked, so a piped answer stream keeps the order it had before the offer existed and a scripted yes never writes the record, and the run reports `drain_rule.offered` under `optional_skipped` naming the terminal as the way to be asked. The terminal gate was chosen over a `--drain-rule` flag because the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. A checkout holding no release tag (a shallow clone fetches none) marks the anchor unknown rather than reading every deferral as lapsed: every record carrying a deferral is handed back as `deferred`, naming the missing tags and `git fetch --tags`, which keeps the rest of the dry run readable where refusing the whole plan would not. The rule's reader refuses, as malformed, a record that states any frontmatter key twice (not only a `drain_` key) and one whose frontmatter `id` disagrees with its file name, and reads each record through the capped trust-boundary reader, so a record that is a symlink or past the size cap refuses; every refusal of the rule exits 2 on the dry run as on the bare verb. - 2026-09-30 — Six entries above appear twice, verbatim: the five dated 2026-09-29 from "Two itd-111 follow-ups from its fidelity audit" to "Ruling J13", and the 2026-09-30 entry beginning "The 2026-09-29 itd111Follow entry above". Two histories carried them in opposite order relative to the 2026-09-30 BU1/BT1 entry (main below them, the implement-loop lanes above them), so joining them in integration 24b-3 kept main's order and repeated the six after BU1 in the lanes' order, the one merge result the append-only gate admits (every parent's lines kept in their order, DA002; no line beyond what the merge base held plus what each side added, DA003). Each pair is one decision recorded once: the first copy is the record, and the second repeats it (recorded by the integration lane of autonomous run A). +- 2026-09-30 — An older site interface-string file keeps building: `abcd site setup` and `abcd site build` add to `site-src/ui.json` each label the allowlist declares and the file does not carry, with abcd's default words, name each on stderr, and change nothing else in it (the product thinker's ruling TG1 of 2026-09-30, relayed verbatim: "(b) ABCD ADDS THE MISSING LABELS: on the next site setup or site build, abcd adds only the missing required labels (with the default words); the project's own wording elsewhere in ui.json is never changed. No failure, no manual step; both intents stay impact: additive."). It is the one exception to "a file the repository owns once it exists is kept", recorded as adr-2609301720596683, which refines adr-47 and leaves decision 2's closed allowlist untouched: a blank declared label and an unknown key are still refused, and the site gate's own render never completes the file. itd-2609212103568351 and itd-2609212103572513 keep `impact: additive` (lane tgLabels of autonomous run A). diff --git a/commands/site.md b/commands/site.md index e67375e8a..296980de9 100644 --- a/commands/site.md +++ b/commands/site.md @@ -57,8 +57,16 @@ the root or in `docs/` — and `CITATION.cff` for the footer), `.claude-plugin/plugin.json` (the forge URL, licence and author the links and footer use) — and writes the landing page, the record export, the redirect and header maps, the stylesheet, the two scripts, the -`install.sh`, and every referenced raster into the output directory, and -nowhere else. It reaches no network. The default output directory is `site`, +`install.sh`, and every referenced raster into the output directory. The one +write outside it is `site-src/ui.json` itself, and only when the file lacks a +label abcd declares (a file written before that label existed): the build adds +each such label with abcd's default words, prints one stderr line per label +(`abcd site build: added the missing label status.target to site-src/ui.json +with its default words`), lists them in `added_labels`, and changes nothing +else in the file. A label the file carries keeps its wording, a blank one is +still refused by name, and a key abcd does not declare is still refused. The +render `abcd lint site` makes of an empty output directory never completes the +file, so the gate refuses an incomplete one by name. It reaches no network. The default output directory is `site`, which the repository does not track. The last two are declared deviations from the generic input contract: a repo @@ -95,7 +103,8 @@ the fix is an edit to the page. ``` sets up the site of a repository abcd manages, in three stages, and emits -`{ "status": …, "files": […], "environments": […], "host": {…}, "remaining": […], "notes": […] }`: +`{ "status": …, "files": […], "environments": […], "host": {…}, "remaining": […], "notes": […] }` +(with `added_labels` and `labels_file` when a label was added): - `files` — the repository half, each `written`, `current`, `kept` or `refused`: `.abcd/site.json` (derived from the identity block and @@ -103,7 +112,10 @@ sets up the site of a repository abcd manages, in three stages, and emits `.github/workflows/site.yml` (render on each published release with abcd's verified binary, deploy from the rendered archive) and `wrangler.jsonc`. The composition and the static inputs are the repository's own once they exist - and are `kept`; a workflow or host configuration that differs from what setup + and are `kept`, with one exception: a `site-src/ui.json` lacking a label abcd + declares gains it with abcd's default words and is `written`, each added + label named on stderr and in `added_labels`, and nothing it already says is + rewritten; a workflow or host configuration that differs from what setup writes is `refused`, the whole run writes nothing, and `--confirm` replaces it. - `environments` — the forge's `site-render` and `site` deployment environments, each admitting only the default branch and tags `v*`, created diff --git a/internal/core/site/build.go b/internal/core/site/build.go index 6d308f152..22a67449a 100644 --- a/internal/core/site/build.go +++ b/internal/core/site/build.go @@ -288,6 +288,11 @@ type Request struct { // The front door hands in the implement loop's reader, which this package // cannot import. Lanes statusblock.LaneReader + // LeaveUI renders without completing the repository's ui.json: the gate's + // own render (Check) writes only inside its output directory, so an older + // file is refused there by name and completed by `site build` or + // `site setup` alone. + LeaveUI bool } // Result describes what a build wrote. @@ -314,6 +319,11 @@ type Result struct { // Version and Commit are what the footer says the site was built from. Version string `json:"version"` Commit string `json:"commit"` + // AddedLabels names each interface label the build added to the + // repository's ui.json, LabelsFile, because the file declared none for it + // (the TG1 ruling). Both are empty when nothing was added. + AddedLabels []string `json:"added_labels,omitempty"` + LabelsFile string `json:"labels_file,omitempty"` } // ErrNoManifest is returned when the repository declares no composition. @@ -360,6 +370,15 @@ func Build(req Request) (Result, error) { } return Result{}, err } + // A ui.json written before a label existed gains that label with its + // default words, and nothing else changes (the TG1 ruling); a label the + // file carries, blank or not, is still judged by LoadUI. + var addedLabels []string + if !req.LeaveUI { + if addedLabels, err = addMissingLabels(repoRoot, manifest.UIStrings); err != nil { + return Result{}, err + } + } ui, err := LoadUI(repoRoot, manifest.UIStrings) if err != nil { return Result{}, err @@ -480,15 +499,19 @@ func Build(req Request) (Result, error) { } res := Result{ - OutDir: fsutil.RepoRelativePath(repoRoot, outDir), - Records: len(export.Nodes), - Links: len(export.Edges), - Mentions: len(export.Mentions), - Unresolved: len(export.Health.Unresolved), - Baseline: export.Health.BaselineCount, - Overlaps: export.Layout.Overlaps, - Version: stamp.Version, - Commit: stamp.Commit, + OutDir: fsutil.RepoRelativePath(repoRoot, outDir), + Records: len(export.Nodes), + Links: len(export.Edges), + Mentions: len(export.Mentions), + Unresolved: len(export.Health.Unresolved), + Baseline: export.Health.BaselineCount, + Overlaps: export.Layout.Overlaps, + Version: stamp.Version, + Commit: stamp.Commit, + AddedLabels: addedLabels, + } + if len(addedLabels) > 0 { + res.LabelsFile = manifest.UIStrings } // The output tree is a RENDER of this commit, not an accumulation of every diff --git a/internal/core/site/check.go b/internal/core/site/check.go index 37169bdfe..d23686d07 100644 --- a/internal/core/site/check.go +++ b/internal/core/site/check.go @@ -284,7 +284,7 @@ func Check(req CheckRequest) (CheckResult, error) { Findings: []CheckFinding{}, Notes: []CheckFinding{}, } if ok, _ := fsutil.Exists(filepath.Join(outDir, "index.html")); !ok { - if _, err := Build(Request{RepoRoot: repoRoot, OutDir: outDir, Lanes: req.Lanes}); err != nil { + if _, err := Build(Request{RepoRoot: repoRoot, OutDir: outDir, Lanes: req.Lanes, LeaveUI: true}); err != nil { return CheckResult{}, err } res.Built = true diff --git a/internal/core/site/setup.go b/internal/core/site/setup.go index fcd1e8dd0..a579d567b 100644 --- a/internal/core/site/setup.go +++ b/internal/core/site/setup.go @@ -175,6 +175,11 @@ type SetupResult struct { Files []scaffold.FileOutcome `json:"files"` Environments []EnvironmentOutcome `json:"environments"` Host HostOutcome `json:"host"` + // AddedLabels names each interface label setup added to a ui.json the + // repository already had, LabelsFile (the TG1 ruling). Both are empty when + // nothing was added. + AddedLabels []string `json:"added_labels,omitempty"` + LabelsFile string `json:"labels_file,omitempty"` // Remaining are the exact steps left for the person, in order. Remaining []string `json:"remaining,omitempty"` // Notes say what the verb deliberately did not do, and why. @@ -258,6 +263,29 @@ func Setup(req SetupRequest) (SetupResult, error) { } return res, nil } + // The one exception to "a file the repository owns is kept" (the TG1 + // ruling): a ui.json the repository already had gains each declared label + // it lacks, with its default words, and nothing in it is rewritten. + added, aerr := addMissingLabels(root, manifest.UIStrings) + if aerr != nil { + res.Status = StatusRefused + res.Notes = append(res.Notes, "the missing interface labels could not be added to "+manifest.UIStrings+ + ", so no remote change was attempted: "+scrubRoot(aerr, root)) + for _, env := range []string{EnvRender, EnvDeploy} { + res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteNotReached}) + } + return res, nil + } + if len(added) > 0 { + res.AddedLabels, res.LabelsFile = added, manifest.UIStrings + for i := range res.Files { + if res.Files[i].Path == manifest.UIStrings { + res.Files[i].Status = scaffold.StatusWritten + res.Files[i].Detail = "added the missing labels " + strings.Join(added, ", ") + } + } + wrote++ + } changed := wrote > 0 declined, refused := false, false diff --git a/internal/core/site/uiadd.go b/internal/core/site/uiadd.go new file mode 100644 index 000000000..68fb52f7b --- /dev/null +++ b/internal/core/site/uiadd.go @@ -0,0 +1,335 @@ +package site + +// Completing an older ui.json (the TG1 ruling, recorded in adr-2609301720596683). +// +// A repository's ui.json is the repository's own once it exists: `site setup` +// seeds it and never rewrites it. The one exception is a label the closed +// allowlist declares and the file does not carry at all, which is what a file +// written before that label existed looks like. `site setup` and `site build` +// add each such label with the word abcd's own ui.json gives it, and change +// nothing else: a label the file declares keeps its wording (a blank one is +// still refused by name), an unknown key is still refused by the closed +// allowlist and blocks the adding, and every byte already in the file stays +// where it is. The added members go at the end of their block, in the block's +// own indentation. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "reflect" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// jsonObject is one object of the file as written: where its members start and +// end, and the objects its members hold. +type jsonObject struct { + open int64 // offset just past '{' + lastEnd int64 // offset just past the last member's value; -1 when empty + close int64 // offset of '}' + keys map[string]bool + children map[string]*jsonObject +} + +// uiEdit replaces src[start:end] with text. +type uiEdit struct { + start, end int64 + text string +} + +// addMissingLabels adds to the ui.json at rel, under repoRoot, every label the +// allowlist declares and the file does not carry, each with its default words, +// and returns the added labels by their path in the file (`status.target`). +// A file that carries every label, that does not decode against the allowlist +// (an unknown key, a syntax fault), or that is absent, is left untouched and +// nothing is returned: LoadUI reports it. A symlinked or non-regular file is +// refused as LoadUI refuses it. +func addMissingLabels(repoRoot, rel string) ([]string, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return nil, err + } + defer root.Close() + src, err := fsutil.ReadGuardedInRoot(root, rel, maxUIBytes) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(src)) + dec.DisallowUnknownFields() + var probe UI + if dec.Decode(&probe) != nil { + return nil, nil + } + top, err := scanObject(src) + if err != nil { + return nil, nil + } + defaults, err := defaultUI() + if err != nil { + return nil, err + } + unit := memberIndent(src, top) + if unit == "" { + unit = " " + } + var added []string + var edits []uiEdit + planLabels(src, top, reflect.TypeOf(UI{}), reflect.ValueOf(defaults), "", unit, true, &added, &edits) + if len(edits) == 0 { + return nil, nil + } + sort.Slice(edits, func(i, j int) bool { return edits[i].start > edits[j].start }) + out := append([]byte(nil), src...) + for _, e := range edits { + out = append(out[:e.start:e.start], append([]byte(e.text), out[e.end:]...)...) + } + check := json.NewDecoder(bytes.NewReader(out)) + check.DisallowUnknownFields() + if err := check.Decode(&probe); err != nil { + return nil, fmt.Errorf("%w: %s: adding the missing labels did not produce a readable file: %v", ErrUIInvalid, rel, err) + } + if err := fsutil.WriteFileAtomicPreserveModeInRoot(root, rel, out); err != nil { + return nil, err + } + return added, nil +} + +// defaultUI is abcd's own ui.json, the source of every default word. +func defaultUI() (UI, error) { + data, err := setupSources.ReadFile("setupsrc/ui.json") + if err != nil { + return UI{}, err + } + var ui UI + if err := json.Unmarshal(data, &ui); err != nil { + return UI{}, err + } + return ui, nil +} + +// planLabels walks one object against its struct, recording an edit for the +// declared labels the object lacks. A declared block the file lacks is added +// whole; a map (forge_names) is never required, so never added; `_purpose` is +// never rendered, so never added. +func planLabels(src []byte, obj *jsonObject, t reflect.Type, def reflect.Value, prefix, unit string, parentMultiline bool, added *[]string, edits *[]uiEdit) { + var members []string + indent := memberIndent(src, obj) + multiline := strings.Contains(string(src[obj.open:firstNonSpace(src, obj.open)]), "\n") + if obj.lastEnd < 0 { + // An empty block has no member to copy: it takes its indentation from + // its closing brace, and breaks its lines when its parent does. + indent = lineIndent(src, obj.close) + unit + multiline = parentMultiline + } + nl := lineBreak(src) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + key := strings.Split(f.Tag.Get("json"), ",")[0] + if key == "_purpose" || f.Type.Kind() == reflect.Map { + continue + } + path := prefix + key + if obj.keys[key] { + if child := obj.children[key]; child != nil && f.Type.Kind() == reflect.Struct { + planLabels(src, child, f.Type, def.Field(i), path+".", unit, multiline, added, edits) + } + continue + } + var val string + switch f.Type.Kind() { + case reflect.String: + if strings.TrimSpace(def.Field(i).String()) == "" { + continue + } + val = jsonString(def.Field(i).String()) + *added = append(*added, path) + case reflect.Struct: + val = renderBlock(f.Type, def.Field(i), path+".", indent, unit, multiline, nl, added) + if val == "" { + continue + } + default: + continue + } + members = append(members, jsonString(key)+": "+val) + } + if len(members) == 0 { + return + } + sep := ", " + lead := "" + if multiline { + sep = "," + nl + indent + lead = nl + indent + } + if obj.lastEnd >= 0 { + *edits = append(*edits, uiEdit{start: obj.lastEnd, end: obj.lastEnd, text: "," + strings.TrimPrefix(sep, ",") + strings.Join(members, sep)}) + return + } + text := lead + strings.Join(members, sep) + if multiline { + text += nl + lineIndent(src, obj.close) + } + *edits = append(*edits, uiEdit{start: obj.open, end: obj.close, text: text}) +} + +// renderBlock renders a whole declared block the file lacks, naming each of +// its labels in added. +func renderBlock(t reflect.Type, def reflect.Value, prefix, indent, unit string, multiline bool, nl string, added *[]string) string { + var members []string + inner := indent + unit + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + key := strings.Split(f.Tag.Get("json"), ",")[0] + var val string + switch f.Type.Kind() { + case reflect.String: + if strings.TrimSpace(def.Field(i).String()) == "" { + continue + } + val = jsonString(def.Field(i).String()) + *added = append(*added, prefix+key) + case reflect.Struct: + val = renderBlock(f.Type, def.Field(i), prefix+key+".", inner, unit, multiline, nl, added) + if val == "" { + continue + } + default: + continue + } + members = append(members, jsonString(key)+": "+val) + } + if len(members) == 0 { + return "" + } + if !multiline { + return "{" + strings.Join(members, ", ") + "}" + } + return "{" + nl + inner + strings.Join(members, ","+nl+inner) + nl + indent + "}" +} + +// jsonString encodes s as a JSON string without HTML escaping, as a person +// would write it. +func jsonString(s string) string { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + _ = enc.Encode(s) + return strings.TrimRight(b.String(), "\n") +} + +// scanObject reads the file's top-level object and every object it holds, +// with the offsets an insertion needs. +func scanObject(src []byte) (*jsonObject, error) { + dec := json.NewDecoder(bytes.NewReader(src)) + tok, err := dec.Token() + if err != nil { + return nil, err + } + if d, ok := tok.(json.Delim); !ok || d != '{' { + return nil, errors.New("ui.json is not an object") + } + return readObject(dec) +} + +// readObject reads the members of an object whose '{' was just read. +func readObject(dec *json.Decoder) (*jsonObject, error) { + obj := &jsonObject{open: dec.InputOffset(), lastEnd: -1, keys: map[string]bool{}, children: map[string]*jsonObject{}} + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + if d, ok := tok.(json.Delim); ok && d == '}' { + obj.close = dec.InputOffset() - 1 + return obj, nil + } + key, ok := tok.(string) + if !ok { + return nil, errors.New("ui.json: an object key is not a string") + } + obj.keys[key] = true + child, err := readValue(dec) + if err != nil { + return nil, err + } + if child != nil { + obj.children[key] = child + } + obj.lastEnd = dec.InputOffset() + } +} + +// readValue reads one value, returning it when it is an object. +func readValue(dec *json.Decoder) (*jsonObject, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + d, ok := tok.(json.Delim) + if !ok { + return nil, nil + } + switch d { + case '{': + return readObject(dec) + case '[': + for dec.More() { + if _, err := readValue(dec); err != nil { + return nil, err + } + } + if _, err := dec.Token(); err != nil { + return nil, err + } + return nil, nil + } + return nil, io.ErrUnexpectedEOF +} + +// firstNonSpace is the offset of the first byte at or after i that is not +// JSON whitespace. +func firstNonSpace(src []byte, i int64) int64 { + for i < int64(len(src)) && strings.IndexByte(" \t\r\n", src[i]) >= 0 { + i++ + } + return i +} + +// memberIndent is the indentation of an object's first member: the spaces +// after the last line break before it, empty when it shares the brace's line. +func memberIndent(src []byte, obj *jsonObject) string { + run := string(src[obj.open:firstNonSpace(src, obj.open)]) + nl := strings.LastIndexByte(run, '\n') + if nl < 0 { + return "" + } + return run[nl+1:] +} + +// lineIndent is the leading whitespace of the line holding offset i. +func lineIndent(src []byte, i int64) string { + start := bytes.LastIndexByte(src[:i], '\n') + 1 + end := start + for end < len(src) && (src[end] == ' ' || src[end] == '\t') { + end++ + } + return string(src[start:end]) +} + +// lineBreak is the file's own line break: CRLF when it uses one, LF otherwise. +func lineBreak(src []byte) string { + if bytes.Contains(src, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} diff --git a/internal/core/site/uiadd_test.go b/internal/core/site/uiadd_test.go new file mode 100644 index 000000000..474b4ea32 --- /dev/null +++ b/internal/core/site/uiadd_test.go @@ -0,0 +1,317 @@ +package site + +// The TG1 ruling (the product thinker, 2026-09-30): on the next `site setup` or +// `site build`, abcd adds only the missing required labels to ui.json, with +// their default words, and never changes the project's own wording. These tests +// hold the adding to exactly that: absent declared keys gain the bundled text, +// every byte already in the file stays, and nothing else widens. + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// bundledUI is the seed ui.json abcd ships, the source of every default word. +func bundledUI(t *testing.T) string { + t.Helper() + data, err := setupSources.ReadFile("setupsrc/ui.json") + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// writeUI puts body at site-src/ui.json under a fresh root. +func writeUI(t *testing.T, body string) (root string) { + t.Helper() + root = t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "site-src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "site-src", "ui.json"), []byte(body), 0o640); err != nil { + t.Fatal(err) + } + return root +} + +func readUIFile(t *testing.T, root string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "site-src", "ui.json")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// cut removes the first occurrence of s from body, failing when it is absent. +func cut(t *testing.T, body, s string) string { + t.Helper() + if !strings.Contains(body, s) { + t.Fatalf("the fixture does not carry %q", s) + } + return strings.Replace(body, s, "", 1) +} + +// The reviewer's probe: a ui.json written before status.target existed gains +// that one line, in the file's own indentation, and nothing else moves. +func TestAMissingLabelIsAddedWithItsDefaultWords(t *testing.T) { + full := bundledUI(t) + // The project's own wording, and an escape the re-encoding would spell + // differently, both of which must survive byte for byte. + own := strings.Replace(full, `"now": "Now"`, `"now": "Right now é"`, 1) + own = strings.Replace(own, `"nav_story": "Story"`, `"nav_story": "Our "`, 1) + old := cut(t, own, " \"target\": \"target\",\n") + root := writeUI(t, old) + + added, err := addMissingLabels(root, "site-src/ui.json") + if err != nil { + t.Fatalf("add: %v", err) + } + if !reflect.DeepEqual(added, []string{"status.target"}) { + t.Fatalf("added %v, want [status.target]", added) + } + got := readUIFile(t, root) + want := strings.Replace(old, "\"order_record_id\": \"READY intents read oldest id first\"\n", + "\"order_record_id\": \"READY intents read oldest id first\",\n \"target\": \"target\"\n", 1) + if got != want { + t.Fatalf("the file is not the old one with one line added:\n--- got\n%s\n--- want\n%s", got, want) + } + fi, err := os.Stat(filepath.Join(root, "site-src", "ui.json")) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o640 { + t.Errorf("the file's mode is %v, want the project's 0640 kept", fi.Mode().Perm()) + } + ui, err := LoadUI(root, "site-src/ui.json") + if err != nil { + t.Fatalf("the completed file does not load: %v", err) + } + if ui.Status.Now != "Right now é" || ui.NavStory != "Our " { + t.Errorf("the project's wording changed: now=%q nav_story=%q", ui.Status.Now, ui.NavStory) + } + + // A second pass has nothing to add and writes nothing. + before, _ := os.Stat(filepath.Join(root, "site-src", "ui.json")) + again, err := addMissingLabels(root, "site-src/ui.json") + if err != nil || len(again) != 0 { + t.Fatalf("second pass added %v, err %v", again, err) + } + after, _ := os.Stat(filepath.Join(root, "site-src", "ui.json")) + if !os.SameFile(before, after) { + t.Error("a pass with nothing to add replaced the file") + } +} + +// A whole block missing is added whole, each of its labels named, and the file +// loads with nothing missing. A compact, one-line block keeps its shape. +func TestAMissingBlockIsAddedWholeAndACompactFileStaysCompact(t *testing.T) { + full := bundledUI(t) + start := strings.Index(full, ",\n \"status\": {") + if start < 0 { + t.Fatal("the bundled file has no status block") + } + noStatus := full[:start] + "\n}\n" + root := writeUI(t, noStatus) + added, err := addMissingLabels(root, "site-src/ui.json") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"status.now", "status.target", "status.order_record_id"} { + if !contains(added, want) { + t.Errorf("added %v does not name %s", added, want) + } + } + if _, err := LoadUI(root, "site-src/ui.json"); err != nil { + t.Fatalf("the completed file does not load: %v", err) + } + if got := readUIFile(t, root); !strings.HasPrefix(got, full[:start]) { + t.Error("the bytes before the added block changed") + } + + compact := `{"relations": {"blocked_by": "blocks", "supersedes": "superseded by"}, "status": {}}` + root = writeUI(t, compact) + added, err = addMissingLabels(root, "site-src/ui.json") + if err != nil { + t.Fatal(err) + } + got := readUIFile(t, root) + if strings.Contains(got, "\n") { + t.Errorf("a one-line file gained line breaks:\n%s", got) + } + if !strings.HasPrefix(got, `{"relations": {"blocked_by": "blocks", "supersedes": "superseded by", "implements": `) { + t.Errorf("the compact block's own members moved:\n%s", got) + } + if !contains(added, "relations.implements") || !contains(added, "status.target") || !contains(added, "nav_story") { + t.Errorf("added %v", added) + } + if _, err := LoadUI(root, "site-src/ui.json"); err != nil { + t.Fatalf("the completed compact file does not load: %v", err) + } +} + +// A label the project declared, even blank, is its own: it is never rewritten, +// and the blank one is still refused by name. +func TestADeclaredLabelIsNeverRewritten(t *testing.T) { + blank := strings.Replace(bundledUI(t), `"target": "target"`, `"target": " "`, 1) + root := writeUI(t, blank) + added, err := addMissingLabels(root, "site-src/ui.json") + if err != nil || len(added) != 0 { + t.Fatalf("added %v, err %v; want nothing", added, err) + } + if got := readUIFile(t, root); got != blank { + t.Error("a declared label was rewritten") + } + if _, err := LoadUI(root, "site-src/ui.json"); err == nil || !strings.Contains(err.Error(), "no text for status.target") { + t.Fatalf("a blank declared label loads: %v", err) + } +} + +// Adding applies to declared keys only: a file carrying an unknown key is left +// exactly as it is, and the closed allowlist still refuses it. +func TestAnUnknownKeyIsStillRefusedAndNothingIsAdded(t *testing.T) { + body := cut(t, bundledUI(t), " \"target\": \"target\",\n") + body = strings.Replace(body, `"nav_story": "Story",`, `"nav_story": "Story", "nav_blog": "Blog",`, 1) + root := writeUI(t, body) + added, err := addMissingLabels(root, "site-src/ui.json") + if err != nil || len(added) != 0 { + t.Fatalf("added %v, err %v; want nothing", added, err) + } + if got := readUIFile(t, root); got != body { + t.Error("a file carrying an unknown key was written") + } + if _, err := LoadUI(root, "site-src/ui.json"); err == nil || !strings.Contains(err.Error(), "nav_blog") { + t.Fatalf("the unknown key is not refused: %v", err) + } +} + +// The adder reads the file the way the site's other reads do: a symlinked or +// non-regular ui.json is refused and nothing is written through it. +func TestASymlinkedOrNonRegularUIIsRefused(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "site-src"), 0o755); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "ui.json") + body := cut(t, bundledUI(t), " \"target\": \"target\",\n") + if err := os.WriteFile(outside, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "site-src", "ui.json")); err != nil { + t.Fatal(err) + } + if _, err := addMissingLabels(root, "site-src/ui.json"); !errors.Is(err, fsutil.ErrNotRegular) { + t.Fatalf("a symlinked ui.json: err %v, want ErrNotRegular", err) + } + if got, _ := os.ReadFile(outside); string(got) != body { + t.Error("the symlink's target was written") + } + + dirRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(dirRoot, "site-src", "ui.json"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := addMissingLabels(dirRoot, "site-src/ui.json"); !errors.Is(err, fsutil.ErrNotRegular) { + t.Fatalf("a directory at ui.json: err %v, want ErrNotRegular", err) + } +} + +// The bundled file is the source of every default word, so it must declare +// every label: a default that is blank would add a refusal, not a word. +func TestTheBundledUIDeclaresEveryLabel(t *testing.T) { + root := writeUI(t, bundledUI(t)) + if _, err := LoadUI(root, "site-src/ui.json"); err != nil { + t.Fatal(err) + } +} + +// `site build` completes an older file before it loads it: the reviewer's +// probe builds, and the build says which label it added. +func TestBuildAddsAMissingLabelAndSaysSo(t *testing.T) { + f := newFixture(t) + body, err := os.ReadFile(filepath.Join(f.Root(), "site-src", "ui.json")) + if err != nil { + t.Fatal(err) + } + f.write("site-src/ui.json", cut(t, string(body), `"target": "target", `)) + res, err := Build(Request{RepoRoot: f.Root(), OutDir: t.TempDir(), Stamp: fixtureStamp}) + if err != nil { + t.Fatalf("a ui.json without status.target does not build: %v", err) + } + if !reflect.DeepEqual(res.AddedLabels, []string{"status.target"}) { + t.Fatalf("build added %v, want [status.target]", res.AddedLabels) + } + if _, err := LoadUI(f.Root(), "site-src/ui.json"); err != nil { + t.Fatalf("the file build completed does not load: %v", err) + } +} + +func contains(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +// `site setup` keeps a ui.json the repository already has, and completes it: +// the missing label is added, the file is reported written with the label +// named, and the commit step names it. +func TestSetupAddsAMissingLabelToAKeptUI(t *testing.T) { + h := newHarness(t) + h.run(t) + path := filepath.Join(h.repo.Root(), "site-src", "ui.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + own := strings.Replace(cut(t, string(body), " \"target\": \"target\",\n"), `"now": "Now"`, `"now": "Today"`, 1) + if err := os.WriteFile(path, []byte(own), 0o644); err != nil { + t.Fatal(err) + } + + res := h.run(t) + if !reflect.DeepEqual(res.AddedLabels, []string{"status.target"}) { + t.Fatalf("setup added %v, want [status.target]", res.AddedLabels) + } + if got := fileStatuses(res)["site-src/ui.json"]; got != "written" { + t.Errorf("site-src/ui.json is %q, want written", got) + } + if !strings.Contains(strings.Join(res.Remaining, "\n"), "site-src/ui.json") { + t.Errorf("the commit step does not name the completed file: %v", res.Remaining) + } + ui, err := LoadUI(h.repo.Root(), "site-src/ui.json") + if err != nil { + t.Fatalf("the completed file does not load: %v", err) + } + if ui.Status.Now != "Today" || ui.Status.Target != "target" { + t.Errorf("now=%q target=%q", ui.Status.Now, ui.Status.Target) + } +} + +// The gate over a rendered site writes only inside its output directory, the +// render it makes when the directory is empty included: it never completes the +// repository's ui.json, and an older file is refused by name there, as before. +func TestTheGatesRenderLeavesTheUIAlone(t *testing.T) { + f := newFixture(t) + body, err := os.ReadFile(filepath.Join(f.Root(), "site-src", "ui.json")) + if err != nil { + t.Fatal(err) + } + old := cut(t, string(body), `"target": "target", `) + f.write("site-src/ui.json", old) + _, err = Check(CheckRequest{RepoRoot: f.Root(), OutDir: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "no text for status.target") { + t.Fatalf("the gate's render: err %v, want the missing label named", err) + } + if got := readUIFile(t, f.Root()); got != old { + t.Error("the gate's render wrote the repository's ui.json") + } +} diff --git a/internal/surface/cli/site.go b/internal/surface/cli/site.go index 04bbb4130..0aec0ee72 100644 --- a/internal/surface/cli/site.go +++ b/internal/surface/cli/site.go @@ -68,6 +68,7 @@ func newSiteCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd site build: " + scrubPaths(err)} } + sayAddedLabels(cmd.ErrOrStderr(), "abcd site build", res.LabelsFile, res.AddedLabels) return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { renderSiteBuild(w, res) }) @@ -296,6 +297,7 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd site setup: " + scrubPaths(err)} } + sayAddedLabels(cmd.ErrOrStderr(), "abcd site setup", res.LabelsFile, res.AddedLabels) if rerr := render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { renderSiteSetup(w, res) }); rerr != nil { @@ -317,6 +319,16 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { return cmd } +// sayAddedLabels tells the person, on stderr and one line per label, which +// interface labels a verb added to the repository's ui.json (the TG1 ruling): +// the file is theirs, so a write to it is never silent, and stdout stays the +// verb's result in both the text and the JSON form. +func sayAddedLabels(w io.Writer, verb, file string, labels []string) { + for _, l := range labels { + fmt.Fprintln(w, termsafe.Sanitize(verb+": added the missing label "+l+" to "+file+" with its default words")) + } +} + // renderSiteSetup prints the three stages and what remains. func renderSiteSetup(w io.Writer, res site.SetupResult) { fmt.Fprintf(w, "abcd site setup — %s\n", termsafe.Sanitize(res.Status)) diff --git a/internal/surface/cli/site_setup_test.go b/internal/surface/cli/site_setup_test.go index 1f0b0bdbf..84647597c 100644 --- a/internal/surface/cli/site_setup_test.go +++ b/internal/surface/cli/site_setup_test.go @@ -173,3 +173,61 @@ func TestSiteVerbsReadTheCheckoutFromASubdirectory(t *testing.T) { t.Fatalf("lint site in a subdirectory refused: %v\n%s", err, out) } } + +// TestSiteVerbsSayWhichLabelsTheyAdded is the TG1 ruling at the CLI: a ui.json +// lacking a declared label is completed by `site setup` and by `site build`, +// and each says so on stderr, one line per label, leaving stdout to the result. +func TestSiteVerbsSayWhichLabelsTheyAdded(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + r := gittest.NewRepo(t) + r.Write("AGENTS.md", "# Example\n\n\nmanaged\n\n") + r.Write(".abcd/positioning.json", `{"schema_version": 1, "block": {"file": ".abcd/development/IDENTITY.md", "heading": "Identity (canonical)"}, "severity": "warn", "surfaces": []}`+"\n") + r.Write(".abcd/development/IDENTITY.md", "# Identity\n\n## Identity (canonical)\n\n- **Title:** Example\n- **Tagline:** An example.\n") + r.Write("docs/README.md", "# Example\n\nThe example's documentation.\n") + r.Commit("the example") + t.Chdir(r.Root()) + if out, err := runCLIErr(t, "site", "setup", "--name", "example-site"); err != nil { + t.Fatalf("site setup: %v\n%s", err, out) + } + uiPath := filepath.Join(r.Root(), "site-src", "ui.json") + drop := func(label string) { + t.Helper() + body, err := os.ReadFile(uiPath) + if err != nil { + t.Fatal(err) + } + // The seeded line, or the last member a completion put back. + line := " \"" + label + "\": \"" + label + "\",\n" + if !strings.Contains(string(body), line) { + line = ",\n \"" + label + "\": \"" + label + "\"" + } + if !strings.Contains(string(body), line) { + t.Fatalf("ui.json carries no %s label:\n%s", label, body) + } + if err := os.WriteFile(uiPath, []byte(strings.Replace(string(body), line, "", 1)), 0o644); err != nil { + t.Fatal(err) + } + } + want := "added the missing label status.target to site-src/ui.json with its default words\n" + + drop("target") + out, errOut, err := runCLISplit(t, "site", "setup") + if err != nil { + t.Fatalf("site setup: %v\n%s%s", err, out, errOut) + } + if errOut != "abcd site setup: "+want { + t.Fatalf("setup stderr = %q", errOut) + } + + drop("target") + out, errOut, err = runCLISplit(t, "site", "build", "--out", t.TempDir()) + if err != nil { + t.Fatalf("site build: %v\n%s%s", err, out, errOut) + } + if errOut != "abcd site build: "+want { + t.Fatalf("build stderr = %q", errOut) + } + if strings.Contains(out, "added the missing label") { + t.Errorf("the added-label lines reached stdout:\n%s", out) + } +}