style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)

Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-04 15:58:06 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a5154ee884
commit ec27228a8e
1588 changed files with 28388 additions and 10890 deletions
@@ -9,15 +9,15 @@ import { requireCompanyId } from '@/lib/company/context'
// touching storage (no signed URL creation per poll).
//
// Derived status:
// running — extracted_at IS NULL (pipeline hasn't stamped yet)
// succeeded — extracted_at IS NOT NULL AND extracted_data IS NOT NULL
// unsupported — extraction_model = 'skipped:*' (HEIC, ZIP, …)
// failed — extracted_at IS NOT NULL AND extracted_data IS NULL AND
// running : extracted_at IS NULL (pipeline hasn't stamped yet)
// succeeded : extracted_at IS NOT NULL AND extracted_data IS NOT NULL
// unsupported : extraction_model = 'skipped:*' (HEIC, ZIP, …)
// failed : extracted_at IS NOT NULL AND extracted_data IS NULL AND
// extraction_model = 'failed:*'
// disabled — the document-extraction extension isn't enabled (column
// disabled : the document-extraction extension isn't enabled (column
// stays untouched indefinitely). Client times out and shows
// a quiet fallback. We don't distinguish this from running
// server-side — the client decides based on elapsed time.
// server-side: the client decides based on elapsed time.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
+3 -3
View File
@@ -10,7 +10,7 @@ import { createServiceClient } from '@/lib/supabase/server'
* an <iframe> or <img> tag.
*
* Supabase Storage signed URLs return `Content-Disposition: attachment`,
* which browsers refuse to render inline — that triggers the
* which browsers refuse to render inline: that triggers the
* "Det här innehållet har blockerats" error in journal entry previews.
*
* Defense in depth: the user's cookie-bound client authorizes access
@@ -31,7 +31,7 @@ const EXTENSION_MIME_MAP: Record<string, string> = {
* `mime_type = null` or `application/octet-stream` (browsers sometimes
* report empty File.type for files dragged from certain sources). Combined
* with the new `X-Content-Type-Options: nosniff` header on this route,
* that broke Chrome's PDF viewer for older rows — the plugin would load
* that broke Chrome's PDF viewer for older rows: the plugin would load
* via <object type="application/pdf"> but refuse to parse a response
* served as octet-stream. Falling back to the file extension covers every
* legacy row without a DB backfill.
@@ -95,7 +95,7 @@ export async function GET(
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
'Content-Disposition': `inline; filename="${safeFileName}"`,
'Cache-Control': 'private, max-age=300',
// Block MIME sniffing — Content-Type is derived from DB metadata
// Block MIME sniffing: Content-Type is derived from DB metadata
// (with extension fallback for legacy rows), never from response
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
@@ -25,7 +25,7 @@ import { validateDocumentMagicBytes } from '@/lib/core/documents/document-servic
import { NextResponse } from 'next/server'
// v4 UUIDs (variant 'a' / 'b' in the 4th group) so Zod's stricter validators
// accept them — the looser `2222...` style fails on the variant check.
// accept them: the looser `2222...` style fails on the variant check.
const mockUser = { id: '11111111-1111-4111-a111-111111111111' }
const validDocId = '22222222-2222-4222-a222-222222222222'
const companyId = '33333333-3333-4333-a333-333333333333'
@@ -159,7 +159,7 @@ describe('GET /api/documents/[id]/integrity', () => {
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.valid).toBe(false)
// The whole point of the V1.2.5 / Art 25(2) hardening — internal text
// The whole point of the V1.2.5 / Art 25(2) hardening: internal text
// never appears in the response.
expect(body.data.reason).toBeUndefined()
expect(JSON.stringify(body)).not.toContain('storage/v1/object')
@@ -194,7 +194,7 @@ describe('GET /api/documents/[id]/integrity', () => {
it('does not import the service-role supabase client', async () => {
// The download must go through the per-request user-scoped client so
// RLS on storage.objects can act as defense-in-depth. Statically
// verifying the source is the cleanest check — runtime mocking of the
// verifying the source is the cleanest check: runtime mocking of the
// service-client export would not catch a future regression where
// someone added an import but conditionally used it.
const fs = await import('node:fs/promises')
@@ -208,7 +208,7 @@ describe('GET /api/documents/[id]/integrity', () => {
// The route's first `from('document_attachments')` chain must include
// `.eq('is_current_version', true)` so superseded versions cannot have
// their bytes probed via this surface. Source-level check rather than
// runtime spying — the proxy-based queued mock collapses every chained
// runtime spying: the proxy-based queued mock collapses every chained
// method into the same handler, so introspecting individual .eq calls
// is not feasible without rebuilding the mock.
const fs = await import('node:fs/promises')
+4 -4
View File
@@ -12,7 +12,7 @@ const ParamsSchema = z.object({ id: z.string().uuid() })
* GET /api/documents/:id/integrity
*
* Probes the actual stored bytes against the declared MIME type. Used by the
* Bilagor modal to surface a clear "this file is corrupt — please re-upload"
* Bilagor modal to surface a clear "this file is corrupt: please re-upload"
* warning instead of relying on the browser's PDF viewer error UI, which
* only fires after the user has already tried to view the file.
*
@@ -21,7 +21,7 @@ const ParamsSchema = z.object({ id: z.string().uuid() })
* after those rows were written. This endpoint lets the UI detect and steer
* the user toward replacing them.
*
* Response shape is intentionally minimal — { valid: boolean } only. The
* Response shape is intentionally minimal: { valid: boolean } only. The
* reason for an invalid result is logged server-side rather than returned
* to the client to avoid information disclosure (V1.2.5 / GDPR Art 25(2))
* and to keep this from being a probe surface for storage internals.
@@ -91,8 +91,8 @@ export async function GET(
}
// Only the first 16 bytes are needed for magic-byte detection (PDF/PNG
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth — the
// full blob is already downloaded — but it makes the intent explicit and
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth (the
// full blob is already downloaded) but it makes the intent explicit and
// keeps memory churn off the hot path for large PDFs.
const headerBuffer = await blob.slice(0, 16).arrayBuffer()
const magicError = validateDocumentMagicBytes(headerBuffer, doc.mime_type)
@@ -30,7 +30,7 @@ import { NextResponse } from 'next/server'
const mockUser = { id: 'user-1', email: 'test@test.se' }
// Body fields are uuid-validated (LinkDocumentSchema) — request fixtures must
// Body fields are uuid-validated (LinkDocumentSchema): request fixtures must
// be real UUIDs. The enqueue mock rows keep their short ids; the queued mock
// doesn't correlate request input with mocked output.
const JE_ID = '11111111-1111-4111-8111-111111111111'
@@ -152,7 +152,7 @@ describe('POST /api/documents/[id]/link', () => {
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
})
it('tolerates a failing transaction pin — the JE link is the primary effect', async () => {
it('tolerates a failing transaction pin: the JE link is the primary effect', async () => {
enqueue({ data: { id: 'je-1' } }) // journal entry company check
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
enqueue({ data: null, error: { message: 'rls denied' } }) // transaction pin fails
@@ -199,7 +199,7 @@ describe('POST /api/documents/[id]/link', () => {
})
it('rejects a journal entry outside the active company with DOC_LINK_ENTRY_NOT_FOUND', async () => {
// The company-scoped lookup finds no row — same result whether the id is
// The company-scoped lookup finds no row: same result whether the id is
// bogus or belongs to another tenant. The document must never be updated.
enqueue({ data: null }) // journal entry company check → no match
const res = await POST(
+6 -6
View File
@@ -8,13 +8,13 @@ import { LinkDocumentSchema } from '@/lib/api/schemas'
ensureInitialized()
/**
* POST /api/documents/[id]/link — link a document to a journal entry.
* POST /api/documents/[id]/link: link a document to a journal entry.
*
* Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string, transaction_id?: string }
*
* When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox
* item is stamped with the verifikat id after a successful link so it drops out
* of the active inbox into "Bokförda" — reusing the inbox's own
* of the active inbox into "Bokförda": reusing the inbox's own
* created_journal_entry_id lifecycle. The document link is the legally-relevant
* write and happens first; the inbox stamp is operational housekeeping, so a
* stamp failure is logged but does not fail the request (the doc is correctly
@@ -23,7 +23,7 @@ ensureInitialized()
* When `transaction_id` is supplied (booking-flow callers that link underlag
* right after booking a bank transaction), the doc is also pinned to the
* transaction row (transactions.document_id) so the /transactions list shows
* the underlag indicator. Only set when the tx has no pin yet — first linked
* the underlag indicator. Only set when the tx has no pin yet: first linked
* doc wins, and an existing räkenskapsinformation pin is never swapped (which
* would trip the immutability trigger). Same best-effort posture as the inbox
* stamp: a failure is logged but does not fail the request.
@@ -64,13 +64,13 @@ export const POST = withRouteContext(
.update({ created_journal_entry_id: body.journal_entry_id })
.eq('id', body.inbox_item_id)
.eq('company_id', companyId!)
// Only stamp the inbox item that actually owns this document — a
// Only stamp the inbox item that actually owns this document: a
// mismatched pairing becomes a safe no-op rather than mis-marking an
// unrelated item as consumed.
.eq('document_id', id)
.select('id')
if (inboxError) {
// Non-fatal — the verifikat ↔ underlag link already succeeded.
// Non-fatal: the verifikat ↔ underlag link already succeeded.
opLog.warn('inbox item stamp after link failed', {
inboxItemId: body.inbox_item_id,
reason: inboxError.message,
@@ -96,7 +96,7 @@ export const POST = withRouteContext(
// for multi-doc bookings and avoids the BFL immutability trigger.
.is('document_id', null)
if (pinError) {
// Non-fatal — the verifikat ↔ underlag link already succeeded; the
// Non-fatal: the verifikat ↔ underlag link already succeeded; the
// pin is row-level UX on the /transactions list.
opLog.warn('transaction pin after link failed', {
transactionId: body.transaction_id,
+1 -1
View File
@@ -72,7 +72,7 @@ export async function GET(
/**
* DELETE /api/documents/:id
* Remove an uploaded document. Only permitted when the document is not yet
* linked to a journal entry — once linked, it is räkenskapsinformation under
* linked to a journal entry: once linked, it is räkenskapsinformation under
* BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller
* should use POST /api/documents/:id/versions to supersede via a new version.
*/
+4 -4
View File
@@ -7,15 +7,15 @@ import type { InvoiceExtractionResult } from '@/types'
ensureInitialized()
/**
* GET /api/documents/inbox-available — list invoice-inbox documents that are
* GET /api/documents/inbox-available: list invoice-inbox documents that are
* available to attach as underlag to a verifikat.
*
* Returns only *unconsumed* inbox items: those that have a file but have not
* yet become a supplier invoice, a direct journal entry, or been matched to a
* bank transaction — and whose underlying document is not already linked to a
* bank transaction, and whose underlying document is not already linked to a
* verifikation. This mirrors the inbox's own "Att göra" set, narrowed to items
* with an attachable file. Re-pointing an already-linked document is forbidden
* (BFL 7 kap — räkenskapsinformation is immutable), so those are excluded here
* (BFL 7 kap: räkenskapsinformation is immutable), so those are excluded here
* and the DB immutability trigger is the backstop.
*
* `invoice_inbox_items` is a core table, so a core route may read it directly
@@ -43,7 +43,7 @@ interface DocRow {
export const GET = withRouteContext('document.inbox_available', async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
// 1) Eligible inbox items — company-scoped (defense in depth alongside RLS),
// 1) Eligible inbox items: company-scoped (defense in depth alongside RLS),
// unconsumed, with a document.
const { data: inboxRows, error: inboxError } = await supabase
.from('invoice_inbox_items')
+3 -3
View File
@@ -8,7 +8,7 @@ import type { DocumentUploadSource } from '@/types'
ensureInitialized()
/**
* POST /api/documents — upload a document to the WORM archive.
* POST /api/documents: upload a document to the WORM archive.
*
* multipart/form-data:
* file: the document file
@@ -72,7 +72,7 @@ export const POST = withRouteContext(
})
}
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
// client problem, not a storage failure — surface as 400 with an
// client problem, not a storage failure: surface as 400 with an
// accurate message instead of the misleading "kunde inte sparas".
if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) {
opLog.warn('document upload rejected by content validation', { reason: message })
@@ -91,7 +91,7 @@ export const POST = withRouteContext(
)
/**
* GET /api/documents — list documents.
* GET /api/documents: list documents.
*
* Query params:
* journal_entry_id: filter by JE
+1 -1
View File
@@ -4,7 +4,7 @@ import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/documents/verify/cron — weekly Sunday 03:00 UTC.
* GET /api/documents/verify/cron: weekly Sunday 03:00 UTC.
* Spot-checks WORM archive integrity by recomputing SHA-256 for the next
* batch of documents and writing INTEGRITY_FAILURE rows to the audit log
* for any mismatches.