@homepages/template-kit 0.1.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 (111) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README.md +70 -16
  3. package/dist/base.css +36 -48
  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 -299
  41. package/dist/design-system/theme.js +112 -83
  42. package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
  43. package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
  44. package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
  45. package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
  46. package/dist/eslint/rules/no-hex.js +84 -7
  47. package/dist/eslint/rules/no-inline-style.js +5 -1
  48. package/dist/eslint/rules/no-nondeterminism.js +5 -1
  49. package/dist/eslint/rules/no-raw-element.js +6 -1
  50. package/dist/eslint/rules/props-from-schema.js +6 -1
  51. package/dist/eslint/rules/serializable-island-props.js +5 -1
  52. package/dist/eslint/rules/slot-marker-literal.js +6 -1
  53. package/dist/eslint.js +1 -1
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.js +2 -2
  56. package/dist/package.js +1 -1
  57. package/dist/primitives/Image.js +1 -1
  58. package/dist/rules/registry.js +16 -0
  59. package/dist/schema/fill-spec.d.ts +80 -590
  60. package/dist/schema/fixture-schema.d.ts +5 -81
  61. package/dist/schema/manifest.d.ts +39 -475
  62. package/dist/schema/resolve-section-ref.js +10 -0
  63. package/dist/schema/rows.js +10 -0
  64. package/dist/schema/section-nav.js +9 -0
  65. package/dist/schema/section-schema.d.ts +51 -437
  66. package/dist/schema/slot-types.d.ts +12 -16
  67. package/dist/styles.css +31 -88
  68. package/docs/INDEX.md +5 -3
  69. package/docs/check.md +121 -0
  70. package/docs/eslint.md +25 -50
  71. package/docs/llms.txt +26 -11
  72. package/docs/primitives.md +5 -0
  73. package/docs/rules/INDEX.md +65 -0
  74. package/docs/rules/audit-severity.md +91 -0
  75. package/docs/rules/bundle-binary-asset.md +102 -0
  76. package/docs/rules/bundle-incomplete.md +117 -0
  77. package/docs/rules/css-reason.md +94 -0
  78. package/docs/rules/determinism-drift.md +112 -0
  79. package/docs/rules/fixtures-invalid.md +96 -0
  80. package/docs/rules/image-bare-needs-reason.md +117 -0
  81. package/docs/rules/license-denied.md +114 -0
  82. package/docs/rules/lockfile-missing.md +68 -0
  83. package/docs/rules/lockfile-stale.md +88 -0
  84. package/docs/rules/manifest-invalid.md +99 -0
  85. package/docs/rules/missing-slot-marker.md +140 -0
  86. package/docs/rules/no-bare-css-import.md +128 -0
  87. package/docs/rules/no-client-directive-in-contract.md +111 -0
  88. package/docs/rules/no-client-runtime-in-server.md +143 -0
  89. package/docs/rules/no-css-import-from-render-path.md +118 -0
  90. package/docs/rules/no-hex.md +163 -0
  91. package/docs/rules/no-inline-style.md +121 -0
  92. package/docs/rules/no-nondeterminism.md +100 -0
  93. package/docs/rules/no-raw-element.md +100 -0
  94. package/docs/rules/no-templates.md +87 -0
  95. package/docs/rules/parse-error.md +76 -0
  96. package/docs/rules/props-from-schema.md +119 -0
  97. package/docs/rules/render-invariant.md +165 -0
  98. package/docs/rules/schema-invalid.md +96 -0
  99. package/docs/rules/serializable-island-props.md +146 -0
  100. package/docs/rules/server-vs-client.md +105 -0
  101. package/docs/rules/sidebar-order.md +109 -0
  102. package/docs/rules/single-react.md +97 -0
  103. package/docs/rules/size-renderer-bundle.md +109 -0
  104. package/docs/rules/size-section-css.md +95 -0
  105. package/docs/rules/slot-group-marker.md +127 -0
  106. package/docs/rules/slot-marker-literal.md +148 -0
  107. package/docs/rules/typecheck.md +103 -0
  108. package/docs/rules/unlayered-fence.md +113 -0
  109. package/docs/theme-and-css.md +193 -82
  110. package/package.json +4 -9
  111. package/tsconfig.json +1 -1
@@ -0,0 +1,87 @@
1
+ import { KIT_VERSION } from "../../index.js";
2
+ import { formatHuman, toJson } from "./diagnostics.js";
3
+ import { loadEsbuild } from "./resolve-tool.js";
4
+ import { resetLoadCache } from "./loader.js";
5
+ import { depsStage } from "./stages/deps.js";
6
+ import { lintStage } from "./stages/lint.js";
7
+ import { renderStage } from "./stages/render.js";
8
+ import { sizeStage } from "./stages/size.js";
9
+ import { treeStage } from "./stages/tree.js";
10
+ import { typecheckStage } from "./stages/typecheck.js";
11
+ import { validateStage } from "./stages/validate/index.js";
12
+ import { findWorkspaceRoot, selectTemplates, templateFromCwd } from "./workspace.js";
13
+ import { parseArgs } from "node:util";
14
+
15
+ //#region src/cli/check/index.ts
16
+ const STAGES = [
17
+ typecheckStage,
18
+ lintStage,
19
+ validateStage,
20
+ treeStage,
21
+ renderStage,
22
+ sizeStage
23
+ ];
24
+ /**
25
+ * A run that gated nothing must never report success. `every()` over an empty set
26
+ * is vacuously true, so a selection of zero templates printed "check passed" and
27
+ * exited 0 having run not one gate — the worst failure this command has, since
28
+ * ingest re-runs it and would accept the submission on that same silence.
29
+ */
30
+ function emptySelectionDiagnostics(selected, all) {
31
+ if (selected.length > 0) return [];
32
+ return [{
33
+ ruleId: "template-kit/no-templates",
34
+ template: "",
35
+ file: "templates",
36
+ message: all ? "check --all found no template to check: templates/ holds no template folder." : "This workspace has no template to check: templates/ holds no template folder.",
37
+ fix: "Add a template: templates/<key>/ with a manifest.json declaring its section composition and a sections/ folder holding the sections it composes."
38
+ }];
39
+ }
40
+ async function checkTemplate(root, template) {
41
+ const diagnostics = [];
42
+ for (const stage of STAGES) diagnostics.push(...await stage(root, template));
43
+ return {
44
+ ok: diagnostics.length === 0,
45
+ diagnostics
46
+ };
47
+ }
48
+ async function runCheck(argv, cwd) {
49
+ resetLoadCache();
50
+ const { values, positionals } = parseArgs({
51
+ args: argv,
52
+ options: {
53
+ all: {
54
+ type: "boolean",
55
+ default: false
56
+ },
57
+ json: {
58
+ type: "boolean",
59
+ default: false
60
+ }
61
+ },
62
+ allowPositionals: true
63
+ });
64
+ const root = await findWorkspaceRoot(cwd);
65
+ const selected = await selectTemplates(root, positionals[0] ?? (values.all ? void 0 : templateFromCwd(root, cwd)), values.all, "check");
66
+ if (selected.length > 0) await loadEsbuild(root);
67
+ const templates = {};
68
+ for (const template of selected) templates[template.key] = await checkTemplate(root, template);
69
+ const workspaceDiagnostics = [...await depsStage(root), ...emptySelectionDiagnostics(selected, values.all)];
70
+ const workspace = {
71
+ ok: workspaceDiagnostics.length === 0,
72
+ diagnostics: workspaceDiagnostics
73
+ };
74
+ const report = {
75
+ ok: workspace.ok && Object.values(templates).every((t) => t.ok),
76
+ kitVersion: KIT_VERSION,
77
+ templates,
78
+ workspace
79
+ };
80
+ return {
81
+ code: report.ok ? 0 : 1,
82
+ output: values.json ? toJson(report) : formatHuman(report)
83
+ };
84
+ }
85
+
86
+ //#endregion
87
+ export { runCheck };
@@ -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 };