@ingram-tech/nk-dev 0.8.0 → 0.10.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/bin/nk.js CHANGED
@@ -5,7 +5,7 @@ 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 { build, check, clean, lint, test, typeCheck } from "../lib/passthrough.js";
9
9
 
10
10
  const USAGE = `nk — the nextkit CLI
11
11
 
@@ -27,7 +27,10 @@ Commands:
27
27
  mechanical refactors — see the codemod skill.
28
28
  check The CI gate: lint + format verify + knip (when configured)
29
29
  + the agent-guide import gate.
30
- type-check next typegen && tsc --noEmit.
30
+ type-check next typegen && tsc --noEmit. Recovers automatically when
31
+ generated types are damaged (e.g. a killed dev server).
32
+ clean Remove regenerable build artifacts: Next's generated
33
+ types and TypeScript incremental caches.
31
34
  test [...] vitest run (extra args passed through).
32
35
  build [...] next build (extra args passed through).
33
36
 
@@ -64,6 +67,9 @@ switch (cmd) {
64
67
  case "type-check":
65
68
  typeCheck();
66
69
  break;
70
+ case "clean":
71
+ clean();
72
+ break;
67
73
  case "test":
68
74
  test(rest);
69
75
  break;
@@ -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
+ }
@@ -5,8 +5,10 @@
5
5
  import baseUi from "./base-ui.js";
6
6
  import deferredCurrentTarget from "./deferred-current-target.js";
7
7
  import lucideIconSuffix from "./lucide-icon-suffix.js";
8
+ import noCryptoRandomUuid from "./no-crypto-random-uuid.js";
8
9
  import noRedirectOnlyPage from "./no-redirect-only-page.js";
9
10
  import redundantUseStateType from "./redundant-usestate-type.js";
11
+ import satoriCss from "./satori-css.js";
10
12
  import tNoPositionalArgs from "./t-no-positional-args.js";
11
13
  import tRequiresValues from "./t-requires-values.js";
12
14
 
@@ -16,8 +18,10 @@ export default {
16
18
  ...baseUi.rules,
17
19
  ...deferredCurrentTarget.rules,
18
20
  ...lucideIconSuffix.rules,
21
+ ...noCryptoRandomUuid.rules,
19
22
  ...noRedirectOnlyPage.rules,
20
23
  ...redundantUseStateType.rules,
24
+ ...satoriCss.rules,
21
25
  ...tNoPositionalArgs.rules,
22
26
  ...tRequiresValues.rules,
23
27
  },
@@ -0,0 +1,117 @@
1
+ // nextkit oxlint JS plugin rule: keep `crypto.randomUUID()` off the id write
2
+ // path.
3
+ //
4
+ // nextkit ids are UUIDv7: time-ordered, so inserts land at the right edge of the
5
+ // primary-key B-tree instead of scattering across it. `crypto.randomUUID()` is
6
+ // v4 — uniformly random — so a single call site minting a stored id fragments
7
+ // that index while every other row in the table stays ordered. The mismatch is
8
+ // invisible until the table is large, which is exactly when it is expensive to
9
+ // undo.
10
+ //
11
+ // The mint is `uuidGenerateId()` from `@ingram-tech/nk-db/id`, already typed
12
+ // `Uuid`. Most rows need no mint at all: `uuid("id").primaryKey().default(sql`
13
+ // `uuidv7()`)` lets the database do it, and the app only mints when it needs the
14
+ // id *before* the insert (a client-chosen document PK it must also use as the
15
+ // storage object name).
16
+ //
17
+ // Deliberately not autofixable. The right replacement depends on what the value
18
+ // is, and one of the answers is "leave it alone":
19
+ //
20
+ // - a stored id -> uuidGenerateId(), or drop it for the column default
21
+ // - a bearer token / nonce -> keep crypto.randomUUID()
22
+ //
23
+ // v7 is the *wrong* choice for a secret. It spends 48 bits on a millisecond
24
+ // timestamp, leaving 74 random bits against v4's 122, and it leaks its own
25
+ // creation time to whoever holds it. Invitation tokens, OAuth `state`, password
26
+ // reset links and similar unguessable values must stay v4 — silence the rule at
27
+ // those call sites with a justified disable comment rather than "fixing" them:
28
+ //
29
+ // // oxlint-disable-next-line nextkit/no-crypto-random-uuid -- CSRF nonce, wants v4 entropy
30
+ //
31
+ // Test files are exempt via an override in the shared oxlintrc: test rows are
32
+ // ephemeral, so index locality is meaningless there and `crypto.randomUUID()`
33
+ // stays the zero-import default that keeps fixtures readable.
34
+
35
+ const NODE_CRYPTO_MODULES = new Set(["crypto", "node:crypto"]);
36
+
37
+ /** `crypto.randomUUID` or `globalThis.crypto.randomUUID`, and not shadowed. */
38
+ const isGlobalCryptoRandomUuid = (callee, scope) => {
39
+ if (callee.type !== "MemberExpression") return false;
40
+ if (callee.computed) return false;
41
+ if (callee.property.type !== "Identifier") return false;
42
+ if (callee.property.name !== "randomUUID") return false;
43
+
44
+ const object = callee.object;
45
+ let root;
46
+ if (object.type === "Identifier" && object.name === "crypto") {
47
+ root = object;
48
+ } else if (
49
+ object.type === "MemberExpression" &&
50
+ !object.computed &&
51
+ object.object.type === "Identifier" &&
52
+ object.object.name === "globalThis" &&
53
+ object.property.type === "Identifier" &&
54
+ object.property.name === "crypto"
55
+ ) {
56
+ // `globalThis.crypto` can't be shadowed by a local binding.
57
+ return true;
58
+ } else {
59
+ return false;
60
+ }
61
+
62
+ // A local `crypto` (a mock, an injected dependency) is not the global one.
63
+ for (let current = scope; current; current = current.upper) {
64
+ const variable = current.variables?.find((v) => v.name === root.name);
65
+ if (!variable) continue;
66
+ return variable.defs.length === 0;
67
+ }
68
+ return true;
69
+ };
70
+
71
+ const noCryptoRandomUuid = {
72
+ meta: {
73
+ type: "problem",
74
+ docs: {
75
+ description:
76
+ "Disallow crypto.randomUUID() (UUIDv4) where nextkit ids are UUIDv7",
77
+ },
78
+ messages: {
79
+ cryptoRandomUuid:
80
+ "`crypto.randomUUID()` is UUIDv4; stored ids are UUIDv7. Mint with `uuidGenerateId()` from `@ingram-tech/nk-db/id`, or omit the id and let the `uuidv7()` column default apply. If this is a bearer token or nonce, keep v4 and add `// oxlint-disable-next-line nextkit/no-crypto-random-uuid -- <reason>`.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const sourceCode = context.sourceCode;
85
+ // Local names bound to `randomUUID` imported from node:crypto.
86
+ const importedNames = new Set();
87
+
88
+ return {
89
+ ImportDeclaration(node) {
90
+ if (!NODE_CRYPTO_MODULES.has(node.source.value)) return;
91
+ for (const specifier of node.specifiers) {
92
+ if (specifier.type !== "ImportSpecifier") continue;
93
+ if (specifier.imported.type !== "Identifier") continue;
94
+ if (specifier.imported.name !== "randomUUID") continue;
95
+ importedNames.add(specifier.local.name);
96
+ }
97
+ },
98
+ CallExpression(node) {
99
+ const callee = node.callee;
100
+ const isImported =
101
+ callee.type === "Identifier" && importedNames.has(callee.name);
102
+ if (
103
+ !isImported &&
104
+ !isGlobalCryptoRandomUuid(callee, sourceCode.getScope(node))
105
+ ) {
106
+ return;
107
+ }
108
+ context.report({ node, messageId: "cryptoRandomUuid" });
109
+ },
110
+ };
111
+ },
112
+ };
113
+
114
+ export default {
115
+ meta: { name: "nextkit" },
116
+ rules: { "no-crypto-random-uuid": noCryptoRandomUuid },
117
+ };
@@ -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,9 @@
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 { run, runCapture, writeThrough } from "./run.js";
6
7
 
7
8
  /** `nk lint [...]` — oxlint, with extra args passed through (e.g. `--fix`). */
8
9
  export function lint(extraArgs = []) {
@@ -48,13 +49,58 @@ function warnToolDrift() {
48
49
  );
49
50
  }
50
51
 
51
- /** `nk type-check` — the house type-check: regenerate Next's types, then tsc. */
52
+ /**
53
+ * `nk type-check` — the house type-check: regenerate Next's types, then tsc.
54
+ *
55
+ * Recovers from damaged generated types. `tsconfig.json` feeds Next's
56
+ * typed-routes output back into `tsc`, and a killed dev server can leave it
57
+ * truncated mid-write; `next typegen` does not repair it, so the same syntax
58
+ * error inside `.next/` survives every re-run and reads as a source defect.
59
+ * When *every* reported error sits in generated output, the artifacts are
60
+ * cleaned and the check retried once — see {@link cleanGeneratedArtifacts}.
61
+ *
62
+ * The retry matters beyond the confusing message: a syntax error in generated
63
+ * output suppresses semantic diagnostics for the whole program, so real `src/`
64
+ * errors are hidden behind it. Recovering surfaces them and still exits
65
+ * non-zero — it never turns a failing check into a passing one.
66
+ */
52
67
  export function typeCheck() {
53
68
  const typegen = run("next", ["typegen"]);
54
69
  if (typegen !== 0) process.exit(typegen);
70
+
71
+ const first = runCapture("tsc", ["--noEmit"]);
72
+ if (first.status === 0 || !onlyGeneratedTypeErrors(first.output)) {
73
+ // Either a pass, or errors the caller needs to read and fix themselves.
74
+ writeThrough(first);
75
+ process.exit(first.status);
76
+ }
77
+
78
+ const removed = cleanGeneratedArtifacts();
79
+ console.error(
80
+ `nk type-check: every error was inside generated types — removed ${removed.join(", ")} and retrying.`,
81
+ );
82
+
83
+ const regen = run("next", ["typegen"]);
84
+ if (regen !== 0) process.exit(regen);
85
+ // Retry with inherited stdio: this is the run whose output matters, and it
86
+ // keeps colour when a human is watching.
55
87
  process.exit(run("tsc", ["--noEmit"]));
56
88
  }
57
89
 
90
+ /**
91
+ * `nk clean` — remove build artifacts that tools regenerate from source
92
+ * (Next's generated types, TypeScript incremental caches). Safe by
93
+ * construction: whatever owns an artifact rebuilds it on the next run.
94
+ */
95
+ export function clean() {
96
+ const removed = cleanGeneratedArtifacts();
97
+ if (removed.length === 0) {
98
+ console.log("nk clean: no generated artifacts found.");
99
+ return;
100
+ }
101
+ console.log(`nk clean: removed ${removed.join(", ")}.`);
102
+ }
103
+
58
104
  /** `nk test [...]` — vitest run, with extra args passed through. */
59
105
  export function test(extraArgs = []) {
60
106
  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
@@ -12,6 +12,8 @@
12
12
  "nextkit/no-redirect-only-page": "warn",
13
13
  "nextkit/t-requires-values": "error",
14
14
  "nextkit/t-no-positional-args": "error",
15
+ "nextkit/no-crypto-random-uuid": "warn",
16
+ "nextkit/satori-css": "warn",
15
17
  "no-unused-vars": "warn",
16
18
  "typescript/no-non-null-assertion": "error",
17
19
  "typescript/no-explicit-any": "error",
@@ -28,5 +30,13 @@
28
30
  "jsx-a11y/prefer-tag-over-role": "off",
29
31
  "jsx-a11y/no-autofocus": "off",
30
32
  "jsx-a11y/role-has-required-aria-props": "off"
31
- }
33
+ },
34
+ "overrides": [
35
+ {
36
+ "files": ["**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/test/**"],
37
+ "rules": {
38
+ "nextkit/no-crypto-random-uuid": "off"
39
+ }
40
+ }
41
+ ]
32
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-dev",
3
- "version": "0.8.0",
3
+ "version": "0.10.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
  }