hstack 0.17.0 → 0.18.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 +16 -0
- package/README.md +8 -5
- package/VERSION +1 -1
- package/package.json +1 -1
- package/template/.claude/agents/adversarial-reviewer.md +5 -3
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +21 -12
- package/template/.claude/skills/hstack-wrap/SKILL.md +10 -8
- package/template/KERNEL.md +5 -5
- package/template/templates/code-standards.md +107 -0
- package/template/templates/eslint-clean-code.mjs +322 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
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.18.0] - 2026-09-18
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
- **`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 }`.
|
|
13
|
+
- **`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*.
|
|
14
|
+
- **`/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.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
- **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`.
|
|
20
|
+
|
|
5
21
|
## [0.17.0] - 2026-08-22
|
|
6
22
|
|
|
7
23
|
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. **
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
1
|
+
0.18.0
|
package/package.json
CHANGED
|
@@ -30,8 +30,9 @@ implementation conversation.
|
|
|
30
30
|
|
|
31
31
|
## Reads
|
|
32
32
|
|
|
33
|
-
What the Skill hands over,
|
|
34
|
-
|
|
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,7 +41,8 @@ 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
|
|
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
47
|
- **The test-immutability audit is mandatory** (protocol: `KERNEL.md` § Test immutability). Diff
|
|
46
48
|
every test file that existed at the base. For each modified, content-drifted or deleted test,
|
|
@@ -107,18 +107,27 @@ sequence to trigger. Irreversible data loss is `critical`. Recoverable inconsist
|
|
|
107
107
|
|
|
108
108
|
## code-quality
|
|
109
109
|
|
|
110
|
-
**What it means.** The change works and will cost more than it should to live with. The
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
110
|
+
**What it means.** The change works and will cost more than it should to live with. The rubric is
|
|
111
|
+
`hstack/context/code-standards.md`: a finding here names the rule it breaks, and a rule the file
|
|
112
|
+
does not hold is not a finding. The easiest category to pad, which is why the rule number is
|
|
113
|
+
required.
|
|
114
|
+
|
|
115
|
+
**Real findings look like.** A second implementation of something the repo already had — a
|
|
116
|
+
client, a parser, a retry — written beside the first instead of found (rule 2). A function that
|
|
117
|
+
orchestrates and also does the steps, so the next phase starts at a comment (rule 3). A component
|
|
118
|
+
that fetches or decodes a protocol (rule 4). A file whose name stopped describing its contents in
|
|
119
|
+
this diff (rule 5). A `get*` that writes (rule 6). A result carrying `error` that nothing reads,
|
|
120
|
+
or a `catch` that continues with no justification (rule 7). A new path wired beside the old one
|
|
121
|
+
with the old one still reachable (rule 8). Code this diff orphaned and left in place (rule 9).
|
|
122
|
+
|
|
123
|
+
**Filler looks like.** Naming preferences that do not make a name lie. Suggested comments.
|
|
124
|
+
Requests to extract a function used once. Anything the repo's linter already fails on — size,
|
|
125
|
+
`any`, console, `process.env` — when the linter is wired; when it is not, one finding says so
|
|
126
|
+
instead of listing what it would have caught.
|
|
127
|
+
|
|
128
|
+
**Severity.** `low` or `medium`. Rule 7 escalates on a write path: an unread error after an
|
|
129
|
+
`update` or `delete` is `data-integrity`, not `code-quality`. Any other `high` here is usually
|
|
130
|
+
mis-categorized — look again at `invariant-breach` or `data-integrity`.
|
|
122
131
|
|
|
123
132
|
---
|
|
124
133
|
|
|
@@ -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
|
|
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 immutability, 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. **
|
|
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. **
|
|
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. **
|
|
28
|
+
4. **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).
|
|
29
29
|
|
|
30
|
-
5. **
|
|
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. **
|
|
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. **
|
|
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. **
|
|
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
|
|
package/template/KERNEL.md
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
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`,
|
|
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
|