@homepages/template-kit 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +61 -17
  3. package/dist/base.css +9 -0
  4. package/dist/cli/check/config.js +37 -0
  5. package/dist/cli/check/css.js +117 -0
  6. package/dist/cli/check/diagnostics.js +32 -0
  7. package/dist/cli/check/index.js +87 -0
  8. package/dist/cli/check/loader.js +142 -0
  9. package/dist/cli/check/paths.js +15 -0
  10. package/dist/cli/check/relativize.js +18 -0
  11. package/dist/cli/check/render-invariants.js +24 -0
  12. package/dist/cli/check/resolve-tool.js +38 -0
  13. package/dist/cli/check/stages/deps.js +337 -0
  14. package/dist/cli/check/stages/lint.js +46 -0
  15. package/dist/cli/check/stages/render.js +101 -0
  16. package/dist/cli/check/stages/size.js +158 -0
  17. package/dist/cli/check/stages/tree.js +207 -0
  18. package/dist/cli/check/stages/typecheck.js +46 -0
  19. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
  20. package/dist/cli/check/stages/validate/facts.js +18 -0
  21. package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
  22. package/dist/cli/check/stages/validate/group-contract.js +21 -0
  23. package/dist/cli/check/stages/validate/index.js +66 -0
  24. package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
  25. package/dist/cli/check/stages/validate/nav-contract.js +93 -0
  26. package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
  27. package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
  28. package/dist/cli/check/stages/validate/orchestrator.js +241 -0
  29. package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
  30. package/dist/cli/check/stages/validate/row-contract.js +31 -0
  31. package/dist/cli/check/stages/validate/select-contract.js +13 -0
  32. package/dist/cli/check/stages/validate/source-contract.js +21 -0
  33. package/dist/cli/check/stages/validate/variant-contract.js +15 -0
  34. package/dist/cli/check/workspace.js +86 -0
  35. package/dist/cli/theme/generate.js +43 -0
  36. package/dist/cli/theme/index.js +33 -0
  37. package/dist/cli/theme/load-theme.js +69 -0
  38. package/dist/cli.js +22 -4
  39. package/dist/contracts/fill-treatments.js +47 -0
  40. package/dist/design-system/theme.d.ts +30 -300
  41. package/dist/design-system/theme.js +112 -90
  42. package/dist/eslint/rules/no-hex.js +78 -6
  43. package/dist/eslint.js +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +2 -2
  46. package/dist/package.js +1 -1
  47. package/dist/primitives/Image.js +1 -1
  48. package/dist/schema/fill-spec.d.ts +80 -590
  49. package/dist/schema/fixture-schema.d.ts +5 -81
  50. package/dist/schema/manifest.d.ts +39 -475
  51. package/dist/schema/resolve-section-ref.js +10 -0
  52. package/dist/schema/rows.js +10 -0
  53. package/dist/schema/section-nav.js +9 -0
  54. package/dist/schema/section-schema.d.ts +51 -437
  55. package/dist/schema/slot-types.d.ts +12 -16
  56. package/dist/styles.css +31 -88
  57. package/docs/INDEX.md +4 -3
  58. package/docs/check.md +121 -0
  59. package/docs/eslint.md +18 -8
  60. package/docs/llms.txt +21 -9
  61. package/docs/primitives.md +5 -0
  62. package/docs/rules/INDEX.md +11 -4
  63. package/docs/rules/bundle-binary-asset.md +102 -0
  64. package/docs/rules/fixtures-invalid.md +96 -0
  65. package/docs/rules/license-denied.md +11 -3
  66. package/docs/rules/manifest-invalid.md +99 -0
  67. package/docs/rules/no-hex.md +76 -10
  68. package/docs/rules/no-templates.md +87 -0
  69. package/docs/rules/parse-error.md +76 -0
  70. package/docs/rules/schema-invalid.md +96 -0
  71. package/docs/rules/size-section-css.md +2 -2
  72. package/docs/theme-and-css.md +155 -90
  73. package/package.json +4 -9
  74. package/tsconfig.json +1 -1
@@ -0,0 +1,142 @@
1
+ import { loadEsbuild } from "./resolve-tool.js";
2
+ import { join, relative } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdir, rm, stat } from "node:fs/promises";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ //#region src/cli/check/loader.ts
8
+ const CONTRACT_FILES = [
9
+ "Renderer.tsx",
10
+ "schema.ts",
11
+ "fill-spec.ts",
12
+ "fixtures.ts"
13
+ ];
14
+ const EXPECTED_EXPORT = {
15
+ "Renderer.tsx": "default",
16
+ "schema.ts": "schema",
17
+ "fill-spec.ts": "fillSpec",
18
+ "fixtures.ts": "default"
19
+ };
20
+ /**
21
+ * A section is missing one or more of the four contract files. Typed (not a bare
22
+ * Error) because exactly one stage owns this failure: the tree stage's
23
+ * `bundle-incomplete`, which names the missing file and where it belongs. Every
24
+ * other stage swallows this cause rather than re-reporting the same root problem
25
+ * under a second rule id.
26
+ */
27
+ var MissingContractFile = class extends Error {
28
+ files;
29
+ constructor(files) {
30
+ super(`Section is missing its contract file(s): ${files.join(", ")}.`);
31
+ this.files = files;
32
+ this.name = "MissingContractFile";
33
+ }
34
+ };
35
+ /** A contract file built, but does not export the binding the contract requires. `file` is workspace-relative. */
36
+ var MissingSectionExport = class extends Error {
37
+ file;
38
+ binding;
39
+ constructor(file, binding) {
40
+ super(binding === "default" ? `${file} has no default export. It must export the section's ${file.endsWith("Renderer.tsx") ? "renderer component" : "fixture module"} as its default export.` : `${file} does not export "${binding}". It must export a "${binding}" binding.`);
41
+ this.file = file;
42
+ this.binding = binding;
43
+ this.name = "MissingSectionExport";
44
+ }
45
+ };
46
+ const RESOLVING = Symbol("css-stub:resolving");
47
+ const cssStub = {
48
+ name: "css-stub",
49
+ setup(b) {
50
+ b.onResolve({ filter: /.*/ }, async (args) => {
51
+ if (args.pluginData === RESOLVING) return null;
52
+ const resolved = await b.resolve(args.path, {
53
+ importer: args.importer,
54
+ namespace: args.namespace,
55
+ resolveDir: args.resolveDir,
56
+ kind: args.kind,
57
+ pluginData: RESOLVING
58
+ });
59
+ if (resolved.errors.length === 0 && resolved.path !== "" && resolved.path.endsWith(".css")) return {
60
+ path: resolved.path,
61
+ namespace: "css-stub"
62
+ };
63
+ if (!args.path.startsWith(".") && !args.path.startsWith("/")) return {
64
+ path: args.path,
65
+ external: true
66
+ };
67
+ return null;
68
+ });
69
+ b.onLoad({
70
+ filter: /.*/,
71
+ namespace: "css-stub"
72
+ }, () => ({
73
+ contents: "",
74
+ loader: "js"
75
+ }));
76
+ }
77
+ };
78
+ const loadCache = /* @__PURE__ */ new Map();
79
+ /** Drops every memoized section load. Called once per `runCheck`; see the note above. */
80
+ function resetLoadCache() {
81
+ loadCache.clear();
82
+ }
83
+ /**
84
+ * Transpile + evaluate a section's four contract modules. Throws `MissingContractFile`
85
+ * when one of the four is absent, `MissingSectionExport` when one built but does not
86
+ * export its required binding, and a plain `Error` (esbuild's, path-relativized by the
87
+ * caller) when one fails to build.
88
+ */
89
+ function loadSection(workspaceRoot, section) {
90
+ const outdir = join(workspaceRoot, "node_modules", ".template-kit", "check", createHash("sha256").update(section.dir).digest("hex").slice(0, 12));
91
+ const cached = loadCache.get(outdir);
92
+ if (cached) return cached;
93
+ const promise = buildAndLoad(section, outdir, workspaceRoot);
94
+ loadCache.set(outdir, promise);
95
+ return promise;
96
+ }
97
+ const isFile = async (p) => {
98
+ try {
99
+ return (await stat(p)).isFile();
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+ async function buildAndLoad(section, outdir, workspaceRoot) {
105
+ const missing = [];
106
+ for (const file of CONTRACT_FILES) if (!await isFile(join(section.dir, file))) missing.push(file);
107
+ if (missing.length > 0) throw new MissingContractFile(missing);
108
+ await rm(outdir, {
109
+ recursive: true,
110
+ force: true
111
+ });
112
+ await mkdir(outdir, { recursive: true });
113
+ const { build } = await loadEsbuild(workspaceRoot);
114
+ await build({
115
+ entryPoints: CONTRACT_FILES.map((f) => join(section.dir, f)),
116
+ outdir,
117
+ bundle: true,
118
+ splitting: true,
119
+ format: "esm",
120
+ platform: "neutral",
121
+ jsx: "automatic",
122
+ logLevel: "silent",
123
+ plugins: [cssStub],
124
+ absWorkingDir: workspaceRoot
125
+ });
126
+ const load = async (source, emitted) => {
127
+ const mod = await import(pathToFileURL(join(outdir, emitted)).href);
128
+ const binding = EXPECTED_EXPORT[source];
129
+ const value = mod[binding];
130
+ if (value === void 0) throw new MissingSectionExport(relative(workspaceRoot, join(section.dir, source)), binding);
131
+ return value;
132
+ };
133
+ return {
134
+ Renderer: await load("Renderer.tsx", "Renderer.js"),
135
+ schema: await load("schema.ts", "schema.js"),
136
+ fillSpec: await load("fill-spec.ts", "fill-spec.js"),
137
+ fixtures: await load("fixtures.ts", "fixtures.js")
138
+ };
139
+ }
140
+
141
+ //#endregion
142
+ export { CONTRACT_FILES, MissingContractFile, MissingSectionExport, cssStub, loadSection, resetLoadCache };
@@ -0,0 +1,15 @@
1
+ import { relative, sep } from "node:path";
2
+
3
+ //#region src/cli/check/paths.ts
4
+ /** `file` is `dir` itself or nested under it. */
5
+ function isUnder(dir, file) {
6
+ const rel = relative(dir, file);
7
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
8
+ }
9
+ /** The section `file` sits inside, or `undefined` when it isn't under any of the template's sections. */
10
+ function sectionOf(template, file) {
11
+ return template.sections.find((section) => isUnder(section.dir, file))?.name;
12
+ }
13
+
14
+ //#endregion
15
+ export { isUnder, sectionOf };
@@ -0,0 +1,18 @@
1
+ import { resolve, sep } from "node:path";
2
+
3
+ //#region src/cli/check/relativize.ts
4
+ function escapeRegExp(s) {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ }
7
+ /**
8
+ * Rewrites every occurrence of `workspaceRoot` in a tool-sourced message to the
9
+ * path an author would type: workspace-relative, or "." for the root itself.
10
+ * Idempotent, and a no-op on a message that names no path.
11
+ */
12
+ function relativizePaths(message, workspaceRoot) {
13
+ const root = resolve(workspaceRoot);
14
+ return message.replace(new RegExp(`${escapeRegExp(root)}${escapeRegExp(sep)}`, "g"), "").replace(new RegExp(escapeRegExp(root), "g"), ".");
15
+ }
16
+
17
+ //#endregion
18
+ export { relativizePaths };
@@ -0,0 +1,24 @@
1
+ //#region src/cli/check/render-invariants.ts
2
+ function checkRenderInvariants(html) {
3
+ const violations = [];
4
+ if (html.includes("[object Object]")) violations.push({
5
+ rule: "sentinel-text",
6
+ detail: "rendered text contains \"[object Object]\""
7
+ });
8
+ for (const m of html.matchAll(/>\s*(null|undefined|NaN)\s*</g)) violations.push({
9
+ rule: "sentinel-text",
10
+ detail: `rendered leaf is the bare sentinel "${m[1]}"`
11
+ });
12
+ for (const m of html.matchAll(/<img\b[^>]*>/g)) {
13
+ const attr = /\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(m[0]);
14
+ const src = attr ? attr[1] ?? attr[2] : void 0;
15
+ if (src === void 0 || src.trim() === "" || src === "undefined" || src === "null") violations.push({
16
+ rule: "broken-image",
17
+ detail: `<img> with ${src === void 0 ? "no src" : `src="${src}"`}`
18
+ });
19
+ }
20
+ return violations;
21
+ }
22
+
23
+ //#endregion
24
+ export { checkRenderInvariants };
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { createRequire } from "node:module";
4
+
5
+ //#region src/cli/check/resolve-tool.ts
6
+ /**
7
+ * Loads `specifier` exactly as code running in the workspace at `root` would:
8
+ * resolved from that workspace's own `package.json`, never from this
9
+ * package's dependency tree. A missing install is a clean, actionable
10
+ * `Error` — never a bare `MODULE_NOT_FOUND` stack trace.
11
+ */
12
+ async function resolveFromWorkspace(root, specifier) {
13
+ const require = createRequire(pathToFileURL(join(root, "package.json")));
14
+ let resolved;
15
+ try {
16
+ resolved = require.resolve(specifier);
17
+ } catch {
18
+ throw new Error(`${specifier} is not installed in this workspace. Install it: npm i -D ${specifier}`);
19
+ }
20
+ return await import(pathToFileURL(resolved).href);
21
+ }
22
+ /**
23
+ * The workspace's esbuild — `check` transpiles a section's contract modules
24
+ * (loader.ts), bundles its package CSS (css.ts) and measures its published bytes
25
+ * (stages/size.ts) with it.
26
+ *
27
+ * `typeof import("esbuild")` is a TYPE-only import: it is erased at build time, so
28
+ * this module never carries a static `esbuild` import into the shipped `dist/`, and
29
+ * a consumer that only renders sections never installs it. Every call site must
30
+ * `await` this rather than importing `build` at module scope — a static import would
31
+ * put esbuild back on the critical path of `import "@homepages/template-kit"`.
32
+ */
33
+ function loadEsbuild(root) {
34
+ return resolveFromWorkspace(root, "esbuild");
35
+ }
36
+
37
+ //#endregion
38
+ export { loadEsbuild, resolveFromWorkspace };
@@ -0,0 +1,337 @@
1
+ import { CHECK_CONFIG } from "../config.js";
2
+ import { z } from "zod";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
6
+ import { execFile } from "node:child_process";
7
+
8
+ //#region src/cli/check/stages/deps.ts
9
+ const execFileAsync = promisify(execFile);
10
+ /** Real `execFile`-backed runner. Never throws on a non-zero exit — `npm audit`
11
+ * and `npm ls` both use exit codes to report *findings*, not tool failure, so
12
+ * the caller must always get the payload to parse. */
13
+ const defaultRun = async (cmd, args, cwd) => {
14
+ try {
15
+ const { stdout } = await execFileAsync(cmd, args, {
16
+ cwd,
17
+ maxBuffer: 64 * 1024 * 1024
18
+ });
19
+ return {
20
+ stdout,
21
+ code: 0
22
+ };
23
+ } catch (err) {
24
+ const e = err;
25
+ return {
26
+ stdout: typeof e.stdout === "string" ? e.stdout : "",
27
+ code: typeof e.code === "number" ? e.code : 1
28
+ };
29
+ }
30
+ };
31
+ async function isFile(p) {
32
+ try {
33
+ return (await stat(p)).isFile();
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+ async function isDir(p) {
39
+ try {
40
+ return (await stat(p)).isDirectory();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ /** `fs.realpath`, or `undefined` if the path is missing or unresolvable
46
+ * (e.g. a broken symlink or a genuine symlink loop, which trips `ELOOP`). */
47
+ async function realPathOrUndefined(p) {
48
+ try {
49
+ return await realpath(p);
50
+ } catch {
51
+ return;
52
+ }
53
+ }
54
+ async function readJsonIfPresent(path) {
55
+ if (!await isFile(path)) return void 0;
56
+ const text = await readFile(path, "utf8");
57
+ return JSON.parse(text);
58
+ }
59
+ const LOCKFILE_MISSING_FIX = "Run npm install and commit the package-lock.json it writes. A submission without one cannot be reproduced.";
60
+ function lockfileMissingGate(hasLockfile) {
61
+ if (hasLockfile) return [];
62
+ return [{
63
+ ruleId: "template-kit/lockfile-missing",
64
+ template: "",
65
+ message: "no package-lock.json at the workspace root.",
66
+ fix: LOCKFILE_MISSING_FIX
67
+ }];
68
+ }
69
+ const LOCKFILE_STALE_FIX = "The lockfile does not match package.json. Run npm install and commit the updated lockfile.";
70
+ const npmLsNodeSchema = z.lazy(() => z.object({
71
+ invalid: z.boolean().optional(),
72
+ dependencies: z.record(z.string(), npmLsNodeSchema).optional()
73
+ }).passthrough());
74
+ const npmLsPayloadSchema = z.object({
75
+ problems: z.array(z.unknown()).optional(),
76
+ dependencies: z.record(z.string(), npmLsNodeSchema).optional()
77
+ }).passthrough();
78
+ function anyInvalid(nodes) {
79
+ if (!nodes) return false;
80
+ for (const node of Object.values(nodes)) {
81
+ if (node.invalid === true) return true;
82
+ if (anyInvalid(node.dependencies)) return true;
83
+ }
84
+ return false;
85
+ }
86
+ async function lockfileStaleGate(run, workspaceRoot) {
87
+ const { stdout, code } = await run("npm", [
88
+ "ls",
89
+ "--json",
90
+ "--package-lock-only"
91
+ ], workspaceRoot);
92
+ const parsed = npmLsPayloadSchema.safeParse(safeJsonParse(stdout));
93
+ if (!(code !== 0 || !parsed.success || (parsed.data.problems?.length ?? 0) > 0 || anyInvalid(parsed.data.dependencies))) return [];
94
+ return [{
95
+ ruleId: "template-kit/lockfile-stale",
96
+ template: "",
97
+ message: "the lockfile does not satisfy package.json — npm ci would fail.",
98
+ fix: LOCKFILE_STALE_FIX
99
+ }];
100
+ }
101
+ function safeJsonParse(text) {
102
+ try {
103
+ return JSON.parse(text);
104
+ } catch {
105
+ return;
106
+ }
107
+ }
108
+ const SINGLE_REACT_FIX = "Two copies of React break hydration. React is a peer of everything here — deduplicate with npm dedupe, or align the dependency that pins its own copy.";
109
+ const packageManifestSchema = z.object({ version: z.string().optional() }).passthrough();
110
+ async function readVersion(pkgDir) {
111
+ try {
112
+ const raw = await readJsonIfPresent(join(pkgDir, "package.json"));
113
+ const parsed = packageManifestSchema.safeParse(raw);
114
+ return parsed.success && parsed.data.version !== void 0 ? parsed.data.version : "unknown version";
115
+ } catch {
116
+ return "unknown version";
117
+ }
118
+ }
119
+ /** True when `entry` is worth descending into: a real directory, or a
120
+ * symlink whose target is one. `Dirent.isDirectory()` reports the dirent's
121
+ * OWN type, not what it resolves to — a symlink to a directory (exactly how
122
+ * `npm link` and npm/yarn workspaces lay out linked packages) reports
123
+ * `false`, so trusting it blindly makes the walk blind to every linked
124
+ * package's own `node_modules`. `isDir` re-`stat`s, which follows the link. */
125
+ async function isTraversableDir(dir, entry) {
126
+ if (entry.isDirectory()) return true;
127
+ if (!entry.isSymbolicLink()) return false;
128
+ return isDir(join(dir, entry.name));
129
+ }
130
+ /**
131
+ * Walks `node_modules` for a nested `react` or `react-dom` — one whose own
132
+ * `node_modules` (not the workspace's top-level one) holds a copy. `owner` is
133
+ * the name of the package whose private `node_modules` directory holds the
134
+ * offending copy, so the message can point straight at what to dedupe.
135
+ *
136
+ * `topLevelReal` maps "react"/"react-dom" to the REAL (symlink-resolved)
137
+ * path of the workspace's own top-level copy, if any — see
138
+ * `visitReactCandidate` for why identity is checked against it rather than
139
+ * against walk depth. `visited` is the set of real directory paths already
140
+ * walked: a defense against a symlink cycle (a linked package whose own
141
+ * `node_modules` loops back to an ancestor already on the path) turning this
142
+ * into infinite recursion.
143
+ */
144
+ async function walkForNestedReact(dir, atTopLevel, owner, out, topLevelReal, visited) {
145
+ const real = await realPathOrUndefined(dir);
146
+ if (real === void 0 || visited.has(real)) return;
147
+ visited.add(real);
148
+ let entries;
149
+ try {
150
+ entries = await readdir(dir, { withFileTypes: true });
151
+ } catch {
152
+ return;
153
+ }
154
+ for (const entry of entries) {
155
+ if (!await isTraversableDir(dir, entry)) continue;
156
+ if (entry.name.startsWith("@")) {
157
+ const scopeDir = join(dir, entry.name);
158
+ let scoped;
159
+ try {
160
+ scoped = await readdir(scopeDir, { withFileTypes: true });
161
+ } catch {
162
+ continue;
163
+ }
164
+ for (const inner of scoped) {
165
+ if (!await isTraversableDir(scopeDir, inner)) continue;
166
+ await visitReactCandidate(join(scopeDir, inner.name), `${entry.name}/${inner.name}`, atTopLevel, owner, out, topLevelReal, visited);
167
+ }
168
+ continue;
169
+ }
170
+ await visitReactCandidate(join(dir, entry.name), entry.name, atTopLevel, owner, out, topLevelReal, visited);
171
+ }
172
+ }
173
+ async function visitReactCandidate(pkgDir, name, atTopLevel, owner, out, topLevelReal, visited) {
174
+ if (!atTopLevel && (name === "react" || name === "react-dom")) {
175
+ if ((await realPathOrUndefined(pkgDir) ?? pkgDir) !== topLevelReal.get(name)) out.push({
176
+ name,
177
+ version: await readVersion(pkgDir),
178
+ owner
179
+ });
180
+ }
181
+ const nested = join(pkgDir, "node_modules");
182
+ if (await isDir(nested)) await walkForNestedReact(nested, false, name, out, topLevelReal, visited);
183
+ }
184
+ async function singleReactGate(workspaceRoot) {
185
+ const nodeModules = join(workspaceRoot, "node_modules");
186
+ const offenders = [];
187
+ const topLevelReal = /* @__PURE__ */ new Map();
188
+ for (const name of ["react", "react-dom"]) {
189
+ const real = await realPathOrUndefined(join(nodeModules, name));
190
+ if (real !== void 0) topLevelReal.set(name, real);
191
+ }
192
+ await walkForNestedReact(nodeModules, true, "", offenders, topLevelReal, /* @__PURE__ */ new Set());
193
+ return offenders.map((offender) => ({
194
+ ruleId: "template-kit/single-react",
195
+ template: "",
196
+ message: `a nested copy of ${offender.name}@${offender.version} is bundled inside ${offender.owner}'s own node_modules.`,
197
+ fix: SINGLE_REACT_FIX
198
+ }));
199
+ }
200
+ const AUDIT_SEVERITY_FIX = "Run npm audit fix, or upgrade the package the advisory names.";
201
+ const npmAuditPayloadSchema = z.object({ metadata: z.object({ vulnerabilities: z.record(z.string(), z.number()).optional() }).passthrough().optional() }).passthrough();
202
+ async function auditSeverityGate(run, workspaceRoot) {
203
+ const { stdout } = await run("npm", ["audit", "--json"], workspaceRoot);
204
+ const parsed = npmAuditPayloadSchema.safeParse(safeJsonParse(stdout));
205
+ const vulnerabilities = parsed.success ? parsed.data.metadata?.vulnerabilities : void 0;
206
+ if (vulnerabilities === void 0) return [{
207
+ ruleId: "template-kit/audit-severity",
208
+ template: "",
209
+ message: "npm audit did not return a parseable report — the audit could not run.",
210
+ fix: "Run npm audit --json in the workspace and fix whatever is preventing it (usually missing network access), then re-run check."
211
+ }];
212
+ const offendingSeverities = CHECK_CONFIG.audit.failOn.filter((severity) => (vulnerabilities[severity] ?? 0) > 0);
213
+ if (offendingSeverities.length === 0) return [];
214
+ return [{
215
+ ruleId: "template-kit/audit-severity",
216
+ template: "",
217
+ message: `npm audit found ${offendingSeverities.map((severity) => `${String(vulnerabilities[severity] ?? 0)} ${severity}`).join(", ")} severity advisories.`,
218
+ fix: AUDIT_SEVERITY_FIX
219
+ }];
220
+ }
221
+ const LICENSE_DENIED_FIX = "Only permissively licensed packages can ship in a template. Replace this one, or ask us to review it.";
222
+ const lockPackageEntrySchema = z.object({
223
+ version: z.string().optional(),
224
+ license: z.string().optional(),
225
+ dependencies: z.record(z.string(), z.string()).optional(),
226
+ peerDependencies: z.record(z.string(), z.string()).optional(),
227
+ peerDependenciesMeta: z.record(z.string(), z.object({ optional: z.boolean().optional() }).passthrough()).optional()
228
+ }).passthrough();
229
+ const lockFileSchema = z.object({ packages: z.record(z.string(), lockPackageEntrySchema) }).passthrough();
230
+ /** `"node_modules/@scope/name/node_modules/other"` -> `["@scope/name", "other"]`; `""` -> `[]`. */
231
+ function parseChain(key) {
232
+ if (key === "") return [];
233
+ return key.split("/node_modules/").map((segment, i) => i === 0 ? segment.replace(/^node_modules\//, "") : segment);
234
+ }
235
+ function buildKey(chain) {
236
+ return chain.length === 0 ? "" : `node_modules/${chain.join("/node_modules/")}`;
237
+ }
238
+ /** Node's own nearest-ancestor `node_modules` resolution, applied to lockfile keys instead of disk. */
239
+ function resolveDependencyKey(packages, fromKey, name) {
240
+ const chain = parseChain(fromKey);
241
+ for (let i = chain.length; i >= 0; i--) {
242
+ const candidate = buildKey([...chain.slice(0, i), name]);
243
+ if (Object.hasOwn(packages, candidate)) return candidate;
244
+ }
245
+ }
246
+ /**
247
+ * The set of lockfile package keys in the production install tree (what an
248
+ * `npm ci --omit=dev` leaves on disk): the root's `dependencies` (never its
249
+ * `devDependencies`), walked transitively through each package's own
250
+ * `dependencies` and its `peerDependencies`. A `devDependencies` edge is a
251
+ * dead end. An optional peer is walked only when it resolves to an installed
252
+ * entry — one the workspace has anyway is kept by `npm ci --omit=dev`; one it
253
+ * lacks never installs and resolves to nothing.
254
+ */
255
+ function shippedPackageKeys(packages) {
256
+ const root = packages[""];
257
+ const visited = /* @__PURE__ */ new Set();
258
+ const queue = [];
259
+ const seed = (fromKey, deps) => {
260
+ if (!deps) return;
261
+ for (const name of Object.keys(deps)) {
262
+ const key = resolveDependencyKey(packages, fromKey, name);
263
+ if (key !== void 0) queue.push(key);
264
+ }
265
+ };
266
+ seed("", root?.dependencies);
267
+ for (let key = queue.pop(); key !== void 0; key = queue.pop()) {
268
+ if (visited.has(key)) continue;
269
+ visited.add(key);
270
+ const entry = packages[key];
271
+ if (!entry) continue;
272
+ seed(key, entry.dependencies);
273
+ if (entry.peerDependencies) for (const name of Object.keys(entry.peerDependencies)) {
274
+ const peerKey = resolveDependencyKey(packages, key, name);
275
+ if (peerKey !== void 0) queue.push(peerKey);
276
+ }
277
+ }
278
+ return visited;
279
+ }
280
+ function packageNameFromKey(key) {
281
+ const chain = parseChain(key);
282
+ return chain[chain.length - 1] ?? key;
283
+ }
284
+ async function licenseOf(entry, workspaceRoot, key) {
285
+ if (entry.license !== void 0 && entry.license !== "") return entry.license;
286
+ const raw = await readJsonIfPresent(join(workspaceRoot, key, "package.json")).catch(() => void 0);
287
+ const parsed = packageManifestLicenseSchema.safeParse(raw);
288
+ return parsed.success ? parsed.data.license : void 0;
289
+ }
290
+ const packageManifestLicenseSchema = z.object({ license: z.string().optional() }).passthrough();
291
+ async function licenseDeniedGate(lockfileText, workspaceRoot) {
292
+ const parsed = lockFileSchema.safeParse(safeJsonParse(lockfileText));
293
+ if (!parsed.success) return [];
294
+ const packages = parsed.data.packages;
295
+ const shipped = shippedPackageKeys(packages);
296
+ const out = [];
297
+ for (const key of shipped) {
298
+ const name = packageNameFromKey(key);
299
+ if (CHECK_CONFIG.licenseExemptions.includes(name)) continue;
300
+ const entry = packages[key];
301
+ if (!entry) continue;
302
+ const license = await licenseOf(entry, workspaceRoot, key);
303
+ const version = entry.version ?? "unknown version";
304
+ if (license === void 0) {
305
+ out.push({
306
+ ruleId: "template-kit/license-denied",
307
+ template: "",
308
+ message: `${name}@${version} has no license field.`,
309
+ fix: LICENSE_DENIED_FIX
310
+ });
311
+ continue;
312
+ }
313
+ if (!CHECK_CONFIG.licenseAllowlist.includes(license)) out.push({
314
+ ruleId: "template-kit/license-denied",
315
+ template: "",
316
+ message: `${name}@${version} is licensed ${license}, which is not on the allowlist.`,
317
+ fix: LICENSE_DENIED_FIX
318
+ });
319
+ }
320
+ return out;
321
+ }
322
+ async function depsStage(workspaceRoot, run = defaultRun) {
323
+ const lockfilePath = join(workspaceRoot, "package-lock.json");
324
+ const hasLockfile = await isFile(lockfilePath);
325
+ const diagnostics = [...lockfileMissingGate(hasLockfile)];
326
+ if (hasLockfile) {
327
+ diagnostics.push(...await lockfileStaleGate(run, workspaceRoot));
328
+ const lockfileText = await readFile(lockfilePath, "utf8");
329
+ diagnostics.push(...await licenseDeniedGate(lockfileText, workspaceRoot));
330
+ }
331
+ diagnostics.push(...await singleReactGate(workspaceRoot));
332
+ diagnostics.push(...await auditSeverityGate(run, workspaceRoot));
333
+ return diagnostics;
334
+ }
335
+
336
+ //#endregion
337
+ export { defaultRun, depsStage };
@@ -0,0 +1,46 @@
1
+ import { resolveFromWorkspace } from "../resolve-tool.js";
2
+ import { sectionOf } from "../paths.js";
3
+ import { relativizePaths } from "../relativize.js";
4
+ import { join, relative } from "node:path";
5
+
6
+ //#region src/cli/check/stages/lint.ts
7
+ const PARSE_ERROR_RULE_ID = "template-kit/parse-error";
8
+ const FIX_HINTS = {
9
+ "template-kit/no-nondeterminism": "Remove the clock or randomness read from server-rendered code, or move this logic into an island (a file marked use client).",
10
+ "template-kit/no-client-runtime-in-server": "Move this into an island (mark the file use client) or drop the browser-only API — a Renderer must be a pure function of its props.",
11
+ "template-kit/serializable-island-props": "Pass only JSON-serializable values to an island: no functions, Date/Map/Set instances, bigints, NaN, or Infinity.",
12
+ "template-kit/no-inline-style": "Replace the style prop with a Tailwind class or a design-system token. Inline style bypasses the cascade and responsive prefixes.",
13
+ "template-kit/no-raw-element": "Use the contract-bearing primitive instead of the raw element: Section for section, Image for img.",
14
+ "template-kit/image-bare-needs-reason": "Add a bare: comment above the Image explaining why the frame is skipped, or remove the bare prop.",
15
+ "template-kit/slot-marker-literal": "Give the marker's id a string literal, not an expression, so the slot-marker completeness check can read it statically.",
16
+ "template-kit/no-hex": "Use a design-system color token instead of a hex literal or CSS color function.",
17
+ "template-kit/no-css-import-from-render-path": "Delete the relative CSS import. The file's own CSS is already collected by path; use a bare package specifier for a third-party stylesheet.",
18
+ "template-kit/props-from-schema": "Derive Props from SectionProps<typeof schema> instead of hand-writing the type.",
19
+ "template-kit/no-client-directive-in-contract": "Remove the use client directive from this contract file. schema.ts, fill-spec.ts, fixtures.ts and Renderer.tsx are read outside the browser."
20
+ };
21
+ async function lintStage(workspaceRoot, template) {
22
+ const { ESLint } = await resolveFromWorkspace(workspaceRoot, "eslint");
23
+ const results = await new ESLint({
24
+ cwd: workspaceRoot,
25
+ errorOnUnmatchedPattern: false
26
+ }).lintFiles([join(template.dir, "**/*.{ts,tsx}")]);
27
+ const out = [];
28
+ for (const result of results) for (const message of result.messages) {
29
+ if (message.severity !== 2) continue;
30
+ const ruleId = message.ruleId ?? PARSE_ERROR_RULE_ID;
31
+ const text = relativizePaths(message.message, workspaceRoot);
32
+ out.push({
33
+ ruleId,
34
+ template: template.key,
35
+ section: sectionOf(template, result.filePath),
36
+ file: relative(workspaceRoot, result.filePath),
37
+ line: message.line,
38
+ message: text,
39
+ fix: FIX_HINTS[ruleId] ?? text
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ //#endregion
46
+ export { lintStage };