Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/notice-list-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@taskless/cli": patch
---

`taskless check` now marks every notice it prints. A run with more than one advisory used to print the first behind a `Notice: ` marker and the rest as bare, unindented lines with nothing identifying them as notices — so a Vale config advisory sitting beside Vale's own diagnostic read as stray output. `verify` had the same defect and it was fixed earlier; `check` did not get the fix until now.

A second, related gap in the same output: the notices from runtime rule planning — what was repaired, what could not be, and why — were printed by their own loop with no `Notice: ` marker at all, while engine notices were marked. `check --json` merges both into one `notices` array, so the same message looked like two different kinds of thing depending on which list it arrived on. Every notice `check` prints is now marked, and every line of one is.

The cause was that notices were joined into one string before they reached the renderer, so `check --json` also published array elements that were several notices glued together, with no separator a consumer could rely on to split them back apart. Notices are now carried as a list from producer to output: in `check --json` the `notices` array keeps its name and type, and only its element boundaries change — one element is now exactly one notice.

**What a consumer crosses:** the optional `notice` string on `verify --json` and `test --json` per-rule results is now a `notices` array of strings, present and empty rather than absent when there is nothing to say. The same replacement applies to the exported `verifyOutputSchema` (its `schema` layer) and `valeVerifyOutputSchema`. Read `notices` where you read `notice`, and render one marker per element instead of splitting on a separator. It was replaced rather than mirrored because a joined `notice` kept alongside would preserve the convention this change removes, and nothing ever published the separator that would have made splitting it safe. `check --json` consumers need change nothing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-23
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
## Why

taskless/cli#390 asked for four duplicated notice joiners to become one helper.
The duplication was never the problem. The problem is that "one notice per
line" was an agreement between four producers and two renderers that existed
only as four string literals nobody was obliged to keep identical — and one of
them already did not, joining with a space.

Nine spec files mention "notice", every one of them about **whether** a notice
surfaces. Nothing standing says how several notices are separated, how they are
rendered, or what a machine consumer receives. So a producer could switch
separator, or a renderer stop prefixing, and no requirement would be violated.

That is not hypothetical. `check` shipped the defect: it printed `Notice: `
once per element while producers glued several advisories into one element, so
a run with two advisories printed the first behind a marker and the second as
an unlabelled stray line. `verify` had the same bug and it was fixed in
`241e1c4`; nothing recorded the fix as a requirement, so `check` kept it.

The behaviour is now a list — one notice per element, all the way from the
producers to the published envelope — and these deltas say so, in the three
capabilities that own the producers and the renderers.

## What Changes

- **`cli-check`** — a new requirement: `check` renders one marker per notice
and prefixes every line of one, and `--json` publishes `notices` as a flat
list in which one element is one notice.
- **`cli-rule-validation`** — a new requirement: `verify` and `test` carry
notices as a list, render one `notice:` marker per element, and publish
`notices` in `--json`, replacing the joined `notice` string.
- **`cli-vale-rule-engine`** — a new requirement: when the Vale engine has
several independent things to say about one run, each is a distinct notice
rather than being folded into one.

All three are ADDED. Nothing standing describes this, so there is no
requirement to restate, and a MODIFIED block would risk dropping scenarios from
requirements that are about a different question entirely.

## Capabilities

### New Capabilities

None. Three existing capabilities gain a requirement each.

### Modified Capabilities

- `cli-check`: gains "Notices render one marker per notice and publish as a
flat list".
- `cli-rule-validation`: gains "Verify and test carry notices as a list".
- `cli-vale-rule-engine`: gains "Independent Vale advisories stay separate
notices".

## Impact

The published `notice?: string` field becomes `notices: string[]` in
`verifyOutputSchema.schema`, `valeVerifyOutputSchema`, and the `verify`/`test`
envelope. It was replaced rather than mirrored: a joined `notice` kept beside
the list would preserve the separator convention this change exists to remove,
and a consumer could not safely split it apart in the first place, since
nothing published the separator. `check --json`'s `notices` keeps its name and
its `string[]` type; only its element boundaries change.

The bump is `patch`. The package is `0.11.2`, pre-1.0, where semver puts the
public API outside the stability guarantee — the changeset body says what a
consumer crosses.

## Delivery shape

**Single PR.** The helper, the structural change, the renderer fix, the tests
and these deltas are one reviewable diff, and splitting them would land a spec
describing behaviour that is not yet there, or a renderer fix without the
requirement that keeps it fixed.

This PR is the tip — no open PR is based on this branch — so the change is
archived here, on this PR, 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## ADDED Requirements

### Requirement: Notices render one marker per notice and publish as a flat list

`check` SHALL carry advisory messages as an ordered list in which one element is one notice, and SHALL NOT join several notices into one element.

In human output the CLI SHALL prefix EVERY line it prints of a notice with the `Notice: ` marker, including the second and later lines of a notice that spans lines. A notice may legitimately span lines — an engine's own diagnostic output is passed through as written — so a marker on the first line alone leaves the rest reading as unlabelled stray output, which is the failure this requires against.

Every notice the run reports SHALL be marked alike, whichever stage produced it — runtime planning or engine dispatch. `--json` merges both into one `notices` array, so text output that marked one source and not the other made the same message look like two different kinds of thing depending on which list it arrived on.

Under `--json` the `notices` array SHALL be flat: each element SHALL be exactly one notice. A consumer SHALL NOT be required to split an element on a separator, because no separator between notices is published and none is part of the contract.

A notice SHALL NOT affect the exit code.

#### Scenario: Two independent advisories each get their own marker

- **WHEN** a `check` run produces two independent notices and human output is rendered
- **THEN** each notice SHALL be printed on its own line behind its own `Notice: ` marker

#### Scenario: Every line of a multi-line notice is marked

- **WHEN** a single notice spans several lines and human output is rendered
- **THEN** the CLI SHALL print each of its lines behind a `Notice: ` marker

#### Scenario: A runtime plan notice is marked like an engine notice

- **WHEN** runtime planning reports a notice and human output is rendered
- **THEN** the CLI SHALL print it behind the same `Notice: ` marker it gives an engine's notice

#### Scenario: A machine consumer receives one notice per element

- **WHEN** a `check --json` run produces two independent notices
- **THEN** the `notices` array SHALL hold two elements, one notice each

#### Scenario: Nothing to say publishes nothing

- **WHEN** a `check` run produces no notices
- **THEN** human output SHALL print no `Notice: ` line
- **AND** `--json` SHALL omit the `notices` field
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## ADDED Requirements

### Requirement: Verify and test carry notices as a list

`verify` and `test` SHALL carry what they have to say about a rule without failing it as an ordered list in which one element is one notice, and SHALL NOT join several notices into one element with any separator.

In human output the CLI SHALL print one ` notice:` marker per notice, and SHALL prefix every line of a notice that spans lines. Under `--json` each per-rule result SHALL carry a `notices` array of strings, present and empty when there is nothing to say rather than absent, so a consumer can tell "nothing to report" from "this CLI does not report notices" — the same distinction `violations` beside it already draws.

A notice SHALL be reported on a rule that passed as well as on one that failed, and SHALL NOT affect the exit code.

#### Scenario: Two advisories about one rule are two notices

- **WHEN** one rule draws both a style-layer advisory and a config-layer advisory
- **THEN** `verify --json` SHALL report them as two elements of that rule's `notices`
- **AND** human output SHALL print each behind its own `notice:` marker

#### Scenario: Two language advisories on one sg rule stay separate

- **WHEN** an sg rule declares an accepted-but-off-list `language:` spelling AND a `files:` glob that language cannot parse
- **THEN** `verify` SHALL report the two as separate notices rather than as one joined line

#### Scenario: A rule with nothing to report carries an empty list

- **WHEN** `verify --json` reports a rule that drew no advisory
- **THEN** that rule's `notices` SHALL be present and empty
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: Independent Vale advisories stay separate notices

When a single Vale run has more than one thing to say without failing, the engine SHALL carry each as its own notice rather than folding them into one.

The two arise independently and a project can perfectly well draw both at once: the converter-skip notice naming files this build cannot parse, and Vale's own stderr from a run that still exited zero. The config schema's advisories about the assembled rules are a third, and they ride beside whatever Vale itself reported. Folding them into one message made the second and later ones render without a marker of their own, which reads as stray output rather than as something the run is telling the author.

The engine SHALL NOT choose a separator between notices: presentation belongs to the renderer, which marks each notice and each of its lines.

#### Scenario: A skip notice and a zero-exit diagnostic are two notices

- **WHEN** one Vale run both declines a converter-dependent file and writes a diagnostic to stderr while exiting zero
- **THEN** the engine SHALL report two notices, one for each

#### Scenario: A config advisory rides beside Vale's own report

- **WHEN** the config schema advises on an assembled rule and Vale then reports a diagnostic of its own
- **THEN** the two SHALL reach the check result as separate notices

#### Scenario: A config advisory survives a Vale that could not run

- **WHEN** the config schema advises on an assembled rule and the Vale binary is unavailable
- **THEN** the advisory and the unavailability message SHALL both reach the result, as separate notices
49 changes: 49 additions & 0 deletions openspec/changes/archive/2026-09-23-notice-list-contract/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## 1. Implementation

- [x] 1.1 Add `packages/cli/src/util/notices.ts` as a leaf module with no
imports of its own, exporting `collectNotices`, which drops `undefined`
and `""` and preserves order.
- [x] 1.2 Cut the unreachable `?? outcome.message` fallback in the Vale
dispatch `unavailable` branch.
- [x] 1.3 Carry notices as `string[]` through `EngineOutcome`,
`ValeAttempt`/`ValeRunOutcome`, `ValeVerifyResult`, `SchemaLayerResult`,
`RuleVerification` and `RuleTestResult`, so `DispatchResult.notices` is
genuinely flat.
- [x] 1.4 Remove the space join in `rules/verify.ts`, so `language.notices`
contributes elements like every other producer and no second separator
convention remains.
- [x] 1.5 Fix `check`'s renderer to prefix every line of every notice, the
defect `241e1c4` fixed in `verify` and left live in `check`.
- [x] 1.6 Replace the published `notice?: string` with `notices: string[]` in
`verifyOutputSchema.schema`, `valeVerifyOutputSchema` and the
`verify`/`test` envelope.
- [x] 1.7 Mark the runtime plan's notices too. They were printed by their own
loop with no marker while the dispatched ones were marked, though
`--json` merges both into one array.
- [x] 1.8 Share one `markNotice` helper between `check` and `verify`, so the
two renderers differ only in the marker.

## 2. Tests

- [x] 2.1 Unit-test `collectNotices`: order preserved, `undefined` dropped,
`""` dropped, empty in empty out, multi-line notice left as one element.
- [x] 2.2 Add the missing end-to-end test of `check`'s TEXT output: two
advisories, two `Notice: ` lines.
- [x] 2.3 Assert `check --json` publishes them as separate elements, none
spanning lines.
- [x] 2.4 Unit-test `markNotice` on a multi-line notice, and assert
end-to-end that the runtime plan's warning is marked.
- [x] 2.5 Strengthen the three separator-blind tests to assert on elements
rather than `toContain` over the whole field.

## 3. Spec

- [x] 3.1 Add the rendering and list contract to `cli-check`.
- [x] 3.2 Add the list contract to `cli-rule-validation`.
- [x] 3.3 Add the separate-advisories contract to `cli-vale-rule-engine`.
- [x] 3.4 Dry-run `openspec archive` and diff requirement and scenario counts
per capability to prove nothing standing is dropped.
- [x] 3.5 Archive for real on this PR, which is the tip, and check the result
against the dry run's counts. `openspec-label.yml` reports an unarchived
change directory, and `main` takes pull requests only, so a change that
lands unarchived needs a second PR to correct it.
38 changes: 38 additions & 0 deletions openspec/specs/cli-check/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,41 @@ A rule id is the name of a rule's directory under `.taskless/rules/<engine>/`. T
- **WHEN** a user runs `taskless check --rule <id> src/foo.ts`
- **THEN** the CLI SHALL treat `src/foo.ts` as the path to scan and `<id>` as the rule filter
- **AND** SHALL NOT treat `<id>` as a path

### Requirement: Notices render one marker per notice and publish as a flat list

`check` SHALL carry advisory messages as an ordered list in which one element is one notice, and SHALL NOT join several notices into one element.

In human output the CLI SHALL prefix EVERY line it prints of a notice with the `Notice: ` marker, including the second and later lines of a notice that spans lines. A notice may legitimately span lines — an engine's own diagnostic output is passed through as written — so a marker on the first line alone leaves the rest reading as unlabelled stray output, which is the failure this requires against.

Every notice the run reports SHALL be marked alike, whichever stage produced it — runtime planning or engine dispatch. `--json` merges both into one `notices` array, so text output that marked one source and not the other made the same message look like two different kinds of thing depending on which list it arrived on.

Under `--json` the `notices` array SHALL be flat: each element SHALL be exactly one notice. A consumer SHALL NOT be required to split an element on a separator, because no separator between notices is published and none is part of the contract.

A notice SHALL NOT affect the exit code.

#### Scenario: Two independent advisories each get their own marker

- **WHEN** a `check` run produces two independent notices and human output is rendered
- **THEN** each notice SHALL be printed on its own line behind its own `Notice: ` marker

#### Scenario: Every line of a multi-line notice is marked

- **WHEN** a single notice spans several lines and human output is rendered
- **THEN** the CLI SHALL print each of its lines behind a `Notice: ` marker

#### Scenario: A runtime plan notice is marked like an engine notice

- **WHEN** runtime planning reports a notice and human output is rendered
- **THEN** the CLI SHALL print it behind the same `Notice: ` marker it gives an engine's notice

#### Scenario: A machine consumer receives one notice per element

- **WHEN** a `check --json` run produces two independent notices
- **THEN** the `notices` array SHALL hold two elements, one notice each

#### Scenario: Nothing to say publishes nothing

- **WHEN** a `check` run produces no notices
- **THEN** human output SHALL print no `Notice: ` line
- **AND** `--json` SHALL omit the `notices` field
24 changes: 24 additions & 0 deletions openspec/specs/cli-rule-validation/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,27 @@ The defect belongs to how `substitution` compiles its keys, not to the regex eng
- **WHEN** a Vale upgrade makes a rejected pattern fire
- **THEN** the vendor contract test asserting it silent SHALL fail
- **AND** the schema's rejection SHALL be removed rather than the test relaxed

### Requirement: Verify and test carry notices as a list

`verify` and `test` SHALL carry what they have to say about a rule without failing it as an ordered list in which one element is one notice, and SHALL NOT join several notices into one element with any separator.

In human output the CLI SHALL print one ` notice:` marker per notice, and SHALL prefix every line of a notice that spans lines. Under `--json` each per-rule result SHALL carry a `notices` array of strings, present and empty when there is nothing to say rather than absent, so a consumer can tell "nothing to report" from "this CLI does not report notices" — the same distinction `violations` beside it already draws.

A notice SHALL be reported on a rule that passed as well as on one that failed, and SHALL NOT affect the exit code.

#### Scenario: Two advisories about one rule are two notices

- **WHEN** one rule draws both a style-layer advisory and a config-layer advisory
- **THEN** `verify --json` SHALL report them as two elements of that rule's `notices`
- **AND** human output SHALL print each behind its own `notice:` marker

#### Scenario: Two language advisories on one sg rule stay separate

- **WHEN** an sg rule declares an accepted-but-off-list `language:` spelling AND a `files:` glob that language cannot parse
- **THEN** `verify` SHALL report the two as separate notices rather than as one joined line

#### Scenario: A rule with nothing to report carries an empty list

- **WHEN** `verify --json` reports a rule that drew no advisory
- **THEN** that rule's `notices` SHALL be present and empty
Loading
Loading