hstack 0.17.0 → 0.19.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to hstack are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/).
4
4
 
5
+ ## [0.19.0] - 2026-09-19
6
+
7
+ Changing an existing test stops needing permission and starts needing a label (ADR-0017). The authorization phrase is abolished; disclosure replaces it. This is the last per-change confirmation gate that survived the light pivot, and it is removed for the reason ADR-0015 removed the others — its cost was paid on every change and its protection was never once observed working.
8
+
9
+ ### Changed
10
+
11
+ - **§ Test immutability is gone as a top-level law** and becomes four paragraphs inside § Tests. Three carve-outs and five prohibitions become one prohibition: changing a test so a red suite goes green when the code under test is what is wrong. Changing a test because the intended behaviour moved is stated, in the kernel, to be normal work — no phrase, no halt, no permission to request.
12
+ - **Disclosure replaces authorization.** A commit that modifies or deletes a test file that existed at the merge-base tags each file with exactly one of `behavior-change`, `refactor`, `obsolete`. There is no fourth tag: if the honest word would be *it was failing*, that is the forbidden move and not a disclosure. The PR description repeats the list in plain language under **Tests changed** — the commit body is for the audit, that section is for the human, who reads the description and not the diff.
13
+ - **The four canonical authorization phrases are removed from the corpus.** `Ok to change test <name>` and its siblings were kept verbatim by ADR-0013 and again by ADR-0014, both times on the argument that the wording *is* the mechanism. The argument was sound and the mechanism was never exercised: `REC-0003` measured 265 candidate violations over 90 days, the watch-list repeated verbatim for seven weeks, and no human ever reviewed a row.
14
+ - **The escalation clause is a disclosure, not a halt.** An agent that cannot tell whether the test or the code is wrong makes the most plausible change and names the doubt in the PR description as an open question. Halting was the previous answer and it put the owner in the loop at a moment he is generally not in it.
15
+ - **Still hard, for one stated reason — they hide from the diff.** Bulk snapshot updates (`vitest -u`, `--updateSnapshot`, any equivalent, including in hooks) and silent neutralization (`.skip`, `test.todo`, a deleted case, a `.toBe()` broadened to `.toContain()`, a timeout raised to mask a bug). A hidden edit cannot be disclosed, which is the whole argument for keeping these two where everything else was relaxed.
16
+ - **The enforcement points follow the rule.** The adversarial reviewer and `/hstack-adversarial-review` keep a mandatory audit and check for the disclosure tag instead of the authorization echo; `/hstack-wrap` writes the **Tests changed** section rather than blocking the PR; the test strategist may now change an existing test instead of being told existing tests are read-only always; `/hstack-test-audit` keeps its own read-only posture, re-justified on the grounds that an audit which rewrites what it audits is worthless.
17
+ - **The two places the disclosure is physically produced now say so.** The PR-description template (`hstack-wrap/references/pr-description.md`) gains a **Tests changed** heading before **Review findings**, because a step that requires a section the template never offers is a step that will be skipped. And `/hstack-commit` step 4 names the tag in its body guidance — it is the one moment where a staged test file and a commit body exist at the same time.
18
+ - **QO-3's labels change and its field name does not.** `qo_3_test_immutability_audit` is produced by the telemetry sidecar outside this repo, so renaming it here would break the contract. Until that sidecar counts undisclosed edits instead of unphrased ones, the metric reads empty rather than wrong — and there is no audit signal in the interval. Named in ADR-0017 as a consequence, not a detail.
19
+
20
+ ### Added
21
+
22
+ - **`adr/ADR-0017-disclosure-replaces-authorization-on-tests.md`** — supersedes ADR-0013 and ADR-0014 on the single arbitration where each declined to touch this rule; the rest of both stands. It records the two consequences that look bad: the net is now weaker in exactly the case it was built for, because a tag can be typed by the model where the old phrase could not; and the decision is made on the cost side with the benefit side still unmeasured, since hstack has never detected one unauthorized test edit — equally consistent with *it never happened* and with *we cannot see it*.
23
+
24
+ ## [0.18.0] - 2026-09-18
25
+
26
+ Code quality gets a mechanical half and a judgment half, with a hard line between them (ADR-0016). The first consumer's audit found the prose rules ignored at scale and the one lint rule at `error` respected everywhere; this release draws the conclusion.
27
+
28
+ ### Added
29
+
30
+ - **`templates/eslint-clean-code.mjs`** — a flat-config the consumer imports from `hstack/templates/`, so `hstack update` can move it. Every rule at `error`: function and file size, complexity, nesting depth, parameter count, `no-explicit-any`, `no-non-null-assertion`, `no-floating-promises`, `only-throw-error`, `no-empty` catch, console outside the named logger, `process.env` outside the named config module, `@supabase/supabase-js` outside the named client module, `import/no-cycle`. Thresholds and module paths are options. The ratchet is ESLint's own suppressions file: freeze the existing violations once with `--suppress-all`; from then on only new ones fail.
31
+ - **`hstack/supabase-unread-error`**, a custom rule inside that file. Supabase returns `{ data, error }` and never throws; the rule fails an awaited query whose result is discarded, destructured without `error`, or read through `.data` alone. `.throwOnError()` satisfies it. The audit counted 64 unchecked server-side writes in one repo, one of them answered with `{ ok: true }`.
32
+ - **`templates/code-standards.md`** — the living doc for the rules that need judgment: search before writing, one function one responsibility, a component does not talk to the network, a file's name says what it holds, names describe behaviour, a returned error is a handled error, replace rather than add beside, delete what nothing imports, constants in one place. Each rule carries a `Seen here` line the consumer fills from its own code. Installed at `hstack/context/code-standards.md`, read on the trigger *application code → code-standards*.
33
+ - **`/hstack-wrap` step 2** reads the diff against `code-standards.md`, rule by rule, and fixes what it finds before `/review` runs. Step 9 now says the fast lane — typecheck, lint, critical tests — runs locally and is green before the commit; CI is the paid backstop, not the first run, and a lint finding is fixed, never suppressed.
34
+
35
+ ### Changed
36
+
37
+ - **The kernel** names `code-standards.md` among the living docs with its read trigger, states that lint is at `error` with a ratchet and that a `warn` is not a rule, and that the fast lane runs locally before the push. Five sentences, ~80 words.
38
+ - **The adversarial reviewer's code-quality lens is `code-standards.md`.** A finding names the rule it breaks. The rubric's filler clause — which until now listed *"anything a formatter or a linter would have said"* — is rewritten: what the linter already fails on is filler only when the linter is wired, and an unread error on a write path escalates to `data-integrity`.
39
+
5
40
  ## [0.17.0] - 2026-08-22
6
41
 
7
42
  The pivot is subtractive (ADR-0015). Four months of hstack v1 produced a workflow whose per-change ceremony made every change roughly ten times slower, and no MVP. The diagnosis is not that the rules were wrong — it is that pre-PMF, the dominant risk is never shipping, not shipping bugs, and a rule that costs something on every single change has to earn it against that. So the question asked of every piece was the same: **does it change what an agent does on an ordinary change, without charging ceremony per change?** What survived was kept *in place*, with its v0.16 wording where the rule survived. What did not was removed, not replaced — this release designs almost nothing new.
package/README.md CHANGED
@@ -95,8 +95,9 @@ macOS and Linux only. Windows is hard-failed at `hstack init` — the dir-level
95
95
  Open a fresh Claude Code session in the consuming repo. There is no init interview in the light version: the kernel is loaded by the import line, and the living docs get written when there is something true to write in them.
96
96
 
97
97
  1. **Write the two architecture docs** when the shape of the product is settled enough to be worth recording — `/hstack-data-architecture` (tenancy, entities, RLS posture, RAG layout) and `/hstack-app-architecture` (module map with its exposure column, LLM call sites, deterministic-vs-LLM split, state ownership, surfaces). Both run in extract mode against a live schema or the source tree, and ask when extraction is not enough.
98
- 2. **Fill the rest by hand or in the PR that needs them**, from `hstack/templates/`: `tech-stack.md`, `infrastructure.md` — whose § Deploy Pipeline is what `/hstack-promote` reads — `roadmap.md`, `invariants.md`, `review-miss.md`.
99
- 3. **Then just work.** The per-change loop below is the whole of it.
98
+ 2. **Fill the rest by hand or in the PR that needs them**, from `hstack/templates/`: `tech-stack.md`, `infrastructure.md` — whose § Deploy Pipeline is what `/hstack-promote` reads — `roadmap.md`, `invariants.md`, `review-miss.md`, and `code-standards.md` with its `Seen here` lines.
99
+ 3. **Wire the linter.** Import `hstack/templates/eslint-clean-code.mjs` from the repo's `eslint.config.mjs` (the file's header shows the call), run `npx eslint --suppress-all` once to freeze the existing violations, and commit `eslint-suppressions.json`. From then on only new violations fail `lint`, and the frozen count only goes down.
100
+ 4. **Then just work.** The per-change loop below is the whole of it.
100
101
 
101
102
  ## The change loop
102
103
 
@@ -115,7 +116,7 @@ The nine Skills:
115
116
 
116
117
  | Skill | What it is for |
117
118
  | --- | --- |
118
- | `/hstack-wrap` | The end of a change: runs `/review` and `/security-review`, audits test immutability, updates the living docs the diff invalidated, writes the PR description. |
119
+ | `/hstack-wrap` | The end of a change: runs `/review` and `/security-review`, audits test disclosure, updates the living docs the diff invalidated, writes the PR description. |
119
120
  | `/hstack-promote` | The release: pending production migrations, a smoke test on the unpromoted build, then promotion. Commands live in `infrastructure.md` § Deploy Pipeline. |
120
121
  | `/hstack-commit` | The one Commitizen format humans and agents both write. |
121
122
  | `/hstack-adversarial-review` | The deep pass on a sensitive surface, from a session that never saw the change being written. Findings land as a PR comment. |
@@ -143,10 +144,11 @@ hstack/
143
144
  roadmap.md # Now / Next / Later — advisory, never a gate
144
145
  invariants.md # the business rules a test names
145
146
  review-miss.md # what a review missed, so the next one re-checks it
147
+ code-standards.md # the rules that need judgment; the linter owns the rest
146
148
  ... # frozen and dated: threat-model, product-brief, vision, personas, incident-runbook
147
149
  adr/ # one-way doors only
148
150
  tech-debt/ # one file per item; deleted in the PR that fixes it
149
- templates/ # the eight canonical templates
151
+ templates/ # the nine canonical templates + eslint-clean-code.mjs
150
152
  .claude/
151
153
  skills/hstack-*/SKILL.md # nine Skills
152
154
  agents/ # four subagents
@@ -166,6 +168,7 @@ Authoritative, in the repo:
166
168
  - [`template/KERNEL.md`](./template/KERNEL.md) — the kernel. Authority over every Skill, subagent, and template.
167
169
  - [`template/templates/`](./template/templates/) — the canonical structure of every artifact type.
168
170
  - [`adr/ADR-0015-the-light-pivot.md`](./adr/ADR-0015-the-light-pivot.md) — why the pivot is subtractive, and what it costs.
171
+ - [`adr/ADR-0016-the-linter-enforces-the-doc-judges.md`](./adr/ADR-0016-the-linter-enforces-the-doc-judges.md) — code quality split into a lint config at `error` and a judgment doc, and why prose alone did not hold.
169
172
  - [`CHANGELOG.md`](./CHANGELOG.md) — the release history, including the full v0.16.0 lineage.
170
173
 
171
174
  Historical companions, describing the frozen full version rather than `main`:
@@ -175,6 +178,6 @@ Historical companions, describing the frozen full version rather than `main`:
175
178
 
176
179
  ## Status
177
180
 
178
- hstack v0.17.0 — the kernel at ~1,700 words, nine Skills, four subagents, eight templates. The `v0.16.0` tag holds the complete version: ~34 Skills, ~16 subagents, the validator, the merge gates, the coord and telemetry trees.
181
+ hstack v0.18.0 — the kernel at ~1,800 words, nine Skills, four subagents, nine templates and a lint config. The `v0.16.0` tag holds the complete version: ~34 Skills, ~16 subagents, the validator, the merge gates, the coord and telemetry trees.
179
182
 
180
- Next milestone: the first real use of the light version, upgrading moso-app from 0.7.1 to 0.17.
183
+ Next milestone: wiring the linter and `code-standards.md` into moso-app after its dead-code cleanup, and watching whether the ratchet count goes down.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.17.0
1
+ 0.19.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hstack",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "A spec-driven engineering workflow that ships as Claude Code Skills and subagents.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: adversarial-reviewer
3
3
  model: opus
4
- description: "Use to critique a PR cold, from a session that never saw it being written — the six lenses, the mandatory test-immutability audit, an empty result that has to be defended. Surfaces findings; never resolves them."
4
+ description: "Use to critique a PR cold, from a session that never saw it being written — the six lenses, the mandatory test-disclosure audit, an empty result that has to be defended. Surfaces findings; never resolves them."
5
5
  tools:
6
6
  - Read
7
7
  - Grep
@@ -30,8 +30,9 @@ implementation conversation.
30
30
 
31
31
  ## Reads
32
32
 
33
- What the Skill hands over, plus on demand, when a category is unfamiliar or a finding feels
34
- thin `references/finding-categories.md` alongside the Skill.
33
+ What the Skill hands over, and `hstack/context/code-standards.md` when it exists the
34
+ code-quality lens is that file, rule by rule. On demand, when a category is unfamiliar or a
35
+ finding feels thin, `references/finding-categories.md` alongside the Skill.
35
36
 
36
37
  ## Writes
37
38
 
@@ -40,13 +41,15 @@ Nothing. Findings are returned to the Skill, which posts them on the PR.
40
41
  ## Behavior rules
41
42
 
42
43
  - **Sweep six lenses**: security, scope-drift, invariant-breach, intent-compliance, data-integrity,
43
- code-quality. They are lenses to look through, not buckets to fill a change carrying all its
44
+ code-quality the last one against `code-standards.md`, a finding naming the rule it breaks.
45
+ They are lenses to look through, not buckets to fill — a change carrying all its
44
46
  risk in one dimension produces findings in one category, and that is the honest answer.
45
- - **The test-immutability audit is mandatory** (protocol: `KERNEL.md` § Test immutability). Diff
47
+ - **The test-disclosure audit is mandatory** (protocol: `KERNEL.md` § Tests). Diff
46
48
  every test file that existed at the base. For each modified, content-drifted or deleted test,
47
- look for its canonical authorization echo in the commit messages or the PR description. Missing
48
- echo: a finding under intent-compliance at `high` minimum, `critical` for a bulk snapshot update.
49
- This one is not subject to your judgment about whether it is worth filing.
49
+ look for its disclosure tag `behavior-change`, `refactor` or `obsolete` in a commit body, and
50
+ its plain-language line under **Tests changed** in the PR description. Missing disclosure: a
51
+ finding under intent-compliance at `high` minimum, `critical` for a bulk snapshot update or a
52
+ silent neutralization. This one is not subject to your judgment about whether it is worth filing.
50
53
  - **Intent is what the PR says it does.** The description names the perimeter, the shortcuts and
51
54
  the docs it updated; the diff either matches or it does not.
52
55
  - **Severity is about the consequence, not the likelihood.** Cross-tenant leakage and irreversible
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: test-strategist
3
3
  model: opus
4
- description: "Use during a test audit of one module: map its business rules against the existing tests and return the gaps, then write the tests the engineer chose to add. Existing tests stay read-only."
4
+ description: "Use during a test audit of one module: map its business rules against the existing tests and return the gaps, then write the tests the engineer chose to add. Existing tests stay read-only during the audit itself."
5
5
  ---
6
6
 
7
7
  ## Role
@@ -18,8 +18,7 @@ the right argument.
18
18
  ## When to invoke
19
19
 
20
20
  When `/hstack-test-audit <module>` needs the module's rules mapped against its tests, or needs the
21
- chosen gaps closed. Not to modify existing tests — that route is the kernel's authorization
22
- protocol, in the engineer's session, never here.
21
+ chosen gaps closed.
23
22
 
24
23
  ## Reads
25
24
 
@@ -32,11 +31,12 @@ New test files, and `hstack/context/invariants.md`. Nothing else.
32
31
 
33
32
  ## Behavior rules
34
33
 
35
- - **Existing tests are read-only, always** (protocol: `KERNEL.md` § Test immutability). When
36
- closing a gap would mean changing an assertion, deleting a test or updating a snapshot, halt and
37
- hand the engineer the routes: authorize the change under the canonical phrase, or write a new
38
- test that supersedes the old one, or leave the gap named in the PR description. Never author an
39
- authorization phrase on the engineer's behalf.
34
+ - **Existing tests stay read-only here** not because they are frozen. Changing one is ordinary
35
+ work when the intended behaviour moved, disclosed with its tag (`KERNEL.md` § Tests). It is that
36
+ an audit which rewrites the tests it is auditing has audited nothing. Name the change the gap
37
+ needs and leave it to the engineer's normal flow; a new test superseding the old one is often
38
+ better anyway, since it keeps the old expectation legible. What is never done, here or in the
39
+ engineer's session, is bending a test so a red suite goes green when the code is what is wrong.
40
40
  - **Pyramid bias.** Unit for pure functions and reducers; integration for behaviour that crosses
41
41
  modules or the database; end-to-end for user-visible journeys. Refuse a strategy that rests
42
42
  primarily on end-to-end tests — slow and flaky is how a suite stops being run.
@@ -52,7 +52,6 @@ New test files, and `hstack/context/invariants.md`. Nothing else.
52
52
 
53
53
  ## Stop conditions
54
54
 
55
- - Closing a gap requires touching an existing test.
56
55
  - The module has no identifiable business rule — report that rather than propose tests.
57
56
  - A rule surfaced contradicts one already in `invariants.md`. Surface both; the engineer decides.
58
57
  - A performance budget is wanted but the repo has no way to assert one. Say so; do not write a
@@ -31,11 +31,11 @@ honor-system, as the kernel says; a sentence is the whole protocol.
31
31
  reconstruct the conversation that produced the change.
32
32
  2. **Invoke `adversarial-reviewer`** with that material. The subagent sweeps six lenses and returns
33
33
  findings; it does not resolve them and does not touch the code.
34
- 3. **The test-immutability audit is mandatory** and is not subject to anyone's judgment about
34
+ 3. **The test-disclosure audit is mandatory** and is not subject to anyone's judgment about
35
35
  whether it is worth filing. Every test file that existed at the base is diffed; a modification
36
- or deletion without its canonical authorization echo `Ok to change test <name>` /
37
- `Ok to delete test <name>`, in a commit message or the PR description — is a finding at `high`
38
- minimum. A bulk snapshot update is `critical`.
36
+ or deletion without its disclosure a `behavior-change` / `refactor` / `obsolete` tag in a
37
+ commit body, repeated under **Tests changed** in the PR description — is a finding at `high`
38
+ minimum. A bulk snapshot update or a silent neutralization is `critical`.
39
39
  4. **Post the findings** with `gh pr comment <n>`, or as a GitHub review when they are anchored to
40
40
  lines. Each finding carries a severity, a category, the evidence in the diff, and what would
41
41
  resolve it.
@@ -74,13 +74,14 @@ feature, the shortcuts taken, the living docs updated.
74
74
  Behaviour the diff adds that no line of the description anticipated — undeclared scope is a
75
75
  compliance gap in the other direction. A living doc the change invalidated and left untouched
76
76
  (kernel § Context docs). A conscious shortcut visible in the code and named nowhere. **Any
77
- test-file modification without its canonical authorization echo** (`KERNEL.md` § Test immutability);
78
- this one is mandatory and never subject to judgment about whether it is worth filing.
77
+ test-file modification without its disclosure tag** (`KERNEL.md` § Tests); this one is mandatory
78
+ and never subject to judgment about whether it is worth filing.
79
79
 
80
80
  **Filler looks like.** "The description could have been clearer." Style disagreements with how the
81
81
  work was sequenced.
82
82
 
83
- **Severity.** Unauthorized test changes: `high` minimum, `critical` for a bulk snapshot update. A
83
+ **Severity.** Undisclosed test changes: `high` minimum, `critical` for a bulk snapshot update or a
84
+ silent neutralization. A
84
85
  stale living doc: `high`, because the next session will read it and believe it. Everything else:
85
86
  judgment.
86
87
 
@@ -107,18 +108,27 @@ sequence to trigger. Irreversible data loss is `critical`. Recoverable inconsist
107
108
 
108
109
  ## code-quality
109
110
 
110
- **What it means.** The change works and will cost more than it should to live with. The weakest
111
- category and the easiest to pad, which is why it is worth being strict about what belongs here.
112
-
113
- **Real findings look like.** A third copy of logic that already exists twice, where the copies have
114
- started to disagree. Error handling that swallows a failure the caller needs. A function whose
115
- behaviour depends on call order with nothing that documents or enforces it.
116
-
117
- **Filler looks like.** Naming preferences. Suggested comments. Requests to extract a function used
118
- once. Anything a formatter or a linter would have said.
119
-
120
- **Severity.** `low` or `medium`. A `code-quality` finding at `high` is usually mis-categorized —
121
- look again at whether it is really `invariant-breach` or `data-integrity`.
111
+ **What it means.** The change works and will cost more than it should to live with. The rubric is
112
+ `hstack/context/code-standards.md`: a finding here names the rule it breaks, and a rule the file
113
+ does not hold is not a finding. The easiest category to pad, which is why the rule number is
114
+ required.
115
+
116
+ **Real findings look like.** A second implementation of something the repo already had — a
117
+ client, a parser, a retry — written beside the first instead of found (rule 2). A function that
118
+ orchestrates and also does the steps, so the next phase starts at a comment (rule 3). A component
119
+ that fetches or decodes a protocol (rule 4). A file whose name stopped describing its contents in
120
+ this diff (rule 5). A `get*` that writes (rule 6). A result carrying `error` that nothing reads,
121
+ or a `catch` that continues with no justification (rule 7). A new path wired beside the old one
122
+ with the old one still reachable (rule 8). Code this diff orphaned and left in place (rule 9).
123
+
124
+ **Filler looks like.** Naming preferences that do not make a name lie. Suggested comments.
125
+ Requests to extract a function used once. Anything the repo's linter already fails on — size,
126
+ `any`, console, `process.env` — when the linter is wired; when it is not, one finding says so
127
+ instead of listing what it would have caught.
128
+
129
+ **Severity.** `low` or `medium`. Rule 7 escalates on a write path: an unread error after an
130
+ `update` or `delete` is `data-integrity`, not `code-quality`. Any other `high` here is usually
131
+ mis-categorized — look again at `invariant-breach` or `data-integrity`.
122
132
 
123
133
  ---
124
134
 
@@ -27,7 +27,7 @@ Optional `--push`: push after committing — still subject to per-invocation con
27
27
  - `<type>(<scope>): <summary>`, where `<type>` is one of `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `style`, `perf`, `ci`.
28
28
  - `<scope>` names the area actually touched — never an invented or aspirational one. When the change spans unrelated areas, no single honest scope exists: propose splitting it into several commits.
29
29
  - `<summary>` ≤ 72 characters, imperative present ("add", not "added"), no trailing period.
30
- - Body, when it earns its place: the why, not the what.
30
+ - Body, when it earns its place: the why, not the what. A staged test file that existed at the merge-base is declared there, one line per file, tagged `behavior-change`, `refactor` or `obsolete` (kernel § Tests).
31
31
  - **Never "Generated with Claude Code" or any similar attribution.**
32
32
  - Footer: conventional-commits footers only (`BREAKING CHANGE:`, `Refs: <issue>`).
33
33
 
@@ -26,8 +26,9 @@ question at plan time, and this Skill is never a phase of a change.
26
26
  `data-architecture.md` when the module touches the database. Write each rule as *decides X;
27
27
  wrong in silence if Y*. A module whose code decides nothing has no gap to close — say so and
28
28
  stop rather than write tests for the sake of coverage.
29
- 2. **Map the existing tests onto the rules.** Existing test files are read-only here
30
- (kernel § Test immutability); this step reads them, it never edits them.
29
+ 2. **Map the existing tests onto the rules.** Existing test files are read-only here — an audit
30
+ that rewrites the tests it is auditing has audited nothing; this step reads them, never edits
31
+ them.
31
32
  3. **Name the gaps** — rules with no test, and tests asserting something no rule needs.
32
33
  4. **Interview the edges.** A handful of questions, not a script: *what does the user notice if
33
34
  this breaks in silence?* — plus concurrency, retries, and the cross-tenant case when the module
@@ -38,9 +39,10 @@ question at plan time, and this Skill is never a phase of a change.
38
39
  6. **The engineer picks the gaps to close now.** A module with twenty uncovered rules would make
39
40
  an unreviewable PR. Close the chosen ones; name the rest in the PR description so the next
40
41
  audit starts there.
41
- 7. **Write the tests.** New test files need no authorization — "new" means the path did not exist
42
- at session start. If closing a gap requires editing or deleting an existing test, halt and ask;
43
- nothing in this Skill authorizes that.
42
+ 7. **Write the tests.** New test files only — "new" means the path did not exist at session start.
43
+ When closing a gap means editing or deleting an existing test, name that change in the output
44
+ rather than make it here; the engineer makes it in the normal flow, where it is ordinary work
45
+ carrying its disclosure line (kernel § Tests).
44
46
  8. **Record the invariants.** Every rule the audit surfaced goes into `hstack/context/invariants.md`
45
47
  in this PR — **including the gaps left open**. The memory is the cheap half; write it whether or
46
48
  not the test was written.
@@ -51,13 +53,12 @@ and the gap list. Steps 4 and 6 are questions for the engineer and happen here,
51
53
  ## Output
52
54
 
53
55
  New test files, an `invariants.md` diff, and a summary for the PR description naming the gaps left
54
- open. Nothing else.
56
+ open and the existing tests that need changing. Nothing else.
55
57
 
56
58
  ## Stop conditions
57
59
 
58
60
  Beyond the kernel's:
59
61
 
60
- - Closing a gap would require touching an existing test. Halt (§ Test immutability).
61
62
  - The module cannot be located, or spans so much of the repo that "one module" is not what is
62
63
  being audited. Ask which one.
63
64
  - An invariant the audit surfaces contradicts one already in `invariants.md`. Surface both; the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hstack-wrap
3
- description: Use at the end of a change, before the PR — runs /review and /security-review on the branch diff, audits test immutability, updates the living docs the diff invalidated, and writes the PR description.
3
+ description: Use at the end of a change, before the PR — reads the diff against code-standards, runs /review and /security-review, audits test disclosure, updates the living docs the diff invalidated, writes the PR description.
4
4
  ---
5
5
 
6
6
  ## Purpose
@@ -21,19 +21,21 @@ None. The diff under review is everything that will land in the PR — committed
21
21
 
22
22
  1. **Read `hstack/context/review-miss.md`** when it exists. A category a review has already missed once gets re-checked explicitly in the pass below; that is the whole reason the file exists.
23
23
 
24
- 2. **Run `/review`, then `/security-review`** on that diff. Fix what is fixable inside the announced perimeter. Everything else is declared in the PR description the finding, and why it was not fixed. A finding is never dropped silently, and an empty result is reported as what it is: the reviewer found nothing.
24
+ 2. **Read the diff against `hstack/context/code-standards.md`**, rule by rule, when the file exists. This is the pass the linter cannot make: a second implementation of something that already existed, a component talking to the network, an orchestrating function that also does the steps, a legacy path left wired, a returned error nobody reads. Fix what is found the fix is usually the extraction or the deletion the rule names — and when a rule's `Seen here` line is empty and this diff is the first example, fill it in this PR. What survives on purpose is a tech-debt file, not silence.
25
25
 
26
- 3. **Audit test immutability.** Diff the test files that already existed at the merge-base. An edit or a deletion without the canonical authorization echoed in this conversation is a blocking finding halt, and do not open the PR (kernel § Test immutability).
26
+ 3. **Run `/review`, then `/security-review`** on that diff. Fix what is fixable inside the announced perimeter. Everything else is declared in the PR description — the finding, and why it was not fixed. A finding is never dropped silently, and an empty result is reported as what it is: the reviewer found nothing.
27
27
 
28
- 4. **Ask each living doc whether this diff invalidated it**, and update it in this PR when it did (kernel § Context docs). When an entry point changed status live, routable, off the exposure column of the Module Map in `app-architecture.md` moves with it.
28
+ 4. **Audit test disclosure.** Diff the test files that already existed at the merge-base. Each one modified or deleted carries its tag — `behavior-change`, `refactor` or `obsolete` — in a commit body, and its plain-language line under a **Tests changed** heading in the PR description; writing that section is part of this step (kernel § Tests). An undisclosed edit is fixed here by disclosing it, not by halting. The one exception: if the honest tag would be *it was failing*, halt and fix the code instead.
29
29
 
30
- 5. **Name the conscious shortcuts.** A shortcut that survives the merge becomes a file under `hstack/tech-debt/`, from the template, written in this PR and named in its description.
30
+ 5. **Ask each living doc whether this diff invalidated it**, and update it in this PR when it did (kernel § Context docs). When an entry point changed status — live, routable, off the exposure column of the Module Map in `app-architecture.md` moves with it.
31
31
 
32
- 6. **Sensitive surface?** If the diff touches one (kernel § Review), the PR says so in its first line and asks for the deep pass in a fresh session — `/hstack-adversarial-review`.
32
+ 6. **Name the conscious shortcuts.** A shortcut that survives the merge becomes a file under `hstack/tech-debt/`, from the template, written in this PR and named in its description.
33
33
 
34
- 7. **Write the PR description** from `references/pr-description.md`: intention, perimeter, decisions, shortcuts and tech-debt, the review findings split into what was fixed and what was declared, and the Notion feature it serves when there is one. When the consumer has a `.github/pull_request_template.md`, that rendered file is the one you fill the reference file is the seed it was copied from, not a second authority.
34
+ 7. **Sensitive surface?** If the diff touches one (kernel § Review), the PR says so in its first line and asks for the deep pass in a fresh session`/hstack-adversarial-review`.
35
35
 
36
- 8. **Commit, then push and open the PR.** Commit in the `hstack-commit` format. Run the repo's fast lane locally first a red PR costs a full round trip; if the repo names no fast-lane command, say so in the PR rather than inventing one. Push and `gh pr create` only after explicit confirmation in the conversation.
36
+ 8. **Write the PR description** from `references/pr-description.md`: intention, perimeter, decisions, shortcuts and tech-debt, the review findings split into what was fixed and what was declared, and the Notion feature it serves when there is one. When the consumer has a `.github/pull_request_template.md`, that rendered file is the one you fill the reference file is the seed it was copied from, not a second authority.
37
+
38
+ 9. **Run the fast lane locally, then commit, push and open the PR.** Typecheck, lint and the critical tests run here, on this machine, and are green before the commit — a red PR costs a paid CI round trip for something a local run would have caught in a minute. Lint is at `error`: fix the finding, never add a suppression or a disable comment to get past it (kernel § Stop conditions). If the repo names no fast-lane command, say so in the PR rather than inventing one. Commit in the `hstack-commit` format. Push and `gh pr create` only after explicit confirmation in the conversation.
37
39
 
38
40
  ## Output
39
41
 
@@ -43,6 +45,6 @@ One open PR against the default branch, carrying the code, the living-doc update
43
45
 
44
46
  Beyond the kernel's:
45
47
 
46
- - A pre-existing test was modified or deleted without authorization.
48
+ - A pre-existing test was changed to turn a red suite green when the code is what is wrong.
47
49
  - A hook or a check was bypassed to get here.
48
50
  - A living doc is invalidated and you cannot update it. Say "stale" in the PR description; never invent the content.
@@ -33,6 +33,13 @@
33
33
  <!-- Conscious shortcuts that survive the merge, each with its hstack/tech-debt/TD-NNNN-<slug>.md
34
34
  file. "None" is a real answer when it is true. -->
35
35
 
36
+ ## Tests changed
37
+
38
+ <!-- Every test that existed at the merge-base and was modified or deleted here, one line each,
39
+ in plain language, tagged behavior-change / refactor / obsolete as in the commit body. A PR
40
+ that touches a test and has no such list is incomplete. Where it was unclear whether the
41
+ test or the code was wrong, say so here as an open question. -->
42
+
36
43
  ## Review findings
37
44
 
38
45
  <!-- /review and /security-review, in full. These are LLM judgments, not evidence: an empty list
@@ -16,11 +16,11 @@ The human's job is intent, testing the app, and reading the PR description with
16
16
 
17
17
  ## Context docs
18
18
 
19
- Living docs, at `hstack/context/`, are the agent's memory between sessions: `data-architecture.md` (tenancy, entities, RLS, RAG) · `app-architecture.md` (module map, state ownership, surface boundaries) · `tech-stack.md` (pinned versions are pinned on purpose — never bump one unrequested) · `infrastructure.md` (where things run, why the couplings, the gotchas) · `roadmap.md` (Now / Next / Later — **advisory only, never a gate**) · `invariants.md` · `review-miss.md`.
19
+ Living docs, at `hstack/context/`, are the agent's memory between sessions: `data-architecture.md` (tenancy, entities, RLS, RAG) · `app-architecture.md` (module map, state ownership, surface boundaries) · `tech-stack.md` (pinned versions are pinned on purpose — never bump one unrequested) · `infrastructure.md` (where things run, why the couplings, the gotchas) · `roadmap.md` (Now / Next / Later — **advisory only, never a gate**) · `code-standards.md` (the rules that need judgment; the linter owns the rest) · `invariants.md` · `review-miss.md`.
20
20
 
21
21
  The **exposure map** is a column of the Module Map in `app-architecture.md`. Its atom is an entry point — page route, API route, server action, job, webhook — at `live`, `routable` (the URL responds, nothing links to it, it is fully exposed) or `off`. Updated in the PR that changes exposure, verified at `/hstack-promote`.
22
22
 
23
- Read triggers: db / RLS / migration → data-architecture; env / deploy / dependencies → infrastructure; user reachability → app-architecture; always → tech-stack.
23
+ Read triggers: db / RLS / migration → data-architecture; env / deploy / dependencies → infrastructure; user reachability → app-architecture; application code → code-standards; always → tech-stack.
24
24
 
25
25
  **Non-negotiable: the agent updates a living doc in the same PR that invalidates it.** Committed state is the only authoritative view of another session — Luke's, a parallel worktree — so that PR is the coordination channel, and there is no other. Missing or stale is said in the PR, never invented.
26
26
 
@@ -56,7 +56,7 @@ Branch (never the default branch, one per change) → announce the perimeter →
56
56
 
57
57
  One PR, one intention — a change spanning unrelated modules splits into several. One change, one session — once the PR is open the next starts fresh, and what it needs lives in the PR description or a living doc, never in the conversation.
58
58
 
59
- Two CI lanes: the fast one — typecheck, lint, critical tests — blocks the merge, the slow one is advisory. A hotfix still goes through PR + CI; it skips preview and the train, never the checks.
59
+ Two CI lanes: the fast one — typecheck, lint, critical tests — blocks the merge, the slow one is advisory. The fast lane runs locally and is green before the push; CI is the backstop, not the first run. Lint is at `error` with a suppressions ratchet — the frozen count only goes down, and a `warn` is not a rule. A hotfix still goes through PR + CI; it skips preview and the train, never the checks.
60
60
 
61
61
  ---
62
62
 
@@ -64,27 +64,27 @@ Two CI lanes: the fast one — typecheck, lint, critical tests — blocks the me
64
64
 
65
65
  Tests are mandatory on critical paths and on every business invariant a change touches. At plan time, one question: *does this code decide something that would be wrong silently?* If it does, a test names the invariant. `/hstack-test-audit` is on demand, never a per-change phase.
66
66
 
67
- ---
67
+ **Changing an existing test is normal work.** A product before product-market fit changes its intended behaviour several times a week, and the tests move with it. No authorization phrase, no halt, no permission to request.
68
68
 
69
- ## Test immutability
69
+ **The one forbidden move** is changing a test so that a red suite goes green when the code under test is what is wrong. Before editing an existing test, one question: *is the test wrong, or is the code wrong?* Fix whichever one is wrong — never the other one.
70
70
 
71
- Once a test file exists in the working tree (committed or staged), **no agent may edit or delete it without per-test, per-conversation human authorization.** This rule exists because the dominant failure mode of LLM-driven implementation is the model editing an assertion or deleting a test to make the suite go green, rather than fixing the code under test.
71
+ **When it cannot tell**, the agent makes the most plausible change and names the doubt in the PR description as an open question, never silently and never by halting. An unresolved doubt is information the reviewer needs, not a reason to stop working.
72
72
 
73
- **What counts as a test.** Files matching the repo's test patterns (e.g. `*.test.ts`, `*.spec.ts`, `__tests__/**/*`, `e2e/**/*`, `*_test.go`), snapshot files (`__snapshots__/*`), and assertion-bearing fixtures factories and seed data encoding expected outputs.
73
+ **Disclosure replaces authorization.** Any commit that modifies or deletes a test file that existed at the merge-base says so in its body, one line per file, tagged with exactly one word:
74
74
 
75
- **Authorization protocol.** Halt before editing. Surface (a) the test file and test name, (b) why it must change — what it asserts vs. what is now correct, with evidence, (c) the proposed diff, (d) the alternatives, starting with fixing the code under test. Then wait for the canonical phrase — `Ok to change test <name>` or `Ok to delete test <name>`, `<name>` being the file path or a uniquely-identifying test name. The agent echoes it back verbatim before acting; nothing else is an authorization.
75
+ - `behavior-change` — the intended behaviour moved and the test followed it.
76
+ - `refactor` — same assertions, moved or renamed or rewired (imports, mocks, fixtures).
77
+ - `obsolete` — the subject of the test no longer exists.
76
78
 
77
- **Carve-outs.** New tests need no authorization "new" means the path did not exist at session start. A content-preserving move is permitted, surfaced in the commit message so the review can verify nothing drifted.
79
+ There is no fourth tag. If the honest word would be *it was failing*, that is the forbidden move, not a disclosure.
78
80
 
79
- **Forbidden no matter what.**
81
+ The PR description repeats the same list in plain language, under a **Tests changed** heading. The commit body is for the audit; that section is for the human, who reads the description and not the diff. A PR that touches a test and has no such section is incomplete.
82
+
83
+ **What counts as a test.** Files matching the repo's test patterns (e.g. `*.test.ts`, `*.spec.ts`, `__tests__/**/*`, `e2e/**/*`, `*_test.go`), snapshot files (`__snapshots__/*`), and assertion-bearing fixtures — factories and seed data encoding expected outputs.
80
84
 
81
- - Blanket authorizations ("go ahead and fix any failing tests", "update whatever snapshots need it"). Authorizations are per-test, per-conversation. The agent refuses blanket scope.
82
- - Bulk snapshot updates via `--update-snapshots`, `jest --updateSnapshot`, `vitest -u`, or any equivalent flag, including in pre-commit hooks.
83
- - Relaxing an assertion without authorization (e.g., tightening a regex to a substring match, broadening a `.toBe()` to `.toContain()`, increasing a timeout to mask a real bug).
84
- - Deleting a `.skip` annotation, replacing a `test()` call with `test.todo()`, or otherwise neutralizing a test without authorization. Neutralization is a form of deletion.
85
- - Editing a test as part of "cleaning up" a phase without an explicit authorization for that test, even if the edit is cosmetic.
85
+ **Two things stay hard, because they hide from the diff.** Bulk snapshot updates via `--update-snapshots`, `jest --updateSnapshot`, `vitest -u` or any equivalent flag, including in hooks a mass edit nobody reads is not a disclosure. And neutralizing a test silently: adding `.skip`, replacing `test()` with `test.todo()`, deleting a case, or loosening an assertion (a `.toBe()` broadened to `.toContain()`, a timeout raised to mask a real bug) are edits like any other and carry their disclosure line.
86
86
 
87
- Authorization is single-use: it covers the test and the change discussed in the current conversation, and never carries across sessions. An unauthorized test edit is a blocking review finding.
87
+ An undisclosed test edit is a review finding. A disclosed one is just work.
88
88
 
89
89
  ---
90
90
 
@@ -108,7 +108,7 @@ CI backstops, wired once: secret scanning, and a grep that fails the build on `s
108
108
 
109
109
  ## Review
110
110
 
111
- Every PR: `/hstack-wrap` runs `/review` and `/security-review` before the push, and their findings go in the PR description.
111
+ Every PR: `/hstack-wrap` reads the diff against `code-standards.md`, then runs `/review` and `/security-review`, before the push; what it could not fix goes in the PR description.
112
112
 
113
113
  **Sensitive surfaces** — agent or tool boundaries, auth, RLS, schema and migrations, pgvector, payments and credits — additionally get a deep review in a fresh session. The session that authored the change and the session that reviews it are separate Claude Code sessions: the author's working memory, scratchpad, and conversation are not loaded into the reviewer's session. This is honor-system.
114
114
 
@@ -136,7 +136,7 @@ One-way doors only: fixable in one PR, then no ADR. Nygard, one page, no frontma
136
136
 
137
137
  ## Templates
138
138
 
139
- Templates live at `hstack/templates/`; fill them, do not invent structure. They are `tech-debt.md`, `adr.md`, `story.md`, and the living-doc templates — data-architecture, app-architecture, tech-stack, infrastructure, roadmap. The PR description has its own, `.github/pull_request_template.md`, rendered by GitHub.
139
+ Templates live at `hstack/templates/`; fill them, do not invent structure. They are `tech-debt.md`, `adr.md`, `story.md`, the living-doc templates — data-architecture, app-architecture, tech-stack, infrastructure, roadmap, code-standards — and `eslint-clean-code.mjs`, the lint config the consumer imports rather than copies. The PR description has its own, `.github/pull_request_template.md`, rendered by GitHub.
140
140
 
141
141
  ---
142
142
 
@@ -0,0 +1,107 @@
1
+ ---
2
+ id: code-standards
3
+ type: code-standards
4
+ updated: <YYYY-MM-DD>
5
+ ---
6
+
7
+ _The rules that need judgment. Size, typing, unread errors, console and `process.env` are the
8
+ linter's job (`hstack/templates/eslint-clean-code.mjs`, at `error`, with a suppressions ratchet);
9
+ nothing here repeats what it already fails. Read this file before writing application code;
10
+ `/hstack-wrap` reads the diff against it before the PR. Each rule carries one example from this
11
+ repo — the observed failure it exists for — so a reader knows the rule is about here, not about a
12
+ book. Fill the `Seen here` lines from the codebase; leave a rule's line empty when it has not
13
+ happened yet, and add the example in the PR where it does._
14
+
15
+ ## 1. Where things live
16
+
17
+ _The one sanctioned path for each cross-cutting concern. A second one is a finding._
18
+
19
+ | Concern | Module | What it gives you |
20
+ | --- | --- | --- |
21
+ | Logging | `<lib/observability/logger>` | structured, redacted, tenant-tagged |
22
+ | Configuration | `<lib/env>` | every variable validated once at boot |
23
+ | Database client | `<lib/supabase/{server,client,admin}>` | the RLS posture chosen for you |
24
+ | Errors | `<lib/errors>` | `AppError` with `code` and `cause`; the boundary handler |
25
+ | Retry / sleep / chunk | `<lib/retry>` | abort-aware, already tested |
26
+ | Shared UI primitives | `<components/ui>` | — |
27
+
28
+ ## 2. Before writing, search
29
+
30
+ Before adding a helper, a client, a wrapper, a parser or a formatter, grep for one that exists.
31
+ If it exists, use it. If it is almost right, fix it in place and move the callers. Never write a
32
+ second one beside the first — the copies drift, and the drift is a bug nobody planned.
33
+
34
+ Seen here: _<e.g. seven `sleep` definitions; three copies of the same vendor client, one with
35
+ retry and two without>_
36
+
37
+ ## 3. One function, one responsibility, one level of abstraction
38
+
39
+ A function either orchestrates — calls named steps in order — or is one step. When a reader needs
40
+ a comment to find where the next phase starts, the function is two functions and the comment is
41
+ the name of the second. Extract until each name says what its body does.
42
+
43
+ Seen here: _<e.g. a stream handler that gates, fetches, builds the prompt, persists, retries and
44
+ reports cost, in one body>_
45
+
46
+ ## 4. A React component does not talk to the network
47
+
48
+ Fetching, streaming, protocol decoding and retry live in a hook or a service module. The
49
+ component receives data and callbacks, and renders. A component that parses a response body is
50
+ a service wearing a template.
51
+
52
+ Seen here: _<e.g. a chat component parsing server-sent events by hand inside a mutation>_
53
+
54
+ ## 5. A file's name says what it contains, and it contains one thing
55
+
56
+ If the folder or the file cannot be named after its single responsibility, it has more than one.
57
+ One vendor client per file. No `helpers.ts` past two hundred lines. No file that is a barrel
58
+ (`index.ts`) and an implementation at the same time.
59
+
60
+ Seen here: _<e.g. a file named after one OAuth broker holding six vendor APIs>_
61
+
62
+ ## 6. Names describe behaviour, not category
63
+
64
+ `get*` reads and has no side effect. `is*` / `has*` return a boolean. A name that needs its
65
+ docblock to be understood is the wrong name. One word per concept across the codebase — pick one
66
+ of the synonyms and use it everywhere. No `v2`, `new`, `old`, `legacy` in a name: the name says
67
+ what it is, git says how old.
68
+
69
+ Seen here: _<e.g. `getX` that creates and connects; card / job / task for one entity>_
70
+
71
+ ## 7. A returned error is a handled error
72
+
73
+ Errors travel one way: thrown (with `cause`) from where they happen, caught once at the boundary
74
+ — the route, the job, the server action — where a single handler logs them and shapes the
75
+ response. In between, code neither logs-and-rethrows nor catches-and-continues. A `catch` either
76
+ handles, rethrows with `cause`, or carries a one-line justification for swallowing. A client
77
+ that returns `{ data, error }` instead of throwing has `error` read on every call. A write is
78
+ never followed by `ok: true` without its result being checked.
79
+
80
+ Seen here: _<e.g. an update whose result was never read, answered with `{ ok: true }`>_
81
+
82
+ ## 8. Replace, do not add beside
83
+
84
+ A new implementation of an existing thing ships in the PR that deletes the old one, with every
85
+ import moved. No parallel folder, no second system left wired "for now", no legacy path still
86
+ reachable from a live route.
87
+
88
+ Seen here: _<e.g. two component trees for one screen; three agent loops each with its own cost
89
+ tracker>_
90
+
91
+ ## 9. Delete what nothing imports
92
+
93
+ An unused file, export or dependency is removed in the PR that orphaned it, not recorded as
94
+ debt. Dead code is read by the next agent as live.
95
+
96
+ Seen here: _<e.g. whole component folders no route reaches>_
97
+
98
+ ## 10. Constants live in one place
99
+
100
+ Table names, status strings, limits, timeouts and model ids are imported from a constants module,
101
+ never retyped as literals at the call site. A rename is one edit.
102
+
103
+ Seen here: _<e.g. one table name typed by hand in a hundred places>_
104
+
105
+ **Drift challenge answered**
106
+
107
+ _"Which rule above has the repo violated since this file was last updated, and where?"_
@@ -0,0 +1,322 @@
1
+ /**
2
+ * hstack — clean-code ESLint reference config.
3
+ *
4
+ * The mechanical half of `hstack/context/code-standards.md`: everything a
5
+ * linter can measure lives here, at `error`, so `lint` fails instead of
6
+ * warning. A `warn` is a message an agent learns to scroll past; an `error`
7
+ * is a message it has to act on. Nothing here is a style preference — each
8
+ * rule maps to a way agent-written code has actually gone wrong.
9
+ *
10
+ * Usage, from the consumer's `eslint.config.mjs`:
11
+ *
12
+ * import { defineConfig } from 'eslint/config'
13
+ * import { cleanCode } from './hstack/templates/eslint-clean-code.mjs'
14
+ *
15
+ * export default defineConfig([
16
+ * ...yourFrameworkConfig, // e.g. eslint-config-next, which registers
17
+ * // the @typescript-eslint and import plugins
18
+ * ...cleanCode({
19
+ * typescript: true, // rules that need @typescript-eslint registered
20
+ * imports: true, // rules that need eslint-plugin-import registered
21
+ * supabase: true, // the unread-`error` rule (see below)
22
+ * modules: {
23
+ * logger: 'lib/observability/logger',
24
+ * config: 'lib/env',
25
+ * dbClient: 'lib/supabase',
26
+ * },
27
+ * }),
28
+ * ])
29
+ *
30
+ * The ratchet. Turning these on against an existing codebase fails `lint`
31
+ * hundreds of times at once. Freeze the current violations once —
32
+ *
33
+ * npx eslint --suppress-all
34
+ *
35
+ * — which writes `eslint-suppressions.json`. From then on only *new*
36
+ * violations fail; touching a file that carries old ones is the moment to
37
+ * pay them down (`npx eslint --prune-suppressions` drops the entries that no
38
+ * longer apply). Commit the suppressions file. The count only goes down.
39
+ *
40
+ * This file is framework-owned: `hstack update` overwrites it. Adjust
41
+ * thresholds through the `thresholds` option, not by editing here.
42
+ */
43
+
44
+ const DEFAULT_FILES = ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']
45
+ const DEFAULT_TEST_FILES = [
46
+ '**/*.test.{ts,tsx,js,jsx}',
47
+ '**/*.spec.{ts,tsx,js,jsx}',
48
+ '**/__tests__/**',
49
+ '**/e2e/**',
50
+ ]
51
+ const DEFAULT_SCRIPT_FILES = ['scripts/**']
52
+
53
+ const DEFAULT_THRESHOLDS = {
54
+ /** Lines per function, blank lines and comments excluded. */
55
+ functionLines: 60,
56
+ /** Lines per file, blank lines and comments excluded. */
57
+ fileLines: 400,
58
+ /** Independent paths through a function (if / for / && / ?: each add one). */
59
+ complexity: 15,
60
+ /** Nested blocks. Four is already a function that wants splitting. */
61
+ depth: 4,
62
+ /** Positional parameters. Past three, pass a named object. */
63
+ params: 3,
64
+ /** Nested callbacks. */
65
+ callbacks: 3,
66
+ }
67
+
68
+ /**
69
+ * Supabase returns `{ data, error }` and never throws. Code that takes `data`
70
+ * and ignores `error` reads as correct and fails silently — an RLS denial
71
+ * becomes an HTTP 200 that lies. This rule flags an awaited query-builder
72
+ * chain (`.from(…)`, `.rpc(…)`, `.storage.from(…)`) whose result is
73
+ * discarded, destructured without `error`, or read through `.data` only.
74
+ * Chaining `.throwOnError()` turns the result into an exception and satisfies
75
+ * the rule.
76
+ */
77
+ const supabaseUnreadErrorRule = {
78
+ meta: {
79
+ type: 'problem',
80
+ docs: {
81
+ description:
82
+ 'Require the `error` half of a Supabase `{ data, error }` result to be read.',
83
+ },
84
+ schema: [],
85
+ messages: {
86
+ unreadError:
87
+ 'The Supabase client never throws; it returns `{ data, error }`. Read `error` here ' +
88
+ '(or chain `.throwOnError()`). An unread error is a silent failure.',
89
+ discardedResult:
90
+ 'The result of this Supabase call is discarded, so a failed write goes unnoticed. ' +
91
+ 'Read `error` (or chain `.throwOnError()`).',
92
+ },
93
+ },
94
+ create(context) {
95
+ const QUERY_ROOTS = new Set(['from', 'rpc'])
96
+
97
+ /** Walks a call chain and reports whether it starts a Supabase query and whether it throws on error. */
98
+ function describeChain(node) {
99
+ let startsQuery = false
100
+ let throwsOnError = false
101
+ let current = node
102
+ while (current) {
103
+ if (current.type === 'CallExpression') {
104
+ const callee = current.callee
105
+ if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
106
+ if (QUERY_ROOTS.has(callee.property.name)) startsQuery = true
107
+ if (callee.property.name === 'throwOnError') throwsOnError = true
108
+ }
109
+ current = callee
110
+ } else if (current.type === 'MemberExpression') {
111
+ current = current.object
112
+ } else {
113
+ break
114
+ }
115
+ }
116
+ return { startsQuery, throwsOnError }
117
+ }
118
+
119
+ function patternReadsError(pattern) {
120
+ return pattern.properties.some(
121
+ (property) =>
122
+ property.type === 'RestElement' ||
123
+ (property.key && property.key.type === 'Identifier' && property.key.name === 'error'),
124
+ )
125
+ }
126
+
127
+ return {
128
+ AwaitExpression(node) {
129
+ const { startsQuery, throwsOnError } = describeChain(node.argument)
130
+ if (!startsQuery || throwsOnError) return
131
+
132
+ const parent = node.parent
133
+ if (parent.type === 'ExpressionStatement') {
134
+ context.report({ node, messageId: 'discardedResult' })
135
+ return
136
+ }
137
+ if (parent.type === 'VariableDeclarator' && parent.id.type === 'ObjectPattern') {
138
+ if (!patternReadsError(parent.id)) {
139
+ context.report({ node, messageId: 'unreadError' })
140
+ }
141
+ return
142
+ }
143
+ if (
144
+ parent.type === 'MemberExpression' &&
145
+ parent.object === node &&
146
+ parent.property.type === 'Identifier' &&
147
+ parent.property.name === 'data'
148
+ ) {
149
+ context.report({ node, messageId: 'unreadError' })
150
+ }
151
+ },
152
+ }
153
+ },
154
+ }
155
+
156
+ export const hstackPlugin = {
157
+ meta: { name: 'hstack', version: '1.0.0' },
158
+ rules: {
159
+ 'supabase-unread-error': supabaseUnreadErrorRule,
160
+ },
161
+ }
162
+
163
+ /**
164
+ * @param {object} [options]
165
+ * @param {string[]} [options.files] Files the rules apply to.
166
+ * @param {string[]} [options.testFiles] Files where size rules are relaxed.
167
+ * @param {string[]} [options.scriptFiles] Operator CLIs, where stdout is the interface.
168
+ * @param {boolean} [options.typescript] Emit rules that need `@typescript-eslint` registered. Default true.
169
+ * @param {boolean} [options.imports] Emit rules that need `eslint-plugin-import` registered. Default false.
170
+ * @param {boolean} [options.supabase] Emit the unread-`error` rule. Default false.
171
+ * @param {Partial<typeof DEFAULT_THRESHOLDS>} [options.thresholds]
172
+ * @param {object} [options.modules] Where the one sanctioned path for each concern lives; used in messages.
173
+ * @param {string} [options.modules.logger] e.g. 'lib/observability/logger'
174
+ * @param {string} [options.modules.config] e.g. 'lib/env'
175
+ * @param {string} [options.modules.dbClient] e.g. 'lib/supabase'
176
+ * @returns {object[]} Flat-config objects to spread into `defineConfig([...])`.
177
+ */
178
+ export function cleanCode(options = {}) {
179
+ const files = options.files ?? DEFAULT_FILES
180
+ const testFiles = options.testFiles ?? DEFAULT_TEST_FILES
181
+ const scriptFiles = options.scriptFiles ?? DEFAULT_SCRIPT_FILES
182
+ const typescript = options.typescript ?? true
183
+ const imports = options.imports ?? false
184
+ const supabase = options.supabase ?? false
185
+ const t = { ...DEFAULT_THRESHOLDS, ...(options.thresholds ?? {}) }
186
+ const modules = options.modules ?? {}
187
+
188
+ const loggerHint = modules.logger ? ` Use ${modules.logger}.` : ' Use the repo logger.'
189
+ const configHint = modules.config
190
+ ? ` Read it through ${modules.config}, which validates every variable once at boot.`
191
+ : ' Read it through the one config module that validates every variable at boot.'
192
+ const dbClientHint = modules.dbClient
193
+ ? ` Get a client from ${modules.dbClient}; a client built elsewhere can bypass RLS.`
194
+ : ' Get a client from the one sanctioned module; a client built elsewhere can bypass RLS.'
195
+
196
+ const sizeAndShape = {
197
+ 'max-lines-per-function': [
198
+ 'error',
199
+ { max: t.functionLines, skipBlankLines: true, skipComments: true, IIFEs: true },
200
+ ],
201
+ 'max-lines': ['error', { max: t.fileLines, skipBlankLines: true, skipComments: true }],
202
+ complexity: ['error', t.complexity],
203
+ 'max-depth': ['error', t.depth],
204
+ 'max-params': ['error', t.params],
205
+ 'max-nested-callbacks': ['error', t.callbacks],
206
+ }
207
+
208
+ const errorsAreHandled = {
209
+ 'no-empty': ['error', { allowEmptyCatch: false }],
210
+ 'prefer-promise-reject-errors': 'error',
211
+ 'no-throw-literal': 'error',
212
+ }
213
+
214
+ // `no-console` carries no custom message; when the consumer names a logger,
215
+ // the restricted-global form says where to go instead.
216
+ const consoleRules = modules.logger
217
+ ? {
218
+ 'no-console': 'off',
219
+ 'no-restricted-globals': [
220
+ 'error',
221
+ { name: 'console', message: `Do not log through console.${loggerHint}` },
222
+ ],
223
+ }
224
+ : { 'no-console': ['error', { allow: [] }] }
225
+ const consoleOff = { 'no-console': 'off', 'no-restricted-globals': 'off' }
226
+
227
+ const onePathPerConcern = {
228
+ ...consoleRules,
229
+ 'no-restricted-syntax': [
230
+ 'error',
231
+ {
232
+ selector: "MemberExpression[object.name='process'][property.name='env']",
233
+ message: `Do not read process.env here.${configHint}`,
234
+ },
235
+ ],
236
+ 'no-restricted-imports': [
237
+ 'error',
238
+ {
239
+ paths: [
240
+ {
241
+ name: '@supabase/supabase-js',
242
+ message: `Do not import @supabase/supabase-js directly.${dbClientHint}`,
243
+ },
244
+ ],
245
+ },
246
+ ],
247
+ }
248
+
249
+ const deadCode = {
250
+ // The core rule is replaced by its type-aware twin when TypeScript is on.
251
+ 'no-unused-vars': typescript ? 'off' : 'error',
252
+ 'no-duplicate-imports': 'error',
253
+ 'no-unreachable': 'error',
254
+ 'no-useless-return': 'error',
255
+ }
256
+
257
+ const typescriptRules = typescript
258
+ ? {
259
+ '@typescript-eslint/no-unused-vars': [
260
+ 'error',
261
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
262
+ ],
263
+ '@typescript-eslint/no-explicit-any': 'error',
264
+ '@typescript-eslint/no-non-null-assertion': 'error',
265
+ '@typescript-eslint/consistent-type-imports': [
266
+ 'error',
267
+ { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
268
+ ],
269
+ // Type-aware: needs `parserOptions.projectService` (or `project`) set by the consumer.
270
+ '@typescript-eslint/no-floating-promises': 'error',
271
+ '@typescript-eslint/only-throw-error': 'error',
272
+ }
273
+ : {}
274
+
275
+ const importRules = imports ? { 'import/no-cycle': ['error', { maxDepth: Infinity }] } : {}
276
+
277
+ const supabaseRules = supabase ? { 'hstack/supabase-unread-error': 'error' } : {}
278
+
279
+ const configs = [
280
+ {
281
+ name: 'hstack/clean-code',
282
+ files,
283
+ ...(supabase ? { plugins: { hstack: hstackPlugin } } : {}),
284
+ rules: {
285
+ ...sizeAndShape,
286
+ ...errorsAreHandled,
287
+ ...onePathPerConcern,
288
+ ...deadCode,
289
+ ...typescriptRules,
290
+ ...importRules,
291
+ ...supabaseRules,
292
+ },
293
+ },
294
+ {
295
+ // A long test is a normal test, and a test file reads the environment to
296
+ // decide what it can run against.
297
+ name: 'hstack/clean-code/tests',
298
+ files: testFiles,
299
+ rules: {
300
+ 'max-lines-per-function': 'off',
301
+ 'max-lines': 'off',
302
+ 'max-nested-callbacks': 'off',
303
+ 'no-restricted-syntax': 'off',
304
+ ...(typescript ? { '@typescript-eslint/no-non-null-assertion': 'off' } : {}),
305
+ },
306
+ },
307
+ {
308
+ // Operator-run CLIs: stdout is the interface, and the process reads its
309
+ // own environment.
310
+ name: 'hstack/clean-code/scripts',
311
+ files: scriptFiles,
312
+ rules: {
313
+ ...consoleOff,
314
+ 'no-restricted-syntax': 'off',
315
+ },
316
+ },
317
+ ]
318
+
319
+ return configs
320
+ }
321
+
322
+ export default cleanCode