@am_shork/attest 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +945 -225
  2. package/README.md +48 -292
  3. package/dist/cli/index.js +9 -5
  4. package/dist/cli/json.js +25 -1
  5. package/dist/cli/report.d.ts +20 -0
  6. package/dist/cli/report.js +32 -44
  7. package/dist/core/apply.js +49 -1
  8. package/dist/core/docs.d.ts +23 -0
  9. package/dist/core/docs.js +92 -0
  10. package/dist/core/loader.d.ts +22 -0
  11. package/dist/core/loader.js +35 -1
  12. package/dist/core/pipeline.js +2 -3
  13. package/dist/core/red-record.d.ts +9 -1
  14. package/dist/core/red-record.js +67 -9
  15. package/dist/core/runner.d.ts +11 -0
  16. package/dist/core/runner.js +12 -1
  17. package/dist/core/schema.d.ts +12 -0
  18. package/dist/core/schema.js +15 -1
  19. package/dist/core/skill.js +13 -2
  20. package/dist/core/terminal.d.ts +24 -0
  21. package/dist/core/terminal.js +59 -0
  22. package/dist/core/types.d.ts +22 -1
  23. package/dist/{cli → core}/version.js +5 -1
  24. package/dist/core/write.js +37 -5
  25. package/package.json +5 -3
  26. package/dist/cli/index.d.ts.map +0 -1
  27. package/dist/cli/index.js.map +0 -1
  28. package/dist/cli/json.d.ts.map +0 -1
  29. package/dist/cli/json.js.map +0 -1
  30. package/dist/cli/report.d.ts.map +0 -1
  31. package/dist/cli/report.js.map +0 -1
  32. package/dist/cli/version.d.ts.map +0 -1
  33. package/dist/cli/version.js.map +0 -1
  34. package/dist/core/apply.d.ts.map +0 -1
  35. package/dist/core/apply.js.map +0 -1
  36. package/dist/core/gate.d.ts.map +0 -1
  37. package/dist/core/gate.js.map +0 -1
  38. package/dist/core/loader.d.ts.map +0 -1
  39. package/dist/core/loader.js.map +0 -1
  40. package/dist/core/locate.d.ts.map +0 -1
  41. package/dist/core/locate.js.map +0 -1
  42. package/dist/core/order.d.ts.map +0 -1
  43. package/dist/core/order.js.map +0 -1
  44. package/dist/core/parser.d.ts.map +0 -1
  45. package/dist/core/parser.js.map +0 -1
  46. package/dist/core/paths.d.ts.map +0 -1
  47. package/dist/core/paths.js.map +0 -1
  48. package/dist/core/pipeline.d.ts.map +0 -1
  49. package/dist/core/pipeline.js.map +0 -1
  50. package/dist/core/red-record.d.ts.map +0 -1
  51. package/dist/core/red-record.js.map +0 -1
  52. package/dist/core/registry.d.ts.map +0 -1
  53. package/dist/core/registry.js.map +0 -1
  54. package/dist/core/render.d.ts.map +0 -1
  55. package/dist/core/render.js.map +0 -1
  56. package/dist/core/runner.d.ts.map +0 -1
  57. package/dist/core/runner.js.map +0 -1
  58. package/dist/core/schema.d.ts.map +0 -1
  59. package/dist/core/schema.js.map +0 -1
  60. package/dist/core/skill.d.ts.map +0 -1
  61. package/dist/core/skill.js.map +0 -1
  62. package/dist/core/static-registry.d.ts.map +0 -1
  63. package/dist/core/static-registry.js.map +0 -1
  64. package/dist/core/status.d.ts.map +0 -1
  65. package/dist/core/status.js.map +0 -1
  66. package/dist/core/targets.d.ts.map +0 -1
  67. package/dist/core/targets.js.map +0 -1
  68. package/dist/core/types.d.ts.map +0 -1
  69. package/dist/core/types.js.map +0 -1
  70. package/dist/core/validator.d.ts.map +0 -1
  71. package/dist/core/validator.js.map +0 -1
  72. package/dist/core/write.d.ts.map +0 -1
  73. package/dist/core/write.js.map +0 -1
  74. package/dist/index.d.ts.map +0 -1
  75. package/dist/index.js.map +0 -1
  76. package/dist/runtime.d.ts.map +0 -1
  77. package/dist/runtime.js.map +0 -1
  78. /package/dist/{cli → core}/version.d.ts +0 -0
package/README.md CHANGED
@@ -21,8 +21,6 @@ the stronger property; noticing that it *moved* is `verify`'s job, and only when
21
21
  a scenario asserts on the value it read from `params`. Read the param inside the
22
22
  assertion, not beside it.
23
23
 
24
- See the authoritative design — [English](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/attest-design.md) · [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/attest-design.md) — and the rest of the [docs](https://gitlab.com/Pseudorca/attest/-/tree/main/docs).
25
-
26
24
  ## Prerequisites
27
25
 
28
26
  - Node ≥ 20.19
@@ -34,6 +32,14 @@ narrowed by 4, and ATX-36 — reading a registry opens no listening socket — i
34
32
  asserted against the Vite that is installed, so on any other major it is a claim
35
33
  rather than a measurement. If you are on Vitest 2 or 3, stay on `0.3.x`.
36
34
 
35
+ The TypeScript compiler is a **bundled dependency**, not a peer — Attest reads
36
+ your registries and specs through the compiler API, and `typescript@7` no longer
37
+ exposes one (its AST moved behind `typescript/unstable/*`). So the supported
38
+ range is `^5.5.0 || ^6.0.0`, both ends run in CI, and your own compiler is not
39
+ involved: on a project already using TypeScript 5 or 6 the two resolve to one
40
+ copy, and on TypeScript 7 you will simply have a second one that only Attest
41
+ uses. Your project's TypeScript version is yours to choose either way.
42
+
37
43
  ## Getting started
38
44
 
39
45
  **Install** (the framework plus its `vitest` + `vite` peers):
@@ -43,9 +49,10 @@ pnpm add -D @am_shork/attest vitest vite
43
49
  ```
44
50
 
45
51
  **1. Declare a requirement** (`requirements/auth.reqs.ts`) — import the intent API
46
- from the vitest-free `/define` subpath so registry loading never touches the runtime.
47
- A registry is a **literal**: every value is written where you can read it (see
48
- [A registry is a literal](#a-registry-is-a-literal)):
52
+ from the vitest-free `/define` subpath so registry loading never touches the
53
+ runtime. A registry is a **literal**: every value is written where you can read
54
+ it, because the commands below read it without executing it
55
+ ([why](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#a-registry-is-a-literal)).
49
56
 
50
57
  ```ts
51
58
  import { defineRequirements } from '@am_shork/attest/define';
@@ -78,12 +85,6 @@ requirement('AUTH-3', () => {
78
85
  });
79
86
  ```
80
87
 
81
- `scenario` takes an optional third argument, a timeout in milliseconds, forwarded
82
- to Vitest's `it`. The run is isolated (see [What a run actually
83
- runs](#what-a-run-actually-runs)), so Vitest's default applies unless a scenario
84
- says otherwise — worth setting on anything that drives a subprocess, a container
85
- or a build.
86
-
87
88
  **3. Run the engine:**
88
89
 
89
90
  ```bash
@@ -97,31 +98,10 @@ attest archive <change> # gate a proposed change: green + covered + no drift
97
98
  attest status <change> # what that gate still wants, without running anything
98
99
  ```
99
100
 
100
- `<change>` names one directory inside `changes/` it cannot be empty, `.`,
101
- `..`, or contain a path separator, since the name is interpolated into a path
102
- whose module the gate then loads and runs. Both commands check it before
103
- resolving anything on its behalf. Any other directory name is fine.
104
-
105
- `status` is the mid-flight view `archive` cannot give you: it reports each
106
- requirement the change adds as `no-scenario`, `unproven` (a scenario exists but
107
- has never been seen failing) or `proven`, reading the delta, the specs and
108
- `first-run.json` — no suite run, no project code. Every state is named after the
109
- issue the gate will raise, and it uses the gate's own predicate, so the two
110
- cannot disagree. What it deliberately cannot tell you is whether the tests pass:
111
-
112
- ```console
113
- $ attest status add-2fa
114
- Change: add-2fa
115
- ✓ AUTH-7 2 scenarios, seen red
116
- ● AUTH-8 1 never run
117
- ✗ AUTH-9 no scenario yet
118
- — 3 added requirements, 1 ready to archive
119
- Not a verdict: run `attest archive add-2fa` to run the suite.
120
- ```
121
-
122
- It exits 0 there. Unmet obligations are the normal state of a change in flight,
123
- so they are rows, never a failing verdict — only a delta it could not read makes
124
- `status` fail. `archive` remains the one command that says "done".
101
+ Every command takes the project root as an optional last argument, and `--json`
102
+ for exactly one machine-readable document on stdout. Flags, per-command
103
+ behaviour and the JSON shape are in the
104
+ [CLI reference](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md).
125
105
 
126
106
  ### Working with an agent
127
107
 
@@ -130,274 +110,50 @@ agree on intent, write the delta, drive the scenarios red, then implement to
130
110
  green — and it is written for an agent to follow:
131
111
 
132
112
  ```bash
133
- attest init # .claude/skills/attest/SKILL.md
134
- attest init --target cursor # .cursor/rules/attest.mdc
135
- attest init --target copilot # .github/instructions/attest.instructions.md
113
+ attest init # .claude/skills/attest/SKILL.md
114
+ attest init --target cursor # .cursor/rules/attest.mdc
115
+ attest init --target copilot # .github/instructions/attest.instructions.md
136
116
  ```
137
117
 
138
- Each target gets the same document; only the frontmatter differs, because that
139
- is the part the tool parses rather than the part the agent follows.
140
-
141
- That document carries the workflow: how to write a `changes/<name>/` delta, why
142
- its scenarios have to be red before you implement, what `attest archive` blocks
143
- on, and the three shortcuts that turn the gate green without changing anything.
144
118
  "Red before you implement" is enforced rather than advised: `archive` records how
145
119
  each of a change's scenarios ended on its first run, into
146
120
  `changes/<name>/first-run.json`, and blocks with `never-red` on any requirement
147
121
  the delta adds whose scenarios were never seen to fail. Commit `first-run.json`
148
122
  with the change — it is the evidence, and CI has to reach the same verdict as
149
123
  you do.
150
- An agent loads it on its own — its description is already in the agent's
151
- context, or its path matches what you have open — so nothing has to be found or
152
- pasted, and nothing is resident when you are working on something else.
153
-
154
- `--target` is repeatable, and a name that is not a known target writes nothing
155
- at all — an `unknown-target` error naming the ones that would have worked. An
156
- agent whose instructions live in a file your *project* owns — `AGENTS.md`,
157
- `CLAUDE.md` — is deliberately not a target: Attest writes whole files at paths it
158
- names and never edits a document you maintain, so what covers those is one line
159
- you add there yourself, pointing at the file above. It is cheaper than a
160
- generated block and it cannot go stale.
161
-
162
- There is deliberately **no `--check`**. The file is a function of the version you
163
- installed, so gating it would turn CI red on upgrades you did nothing to cause,
164
- and the remedy would be a commit containing no decision. `init` is idempotent
165
- instead: re-run it after upgrading, and that is the whole update mechanism.
166
-
167
- It also writes nothing else — no registry, no `package.json` scripts, no
168
- `changes/` skeleton. A generated registry is either empty (an `empty-spec` error
169
- on day one) or invented, and invented requirements are worse than none.
170
-
171
- ### A registry is a literal
172
-
173
- `check`, `cover`, `render` and `status` read your `*.reqs.ts` files from their
174
- **source**, with the TypeScript compiler API. They never execute them. That is
175
- what makes `attest check` safe to run where its description implies you can —
176
- first in the pipeline, on an untrusted fork MR, outside whatever sandbox you give
177
- the tests. (`verify` and `archive` do run your suite, so they read the registry by
178
- evaluating it: there is nothing left to protect at that point.)
179
-
180
- The cost is a contract: every value in a registry must be written in the
181
- registry.
182
-
183
- ```ts
184
- import { MAX_MB } from '../src/config.js';
185
-
186
- export default defineRequirements({
187
- 'UP-1': {
188
- statement: 'An upload SHALL be rejected above {maxMb} MB.',
189
- rationale: 'Protect the storage tier from unbounded writes.',
190
- params: { maxMb: MAX_MB }, // ✗ registry-not-static
191
- },
192
- });
193
- ```
194
-
195
- This is reported as a `registry-not-static` ERROR pointing at the line. It is
196
- not only a tooling limit: the single-source rule says the requirement owns that
197
- number, and importing it says the application owns it. Inline the value and read
198
- it from the registry in your code — that is the fix. If you need the old
199
- behaviour instead, `--eval` restores it on every one of those commands:
200
-
201
- ```bash
202
- attest check --eval # reads the registry by executing every *.reqs.ts
203
- ```
204
-
205
- `--eval` changes *how* the registry is obtained, never what counts as one: whatever
206
- the module exports goes through the same schema, so a malformed registry is a
207
- `registry-invalid` ERROR under both readers rather than a crash under one of them.
208
-
209
- **The same rule covers a change's `requirements.delta.ts`**, because `status`
210
- reads that file the same way — a delta built from an imported constant is the
211
- same `registry-not-static` ERROR, with the same `--eval` way out. The reason is
212
- sharper there than it is for a registry: `changes/` holds a proposal nobody has
213
- agreed to yet, so a command whose only job is to report on it is the last thing
214
- that should execute it. `archive` evaluates the delta regardless — it is about to
215
- run your suite anyway.
216
-
217
- One more thing to know about literals: write registry prose as **one long
218
- single-line string**. A template literal with no substitution reads statically and
219
- is accepted — but it keeps every newline and every space of indentation inside it,
220
- and `render` puts the statement into the Markdown exactly as written, so the
221
- wrapping that looked tidy in the editor lands in the generated document.
222
-
223
- ### Each registry file owns its id prefix
224
-
225
- You can split intent across as many `*.reqs.ts` as you like — every one under the
226
- project root is read and merged. What you cannot do is have two of them declare
227
- the same prefix:
228
-
229
- ```ts
230
- // requirements/auth.reqs.ts
231
- export default defineRequirements({ 'AUTH-3': { /* … */ } });
232
124
 
233
- // requirements/session.reqs.ts
234
- export default defineRequirements({ 'AUTH-9': { /* */ } }); // duplicate-prefix
235
- ```
236
-
237
- An id is `PREFIX-n` and nothing above the requirement gives that prefix a
238
- meaning, so two files declaring one are two owners of a single space. The ids
239
- above are distinct and every other check passes; what you find out later is that
240
- `AUTH-9` was taken the day someone's counter reached it, in a file they were not
241
- looking at. The ERROR names both files, and the fix is a prefix of your own —
242
- `SESS-1` — not a move or a rename.
243
-
244
- `duplicate-requirement` is the narrower one still there for the same id in two
245
- files. Nothing requires the prefix to match the file's name; `attest.reqs.ts`
246
- holding `ATX-*` is fine, here and in your repo.
247
-
248
- ### A readable spec for humans (`attest render`)
249
-
250
- The intent layer lives in TypeScript, which means a reviewer, a QA engineer, or
251
- anyone doing an audit cannot read it. `render` projects the registry into one
252
- Markdown document — an index of every requirement, then each one with its
253
- statement, rationale, params and out-of-scope notes:
254
-
255
- ```bash
256
- attest render # to stdout
257
- attest render --out docs/SPEC.md # to a file
258
- attest render --out docs/SPEC.md --check # fail if that file is out of date
259
- ```
260
-
261
- The rendering **substitutes params into the statement**, so the document says
262
- "after **30** minutes" where the source says `{idleTimeoutMin}` — the number a
263
- reader actually wants, still from the single source.
264
-
265
- Three properties this deliberately keeps:
266
-
267
- - **One-way.** Markdown is only ever an output. Nothing reads it back, so the
268
- free-form-prose drift that a Markdown *source* invites stays out.
269
- - **Intent only.** No coverage, no counts, no test locations, no pass/fail —
270
- those are verdicts, and `cover` / `verify` recompute them on demand. So the
271
- document is a pure function of your `*.reqs.ts` files and changes if and only
272
- if intent changes: one readable hunk per registry edit, and never a rewrite
273
- because a line moved in a test file.
274
- - **Gated.** A generated file you commit is a new place for drift to hide, so
275
- `--check` regenerates and compares, failing with `stale-spec-doc` if the
276
- committed document no longer matches the registry, or `missing-spec-doc` if it
277
- was never generated. It compares the document, not the bytes: line endings are
278
- a property of your checkout (`core.autocrlf` is on by default on Windows), and
279
- a gate that calls a fresh clone stale is worse than no gate.
280
- Put it in CI next to `attest check`. (Attest does this to itself:
281
- [`self/requirements/SPEC.md`](https://gitlab.com/Pseudorca/attest/-/blob/main/self/requirements/SPEC.md)
282
- is generated, and the pipeline fails if it goes stale.)
283
-
284
- The Markdown is prose for humans and its formatting is not a stable contract —
285
- don't parse it. `--json` is the machine surface.
286
-
287
- ### What a run actually runs
288
-
289
- `verify` executes **only the spec files that declare a `requirement()`**. A repo
290
- that already has a test suite keeps it: those files are located, reported, and
291
- left alone — Attest is not a general test runner, and running someone's suite in
292
- a child process with no aliases and no DOM only ever produced a red about code it
293
- was never pointed at. `archive` uses the same scope.
294
-
295
- Every run says so, before the issues:
296
-
297
- ```
298
- Running 12 spec files (30 without a requirement(), not run)
299
- ✓ No issues.
300
- — 20 requirements / 41 scenarios, 0 error, 0 warning, 0 info
301
- ```
125
+ An agent loads the document on its own — its description is already in the
126
+ agent's context, or its path matches what you have open so nothing has to be
127
+ found or pasted. See
128
+ [`attest init`](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#attest-init)
129
+ for the targets and what `init` deliberately does not write.
302
130
 
303
- The gap between the two numbers is worth watching: a spec file whose
304
- `requirement()` call Attest cannot see statically is skipped rather than failed,
305
- and that is where it shows. The same four numbers are in `--json` under `counts`.
131
+ ### When something goes wrong
306
132
 
307
- When three or more issues in one report share a level and a code, one line above
308
- the counts says so, because a wall of identical codes is one rule and many sites:
133
+ Every diagnostic carries a `code`, and every code has a section in
134
+ **Troubleshooting** which the diagnostic itself links to:
309
135
 
310
136
  ```
311
- Repeated: 6 × ERROR registry-not-static, 4 × WARNING rationale-placeholder
137
+ ERROR registry-not-static (requirements/upload.reqs.ts:5)
138
+ Value is not a literal.
139
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.4.2/docs/en/troubleshooting.md#registry-not-static
312
140
  ```
313
141
 
314
- Every per-issue line stays exactly as it is they are what makes a diagnostic
315
- clickable and the summary is terminal output only: it is not an issue, so it
316
- appears nowhere in `--json`, changes no count, and never moves a verdict.
142
+ The anchor **is** the code, so the link cannot point somewhere the section
143
+ isn't. In `--json` the same link is on each issue as `docsUrl`.
317
144
 
318
- A run with **no requirements** under the root is an `empty-spec` ERROR, not a
319
- pass. "No failing tests" is not the same claim as "the requirements hold", and
320
- the case that matters is not the empty directory — it is the repo whose registry
321
- quietly stopped being found, where the tests still run and still go green.
145
+ ## Documentation
322
146
 
323
- ### Framework code (`.vue`, DOM, path aliases)
324
-
325
- The child run isolates from any ambient config by default, so specs run in a bare
326
- Vite/Vitest environmentno `.vue` transform, DOM, or `~/`/`@/` aliases. When
327
- your specs need that environment, point `verify` (or `archive`) at a config:
328
-
329
- ```bash
330
- attest verify --vitest-config vitest.config.ts
331
- ```
332
-
333
- The config supplies the environment (plugins, `resolve.alias`, `test.environment`);
334
- Attest still owns the run scope — `include`/`exclude`/`root` always win, so a
335
- config cannot pull in your existing suite or un-exclude `changes/`. Without the
336
- flag the run stays isolated and deterministic.
337
-
338
- ### Machine-readable output
339
-
340
- Every command that produces a verdict takes `--json` and then writes exactly one
341
- JSON document to stdout — same work, same exit code. Branch on `ok`,
342
- `issues[].code` and `issues[].reqId`; never parse `message`, which is prose and
343
- may be reworded. (`render` is the one exception, because its own output is a
344
- document: there, `--json` needs `--out` or `--check`, so stdout still carries
345
- exactly one thing.)
346
-
347
- ```bash
348
- attest check --json
349
- ```
350
-
351
- ```json
352
- {
353
- "tool": "attest",
354
- "schemaVersion": 1,
355
- "version": "0.4.0",
356
- "command": "check",
357
- "ok": false,
358
- "summary": { "error": 1, "warning": 0, "info": 0 },
359
- "issues": [
360
- {
361
- "level": "ERROR",
362
- "code": "orphan-test",
363
- "reqId": "PLAN-2",
364
- "file": "auth/session.spec.ts",
365
- "line": 12,
366
- "message": "scenario \"…\" attests unknown requirement \"PLAN-2\". …"
367
- }
368
- ]
369
- }
370
- ```
147
+ | Document | English | 中文 |
148
+ |---|---|---|
149
+ | **CLI reference** every command, flag and JSON field | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/cli-reference.md) |
150
+ | **Troubleshooting**one section per issue code | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/troubleshooting.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/troubleshooting.md) |
151
+ | **Design** the authoritative design of the framework | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/attest-design.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/attest-design.md) |
152
+ | **Feedback template** — report how adoption actually went | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/feedback.template.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/feedback.template.md) |
371
153
 
372
- `verify` adds `passed` (was the test run itself green, independent of the
373
- overall verdict) and `counts` (`requirements`, `scenarios`, `specFiles`,
374
- `attesting`); `cover` adds `coverage[]` — one `{ reqId, covered,
375
- scenarioCount }` row per requirement — plus a `requirements` roll-up;
376
- `archive` adds `change`; `status` adds `change`, `progress[]` — one
377
- `{ reqId, state, scenarios[] }` row per added requirement, each scenario
378
- carrying its recorded `firstRun` (`"fail"`, `"pass"`, or `null` for never
379
- observed) — plus an `added` roll-up; `render` and `init` add `outFile` — the
380
- file the command wrote, and never its contents — and `init` adds `outFiles`,
381
- every file it wrote, since it can be asked for more than one agent target at
382
- once (`outFile` is still there whenever exactly one was written).
383
- `schemaVersion` is bumped on any breaking change to
384
- the shape. The TypeScript type is exported as `JsonReport`.
385
-
386
- `ok` means the same thing everywhere: the report carries no `ERROR`. So an
387
- uncovered requirement reaches a `cover` report twice — as a `covered: false`
388
- row and as an `uncovered-requirement` issue — because the rule above is to
389
- branch on `issues[].code`, and a command that answered `ok: false` with an
390
- empty `issues` array would make you read `coverage[]` to find out why.
391
-
392
- `status` is the one report whose rows are deliberately *not* issues. Its rows
393
- describe a change in progress, and a change in progress has unmet obligations by
394
- definition; carrying them as errors would make `status` exit 1 for the whole life
395
- of every change, and put a second verdict beside `archive`'s. Its `issues` carry
396
- only the reasons the report could not be produced.
397
-
398
- Even when a command crashes, `--json` still writes one parseable envelope
399
- (`ok: false` with an `internal-error` issue) rather than an empty stdout — so a
400
- consumer can always parse stdout and branch on `ok`.
154
+ The change workflow is not here: it is what `attest init` writes into your
155
+ project, as a skill, a rule or an instructions file, depending on which agent
156
+ reads it.
401
157
 
402
158
  ## Development
403
159
 
@@ -430,12 +186,12 @@ from `pnpm test`; it runs in CI and as `prepublishOnly`.
430
186
  ## Feedback
431
187
 
432
188
  Adoption reports drive the roadmap. After using Attest on a real project, fill in
433
- the feedback template [English](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/feedback.template.md) · [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/feedback.template.md)
434
- and open it as an issue (it is also the **Usage feedback** issue template). It
435
- covers greenfield and mid-project adoption alike, and ends with a prompt to run
436
- inside the adopting repo that fills most of the report automatically and probes
437
- for problems you haven't hit yet. The highest-value part is **false negatives**:
438
- drift the engine should have caught and didn't.
189
+ the feedback template (linked above) and open it as an issue it is also the
190
+ **Usage feedback** issue template. It covers greenfield and mid-project adoption
191
+ alike, and ends with a prompt to run inside the adopting repo that fills most of
192
+ the report automatically and probes for problems you haven't hit yet. The
193
+ highest-value part is **false negatives**: drift the engine should have caught
194
+ and didn't.
439
195
 
440
196
  ## Acknowledgments
441
197
 
package/dist/cli/index.js CHANGED
@@ -13,9 +13,9 @@ import { resolve } from 'node:path';
13
13
  import { writeAtomic } from '../core/write.js';
14
14
  import { runCheck, runVerify, runCover, runArchive, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
15
15
  import { hasError } from '../core/types.js';
16
- import { formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
16
+ import { formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
17
17
  import { archiveReport, checkReport, coverReport, errorReport, initReport, renderJson, renderReport, statusReport, verifyReport, } from './json.js';
18
- import { packageVersion } from './version.js';
18
+ import { packageVersion } from '../core/version.js';
19
19
  const VERSION = packageVersion();
20
20
  const program = new Command();
21
21
  program
@@ -60,10 +60,12 @@ async function runAction(command, opts, action) {
60
60
  }
61
61
  catch (err) {
62
62
  if (opts.json) {
63
+ // Unsanitised on purpose: `JSON.stringify` escapes every C0 character, so
64
+ // the machine surface was never the exposed one (see report.ts).
63
65
  console.log(renderJson(errorReport(VERSION, command, err)));
64
66
  }
65
67
  else {
66
- console.error(chalk.red(err instanceof Error ? err.stack ?? err.message : String(err)));
68
+ console.error(formatCrash(err));
67
69
  }
68
70
  process.exitCode = 1;
69
71
  }
@@ -284,8 +286,10 @@ program
284
286
  }));
285
287
  program.parseAsync().catch((err) => {
286
288
  // Last resort for failures outside a command action (e.g. arg parsing), which
287
- // are not part of the per-command `--json` contract.
288
- console.error(chalk.red(err instanceof Error ? err.stack ?? err.message : String(err)));
289
+ // are not part of the per-command `--json` contract. Through the same
290
+ // formatter as the per-command path: this one is reached with a project root
291
+ // already resolved, so it is no less able to carry the repository's text.
292
+ console.error(formatCrash(err));
289
293
  process.exitCode = 1;
290
294
  });
291
295
  //# sourceMappingURL=index.js.map
package/dist/cli/json.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // The shape is a contract: `schemaVersion` is bumped on any breaking change,
6
6
  // and every command emits the same envelope (tool/version/command/ok/summary/
7
7
  // issues) plus command-specific fields.
8
+ import { docsUrl } from '../core/docs.js';
8
9
  import { hasError } from '../core/types.js';
9
10
  /** Bumped whenever the emitted shape changes incompatibly. */
10
11
  export const SCHEMA_VERSION = 1;
@@ -19,15 +20,38 @@ export function countLevels(issues) {
19
20
  summary[LEVEL_KEY[i.level]]++;
20
21
  return summary;
21
22
  }
23
+ /**
24
+ * Attach the page that explains each code (design §5.3).
25
+ *
26
+ * Done here rather than at the ~20 sites that construct an `Issue`, for the
27
+ * reason `hasError` is one function: a property every report shares should have
28
+ * one place to be wrong. Every JSON report is built through this envelope —
29
+ * including the `internal-error` crash path — so there is no command that emits
30
+ * issues without going past this line.
31
+ *
32
+ * Non-mutating, and an issue that already carries a `docsUrl` keeps it: the
33
+ * argument arrays belong to the caller, and one of them is a pipeline result a
34
+ * human rendering may read afterwards.
35
+ */
36
+ function withDocs(issues) {
37
+ return issues.map((i) => {
38
+ const url = i.docsUrl ?? docsUrl(i.code);
39
+ return url === undefined ? i : { ...i, docsUrl: url };
40
+ });
41
+ }
22
42
  function envelope({ version, command, ok, issues = [] }) {
43
+ const documented = withDocs(issues);
23
44
  return {
24
45
  tool: 'attest',
25
46
  schemaVersion: SCHEMA_VERSION,
26
47
  version,
27
48
  command,
28
49
  ok,
50
+ // Counted from the input, not the decorated copy: the two have the same
51
+ // length by construction, and counting what was passed in keeps the summary
52
+ // a function of the issues rather than of the decoration.
29
53
  summary: countLevels(issues),
30
- issues,
54
+ issues: documented,
31
55
  };
32
56
  }
33
57
  export function checkReport(version, issues) {
@@ -1,5 +1,25 @@
1
1
  import type { CoverageRow, StatusResult, VerifyCounts } from '../core/pipeline.js';
2
2
  import type { Issue } from '../core/types.js';
3
+ /**
4
+ * A caught error, as it may be written to a terminal.
5
+ *
6
+ * The crash path was the hole in the guarantee above, and the least obvious
7
+ * place for one: every diagnostic went through `block`, and then the two
8
+ * `catch` blocks in the CLI printed `err.stack` raw. Under `verify`, `archive`
9
+ * or `--eval` that stack is not Attest's prose — a module from the repository
10
+ * under test threw it, so its message is that repository's text, arriving at
11
+ * the reviewer's terminal with nothing removed. The path a hostile registry
12
+ * reaches most easily is the one where it throws.
13
+ *
14
+ * `block`, not a rule of its own: a stack is exactly what that function is for
15
+ * — a multi-line message whose continuations must not be able to occupy the
16
+ * column a real header sits in. Frames indent by four more spaces than they
17
+ * already do, which is the whole cost.
18
+ *
19
+ * Here rather than in the CLI because this file is where the decision lives.
20
+ * Two `catch` blocks with a copy each is how the first one came to be missed.
21
+ */
22
+ export declare function formatCrash(err: unknown): string;
3
23
  export declare function formatIssues(issues: Issue[]): string;
4
24
  /**
5
25
  * A code repeating across files is one rule and many sites, and a reader looking
@@ -1,63 +1,51 @@
1
1
  // Terminal rendering of graded issues (design §5.3 levels). Pure formatting —
2
2
  // no process side effects — so it stays testable and the CLI stays thin.
3
3
  import chalk from 'chalk';
4
+ import { docsUrl } from '../core/docs.js';
4
5
  import { byCodeUnit } from '../core/order.js';
6
+ import { INDENT, block, inline } from '../core/terminal.js';
5
7
  const LEVEL_TAG = {
6
8
  ERROR: (s) => chalk.red.bold(s),
7
9
  WARNING: (s) => chalk.yellow.bold(s),
8
10
  INFO: (s) => chalk.blueBright(s),
9
11
  };
10
12
  const ORDER = ['ERROR', 'WARNING', 'INFO'];
11
- /** The one control character a report keeps: see `block`. */
12
- const NEWLINE = 0x0a;
13
- /** How far a message is indented under its header line. */
14
- const INDENT = ' ';
13
+ // `block` / `inline` live in core/terminal.ts: the loader has to make the same
14
+ // decision about Vite's log output and cannot import from this layer.
15
15
  /**
16
- * Text from the project, on its way to a terminal.
16
+ * The line under a message pointing at the section that explains the code.
17
17
  *
18
- * Most of what a report prints is Attest's own prose, but the parts a reader
19
- * actually navigates by are not: a scenario name and a file path are quoted
20
- * verbatim out of the repository under test (`orphan-test`, `declared-not-run`,
21
- * `never-red`), and the parser hands back the *cooked* string, so an ESC
22
- * written as an escape in a spec file is a real control byte by the time it
23
- * arrives here.
24
- *
25
- * That makes the human report a place where the repository being checked can
26
- * write to the terminal of whoever is checking it. On a fork MR the author is
27
- * not the reviewer, and the payload is a scenario name: erase the lines above,
28
- * repaint a red verdict green, rewrite the window title. The `--json` path was
29
- * never exposed — `JSON.stringify` escapes every C0 character — which is
30
- * precisely why this had to be fixed on the side people read.
31
- *
32
- * A newline survives, because one diagnostic is legitimately multi-line:
33
- * `registry-invalid` carries Zod's list of field errors, and flattening it
34
- * would cost a real reader a real thing to save a hypothetical one. It is
35
- * re-indented instead, so injected text lands inside the message block rather
36
- * than at column 0 where a forged `ERROR ...` header would be indistinguishable
37
- * from a real one.
18
+ * Dim and last, so it never competes with the file:line above it that is what
19
+ * a reader navigates by, and this is what they reach for once navigating did not
20
+ * settle it. Not sanitised through `inline`, unlike everything else on these
21
+ * lines: it is derived from a fixed roster, so unlike a scenario name or a path
22
+ * it never contains a byte the repository under test chose.
38
23
  */
39
- function block(text) {
40
- return control(text).replaceAll('\n', `\n${INDENT}`);
41
- }
42
- /** The same, for text that sits inline in a header and must stay one line. */
43
- function inline(text) {
44
- return control(text).replaceAll('\n', ' ');
24
+ function docsLine(issue) {
25
+ const url = issue.docsUrl ?? docsUrl(issue.code);
26
+ return url ? `\n${INDENT}${chalk.dim(`→ ${url}`)}` : '';
45
27
  }
46
28
  /**
47
- * Every C0 control except the newline, plus DEL and the C1 range, as a space.
29
+ * A caught error, as it may be written to a terminal.
48
30
  *
49
- * A space rather than deletion: removing the byte would silently splice
50
- * `atte` + `st` into a word that was never in the file, and a diagnostic that
51
- * quietly rewrites what it quotes is its own kind of wrong.
31
+ * The crash path was the hole in the guarantee above, and the least obvious
32
+ * place for one: every diagnostic went through `block`, and then the two
33
+ * `catch` blocks in the CLI printed `err.stack` raw. Under `verify`, `archive`
34
+ * or `--eval` that stack is not Attest's prose — a module from the repository
35
+ * under test threw it, so its message is that repository's text, arriving at
36
+ * the reviewer's terminal with nothing removed. The path a hostile registry
37
+ * reaches most easily is the one where it throws.
38
+ *
39
+ * `block`, not a rule of its own: a stack is exactly what that function is for
40
+ * — a multi-line message whose continuations must not be able to occupy the
41
+ * column a real header sits in. Frames indent by four more spaces than they
42
+ * already do, which is the whole cost.
43
+ *
44
+ * Here rather than in the CLI because this file is where the decision lives.
45
+ * Two `catch` blocks with a copy each is how the first one came to be missed.
52
46
  */
53
- function control(text) {
54
- return [...text]
55
- .map((ch) => {
56
- const c = ch.codePointAt(0);
57
- const isControl = (c < 0x20 && c !== NEWLINE) || (c >= 0x7f && c <= 0x9f);
58
- return isControl ? ' ' : ch;
59
- })
60
- .join('');
47
+ export function formatCrash(err) {
48
+ return chalk.red(block(err instanceof Error ? err.stack ?? err.message : String(err)));
61
49
  }
62
50
  export function formatIssues(issues) {
63
51
  if (issues.length === 0)
@@ -67,7 +55,7 @@ export function formatIssues(issues) {
67
55
  for (const i of issues.filter((x) => x.level === level)) {
68
56
  const where = i.file ? `${inline(i.file)}${i.line ? `:${i.line}` : ''}` : '';
69
57
  const loc = where ? chalk.dim(` (${where})`) : '';
70
- lines.push(`${LEVEL_TAG[level](level.padEnd(7))} ${chalk.cyan(inline(i.code))}${loc}\n${INDENT}${block(i.message)}`);
58
+ lines.push(`${LEVEL_TAG[level](level.padEnd(7))} ${chalk.cyan(inline(i.code))}${loc}\n${INDENT}${block(i.message)}${docsLine(i)}`);
71
59
  }
72
60
  }
73
61
  const repeats = formatRepeats(issues);