2 Commits

Author SHA1 Message Date
user
cedf98b83e rewrite first paragraph
All checks were successful
check / check (push) Successful in 5s
2026-03-04 14:41:58 -08:00
user
368fc0edf0 fix first paragraph
All checks were successful
check / check (push) Successful in 12s
2026-03-04 14:41:15 -08:00
26 changed files with 274 additions and 1897 deletions

View File

@@ -1,97 +1,3 @@
# Docker matches this file with moby/patternmatcher: Go filepath.Match
# semantics plus a `**` extension, compiled to a regexp. Plain
# filepath.Match has no `**` at all. What follows from that: `*` does not
# cross `/`, and a pattern without a leading `**/` is anchored at the
# build-context root. Every depth-independent pattern therefore needs the
# `**/` prefix — without it `config/.env` and `certs/server.key` still
# ship while the file reads as solved.
#
# Root-anchored entries are for paths that occur exactly once, at the
# context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the
# context. In-repo agent scratch is the other case, for the same reason
# — with the caveat recorded at that entry: anchoring is exact only
# where agents run at the repo root, and a repo where they do not must
# add its own entries.
#
# Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`, which is reachable on the case-insensitive
# filesystems most laptops use. Adding an ALL-CAPS twin per pattern is
# not the fix: it still misses `Server.Key` while reading as though case
# were handled. Character ranges cover every spelling in one line, so
# every secret name below is written that way — including the
# extensionless SSH keys and `.envrc`, because on those same
# case-insensitive filesystems direnv reads `.ENVRC` and ssh reads
# `ID_RSA`.
#
# `**/*.[eE][nN][vV]` also excludes a committed env template such as
# `example.env`. If the build genuinely needs one, re-include it with a
# negation after the pattern: `!docs/example.env`.
#
# Extend this file with the repo's own host-built artifacts (compiled
# binaries, test binaries, coverage output); those are per-repo and
# belong here because a host build otherwise drops them into the
# context.
# Repository metadata: exactly one, at the context root. Excluding it
# means `git describe` cannot run in any build stage, and it fails
# quietly there rather than erroring, so a version embedded that way
# comes out empty. Compute the version on the host and pass it in with
# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md.
.git .git
node_modules
# In-repo agent scratch: a directory holding a full additional checkout .DS_Store
# of the repo for each in-flight agent. Anchored because it occurs
# exactly once *where agents run at the repo root*, which is the
# convention this file assumes; the `**/` form would also match any
# nested directory of that name and delete it from the build.
#
# KNOWN GAP, and it is not hypothetical: the directory is created in the
# agent's working directory. If agents in this repo run in
# subdirectories — a monorepo with a per-service agent, say — then
# `services/api/.claude/` is NOT excluded by the line below and still
# reaches the build context and the image, which is the exposure this
# entry exists to close. A repo in that shape adds its own anchored
# entries (`/services/api/.claude`), or `**/.claude` after confirming no
# legitimately named nested directory would be caught.
#
# Not case-folded, unlike the secret patterns below: tooling creates
# this directory in exactly one spelling, so a folded pattern would add
# no coverage.
.claude
# Environment files. `*.env` covers both the bare `.env` name (`*` matches
# the empty string) and the `prod.env` convention.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][dD]25519
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state. Never a build input, and it churns under a developer's
# hands, so it invalidates COPY for reasons unrelated to the source.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*

View File

@@ -6,4 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild - run: docker build .

6
.gitignore vendored
View File

@@ -11,12 +11,6 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node # Node
node_modules/ node_modules/

View File

@@ -1,9 +1,5 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -18,17 +14,19 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
lll: linters-settings:
line-length: 88 lll:
funlen: line-length: 88
lines: 80 funlen:
statements: 50 lines: 80
cyclop: statements: 50
max-complexity: 15 cyclop:
dupl: max-complexity: 15
threshold: 100 dupl:
threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

View File

@@ -1,28 +1,11 @@
# node 22-alpine, 2026-02-22 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
RUN apk add --no-cache make
WORKDIR /app WORKDIR /app
# script/bootstrap installs all prerequisites (make via apk here; node
# and yarn are already in the base image, so those steps are skipped).
# Dependency manifests are copied first so the bootstrap layer is
# cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN yarn install --frozen-lockfile
COPY . . COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and RUN make check
# script/docker. Without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped,
# so it must be redeclared in every stage that runs checks. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check

View File

@@ -1,32 +1,31 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks .PHONY: test lint fmt fmt-check check docker hooks
# Makefile targets are thin shims; the implementations live in script/ # flags are repeated here (also in .prettierrc) so this Makefile works
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # standalone when copied as a template
# of README.md). PRETTIER := yarn run prettier
bootstrap:
@script/bootstrap
setup:
@script/setup
test: test:
@script/test @echo "No tests defined."
lint: lint:
@script/lint @echo "Linting markdown files..."
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
fmt: fmt:
@script/fmt @$(PRETTIER) --write '**/*.md' --tab-width 4 --prose-wrap always
fmt-check: fmt-check:
@script/fmt-check @$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
check: check: test lint fmt-check
@script/check
docker: docker:
@script/docker docker build -t prompts .
hooks: hooks:
@script/install-precommit @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@if [ -f go.mod ]; then \
printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit; \
fi
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

View File

@@ -102,41 +102,6 @@ cd prompts
Prompts are stored as Markdown files in `prompts/`. Copy or reference them as Prompts are stored as Markdown files in `prompts/`. Copy or reference them as
needed in your projects. needed in your projects.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide:
- `script/bootstrap` — install all dependencies (yarn install)
- `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files with prettier
- `script/fmt` — format all markdown files with prettier (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
`script/cibuild`
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what
stops Docker serving that check from cache on an unchanged tree — a bare
`docker build .` fails closed on purpose)
- `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
`make hooks` installs the pre-commit hook that runs `script/precommit`.
## Rationale ## Rationale
LLM prompts, especially development policies, benefit from version control and a LLM prompts, especially development policies, benefit from version control and a

133
TODO.md
View File

@@ -1,133 +0,0 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0
# Next Step
Finish the two draft prompt documents in the working tree and commit them:
prompts/FIXUP_CLEAN.md (currently a near-empty stub) and prompts/FIXUP_REPORT.md
(a rough draft). Write the missing content, run `make fmt` so they pass
fmt-check, and commit.
# Completed Steps
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two
are separate defects and the second is the one that gets dropped: the result
cache is keyed on file content rather than location, so checkouts holding
identical files serve each other's findings under the other's path, while the
concurrency lock is `$TMPDIR/golangci-lint.lock` — host-global, independent of
the cache, and unaffected by isolating it. Moving workers from worktrees to
their own clones does not help either half; it only removes the foreign-path
artefact that made the defect visible. The lock error is retried rather than
surfaced, because it is not a result: it exits non-zero exactly as findings
do, and reporting it as findings sends a correct branch back for rework.
Detection is on the stderr stream and never on exit status, so a finding
quoting the lock message in source cannot be retried away, and exhaustion
exits 75 with a VOID message rather than passing or failing quietly.
`--allow-serial-runners` (which keeps the guard and queues) covers the
same-checkout overlap that `TMPDIR` scoping cannot; `--allow-parallel-runners`
is rejected outright. Both checklists gained the corresponding items, since a
half-fix that sets only the cache reads as complete. `GOCACHE` was measured
and does not need isolating. Verified with the snippet extracted from the
committed document and executed as a consuming repo would adopt it, against
paired controls: contamination reproduced on the pre-fix form and absent on
the adopted one, retry engaged, exhaustion loud, a genuine finding still
reported, and a held host lock failing the pre-fix script while leaving the
adopted one untouched.
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out
of version control. `.claude/` holds one worktree — an entire additional
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was
reaching the image: another session's unreviewed, sometimes uncommitted work,
inflating the context by a multiple of the repo and invalidating `COPY` for
reasons unrelated to the repo's own content. The `.dockerignore` entry is
root-anchored, because the directory occurs exactly once where agents run at
the repo root and the `**/` form additionally deletes any nested directory of
that name — with the residual gap that follows from anchoring (a monorepo
running agents in subdirectories still ships `services/api/.claude/`) stated
in the canonical `.dockerignore`, the policy and the existing-repo checklist,
since consuming repos receive the files rather than the tracker; the
`.gitignore` entry is unanchored, because `.gitignore` patterns already match
at every depth, and each file is written to its own semantics rather than
derived from the other. Also closed the consequence that ships broken
silently: excluding `.git` means `git describe` cannot run in any build stage
and yields an empty version without erroring, so `script/docker` and
`script/cibuild` now compute the version on the host and pass
`--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes
from instead of leaving the reader to fill the gap with `git describe` inside
the build. The two Go documents that carry the `GOLDFLAGS` pattern were
corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)`
evaluated inside a build stage is exactly the empty version this closes.
Verified by enumerating a probe image before, after, and against the
`**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache
verification re-run under the changed build context.
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because
`.gitignore` covers it. The patterns are written to `.dockerignore`'s own
`moby/patternmatcher` semantics — `**/`-prefixed so they hold at every depth,
which also fixes nested `node_modules` — rather than transplanted from
`.gitignore`, whose unprefixed form protects only the repository root while
reading as solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the
`prod.env` convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys,
every one of them case-folded with character ranges because matching is
case-sensitive and an ALL-CAPS twin per pattern still misses `Server.Key`.
`REPO_POLICIES.md` and both repo checklists now state that asymmetry and
require verification by enumerating the image rather than by reading the
patterns. Verified with a probe image before, against three naive forms
(unprefixed, lowercase-only, ALL-CAPS-doubled), and after.
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old
`if missing` guard tested PATH presence only, so pins were inert on any
provisioned machine and CI silently disagreed with local) and then re-resolves
the binary through `PATH` and fails loudly, naming the shadowing path, when
the install did not take effect — the failure mode the naive
compare-then-install fix leaves behind while reporting success.
- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the
`Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its
lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a
bare `docker build .` fail closed. Corrected the org-canonical text that
asserted a successful build implies all checks pass, across every document
carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents
to write the pre-fix `script/cibuild` and ended on an acceptance item the
guard makes unsatisfiable), and the Go styleguide.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually
apply under golangci-lint v2). Recorded the canonical golangci-lint version
(v2.12.2, commit-pinned) in REPO_POLICIES.md.
- 2026-03-20: Strengthened constructor naming and Params struct rules in the Go
styleguide.
- 2026-03-18: Documented fail-fast Dockerfile lint stage and conditional -v test
rerun patterns in REPO_POLICIES.md.
- 2026-03-11: Added HTTP service hardening policy for 1.0 releases.
- 2026-03-10: Added policy: no build artifacts in repos.
- 2026-03-04: Added LLM prose tells reference and copyediting checklist, then
several self-applied revision passes.
- 2026-02-28: Expanded the pre-1.0 schema migration rule; added clawpub
reference.
- 2026-02-23: Added Go style rules (no type-only packages, Stringer for
string-based types); template repos section in README.
- 2026-02-22: Initial policy corpus: REPO_POLICIES.md, code styleguides
(general, Go, JS, Python), repo checklists, CI policy, hash pinning, Go HTTP
server conventions, repo scaffolding.
# Future Steps
- Finish, format, and commit FIXUP_CLEAN.md and FIXUP_REPORT.md (the Next Step).
- Commit this TODO.md at the repo root; it is the last missing policy file.
- Decide the fate of untracked resume.sh: commit it or delete it.
- Add more prompt templates for common development tasks (from README TODO).

View File

@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-09 last_modified: 2026-02-22
--- ---
1. Try to hard wrap long lines at 77 characters or less. 1. Try to hard wrap long lines at 77 characters or less.
@@ -49,20 +49,7 @@ last_modified: 2026-08-09
``` ```
```make ```make
# ?= rather than := because this `$(shell git describe ...)` is only VERSION := $(shell git describe --always --dirty)
# correct on the host. `.dockerignore` excludes `.git`, so evaluated
# inside a build stage it expands to the empty string without failing
# and the binary reports no version at all. The version is computed on
# the host by `script/docker` / `script/cibuild` and passed with
# `--build-arg VERSION=...`. If this repo's Dockerfile compiles by
# invoking make (`RUN make build`), `ARG VERSION` in that stage puts the
# value in the environment and `?=` defers to it. The canonical Go
# template in REPO_POLICIES.md instead runs `go build` directly with
# `-ldflags "... -X main.Version=${VERSION}"`, so there this Makefile is
# a host-only path — but it is still `?=`, because a repo that later
# moves the build behind make must not silently start shipping an empty
# version. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m) BUILDARCH := $(shell uname -m)
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
@@ -114,16 +101,9 @@ last_modified: 2026-08-09
`golangci-lint`. `golangci-lint`.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and 1. Write a `Dockerfile` for every repo, even if it only runs the tests and
linting. `script/cibuild` and `script/docker` should always make sure that linting. `docker build .` should always make sure that the code is in an
the code is in an able-to-be-compiled state, linted, and any tests run, and able-to-be-compiled state, linted, and any tests run. The Docker build
the build should fail if linting doesn't pass. That guarantee holds only should fail if linting doesn't pass.
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that
busts the check layers out of the Docker cache; without it an unchanged tree
serves those layers from cache and the build reports a green it never ran. A
bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]`
guard — always go through `script/cibuild` or `script/docker`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
@@ -156,15 +136,8 @@ last_modified: 2026-08-09
1. Provide a .gitignore file that ignores at least `*.log`, `*.out`, and 1. Provide a .gitignore file that ignores at least `*.log`, `*.out`, and
`*.test` files, as well as any binaries. `*.test` files, as well as any binaries.
1. Constructors **must** be called `New()`. `modulename.New()` works great if 1. Constructors should be called `New()` whenever possible. `modulename.New()`
you name the packages properly. If the constructor creates an instance from works great if you name the packages properly.
an existing value or representation, `From<Something>()` (e.g.
`FromBytes()`, `FromConfig()`) is also acceptable. If the package contains
multiple types and `New()` is ambiguous, `NewThing()` is occasionally
acceptable — but prefer restructuring packages so each type gets its own
package and a plain `New()`. Do not invent creative constructor names like
`Create()`, `Make()`, `Build()`, `Open()` (unless wrapping an OS resource),
or `Init()`. If you see a constructor with a non-standard name, rename it.
1. Don't make packages too big. Break them up. 1. Don't make packages too big. Break them up.
@@ -176,15 +149,9 @@ last_modified: 2026-08-09
1. Use descriptive names for modules and filenames. Avoid generic names like 1. Use descriptive names for modules and filenames. Avoid generic names like
`server`. `util` is banned. `server`. `util` is banned.
1. Constructors **must** take a `Params` struct (or `ThingParams` when 1. Constructors should take a Params struct if they need more than 1-2
`NewThing()` is used), even for a single argument. Named fields in a Params arguments. Positional arguments are an endless source of bugs and should be
struct are always clearer than positional arguments. Positional arguments avoided whenever possible.
for constructors are an endless source of bugs — they make call sites
unreadable, invite wrong-order errors that the compiler can't catch when
types coincide, and force every caller to update when a new field is added.
The only exception is when the single argument is stupidly obvious from
context — e.g. `featureflag.New(true)` or `thing.NewFromReader(r)`. When in
doubt, use a Params struct.
1. Use `context.Context` for all functions that need it. If you don't need it, 1. Use `context.Context` for all functions that need it. If you don't need it,
you can pass `context.Background()`. Anything long-running should get and you can pass `context.Background()`. Anything long-running should get and

View File

@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-02-22
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -24,57 +24,15 @@ with your task.
- [ ] `LICENSE` file exists and matches the README - [ ] `LICENSE` file exists and matches the README
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from - [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language - [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) —
artifacts, secrets) — fetch from fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` if missing. if missing
An existing repo usually has a hand-written one that is never re-fetched,
so check the entries rather than the file's presence: `.claude/` in
particular, unanchored, so agent worktrees cannot be committed by
accident. Do not give it a `**/` prefix — that is a `.dockerignore` form
and is wrong here.
- [ ] `.editorconfig` exists — fetch from - [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); build step — fetch `.dockerignore` from
Dockerfile runs `make check` as a build step, and every stage containing a `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
check-running `RUN` declares `ARG CHECK_EPOCH` with the - [ ] Gitea Actions workflow in `.gitea/workflows/` runs `docker build .` on
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
existing repo is where such a binary is likeliest to already be sitting in
the build context, invisible to git.
- [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/`
prefix. Agent worktrees are entire checkouts of the repo, so they inflate
the context by a multiple of it and can copy another session's unreviewed
work into an image layer. Confirm by enumerating the image, not by reading
the file — `.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The
scratch directory is created in the agent's working directory, so the
canonical anchored entry misses `services/api/.claude/` in a monorepo with
a per-service agent — it still reaches the build context and the image. An
existing repo is where such a layout already exists, so check it here
rather than assuming the canonical entry covers you: add anchored entries
for the subdirectories that have one (`/services/api/.claude`), or
`**/.claude` once you have confirmed no legitimately named nested
directory would be caught.
- [ ] If the repo embeds a version in a binary, that version is computed on the
host and passed with `--build-arg VERSION=...` by `script/docker` and
`script/cibuild`. No stage calls `git describe`: `.dockerignore` excludes
`.git`, so it yields an empty version without failing the build. A
tag-derived version additionally needs `fetch-depth: 0` on the CI checkout
step, which clones shallow and fetches no tags by default.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified.
`.dockerignore` anchors an unprefixed pattern at the context root, so the
transplanted form leaves `config/.env` and `certs/server.key` in the build
context while reading as solved — see the `.dockerignore` rule in
`REPO_POLICIES.md`.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
- [ ] Language-specific config: - [ ] Language-specific config:
@@ -87,26 +45,12 @@ with your task.
- [ ] Python: `pyproject.toml` - [ ] Python: `pyproject.toml`
- [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above) - [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above)
# Makefile and script/ Entrypoints # Makefile
- [ ] `Makefile` exists in root — reference - [ ] `Makefile` exists in root — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile` `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, - [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
`hooks` `hooks`
- [ ] Target implementations live in `script/` (scripts-to-rule-them-all);
Makefile targets are thin shims calling them — model scripts at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`
- [ ] `script/precommit` exists and the pre-commit hook (installed by
`script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] Go: `script/lint` isolates golangci-lint per checkout —
`GOLANGCI_LINT_CACHE` and `TMPDIR` both exported into `.lint-cache/`
(which is in `.gitignore` and `.dockerignore`), `--allow-serial-runners`
passed, and the lock error retried rather than reported as findings. Copy
the canonical block from `REPO_POLICIES.md`. Setting only the cache is the
common half-fix and leaves `parallel golangci-lint is running` failing
runs red.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 30-second timeout - [ ] `make test` has a 30-second timeout
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile - [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
@@ -134,25 +78,8 @@ with your task.
`internal/`, `static/`, etc.) `internal/`, `static/`, etc.)
- [ ] Go migrations in `internal/db/migrations/` and embedded in binary - [ ] Go migrations in `internal/db/migrations/` and embedded in binary
# HTTP Service Hardening (if targeting 1.0 and the repo is an HTTP/web service)
- [ ] Security headers set on all responses (HSTS, CSP, X-Frame-Options,
X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
- [ ] Request body size limits enforced on all endpoints
- [ ] Read/write/idle timeouts configured on the HTTP server (slowloris defense)
- [ ] Per-handler execution time limits in place
- [ ] Password-based auth endpoints are rate-limited
- [ ] CSRF tokens on all state-mutating HTML forms
- [ ] Passwords hashed with bcrypt, scrypt, or argon2
- [ ] Session cookies use HttpOnly, Secure, and SameSite attributes
- [ ] True client IP correctly detected behind reverse proxy (trusted proxy
allowlist configured)
- [ ] CORS restricted to explicit origin allowlist for authenticated endpoints
- [ ] Error responses do not leak stack traces, SQL queries, or internal paths
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design, - [ ] `docker build` succeeds
on the `CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

@@ -1,6 +1,6 @@
--- ---
title: Go HTTP Server Conventions title: Go HTTP Server Conventions
last_modified: 2026-08-09 last_modified: 2026-02-22
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -991,14 +991,7 @@ func main() {
Use ldflags to inject version information at build time: Use ldflags to inject version information at build time:
```makefile ```makefile
# ?= rather than := because this `$(shell git describe ...)` is only correct VERSION := $(shell git describe --tags --always)
# on the host: `.dockerignore` excludes `.git`, so evaluated inside a build
# stage it expands to the empty string without failing and the binary reports
# no version. The version is computed on the host by `script/docker` /
# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build
# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers
# to it. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH) BUILDARCH := $(shell go env GOARCH)
build: build:

View File

@@ -1,6 +1,8 @@
# LLM Prose Tells # LLM Prose Tells
A catalog of patterns found in LLM-generated prose. Every pattern in this document shows up in human writing occasionally. They
become diagnostic only through density. A person might use one or two across an
entire essay, but LLM output packs fifteen into a single paragraph.
--- ---
@@ -13,11 +15,19 @@ A negation followed by an em-dash and a reframe.
> "It's not just a tool—it's a paradigm shift." "This isn't about > "It's not just a tool—it's a paradigm shift." "This isn't about
> technology—it's about trust." > technology—it's about trust."
The single most recognizable LLM construction. Models produce this at roughly 10
to 50x the rate of human writers. Four of them in one essay and you know what
you're reading.
### Em-Dash Overuse Generally ### Em-Dash Overuse Generally
Even outside the "not X but Y" pivot, models substitute em-dashes for commas, Even outside the "not X but Y" pivot, models use em-dashes at far higher rates
semicolons, parentheses, colons, and periods. The em-dash can replace any other than human writers. They substitute em-dashes for commas, semicolons,
punctuation mark, so models default to it. parentheses, colons, and periods, often multiple times per paragraph. A human
writer might use one or two in an entire piece for a specific parenthetical
effect. Models scatter them everywhere because the em-dash can stand in for any
other punctuation mark, so they default to it. More than two or three per page
is a meaningful signal on its own.
### The Colon Elaboration ### The Colon Elaboration
@@ -25,25 +35,33 @@ A short declarative clause, then a colon, then a longer explanation.
> "The answer is simple: we need to rethink our approach from the ground up." > "The answer is simple: we need to rethink our approach from the ground up."
Models reach for this in every other paragraph. The construction is perfectly
normal. The frequency gives it away.
### The Triple Construction ### The Triple Construction
> "It's fast, it's scalable, and it's open source." > "It's fast, it's scalable, and it's open source."
Three parallel items in a list, usually escalating. Always exactly three (rarely Three parallel items in a list, usually escalating. Always exactly three (rarely
two, never four) with strict grammatical parallelism. two, never four) with strict grammatical parallelism that human writers rarely
bother maintaining.
### The Staccato Burst ### The Staccato Burst
> "This matters. It always has. And it always will." "The data is clear. The > "This matters. It always has. And it always will." "The data is clear. The
> trend is undeniable. The conclusion is obvious." > trend is undeniable. The conclusion is obvious."
Runs of very short sentences at the same cadence and matching length. Runs of very short sentences at the same cadence. Human writers use a short
sentence for emphasis occasionally, but stacking three or four of them in a row
at matching length creates a mechanical regularity that reads as generated.
### The Two-Clause Compound Sentence ### The Two-Clause Compound Sentence
An independent clause, a comma, a conjunction ("and," "but," "which," Possibly the most pervasive structural tell, and easy to miss because each
"because"), and a second independent clause of similar length. Every sentence individual instance looks like normal English. The model produces sentence after
becomes two balanced halves. sentence where an independent clause is followed by a comma, a conjunction
("and," "but," "which," "because"), and a second independent clause of similar
length. Every sentence becomes two balanced halves joined in the middle.
> "The construction itself is perfectly normal, which is why the frequency is > "The construction itself is perfectly normal, which is why the frequency is
> what gives it away." "They contain zero information, and the actual point > what gives it away." "They contain zero information, and the actual point
@@ -53,62 +71,76 @@ becomes two balanced halves.
Human prose has sentences with one clause, sentences with three, sentences that Human prose has sentences with one clause, sentences with three, sentences that
start with a subordinate clause before reaching the main one, sentences that start with a subordinate clause before reaching the main one, sentences that
embed their complexity in the middle. embed their complexity in the middle. When every sentence on the page has that
same two-part structure, the rhythm becomes monotonous in a way that's hard to
pinpoint but easy to feel.
### Uniform Sentences Per Paragraph ### Uniform Sentences Per Paragraph
Model-generated paragraphs contain between three and five sentences, a count Model-generated paragraphs contain between three and five sentences. This count
that holds steady across a piece. If the first paragraph has four sentences, holds steady across an entire piece. If the first paragraph has four sentences,
every subsequent paragraph will too. every subsequent paragraph will too. Human writers are much more varied (a
single sentence followed by one that runs eight or nine) because they follow the
shape of an idea, not a template.
### The Dramatic Fragment ### The Dramatic Fragment
Sentence fragments used as standalone paragraphs for emphasis. Sentence fragments used as standalone paragraphs for emphasis, like "Full stop."
or "Let that sink in." on their own line. Using one in an entire essay is a
> "Full stop." "Let that sink in." reasonable stylistic choice, but models drop them in once per section or more,
at which point it becomes a habit rather than a deliberate decision.
### The Pivot Paragraph ### The Pivot Paragraph
> "But here's where it gets interesting." "Which raises an uncomfortable truth." > "But here's where it gets interesting." "Which raises an uncomfortable truth."
One-sentence paragraphs that exist only to transition between ideas, containing One-sentence paragraphs that exist only to transition between ideas. They
zero information. The actual point is always in the next paragraph. contain zero information. The actual point is always in the next paragraph.
Delete every one of these and the piece reads better.
### The Parenthetical Qualifier ### The Parenthetical Qualifier
> "This is, of course, a simplification." "There are, to be fair, exceptions." > "This is, of course, a simplification." "There are, to be fair, exceptions."
Parenthetical asides inserted to perform nuance without changing the argument. Parenthetical asides inserted to look thoughtful. The qualifier never changes
the argument that follows it. Its purpose is to perform nuance, not to express a
real reservation about what's being said.
### The Unnecessary Contrast ### The Unnecessary Contrast
A contrasting clause appended to a statement that doesn't need one, using Models append a contrasting clause to statements that don't need one, tacking on
"whereas," "as opposed to," "unlike," or "except that." "whereas," "as opposed to," "unlike," or "except that" to draw a comparison the
reader could already infer.
> "Models write one register above where a human would, whereas human writers > "Models write one register above where a human would, whereas human writers
> tend to match register to context." > tend to match register to context."
The contrasting clause restates what the first clause already said. If you The first clause already makes the point. The contrasting clause restates it
delete the "whereas" clause and the sentence still says everything it needs to, from the other direction. If you delete the "whereas" clause and the sentence
the contrast was filler. still says everything it needs to, the contrast was filler.
### Unnecessary Elaboration ### Unnecessary Elaboration
Models keep going after the sentence has already made its point. Models keep going after the sentence has already made its point, tacking on
clarifying phrases, adverbial modifiers, or restatements that add nothing.
> "A person might lean on one or two of these habits across an entire essay, but > "A person might lean on one or two of these habits across an entire essay, but
> LLM output will use fifteen of them per paragraph, consistently, throughout > LLM output will use fifteen of them per paragraph, consistently, throughout
> the entire piece." > the entire piece."
This sentence could end at "paragraph." The words after it repeat what "per This sentence could end at "paragraph." The words after it just repeat what "per
paragraph" already means. If you can cut the last third of a sentence without paragraph" already means. Models do this because they're optimizing for clarity
losing meaning, the last third shouldn't be there. at the expense of concision, and because their training rewards thoroughness.
The result is prose that feels padded. If you can cut the last third of a
sentence without losing any meaning, the last third shouldn't be there.
### The Question-Then-Answer ### The Question-Then-Answer
> "So what does this mean for the average user? It means everything." > "So what does this mean for the average user? It means everything."
A rhetorical question immediately followed by its own answer. A rhetorical question immediately followed by its own answer. Models do this two
or three times per piece because it fakes forward momentum. A human writer might
do it once.
--- ---
@@ -116,38 +148,44 @@ A rhetorical question immediately followed by its own answer.
### Overused Intensifiers ### Overused Intensifiers
"Crucial," "vital," "robust," "comprehensive," "fundamental," "arguably," The following words appear at dramatically elevated rates in model output:
"crucial," "vital," "robust," "comprehensive," "fundamental," "arguably,"
"straightforward," "noteworthy," "realm," "landscape," "leverage" (as a verb), "straightforward," "noteworthy," "realm," "landscape," "leverage" (as a verb),
"delve," "tapestry," "multifaceted," "nuanced" (applied to the model's own "delve," "tapestry," "multifaceted," "nuanced" (which models apply to their own
analysis), "pivotal," "unprecedented" (applied to things with plenty of analysis with startling regularity), "pivotal," "unprecedented" (frequently
precedent), "navigate," "foster," "underscores," "resonates," "embark," applied to things with plenty of precedent), "navigate," "foster,"
"streamline," "spearhead." "underscores," "resonates," "embark," "streamline," and "spearhead." Three or
more on the same page is a strong signal.
### Elevated Register Drift ### Elevated Register Drift
Models write one register above where a human would, replacing "use" with Models write one register above where a human would. "Use" becomes "utilize."
"utilize," "start" with "commence," "help" with "facilitate," "show" with "Start" becomes "commence." "Help" becomes "facilitate." "Show" becomes
"demonstrate," "try" with "endeavor," "change" with "transform," and "make" with "demonstrate." "Try" becomes "endeavor." "Change" becomes "transform." "Make"
"craft." becomes "craft." The tendency holds regardless of topic or audience.
### Filler Adverbs ### Filler Adverbs
"Importantly," "essentially," "fundamentally," "ultimately," "inherently," "Importantly," "essentially," "fundamentally," "ultimately," "inherently,"
"particularly," "increasingly." Dropped in to signal that something matters when "particularly," "increasingly." Dropped in to signal that something matters,
the writing itself should make the importance clear. which is unnecessary when the writing itself already makes the importance clear.
### The "Almost" Hedge ### The "Almost" Hedge
Instead of saying a pattern "always" or "never" does something, models write Models rarely commit to an unqualified statement. Instead of saying a pattern
"almost always," "almost never," "almost certainly," "almost exclusively." A "always" or "never" does something, they write "almost always," "almost never,"
micro-hedge, less obvious than the full hedge stack. "almost certainly," "almost exclusively." The word "almost" shows up at
extraordinary density in model-generated analytical prose. It's a micro-hedge,
less obvious than the full hedge stack but just as diagnostic when it appears
ten or fifteen times in a single document.
### "In an era of..." ### "In an era of..."
> "In an era of rapid technological change..." > "In an era of rapid technological change..."
Used to open an essay. The model is stalling while it figures out what the A model habit as an essay opener. The model uses it to stall while it figures
actual argument is. out what the actual argument is. Human writers don't begin a piece by zooming
out to the civilizational scale before they've said anything specific.
--- ---
@@ -158,20 +196,24 @@ actual argument is.
> "While X has its drawbacks, it also offers significant benefits." > "While X has its drawbacks, it also offers significant benefits."
Every argument followed by a concession, every criticism softened. A direct Every argument followed by a concession, every criticism softened. A direct
artifact of RLHF training, which penalizes strong stances. artifact of RLHF training, which penalizes strong stances. Models reflexively
both-sides everything even when a clear position would serve the reader better.
### The Throat-Clearing Opener ### The Throat-Clearing Opener
> "In today's rapidly evolving digital landscape, the question of data privacy > "In today's rapidly evolving digital landscape, the question of data privacy
> has never been more important." > has never been more important."
The first paragraph adds no information. Delete it and the piece improves. The first paragraph of most model-generated essays adds no information. Delete
it and the piece improves immediately. The actual argument starts in paragraph
two.
### The False Conclusion ### The False Conclusion
> "At the end of the day, what matters most is..." "Moving forward, we must..." > "At the end of the day, what matters most is..." "Moving forward, we must..."
The high school "In conclusion,..." dressed up for a professional audience. The high school "In conclusion,..." dressed up for a professional audience.
Signals that the model is wrapping up without actually landing on anything.
### The Sycophantic Frame ### The Sycophantic Frame
@@ -192,13 +234,15 @@ the key considerations:"
> cases it can potentially offer significant benefits." > cases it can potentially offer significant benefits."
Five hedges in one sentence ("worth noting," "while," "may not be," "in many Five hedges in one sentence ("worth noting," "while," "may not be," "in many
cases," "can potentially"), communicating nothing. cases," "can potentially"), communicating nothing. The model would rather be
vague than risk being wrong about anything.
### The Empathy Performance ### The Empathy Performance
> "This can be a deeply challenging experience." "Your feelings are valid." > "This can be a deeply challenging experience." "Your feelings are valid."
Generic emotional language that could apply to anything. Generic emotional language that could apply equally to a bad day at work or a
natural disaster. That interchangeability is what makes it identifiable.
--- ---
@@ -206,28 +250,35 @@ Generic emotional language that could apply to anything.
### Symmetrical Section Length ### Symmetrical Section Length
If the first section runs about 150 words, every subsequent section will fall If the first section of a model-generated essay runs about 150 words, every
between 130 and 170. subsequent section will fall between 130 and 170. Human writing is much more
uneven, with 50 words in one section and 400 in the next.
### The Five-Paragraph Prison ### The Five-Paragraph Prison
Model essays follow a rigid introduction-body-conclusion arc even when nobody Model essays follow a rigid introduction-body-conclusion arc even when nobody
asked for one. The introduction previews the argument, the body presents 3 to 5 asked for one. The introduction previews the argument, the body presents 3 to 5
points, the conclusion restates the thesis. points, and then the conclusion restates the thesis using slightly different
words.
### Connector Addiction ### Connector Addiction
The first word of each paragraph forms an unbroken chain of transition words: Look at the first word of each paragraph in model output. You'll find an
"However," "Furthermore," "Moreover," "Additionally," "That said," "To that unbroken chain of transition words: "However," "Furthermore," "Moreover,"
end," "With that in mind," "Building on this." "Additionally," "That said," "To that end," "With that in mind," "Building on
this." Human prose moves between ideas without announcing every transition.
### Absence of Mess ### Absence of Mess
Model prose doesn't contradict itself mid-paragraph and then catch the Model prose doesn't contradict itself mid-paragraph and then catch the
contradiction, go on a tangent and have to walk it back, use an obscure idiom contradiction. It doesn't go on a tangent and have to walk it back, use an
without explaining it, make a joke that risks falling flat, leave a thought obscure idiom without explaining it, make a joke that risks falling flat, leave
genuinely unfinished, or keep a sentence the writer liked the sound of even a thought genuinely unfinished, or keep a sentence the writer liked the sound of
though it doesn't quite work. even though it doesn't quite work.
Human writing does all of those things regularly. That total absence of rough
patches and false starts is one of the strongest signals that text was
machine-generated.
--- ---
@@ -237,27 +288,45 @@ though it doesn't quite work.
> "This has implications far beyond just the tech industry." > "This has implications far beyond just the tech industry."
Zooming out to claim broader significance without substantiating it. Zooming out to claim broader significance without substantiating it. The model
has learned that essays are supposed to gesture at big ideas, so it gestures.
Nothing concrete is behind the gesture.
### "It's important to note that..." ### "It's important to note that..."
This phrase and its variants ("it's worth noting," "it bears mentioning," "it This phrase and its variants ("it's worth noting," "it bears mentioning," "it
should be noted") function as verbal tics before a qualification the model should be noted") appear at absurd rates in model output. They function as
believes someone expects. verbal tics before a qualification the model believes someone expects.
### The Metaphor Crutch ### The Metaphor Crutch
Models rely on a small, predictable set of metaphors: "double-edged sword," "tip Models rely on a small, predictable set of metaphors ("double-edged sword," "tip
of the iceberg," "north star," "building blocks," "elephant in the room," of the iceberg," "north star," "building blocks," "elephant in the room,"
"perfect storm," "game-changer." "perfect storm," "game-changer") and reach for them with unusual regularity
across every topic. The pool is noticeably smaller than what human writers draw
from.
---
## How to Actually Spot It
No single pattern on this list proves anything by itself. Humans use em-dashes.
Humans write "crucial." Humans ask rhetorical questions.
What gives it away is how many of these show up at once. Model output will hit
10 to 20 of these patterns per page. Human writing might trigger 2 or 3,
distributed unevenly, mixed with idiosyncratic constructions no model would
produce. When every paragraph on the page reads like it came from the same
careful, balanced, slightly formal, structurally predictable process, it was
generated by one.
--- ---
## Copyediting Checklist: Removing LLM Tells ## Copyediting Checklist: Removing LLM Tells
Follow this checklist when editing any document to remove machine-generated Follow this checklist when editing any document to remove machine-generated
patterns. Do at least two full passes, because fixing one pattern often patterns. Go through the entire list for every piece. Do at least two full
introduces another. passes, because fixing one pattern often introduces another.
### Pass 1: Word-Level Cleanup ### Pass 1: Word-Level Cleanup
@@ -271,7 +340,8 @@ introduces another.
2. Search for filler adverbs ("importantly," "essentially," "fundamentally," 2. Search for filler adverbs ("importantly," "essentially," "fundamentally,"
"ultimately," "inherently," "particularly," "increasingly") and delete every "ultimately," "inherently," "particularly," "increasingly") and delete every
instance where the sentence still makes sense without it. instance where the sentence still makes sense without it. That will be most
of them.
3. Look for elevated register drift ("utilize," "commence," "facilitate," 3. Look for elevated register drift ("utilize," "commence," "facilitate,"
"demonstrate," "endeavor," "transform," "craft" and similar) and replace with "demonstrate," "endeavor," "transform," "craft" and similar) and replace with
@@ -279,6 +349,7 @@ introduces another.
4. Search for "it's important to note," "it's worth noting," "it bears 4. Search for "it's important to note," "it's worth noting," "it bears
mentioning," and "it should be noted" and delete the phrase in every case. mentioning," and "it should be noted" and delete the phrase in every case.
The sentence that follows always stands on its own.
5. Search for the stock metaphors ("double-edged sword," "tip of the iceberg," 5. Search for the stock metaphors ("double-edged sword," "tip of the iceberg,"
"north star," "building blocks," "elephant in the room," "perfect storm," "north star," "building blocks," "elephant in the room," "perfect storm,"
@@ -287,114 +358,101 @@ introduces another.
6. Search for "almost" used as a hedge ("almost always," "almost never," "almost 6. Search for "almost" used as a hedge ("almost always," "almost never," "almost
certainly," "almost exclusively") and decide in each case whether to commit certainly," "almost exclusively") and decide in each case whether to commit
to the unqualified claim or to drop the sentence entirely. to the unqualified claim or to drop the sentence entirely. If the claim needs
"almost" to be true, it might not be worth making.
7. Search for em-dashes and replace each one with the punctuation mark that 7. Search for em-dashes and replace each one with the punctuation mark that
would normally be used in that position (comma, semicolon, colon, period, or would normally be used in that position (comma, semicolon, colon, period, or
parentheses). If you can't identify which one it should be, the sentence parentheses). If you can't identify which one it should be, the sentence
needs to be restructured. needs to be restructured.
8. Remove redundant adjectives. For each adjective, ask whether the sentence
changes meaning without it. "A single paragraph" means the same as "a
paragraph." "An entire essay" means the same as "an essay." If the adjective
doesn't change the meaning, cut it.
9. Remove unnecessary trailing clauses. Read the end of each sentence and ask
whether the last clause restates what the sentence already said. If so, end
the sentence earlier.
### Pass 2: Sentence-Level Restructuring ### Pass 2: Sentence-Level Restructuring
10. Find every em-dash pivot ("not Xbut Y," "not just XY," "more than X—Y") 8. Find every em-dash pivot ("not X...but Y," "not just X...Y," "more than
and rewrite it as two separate clauses or a single sentence that makes the X...Y") and rewrite it as two separate clauses or a single sentence that
point without the negation-then-correction structure. makes the point without the negation-then-correction structure.
11. Find every colon elaboration and check whether it's doing real work. If the 9. Find every colon elaboration and check whether it's doing real work. If the
clause before the colon could be deleted without losing meaning, rewrite the clause before the colon could be deleted without losing meaning, rewrite the
sentence to start with the substance that comes after the colon. sentence to start with the substance that comes after the colon.
12. Find every triple construction (three parallel items in a row) and either 10. Find every triple construction (three parallel items in a row) and either
reduce it to two, expand it to four or more, or break the parallelism so the reduce it to two, expand it to four or more, or break the parallelism so the
items don't share the same grammatical structure. items don't share the same grammatical structure.
13. Find every staccato burst (three or more short sentences in a row at similar 11. Find every staccato burst (three or more short sentences in a row at similar
length) and combine at least two of them into a longer sentence, or vary length) and combine at least two of them into a longer sentence, or vary
their lengths so they don't land at the same cadence. their lengths so they don't land at the same cadence.
14. Find every unnecessary contrast ("whereas," "as opposed to," "unlike," "as 12. Find every unnecessary contrast ("whereas," "as opposed to," "unlike," "as
compared to," "except that") and check whether the contrasting clause adds compared to," "except that") and check whether the contrasting clause adds
information not already obvious from the main clause. If the sentence says information not already obvious from the main clause. If the sentence says
the same thing twice from two directions, delete the contrast. the same thing twice from two directions, delete the contrast.
15. Check for the two-clause compound sentence pattern. If most sentences in a 13. Check for the two-clause compound sentence pattern. If most sentences in a
passage follow the "\[clause\], \[conjunction\] \[clause\]" structure, first passage follow the "\[clause\], \[conjunction\] \[clause\]" structure,
try removing the conjunction and second clause entirely, since it's often rewrite some of them. Break a few into two sentences. Start some with a
redundant. If the second clause does carry meaning, break it into its own subordinate clause. Embed a relative clause in the middle of one instead of
sentence, start the sentence with a subordinate clause, or embed a relative appending it at the end. The goal is variety in sentence shape, not just
clause in the middle instead of appending it at the end. sentence length.
16. Find every rhetorical question that is immediately followed by its own 14. Find every rhetorical question that is immediately followed by its own
answer and rewrite the passage as a direct statement. answer and rewrite the passage as a direct statement.
17. Find every sentence fragment being used as its own paragraph and either 15. Find every sentence fragment being used as its own paragraph and either
delete it or expand it into a complete sentence that adds information. delete it or expand it into a complete sentence that adds actual
information.
18. Check for unnecessary elaboration. Read every clause, phrase, and adjective 16. Check for unnecessary elaboration at the end of sentences. Read the last
in each sentence and ask whether the sentence loses meaning without it. If clause or phrase of each sentence and ask whether the sentence would lose
you can cut it and the sentence still says the same thing, cut it. any meaning without it. If not, cut it.
19. Check each pair of adjacent sentences to see if they can be merged into one 17. Find every pivot paragraph ("But here's where it gets interesting." and
sentence cleanly. If a sentence just continues the thought of the previous similar) and delete it. The paragraph after it always contains the actual
one, combine them using a participle, a relative clause, or by folding the point.
second into the first. Don't merge if the result would create a two-clause
compound.
20. Find every pivot paragraph ("But here's where it gets interesting." and
similar) and delete it.
### Pass 3: Paragraph and Section-Level Review ### Pass 3: Paragraph and Section-Level Review
21. Review the last sentence of each paragraph. If it restates the point the 18. Check paragraph lengths across the piece and verify they actually vary. If
paragraph already made, delete it.
22. Check paragraph lengths across the piece and verify they actually vary. If
most paragraphs have between three and five sentences, rewrite some to be most paragraphs have between three and five sentences, rewrite some to be
one or two sentences and let others run to six or seven. one or two sentences and let others run to six or seven.
23. Check section lengths for suspicious uniformity. If every section is roughly 19. Check section lengths for suspicious uniformity. If every section is roughly
the same word count, combine some shorter ones or split a longer one the same word count, combine some shorter ones or split a longer one
unevenly. unevenly.
24. Check the first word of every paragraph for chains of connectors ("However," 20. Check the first word of every paragraph for chains of connectors ("However,"
"Furthermore," "Moreover," "Additionally," "That said"). If more than two "Furthermore," "Moreover," "Additionally," "That said"). If more than two
transition words start consecutive paragraphs, rewrite those openings to transition words start consecutive paragraphs, rewrite those openings to
start with their subject. start with their subject.
25. Check whether every argument is followed by a concession or qualifier. If 21. Check whether every argument is followed by a concession or qualifier. If
the piece both-sides every point, pick a side on at least some of them and the piece both-sides every point, pick a side on at least some of them and
cut the hedging. cut the hedging.
26. Read the first paragraph and ask whether deleting it would improve the 22. Read the first paragraph and ask whether deleting it would improve the
piece. If it's scene-setting that previews the argument, delete it and start piece. If it's scene-setting that previews the argument, delete it and start
with paragraph two. with paragraph two.
27. Read the last paragraph and check whether it restates the thesis or uses a 23. Read the last paragraph and check whether it restates the thesis or uses a
phrase like "at the end of the day" or "moving forward." If so, either phrase like "at the end of the day" or "moving forward." If so, either
delete it or rewrite it to say something the piece hasn't said yet. delete it or rewrite it to say something the piece hasn't said yet.
### Pass 4: Overall Texture ### Pass 4: Overall Texture
28. Read the piece aloud and listen for passages that sound too smooth, too 24. Read the piece aloud and listen for passages that sound too smooth, too
even, or too predictable. Human prose has rough patches. If there aren't even, or too predictable. Human prose has rough patches. If there aren't
any, the piece still reads as machine output. any, the piece still reads as machine output.
29. Check that the piece contains at least a few constructions that feel 25. Check that the piece contains at least a few constructions that feel
idiosyncratic: a sentence with unusual word order, a parenthetical that goes idiosyncratic: a sentence with unusual word order, a parenthetical that goes
on a bit long, an aside only loosely connected to the main point, a word on a bit long, an aside only loosely connected to the main point, a word
choice that's specific and unexpected. choice that's specific and unexpected. If every sentence is clean and
correct and unremarkable, it will still read as generated.
30. Verify that you haven't introduced new patterns while fixing the original 26. Verify that you haven't introduced new patterns while fixing the original
ones. Run the entire checklist again from the top on the revised version. ones. This happens constantly. Run the entire checklist again from the top
on the revised version.
--- ---
@@ -441,9 +499,16 @@ roughly like this:
> >
> **model:** _(rewrites entire document without em-dashes while describing > **model:** _(rewrites entire document without em-dashes while describing
> em-dash overuse)_ > em-dash overuse)_
>
> **human:** now run the checklist methodically on each paragraph
>
> **model:** _(finds staccato burst in the section about triple constructions, a
> triple in the section about absence of mess, two-clause compounds everywhere,
> and "almost" hedges in its own prose about em-dash overuse)_
The human compared this process to the deleted scene in Terminator 2 where John The human compared this process to the deleted scene in Terminator 2 where John
Connor switches the T-800's CPU to learning mode. The model compared it to a Connor switches the T-800's CPU to learning mode. The model compared it to a
physician trying to heal itself. Both are accurate. physician trying to heal itself. Both are accurate.
This document has been through ten editing passes and it still has tells in it. This document has been through eight editing passes and it still has tells in
it.

View File

@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-02-22
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -35,11 +35,7 @@ Template files can be fetched from:
- [ ] `.gitignore` — fetch from - [ ] `.gitignore` — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, extend for `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, extend for
language-specific artifacts. Extensions are written to `.gitignore`'s own language-specific artifacts
semantics, where an unanchored pattern already matches at every depth:
never add a `**/` prefix here, which is a `.dockerignore` form. The
canonical file already carries `.claude/` so agent worktrees cannot be
committed by accident.
- [ ] `.editorconfig` — fetch from - [ ] `.editorconfig` — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Makefile` — fetch from - [ ] `Makefile` — fetch from
@@ -56,34 +52,12 @@ Template files can be fetched from:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- Extend `.dockerignore` with the repo's own host-built artifacts, giving - All Dockerfiles must run `make check` as a build step
every depth-independent pattern a `**/` prefix — but write a repo-root
binary anchored, `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory. Do not transplant
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. See the `.dockerignore` rule in
`REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for
the same reason as a repo-root binary; leave it that way, but note it only
covers agents running at the repo root — if this repo will run them in
subdirectories, `services/api/.claude/` is not excluded and needs its own
anchored entry.
- If the image embeds a version in a binary, the version is computed on the
host and passed with `--build-arg VERSION=...`. `ARG VERSION=dev` is
declared in the stage that compiles, and **no stage calls `git describe`**
`.dockerignore` excludes `.git`, so it yields an empty version without
failing the build.
- All Dockerfiles must run `make check` as a build step, and every stage
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- Server: also builds and runs the application - Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check` - Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs - [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference `docker build .` on push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
- [ ] Language-specific: - [ ] Language-specific:
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from - [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
@@ -91,72 +65,15 @@ Template files can be fetched from:
- [ ] JS: `yarn init`, `yarn add --dev prettier` - [ ] JS: `yarn init`, `yarn add --dev prettier`
- [ ] Python: `pyproject.toml` - [ ] Python: `pyproject.toml`
## Configure script/ Entrypoints and Makefile ## Configure Makefile
Implementations live in `script/` (scripts-to-rule-them-all); Makefile targets - [ ] `make test` — runs real tests, not a no-op (30-second timeout)
are thin shims calling them. Model scripts: - [ ] `make lint` — runs linter
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>` - [ ] `make fmt` — formats code (writes)
- [ ] `make fmt-check` — checks formatting (read-only)
- [ ] scripts are POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run on - [ ] `make check` — prereqs: `test`, `lint`, `fmt-check`; must not modify files
alpine images without bash - [ ] `make docker` — builds Docker image
- [ ] `script/bootstrap` / `make bootstrap` — installs all dependencies, - [ ] `make hooks` — installs pre-commit hook
idempotently, assuming nothing (pkg manager detection nix/apt/brew/apk;
node used if present, else pinned version via nvm from a hash-verified
archive; pinned yarn via corepack); Dockerfile runs it instead of inline
installs
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
timeout)
- [ ] `script/lint` / `make lint` — runs linter
- [ ] Go: exports `GOLANGCI_LINT_CACHE` **and** `TMPDIR` into a
`.lint-cache/` directory inside the checkout, above any
container-versus-host branch so every path that reaches the linter
gets them; passes `--allow-serial-runners` (never
`--allow-parallel-runners`); retries on
`parallel golangci-lint is running` detected on **stderr** and exits
75 with a VOID message on exhaustion. Copy the canonical block from
`REPO_POLICIES.md` rather than writing your own: a version that sets
only the cache leaves the false-red half live, and one that detects
the collision by exit status can retry a real finding away.
- [ ] Go: `.lint-cache/` is in both `.gitignore` and `.dockerignore`
- [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files
- [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); carries the same three
version lines as `script/cibuild` below, and passes
`--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
- [ ] `script/cibuild` — cd to repo root, then, each on its own line:
```sh
epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
(what CI runs). Both build args are mandatory, and both assignments must be
on their own line: a failing command substitution inside an argument does
not trip `set -e`, so the inline form degrades silently to an empty
constant. The `[ -n "$version" ]` line is a live check that fires on an
export with no `.git` and on a repo with no commits — keep it, and do not
collapse it into `|| echo unknown`, which makes it unreachable. See the
`CHECK_EPOCH` and git-describe rules in `REPO_POLICIES.md` for why each
element is load-bearing. A bare `docker build .` fails closed by design.
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit`
- [ ] `make hooks` — shims to `script/install-precommit`
- [ ] README **Entrypoints** section documents the scripts and links the
standard
# 4. Verify # 4. Verify

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-08-09 last_modified: 2026-02-22
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,50 +34,10 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile `make hooks` (installs pre-commit hook). A model Makefile is at
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`,
where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and
`version` is computed on the host because `.git` is not in the build context
(see the git-describe rule below); the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -94,189 +54,14 @@ style conventions are in separate documents:
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green — which requires as a build step so the build fails if the branch is not green. For non-server
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running repos, the Dockerfile should bring up a development environment and run
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies `make check`. For server repos, `make check` should run as an early build
this criterion while its check layers are served from cache, so the build stage before the final image is assembled.
cannot fail on a branch that is not green. For non-server repos, the
Dockerfile should bring up a development environment and run `make check`. For
server repos, `make check` should run as an early build stage before the final
image is assembled. Dockerfiles install development prerequisites by running
`script/bootstrap` rather than duplicating installs inline; COPY `script/` and
the dependency manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`,
etc.) before running it so the bootstrap layer stays cached until dependencies
change.
- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the `RUN make check` layer is served from cache, the suite
never runs, and the build still exits 0. A sub-second `docker build` reporting
success is a cache hit, not a result. The canonical form, in **every** stage
containing a check-running `RUN`:
```dockerfile
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
```
and in both `script/cibuild` and `script/docker`:
```sh
epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
The `VERSION` lines are there for a different reason, covered by the
git-describe rule below; they are shown here so the two rules do not each
document half a command. All four `CHECK_EPOCH` elements are load-bearing;
none is optional, and each guards a failure mode that otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`.
- Expand the value into the command. This makes the cache miss contractual
rather than dependent on BuildKit's handling of an unreferenced `ARG`, and
it puts the epoch in the build log. The guard is itself value-keyed, for
the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the
epoch into that layer's description (rendered as
`RUN [ -n "<epoch>" ] || exit 1`) and re-runs it whenever the value
changes. Each stage therefore has two independent invalidation points, and
the guard always precedes the check `RUN`. Keep both: the expansion is
defence in depth, and it is what makes the epoch visible in the build
output.
- The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is
a stable cache key, so without it a bare `docker build .` still produces
the false green. Failed steps are never cached, so the guard fails on
every such invocation, loudly. A bare `docker build .` failing is by
design.
- Assign `epoch=` on its own line, never inline in the `--build-arg`
argument: a failing command substitution inside an argument does not trip
`set -e`, so the inline form silently degrades to an empty constant. The
`$$` suffix is required because busybox `date` drops `%N` and exits 0, so
on an alpine host the epoch would degrade to second granularity and
concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving
`go mod download`, `script/bootstrap`, and the pinned toolchain install
cached, so it does not push against the five-minute Docker build ceiling.
Blanket `--no-cache` also works but is wasteful and can blow that ceiling.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# VERSION comes from the host via --build-arg; see the git-describe rule
# below. Never run `git describe` here: .dockerignore excludes .git, so
# it yields an empty version without failing the build.
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.**
The cache-bust turns this no-op `COPY` into a content-cache hit, so an
ordering guarantee established on a cold cache does not automatically
carry over; it has to be re-checked warm. This was re-proved in another
repo in the org that uses the same file-dependency trick (there with a
marker file in place of `go.sum`), and the ordering held. It has **not**
been verified in this repo, which is single-stage and has no lint stage to
order against. Any repo relying on a file-dependency trick for stage
ordering should re-check it warm after adopting the bust rather than
assuming this result transfers.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at the last cached result. In each stage the guard sits immediately
below the `ARG` so a bare `docker build .` fails instead of reusing the
empty cache key, and the value is expanded into the first check `RUN` so
the cache miss does not rely on BuildKit's unreferenced-`ARG` handling.
Both of those lines reference `$CHECK_EPOCH`, so both are value-keyed:
each stage is invalidated at two independent points. The later `RUN`s in
the same stage need no expansion of their own: they are already
invalidated by their busted parent layer.
- `ARG VERSION=dev` is declared in the build stage, and its value is
supplied on the host by `script/docker` and `script/cibuild` via
`--build-arg VERSION=...`. The `dev` default is a placeholder for a local
build, not a source of truth. **No stage may call `git describe`**:
`.dockerignore` excludes `.git`, so it yields an empty version without
failing. See the git-describe rule further down.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs runs `docker build .` on push. Since the Dockerfile already runs `make check`,
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`) a successful build implies all checks pass.
on push. The Dockerfile runs `make check`, so a successful build implies all
checks pass — but that implication holds **only** because of the `CHECK_EPOCH`
cache-bust described above. Without it, an unchanged tree serves the check
layer from cache and the build reports a green it never earned. A bare
`docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard;
always go through `script/cibuild` or `script/docker`. Never accept a
`script/cibuild` pass as evidence without confirming it ran: a sub-second wall
time, or `CACHED` on the check layer, means nothing was executed.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -284,11 +69,9 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local - Pre-commit hook: `make check` if local testing is possible, otherwise
testing is not possible in the repo, `script/precommit` may skip `script/test` `make lint && make fmt-check`. The Makefile should provide a `make hooks`
and run only `script/lint` and `script/fmt-check`. The hook is installed by target to install the pre-commit hook.
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -299,42 +82,6 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile. Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes. - Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary - `make check` must not modify any files in the repo. Tests may use temporary
@@ -346,147 +93,10 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`, editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
which holds one worktree — an entire additional checkout of the repo — per Fetch the standard `.gitignore` from
in-flight agent), language build artifacts, and `node_modules/`. Fetch the
standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. These patterns are written to `.gitignore`'s own semantics, in a new repo.
which an unanchored pattern already matches at every depth. They are not a
`.dockerignore` and must not be transplanted into one unmodified — see the
next rule.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: Go `filepath.Match` semantics plus a `**` extension,
compiled to a regexp — plain `filepath.Match` has no `**` at all. So `*` does
not cross `/`, and a pattern without a leading `**/` is anchored at the
build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root; `config/.env` and
`certs/server.key` still reach the context and can land in an image layer.
That file is more dangerous than a short one with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix — `**/node_modules`,
`**/.DS_Store`, and the secret patterns in the canonical file, which are
additionally case-folded per the rule below — and leave only genuinely
root-anchored entries unprefixed: `.git`, the in-repo agent scratch directory
`.claude`, and the repo's own host-built binary. The inverse move is equally
wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a
file that is wrong in a way that looks careful. Each file is written to its
own semantics; neither is derived from the other. Fetch the standard
`.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own host-built artifacts — a host `make build` that leaves
a compiled binary in the repo root puts that binary in the build context,
where `.gitignore` hides it from every git-based check. Write that binary
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so with `COPY . .` the build context
inflates by a multiple of the repo, and another session's unreviewed,
sometimes uncommitted work can be copied into an image layer. The directory is
also created and destroyed constantly, so it invalidates `COPY . .` for
reasons that have nothing to do with the repo's own content. In `.gitignore`
the entry is `.claude/`, unanchored, which already matches at every depth. In
`.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the
directory occurs exactly once **where agents run at the repo root**, and the
prefixed form would also match any nested directory of that name and delete it
from the build. It is not case-folded the way the secret patterns are, because
tooling creates it in exactly one spelling, so a folded pattern would add no
coverage.
**Known gap that comes with the anchored form.** The directory is created in
the agent's working directory, so the "exactly once, at the root" premise is
a property of how agents are run and not of the tooling. Where agents run in
subdirectories — a monorepo with a per-service agent is the ordinary case —
`services/api/.claude/` is **not** excluded by the canonical entry and still
reaches the build context and the image, which is the exposure the entry
exists to close. A repo in that shape adds its own anchored entries
(`/services/api/.claude`), or `**/.claude` once it has confirmed no
legitimately named nested directory would be caught. This is stated in the
canonical `.dockerignore` itself, since that file is what consuming repos
receive.
- **`.dockerignore` matching is case-sensitive, so cover capitalisation with
character classes rather than by doubling patterns.** `**/*.key` does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive
filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not
the fix: it still misses `Server.Key` and `Ca.Pem` while reading as though
case were handled — the same manufactured confidence as the root-anchored
form. The matcher supports character ranges, so one line covers every
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to every secret
name, not only to extensions: the extensionless SSH keys and `.envrc` need it
for the same reason, since on the very filesystems that make `SERVER.KEY`
reachable, direnv reads `.ENVRC` and ssh reads `ID_RSA`. Note that `*` matches
the empty string, so `**/*.[eE][nN][vV]` already covers a bare `.ENV` and no
separate literal `.env` entry is needed.
- **A pattern that also catches something the build needs is re-included with a
negation, not deleted.** The canonical `**/*.[eE][nN][vV]` excludes a
committed env template such as `example.env`; a repo whose build genuinely
reads one adds `!docs/example.env` after the pattern. Deleting the pattern
instead reopens the exposure for every other file it covers.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). Reading the patterns and
agreeing they look right is exactly what lets the root-only form through. The
`transferring context` size is not a substitute: a nested secret is a few
bytes, and BuildKit transfers only the delta from the previous build, so the
reported size describes the transfer and not the contents of the image.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** The `GOLDFLAGS` version-embedding pattern assumes
`.git` is present; in a build stage there is no repository, so `git describe`
writes nothing to stdout and the `-X main.Version=` value comes out **empty**
rather than erroring. The binary then reports no version at all and the build
still exits 0. Compute the version **on the host** and thread it in as a build
arg. `script/docker` and `script/cibuild` do this, byte-identically across
repos:
```sh
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, so the inline form degrades to an
# empty constant — the same silent-empty failure this rule is about.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
`--always` makes an untagged repo yield the abbreviated commit hash instead
of failing. `|| true` keeps a failing `git describe` from tripping `set -e`
and leaves the value empty, so the `[ -n "$version" ]` line is the single
place the fallback is applied — and it is a **live** check, not defence in
depth: it fires on a build from an export with no `.git`, and on a
repository with no commits yet. Do not fold the fallback into the
substitution as `|| echo unknown`; that makes the guard unreachable, and a
guard that cannot fire is indistinguishable from one that works to everyone
who copies it. The result is non-empty by construction either way, which is
the point: an empty version reads as a successful one, while `unknown` is
visibly wrong. The Dockerfile's side is `ARG VERSION=dev` in the stage that
compiles, declared there and not inherited, because `ARG` is stage-scoped
exactly as `CHECK_EPOCH` is. Passing `VERSION` to a repo whose Dockerfile
declares no such `ARG` is silently ignored by BuildKit and costs nothing,
which is why the scripts stay byte-identical rather than growing a per-repo
variant.
One consequence for CI: the standard checkout action clones shallow and
fetches no tags, so `git describe --tags` there falls back to a bare commit
hash. A repo that embeds a tag-derived version must set `fetch-depth: 0` on
its checkout step; a repo that does not embed a version needs no change.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
@@ -497,434 +107,7 @@ style conventions are in separate documents:
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
commit-pinned via
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
- **`script/bootstrap` in Go repos must install the pinned golangci-lint
whenever the installed version does not match the pin — not merely when the
binary is absent — and must then verify the install took effect by
re-resolving the binary through `PATH`.** The presence test
`if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi` is wrong:
it tests `PATH` presence and never version, so on any already-provisioned
machine the pin is inert and a version bump is a no-op. Meanwhile the
Dockerfile installs unconditionally into a clean image, so CI and local
silently disagree about what the linter even is. Observed consequences: a
local `make check` green while `make docker` rejected the same commit with six
`goconst` findings, and a container linter surfacing thirteen findings the
host run missed. A stale host linter does not merely fail to prove the tree is
clean — it hides findings only the container can see. This is a deliberate
departure from the node handling described above, which uses whatever node is
installed: the linter version is the specific thing being held equal between
host and container, so for it, presence is not enough.
Comparing versions is necessary but **not sufficient**, because the obvious
fix also fails green. `go install` writes to `GOBIN` (or `GOPATH/bin`) while
callers resolve `golangci-lint` through `PATH`. If a different binary
shadows it earlier in `PATH`, the install genuinely succeeds and changes
nothing any caller will ever see: bootstrap prints success and the next
`make lint` still runs the stale linter. That is worse than no fix, because
it converts a known-stale toolchain into one everyone believes is pinned.
The canonical form, placed in `script/bootstrap` after Go itself is present:
```sh
# golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly
# what `golangci-lint --version` prints for this ref; update both together.
GOLANGCI_LINT_VERSION="2.12.2"
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# The version golangci-lint reports, resolved the way callers resolve it.
# Prints nothing when the binary is absent, exits non-zero, or prints
# something unparseable: all of those must read as "does not match".
# The capture is the whole version token, not just its numeric prefix.
# Stopping at the first `-` would make 2.12.2-rc1 compare equal to 2.12.2
# and skip the install, which is the defect this whole rule exists to close.
# The trailing `|| true` is required, not tidiness. Under `set -o pipefail`
# a non-zero --version would otherwise propagate out of the pipeline and
# kill the script through `set -e` before the diagnostic below is printed.
golangci_lint_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
golangci-lint --version 2>/dev/null | head -n 1 |
sed -n 's/.*has version v\{0,1\}\([0-9][^ ]*\).*/\1/p' || true
}
ensure_golangci_lint() {
if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed"
return 0
fi
echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
go install "$GOLANGCI_LINT_REF"
# go install writes to GOBIN (or GOPATH/bin); callers resolve through
# PATH. Re-resolve through PATH and assert the install took effect.
# `hash -r` is load-bearing: without it a shell that already resolved
# a stale golangci-lint answers from its own lookup cache, and this
# check false-fails with the shadowing message below.
hash -r 2>/dev/null || true
gcl_got="$(golangci_lint_version)"
if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \
"and PATH resolves it"
return 0
fi
gcl_bin="$(go env GOBIN)"
[ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin"
# Strip a trailing slash: GOBIN=/x/ would otherwise make the
# "$gcl_bin"/* test below miss and misreport shadowing.
while :; do
case "$gcl_bin" in
*/) gcl_bin="${gcl_bin%/}" ;;
*) break ;;
esac
done
gcl_found="$(command -v golangci-lint 2>/dev/null || true)"
echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
"$gcl_bin, but that is not what callers will get." >&2
case "$gcl_found" in
"")
echo "bootstrap: PATH resolves no golangci-lint at all." \
"Add $gcl_bin to PATH, then re-run bootstrap." >&2
;;
"$gcl_bin"/*)
echo "bootstrap: PATH resolves $gcl_found, inside that same" \
"directory, reporting version ${gcl_got:-unparseable}." \
"Nothing is shadowing it, so the install itself did not" \
"produce the pinned version: check that" \
"GOLANGCI_LINT_VERSION matches GOLANGCI_LINT_REF." >&2
;;
*)
echo "bootstrap: PATH resolves $gcl_found instead, reporting" \
"version ${gcl_got:-unparseable}. Remove that binary or" \
"put $gcl_bin earlier in PATH, then re-run bootstrap." >&2
;;
esac
exit 1
}
# The definitions above are inert on their own; the call site is part of
# the canonical form. In a script/bootstrap that follows the "define all
# functions, then call main" convention, this line belongs inside main()
# next to the other ensure_* steps.
ensure_golangci_lint
```
Four properties are load-bearing; each guards a failure mode that otherwise
fails green:
- **Compare the installed version against the pin**, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint. Compare the **whole** version token, exactly: a parser
that stops at the first `-` reports `2.12.2` for a host running
`2.12.2-rc1`, which compares equal to a `2.12.2` pin and skips the install
— the original defect, reintroduced through the comparison meant to fix
it.
- **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the path `go install` wrote to — and assert
`--version` reports the pin. When it does not, fail non-zero and name the
path `command -v` actually found, the version it reports, and the
directory the install wrote to. That is a condition a human has to fix by
hand, so bootstrap must not print success in it. Use `hash -r` first so
the shell does not answer from its own lookup cache. Diagnose the cause
from the resolved path rather than asserting one: only a path **outside**
the install directory is shadowing. When the resolved path is inside it,
nothing is shadowing and telling the operator to delete that binary or
reorder `PATH` sends them after a fault that does not exist.
- **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output, and unrecognised output all
yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** Two function definitions with no call
site are a silent no-op that reproduces the original defect exactly: exit
0, nothing installed, no output, stale linter still resolved. A success
path that prints nothing is byte-identical to that no-op — same exit
status, same empty output — so both success branches must print a
confirmation naming the version. In a change about undetectable no-ops,
"it printed nothing and exited 0" must not be the healthy signal.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
**On the hash-pinning rule.** `@c0d3ddc9cf3faa61a4e378e879ece580256d76e5` is
a commit hash, not a server-mutable version tag, and the go command verifies
the fetched module against the checksum database — the mechanism the
hash-pinning rule at the top of this document already names as acceptable
for Go modules. Note that `go install pkg@version` runs in module-aware mode
ignoring the `go.mod` in the current directory or any parent, so no repo
`go.sum` is consulted for this install; the checksum database is what
verifies it. The linter is a bootstrap prerequisite rather than part of any
repo's module graph, which is why the canonical form installs it by
commit-pinned ref instead of declaring it in `go.mod`. Whether a `go.mod`
tool dependency — which would pin the hash in a committed, reviewable file
instead — should replace this is an open decision, tracked at
[prompts#37](https://git.eeqj.de/sneak/prompts/issues/37).
**Keep `GOLANGCI_LINT_VERSION` and the ref in sync.** The ref is a hash and
carries no readable version, so the expected version is a separate string,
and it must be exactly what `--version` prints for that ref — the comparison
is an exact match on the whole version token. When the pinned commit carries
a release tag the go command resolves the hash to that tag, so the string is
simply the release number, `2.12.2` here. When it does not, the go command
falls back to a pseudo-version and the binary reports something like
`2.12.3-0.20260506110758-c0d3ddc9cf3f`; that compares exactly like any other
string, so it works, but it cannot be known without building the binary once
and reading `--version` off it. Prefer pins on tagged releases for that
reason — the expected string is then derivable from the ref — not because
the comparison cannot handle the alternative.
Because the comparison covers the whole token, a pre-release is never
confused with its release: a host carrying `2.12.2-rc1` against a `2.12.2`
pin compares unequal and gets reinstalled. This matters more than it looks,
because a pre-release tag is still a tag, so a rule requiring merely that
the pin be tagged would not catch it.
**Verifying a change to this logic requires a negative control run in an
environment where a shadowing binary exists earlier in `PATH` than the
install target.** Without that, the control passes against the naive
compare-then-install form as well and therefore proves nothing. Also check
the mis-parse direction by feeding it unparseable `--version` output and
confirming it reinstalls rather than reporting a match.
**Run those controls against the block as a consuming repo would adopt it**
— pasted into a `script/bootstrap`-shaped file that is then executed — not
by sourcing it and invoking the function yourself. Driving the function
directly tests something the artifact does not do, and it is exactly how a
missing call site passes every control while the adopted snippet does
nothing.
- **`script/lint` in Go repos must give golangci-lint per-checkout cache and
lock state, and must never report a lock collision as a lint result.**
golangci-lint shares two pieces of state across every process on the host, and
they are separate mechanisms with separate fixes. Isolating one and stopping
leaves the other fully live while reading as a fix. This is independent of the
pinned-install rule above and does not replace it: that one makes the host run
the right linter, this one makes the run's result belong to your own tree.
**Mechanism 1, the result cache — produces false greens as well as false
reds.** golangci-lint keys cached results on file **content, not location**,
so two checkouts of the same commit hold byte-identical files, share cache
entries, and one tree's findings are served for the other — reported at the
_other_ tree's path. Observed across the org: 399 issues attributed to a
`/tmp` worktree that no longer existed, returned from a clean clone that
genuinely lints 0 issues; ten findings against a deleted worktree; findings
reported against `../wt82-lint/...`; and, in the dangerous direction, an
implementer reporting "lint 0 issues" on a branch that was genuinely red.
Note what content-keying implies: **moving agents from worktrees to their
own clones does not help.** Two clones of a repo are byte-identical exactly
as two worktrees were. What own-clones removes is the deleted-worktree path
artefact — the loud, obviously wrong symptom — while leaving the mechanism
live, which makes the defect quieter rather than rarer.
**Mechanism 2, the concurrency lock — and it does not live in the cache
directory.** From `pkg/commands/run.go`, `acquireFileLock()`:
```go
lockFile := filepath.Join(os.TempDir(), "golangci-lint.lock")
```
That is `$TMPDIR/golangci-lint.lock` — host-global, keyed on the temp
directory, entirely independent of `GOLANGCI_LINT_CACHE`. It is an `flock`
retried every second under a **5-second total timeout**, so it fails
precisely when the host is busiest. On failure the run emits
`parallel golangci-lint is running` and analyzes nothing. **A private cache
directory does not prevent this**; that was established by controlled test,
with two concurrent runs under separate cache directories sharing no mounted
path, one of which still collided. Anyone who sets only
`GOLANGCI_LINT_CACHE` has closed the contamination half and left the
false-red half untouched.
The canonical form for a Go repo's `script/lint`:
```sh
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Per-checkout golangci-lint state. Both variables are required and they
# fix different defects; neither is redundant with the other.
#
# GOLANGCI_LINT_CACHE: the result cache is keyed on file content, not
# location, so checkouts holding identical files serve each other's
# findings. A per-REPO cache directory does NOT fix this — every checkout
# of that repo still collides — so the path must be inside the invoking
# checkout.
#
# TMPDIR: golangci-lint flocks $TMPDIR/golangci-lint.lock
# (pkg/commands/run.go, acquireFileLock: filepath.Join(os.TempDir(),
# "golangci-lint.lock")). That lock is host-global and independent of
# GOLANGCI_LINT_CACHE, with a 5s acquire timeout. Scoping TMPDIR into the
# checkout is the only thing here that isolates it. Do not delete this as
# redundant with the cache variable; it is not.
#
# The leading dot in .lint-cache is load-bearing: the go tool skips
# dot-prefixed directories when expanding ./..., so the linter never reads
# its own cache and temp files back as source. Do not rename it.
LINT_STATE="$ROOT/.lint-cache"
GOLANGCI_LINT_CACHE="$LINT_STATE/cache"
TMPDIR="$LINT_STATE/tmp"
export GOLANGCI_LINT_CACHE TMPDIR
mkdir -p "$GOLANGCI_LINT_CACHE" "$TMPDIR"
# Backstop for a caller that reached the linter without the environment
# above. With it set, this should never fire.
LINT_MAX_ATTEMPTS=5
# EX_TEMPFAIL. Distinct from 1 (findings) and 3 (linter error) so a void
# run is never counted as either.
LINT_VOID_EXIT=75
golangci_lint_run() {
lint_out="$LINT_STATE/run.stdout"
lint_err="$LINT_STATE/run.stderr"
attempt=1
delay=2
while :; do
rc=0
# --allow-serial-runners KEEPS the mutual-exclusion guard and makes
# an overlapping run queue on the lock instead of aborting after
# 5s. It is NOT --allow-parallel-runners, which removes the guard
# entirely; never use that one. This is what covers two runs inside
# the SAME checkout, which TMPDIR scoping cannot — script/precommit
# overlapping a make check is the realistic trigger.
golangci-lint run --allow-serial-runners "$@" \
>"$lint_out" 2>"$lint_err" || rc=$?
# Detect the lock collision on the STDERR STREAM, never on the exit
# status. Findings are written to stdout and golangci-lint reports
# this failure only on stderr, so a finding that quotes the string
# from source cannot be mistaken for a collision and retried away —
# that direction would be a false green. The exit status is not a
# usable discriminator: the collision exits 3 (exitcodes.Failure)
# while findings exit 1, and field reports of 2 mean the value is
# not stable across versions.
if ! grep -q 'parallel golangci-lint is running' "$lint_err"; then
cat "$lint_err" >&2
cat "$lint_out"
return "$rc"
fi
if [ "$attempt" -ge "$LINT_MAX_ATTEMPTS" ]; then
cat "$lint_err" >&2
echo "lint: VOID after $LINT_MAX_ATTEMPTS attempts:" \
"golangci-lint never acquired its lock, so nothing was" \
"analyzed. This is NOT a lint result and no verdict may" \
"be recorded from it. Re-run it." >&2
return "$LINT_VOID_EXIT"
fi
echo "lint: lock held by another golangci-lint; attempt" \
"$attempt of $LINT_MAX_ATTEMPTS, retrying in ${delay}s" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
main() {
cd "$ROOT"
golangci_lint_run ./...
}
main "$@"
```
Load-bearing properties, each guarding a mode that otherwise reports a
verdict it did not earn:
- **Both variables, per checkout.** Cache alone leaves the false reds;
`TMPDIR` alone leaves the contamination that produced a confirmed false
green. A per-repo path for either is not isolation on a host where every
worker holds its own copy of the same repo.
- **Set them on every path that reaches the linter.** The export block goes
_above_ any container-versus-host branch, and a native escape hatch must
call `golangci_lint_run` rather than `exec golangci-lint` directly. One
repo in the org had exactly one such path with no cache environment at
all, inheriting the fleet-wide default, so the fix on the other path was
worth nothing there.
- **The lock collision is not a result, and must never be reported as one.**
Retry it, and on exhaustion exit a status that is neither the findings
status nor success, with a message that says VOID. Swallowing it into a
success is the worst available outcome; reporting it as findings sends a
correct branch back for rework against findings that do not exist.
- **Detect the collision by the message on stderr, not by exit status, and
never retry a genuine finding.** Distinguishing "exited non-zero because
of the lock" from "exited non-zero because of findings" is the whole crux,
and getting it wrong in the direction of treating findings as a lock error
retries a real failure into a void — or, if a later implementation decided
to treat exhaustion as success, into a green.
- **`--allow-serial-runners`, never `--allow-parallel-runners`.** The first
keeps the guard and queues; the second deletes it and lets two runs
corrupt shared state. With the flag set, an overlapping run inside the
same checkout waits rather than failing, which is what is actually wanted.
The honest cost is that it waits without bound, so a stale process holding
the lock hangs the run instead of failing it; the contending set is
bounded to the same checkout, and an eventual result is preferable to a
fabricated one.
Adopting repos must add `.lint-cache/` to both `.gitignore` and
`.dockerignore`. The second matters as much as the first: the directory
reaches tens of megabytes, and without the exclusion it enters the build
context and invalidates `COPY . .` for reasons unrelated to the repo's
content.
**`GOCACHE` does not need isolating, and this was measured rather than
assumed.** With `GOLANGCI_LINT_CACHE` and `TMPDIR` per checkout and
`GOCACHE` left at the host default and shared, two checkouts of identical
content each reported their own paths and neither reported the other's. The
Go build cache is content-addressed and its entries are compiled artifacts
rather than diagnostics carrying a foreign tree's paths, and it has no
equivalent global lock — the whole fleet compiles concurrently against one
`GOCACHE` all day without a contention error. Isolating it would cost a full
cold compile per checkout for no measured benefit. The earlier hypothesis
that Go build-cache contention might explain the lock error is superseded:
the lock is located in source at `$TMPDIR/golangci-lint.lock`.
**Verifying a change to this logic requires a negative control, and the
control must be built out of checkouts with identical content.** Create two
checkouts of the same tree containing a deliberate lint finding, run the
linter in the second so it populates the cache, then run it in the first and
confirm the finding is reported at the first checkout's own path and never
at the second's. Run the same control against the unisolated form and
confirm the contamination appears there — a control that passes against the
broken implementation proves nothing. Note specifically that a control built
from checkouts whose **content differs** passes against the unisolated form
too, because differing content does not collide in a content-keyed cache, so
it is not a test of anything. For the lock half, hold
`$TMPDIR/golangci-lint.lock` with `flock` and confirm the run queues rather
than aborting, that a caller never sees the collision as findings, and that
exhaustion fails loudly and distinguishably.
**Run those controls against the block as a consuming repo would adopt it**
— pasted into a `script/lint`-shaped file that is then executed, not sourced
with the functions driven by hand. The same warning as for the bootstrap
block above, for the same reason.
- **Interim rule for reading a golangci-lint result on a shared host, until
every repo has adopted the isolation above.** A lint run is **VOID** unless
both hold:
- the output contains no `parallel golangci-lint is running`, and
- no reported file path begins with `../`, and none is an absolute path
outside the tree the run was launched from.
Do not record a verdict from a void run, and do not "fix" findings in files
the change does not touch — chasing phantom findings across untouched files
puts unrelated edits into a reviewed diff, which is more expensive than the
wasted rework.
The `../` clause is the one that actually bites, and it is why a filter
keyed on `/tmp` or on absolute prefixes is not enough: golangci-lint reports
paths relative to its own resolved root rather than yours, and three of the
org's reported sightings had relative paths and would have passed such a
filter. Both clauses are needed and neither alone is sufficient — one
reproduction exited non-zero with the lock error and no foreign paths at
all, and another reported 34 well-formed findings, every one of them against
another checkout.
**State the limit of these tests rather than treating them as a guarantee.**
They catch contamination that **names** foreign files. They cannot catch
contamination that **suppresses** findings through a poisoned entry for
colliding content, which has no wall-clock tell either. They are a filter
for the loud mode, not a proof of soundness — which is the whole argument
for fixing this in the tooling instead of documenting a discipline that
depends on every agent remembering to apply it.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -938,76 +121,12 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with - Dockerized web services listen on port 8080 by default, overridable with
`PORT`. `PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -1062,9 +181,6 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`

View File

@@ -1,136 +0,0 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete"
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

View File

@@ -1,33 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but
# that only proves anything because CHECK_EPOCH is a fresh nonce on every
# invocation: without it Docker serves the check layer from cache on an
# unchanged tree and the build exits 0 without running the suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
}
main "$@"

View File

@@ -1,35 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. The
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"

View File

@@ -1,16 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Linting markdown files..."
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/check"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "prompts"
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "No tests defined."
}
main "$@"