@ingram-tech/nk-dev 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  The nextkit **dev toolchain in one package**. Everything a site needs at
4
4
  development time — and nothing that ships to production — lives here:
5
5
 
6
- - the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `ast-grep` / `check` / `type-check` / `test` / `build`, plus `nk doctor`);
6
+ - the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `ast-grep` / `migrations` / `check` / `type-check` / `test` / `build`, plus
7
+ `nk doctor`);
7
8
  - the shared **oxlint + oxfmt**, **TypeScript**, and **Vitest** config;
8
9
  - **knip** (unused dependency / export / file detection), bundled and run by `nk check`;
9
10
  - the **oxfmt format-on-commit** git hook (`nextkit-format-staged`);
@@ -80,7 +81,8 @@ tsc), so versions stay under each site's control — nk just orchestrates.
80
81
  skips files that already exist.
81
82
  - **`nk doctor [--fix]`** — report drift from the canonical nk-dev toolchain
82
83
  (superseded deps, config `extends`, package.json scripts, the agent-guide
83
- import, a stale `.prettierignore`); `--fix` applies the auto-fixable findings.
84
+ import, a stale `.prettierignore`, an unsealed migration chain and the DDL in
85
+ it drizzle can't model); `--fix` applies the auto-fixable findings.
84
86
  - **`nk dev`** — start the Next dev server on the golden-path local database
85
87
  (see [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md)):
86
88
  - **PGlite** — if `@ingram-tech/nk-db`'s `nk-pglite-dev` bin resolves, hand off
@@ -92,6 +94,14 @@ tsc), so versions stay under each site's control — nk just orchestrates.
92
94
  generated (drizzle migrations, `pg_dump` baselines, pglite fixtures).
93
95
  - **`nk lint`** — `oxlint`.
94
96
  - **`nk knip`** — `knip` (unused dependencies / exports / files).
97
+ - **`nk migrations [--check|--reseal|--ddl]`** — guard the `drizzle/` migration
98
+ chain, with no database involved. Verifies each file against the hashes in
99
+ `drizzle/_seal.json` and seals newly generated ones; `--check` verifies
100
+ without writing (what `nk check` runs); `--reseal` rewrites every hash for a
101
+ deliberate squash; `--ddl` lists the migrations carrying DDL drizzle's
102
+ snapshot can't model. Details in
103
+ [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md#the-seal-applied-migrations-are-immutable).
104
+ A no-op on sites without a migration journal.
95
105
  - **`nk ast-grep [...]`** — structural search & rewrite of TS/TSX by AST pattern,
96
106
  via the vendored [ast-grep](https://ast-grep.github.io) (args passed through to
97
107
  it). For large mechanical refactors — import rewrites, API renames, call-shape
@@ -99,8 +109,8 @@ tsc), so versions stay under each site's control — nk just orchestrates.
99
109
  apply → `nk format` + `nk type-check`) and its syntactic-not-semantic limits
100
110
  live in the codemod skill, `skills/ts-codemod.md`.
101
111
  - **`nk check`** — `oxlint` + `oxfmt --check` + `knip` (only when the repo has a
102
- knip config) + the agent-guide import gate. The CI gate; runs every checker and
103
- reports them all before failing.
112
+ knip config) + the agent-guide import gate + the migration seal. The CI gate;
113
+ runs every checker and reports them all before failing.
104
114
  - **`nk type-check`** — `next typegen && tsc --noEmit`.
105
115
  - **`nk test [...]`** — `vitest run`, extra args passed through.
106
116
  - **`nk build [...]`** — `next build`, extra args passed through.
package/bin/nk.js CHANGED
@@ -5,7 +5,8 @@ import { doctor } from "../lib/doctor.js";
5
5
  import { format } from "../lib/format.js";
6
6
  import { init } from "../lib/init.js";
7
7
  import { knip } from "../lib/knip.js";
8
- import { build, check, lint, test, typeCheck } from "../lib/passthrough.js";
8
+ import { migrations } from "../lib/migrations.js";
9
+ import { build, check, clean, lint, test, typeCheck } from "../lib/passthrough.js";
9
10
 
10
11
  const USAGE = `nk — the nextkit CLI
11
12
 
@@ -22,12 +23,20 @@ Commands:
22
23
  format [--check] Format code with oxfmt. --check verifies without writing.
23
24
  lint [...] Lint with oxlint (extra args passed through, e.g. --fix).
24
25
  knip Find unused dependencies / exports / files with knip.
26
+ migrations [...] Guard the drizzle migration chain: verify that no applied
27
+ migration's bytes changed, and seal newly generated ones.
28
+ --check verifies without writing (CI); --reseal rewrites
29
+ every hash (a deliberate squash); --ddl lists the DDL
30
+ drizzle's snapshot cannot model.
25
31
  ast-grep [...] Structural search & rewrite of TS/TSX by AST pattern
26
32
  (vendored ast-grep; args passed through). For large
27
33
  mechanical refactors — see the codemod skill.
28
34
  check The CI gate: lint + format verify + knip (when configured)
29
- + the agent-guide import gate.
30
- type-check next typegen && tsc --noEmit.
35
+ + the agent-guide import gate + the migration seal.
36
+ type-check next typegen && tsc --noEmit. Recovers automatically when
37
+ generated types are damaged (e.g. a killed dev server).
38
+ clean Remove regenerable build artifacts: Next's generated
39
+ types and TypeScript incremental caches.
31
40
  test [...] vitest run (extra args passed through).
32
41
  build [...] next build (extra args passed through).
33
42
 
@@ -55,6 +64,9 @@ switch (cmd) {
55
64
  case "knip":
56
65
  knip(rest);
57
66
  break;
67
+ case "migrations":
68
+ migrations(rest);
69
+ break;
58
70
  case "ast-grep":
59
71
  astGrep(rest);
60
72
  break;
@@ -64,6 +76,9 @@ switch (cmd) {
64
76
  case "type-check":
65
77
  typeCheck();
66
78
  break;
79
+ case "clean":
80
+ clean();
81
+ break;
67
82
  case "test":
68
83
  test(rest);
69
84
  break;
package/guide.md CHANGED
@@ -83,6 +83,33 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
83
83
  transaction, before commit. A blind move that touches nothing (an RLS mask, a
84
84
  wrong `where`) otherwise reports success, and the drop of the source columns
85
85
  in the same migration makes it unrecoverable.
86
+ - **Never edit a migration that has been applied.** The runner records
87
+ `sha256(file)`, so the bytes are history: editing one drifts every database
88
+ that already ran it, and drizzle never looks at the file again to notice.
89
+ Express the change as a **new** migration. `drizzle/_seal.json` pins the
90
+ hashes and `nk check` fails on a mismatch — if it does, `git checkout` the
91
+ file rather than resealing. After generating a migration, run **`nk
92
+ migrations`** and commit `_seal.json` in the same commit as the `.sql`.
93
+ `nk migrations --reseal` exists only for a deliberate squash, which also
94
+ requires reconciling every database with `nk-pg-migrate --baseline`.
95
+ - **A clean `db:generate` does not mean the chain matches the database.**
96
+ drizzle diffs `schema.ts` against `meta/*_snapshot.json`, never against the
97
+ `.sql` files, and the snapshot can't model functions, triggers, `DEFERRABLE`
98
+ constraints, grants or roles. Anything regenerated from `schema.ts` drops
99
+ those clauses silently. `nk migrations --ddl` lists which migrations carry
100
+ them; verify against a real database before trusting a regenerated chain.
101
+ - **Never hand-append unmodelled DDL to a generated migration.** A generated
102
+ file must stay exactly what `drizzle-kit generate` produced, or the snapshot
103
+ becomes an active lie about a file drizzle believes it owns — and the next
104
+ regenerate re-emits those objects without your clauses. Put functions,
105
+ triggers, `DEFERRABLE`, grants and roles in `drizzle-kit generate --custom`
106
+ migrations instead.
107
+ - **Merging two branches that both added migrations? Check the journal.**
108
+ drizzle applies files by `when > max(created_at)`, so a migration whose `when`
109
+ lands below one already applied is skipped silently and forever.
110
+ `nk-pg-migrate` refuses to run in that state (`MigrationOrderError`); fix it
111
+ by raising the stranded entry's `when` in `meta/_journal.json` — never by
112
+ editing the `.sql`, which would break the hash every database recorded.
86
113
  - **`drizzle-kit` is GENERATE-ONLY — it must never apply schema.** Use it for
87
114
  `drizzle-kit generate` (and `generate --custom` for a package-owned/raw SQL
88
115
  migration). Applying is always **`nk-pg-migrate`** (the bin from
@@ -0,0 +1,105 @@
1
+ import { existsSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Directories a tool regenerates from source, and which `tsconfig.json`
6
+ * typically feeds back into `tsc` (Next's typed-routes output is in `include`).
7
+ *
8
+ * That round trip is what makes them worth tracking: a killed `next dev` can
9
+ * leave `.next/dev/types/validator.ts` truncated mid-write, and `next typegen`
10
+ * does **not** repair it, so `tsc` keeps reporting a syntax error inside
11
+ * generated code until the directory is removed. The error points at `.next/`,
12
+ * so the natural reflex is to hunt a type error in `src/` that doesn't exist.
13
+ */
14
+ const GENERATED_DIRECTORIES = [
15
+ { path: ".next/dev/types", owner: "next typegen", typeCheckInput: true },
16
+ { path: ".next/types", owner: "next typegen", typeCheckInput: true },
17
+ ];
18
+
19
+ /** Prefixes (posix-normalised) that `tsc` error locations may fall inside. */
20
+ export const TYPE_CHECK_INPUT_PREFIXES = GENERATED_DIRECTORIES.filter(
21
+ (entry) => entry.typeCheckInput,
22
+ ).map((entry) => entry.path);
23
+
24
+ /**
25
+ * Every generated artifact present in `cwd`. Incremental-build caches are
26
+ * discovered rather than hardcoded, since `tsBuildInfoFile` renames them and a
27
+ * repo can carry several (`tsconfig.tsbuildinfo`, `tsconfig.slice.tsbuildinfo`).
28
+ *
29
+ * Deleting any of these is safe by construction: the owning tool rebuilds it.
30
+ */
31
+ export function listGeneratedArtifacts(cwd = process.cwd()) {
32
+ const present = GENERATED_DIRECTORIES.filter((entry) =>
33
+ existsSync(join(cwd, entry.path)),
34
+ );
35
+
36
+ let buildInfo = [];
37
+ try {
38
+ buildInfo = readdirSync(cwd)
39
+ .filter((name) => name.endsWith(".tsbuildinfo"))
40
+ .map((name) => ({ path: name, owner: "tsc", typeCheckInput: false }));
41
+ } catch {
42
+ // An unreadable cwd is the caller's problem, not this helper's.
43
+ }
44
+
45
+ return [...present, ...buildInfo];
46
+ }
47
+
48
+ /**
49
+ * Remove generated artifacts and return the paths actually deleted. Missing
50
+ * paths are skipped rather than reported, so this is idempotent.
51
+ */
52
+ export function cleanGeneratedArtifacts(cwd = process.cwd()) {
53
+ const removed = [];
54
+ for (const entry of listGeneratedArtifacts(cwd)) {
55
+ rmSync(join(cwd, entry.path), { recursive: true, force: true });
56
+ removed.push(entry.path);
57
+ }
58
+ return removed;
59
+ }
60
+
61
+ // Colour codes only (ESC [ … m). ESC is built from its code point rather
62
+ // than written inline: matching it is the entire point of stripping colour, but
63
+ // a control character inside a regex literal trips `no-control-regex`.
64
+ const ANSI = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
65
+
66
+ /**
67
+ * `tsc` error headers, in both layouts: plain (piped / `--pretty false`) and
68
+ * pretty (a TTY, or `--pretty`).
69
+ */
70
+ const ERROR_LOCATIONS = [
71
+ /^(.+?)\((\d+),(\d+)\): (?:error|warning) TS\d+/,
72
+ /^(.+?):(\d+):(\d+) - (?:error|warning) TS\d+/,
73
+ ];
74
+
75
+ /** Unique file paths `tsc` reported errors in, posix-normalised. */
76
+ export function errorFiles(output) {
77
+ const files = new Set();
78
+ for (const rawLine of String(output).split(/\r?\n/)) {
79
+ const line = rawLine.replace(ANSI, "");
80
+ for (const pattern of ERROR_LOCATIONS) {
81
+ const match = line.match(pattern);
82
+ if (!match?.[1]) continue;
83
+ files.add(match[1].trim().replaceAll("\\", "/").replace(/^\.\//, ""));
84
+ break;
85
+ }
86
+ }
87
+ return [...files];
88
+ }
89
+
90
+ /**
91
+ * Whether every error `tsc` reported sits inside generated type output — the
92
+ * signature of a damaged artifact rather than a source defect.
93
+ *
94
+ * Deliberately "every", not "any": a run that mixes generated and `src/` errors
95
+ * has real work in it, and cleaning would neither fix nor excuse those.
96
+ */
97
+ export function onlyGeneratedTypeErrors(output) {
98
+ const files = errorFiles(output);
99
+ if (files.length === 0) return false;
100
+ return files.every((file) =>
101
+ TYPE_CHECK_INPUT_PREFIXES.some(
102
+ (prefix) => file === prefix || file.startsWith(`${prefix}/`),
103
+ ),
104
+ );
105
+ }
package/lib/doctor.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { SUPERSEDED_DEPS } from "./drift.js";
4
+ import {
5
+ SEAL_FILE,
6
+ migrationsFolder,
7
+ readChain,
8
+ readSeal,
9
+ summarizeKinds,
10
+ unmodelledDdl,
11
+ writeSeal,
12
+ } from "./migrations.js";
4
13
 
5
14
  // The canonical script → command mapping for a site on the nk-dev toolchain.
6
15
  // The key is matched loosely (`type-check` and `typecheck` are both accepted);
@@ -257,6 +266,62 @@ export function findings(cwd) {
257
266
  });
258
267
  }
259
268
 
269
+ // 9. The migration chain is sealed, and its unmodelled DDL is declared.
270
+ out.push(...migrationFindings(cwd));
271
+
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * Findings over a `drizzle/` chain. Silent on repos without one.
277
+ *
278
+ * The seal finding is the cheap half of migration safety: applied migrations
279
+ * are immutable, and nothing in drizzle notices when one is edited.
280
+ *
281
+ * The unmodelled-DDL finding is the honest half. `drizzle-kit generate` diffs
282
+ * `schema.ts` against `meta/*_snapshot.json`, so any DDL the snapshot can't
283
+ * model — functions, triggers, `DEFERRABLE` constraints, grants, roles — is
284
+ * outside the diff basis entirely. A chain carrying it can drift arbitrarily
285
+ * far from the database while `db:generate` still reports no changes, and
286
+ * anything regenerated from `schema.ts` drops it. That is a real property of
287
+ * the repo, so `nk doctor` states it rather than leaving it to be rediscovered.
288
+ */
289
+ function migrationFindings(cwd) {
290
+ const out = [];
291
+ const folder = migrationsFolder(cwd);
292
+ let chain;
293
+ try {
294
+ chain = readChain(cwd, folder);
295
+ } catch (err) {
296
+ return [
297
+ { id: "migrations:broken-chain", level: "error", message: err.message },
298
+ ];
299
+ }
300
+ if (chain === null || chain.length === 0) return out;
301
+
302
+ if (readSeal(cwd, folder) === null) {
303
+ out.push({
304
+ id: "migrations:unsealed",
305
+ level: "warn",
306
+ message: `${chain.length} migration(s) with no ${folder}/${SEAL_FILE} — an edit to an already-applied migration would go unnoticed`,
307
+ fix: (dir) => {
308
+ const f = migrationsFolder(dir);
309
+ writeSeal(dir, f, readChain(dir, f));
310
+ return `sealed ${chain.length} migration(s) in ${f}/${SEAL_FILE}`;
311
+ },
312
+ });
313
+ }
314
+
315
+ const inventory = unmodelledDdl(cwd, folder);
316
+ if (inventory.length > 0) {
317
+ const kinds = summarizeKinds(inventory);
318
+ out.push({
319
+ id: "migrations:unmodelled-ddl",
320
+ level: "warn",
321
+ message: `${inventory.length} of ${chain.length} migration(s) carry DDL drizzle's snapshot cannot model (${kinds.join(", ")}) — \`db:generate\` reporting "no changes" does not mean the chain reproduces the database, and regenerating from schema.ts drops it. Run \`nk migrations --ddl\` for the per-file list.`,
322
+ });
323
+ }
324
+
260
325
  return out;
261
326
  }
262
327
 
@@ -264,7 +329,8 @@ export function findings(cwd) {
264
329
  * `nk doctor [--fix]` — report drift from the canonical nk-dev model (scripts,
265
330
  * dependencies, oxlint/tsconfig extends, the CLAUDE.md guide import, stale knip
266
331
  * ignores, forbidden schema-applying drizzle-kit scripts, a dead
267
- * .prettierignore). With `--fix`, apply every auto-fixable finding, then remind
332
+ * .prettierignore, an unsealed or unmodelled-DDL-carrying migration chain).
333
+ * With `--fix`, apply every auto-fixable finding, then remind
268
334
  * to reinstall.
269
335
  */
270
336
  export function doctor(args = []) {
@@ -0,0 +1,354 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ // Two guards over a `drizzle/` migration chain, both of which exist because
6
+ // `drizzle-kit generate` diffs `schema.ts` against `meta/*_snapshot.json` and
7
+ // NEVER against the `.sql` files:
8
+ //
9
+ // 1. THE SEAL. Once a migration has been applied anywhere, its bytes are
10
+ // history — the runner records `sha256(file)` in the journal table, so
11
+ // editing the file (a formatter sweep, a "quick fix" to a generated
12
+ // migration) permanently drifts every database that already ran it. There
13
+ // is nothing in drizzle that notices. {@link verifySeal} pins each file's
14
+ // hash in a committed `_seal.json`, so the edit shows up as a failed check
15
+ // in the PR that made it instead of as a confusing `already exists` on the
16
+ // next deploy.
17
+ //
18
+ // 2. THE UNMODELLED-DDL INVENTORY. Functions, triggers, `DEFERRABLE`
19
+ // constraints, grants and roles are not in drizzle's snapshot model. Once
20
+ // a migration carries them, the snapshot is a permanently partial view of
21
+ // the schema: `db:generate` reports "nothing to migrate" no matter how far
22
+ // the chain has drifted from the database, and anything regenerated from
23
+ // `schema.ts` (notably a squash) silently drops them.
24
+ // {@link unmodelledDdl} turns that from tribal knowledge into a list.
25
+ //
26
+ // Both are deliberately database-free: they run in CI, in a pre-commit hook and
27
+ // on a laptop with no `DATABASE_URL`. Proving the chain actually reproduces the
28
+ // live schema needs a catalog diff against a real database, which is a
29
+ // different (and much larger) tool.
30
+
31
+ /** Name of the seal file, written inside the migrations folder. */
32
+ export const SEAL_FILE = "_seal.json";
33
+
34
+ const SEAL_COMMENT =
35
+ "sha256 of each migration file at the time it was sealed. Applied migrations are immutable: if a hash here stops matching, the file was edited after it ran and every database that already applied it has drifted. Regenerate with `nk migrations --reseal` ONLY as part of a deliberate squash.";
36
+
37
+ /**
38
+ * The migrations folder for a repo. Honours `out:` in a drizzle config when one
39
+ * is present (matched textually — we are not loading the site's TS config just
40
+ * to read one string), else drizzle's `drizzle` default.
41
+ */
42
+ export function migrationsFolder(cwd = process.cwd()) {
43
+ for (const name of [
44
+ "drizzle.config.ts",
45
+ "drizzle.config.js",
46
+ "drizzle.config.mjs",
47
+ ]) {
48
+ const path = resolve(cwd, name);
49
+ if (!existsSync(path)) continue;
50
+ const match = /\bout\s*:\s*["'`]([^"'`]+)["'`]/.exec(
51
+ readFileSync(path, "utf8"),
52
+ );
53
+ if (match?.[1]) return match[1];
54
+ }
55
+ return "drizzle";
56
+ }
57
+
58
+ const journalPathFor = (cwd, folder) => resolve(cwd, folder, "meta", "_journal.json");
59
+
60
+ /**
61
+ * The migration chain as `{ tag, hash }`, in journal order. `hash` is
62
+ * `sha256(rawFile)` — the exact value drizzle records in `__drizzle_migrations`,
63
+ * so a mismatch here is a mismatch there.
64
+ *
65
+ * Returns null when the repo has no journal (not a drizzle site — nothing to
66
+ * guard). Throws when the journal names a file that doesn't exist, which is
67
+ * itself a broken chain.
68
+ */
69
+ export function readChain(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
70
+ const journalPath = journalPathFor(cwd, folder);
71
+ if (!existsSync(journalPath)) return null;
72
+ const journal = JSON.parse(readFileSync(journalPath, "utf8"));
73
+ const entries = Array.isArray(journal?.entries) ? journal.entries : [];
74
+ return entries.map((entry) => {
75
+ const sqlPath = resolve(cwd, folder, `${entry.tag}.sql`);
76
+ if (!existsSync(sqlPath)) {
77
+ throw new Error(
78
+ `nk migrations: journal entry "${entry.tag}" has no ${folder}/${entry.tag}.sql`,
79
+ );
80
+ }
81
+ const sql = readFileSync(sqlPath, "utf8");
82
+ return {
83
+ tag: entry.tag,
84
+ hash: createHash("sha256").update(sql).digest("hex"),
85
+ sql,
86
+ };
87
+ });
88
+ }
89
+
90
+ /** The committed seal, or an empty one when the repo hasn't sealed yet. */
91
+ export function readSeal(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
92
+ const path = resolve(cwd, folder, SEAL_FILE);
93
+ if (!existsSync(path)) return null;
94
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
95
+ return parsed?.migrations && typeof parsed.migrations === "object"
96
+ ? parsed.migrations
97
+ : {};
98
+ }
99
+
100
+ /** Write the seal for `chain`, in journal order (stable diffs). */
101
+ export function writeSeal(cwd, folder, chain) {
102
+ const migrations = {};
103
+ for (const m of chain) migrations[m.tag] = m.hash;
104
+ writeFileSync(
105
+ resolve(cwd, folder, SEAL_FILE),
106
+ `${JSON.stringify({ $comment: SEAL_COMMENT, migrations }, null, "\t")}\n`,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Compare the chain on disk to the committed seal.
112
+ *
113
+ * - `changed` — sealed migrations whose bytes moved. Always a defect: those
114
+ * files have already run somewhere.
115
+ * - `dropped` — sealed migrations no longer in the journal. Normal during a
116
+ * squash, a defect at any other time.
117
+ * - `unsealed` — migrations with no seal entry yet (newly generated).
118
+ */
119
+ export function verifySeal(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
120
+ const chain = readChain(cwd, folder);
121
+ if (chain === null) return null;
122
+ const sealed = readSeal(cwd, folder);
123
+ if (sealed === null) {
124
+ return { chain, sealedYet: false, changed: [], dropped: [], unsealed: chain };
125
+ }
126
+ const changed = [];
127
+ const unsealed = [];
128
+ for (const m of chain) {
129
+ const expected = sealed[m.tag];
130
+ if (expected === undefined) unsealed.push(m);
131
+ else if (expected !== m.hash) changed.push({ ...m, sealed: expected });
132
+ }
133
+ const tags = new Set(chain.map((m) => m.tag));
134
+ const dropped = Object.keys(sealed).filter((tag) => !tags.has(tag));
135
+ return { chain, sealedYet: true, changed, dropped, unsealed };
136
+ }
137
+
138
+ // DDL that drizzle's snapshot model does not represent. Each entry is
139
+ // `[kind, pattern]`, matched against SQL with comments, string literals and
140
+ // dollar-quoted bodies stripped, so a mention inside a function body or a
141
+ // `-- create trigger` comment doesn't count.
142
+ const UNMODELLED = [
143
+ ["function", /\bcreate\s+(?:or\s+replace\s+)?(?:function|procedure)\b/i],
144
+ ["trigger", /\bcreate\s+(?:constraint\s+|event\s+)?trigger\b/i],
145
+ ["deferrable", /\bdeferrable\b/i],
146
+ ["grant", /\b(?:grant|revoke)\b/i],
147
+ ["role", /\b(?:create|alter|drop)\s+role\b/i],
148
+ ["extension", /\bcreate\s+extension\b/i],
149
+ ["materialized-view", /\bcreate\s+materialized\s+view\b/i],
150
+ ["rule", /\bcreate\s+(?:or\s+replace\s+)?rule\b/i],
151
+ ["do-block", /(?:^|;|\n)\s*do\s+\$/i],
152
+ ];
153
+
154
+ /**
155
+ * Blank out anything that isn't executable DDL text: dollar-quoted bodies (a
156
+ * function body full of SQL keywords), block and line comments, single-quoted
157
+ * literals and double-quoted identifiers. Replaced with spaces rather than
158
+ * removed so nothing accidentally joins into a new keyword.
159
+ */
160
+ function stripNonDdl(sql) {
161
+ const blank = (m) => " ".repeat(m.length);
162
+ return (
163
+ sql
164
+ // A dollar-quoted body collapses to a bare `$$` rather than to spaces:
165
+ // the body's contents are not statements, but the opener still has to be
166
+ // visible so `do $$ ... $$` is recognisable as an anonymous block.
167
+ .replace(/\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1?\$/g, " $$$$ ")
168
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
169
+ .replace(/--[^\n]*/g, blank)
170
+ .replace(/'(?:[^']|'')*'/g, blank)
171
+ .replace(/"(?:[^"]|"")*"/g, blank)
172
+ );
173
+ }
174
+
175
+ /** The unmodelled-DDL kinds present in one migration's SQL. */
176
+ export function unmodelledKinds(sql) {
177
+ const stripped = stripNonDdl(sql);
178
+ return UNMODELLED.filter(([, pattern]) => pattern.test(stripped)).map(
179
+ ([kind]) => kind,
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Per-file inventory of DDL drizzle can't model: `[{ tag, kinds }]`, only for
185
+ * files that carry some. Empty when the chain is purely generated output (the
186
+ * only case in which `db:generate` reporting "no changes" actually means the
187
+ * chain reproduces `schema.ts`).
188
+ */
189
+ export function unmodelledDdl(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
190
+ const chain = readChain(cwd, folder);
191
+ if (chain === null) return [];
192
+ return chain
193
+ .map((m) => ({ tag: m.tag, kinds: unmodelledKinds(m.sql) }))
194
+ .filter((m) => m.kinds.length > 0);
195
+ }
196
+
197
+ /** Distinct kinds across a whole inventory, for a one-line summary. */
198
+ export function summarizeKinds(inventory) {
199
+ return [...new Set(inventory.flatMap((m) => m.kinds))].sort();
200
+ }
201
+
202
+ const short = (hash) => hash.slice(0, 12);
203
+
204
+ /**
205
+ * The `nk check` gate: `{ ok, reason }`, non-exiting. `ok` on a repo with no
206
+ * journal (not every site has a database) and on a chain that matches its seal.
207
+ */
208
+ export function checkSeal(cwd = process.cwd()) {
209
+ const folder = migrationsFolder(cwd);
210
+ let state;
211
+ try {
212
+ state = verifySeal(cwd, folder);
213
+ } catch (err) {
214
+ return { ok: false, reason: err.message };
215
+ }
216
+ if (state === null) return { ok: true };
217
+ const problems = [
218
+ ...state.changed.map(
219
+ (m) =>
220
+ `${m.tag} changed after it was sealed (${short(m.sealed)} → ${short(m.hash)})`,
221
+ ),
222
+ ...state.dropped.map(
223
+ (tag) => `${tag} was sealed but is no longer in the journal`,
224
+ ),
225
+ ...state.unsealed.map((m) => `${m.tag} is unsealed`),
226
+ ];
227
+ if (problems.length === 0) return { ok: true };
228
+ return {
229
+ ok: false,
230
+ reason: `migration chain does not match ${folder}/${SEAL_FILE} — ${problems.join("; ")}`,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * `nk migrations [--check|--reseal|--ddl]` — guard the migration chain.
236
+ *
237
+ * Default: verify the seal, then seal anything newly generated and write the
238
+ * file. `--check` verifies without writing (the CI shape: an unsealed migration
239
+ * is a failure, because the seal must land in the same commit as the
240
+ * migration). `--reseal` rewrites every hash — the deliberate squash escape
241
+ * hatch, whose effect is visible in the diff. `--ddl` prints the
242
+ * unmodelled-DDL inventory.
243
+ */
244
+ export function migrations(args = []) {
245
+ const cwd = process.cwd();
246
+ const folder = migrationsFolder(cwd);
247
+ const checkOnly = args.includes("--check");
248
+
249
+ let state;
250
+ try {
251
+ state = verifySeal(cwd, folder);
252
+ } catch (err) {
253
+ console.error(`nk migrations: ${err.message}`);
254
+ process.exit(1);
255
+ }
256
+ if (state === null) {
257
+ if (!checkOnly) console.log(`nk migrations: no ${folder}/meta/_journal.json.`);
258
+ process.exit(0);
259
+ }
260
+
261
+ if (args.includes("--ddl")) {
262
+ printDdl(cwd, folder);
263
+ process.exit(0);
264
+ }
265
+
266
+ if (args.includes("--reseal")) {
267
+ writeSeal(cwd, folder, state.chain);
268
+ console.log(
269
+ `nk migrations: resealed ${state.chain.length} migration(s) in ${folder}/${SEAL_FILE}.`,
270
+ );
271
+ for (const m of state.changed) {
272
+ console.log(` ! ${m.tag}: ${short(m.sealed)} → ${short(m.hash)}`);
273
+ }
274
+ for (const tag of state.dropped) console.log(` – ${tag} (dropped)`);
275
+ console.log(
276
+ "\n Every database that already ran a changed or dropped migration must be reconciled (`nk-pg-migrate --baseline`) and verified against the new chain before this ships.",
277
+ );
278
+ process.exit(0);
279
+ }
280
+
281
+ const broken = state.changed.length > 0 || state.dropped.length > 0;
282
+ for (const m of state.changed) {
283
+ console.error(
284
+ `nk migrations: ✗ ${m.tag} changed after it was sealed (${short(m.sealed)} → ${short(m.hash)})`,
285
+ );
286
+ }
287
+ for (const tag of state.dropped) {
288
+ console.error(
289
+ `nk migrations: ✗ ${tag} was sealed but is no longer in the journal`,
290
+ );
291
+ }
292
+ if (broken) {
293
+ console.error(
294
+ "\n An applied migration's bytes are history: every database that ran it recorded that hash. Restore the file (`git checkout`) and express the change as a NEW migration.",
295
+ );
296
+ console.error(
297
+ " If this is a deliberate squash, run `nk migrations --reseal` and reconcile each database with `nk-pg-migrate --baseline`.",
298
+ );
299
+ process.exit(1);
300
+ }
301
+
302
+ if (checkOnly) {
303
+ if (state.unsealed.length > 0) {
304
+ console.error(
305
+ `nk migrations: ✗ ${state.unsealed.length} unsealed migration(s): ${state.unsealed.map((m) => m.tag).join(", ")}`,
306
+ );
307
+ console.error(
308
+ ` → run \`nk migrations\` and commit ${folder}/${SEAL_FILE} alongside the migration.`,
309
+ );
310
+ process.exit(1);
311
+ }
312
+ console.log(
313
+ `nk migrations: ✓ ${state.chain.length} migration(s) match the seal.`,
314
+ );
315
+ process.exit(0);
316
+ }
317
+
318
+ if (state.unsealed.length === 0 && state.sealedYet) {
319
+ console.log(
320
+ `nk migrations: ✓ ${state.chain.length} migration(s) match the seal.`,
321
+ );
322
+ process.exit(0);
323
+ }
324
+ writeSeal(cwd, folder, state.chain);
325
+ console.log(
326
+ `nk migrations: sealed ${state.unsealed.length} new migration(s) — commit ${folder}/${SEAL_FILE}.`,
327
+ );
328
+ for (const m of state.unsealed) console.log(` + ${m.tag}`);
329
+ process.exit(0);
330
+ }
331
+
332
+ function printDdl(cwd, folder) {
333
+ const inventory = unmodelledDdl(cwd, folder);
334
+ if (inventory.length === 0) {
335
+ console.log(
336
+ "nk migrations: no DDL outside drizzle's snapshot model — `db:generate` sees the whole schema.",
337
+ );
338
+ return;
339
+ }
340
+ console.log(
341
+ `nk migrations: ${inventory.length} migration(s) carry DDL drizzle's snapshot cannot model:\n`,
342
+ );
343
+ for (const m of inventory) console.log(` ${m.tag} ${m.kinds.join(", ")}`);
344
+ console.log(
345
+ "\n drizzle diffs schema.ts against meta/*_snapshot.json, so none of this is in the diff basis:",
346
+ );
347
+ console.log(
348
+ " `db:generate` reporting no changes does NOT mean the chain reproduces the database, and anything",
349
+ );
350
+ console.log(
351
+ " regenerated from schema.ts (a squash above all) drops these clauses silently. Verify against a real",
352
+ );
353
+ console.log(" database before trusting a regenerated chain.");
354
+ }
@@ -7,7 +7,9 @@ import deferredCurrentTarget from "./deferred-current-target.js";
7
7
  import lucideIconSuffix from "./lucide-icon-suffix.js";
8
8
  import noCryptoRandomUuid from "./no-crypto-random-uuid.js";
9
9
  import noRedirectOnlyPage from "./no-redirect-only-page.js";
10
+ import noRedundantNodeCrypto from "./no-redundant-node-crypto.js";
10
11
  import redundantUseStateType from "./redundant-usestate-type.js";
12
+ import satoriCss from "./satori-css.js";
11
13
  import tNoPositionalArgs from "./t-no-positional-args.js";
12
14
  import tRequiresValues from "./t-requires-values.js";
13
15
 
@@ -19,7 +21,9 @@ export default {
19
21
  ...lucideIconSuffix.rules,
20
22
  ...noCryptoRandomUuid.rules,
21
23
  ...noRedirectOnlyPage.rules,
24
+ ...noRedundantNodeCrypto.rules,
22
25
  ...redundantUseStateType.rules,
26
+ ...satoriCss.rules,
23
27
  ...tNoPositionalArgs.rules,
24
28
  ...tRequiresValues.rules,
25
29
  },
@@ -0,0 +1,137 @@
1
+ // nextkit oxlint JS plugin rule: don't import from `node:crypto` what is
2
+ // already a global.
3
+ //
4
+ // Web Crypto is on `globalThis` in every runtime we ship to — Node (since 19,
5
+ // and nk-dev's floor is 22), the browser, and every edge/worker runtime. So
6
+ // `randomUUID`, `getRandomValues`, `subtle` and `webcrypto` are reachable as
7
+ // `crypto.randomUUID()`, `crypto.getRandomValues()`, `crypto.subtle` and
8
+ // `crypto` with no import at all.
9
+ //
10
+ // Importing them anyway costs something real: it pins the module to a Node-only
11
+ // runtime for a function it would have had regardless. A component, a shared
12
+ // helper or a route that could have run anywhere now can't, and the reason is
13
+ // invisible at the call site — the code reads identically either way. Two of
14
+ // these are not even different objects: `node:crypto`'s `subtle` and `webcrypto`
15
+ // are the very same references as `globalThis.crypto.subtle` and
16
+ // `globalThis.crypto`.
17
+ //
18
+ // nk-db already pays for this the hard way: its id codec is imported by Drizzle
19
+ // schemas, client components and edge runtimes, so `id.ts` is held to an empty
20
+ // import list by a test (`id.test.ts`, "isomorphic invariant") whose comment
21
+ // names `node:crypto` for randomness as the tempting one. That invariant was
22
+ // prose in one package; this rule is the mechanical version of it, fleet-wide.
23
+ //
24
+ // This is about the module boundary, not the algorithm. The rest of `node:crypto`
25
+ // — `createHash`, `createHmac`, `createPrivateKey`, `randomBytes`,
26
+ // `timingSafeEqual` — has no drop-in global (the Web Crypto equivalents live
27
+ // under `crypto.subtle` and are async), so those imports are correct and this
28
+ // rule leaves them alone. Trimming a redundant name off an import list is the
29
+ // common fix; the import disappears entirely only when nothing else was on it.
30
+ //
31
+ // Deliberately not autofixable. Deleting the specifier is the easy half — the
32
+ // call sites still have to become member expressions on the global, and a
33
+ // default or namespace import named `crypto` (which shadows the global it is
34
+ // standing in for) needs the whole file reread, not a mechanical edit.
35
+ //
36
+ // One case keeps the import: `node:crypto`'s `randomUUID` takes an options bag
37
+ // (`randomUUID({ disableEntropyCache: true })`) that Web Crypto's does not. If
38
+ // you need it, keep the import and say so:
39
+ //
40
+ // // oxlint-disable-next-line nextkit/no-redundant-node-crypto -- needs disableEntropyCache
41
+ //
42
+ // Only static `import` is checked, matching every other rule in this plugin.
43
+ // `require("node:crypto")` in a CommonJS script is out of scope.
44
+ //
45
+ // Note this overlaps by design with `nextkit/no-crypto-random-uuid`, which asks
46
+ // a different question about the same call: that rule is about v4-versus-v7 for
47
+ // a *stored id*, this one is about the module. A call site that justifiably
48
+ // keeps v4 — a bearer token, a nonce — silences that rule and should still be
49
+ // reaching for the global.
50
+
51
+ const NODE_CRYPTO_MODULES = new Set(["crypto", "node:crypto"]);
52
+
53
+ /** node:crypto exports that are already global, and what to reach for instead. */
54
+ const REDUNDANT_EXPORTS = new Map([
55
+ ["randomUUID", "crypto.randomUUID()"],
56
+ ["getRandomValues", "crypto.getRandomValues()"],
57
+ ["subtle", "crypto.subtle"],
58
+ ["webcrypto", "crypto"],
59
+ ]);
60
+
61
+ const noRedundantNodeCrypto = {
62
+ meta: {
63
+ type: "suggestion",
64
+ docs: {
65
+ description:
66
+ "Disallow importing node:crypto members that are already on the Web Crypto global",
67
+ },
68
+ messages: {
69
+ redundantImport:
70
+ "`{{name}}` from `{{module}}` is already global — use `{{replacement}}` and drop the import. Web Crypto is on globalThis in Node (>=19), the browser and every edge runtime, so importing it pins this module to Node for nothing. Keep the import only if you need a Node-specific signature, with `// oxlint-disable-next-line nextkit/no-redundant-node-crypto -- <reason>`.",
71
+ redundantMember:
72
+ "`{{local}}.{{name}}` is already global — use `{{replacement}}`. Web Crypto is on globalThis in Node (>=19), the browser and every edge runtime; reaching for it through the `{{module}}` namespace pins this module to Node for nothing.",
73
+ },
74
+ },
75
+ create(context) {
76
+ // Local names bound to the whole module (`import * as c` / `import c`),
77
+ // whose members we then check.
78
+ const namespaceNames = new Set();
79
+
80
+ return {
81
+ ImportDeclaration(node) {
82
+ const module = node.source.value;
83
+ if (!NODE_CRYPTO_MODULES.has(module)) return;
84
+
85
+ for (const specifier of node.specifiers) {
86
+ if (
87
+ specifier.type === "ImportNamespaceSpecifier" ||
88
+ specifier.type === "ImportDefaultSpecifier"
89
+ ) {
90
+ namespaceNames.add(specifier.local.name);
91
+ continue;
92
+ }
93
+ if (specifier.type !== "ImportSpecifier") continue;
94
+ if (specifier.imported.type !== "Identifier") continue;
95
+
96
+ const name = specifier.imported.name;
97
+ const replacement = REDUNDANT_EXPORTS.get(name);
98
+ if (!replacement) continue;
99
+
100
+ context.report({
101
+ node: specifier,
102
+ messageId: "redundantImport",
103
+ data: { name, module, replacement },
104
+ });
105
+ }
106
+ },
107
+ // `nodeCrypto.subtle` where `nodeCrypto` is the imported module. The
108
+ // import itself can be legitimate (it may also carry `createHash`), so
109
+ // the redundant part is this access, not the declaration.
110
+ MemberExpression(node) {
111
+ if (node.computed) return;
112
+ if (node.object.type !== "Identifier") return;
113
+ if (!namespaceNames.has(node.object.name)) return;
114
+ if (node.property.type !== "Identifier") return;
115
+
116
+ const replacement = REDUNDANT_EXPORTS.get(node.property.name);
117
+ if (!replacement) return;
118
+
119
+ context.report({
120
+ node,
121
+ messageId: "redundantMember",
122
+ data: {
123
+ local: node.object.name,
124
+ name: node.property.name,
125
+ replacement,
126
+ module: "node:crypto",
127
+ },
128
+ });
129
+ },
130
+ };
131
+ },
132
+ };
133
+
134
+ export default {
135
+ meta: { name: "nextkit" },
136
+ rules: { "no-redundant-node-crypto": noRedundantNodeCrypto },
137
+ };
@@ -0,0 +1,318 @@
1
+ // nextkit oxlint JS plugin rule: validate inline styles in satori-rendered JSX.
2
+ //
3
+ // `next/og`'s `ImageResponse` types `style` as the full `React.CSSProperties`,
4
+ // but satori implements a finite subset and **silently drops** everything else.
5
+ // The image still renders, just wrong — which is why a render test can't catch
6
+ // it: the PNG is valid, the shadow is simply missing. That gap is the whole
7
+ // reason this rule exists (nk-seo README, "Open Graph image").
8
+ //
9
+ // Two classes of finding:
10
+ //
11
+ // 1. Style properties satori does not implement (`transition`, `cursor`,
12
+ // `backdropFilter`, the grid family, `zIndex`, `calc()`, …) — silent drops.
13
+ // 2. The structural rules satori enforces at render time: a node with more
14
+ // than one child must set `display: flex` (or `none`), and text must not
15
+ // sit next to element siblings. These *do* throw at render; flagging them
16
+ // in-editor just moves the failure earlier.
17
+ //
18
+ // Scope: only files that are satori-bound — they import `next/og` (or
19
+ // `@vercel/og`), or they are an `opengraph-image` / `twitter-image` file
20
+ // convention. Sites using nk-seo's `ogImageResponse` write no satori JSX at all
21
+ // and never trip this.
22
+ //
23
+ // The supported list is satori's documented one (https://github.com/vercel/satori#css)
24
+ // plus the box-model properties yoga handles that the README's table omits. It
25
+ // is deliberately generous: an over-wide allowlist only lowers the catch rate,
26
+ // while a too-narrow one puts false positives into a config the whole fleet
27
+ // inherits.
28
+
29
+ /** https://github.com/vercel/satori#css, plus the yoga box-model properties. */
30
+ const SUPPORTED = new Set([
31
+ // Display & position
32
+ "display",
33
+ "position",
34
+ "top",
35
+ "right",
36
+ "bottom",
37
+ "left",
38
+ "overflow",
39
+ "opacity",
40
+ "boxSizing",
41
+ "boxShadow",
42
+ "filter",
43
+ "clipPath",
44
+ "lineClamp",
45
+ "color",
46
+ // Box model
47
+ "margin",
48
+ "marginTop",
49
+ "marginRight",
50
+ "marginBottom",
51
+ "marginLeft",
52
+ "padding",
53
+ "paddingTop",
54
+ "paddingRight",
55
+ "paddingBottom",
56
+ "paddingLeft",
57
+ "width",
58
+ "height",
59
+ "minWidth",
60
+ "minHeight",
61
+ "maxWidth",
62
+ "maxHeight",
63
+ // Border
64
+ "border",
65
+ "borderTop",
66
+ "borderRight",
67
+ "borderBottom",
68
+ "borderLeft",
69
+ "borderWidth",
70
+ "borderTopWidth",
71
+ "borderRightWidth",
72
+ "borderBottomWidth",
73
+ "borderLeftWidth",
74
+ "borderStyle",
75
+ "borderTopStyle",
76
+ "borderRightStyle",
77
+ "borderBottomStyle",
78
+ "borderLeftStyle",
79
+ "borderColor",
80
+ "borderTopColor",
81
+ "borderRightColor",
82
+ "borderBottomColor",
83
+ "borderLeftColor",
84
+ "borderRadius",
85
+ "borderTopLeftRadius",
86
+ "borderTopRightRadius",
87
+ "borderBottomLeftRadius",
88
+ "borderBottomRightRadius",
89
+ // Flex
90
+ "flex",
91
+ "flexDirection",
92
+ "flexWrap",
93
+ "flexFlow",
94
+ "flexGrow",
95
+ "flexShrink",
96
+ "flexBasis",
97
+ "alignItems",
98
+ "alignContent",
99
+ "alignSelf",
100
+ "justifyContent",
101
+ "gap",
102
+ "rowGap",
103
+ "columnGap",
104
+ "order",
105
+ "aspectRatio",
106
+ // Font & text
107
+ "fontFamily",
108
+ "fontSize",
109
+ "fontWeight",
110
+ "fontStyle",
111
+ "tabSize",
112
+ "textAlign",
113
+ "textIndent",
114
+ "textTransform",
115
+ "textOverflow",
116
+ "textDecoration",
117
+ "textDecorationColor",
118
+ "textDecorationLine",
119
+ "textDecorationStyle",
120
+ "textShadow",
121
+ "textWrap",
122
+ "lineHeight",
123
+ "letterSpacing",
124
+ "whiteSpace",
125
+ "wordBreak",
126
+ // Background
127
+ "background",
128
+ "backgroundColor",
129
+ "backgroundImage",
130
+ "backgroundPosition",
131
+ "backgroundSize",
132
+ "backgroundClip",
133
+ "backgroundRepeat",
134
+ // Transform
135
+ "transform",
136
+ "transformOrigin",
137
+ // Image
138
+ "objectFit",
139
+ "objectPosition",
140
+ // Mask
141
+ "maskImage",
142
+ "maskPosition",
143
+ "maskSize",
144
+ "maskRepeat",
145
+ // Text stroke
146
+ "WebkitTextStroke",
147
+ "WebkitTextStrokeWidth",
148
+ "WebkitTextStrokeColor",
149
+ "WebkitBackgroundClip",
150
+ "WebkitTextFillColor",
151
+ ]);
152
+
153
+ const OG_MODULES = new Set(["next/og", "@vercel/og"]);
154
+ const IMAGE_CONVENTION = /\/(opengraph-image|twitter-image)(\.[^/]+)?\.[jt]sx$/;
155
+
156
+ /** kebab-case is legal in a style object via string keys; normalize to camel. */
157
+ const toCamelCase = (name) =>
158
+ name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
159
+
160
+ const satoriCss = {
161
+ meta: {
162
+ type: "problem",
163
+ docs: {
164
+ description:
165
+ "Restrict inline styles in satori-rendered JSX to what satori implements",
166
+ },
167
+ messages: {
168
+ unsupportedProperty:
169
+ "satori does not implement `{{property}}` — it is silently dropped from the rendered image (the PNG still comes out, just wrong, so no render test catches it). See https://github.com/vercel/satori#css for the supported subset.",
170
+ calc: "satori does not support `calc()` in `{{property}}` — the declaration is dropped. Compute the number in JS instead.",
171
+ missingFlex:
172
+ 'This element has {{count}} children but no `display`. satori defaults every node to `display: flex`, and a node with more than one child must set it explicitly — add `display: "flex"` (or `"none"`) to make the intent survive.',
173
+ textWithElementSiblings:
174
+ "satori cannot lay out a text node next to element siblings — it throws at render. Wrap the text in its own element.",
175
+ },
176
+ },
177
+ create(context) {
178
+ const filename = context.physicalFilename || context.filename || "";
179
+ const isConventionFile = IMAGE_CONVENTION.test(filename.replace(/\\/g, "/"));
180
+ let importsOg = false;
181
+ /** Findings held until the whole file has been seen — the `next/og`
182
+ * import is what proves the JSX is satori-bound, and a re-export or a
183
+ * type-only file can carry JSX with no import at all. */
184
+ const findings = [];
185
+ const report = (descriptor) => findings.push(descriptor);
186
+
187
+ const checkStyleObject = (object) => {
188
+ for (const property of object.properties) {
189
+ if (property.type !== "Property") continue;
190
+ let name;
191
+ if (property.key.type === "Identifier" && !property.computed) {
192
+ name = property.key.name;
193
+ } else if (property.key.type === "Literal") {
194
+ name = String(property.key.value);
195
+ } else {
196
+ // A computed key is unknowable statically; a spread may carry
197
+ // anything. Silence beats guessing.
198
+ continue;
199
+ }
200
+ // CSS custom properties are supported (with var() and fallbacks).
201
+ if (name.startsWith("--")) continue;
202
+ const camel = toCamelCase(name);
203
+ if (!SUPPORTED.has(camel)) {
204
+ report({
205
+ node: property.key,
206
+ messageId: "unsupportedProperty",
207
+ data: { property: name },
208
+ });
209
+ continue;
210
+ }
211
+ if (
212
+ property.value.type === "Literal" &&
213
+ typeof property.value.value === "string" &&
214
+ property.value.value.includes("calc(")
215
+ ) {
216
+ report({
217
+ node: property.value,
218
+ messageId: "calc",
219
+ data: { property: name },
220
+ });
221
+ }
222
+ }
223
+ };
224
+
225
+ // Both structural checks only count children that are *certainly*
226
+ // rendered. A `{cond ? <a/> : null}` child may collapse to nothing, and
227
+ // counting it would flag templates that lay out fine — a false positive in
228
+ // a rule the whole fleet inherits costs more than the miss.
229
+ const isCertainElement = (child) =>
230
+ child.type === "JSXElement" ||
231
+ child.type === "JSXFragment" ||
232
+ (child.type === "JSXExpressionContainer" &&
233
+ (child.expression.type === "JSXElement" ||
234
+ child.expression.type === "JSXFragment"));
235
+
236
+ /** Raw text, or an interpolation that can only be a string/number. */
237
+ const isCertainText = (child) => {
238
+ if (child.type === "JSXText") return child.value.trim() !== "";
239
+ if (child.type !== "JSXExpressionContainer") return false;
240
+ const expression = child.expression;
241
+ return (
242
+ (expression.type === "Literal" &&
243
+ typeof expression.value !== "boolean") ||
244
+ expression.type === "TemplateLiteral"
245
+ );
246
+ };
247
+
248
+ return {
249
+ ImportDeclaration(node) {
250
+ if (OG_MODULES.has(node.source.value)) importsOg = true;
251
+ },
252
+ JSXAttribute(node) {
253
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "style") {
254
+ return;
255
+ }
256
+ const value = node.value;
257
+ if (value?.type !== "JSXExpressionContainer") return;
258
+ if (value.expression.type !== "ObjectExpression") return;
259
+ checkStyleObject(value.expression);
260
+ },
261
+ JSXElement(node) {
262
+ const texts = node.children.filter(isCertainText);
263
+ const elements = node.children.filter(isCertainElement);
264
+ if (texts.length > 0 && elements.length > 0) {
265
+ report({ node, messageId: "textWithElementSiblings" });
266
+ }
267
+
268
+ const children = texts.length + elements.length;
269
+ if (children < 2) return;
270
+
271
+ // A component's own JSX is that component's business; only the
272
+ // intrinsic elements satori lays out carry the flex rule.
273
+ if (node.openingElement.name.type !== "JSXIdentifier") return;
274
+ if (!/^[a-z]/.test(node.openingElement.name.name)) return;
275
+
276
+ let styleObject;
277
+ let hasUnknownStyle = false;
278
+ for (const attribute of node.openingElement.attributes) {
279
+ if (attribute.type === "JSXSpreadAttribute") {
280
+ hasUnknownStyle = true;
281
+ continue;
282
+ }
283
+ if (attribute.name.name !== "style") continue;
284
+ if (attribute.value?.type !== "JSXExpressionContainer") {
285
+ hasUnknownStyle = true;
286
+ } else if (attribute.value.expression.type === "ObjectExpression") {
287
+ styleObject = attribute.value.expression;
288
+ } else {
289
+ hasUnknownStyle = true;
290
+ }
291
+ }
292
+ if (hasUnknownStyle) return;
293
+ const declaresDisplay = styleObject?.properties.some(
294
+ (property) =>
295
+ property.type === "Property" &&
296
+ !property.computed &&
297
+ (property.key.name === "display" ||
298
+ property.key.value === "display"),
299
+ );
300
+ if (declaresDisplay) return;
301
+ report({
302
+ node: node.openingElement,
303
+ messageId: "missingFlex",
304
+ data: { count: String(children) },
305
+ });
306
+ },
307
+ "Program:exit"() {
308
+ if (!importsOg && !isConventionFile) return;
309
+ for (const descriptor of findings) context.report(descriptor);
310
+ },
311
+ };
312
+ },
313
+ };
314
+
315
+ export default {
316
+ meta: { name: "nextkit" },
317
+ rules: { "satori-css": satoriCss },
318
+ };
@@ -1,8 +1,10 @@
1
1
  import { checkAgentGuideImport } from "./agent-guide.js";
2
+ import { cleanGeneratedArtifacts, onlyGeneratedTypeErrors } from "./artifacts.js";
2
3
  import { toolDrift } from "./drift.js";
3
4
  import { FORMATTER } from "./formatter.js";
4
5
  import { hasKnipConfig, runKnip } from "./knip.js";
5
- import { run } from "./run.js";
6
+ import { checkSeal } from "./migrations.js";
7
+ import { run, runCapture, writeThrough } from "./run.js";
6
8
 
7
9
  /** `nk lint [...]` — oxlint, with extra args passed through (e.g. `--fix`). */
8
10
  export function lint(extraArgs = []) {
@@ -32,8 +34,19 @@ export function check() {
32
34
  " → add `@./node_modules/@ingram-tech/nk-dev/guide.md` to your CLAUDE.md (or run `nk init`).",
33
35
  );
34
36
  }
37
+ // Applied migrations are immutable. A no-op on sites without a `drizzle/`
38
+ // journal, so it costs non-database sites nothing.
39
+ const seal = checkSeal();
40
+ if (!seal.ok) {
41
+ console.error(`nk check: ${seal.reason}`);
42
+ console.error(
43
+ " → restore the file and add a new migration, or run `nk migrations` to seal a newly generated one.",
44
+ );
45
+ }
35
46
  warnToolDrift();
36
- process.exit(lintFailed || fmtFailed || knipFailed || !guide.ok ? 1 : 0);
47
+ process.exit(
48
+ lintFailed || fmtFailed || knipFailed || !guide.ok || !seal.ok ? 1 : 0,
49
+ );
37
50
  }
38
51
 
39
52
  /** Non-fatal: surface superseded deps so drift doesn't silently re-accumulate. */
@@ -48,13 +61,58 @@ function warnToolDrift() {
48
61
  );
49
62
  }
50
63
 
51
- /** `nk type-check` — the house type-check: regenerate Next's types, then tsc. */
64
+ /**
65
+ * `nk type-check` — the house type-check: regenerate Next's types, then tsc.
66
+ *
67
+ * Recovers from damaged generated types. `tsconfig.json` feeds Next's
68
+ * typed-routes output back into `tsc`, and a killed dev server can leave it
69
+ * truncated mid-write; `next typegen` does not repair it, so the same syntax
70
+ * error inside `.next/` survives every re-run and reads as a source defect.
71
+ * When *every* reported error sits in generated output, the artifacts are
72
+ * cleaned and the check retried once — see {@link cleanGeneratedArtifacts}.
73
+ *
74
+ * The retry matters beyond the confusing message: a syntax error in generated
75
+ * output suppresses semantic diagnostics for the whole program, so real `src/`
76
+ * errors are hidden behind it. Recovering surfaces them and still exits
77
+ * non-zero — it never turns a failing check into a passing one.
78
+ */
52
79
  export function typeCheck() {
53
80
  const typegen = run("next", ["typegen"]);
54
81
  if (typegen !== 0) process.exit(typegen);
82
+
83
+ const first = runCapture("tsc", ["--noEmit"]);
84
+ if (first.status === 0 || !onlyGeneratedTypeErrors(first.output)) {
85
+ // Either a pass, or errors the caller needs to read and fix themselves.
86
+ writeThrough(first);
87
+ process.exit(first.status);
88
+ }
89
+
90
+ const removed = cleanGeneratedArtifacts();
91
+ console.error(
92
+ `nk type-check: every error was inside generated types — removed ${removed.join(", ")} and retrying.`,
93
+ );
94
+
95
+ const regen = run("next", ["typegen"]);
96
+ if (regen !== 0) process.exit(regen);
97
+ // Retry with inherited stdio: this is the run whose output matters, and it
98
+ // keeps colour when a human is watching.
55
99
  process.exit(run("tsc", ["--noEmit"]));
56
100
  }
57
101
 
102
+ /**
103
+ * `nk clean` — remove build artifacts that tools regenerate from source
104
+ * (Next's generated types, TypeScript incremental caches). Safe by
105
+ * construction: whatever owns an artifact rebuilds it on the next run.
106
+ */
107
+ export function clean() {
108
+ const removed = cleanGeneratedArtifacts();
109
+ if (removed.length === 0) {
110
+ console.log("nk clean: no generated artifacts found.");
111
+ return;
112
+ }
113
+ console.log(`nk clean: removed ${removed.join(", ")}.`);
114
+ }
115
+
58
116
  /** `nk test [...]` — vitest run, with extra args passed through. */
59
117
  export function test(extraArgs = []) {
60
118
  process.exit(run("vitest", ["run", ...extraArgs]));
package/lib/run.js CHANGED
@@ -21,6 +21,40 @@ export function run(tool, args = [], opts = {}) {
21
21
  return res.status ?? (res.signal ? 1 : 0);
22
22
  }
23
23
 
24
+ /**
25
+ * Like {@link run}, but captures the tool's output instead of inheriting stdio,
26
+ * so a caller can inspect it before deciding what to print. Returns the exit
27
+ * code plus the combined output; the caller is responsible for forwarding it.
28
+ *
29
+ * Colour is left to the tool: with stdio piped there is no TTY, so tools that
30
+ * auto-detect print plain text — which is also what makes their output
31
+ * parseable.
32
+ */
33
+ export function runCapture(tool, args = [], opts = {}) {
34
+ const res = spawnSync("bun", ["x", tool, ...args], {
35
+ encoding: "utf8",
36
+ ...opts,
37
+ });
38
+ if (res.error) {
39
+ if (res.error.code === "ENOENT") {
40
+ fail("could not run `bun` — is bun installed and on PATH?");
41
+ }
42
+ throw res.error;
43
+ }
44
+ return {
45
+ status: res.status ?? (res.signal ? 1 : 0),
46
+ stdout: res.stdout ?? "",
47
+ stderr: res.stderr ?? "",
48
+ output: `${res.stdout ?? ""}${res.stderr ?? ""}`,
49
+ };
50
+ }
51
+
52
+ /** Forward a {@link runCapture} result to this process's stdio, unchanged. */
53
+ export function writeThrough({ stdout, stderr }) {
54
+ if (stdout) process.stdout.write(stdout);
55
+ if (stderr) process.stderr.write(stderr);
56
+ }
57
+
24
58
  /** Print an `nk:`-prefixed error and exit non-zero. */
25
59
  export function fail(message) {
26
60
  console.error(`nk: ${message}`);
package/oxlintrc.json CHANGED
@@ -13,6 +13,8 @@
13
13
  "nextkit/t-requires-values": "error",
14
14
  "nextkit/t-no-positional-args": "error",
15
15
  "nextkit/no-crypto-random-uuid": "warn",
16
+ "nextkit/no-redundant-node-crypto": "warn",
17
+ "nextkit/satori-css": "warn",
16
18
  "no-unused-vars": "warn",
17
19
  "typescript/no-non-null-assertion": "error",
18
20
  "typescript/no-explicit-any": "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-dev",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The nextkit dev toolchain in one package: the `nk` CLI plus shared oxlint/oxfmt, TypeScript, and Vitest config, the format-on-commit hook, and the AI agent guide. `nk init` scaffolds a site to use it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,17 +45,18 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@ast-grep/cli": "^0.44.1",
49
- "@testing-library/jest-dom": "^6.9.1",
48
+ "@ast-grep/cli": "^0.45.0",
49
+ "@testing-library/dom": "^10.4.1",
50
+ "@testing-library/jest-dom": "^7.0.0",
50
51
  "@typescript/native": "npm:typescript@^7.0.2",
51
- "jsdom": "^29.1.1",
52
- "knip": "^6.27.0",
53
- "oxfmt": "^0.59.0",
54
- "oxlint": "^1.74.0",
52
+ "jsdom": "^30.0.1",
53
+ "knip": "^6.31.0",
54
+ "oxfmt": "^0.61.0",
55
+ "oxlint": "^1.76.0",
55
56
  "typescript": "npm:@typescript/typescript6@^6.0.2",
56
57
  "vitest": "^4.1.10"
57
58
  },
58
59
  "engines": {
59
- "node": ">=20"
60
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
60
61
  }
61
62
  }