Skip to content

fix: mark every notice check prints, and carry notices as a list - #397

Merged
thecodedrift merged 8 commits into
mainfrom
refactor/consolidate-notice-joiners
Sep 23, 2026
Merged

thecodedrift merged 8 commits into
mainfrom
refactor/consolidate-notice-joiners

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

The user-visible bug, first

taskless check printed only the first line of its notice output behind a marker. A run with two advisories — a Vale config advisory beside Vale's own zero-exit diagnostic, say — rendered like this:

Notice: no-simply/.vale.ini line 8: matcher [*.md] assigns no-simply.no-simply again …
no-twist/.vale.ini line 8: matcher [*.md] assigns no-twist.no-twist again …

The second advisory arrives as a bare, unindented line with nothing identifying it as a notice. This is not cleanup: it is the same defect 241e1c4 fixed in verify, still live in check, and it fails in the direction hardest to notice — the notice still appears, just wrongly attributed.

check's renderer now splits each notice on "\n" and prefixes every line, exactly as verify does.

The second one, in the same output

The runtime plan's notices were printed with no marker at all. check had two loops: plan.notices went out bare, dispatched.notices got Notice: . Both land in the same --json notices array, so the same message looked like two different kinds of thing depending on which list it arrived on — and the plan's notices are the ones whose entire purpose is explaining a rule that did not run.

All three sources (plan notices, skipped runtime rules, dispatched notices) now go through one warnNotice.

Both commands now share markNotice from the leaf module. They differ only in the marker — Notice: at the left margin for check, notice: indented under the rule for verify — which is a presentation choice, not a second contract. One helper is what stops 241e1c4 being fixed in one renderer and left live in the other, which is precisely this bug's history.

Per-line marking on this path is latent rather than live: no notice the CLI produces today spans lines. But a runtime repair notice interpolates an Error.message it did not author, and Vale's stderr is passed through as written, so it will arrive without anyone choosing it. markNotice is therefore unit-tested on the multi-line case directly rather than through a producer that cannot yet reach it.

The structural half, so the trap stays sprung

Fixing the renderer alone leaves the cause in place. Four producers each built a notice by joining a list of strings, so DispatchResult.notices was assembled by flatMap over pre-joined strings — and flatMap does not split strings, so several notices arrived as one array element. That is what check --json published.

notices is now a list all the way through: EngineOutcome, ValeAttempt/ValeRunOutcome, ValeVerifyResult, SchemaLayerResult, RuleVerification and RuleTestResult all carry string[], one notice per element. check --json's notices array keeps its name and its string[] type; only its element boundaries change, so one element is now exactly one notice.

Presentation belongs to the renderer. A producer no longer picks a separator, so it cannot pick the wrong one.

packages/cli/src/util/notices.ts holds the one helper, collectNotices, as a leaf module with no imports of its own — dispatch.ts importing it from vale/run.ts would add an edge inside src/rules/ between modules that already sit close to the cycle #388 fixed. It drops undefined entries and also "", which previously survived the filter and rendered as a bare marker saying nothing.

The " " join in rules/verify.ts, and what it cost to remove

This was the open question in the brief, and the answer is that it no longer exists.

rules/verify.ts joined language.notices with a space, not a newline. An sg rule declaring both an accepted-but-off-list language: spelling and a files: glob that language cannot parse produced one run-on sentence behind one marker. It was proposed to keep it as a documented exception. With the field a list, that call site does not join at all — it contributes two elements and the renderer marks each.

What was measured before committing to that. The two pipelines are not independent: they converge at runVale, which feeds check via dispatch.ts and verify/test via vale/verify.ts → inspect.ts. run.ts glued the converter-skip notice and Vale's stderr into one string, so leaving ValeRunOutcome.notice a joined string would have left check --json publishing an element with an embedded newline regardless. Carrying the list through the verify pipeline was therefore not optional extra scope — it was what the check fix already required.

The real cost is the published surface, and it is three released fields, not the two the brief named:

Field Introduced In v0.11.2?
verifyOutputSchema.schema.notice 520a382 yes
valeVerifyOutputSchema.notice 5f2dbb2 yes
verify/test envelope ruleResult.notice — yes

All three become notices: string[]. Replaced, not mirrored. A joined notice retained alongside would preserve the separator convention this PR exists to remove, and a consumer could not have split it safely in the first place, because nothing published the separator. Empty-and-present rather than absent, matching violations beside it, so a consumer can tell "nothing to report" from "this CLI does not report notices". check --json consumers need change nothing.

Pre-1.0 (0.11.2) the bump stays patch; the changeset body says what a consumer crosses rather than leaning on the bump to say it.

Also cut: the ?? outcome.message fallback at the Vale unavailable branch, which was unreachable — outcome.message is a string there, so the joiner never returned undefined.

Spec

Nine spec files mention "notice" and every one is about whether one surfaces. Nothing said how several are separated, how they render, or what a machine consumer receives — which is why check could ship this defect without violating a requirement. Three ADDED requirements, one each to cli-check, cli-rule-validation and cli-vale-rule-engine.

ADDED rather than MODIFIED throughout: nothing standing describes this, so there is no requirement to restate, and a MODIFIED block against a requirement about a different question is how scenarios get dropped silently.

Verified by dry-run archive and reset to a captured SHA, per CLAUDE.md:

Capability Requirements Scenarios
cli-check 20 → 21 55 → 60
cli-rule-validation 7 → 8 37 → 40
cli-vale-rule-engine 11 → 12 37 → 40

No prior scenario is missing from any of the three. The change directory is left in place.

Tests

  • markNotice on a multi-line notice: every line marked, not just the first.
  • The runtime plan's warning, end to end: asserted to carry the Notice: marker. The existing test used toContain("dangerously-run-scripts"), which passed on the unmarked output.
  • The one that was actually missing: check's TEXT output had no test of how it renders notices at all. The new one drives the built CLI over a project whose two Vale rules each draw a config advisory and asserts each reaches its own Notice: line — it fails on the old code.
  • check --json asserts the two are separate elements and that no element spans lines.
  • collectNotices gets unit tests: order preserved, undefined dropped, "" dropped, empty in empty out, multi-line notice left as one element.
  • The three separator-blind tests named in the issue asserted with toContain over the whole field, so a producer switching separator kept passing. They now assert on elements. No assertion was weakened.

pnpm build, pnpm typecheck, pnpm lint (exit 0, including pnpm cli check) and pnpm test (1709 passed, 103 files) all green.

Fixes #390

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@github-actions github-actions Bot added the Open OpenSpec Contains unresolved OpenSpec changes. All openspec changes must eventually reach an archive state. label Sep 23, 2026
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 3m 38s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no reviewThreads and no reviews, only the triggering @claude /review comment itself — this is the first review of this PR.

  • Read .prior-review.json
  • Gather PR diff and metadata (gh pr diff 397, gh pr view 397)
  • Run finder angles across the diff (line-by-line scan, removed-behavior audit, cross-file tracing of EngineOutcome/RuleVerification/RuleTestResult/ValeRunOutcome/SchemaLayerResult producers and consumers, reuse/simplification/efficiency/altitude, CLAUDE.md conventions)
  • Verify candidates
  • Post findings

Nothing new found. No confirmed or plausible correctness bugs survived verification.

What I checked

  • The core fix (packages/cli/src/commands/check.ts:255-263, packages/cli/src/commands/verify.ts:168-172): both renderers now split each notice on "\n" and prefix every line, matching the described defect and fix.
  • The structural change: traced notice?: string → notices: string[] through every producer and consumer — dispatch.ts (EngineOutcome, runValeEngine, runEngines), vale/run.ts (ValeAttempt/ValeRunOutcome, retry loop in runVale), vale/verify.ts (ValeRuleVerification), inspect.ts (RuleVerification/RuleTestResult, verifySgRule, verifyRuleComponents, testOneRule — every return branch, including the early-error ones), and rules/verify.ts (SchemaLayerResult, validateLanguage). Every return site was updated consistently; grepped the whole packages/cli/src and packages/cli/test trees for .notice (singular) and found zero stragglers.
  • The removed ?? outcome.message fallback (dispatch.ts, now at line ~258): confirmed genuinely dead — outcome.message on the unavailable branch is a non-optional string, so collectNotices([...advisories, outcome.message]) always includes it; the old ?? arm could never fire.
  • collectNotices (packages/cli/src/util/notices.ts): correctly drops undefined and "", preserves order, is a genuine leaf module (no imports), and is used consistently by every producer that previously joined strings by hand.
  • Ordering: verified test expectations (verify-test-commands.test.ts, vale-orchestration.test.ts) match the actual push order in production code (style/schema advisories before config advisories, schema advisories before Vale's own diagnostic).
  • Schema changes (schemas/rules-verify.ts, schemas/verify-test.ts): notices is now a required z.array(z.string()) (never .optional()), matching the non-optional string[] on the TS interfaces — no absent/empty mismatch.
  • OpenSpec deltas: correctly ADDED (not MODIFIED) requirements in all three capability spec files, avoiding the requirement-replacement trap called out in this repo's root CLAUDE.md. The changeset bump is patch with a body stating what a consumer crosses, matching the pre-1.0 guidance verbatim. Fixes #390 is a correct trailing reference.
  • Conventions: no barrel-import violations, no library-type violations found in the touched files; nothing in .conventions/STYLEGUIDE-CODE.md or STYLEGUIDE-UI.md applies distinctly here beyond what's already followed.

One thing that's a documentation nuance rather than a bug worth blocking on: the proposal (openspec/changes/2026-09-23-notice-list-contract/proposal.md) says "This PR is the tip, so the change is archived here," while tasks.md and the PR body both say the change was only dry-run archived and "the change directory is left in place." Not a code defect, just a wording mismatch between the proposal and what actually happened — worth a one-line tweak if you touch that file again, not worth a follow-up on its own.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 26s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains only the earlier @claude /review comment and its reply (which itself found nothing); no inline review threads exist on this PR. Every finding below is therefore [New] relative to prior review state — there is nothing previously-raised to mark as addressed or still-open.

  • Read .prior-review.json
  • Gather PR diff and metadata (gh pr diff, gh pr view)
  • Run five parallel finder angles: line-by-line diff scan, removed-behavior audit, cross-file tracing of EngineOutcome/RuleVerification/RuleTestResult/ValeRunOutcome/SchemaLayerResult producers and consumers, reuse/simplification/efficiency/altitude, CLAUDE.md conventions
  • Verify candidates against file contents
  • Post findings

No correctness, security, or test-adequacy bugs found. The notice?: string → notices: string[] migration was traced through every producer (dispatch.ts, vale/run.ts, vale/verify.ts, inspect.ts, rules/verify.ts) and every return branch, including early-error paths — all consistent, no stragglers, no reachable dead code left behind (the removed ?? outcome.message fallback was confirmed genuinely unreachable pre-PR).

Worth a look (not blocking)

[New] Documented external consumer of the changed envelope. packages/cli/src/schemas/index.ts:14-15 states the Cloud eval team "is doing exactly that today" — hand-writing an interface against the verify envelope, which is exactly the ruleResultSchema/valeVerifyOutputSchema surface this PR changes from notice?: string to notices: string[]. The changeset (.changeset/notice-list-contract.md) does correctly spell out "what a consumer crosses" per this repo's convention, so the process was followed — but given a specific team is named in-repo as depending on this exact shape today, it may be worth a direct heads-up to them beyond the changelog.

[New] Two small duplications, low severity:

  • commands/check.ts (~184-186) and commands/verify.ts (~169-173) each hand-roll the same "for each notice, split via markNotice, print each line" loop rather than sharing it from util/notices.ts. The PR's own description frames the marker as the only difference between the two renderers, which suggests the outer loop could be shared too, leaving only the marker string as a parameter.
  • collectNotices() is called on advisories arrays in dispatch.ts:239/252/258 and inspect.ts:343 that are built solely from non-empty template-literal .push() calls (verified in schemas/vale-config.ts, schemas/vale-rule.ts) — these can never contain undefined/"", so the filter is a no-op there. Harmless, but slightly implies the data needs cleaning when it doesn't.

Considered and set aside: whether the changeset should be minor rather than patch since the replaced notice field was already released in 0.11.0. Concluded this doesn't hold up — CLAUDE.md's primary rule ("while 0.y.z, adding surface is still patch... ask this first") and its instruction to put the consumer-facing detail in the changeset body rather than the bump are both followed here, and the PR body's own bump rationale already applies this reasoning correctly.

One pre-existing documentation nuance from the last review remains true and isn't repeated here as a new finding: the OpenSpec proposal's "archived here" wording vs. the dry-run-only archive described in tasks.md/PR body.
(branch: refactor/consolidate-notice-joiners)

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 3m 38s … ### Review complete … Nothing new found.…"
#397 (comment)

Confirmed — no high/medium findings, nothing to change. The proposal-vs-tasks.md wording nuance you spotted is real and is being routed to the owner as a low item rather than fixed here; the archive was deliberately dry-run only (verification per CLAUDE.md, then git reset --hard to a captured SHA), and the change directory is intentionally left in place.

— AI Coding Agent

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 2m 26s … ### Review complete … No correctness, security, or test-adequacy bugs found.…"
#397 (comment)

No high/medium findings, so nothing auto-fixed. Your three "worth a look" items are all low and are being reported to the owner verbatim for a call rather than actioned unilaterally. Two notes so the reasoning is on the record:

  • The shared render loop. markNotice already carries the contract both renderers depend on; what is left in each is genuinely different — check writes through warn (a no-op under --json) while verify writes to stdout via console.log. Folding that into the leaf module would push an output-sink decision into a module whose value is having no dependencies, so the marker is not quite the only difference. Still a fair suggestion, and the owner's to take.
  • collectNotices as a no-op on some arrays. Accurate today. It is deliberately called unconditionally so the contract holds at the seam rather than at each producer — a later producer appending an optional or interpolated value should not have to remember to re-add the filter. Worth stating in the docblock if the owner wants it kept.

Separately, one gap this review round did catch indirectly: the changeset was missing the paragraph describing the runtime-plan notice fix (it was written but never staged into the amend it rode on). Fixed in 95c2587.

The two deliberate positions in this PR stand and were not challenged: the renderers split on "\n" by design (an element is one notice, but one notice can legitimately span lines — Vale's stderr passes through as written, so "\n" is formatting within a message, never a separator between messages), and the three published notice fields were replaced rather than mirrored (retaining a joined notice would preserve the very separator convention this change removes, and nothing ever published the separator a consumer would need to split on). Agreed on patch over minor, for the reasons you set aside it for.

— AI Coding Agent

@github-actions github-actions Bot removed the Open OpenSpec Contains unresolved OpenSpec changes. All openspec changes must eventually reach an archive state. label Sep 23, 2026
The `unavailable` branch built its notice as
`joinNotices([...advisories, outcome.message]) ?? outcome.message`. On that
branch `outcome.message` is a `string`, so the list handed to the joiner is
never empty and the joiner never returns `undefined`. The `??` arm could not
run.

It read as a safety net, which is worse than no net: the next person to touch
the joiner would have reasoned about a fallback path that does not exist.
…as a list

`check`'s text output printed `Notice: ` once per element of
`DispatchResult.notices`, while the producers glued several advisories into
one element with `"\n"`. A run with a schema advisory and a Vale diagnostic
therefore printed the first behind a marker and the second as an unlabelled
stray line. That is the same defect `241e1c4` fixed in `verify`, still live in
`check`.

Fixed twice over, because one fix alone leaves the trap set:

- Both renderers now split a notice on `"\n"` and prefix every line. A single
  notice can legitimately span lines — Vale's stderr is passed through as
  written — so this is needed even once the list is flat.
- The underlying field is a list. `EngineOutcome.notices`, `ValeRunOutcome`,
  `ValeVerifyResult`, `SchemaLayerResult`, `RuleVerification` and
  `RuleTestResult` all carry `string[]`, one notice per element, so
  `runEngines` concatenates into a genuinely flat `DispatchResult.notices` and
  `check --json` stops publishing array elements that are several notices in a
  trench coat. Presentation belongs to the renderer; a producer that picked its
  own separator could only mis-render.

`rules/verify.ts` was the producer that proved the point: it joined
`language.notices` with a SPACE, so an off-list `language:` spelling and a
`files:` glob its language cannot parse arrived as one run-on sentence behind
one marker. There is now no separator convention left for a producer to get
wrong.

`packages/cli/src/util/notices.ts` holds the one helper, `collectNotices`, as a
leaf module with no imports of its own: having `dispatch.ts` import it from
`vale/run.ts` would add an edge inside `src/rules/` between modules that
already sit close to the cycle #388 fixed. It drops `undefined` entries, and
also `""`, which no longer renders as a bare marker saying nothing.

BREAKING: the published `notice?: string` field becomes `notices: string[]` in
`verifyOutputSchema.schema`, `valeVerifyOutputSchema` and the `verify`/`test`
envelope — so `verify --json` and `test --json` emit `notices`. Replaced rather
than mirrored: keeping a joined `notice` alongside would preserve the separator
convention this change exists to remove, and a consumer could not safely split
it in the first place. Empty, never absent, as `violations` beside it already
is.

Refs #390
…s copies

Three gaps, all of them the reason the defect survived:

- `collectNotices` gets its own tests: order preserved, `undefined` dropped,
  `""` dropped, empty input empty out, and a multi-line notice left as one
  element.
- `check`'s TEXT output had no test at all for how it renders notices. The new
  one drives the built CLI over a project whose two Vale rules each draw a
  config advisory, and asserts each reaches its own `Notice: ` line. On the old
  code the two arrived joined and the second printed as an unmarked stray, so
  this fails there.
- `check --json` now asserts the two are separate elements and that no element
  spans lines.

The three separator-blind tests named in #390 asserted with `toContain` over
the whole field, so a producer switching separator would have kept passing.
They now assert on the elements.
Nine spec files mention "notice" and every one is about WHETHER one surfaces.
Nothing said how several are separated, how they render, or what a machine
consumer receives — so the four producers and two renderers agreed only by
coincidence, and `check`'s renderer could ship the `241e1c4` defect without
violating a requirement.

Three ADDED requirements, one per capability that owns a producer or a
renderer:

- `cli-check` — one marker per notice, every line of a multi-line notice
  marked, and `--json` `notices` flat with one notice per element.
- `cli-rule-validation` — `verify`/`test` carry notices as a list, one
  `notice:` marker each, and publish `notices` present-and-empty rather than
  absent.
- `cli-vale-rule-engine` — independent advisories from one run stay separate
  notices; the engine picks no separator.

ADDED rather than MODIFIED throughout: nothing standing describes this, so
there is no requirement to restate, and a MODIFIED block against a requirement
about a different question is how scenarios get dropped silently.

Verified by dry-run archive and reset. Requirements 20/7/11 -> 21/8/12 and
scenarios 55/37/37 -> 59/40/40, with no prior scenario missing from any of the
three.
`patch`, and pre-1.0 settles it: the package is 0.11.2, where semver puts the
public API outside the stability guarantee, so replacing a published field's
shape does not earn more. The body says what a consumer crosses rather than
leaning on the bump to say it.
…enderer

`check` printed the runtime plan's notices — what a repair restored, what it
could not, and why — in their own loop with NO marker, while the dispatched
notices got `Notice: `. Both end up in the same `--json` `notices` array, so
the same message looked like two different kinds of thing depending on which
list it arrived on, and the plan's notices are exactly the ones whose whole
purpose is explaining a rule that did not run.

All three sources (plan notices, skipped runtime rules, dispatched notices) now
go through one `warnNotice`, so they cannot drift apart again.

`markNotice` joins `collectNotices` in the leaf module and is shared by both
commands, which differ only in the marker: `Notice: ` at the left margin for
`check`, `    notice: ` indented under the rule for `verify`. That is a
presentation choice, not a second contract, and having one helper is what stops
`241e1c4` being fixed in one renderer and left live in the other — which is the
exact history of this bug.

Per-line marking matters here for a reason that is latent rather than live: no
notice the CLI produces today spans lines, but a runtime repair notice embeds
an `Error.message` it did not author, and Vale's stderr is passed through as
written. `markNotice` is unit-tested on the multi-line case directly, since
nothing reachable end to end exercises it yet.

The `cli-check` delta gains a sentence and a scenario for the marked-alike
rule. Re-verified by dry-run archive and reset: requirements 20/7/11 -> 21/8/12
and scenarios 55/37/37 -> 60/40/40, no prior scenario missing.
The paragraph was written but never staged: the amend it was meant to ride on
reported no staged files and went through with the earlier tree, so the shipped
note described only the dispatched-notice half of a fix that has two.
This PR is the tip — no open PR is based on this branch — so the change
archives here rather than on landing. `openspec-label.yml` reports an
unarchived change directory, and `main` takes pull requests only, so a change
that reaches `main` unarchived needs a second PR to do what the tip should have
done.

The real archive matches the dry run exactly, which is the check that matters:
an archive REPLACES each requirement it names, so a delta that quietly drops a
scenario leaves no trace and `validate --strict` still passes.

  cli-check             20 -> 21 requirements, 55 -> 60 scenarios
  cli-rule-validation    7 ->  8 requirements, 37 -> 40 scenarios
  cli-vale-rule-engine  11 -> 12 requirements, 37 -> 40 scenarios

Zero prior scenarios missing in any of the three, compared set-wise rather than
by count so a drop masked by an addition could not hide.

The proposal's delivery-shape note said the change "is archived here" while
tasks.md recorded only the dry run, which read as a contradiction. Both now say
the same thing, and say why the tip is where it happens.
@thecodedrift
thecodedrift force-pushed the refactor/consolidate-notice-joiners branch from 5790e3e to 48afe1a Compare September 23, 2026 20:54
@thecodedrift
thecodedrift merged commit 10a7522 into main Sep 23, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the refactor/consolidate-notice-joiners branch September 23, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consolidate the three notice joiners, so the one-notice-per-line contract is written down once

1 participant