@jay-framework/jay-stack-cli 0.24.7 → 0.25.1

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/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { checkValidationErrors, JAY_CONTRACT_EXTENSION as JAY_CONTRACT_EXTENSION
12
12
  import fs$2 from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import path__default from "node:path";
15
- import { scanPlugins, sortPluginsByDependencies, getServiceRegistry, actionRegistry, resolveServices, ActionRegistry, discoverPluginsWithInit, executePluginServerInits, runInitCallbacks, registerService } from "@jay-framework/stack-server-runtime";
15
+ import { scanPlugins, sortPluginsByDependencies, getServiceRegistry, actionRegistry, resolveServices, headMetaToHeadTags, ActionRegistry, discoverPluginsWithInit, executePluginServerInits, runInitCallbacks, registerService } from "@jay-framework/stack-server-runtime";
16
16
  import { createRequire } from "module";
17
17
  import jsBeautify from "js-beautify";
18
18
  import "node:crypto";
@@ -21,10 +21,11 @@ import fs__default from "node:fs";
21
21
  import { createRequire as createRequire$1 } from "node:module";
22
22
  import { isJayAction, isJayStreamAction, isJayCliCommand, CONSOLE_CONTEXT } from "@jay-framework/fullstack-component";
23
23
  import { parse, NodeType, HTMLElement } from "node-html-parser";
24
- import { Command } from "commander";
24
+ import { Command, Option } from "commander";
25
25
  import chalk from "chalk";
26
26
  import { glob } from "glob";
27
27
  import postcss from "postcss";
28
+ import { scanRoutes, JayRouteParamType } from "@jay-framework/stack-route-scanner";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { select, confirm, input } from "@inquirer/prompts";
30
31
  const DEFAULT_CONFIG = {
@@ -4253,11 +4254,10 @@ function firstColon(decl) {
4253
4254
  }
4254
4255
  function diffBodies(sourceParent, regionParent) {
4255
4256
  const out = [];
4256
- const ignoreClass = isRegionTag(regionParent) ? readAttr(regionParent, "ref") : void 0;
4257
- diffChildren([], sourceParent, regionParent, ignoreClass, out);
4257
+ diffChildren([], sourceParent, regionParent, out);
4258
4258
  return out;
4259
4259
  }
4260
- function diffChildren(parentPath, sParent, rParent, ignoreClass, out) {
4260
+ function diffChildren(parentPath, sParent, rParent, out) {
4261
4261
  const rSup = isElement(rParent) ? parseOverride(rParent) : NO_SUPPRESSION;
4262
4262
  const sSup = isElement(sParent) ? parseContent(sParent) : NO_SUPPRESSION;
4263
4263
  const sup = unionSuppression(rSup, sSup);
@@ -4280,24 +4280,23 @@ function diffChildren(parentPath, sParent, rParent, ignoreClass, out) {
4280
4280
  if (rc.nodeType !== NodeType.ELEMENT_NODE) continue;
4281
4281
  const re = rc;
4282
4282
  if (isRegionTag(re)) continue;
4283
- const childIgnoreClass = parentPath.length === 0 ? ignoreClass : void 0;
4284
- diffElement([...parentPath, i], s2[i], re, childIgnoreClass, out);
4283
+ diffElement([...parentPath, i], s2[i], re, out);
4285
4284
  }
4286
4285
  }
4287
- function diffElement(path2, se, re, ignoreClass, out) {
4286
+ function diffElement(path2, se, re, out) {
4288
4287
  const sup = unionSuppression(parseOverride(re), parseContent(se));
4289
4288
  if (sup.all) return;
4290
- compareAttributes(path2, se, re, sup, ignoreClass, out);
4289
+ compareAttributes(path2, se, re, sup, out);
4291
4290
  compareStyle(path2, se, re, sup, out);
4292
- diffChildren(path2, se, re, void 0, out);
4291
+ diffChildren(path2, se, re, out);
4293
4292
  }
4294
- function compareAttributes(path2, se, re, sup, ignoreClass, out) {
4293
+ function compareAttributes(path2, se, re, sup, out) {
4295
4294
  const sm = attrMap(se);
4296
4295
  const rm = attrMap(re);
4297
4296
  const names = [.../* @__PURE__ */ new Set([...sm.keys(), ...rm.keys()])].sort();
4298
4297
  for (const name of names) {
4299
- const sv = name === "class" ? stripClassToken(sm.get(name), ignoreClass) : sm.get(name);
4300
- const rv = name === "class" ? stripClassToken(rm.get(name), ignoreClass) : rm.get(name);
4298
+ const sv = sm.get(name);
4299
+ const rv = rm.get(name);
4301
4300
  const change = scalarChange(sv, rv, (a, b) => normalizeExpr(a) === normalizeExpr(b));
4302
4301
  if (!change) continue;
4303
4302
  const facet = { kind: "attribute", path: path2, element: tagOf(re), name };
@@ -4319,11 +4318,6 @@ function compareStyle(path2, se, re, sup, out) {
4319
4318
  out.push({ facet, change, sourceValue: sv, regionValue: rv });
4320
4319
  }
4321
4320
  }
4322
- function stripClassToken(value, token) {
4323
- if (value === void 0 || !token) return value;
4324
- const kept = value.split(/\s+/).filter((t) => t && t !== token);
4325
- return kept.length ? kept.join(" ") : void 0;
4326
- }
4327
4321
  function scalarChange(sv, rv, equal) {
4328
4322
  if (sv === void 0 && rv === void 0) return void 0;
4329
4323
  if (sv === void 0) return "added";
@@ -4541,7 +4535,7 @@ function coalesceCss(contributions) {
4541
4535
  (g) => g.selectors.length > 0 ? scopeWrap(g.css, g.selectors.join(", "), g.to) : g.css
4542
4536
  ).join("\n\n");
4543
4537
  }
4544
- function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = false) {
4538
+ function fillRegions(container, stack, opts, errors, cssBlocks) {
4545
4539
  for (const region of directRegions(container)) {
4546
4540
  const name = contractName(region);
4547
4541
  const templatePath = opts.resolveTemplate(name);
@@ -4559,15 +4553,11 @@ function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = f
4559
4553
  );
4560
4554
  continue;
4561
4555
  }
4562
- const existing = region.innerHTML;
4563
- const filled = opts.preserveOverrides ? mergeOverrides(template.body, existing) : template.body;
4556
+ const filled = opts.preserveOverrides ? mergeOverrides(template.body, region.innerHTML) : template.body;
4564
4557
  region.set_content(filled);
4565
- const ref = readAttr(region, "ref");
4566
- let scoped = false;
4567
4558
  if (template.css) {
4568
4559
  const selector = (opts.scopeSelector ?? defaultScopeSelector)(region);
4569
4560
  if (selector) {
4570
- scoped = true;
4571
4561
  const to = [
4572
4562
  ...new Set(
4573
4563
  directRegions(region).filter((c) => opts.resolveTemplate(contractName(c)) !== null).map((c) => readAttr(c, "ref")).filter((r) => !!r).map((r) => `.${r}`)
@@ -4576,15 +4566,14 @@ function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = f
4576
4566
  cssBlocks.push({
4577
4567
  key: `${templatePath}\0${[...to].sort().join(",")}`,
4578
4568
  selector,
4579
- css: scopeReadyCss(template.body, template.css),
4569
+ css: template.css,
4580
4570
  to
4581
4571
  });
4582
4572
  } else {
4583
4573
  cssBlocks.push({ key: templatePath, selector: null, css: template.css });
4584
4574
  }
4585
4575
  }
4586
- if (ref && (scoped || parentScoped)) stampScopeAnchor(region, ref);
4587
- fillRegions(region, [...stack, templatePath], opts, errors, cssBlocks, scoped);
4576
+ fillRegions(region, [...stack, templatePath], opts, errors, cssBlocks);
4588
4577
  }
4589
4578
  }
4590
4579
  function directRegions(container) {
@@ -4612,39 +4601,6 @@ function scopeWrap(css, selector, to) {
4612
4601
  ${css}
4613
4602
  }`;
4614
4603
  }
4615
- function rootClassesOf(templateBody) {
4616
- const classes = /* @__PURE__ */ new Set();
4617
- for (const child of parse(templateBody).childNodes) {
4618
- if (child instanceof HTMLElement) {
4619
- for (const c of child.classList.values()) classes.add(c);
4620
- }
4621
- }
4622
- return classes;
4623
- }
4624
- function scopeReadyCss(templateBody, css) {
4625
- const rootClasses = rootClassesOf(templateBody);
4626
- if (rootClasses.size === 0) return css;
4627
- try {
4628
- const root = postcss.parse(css);
4629
- root.walkRules((rule) => {
4630
- rule.selectors = rule.selectors.map((sel) => rewriteRootSelector(sel, rootClasses));
4631
- });
4632
- return root.toString();
4633
- } catch {
4634
- return css;
4635
- }
4636
- }
4637
- function rewriteRootSelector(selector, rootClasses) {
4638
- return selector.replace(
4639
- /\.([A-Za-z0-9_-]+)/g,
4640
- (match, name) => rootClasses.has(name) ? ":scope" : match
4641
- );
4642
- }
4643
- function stampScopeAnchor(region, className) {
4644
- for (const child of region.childNodes) {
4645
- if (child instanceof HTMLElement) child.classList.add(className);
4646
- }
4647
- }
4648
4604
  function mergeOverrides(templateBody, existingBody) {
4649
4605
  const tRoot = parse(templateBody);
4650
4606
  const eRoot = parse(existingBody);
@@ -4790,6 +4746,428 @@ function scopeInnerBody(block) {
4790
4746
  function normalizeCssBody(body) {
4791
4747
  return body.replace(/\s+/g, " ").replace(/\s*([{}:;,])\s*/g, "$1").replace(/;}/g, "}").trim();
4792
4748
  }
4749
+ const SUPPRESS_KEY = "allow-broken-links";
4750
+ const SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin a <script type="application/jay-validations"> block';
4751
+ function normalizePath(p) {
4752
+ let s2 = p.split("#")[0].split("?")[0];
4753
+ s2 = s2.replace(/\/{2,}/g, "/");
4754
+ if (s2.length > 1) s2 = s2.replace(/\/+$/, "");
4755
+ return s2 === "" ? "/" : s2;
4756
+ }
4757
+ function escapeRegExp(s2) {
4758
+ return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4759
+ }
4760
+ function routeToConcreteUrl(route) {
4761
+ const parts = [];
4762
+ for (const seg of route.segments) {
4763
+ if (typeof seg === "string") {
4764
+ parts.push(seg);
4765
+ } else {
4766
+ const v = route.inferredParams?.[seg.name];
4767
+ if (v === void 0) return null;
4768
+ parts.push(v);
4769
+ }
4770
+ }
4771
+ return normalizePath("/" + parts.join("/"));
4772
+ }
4773
+ function routeToRegex(route) {
4774
+ let pattern = "";
4775
+ for (const seg of route.segments) {
4776
+ if (typeof seg === "string") {
4777
+ pattern += "/" + escapeRegExp(seg);
4778
+ } else if (seg.type === JayRouteParamType.optional) {
4779
+ pattern += "(?:/[^/]+)?";
4780
+ } else if (seg.type === JayRouteParamType.catchAll) {
4781
+ pattern += "(?:/[^/]+)+";
4782
+ } else {
4783
+ pattern += "/[^/]+";
4784
+ }
4785
+ }
4786
+ if (pattern === "") pattern = "/";
4787
+ return new RegExp("^" + pattern + "$");
4788
+ }
4789
+ async function buildRouteOracle(pagesBase) {
4790
+ let routes = [];
4791
+ try {
4792
+ routes = await scanRoutes(pagesBase, {
4793
+ jayHtmlFilename: "page.jay-html",
4794
+ compFilename: "page.ts"
4795
+ });
4796
+ } catch {
4797
+ routes = [];
4798
+ }
4799
+ const staticUrls = /* @__PURE__ */ new Set();
4800
+ const dynamicMatchers = [];
4801
+ const selfUrlByPath = /* @__PURE__ */ new Map();
4802
+ for (const route of routes) {
4803
+ const concrete = routeToConcreteUrl(route);
4804
+ if (concrete !== null) {
4805
+ staticUrls.add(concrete);
4806
+ if (route.jayHtmlPath) selfUrlByPath.set(path$1.resolve(route.jayHtmlPath), concrete);
4807
+ }
4808
+ if (route.segments.some((s2) => typeof s2 !== "string")) {
4809
+ dynamicMatchers.push(routeToRegex(route));
4810
+ }
4811
+ }
4812
+ return { staticUrls, dynamicMatchers, selfUrlByPath };
4813
+ }
4814
+ async function collectPublicAssets(publicFolder) {
4815
+ const assets = /* @__PURE__ */ new Set();
4816
+ async function walk(dir, prefix) {
4817
+ let entries;
4818
+ try {
4819
+ entries = await promises.readdir(dir, { withFileTypes: true });
4820
+ } catch {
4821
+ return;
4822
+ }
4823
+ for (const entry of entries) {
4824
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
4825
+ if (entry.isDirectory()) {
4826
+ await walk(path$1.join(dir, entry.name), rel);
4827
+ } else {
4828
+ assets.add("/" + rel);
4829
+ }
4830
+ }
4831
+ }
4832
+ await walk(publicFolder, "");
4833
+ return assets;
4834
+ }
4835
+ function originOf(baseUrl) {
4836
+ try {
4837
+ return new URL(baseUrl).origin;
4838
+ } catch {
4839
+ return void 0;
4840
+ }
4841
+ }
4842
+ function classifyHref(raw, baseUrl) {
4843
+ if (raw.includes("{")) return { kind: "skip" };
4844
+ const t = raw.trim();
4845
+ if (t === "" || t === "#") return { kind: "degenerate" };
4846
+ if (t.startsWith("#")) return { kind: "skip" };
4847
+ if (/^(mailto:|tel:|javascript:|data:|sms:|ftp:)/i.test(t)) return { kind: "skip" };
4848
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || t.startsWith("//")) {
4849
+ const sameOrigin = baseUrl ? originOf(baseUrl) : void 0;
4850
+ if (sameOrigin) {
4851
+ try {
4852
+ const u2 = new URL(t.startsWith("//") ? "https:" + t : t);
4853
+ if (u2.origin === sameOrigin)
4854
+ return { kind: "check", targetPath: normalizePath(u2.pathname) };
4855
+ } catch {
4856
+ }
4857
+ }
4858
+ return { kind: "skip" };
4859
+ }
4860
+ if (t.startsWith("/")) return { kind: "check", targetPath: normalizePath(t) };
4861
+ return { kind: "skip" };
4862
+ }
4863
+ function checkInternalLinks(options) {
4864
+ const { parsedFiles, oracle, assetUrls, baseUrl, projectRoot } = options;
4865
+ const findings = [];
4866
+ for (const { relativePath, parsed } of parsedFiles) {
4867
+ if (parsed.validationOverrides?.["jay-stack"]?.[SUPPRESS_KEY] === true) continue;
4868
+ const ownUrl = oracle.selfUrlByPath.get(path$1.resolve(projectRoot ?? ".", relativePath));
4869
+ for (const anchor of parsed.body.querySelectorAll("a[href]")) {
4870
+ const raw = anchor.getAttribute("href");
4871
+ if (raw === void 0 || raw === null) continue;
4872
+ const verdict = classifyHref(raw, baseUrl);
4873
+ if (verdict.kind === "skip") continue;
4874
+ if (verdict.kind === "degenerate") {
4875
+ findings.push({
4876
+ file: relativePath,
4877
+ message: `Anchor has a placeholder href "${raw}" — it links nowhere and ships as a dead link.`,
4878
+ suggestion: `Point the link at a real page, use <button> for a JS-driven action, or ${SUPPRESS_HINT}.`
4879
+ });
4880
+ continue;
4881
+ }
4882
+ const target = verdict.targetPath;
4883
+ if (ownUrl !== void 0 && target === ownUrl) {
4884
+ findings.push({
4885
+ file: relativePath,
4886
+ message: `Link points to the page's own route "${target}" — a self-link goes nowhere.`,
4887
+ suggestion: `Remove the link, use "#" within-page navigation, or point it at a different page.`
4888
+ });
4889
+ continue;
4890
+ }
4891
+ if (oracle.staticUrls.has(target)) continue;
4892
+ if (assetUrls.has(target)) continue;
4893
+ if (oracle.dynamicMatchers.some((re) => re.test(target))) continue;
4894
+ findings.push({
4895
+ file: relativePath,
4896
+ message: `Broken internal link "${raw}" — no page or public asset produces the URL "${target}".`,
4897
+ suggestion: `Create a placeholder page/route for "${target}", or remove the link. Otherwise ${SUPPRESS_HINT}.`
4898
+ });
4899
+ }
4900
+ }
4901
+ return findings;
4902
+ }
4903
+ const META_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-meta-issues: true\nin the page <script type="application/jay-validations"> block';
4904
+ const LINK_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin the page <script type="application/jay-validations"> block';
4905
+ const UNRESOLVED_BINDING = /\{[A-Za-z_$][\w$]*(?:\.[\w$]+)*\}/;
4906
+ const TITLE_MAX = 60;
4907
+ const DESCRIPTION_MAX = 160;
4908
+ function buildUrlFromManifest(pattern, params) {
4909
+ return pattern.replace(/\[\[(\w+)\]\]/g, (_, name) => params[name] || "").replace(/\[\.\.\.(\w+)\]/g, (_, name) => params[name] || "").replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
4910
+ }
4911
+ function routeInstanceUrl(route, instance) {
4912
+ return normalizePath(buildUrlFromManifest(route.pattern, instance.params));
4913
+ }
4914
+ async function discoverBuildBackendDir(projectRoot, explicit) {
4915
+ if (explicit) {
4916
+ const abs = path$1.resolve(projectRoot, explicit);
4917
+ if (await fileExists(path$1.join(abs, "route-manifest.json"))) return abs;
4918
+ const nested = path$1.join(abs, "backend");
4919
+ if (await fileExists(path$1.join(nested, "route-manifest.json"))) return nested;
4920
+ const fromRoot = await highestVersionBackend(abs);
4921
+ if (fromRoot) return fromRoot;
4922
+ return null;
4923
+ }
4924
+ return highestVersionBackend(path$1.join(projectRoot, "build"));
4925
+ }
4926
+ async function highestVersionBackend(buildRoot) {
4927
+ let entries;
4928
+ try {
4929
+ entries = (await promises.readdir(buildRoot, { withFileTypes: true })).filter((e2) => e2.isDirectory() && /^v/.test(e2.name)).map((e2) => e2.name);
4930
+ } catch {
4931
+ return null;
4932
+ }
4933
+ if (entries.length === 0) return null;
4934
+ entries.sort(compareVersionDesc);
4935
+ for (const name of entries) {
4936
+ const backend = path$1.join(buildRoot, name, "backend");
4937
+ if (await fileExists(path$1.join(backend, "route-manifest.json"))) return backend;
4938
+ }
4939
+ return null;
4940
+ }
4941
+ function compareVersionDesc(a, b) {
4942
+ const pa = a.replace(/^v/, "").split(".").map(Number);
4943
+ const pb = b.replace(/^v/, "").split(".").map(Number);
4944
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
4945
+ const da = pa[i] || 0;
4946
+ const db = pb[i] || 0;
4947
+ if (da !== db) return db - da;
4948
+ }
4949
+ return 0;
4950
+ }
4951
+ async function fileExists(p) {
4952
+ try {
4953
+ await promises.access(p);
4954
+ return true;
4955
+ } catch {
4956
+ return false;
4957
+ }
4958
+ }
4959
+ async function loadBuildManifest(backendDir) {
4960
+ let manifest;
4961
+ try {
4962
+ manifest = JSON.parse(
4963
+ await promises.readFile(path$1.join(backendDir, "route-manifest.json"), "utf8")
4964
+ );
4965
+ } catch {
4966
+ return null;
4967
+ }
4968
+ let metadata = null;
4969
+ try {
4970
+ metadata = JSON.parse(
4971
+ await promises.readFile(path$1.join(backendDir, "build-metadata.json"), "utf8")
4972
+ );
4973
+ } catch {
4974
+ metadata = null;
4975
+ }
4976
+ return { manifest, metadata };
4977
+ }
4978
+ function buildBuildOracle(manifest, backendDir) {
4979
+ const urls = /* @__PURE__ */ new Set();
4980
+ const instances = [];
4981
+ for (const route of manifest.routes) {
4982
+ const hasDynamic = route.segments.some((s2) => s2.type !== "static");
4983
+ if (route.instances.length === 0) {
4984
+ if (!hasDynamic) urls.add(normalizePath(route.pattern));
4985
+ continue;
4986
+ }
4987
+ for (const instance of route.instances) {
4988
+ const url = routeInstanceUrl(route, instance);
4989
+ urls.add(url);
4990
+ instances.push({
4991
+ url,
4992
+ cacheAbsPath: path$1.resolve(backendDir, instance.cachePath),
4993
+ pattern: route.pattern,
4994
+ headMeta: route.headMeta
4995
+ });
4996
+ }
4997
+ }
4998
+ return { urls, instances };
4999
+ }
5000
+ async function loadSlowViewState(cacheAbsPath) {
5001
+ try {
5002
+ const cache = JSON.parse(await promises.readFile(cacheAbsPath, "utf8"));
5003
+ return cache.slowViewState ?? {};
5004
+ } catch {
5005
+ return {};
5006
+ }
5007
+ }
5008
+ function checkTemplateLinksAgainstBuild(args) {
5009
+ const { parsedFiles, buildUrls, assetUrls, dynamicMatchers, baseUrl } = args;
5010
+ const findings = [];
5011
+ for (const { relativePath, parsed } of parsedFiles) {
5012
+ if (parsed.validationOverrides?.["jay-stack"]?.["allow-broken-links"] === true) continue;
5013
+ for (const anchor of parsed.body.querySelectorAll("a[href]")) {
5014
+ const raw = anchor.getAttribute("href");
5015
+ if (raw === void 0 || raw === null) continue;
5016
+ const verdict = classifyHref(raw, baseUrl);
5017
+ if (verdict.kind !== "check") continue;
5018
+ const target = verdict.targetPath;
5019
+ if (buildUrls.has(target) || assetUrls.has(target)) continue;
5020
+ if (!dynamicMatchers.some((re) => re.test(target))) continue;
5021
+ findings.push({
5022
+ file: relativePath,
5023
+ message: `Broken link "${raw}" — matches a dynamic route but "${target}" is not among the URLs this build produces.`,
5024
+ suggestion: `Create the slug/page, fix the path, or remove the link. Otherwise ${LINK_SUPPRESS_HINT}.`,
5025
+ severity: "error",
5026
+ source: "build-output"
5027
+ });
5028
+ }
5029
+ }
5030
+ return findings;
5031
+ }
5032
+ function resolveContentHref(raw, instanceUrl, baseUrl) {
5033
+ const t = raw.trim();
5034
+ if (t === "" || t === "#" || t.startsWith("#")) return { kind: "skip" };
5035
+ if (t.includes("{")) return { kind: "skip" };
5036
+ if (t.startsWith("/") || t.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(t)) {
5037
+ const v = classifyHref(t, baseUrl);
5038
+ return v.kind === "check" ? v : { kind: "skip" };
5039
+ }
5040
+ try {
5041
+ const u2 = new URL(t, "https://_local_" + (instanceUrl === "/" ? "/" : instanceUrl));
5042
+ return { kind: "check", targetPath: normalizePath(u2.pathname) };
5043
+ } catch {
5044
+ return { kind: "skip" };
5045
+ }
5046
+ }
5047
+ function collectContentHrefs(viewState) {
5048
+ const hrefs = [];
5049
+ const seen = /* @__PURE__ */ new Set();
5050
+ const walk = (node) => {
5051
+ if (node == null) return;
5052
+ if (typeof node === "string") {
5053
+ if (node.includes("<a ")) {
5054
+ const root = parse(node);
5055
+ for (const a of root.querySelectorAll("a[href]")) {
5056
+ const h = a.getAttribute("href");
5057
+ if (h) hrefs.push(h);
5058
+ }
5059
+ }
5060
+ return;
5061
+ }
5062
+ if (typeof node !== "object") return;
5063
+ if (seen.has(node)) return;
5064
+ seen.add(node);
5065
+ if (Array.isArray(node)) {
5066
+ for (const item of node) walk(item);
5067
+ } else {
5068
+ for (const v of Object.values(node)) walk(v);
5069
+ }
5070
+ };
5071
+ walk(viewState);
5072
+ return hrefs;
5073
+ }
5074
+ function checkContentLinks(args) {
5075
+ const { instanceUrl, slowViewState, buildUrls, assetUrls, baseUrl } = args;
5076
+ const findings = [];
5077
+ const reported = /* @__PURE__ */ new Set();
5078
+ for (const raw of collectContentHrefs(slowViewState)) {
5079
+ const verdict = resolveContentHref(raw, instanceUrl, baseUrl);
5080
+ if (verdict.kind !== "check") continue;
5081
+ const target = verdict.targetPath;
5082
+ if (buildUrls.has(target) || assetUrls.has(target)) continue;
5083
+ if (reported.has(target)) continue;
5084
+ reported.add(target);
5085
+ findings.push({
5086
+ file: instanceUrl,
5087
+ message: `Broken link "${raw}" in rendered content of ${instanceUrl} — resolves to "${target}", which this build does not produce.`,
5088
+ // Content links come from authored markdown/CMS data that often doubles as repo docs (where a
5089
+ // repo-relative "../pkg/foo.ts" is correct), so this is a warning, not a build-failing error —
5090
+ // unlike hand-authored template links (Check 1), which are site-only and stay errors.
5091
+ suggestion: `Fix the link in the source content, point it at an absolute URL, or ${LINK_SUPPRESS_HINT}.`,
5092
+ severity: "warning",
5093
+ source: "build-output"
5094
+ });
5095
+ }
5096
+ return findings;
5097
+ }
5098
+ function checkInstanceMeta(args) {
5099
+ const { instanceUrl, headMeta, slowViewState } = args;
5100
+ if (!headMeta) return [];
5101
+ const findings = [];
5102
+ const tags = headMetaToHeadTags(headMeta, slowViewState);
5103
+ const addLeftover = (what, value) => findings.push({
5104
+ file: instanceUrl,
5105
+ message: `<${what}> for ${instanceUrl} resolves to "${value}" — an unresolved binding was not produced by slow render.`,
5106
+ suggestion: `Ensure the field is produced by the slow phase, or make the ${what} static.`,
5107
+ severity: "error",
5108
+ source: "build-output"
5109
+ });
5110
+ const addWarn = (message) => findings.push({
5111
+ file: instanceUrl,
5112
+ message,
5113
+ suggestion: `Fix the content or ${META_SUPPRESS_HINT}.`,
5114
+ severity: "warning",
5115
+ source: "build-output"
5116
+ });
5117
+ for (const tag of tags) {
5118
+ if (tag.tag === "title") {
5119
+ const value = tag.children ?? "";
5120
+ if (UNRESOLVED_BINDING.test(value)) addLeftover("title", value);
5121
+ else if (value.trim() === "") addWarn(`<title> for ${instanceUrl} is empty.`);
5122
+ else if (value.length > TITLE_MAX)
5123
+ addWarn(
5124
+ `<title> for ${instanceUrl} is ${value.length} chars (recommend ≤ ${TITLE_MAX}).`
5125
+ );
5126
+ } else if (tag.tag === "meta" && tag.attrs?.name === "description") {
5127
+ const value = tag.attrs.content ?? "";
5128
+ if (UNRESOLVED_BINDING.test(value)) addLeftover('meta name="description"', value);
5129
+ else if (value.trim() === "")
5130
+ addWarn(`<meta name="description"> for ${instanceUrl} is empty.`);
5131
+ else if (value.length > DESCRIPTION_MAX)
5132
+ addWarn(
5133
+ `<meta name="description"> for ${instanceUrl} is ${value.length} chars (recommend ≤ ${DESCRIPTION_MAX}).`
5134
+ );
5135
+ }
5136
+ }
5137
+ return findings;
5138
+ }
5139
+ function isBuildStale(metadata, newestSourceMtimeMs) {
5140
+ if (!metadata?.buildTimestamp) return false;
5141
+ const built = Date.parse(metadata.buildTimestamp);
5142
+ if (Number.isNaN(built)) return false;
5143
+ return newestSourceMtimeMs > built;
5144
+ }
5145
+ async function newestSourceMtime(dirs) {
5146
+ let newest = 0;
5147
+ const walk = async (dir) => {
5148
+ let entries;
5149
+ try {
5150
+ entries = await promises.readdir(dir, { withFileTypes: true });
5151
+ } catch {
5152
+ return;
5153
+ }
5154
+ for (const entry of entries) {
5155
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
5156
+ const full = path$1.join(dir, entry.name);
5157
+ if (entry.isDirectory()) {
5158
+ await walk(full);
5159
+ } else {
5160
+ try {
5161
+ const st = await promises.stat(full);
5162
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
5163
+ } catch {
5164
+ }
5165
+ }
5166
+ }
5167
+ };
5168
+ for (const dir of dirs) await walk(dir);
5169
+ return newest;
5170
+ }
4793
5171
  async function findJayFiles(dir) {
4794
5172
  return await glob(`${dir}/**/*${JAY_EXTENSION$1}`);
4795
5173
  }
@@ -5369,8 +5747,7 @@ function checkRegionCssDrift(jayHtml, loadTemplate) {
5369
5747
  if (content === void 0) {
5370
5748
  templateCss = null;
5371
5749
  } else {
5372
- const body = parse(content).querySelector("body");
5373
- templateCss = scopeReadyCss(body ? body.innerHTML : "", extractStyleCss(content));
5750
+ templateCss = extractStyleCss(content);
5374
5751
  }
5375
5752
  templateCssCache.set(imp.template, templateCss);
5376
5753
  }
@@ -5462,6 +5839,33 @@ function checkNestedRegionRefs(jayHtml, loadTemplate) {
5462
5839
  }
5463
5840
  return warnings;
5464
5841
  }
5842
+ function checkRegionCssNoRef(jayHtml, loadTemplate) {
5843
+ const importsWithTemplate = jayHtml.headlessImports.filter((imp) => imp.template);
5844
+ if (importsWithTemplate.length === 0) return [];
5845
+ const findings = [];
5846
+ const shipsCssCache = /* @__PURE__ */ new Map();
5847
+ const templateShipsCss = (rel) => {
5848
+ let v = shipsCssCache.get(rel);
5849
+ if (v === void 0) {
5850
+ const content = loadTemplate(rel);
5851
+ v = content !== void 0 && extractStyleCss(content).trim() !== "";
5852
+ shipsCssCache.set(rel, v);
5853
+ }
5854
+ return v;
5855
+ };
5856
+ for (const region of collectRegionElements(jayHtml.body)) {
5857
+ const contractName2 = (region.rawTagName ?? "").toLowerCase().substring(4);
5858
+ const imp = importsWithTemplate.find((i) => i.contractName === contractName2);
5859
+ if (!imp?.template) continue;
5860
+ if (region.getAttribute("ref")) continue;
5861
+ if (!templateShipsCss(imp.template)) continue;
5862
+ findings.push({
5863
+ message: `<jay:${contractName2}> flattens template="${imp.template}" which ships CSS, but the region has no ref= — so its CSS cannot be scoped and \`jay-stack sync\` will silently drop it.`,
5864
+ suggestion: `Add a ref= to the <jay:${contractName2}> so sync can scope its CSS as an @scope (.<ref>) block and preserve it.`
5865
+ });
5866
+ }
5867
+ return findings;
5868
+ }
5465
5869
  const CONTENT_TAGS = /* @__PURE__ */ new Set([
5466
5870
  // inline text semantics
5467
5871
  "span",
@@ -5888,6 +6292,9 @@ async function validateJayFiles(options = {}) {
5888
6292
  const parsedFiles = [];
5889
6293
  let regionsSeen = 0;
5890
6294
  let templateImportsSeen = 0;
6295
+ const designSystemCoverage = [];
6296
+ const reuseByTemplate = /* @__PURE__ */ new Map();
6297
+ const cataloguedContractFiles = /* @__PURE__ */ new Set();
5891
6298
  const pageJayHtmlFiles = await findJayFiles(scanDir);
5892
6299
  const componentJayHtmlFiles = await findJayFiles(componentsDir).catch(() => []);
5893
6300
  const jayHtmlFiles = [...pageJayHtmlFiles, ...componentJayHtmlFiles];
@@ -5895,6 +6302,7 @@ async function validateJayFiles(options = {}) {
5895
6302
  const componentContractFiles = await findContractFiles(componentsDir).catch(
5896
6303
  () => []
5897
6304
  );
6305
+ for (const cf of componentContractFiles) cataloguedContractFiles.add(cf);
5898
6306
  const contractFiles = [...await findContractFiles(scanDir), ...componentContractFiles];
5899
6307
  const contractNameByFile = /* @__PURE__ */ new Map();
5900
6308
  if (options.verbose) {
@@ -6044,10 +6452,45 @@ async function validateJayFiles(options = {}) {
6044
6452
  suggestion: finding.suggestion
6045
6453
  });
6046
6454
  }
6455
+ const cssNoRefErrors = checkRegionCssNoRef(parsedFile.val, readTemplateRel);
6456
+ for (const finding of cssNoRefErrors) {
6457
+ errors.push({
6458
+ file: relativePath,
6459
+ message: finding.message,
6460
+ stage: "generate",
6461
+ suggestion: finding.suggestion
6462
+ });
6463
+ }
6047
6464
  regionsSeen += collectRegionElements(parsedFile.val.body).length;
6048
6465
  templateImportsSeen += parsedFile.val.headlessImports.filter(
6049
6466
  (imp) => imp.template
6050
6467
  ).length;
6468
+ if (!componentJayHtmlFileSet.has(jayFile)) {
6469
+ const body = parsedFile.val.body;
6470
+ const total = body.querySelectorAll("*").length;
6471
+ const coveredNodes = /* @__PURE__ */ new Set();
6472
+ for (const region of directChildRegions(body)) {
6473
+ const contractName2 = regionContractName(region);
6474
+ const imp = parsedFile.val.headlessImports.find(
6475
+ (i) => i.contractName === contractName2
6476
+ );
6477
+ if (!imp?.template) continue;
6478
+ coveredNodes.add(region);
6479
+ for (const descendant of region.querySelectorAll("*")) {
6480
+ coveredNodes.add(descendant);
6481
+ }
6482
+ const templateAbs = path$1.resolve(dirname, imp.template);
6483
+ reuseByTemplate.set(templateAbs, (reuseByTemplate.get(templateAbs) ?? 0) + 1);
6484
+ if (imp.contractPath) cataloguedContractFiles.add(imp.contractPath);
6485
+ }
6486
+ const covered = coveredNodes.size;
6487
+ designSystemCoverage.push({
6488
+ file: relativePath,
6489
+ coveragePct: total ? covered / total : 0,
6490
+ covered,
6491
+ total
6492
+ });
6493
+ }
6051
6494
  const notLinkedWarnings = componentJayHtmlFileSet.has(jayFile) ? [] : checkRegionNotLinked(
6052
6495
  parsedFile.val,
6053
6496
  (imp) => listTemplatesForContractFile(imp.contractPath).map((t) => ({
@@ -6198,6 +6641,55 @@ async function validateJayFiles(options = {}) {
6198
6641
  suggestion: noDesignSystem.suggestion
6199
6642
  });
6200
6643
  }
6644
+ const cataloguedTemplatePaths = /* @__PURE__ */ new Set();
6645
+ for (const contractFile of cataloguedContractFiles) {
6646
+ for (const t of listTemplatesForContractFile(contractFile)) {
6647
+ cataloguedTemplatePaths.add(path$1.resolve(t.path));
6648
+ }
6649
+ }
6650
+ const rel = (p) => "./" + path$1.relative(projectRoot, p).replace(/\\/g, "/");
6651
+ const perTemplate = {};
6652
+ for (const [abs, count] of reuseByTemplate) perTemplate[rel(abs)] = count;
6653
+ const designSystemReuse = {
6654
+ perTemplate,
6655
+ catalogued: cataloguedTemplatePaths.size,
6656
+ reusedMoreThanOnce: [...reuseByTemplate.values()].filter((n) => n >= 2).length,
6657
+ reusedOfCatalogued: [...cataloguedTemplatePaths].filter(
6658
+ (p) => (reuseByTemplate.get(p) ?? 0) >= 2
6659
+ ).length
6660
+ };
6661
+ const linkOracle = await buildRouteOracle(scanDir);
6662
+ const assetUrls = await collectPublicAssets(
6663
+ path$1.resolve(projectRoot, resolvedConfig.devServer.publicFolder)
6664
+ );
6665
+ const linkFindings = checkInternalLinks({
6666
+ parsedFiles,
6667
+ oracle: linkOracle,
6668
+ assetUrls,
6669
+ baseUrl: config.site?.baseUrl,
6670
+ projectRoot
6671
+ });
6672
+ for (const finding of linkFindings) {
6673
+ errors.push({
6674
+ file: finding.file,
6675
+ message: finding.message,
6676
+ stage: "generate",
6677
+ source: "internal-links",
6678
+ suggestion: finding.suggestion
6679
+ });
6680
+ }
6681
+ if (options.tier2) {
6682
+ await runBuildOutputValidation({
6683
+ projectRoot,
6684
+ buildDir: options.buildDir,
6685
+ parsedFiles,
6686
+ linkOracle,
6687
+ assetUrls,
6688
+ baseUrl: config.site?.baseUrl,
6689
+ errors,
6690
+ warnings
6691
+ });
6692
+ }
6201
6693
  const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
6202
6694
  return {
6203
6695
  valid: errors.length === 0,
@@ -6206,9 +6698,94 @@ async function validateJayFiles(options = {}) {
6206
6698
  errors,
6207
6699
  warnings,
6208
6700
  coverage,
6209
- pluginValidators
6701
+ pluginValidators,
6702
+ designSystemCoverage,
6703
+ designSystemReuse
6210
6704
  };
6211
6705
  }
6706
+ async function runBuildOutputValidation(args) {
6707
+ const { projectRoot, buildDir, parsedFiles, linkOracle, assetUrls, baseUrl, errors, warnings } = args;
6708
+ const backendDir = await discoverBuildBackendDir(projectRoot, buildDir);
6709
+ if (!backendDir) {
6710
+ errors.push({
6711
+ file: "build",
6712
+ message: "Tier 2: no build found. Run `jay-stack build` first, or point --build-dir at a build.",
6713
+ stage: "generate",
6714
+ source: "build-output"
6715
+ });
6716
+ return;
6717
+ }
6718
+ const loaded = await loadBuildManifest(backendDir);
6719
+ if (!loaded) {
6720
+ errors.push({
6721
+ file: backendDir,
6722
+ message: `Tier 2: could not read route-manifest.json in ${backendDir}.`,
6723
+ stage: "generate",
6724
+ source: "build-output"
6725
+ });
6726
+ return;
6727
+ }
6728
+ const newest = await newestSourceMtime([
6729
+ path$1.join(projectRoot, "src"),
6730
+ path$1.join(projectRoot, "content")
6731
+ ]);
6732
+ if (isBuildStale(loaded.metadata, newest)) {
6733
+ warnings.push({
6734
+ file: "build",
6735
+ message: `Tier 2: validated against a build from ${loaded.metadata?.buildTimestamp}; source has changed since.`,
6736
+ source: "build-output",
6737
+ suggestion: "Re-run `jay-stack build` for accurate results."
6738
+ });
6739
+ }
6740
+ const oracle = buildBuildOracle(loaded.manifest, backendDir);
6741
+ const findings = [];
6742
+ findings.push(
6743
+ ...checkTemplateLinksAgainstBuild({
6744
+ parsedFiles,
6745
+ buildUrls: oracle.urls,
6746
+ assetUrls,
6747
+ dynamicMatchers: linkOracle.dynamicMatchers,
6748
+ baseUrl
6749
+ })
6750
+ );
6751
+ for (const instance of oracle.instances) {
6752
+ const slowViewState = await loadSlowViewState(instance.cacheAbsPath);
6753
+ findings.push(
6754
+ ...checkContentLinks({
6755
+ instanceUrl: instance.url,
6756
+ slowViewState,
6757
+ buildUrls: oracle.urls,
6758
+ assetUrls,
6759
+ baseUrl
6760
+ })
6761
+ );
6762
+ findings.push(
6763
+ ...checkInstanceMeta({
6764
+ instanceUrl: instance.url,
6765
+ headMeta: instance.headMeta,
6766
+ slowViewState
6767
+ })
6768
+ );
6769
+ }
6770
+ for (const finding of findings) {
6771
+ if (finding.severity === "error") {
6772
+ errors.push({
6773
+ file: finding.file,
6774
+ message: finding.message,
6775
+ stage: "generate",
6776
+ source: "build-output",
6777
+ suggestion: finding.suggestion
6778
+ });
6779
+ } else {
6780
+ warnings.push({
6781
+ file: finding.file,
6782
+ message: finding.message,
6783
+ source: "build-output",
6784
+ suggestion: finding.suggestion
6785
+ });
6786
+ }
6787
+ }
6788
+ }
6212
6789
  function printJayValidationResult(result, options) {
6213
6790
  const logger = getLogger();
6214
6791
  if (options.json) {
@@ -6216,9 +6793,11 @@ function printJayValidationResult(result, options) {
6216
6793
  return;
6217
6794
  }
6218
6795
  logger.important("");
6219
- const coreErrors = result.errors.filter((e2) => e2.stage !== "plugin");
6796
+ const coreErrors = result.errors.filter(
6797
+ (e2) => e2.stage !== "plugin" && e2.source !== "build-output"
6798
+ );
6220
6799
  const coreWarnings = result.warnings.filter((w) => !w.source);
6221
- logger.important(chalk.bold("📦 jay-stack (core)"));
6800
+ logger.important(chalk.bold("📦 Tier 1 — jay-stack (core)"));
6222
6801
  if (coreErrors.length === 0) {
6223
6802
  logger.important(
6224
6803
  chalk.green(
@@ -6241,6 +6820,40 @@ function printJayValidationResult(result, options) {
6241
6820
  logger.important(chalk.blue(` Suggestion: ${warning.suggestion}`));
6242
6821
  }
6243
6822
  }
6823
+ const buildErrors = result.errors.filter((e2) => e2.source === "build-output");
6824
+ const buildWarnings = result.warnings.filter((w) => w.source === "build-output");
6825
+ if (buildErrors.length > 0 || buildWarnings.length > 0) {
6826
+ logger.important("");
6827
+ logger.important(chalk.bold("📦 Tier 2 — build-output validation (--tier-2)"));
6828
+ for (const error of buildErrors) {
6829
+ logger.important(chalk.red(` ❌ ${error.file}`));
6830
+ logger.important(chalk.gray(` ${error.message}`));
6831
+ if (error.suggestion) {
6832
+ logger.important(chalk.blue(` Suggestion: ${error.suggestion}`));
6833
+ }
6834
+ }
6835
+ const buildWarnGroups = /* @__PURE__ */ new Map();
6836
+ for (const warning of buildWarnings) {
6837
+ const group = buildWarnGroups.get(warning.file) || [];
6838
+ group.push(warning);
6839
+ buildWarnGroups.set(warning.file, group);
6840
+ }
6841
+ for (const [file, groupWarns] of buildWarnGroups) {
6842
+ logger.important(chalk.yellow(` ⚠ ${file}`));
6843
+ for (const warning of groupWarns) {
6844
+ if (warning.message) {
6845
+ logger.important(chalk.gray(` ${warning.message}`));
6846
+ }
6847
+ }
6848
+ const suggestions = [...new Set(groupWarns.map((w) => w.suggestion).filter(Boolean))];
6849
+ if (suggestions.length > 0) {
6850
+ logger.important(chalk.blue(` Suggestions:`));
6851
+ for (const s2 of suggestions) {
6852
+ logger.important(chalk.blue(` ${s2}`));
6853
+ }
6854
+ }
6855
+ }
6856
+ }
6244
6857
  const pluginErrors = result.errors.filter((e2) => e2.stage === "plugin");
6245
6858
  const pluginWarnings = result.warnings.filter((w) => !!w.source);
6246
6859
  for (const validatorName of result.pluginValidators) {
@@ -6280,23 +6893,84 @@ function printJayValidationResult(result, options) {
6280
6893
  }
6281
6894
  }
6282
6895
  }
6283
- if (options.verbose && result.coverage.length > 0) {
6284
- logger.important("");
6285
- logger.important(chalk.bold("📦 Tag Coverage"));
6286
- for (const fileCov of result.coverage) {
6287
- logger.important(` ${fileCov.file}`);
6288
- for (const contract of fileCov.contracts) {
6289
- const label = contract.key ? `${contract.key} (${contract.contractName})` : contract.contractName;
6290
- logger.important(
6291
- ` ${label}: ${contract.usedTags}/${contract.totalTags} tags used`
6292
- );
6293
- if (contract.unusedTags.length > 0) {
6896
+ const { designSystemCoverage, designSystemReuse } = result;
6897
+ const reuseTemplates = Object.keys(designSystemReuse.perTemplate);
6898
+ const hasTagCoverage = result.coverage.length > 0;
6899
+ const hasScorecard = designSystemCoverage.length > 0 || reuseTemplates.length > 0;
6900
+ let totalUsedTags = 0;
6901
+ let totalTags = 0;
6902
+ for (const fileCov of result.coverage) {
6903
+ for (const contract of fileCov.contracts) {
6904
+ totalUsedTags += contract.usedTags;
6905
+ totalTags += contract.totalTags;
6906
+ }
6907
+ }
6908
+ let totalCovered = 0;
6909
+ let totalElements = 0;
6910
+ for (const cov of designSystemCoverage) {
6911
+ totalCovered += cov.covered;
6912
+ totalElements += cov.total;
6913
+ }
6914
+ if (options.verbose) {
6915
+ if (hasTagCoverage) {
6916
+ logger.important("");
6917
+ logger.important(chalk.bold("📦 Tag Coverage"));
6918
+ for (const fileCov of result.coverage) {
6919
+ logger.important(` ${fileCov.file}`);
6920
+ for (const contract of fileCov.contracts) {
6921
+ const label = contract.key ? `${contract.key} (${contract.contractName})` : contract.contractName;
6294
6922
  logger.important(
6295
- chalk.gray(` Unused: ${contract.unusedTags.join(", ")}`)
6923
+ ` ${label}: ${contract.usedTags}/${contract.totalTags} tags used`
6296
6924
  );
6925
+ if (contract.unusedTags.length > 0) {
6926
+ logger.important(
6927
+ chalk.gray(` Unused: ${contract.unusedTags.join(", ")}`)
6928
+ );
6929
+ }
6297
6930
  }
6298
6931
  }
6299
6932
  }
6933
+ if (hasScorecard) {
6934
+ logger.important("");
6935
+ logger.important(chalk.bold("📊 Design-system scorecard"));
6936
+ for (const cov of designSystemCoverage) {
6937
+ const pct = Math.round(cov.coveragePct * 100);
6938
+ const lowFlag = pct < 50 ? chalk.yellow(" ⚠ low") : "";
6939
+ logger.important(
6940
+ chalk.gray(
6941
+ ` Coverage: ${cov.file} ${pct}% (${cov.covered}/${cov.total} elements)`
6942
+ ) + lowFlag
6943
+ );
6944
+ }
6945
+ if (reuseTemplates.length > 0) {
6946
+ const parts = reuseTemplates.map((t) => {
6947
+ const n = designSystemReuse.perTemplate[t];
6948
+ return n < 2 ? `${t} ×${n}${chalk.yellow(" ⚠ single-use")}` : `${t} ×${n}`;
6949
+ });
6950
+ logger.important(chalk.gray(` Reuse: ${parts.join(" ")}`));
6951
+ }
6952
+ logger.important(
6953
+ chalk.gray(
6954
+ ` ${designSystemReuse.reusedOfCatalogued} of ${designSystemReuse.catalogued} catalogued templates reused > 1`
6955
+ )
6956
+ );
6957
+ }
6958
+ } else if (hasTagCoverage || hasScorecard) {
6959
+ logger.important("");
6960
+ if (hasTagCoverage) {
6961
+ const pct = totalTags ? Math.round(totalUsedTags / totalTags * 100) : 0;
6962
+ logger.important(
6963
+ chalk.bold("📦 Tag coverage: ") + `${pct}% (${totalUsedTags}/${totalTags} tags used across ${result.coverage.length} page(s))`
6964
+ );
6965
+ }
6966
+ if (hasScorecard) {
6967
+ const pct = totalElements ? Math.round(totalCovered / totalElements * 100) : 0;
6968
+ const lowFlag = pct < 50 ? chalk.yellow(" ⚠ low") : "";
6969
+ logger.important(
6970
+ chalk.bold("📊 Design-system scorecard: ") + `${pct}% element coverage (${totalCovered}/${totalElements}), ${designSystemReuse.reusedOfCatalogued} of ${designSystemReuse.catalogued} catalogued templates reused > 1` + lowFlag
6971
+ );
6972
+ }
6973
+ logger.important(chalk.gray(" Run validate -v for per-page details."));
6300
6974
  }
6301
6975
  logger.important("");
6302
6976
  if (result.valid && result.warnings.length === 0) {
@@ -6322,12 +6996,22 @@ function printJayValidationResult(result, options) {
6322
6996
  )
6323
6997
  );
6324
6998
  }
6999
+ if (!options.tier2) {
7000
+ logger.important("");
7001
+ logger.important(
7002
+ chalk.gray(
7003
+ "ℹ This was Tier 1 (template) validation. Tier 2 runs deeper checks against an existing build\n (dynamic-slug links, rendered-content links, per-instance meta):\n jay-stack validate --tier-2\n See: agent-kit/designer/validation-tier-2-guide.md"
7004
+ )
7005
+ );
7006
+ }
6325
7007
  }
6326
7008
  async function runValidate(scanPath, options) {
6327
7009
  const result = await validateJayFiles({
6328
7010
  path: scanPath,
6329
7011
  verbose: options.verbose,
6330
- json: options.json
7012
+ json: options.json,
7013
+ tier2: options.tier2,
7014
+ buildDir: options.buildDir
6331
7015
  });
6332
7016
  printJayValidationResult(result, options);
6333
7017
  if (!result.valid) {
@@ -6474,7 +7158,7 @@ async function runBuild(projectPath, options) {
6474
7158
  }
6475
7159
  const ctx = await resolveProductionContext(projectPath, options.version);
6476
7160
  await generateDefinitionFiles(ctx);
6477
- const { buildVersion } = await import("./index-l6QVpvnY.js");
7161
+ const { buildVersion } = await import("./index-BQbkAEhi.js");
6478
7162
  await buildVersion({
6479
7163
  version: ctx.version,
6480
7164
  projectRoot: ctx.resolvedPath,
@@ -7594,9 +8278,17 @@ program.command("cleanup").description("Delete orphaned files from previous rebu
7594
8278
  process.exit(1);
7595
8279
  }
7596
8280
  });
7597
- program.command("validate").description("Validate all .jay-html and .jay-contract files").option("-p, --path <path>", "Scan root (default: cwd)").option("-v, --verbose", "Show per-file validation status").option("--json", "Output results as JSON").action(async (options) => {
8281
+ program.command("validate").description("Validate all .jay-html and .jay-contract files").option("-p, --path <path>", "Scan root (default: cwd)").option("-v, --verbose", "Show per-file validation status").option("--json", "Output results as JSON").addOption(
8282
+ new Option(
8283
+ "--tier-2",
8284
+ "Run Tier 2: deep validation against an existing build (dynamic-slug links, rendered content, per-instance meta)"
8285
+ )
8286
+ ).addOption(new Option("--t2", "Alias for --tier-2").hideHelp()).option(
8287
+ "--build-dir <dir>",
8288
+ "Build backend dir or build root for Tier 2 (default: highest build/v*)"
8289
+ ).action(async (options) => {
7598
8290
  try {
7599
- await runValidate(options.path, options);
8291
+ await runValidate(options.path, { ...options, tier2: options.tier2 || options.t2 });
7600
8292
  } catch (error) {
7601
8293
  if (options.json) {
7602
8294
  getLogger().important(