@jay-framework/jay-stack-cli 0.25.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import YAML from "yaml";
9
9
  import { getLogger, setDevLogger, createDevLogger } from "@jay-framework/logger";
10
10
  import { parseJayFile, JAY_IMPORT_RESOLVER, generateElementDefinitionFile, parseContract, compileContract, parseAction, generateElementFile, generateServerElementFile, htmlElementTagNameMap, loadLinkedContract, getLinkedContractDir } from "@jay-framework/compiler-jay-html";
11
11
  import { checkValidationErrors, JAY_CONTRACT_EXTENSION as JAY_CONTRACT_EXTENSION$1, loadPluginManifest as loadPluginManifest$1, normalizeActionEntry as normalizeActionEntry$1, JAY_EXTENSION as JAY_EXTENSION$1, RuntimeMode as RuntimeMode$1, GenerateTarget, findDynamicContract, isEnumType as isEnumType$1, equalJayTypes, prettifyHtml } from "@jay-framework/compiler-shared";
12
- import fs$2 from "node:fs/promises";
12
+ import fs$1$1 from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import path__default from "node:path";
15
15
  import { scanPlugins, sortPluginsByDependencies, getServiceRegistry, actionRegistry, resolveServices, headMetaToHeadTags, ActionRegistry, discoverPluginsWithInit, executePluginServerInits, runInitCallbacks, registerService } from "@jay-framework/stack-server-runtime";
@@ -26,6 +26,7 @@ import chalk from "chalk";
26
26
  import { glob } from "glob";
27
27
  import postcss from "postcss";
28
28
  import { scanRoutes, JayRouteParamType } from "@jay-framework/stack-route-scanner";
29
+ import { FilesystemArtifactStore } from "@jay-framework/production-server";
29
30
  import { fileURLToPath } from "node:url";
30
31
  import { select, confirm, input } from "@inquirer/prompts";
31
32
  const DEFAULT_CONFIG = {
@@ -65,7 +66,11 @@ function getConfigWithDefaults(config) {
65
66
  pagesBase: config.devServer?.pagesBase || DEFAULT_CONFIG.devServer.pagesBase,
66
67
  componentsBase: config.devServer?.componentsBase || DEFAULT_CONFIG.devServer.componentsBase,
67
68
  publicFolder: config.devServer?.publicFolder || DEFAULT_CONFIG.devServer.publicFolder,
68
- configBase: config.devServer?.configBase || DEFAULT_CONFIG.devServer.configBase
69
+ configBase: config.devServer?.configBase || DEFAULT_CONFIG.devServer.configBase,
70
+ // No default — absent means Vite's localhost-only host check (DL#220).
71
+ allowedHosts: config.devServer?.allowedHosts,
72
+ // Default off — source-editing markers (DL#219) only for opt-in sessions.
73
+ sourceEditing: config.devServer?.sourceEditing
69
74
  },
70
75
  site: config.site
71
76
  };
@@ -220,7 +225,9 @@ async function startDevServer(options = {}) {
220
225
  publicBaseUrlPath: "/",
221
226
  jayRollupConfig: jayOptions,
222
227
  logLevel: options.logLevel,
223
- httpServer
228
+ httpServer,
229
+ allowedHosts: resolvedConfig.devServer.allowedHosts,
230
+ sourceEditing: resolvedConfig.devServer.sourceEditing
224
231
  });
225
232
  app.use(server);
226
233
  const publicPath = path$1.resolve(resolvedConfig.devServer.publicFolder);
@@ -3306,7 +3313,7 @@ async function validateSchema(context, result) {
3306
3313
  }
3307
3314
  }
3308
3315
  const hasCapability = Boolean(
3309
- manifest.contracts || manifest.dynamic_contracts || manifest.actions || manifest.validators || manifest.routes || manifest.services || manifest.contexts || manifest.init || manifest.setup || manifest.agentkit || manifest.commands
3316
+ manifest.contracts || manifest.dynamic_contracts || manifest.actions || manifest.validators || manifest.projectValidators || manifest.routes || manifest.services || manifest.contexts || manifest.init || manifest.setup || manifest.agentkit || manifest.commands
3310
3317
  );
3311
3318
  if (!hasCapability) {
3312
3319
  if (manifest.global === true) {
@@ -3322,7 +3329,7 @@ async function validateSchema(context, result) {
3322
3329
  } else {
3323
3330
  result.warnings.push({
3324
3331
  type: "schema",
3325
- message: "Plugin declares no capabilities (contracts, dynamic_contracts, actions, validators, routes, services, contexts, init, setup, agentkit, commands)",
3332
+ message: "Plugin declares no capabilities (contracts, dynamic_contracts, actions, validators, projectValidators, routes, services, contexts, init, setup, agentkit, commands)",
3326
3333
  location: "plugin.yaml",
3327
3334
  suggestion: "Declare at least one capability. See agent-kit/plugin/plugin-structure.md"
3328
3335
  });
@@ -3512,6 +3519,43 @@ async function validateSchema(context, result) {
3512
3519
  });
3513
3520
  }
3514
3521
  }
3522
+ if (manifest.projectValidators) {
3523
+ if (!Array.isArray(manifest.projectValidators)) {
3524
+ result.errors.push({
3525
+ type: "schema",
3526
+ message: 'Field "projectValidators" must be an array',
3527
+ location: "plugin.yaml"
3528
+ });
3529
+ } else {
3530
+ manifest.projectValidators.forEach((validator, index) => {
3531
+ if (!validator.name) {
3532
+ result.errors.push({
3533
+ type: "schema",
3534
+ message: `Project validator at index ${index} is missing "name" field`,
3535
+ location: "plugin.yaml"
3536
+ });
3537
+ }
3538
+ if (!validator.handler) {
3539
+ result.errors.push({
3540
+ type: "schema",
3541
+ message: `Project validator "${validator.name || index}" is missing "handler" field`,
3542
+ location: "plugin.yaml",
3543
+ suggestion: "Specify the relative path to the project validator handler module"
3544
+ });
3545
+ }
3546
+ if (validator.handler) {
3547
+ validateHandlerRef(
3548
+ validator.handler,
3549
+ `Project validator "${validator.name}" handler`,
3550
+ "plugin.yaml projectValidators",
3551
+ context,
3552
+ result,
3553
+ "./tools"
3554
+ );
3555
+ }
3556
+ });
3557
+ }
3558
+ }
3515
3559
  if (manifest.setup) {
3516
3560
  if (typeof manifest.setup !== "string") {
3517
3561
  result.errors.push({
@@ -3593,7 +3637,7 @@ function hasComponentCapability(context) {
3593
3637
  return Boolean(m.contracts || m.dynamic_contracts || m.routes);
3594
3638
  }
3595
3639
  function needsToolsEntry(manifest) {
3596
- if (manifest.validators || manifest.commands || manifest.agentkit || manifest.setup) {
3640
+ if (manifest.validators || manifest.projectValidators || manifest.commands || manifest.agentkit || manifest.setup) {
3597
3641
  return true;
3598
3642
  }
3599
3643
  if (manifest.actions) {
@@ -3695,6 +3739,25 @@ function resolveContractFile(contractSpec, context) {
3695
3739
  }
3696
3740
  async function validateContract(contract, index, context, generateTypes, result) {
3697
3741
  result.contractsChecked = (result.contractsChecked || 0) + 1;
3742
+ const hasEditingGuide = typeof contract.editingGuide === "string" && contract.editingGuide.length > 0;
3743
+ const isReadonly = contract.readonly === true;
3744
+ if (!hasEditingGuide && !isReadonly) {
3745
+ result.warnings.push({
3746
+ type: "source-editing-coverage",
3747
+ message: `Contract "${contract.name}" declares no source-editing story. Add "editingGuide: <path.md>" (how to edit its bound data) or "readonly: true" (not editable in place).`,
3748
+ location: `plugin.yaml contracts[${index}]`,
3749
+ suggestion: "See the plugin source-editing guide (DL#219)."
3750
+ });
3751
+ } else if (hasEditingGuide) {
3752
+ const guideFullPath = path$1.resolve(context.pluginPath, contract.editingGuide);
3753
+ if (!fs$1.existsSync(guideFullPath)) {
3754
+ result.warnings.push({
3755
+ type: "source-editing-coverage",
3756
+ message: `Contract "${contract.name}" editingGuide file not found: ${contract.editingGuide}`,
3757
+ location: `plugin.yaml contracts[${index}]`
3758
+ });
3759
+ }
3760
+ }
3698
3761
  const contractPath = resolveContractFile(contract.contract, context);
3699
3762
  if (!contractPath) {
3700
3763
  result.errors.push({
@@ -4198,7 +4261,7 @@ function facetLabel(f) {
4198
4261
  }
4199
4262
  }
4200
4263
  const CONTENT_MARKER = "jay-content";
4201
- const META_ATTRS = /* @__PURE__ */ new Set(["jc", "override", "page-scope", CONTENT_MARKER]);
4264
+ const META_ATTRS = /* @__PURE__ */ new Set(["jc", "override", "page-scope", CONTENT_MARKER, "jay-policy"]);
4202
4265
  function isMetaAttr(name) {
4203
4266
  return META_ATTRS.has(name.toLowerCase());
4204
4267
  }
@@ -4821,6 +4884,8 @@ function normalizeCssBody(body) {
4821
4884
  }
4822
4885
  const SUPPRESS_KEY = "allow-broken-links";
4823
4886
  const SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin a <script type="application/jay-validations"> block';
4887
+ const SELF_LINK_ALLOW_ATTR = "jay-allow-self-link";
4888
+ const TODO_SCHEME = /^todo:/i;
4824
4889
  function normalizePath(p) {
4825
4890
  let s2 = p.split("#")[0].split("?")[0];
4826
4891
  s2 = s2.replace(/\/{2,}/g, "/");
@@ -4916,64 +4981,137 @@ function originOf(baseUrl) {
4916
4981
  return void 0;
4917
4982
  }
4918
4983
  }
4984
+ function fragmentOf(raw) {
4985
+ const hash = raw.indexOf("#");
4986
+ return hash === -1 ? "" : raw.slice(hash + 1);
4987
+ }
4919
4988
  function classifyHref(raw, baseUrl) {
4920
4989
  if (raw.includes("{")) return { kind: "skip" };
4921
4990
  const t = raw.trim();
4922
4991
  if (t === "" || t === "#") return { kind: "degenerate" };
4923
- if (t.startsWith("#")) return { kind: "skip" };
4992
+ if (TODO_SCHEME.test(t)) return { kind: "todo", note: t.replace(TODO_SCHEME, "").trim() };
4993
+ if (t.startsWith("#")) return { kind: "localFragment", fragment: t.slice(1) };
4924
4994
  if (/^(mailto:|tel:|javascript:|data:|sms:|ftp:)/i.test(t)) return { kind: "skip" };
4925
4995
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || t.startsWith("//")) {
4926
4996
  const sameOrigin = baseUrl ? originOf(baseUrl) : void 0;
4927
4997
  if (sameOrigin) {
4928
4998
  try {
4929
4999
  const u2 = new URL(t.startsWith("//") ? "https:" + t : t);
4930
- if (u2.origin === sameOrigin)
4931
- return { kind: "check", targetPath: normalizePath(u2.pathname) };
5000
+ if (u2.origin === sameOrigin) {
5001
+ const frag = u2.hash.slice(1);
5002
+ return frag ? { kind: "check", targetPath: normalizePath(u2.pathname), fragment: frag } : { kind: "check", targetPath: normalizePath(u2.pathname) };
5003
+ }
4932
5004
  } catch {
4933
5005
  }
4934
5006
  }
4935
5007
  return { kind: "skip" };
4936
5008
  }
4937
- if (t.startsWith("/")) return { kind: "check", targetPath: normalizePath(t) };
5009
+ if (t.startsWith("/")) {
5010
+ const frag = fragmentOf(t);
5011
+ return frag ? { kind: "check", targetPath: normalizePath(t), fragment: frag } : { kind: "check", targetPath: normalizePath(t) };
5012
+ }
4938
5013
  return { kind: "skip" };
4939
5014
  }
5015
+ function fragmentNeedsAnchor(fragment) {
5016
+ const f = fragment.trim();
5017
+ return f !== "" && f.toLowerCase() !== "top";
5018
+ }
5019
+ function collectAnchorIds(body) {
5020
+ const ids = /* @__PURE__ */ new Set();
5021
+ for (const el of body.querySelectorAll("[id]")) {
5022
+ const id = el.getAttribute("id");
5023
+ if (id) ids.add(id);
5024
+ }
5025
+ for (const a of body.querySelectorAll("a[name]")) {
5026
+ const name = a.getAttribute("name");
5027
+ if (name) ids.add(name);
5028
+ }
5029
+ return ids;
5030
+ }
4940
5031
  function checkInternalLinks(options) {
4941
5032
  const { parsedFiles, oracle, assetUrls, baseUrl, projectRoot } = options;
4942
5033
  const findings = [];
5034
+ const idsByUrl = /* @__PURE__ */ new Map();
4943
5035
  for (const { relativePath, parsed } of parsedFiles) {
4944
- if (parsed.validationOverrides?.["jay-stack"]?.[SUPPRESS_KEY] === true) continue;
5036
+ const url = oracle.selfUrlByPath.get(path$1.resolve(projectRoot ?? ".", relativePath));
5037
+ if (url !== void 0) idsByUrl.set(url, collectAnchorIds(parsed.body));
5038
+ }
5039
+ for (const { relativePath, parsed } of parsedFiles) {
5040
+ const allowBroken = parsed.validationOverrides?.["jay-stack"]?.[SUPPRESS_KEY] === true;
4945
5041
  const ownUrl = oracle.selfUrlByPath.get(path$1.resolve(projectRoot ?? ".", relativePath));
5042
+ const ownIds = ownUrl !== void 0 ? idsByUrl.get(ownUrl) : void 0;
4946
5043
  for (const anchor of parsed.body.querySelectorAll("a[href]")) {
4947
5044
  const raw = anchor.getAttribute("href");
4948
5045
  if (raw === void 0 || raw === null) continue;
4949
5046
  const verdict = classifyHref(raw, baseUrl);
4950
5047
  if (verdict.kind === "skip") continue;
4951
5048
  const tag = openingTag(anchor);
5049
+ if (verdict.kind === "todo") {
5050
+ const note = verdict.note ? ` — intended target "${verdict.note}" is not wired up yet` : "";
5051
+ findings.push({
5052
+ file: relativePath,
5053
+ severity: "warning",
5054
+ message: `TODO link ${tag}${note}. This reminder cannot be suppressed — resolve it before release.`,
5055
+ suggestion: `Replace the "todo:" href with the real destination once the page/route exists.`
5056
+ });
5057
+ continue;
5058
+ }
5059
+ if (verdict.kind === "localFragment") {
5060
+ if (!fragmentNeedsAnchor(verdict.fragment)) continue;
5061
+ if (ownIds && !ownIds.has(verdict.fragment)) {
5062
+ findings.push({
5063
+ file: relativePath,
5064
+ severity: "error",
5065
+ message: `Broken fragment link ${tag} — this page has no element with id "${verdict.fragment}".`,
5066
+ suggestion: `Add id="${verdict.fragment}" to the target element, or fix the fragment.`
5067
+ });
5068
+ }
5069
+ continue;
5070
+ }
4952
5071
  if (verdict.kind === "degenerate") {
5072
+ if (allowBroken) continue;
4953
5073
  findings.push({
4954
5074
  file: relativePath,
5075
+ severity: "error",
4955
5076
  message: `Anchor ${tag} has a placeholder href "${raw}" — it links nowhere and ships as a dead link.`,
4956
5077
  suggestion: `Point the link at a real page, use <button> for a JS-driven action, or ${SUPPRESS_HINT}.`
4957
5078
  });
4958
5079
  continue;
4959
5080
  }
4960
5081
  const target = verdict.targetPath;
4961
- if (ownUrl !== void 0 && target === ownUrl) {
5082
+ const fragment = verdict.fragment ?? "";
5083
+ if (ownUrl !== void 0 && target === ownUrl && !fragmentNeedsAnchor(fragment)) {
5084
+ if (anchor.hasAttribute(SELF_LINK_ALLOW_ATTR)) continue;
4962
5085
  findings.push({
4963
5086
  file: relativePath,
5087
+ severity: "warning",
4964
5088
  message: `Anchor ${tag} points to the page's own route "${target}" — a self-link goes nowhere.`,
4965
- suggestion: `Remove the link, use "#" within-page navigation, or point it at a different page.`
5089
+ suggestion: `Remove the link, use an in-page "#" fragment, or add ${SELF_LINK_ALLOW_ATTR} to the anchor if intentional.`
4966
5090
  });
4967
5091
  continue;
4968
5092
  }
4969
- if (oracle.staticUrls.has(target)) continue;
4970
- if (assetUrls.has(target)) continue;
4971
- if (oracle.dynamicMatchers.some((re) => re.test(target))) continue;
4972
- findings.push({
4973
- file: relativePath,
4974
- message: `Broken internal link in anchor ${tag} — no page or public asset produces the URL "${target}".`,
4975
- suggestion: `Create a placeholder page/route for "${target}", or remove the link. Otherwise ${SUPPRESS_HINT}.`
4976
- });
5093
+ const resolves = oracle.staticUrls.has(target) || assetUrls.has(target) || oracle.dynamicMatchers.some((re) => re.test(target));
5094
+ if (!resolves) {
5095
+ if (allowBroken) continue;
5096
+ findings.push({
5097
+ file: relativePath,
5098
+ severity: "error",
5099
+ message: `Broken internal link in anchor ${tag} — no page or public asset produces the URL "${target}".`,
5100
+ suggestion: `Create a placeholder page/route for "${target}", or remove the link. Otherwise ${SUPPRESS_HINT}.`
5101
+ });
5102
+ continue;
5103
+ }
5104
+ if (fragmentNeedsAnchor(fragment)) {
5105
+ const targetIds = target === ownUrl ? ownIds : idsByUrl.get(target);
5106
+ if (targetIds && !targetIds.has(fragment)) {
5107
+ findings.push({
5108
+ file: relativePath,
5109
+ severity: "error",
5110
+ message: `Broken fragment link ${tag} — page "${target}" has no element with id "${fragment}".`,
5111
+ suggestion: `Add id="${fragment}" to the target element on "${target}", or fix the fragment.`
5112
+ });
5113
+ }
5114
+ }
4977
5115
  }
4978
5116
  }
4979
5117
  return findings;
@@ -4983,6 +5121,47 @@ const LINK_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true
4983
5121
  const UNRESOLVED_BINDING = /\{[A-Za-z_$][\w$]*(?:\.[\w$]+)*\}/;
4984
5122
  const TITLE_MAX = 60;
4985
5123
  const DESCRIPTION_MAX = 160;
5124
+ const PAGE_CACHE_SOFT_BYTES = 300 * 1024;
5125
+ const PAGE_CACHE_HARD_BYTES = 512 * 1024;
5126
+ async function checkPageCacheFileSizes(args) {
5127
+ const { cacheAbsPath, routePattern } = args;
5128
+ const findings = [];
5129
+ const rel = path$1.basename(path$1.dirname(cacheAbsPath)) + "/" + path$1.basename(cacheAbsPath);
5130
+ let bytes;
5131
+ try {
5132
+ bytes = (await promises.stat(cacheAbsPath)).size;
5133
+ } catch (err) {
5134
+ const detail = err instanceof Error ? err.message : String(err);
5135
+ findings.push({
5136
+ file: routePattern,
5137
+ message: `${rel} is listed in the build manifest but could not be read (${detail}).`,
5138
+ suggestion: "Rebuild the project or fix the missing pre-rendered cache file.",
5139
+ severity: "error",
5140
+ source: "build-output"
5141
+ });
5142
+ return findings;
5143
+ }
5144
+ if (bytes >= PAGE_CACHE_HARD_BYTES) {
5145
+ const hardKiB = PAGE_CACHE_HARD_BYTES / 1024;
5146
+ findings.push({
5147
+ file: routePattern,
5148
+ message: `${rel} is ${bytes} bytes (≥ ${PAGE_CACHE_HARD_BYTES} bytes / ${hardKiB} KiB hard limit). Shrink slow-phase carry before deploy.`,
5149
+ suggestion: "Reduce slow-phase carryForward in this cache file before deploy (smaller payloads, defer non-first-paint data). See agent-kit developer/query-param-pages.md — cache size thresholds.",
5150
+ severity: "error",
5151
+ source: "build-output"
5152
+ });
5153
+ } else if (bytes >= PAGE_CACHE_SOFT_BYTES) {
5154
+ const softKiB = PAGE_CACHE_SOFT_BYTES / 1024;
5155
+ findings.push({
5156
+ file: routePattern,
5157
+ message: `${rel} is ${bytes} bytes (≥ ${PAGE_CACHE_SOFT_BYTES} bytes / ${softKiB} KiB soft budget). Consider deferring non-critical carry data.`,
5158
+ suggestion: "Review carryForward size: keep only what the default view needs for first paint; defer the rest (per-request fetch or post-hydration load). Oversize caches often need product decisions about what loads when — see agent-kit developer/query-param-pages.md.",
5159
+ severity: "warning",
5160
+ source: "build-output"
5161
+ });
5162
+ }
5163
+ return findings;
5164
+ }
4986
5165
  function buildUrlFromManifest(pattern, params) {
4987
5166
  return pattern.replace(/\[\[(\w+)\]\]/g, (_, name) => params[name] || "").replace(/\[\.\.\.(\w+)\]/g, (_, name) => params[name] || "").replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
4988
5167
  }
@@ -5053,16 +5232,15 @@ async function loadBuildManifest(backendDir) {
5053
5232
  }
5054
5233
  return { manifest, metadata };
5055
5234
  }
5056
- function buildBuildOracle(manifest, backendDir) {
5235
+ async function buildBuildOracle(manifest, backendDir) {
5236
+ const store = new FilesystemArtifactStore(backendDir);
5057
5237
  const urls = /* @__PURE__ */ new Set();
5058
5238
  const instances = [];
5059
5239
  for (const route of manifest.routes) {
5060
5240
  const hasDynamic = route.segments.some((s2) => s2.type !== "static");
5061
- if (route.instances.length === 0) {
5062
- if (!hasDynamic) urls.add(normalizePath(route.pattern));
5063
- continue;
5064
- }
5065
- for (const instance of route.instances) {
5241
+ let count = 0;
5242
+ for await (const instance of store.listInstances(route, { fresh: true })) {
5243
+ count++;
5066
5244
  const url = routeInstanceUrl(route, instance);
5067
5245
  urls.add(url);
5068
5246
  instances.push({
@@ -5072,6 +5250,7 @@ function buildBuildOracle(manifest, backendDir) {
5072
5250
  headMeta: route.headMeta
5073
5251
  });
5074
5252
  }
5253
+ if (count === 0 && !hasDynamic) urls.add(normalizePath(route.pattern));
5075
5254
  }
5076
5255
  return { urls, instances };
5077
5256
  }
@@ -5329,122 +5508,6 @@ async function newestSourceMtime(dirs) {
5329
5508
  for (const dir of dirs) await walk(dir);
5330
5509
  return newest;
5331
5510
  }
5332
- const URN_PATTERN = /^urn:air:[^:\s]+:[^:\s]+:[^:\s]+$/;
5333
- const MEDIA_TYPE_PATTERN = /^[a-z]+\/[A-Za-z0-9.+-]+$/;
5334
- const ARD_SUGGEST = "See developer/seo-guide.md (ARD v0.91) for the schema and a worked example.";
5335
- function validateArdDocument(doc) {
5336
- const findings = [];
5337
- if (!doc || typeof doc !== "object") {
5338
- return [
5339
- {
5340
- message: "ard.json must be a JSON object with an `entries` array.",
5341
- severity: "error",
5342
- suggestion: ARD_SUGGEST
5343
- }
5344
- ];
5345
- }
5346
- const entries = doc.entries;
5347
- if (!Array.isArray(entries)) {
5348
- return [
5349
- {
5350
- message: "ard.json is missing the required `entries` array.",
5351
- severity: "error",
5352
- suggestion: ARD_SUGGEST
5353
- }
5354
- ];
5355
- }
5356
- entries.forEach((entry, i) => {
5357
- const at = `ard.json entries[${i}]`;
5358
- if (!entry || typeof entry !== "object") {
5359
- findings.push({ message: `${at} must be an object.`, severity: "error" });
5360
- return;
5361
- }
5362
- if (typeof entry.identifier !== "string" || !URN_PATTERN.test(entry.identifier)) {
5363
- findings.push({
5364
- message: `${at}.identifier must match urn:air:<publisher>:<namespace>:<name>.`,
5365
- severity: "error",
5366
- suggestion: ARD_SUGGEST
5367
- });
5368
- }
5369
- if (typeof entry.displayName !== "string" || entry.displayName.trim() === "") {
5370
- findings.push({
5371
- message: `${at}.displayName is required.`,
5372
- severity: "error"
5373
- });
5374
- }
5375
- if (typeof entry.type !== "string" || !MEDIA_TYPE_PATTERN.test(entry.type)) {
5376
- findings.push({
5377
- message: `${at}.type must be an IANA media type (e.g. application/mcp-server-card+json).`,
5378
- severity: "error"
5379
- });
5380
- }
5381
- const hasUrl = entry.url !== void 0;
5382
- const hasData = entry.data !== void 0;
5383
- if (hasUrl === hasData) {
5384
- findings.push({
5385
- message: `${at} must have exactly one of \`url\` or \`data\`.`,
5386
- severity: "error"
5387
- });
5388
- }
5389
- const rq = entry.representativeQueries;
5390
- if (rq === void 0) {
5391
- findings.push({
5392
- message: `${at} is missing representativeQueries (2–5 recommended) — agents may not surface this resource.`,
5393
- severity: "warning",
5394
- suggestion: ARD_SUGGEST
5395
- });
5396
- } else if (!Array.isArray(rq) || rq.length < 2 || rq.length > 5) {
5397
- findings.push({
5398
- message: `${at}.representativeQueries should list 2–5 example queries.`,
5399
- severity: "warning"
5400
- });
5401
- }
5402
- });
5403
- return findings;
5404
- }
5405
- async function checkArdFiles(projectRoot) {
5406
- const wellKnown = path$1.resolve(projectRoot, "public/.well-known");
5407
- const ardPath = path$1.join(wellKnown, "ard.json");
5408
- const aliasPath = path$1.join(wellKnown, "ai-catalog.json");
5409
- const ardRaw = await readIfExists(ardPath);
5410
- const aliasRaw = await readIfExists(aliasPath);
5411
- if (ardRaw === null && aliasRaw === null) {
5412
- return { findings: [], absent: true };
5413
- }
5414
- const findings = [];
5415
- if (ardRaw === null && aliasRaw !== null) {
5416
- findings.push({
5417
- message: "Only public/.well-known/ai-catalog.json found — ARD renamed this to ard.json. Add ard.json (keep ai-catalog.json as a copy for older clients).",
5418
- severity: "warning",
5419
- suggestion: ARD_SUGGEST
5420
- });
5421
- } else if (ardRaw !== null && aliasRaw !== null && ardRaw.trim() !== aliasRaw.trim()) {
5422
- findings.push({
5423
- message: "public/.well-known/ard.json and ai-catalog.json differ — the alias should be a copy of ard.json.",
5424
- severity: "warning"
5425
- });
5426
- }
5427
- const canonicalRaw = ardRaw ?? aliasRaw;
5428
- let parsed;
5429
- try {
5430
- parsed = JSON.parse(canonicalRaw);
5431
- } catch (e2) {
5432
- findings.push({
5433
- message: `${ardRaw !== null ? "ard.json" : "ai-catalog.json"} is not valid JSON: ${e2.message}`,
5434
- severity: "error"
5435
- });
5436
- return { findings, absent: false };
5437
- }
5438
- findings.push(...validateArdDocument(parsed));
5439
- return { findings, absent: false };
5440
- }
5441
- async function readIfExists(p) {
5442
- try {
5443
- return await promises.readFile(p, "utf8");
5444
- } catch {
5445
- return null;
5446
- }
5447
- }
5448
5511
  async function findJayFiles(dir) {
5449
5512
  return await glob(`${dir}/**/*${JAY_EXTENSION$1}`);
5450
5513
  }
@@ -6448,6 +6511,37 @@ function resolveContractLinks(contract, contractPath) {
6448
6511
  const contractDir = path$1.dirname(contractPath);
6449
6512
  return { ...contract, tags: resolveLinkedTags(contract.tags, contractDir) };
6450
6513
  }
6514
+ async function loadPluginToolsHandler(plugin, handlerRef, capabilityLabel, name, source, errors) {
6515
+ try {
6516
+ let handlerModule;
6517
+ if (plugin.isLocal) {
6518
+ const handlerPath = path$1.resolve(plugin.pluginPath, handlerRef);
6519
+ handlerModule = await import(handlerPath);
6520
+ } else {
6521
+ handlerModule = await import(`${plugin.packageName}/tools`);
6522
+ }
6523
+ const fn = plugin.isLocal ? handlerModule.validate ?? handlerModule.default : handlerModule[handlerRef];
6524
+ if (typeof fn !== "function") {
6525
+ const expected = plugin.isLocal ? "validate" : handlerRef;
6526
+ errors.push({
6527
+ file: `plugin:${plugin.name}`,
6528
+ message: `${capabilityLabel} "${name}" handler does not export a "${expected}" function`,
6529
+ stage: "plugin",
6530
+ source
6531
+ });
6532
+ return void 0;
6533
+ }
6534
+ return fn;
6535
+ } catch (loadErr) {
6536
+ errors.push({
6537
+ file: `plugin:${plugin.name}`,
6538
+ message: `Failed to load ${capabilityLabel.toLowerCase()} "${name}": ${loadErr.message}`,
6539
+ stage: "plugin",
6540
+ source
6541
+ });
6542
+ return void 0;
6543
+ }
6544
+ }
6451
6545
  async function runPluginValidators(projectRoot, parsedFiles, errors, warnings) {
6452
6546
  const scannedPlugins = await scanPlugins({ projectRoot, includeDevDeps: true });
6453
6547
  const loadedValidators = [];
@@ -6455,37 +6549,16 @@ async function runPluginValidators(projectRoot, parsedFiles, errors, warnings) {
6455
6549
  if (!plugin.manifest.validators) continue;
6456
6550
  for (const validatorDef of plugin.manifest.validators) {
6457
6551
  const source = `${plugin.name}/${validatorDef.name}`;
6458
- let validatorFn;
6459
- try {
6460
- let handlerModule;
6461
- if (plugin.isLocal) {
6462
- const handlerPath = path$1.resolve(plugin.pluginPath, validatorDef.handler);
6463
- handlerModule = await import(handlerPath);
6464
- } else {
6465
- handlerModule = await import(`${plugin.packageName}/tools`);
6466
- }
6467
- validatorFn = plugin.isLocal ? handlerModule.validate ?? handlerModule.default : handlerModule[validatorDef.handler];
6468
- if (typeof validatorFn !== "function") {
6469
- errors.push({
6470
- file: `plugin:${plugin.name}`,
6471
- message: `Validator "${validatorDef.name}" handler does not export a "validate" function`,
6472
- stage: "plugin",
6473
- source
6474
- });
6475
- loadedValidators.push(source);
6476
- continue;
6477
- }
6478
- } catch (loadErr) {
6479
- errors.push({
6480
- file: `plugin:${plugin.name}`,
6481
- message: `Failed to load validator "${validatorDef.name}": ${loadErr.message}`,
6482
- stage: "plugin",
6483
- source
6484
- });
6485
- loadedValidators.push(source);
6486
- continue;
6487
- }
6552
+ const validatorFn = await loadPluginToolsHandler(
6553
+ plugin,
6554
+ validatorDef.handler,
6555
+ "Validator",
6556
+ validatorDef.name,
6557
+ source,
6558
+ errors
6559
+ );
6488
6560
  loadedValidators.push(source);
6561
+ if (!validatorFn) continue;
6489
6562
  for (const { relativePath, parsed } of parsedFiles) {
6490
6563
  const pageContractPath = parsed.contractRef ? path$1.resolve(
6491
6564
  path$1.dirname(path$1.resolve(projectRoot, relativePath)),
@@ -6568,6 +6641,56 @@ async function runPluginValidators(projectRoot, parsedFiles, errors, warnings) {
6568
6641
  }
6569
6642
  return loadedValidators;
6570
6643
  }
6644
+ async function runProjectValidators(projectRoot, errors, warnings) {
6645
+ const scannedPlugins = await scanPlugins({ projectRoot, includeDevDeps: true });
6646
+ const loadedValidators = [];
6647
+ for (const [, plugin] of scannedPlugins) {
6648
+ if (!plugin.manifest.projectValidators) continue;
6649
+ for (const validatorDef of plugin.manifest.projectValidators) {
6650
+ const source = `${plugin.name}/${validatorDef.name}`;
6651
+ const validatorFn = await loadPluginToolsHandler(
6652
+ plugin,
6653
+ validatorDef.handler,
6654
+ "Project validator",
6655
+ validatorDef.name,
6656
+ source,
6657
+ errors
6658
+ );
6659
+ loadedValidators.push(source);
6660
+ if (!validatorFn) continue;
6661
+ const ctx = { projectRoot };
6662
+ try {
6663
+ const findings = await validatorFn(ctx);
6664
+ for (const finding of findings) {
6665
+ if (finding.severity === "error") {
6666
+ errors.push({
6667
+ file: finding.file,
6668
+ message: finding.message,
6669
+ stage: "plugin",
6670
+ source,
6671
+ suggestion: finding.suggestion
6672
+ });
6673
+ } else {
6674
+ warnings.push({
6675
+ file: finding.file,
6676
+ message: finding.message,
6677
+ source,
6678
+ suggestion: finding.suggestion
6679
+ });
6680
+ }
6681
+ }
6682
+ } catch (runErr) {
6683
+ errors.push({
6684
+ file: ".jay",
6685
+ message: `Project validator "${source}" threw: ${runErr.message}`,
6686
+ stage: "plugin",
6687
+ source
6688
+ });
6689
+ }
6690
+ }
6691
+ }
6692
+ return loadedValidators;
6693
+ }
6571
6694
  async function validateJayFiles(options = {}) {
6572
6695
  const projectRoot = options.projectRoot ?? process.cwd();
6573
6696
  const config = loadConfig(projectRoot);
@@ -6924,56 +7047,6 @@ async function validateJayFiles(options = {}) {
6924
7047
  }
6925
7048
  };
6926
7049
  }
6927
- const robotsTxtPath = path$1.resolve(projectRoot, "public/robots.txt");
6928
- if (!fs$1.existsSync(robotsTxtPath)) {
6929
- warnings.push({
6930
- file: "public/robots.txt",
6931
- message: "public/robots.txt not found — search engines may crawl pages you don't intend to expose.",
6932
- suggestion: "Create public/robots.txt with: User-agent: *\nAllow: /\nSitemap: https://your-domain.com/sitemap.xml"
6933
- });
6934
- }
6935
- if (!config.site?.baseUrl) {
6936
- warnings.push({
6937
- file: ".jay",
6938
- message: "site.baseUrl not configured — sitemap.xml will not be generated in production.",
6939
- suggestion: "Add to .jay config:\n site:\n baseUrl: https://your-domain.com"
6940
- });
6941
- }
6942
- if (!config.site?.name) {
6943
- warnings.push({
6944
- file: ".jay",
6945
- message: "site.name not configured — Organization/WebSite JSON-LD and llms.txt will not be emitted, reducing how well search engines and agents understand the site.",
6946
- suggestion: "Add to .jay config:\n site:\n name: Your Site Name\n logo: /logo.png\n description: What your site is about"
6947
- });
6948
- }
6949
- const allowNoArd = config.validations?.["allow-no-ard"] === true;
6950
- const ardResult = await checkArdFiles(projectRoot);
6951
- if (ardResult.absent) {
6952
- if (!allowNoArd) {
6953
- warnings.push({
6954
- file: "public/.well-known/ard.json",
6955
- message: "public/.well-known/ard.json not found — agents cannot discover this site’s resources/APIs via Agentic Resource Discovery.",
6956
- suggestion: "Author public/.well-known/ard.json (ARD v0.91), or suppress with `validations: { allow-no-ard: true }` in .jay if the site has no agent resources to advertise. See developer/seo-guide.md."
6957
- });
6958
- }
6959
- } else {
6960
- for (const finding of ardResult.findings) {
6961
- if (finding.severity === "error") {
6962
- errors.push({
6963
- file: "public/.well-known/ard.json",
6964
- message: finding.message,
6965
- stage: "generate",
6966
- suggestion: finding.suggestion
6967
- });
6968
- } else {
6969
- warnings.push({
6970
- file: "public/.well-known/ard.json",
6971
- message: finding.message,
6972
- suggestion: finding.suggestion
6973
- });
6974
- }
6975
- }
6976
- }
6977
7050
  const allowNoErrorPages = config.validations?.["allow-no-error-pages"] === true;
6978
7051
  if (!allowNoErrorPages) {
6979
7052
  const scanResult = await scanRoutes(scanDir, {
@@ -7060,13 +7133,22 @@ async function validateJayFiles(options = {}) {
7060
7133
  projectRoot
7061
7134
  });
7062
7135
  for (const finding of linkFindings) {
7063
- errors.push({
7064
- file: finding.file,
7065
- message: finding.message,
7066
- stage: "generate",
7067
- source: "internal-links",
7068
- suggestion: finding.suggestion
7069
- });
7136
+ if (finding.severity === "warning") {
7137
+ warnings.push({
7138
+ file: finding.file,
7139
+ message: finding.message,
7140
+ source: "internal-links",
7141
+ suggestion: finding.suggestion
7142
+ });
7143
+ } else {
7144
+ errors.push({
7145
+ file: finding.file,
7146
+ message: finding.message,
7147
+ stage: "generate",
7148
+ source: "internal-links",
7149
+ suggestion: finding.suggestion
7150
+ });
7151
+ }
7070
7152
  }
7071
7153
  if (options.tier2) {
7072
7154
  await runBuildOutputValidation({
@@ -7081,6 +7163,7 @@ async function validateJayFiles(options = {}) {
7081
7163
  });
7082
7164
  }
7083
7165
  const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
7166
+ const projectValidators = await runProjectValidators(projectRoot, errors, warnings);
7084
7167
  return {
7085
7168
  valid: errors.length === 0,
7086
7169
  jayHtmlFilesScanned: jayHtmlFiles.length,
@@ -7088,7 +7171,7 @@ async function validateJayFiles(options = {}) {
7088
7171
  errors,
7089
7172
  warnings,
7090
7173
  coverage,
7091
- pluginValidators,
7174
+ pluginValidators: [...pluginValidators, ...projectValidators],
7092
7175
  designSystemCoverage,
7093
7176
  designSystemReuse
7094
7177
  };
@@ -7127,7 +7210,7 @@ async function runBuildOutputValidation(args) {
7127
7210
  suggestion: "Re-run `jay-stack build` for accurate results."
7128
7211
  });
7129
7212
  }
7130
- const oracle = buildBuildOracle(loaded.manifest, backendDir);
7213
+ const oracle = await buildBuildOracle(loaded.manifest, backendDir);
7131
7214
  const findings = [];
7132
7215
  findings.push(
7133
7216
  ...checkTemplateLinksAgainstBuild({
@@ -7139,6 +7222,14 @@ async function runBuildOutputValidation(args) {
7139
7222
  })
7140
7223
  );
7141
7224
  findings.push(...checkBuildJsonLd(loaded.manifest));
7225
+ for (const inst of oracle.instances) {
7226
+ findings.push(
7227
+ ...await checkPageCacheFileSizes({
7228
+ cacheAbsPath: inst.cacheAbsPath,
7229
+ routePattern: inst.pattern
7230
+ })
7231
+ );
7232
+ }
7142
7233
  for (const instance of oracle.instances) {
7143
7234
  const slowViewState = await loadSlowViewState(instance.cacheAbsPath);
7144
7235
  findings.push(
@@ -7507,7 +7598,7 @@ async function resolveProductionContext(projectPath, versionOverride) {
7507
7598
  let siteBaseUrl;
7508
7599
  let site;
7509
7600
  try {
7510
- const jayConfig = YAML.parse(await fs$2.readFile(jayConfigPath, "utf-8"));
7601
+ const jayConfig = YAML.parse(await fs$1$1.readFile(jayConfigPath, "utf-8"));
7511
7602
  pagesBase = jayConfig?.devServer?.pagesBase || pagesBase;
7512
7603
  siteBaseUrl = jayConfig?.site?.baseUrl;
7513
7604
  site = jayConfig?.site;
@@ -7527,7 +7618,7 @@ async function resolveProductionContext(projectPath, versionOverride) {
7527
7618
  async function resolveVersionFromPackageJson(projectRoot) {
7528
7619
  try {
7529
7620
  const pkgJson = JSON.parse(
7530
- await fs$2.readFile(path__default.join(projectRoot, "package.json"), "utf-8")
7621
+ await fs$1$1.readFile(path__default.join(projectRoot, "package.json"), "utf-8")
7531
7622
  );
7532
7623
  if (pkgJson.version) {
7533
7624
  return pkgJson.version;
@@ -7548,7 +7639,7 @@ async function runBuild(projectPath, options) {
7548
7639
  }
7549
7640
  const ctx = await resolveProductionContext(projectPath, options.version);
7550
7641
  await generateDefinitionFiles(ctx);
7551
- const { buildVersion } = await import("./index-B5oavmfA.js");
7642
+ const { buildVersion } = await import("@jay-framework/production-build");
7552
7643
  await buildVersion({
7553
7644
  version: ctx.version,
7554
7645
  projectRoot: ctx.resolvedPath,
@@ -7914,22 +8005,22 @@ async function ensureAgentKitDocs(projectRoot, _force, mode) {
7914
8005
  const roleOutputDir = path__default.join(agentKitDir, role);
7915
8006
  let files;
7916
8007
  try {
7917
- files = (await fs$2.readdir(roleTemplateDir)).filter((f) => f.endsWith(".md"));
8008
+ files = (await fs$1$1.readdir(roleTemplateDir)).filter((f) => f.endsWith(".md"));
7918
8009
  } catch {
7919
8010
  continue;
7920
8011
  }
7921
- await fs$2.mkdir(roleOutputDir, { recursive: true });
8012
+ await fs$1$1.mkdir(roleOutputDir, { recursive: true });
7922
8013
  for (const filename of files) {
7923
- await fs$2.copyFile(
8014
+ await fs$1$1.copyFile(
7924
8015
  path__default.join(roleTemplateDir, filename),
7925
8016
  path__default.join(roleOutputDir, filename)
7926
8017
  );
7927
8018
  getLogger().info(chalk.gray(` Created agent-kit/${role}/${filename}`));
7928
8019
  }
7929
8020
  }
7930
- const topLevelFiles = (await fs$2.readdir(templateDir)).filter((f) => f.endsWith(".md"));
8021
+ const topLevelFiles = (await fs$1$1.readdir(templateDir)).filter((f) => f.endsWith(".md"));
7931
8022
  for (const filename of topLevelFiles) {
7932
- await fs$2.copyFile(path__default.join(templateDir, filename), path__default.join(agentKitDir, filename));
8023
+ await fs$1$1.copyFile(path__default.join(templateDir, filename), path__default.join(agentKitDir, filename));
7933
8024
  getLogger().info(chalk.gray(` Created agent-kit/${filename}`));
7934
8025
  }
7935
8026
  const sharedDirs = ["contracts"];
@@ -7941,15 +8032,15 @@ async function ensureAgentKitDocs(projectRoot, _force, mode) {
7941
8032
  }
7942
8033
  }
7943
8034
  async function copyDirRecursive(src, dest) {
7944
- await fs$2.mkdir(dest, { recursive: true });
7945
- const entries = await fs$2.readdir(src, { withFileTypes: true });
8035
+ await fs$1$1.mkdir(dest, { recursive: true });
8036
+ const entries = await fs$1$1.readdir(src, { withFileTypes: true });
7946
8037
  for (const entry of entries) {
7947
8038
  const srcPath = path__default.join(src, entry.name);
7948
8039
  const destPath = path__default.join(dest, entry.name);
7949
8040
  if (entry.isDirectory()) {
7950
8041
  await copyDirRecursive(srcPath, destPath);
7951
8042
  } else if (entry.name.endsWith(".md")) {
7952
- await fs$2.copyFile(srcPath, destPath);
8043
+ await fs$1$1.copyFile(srcPath, destPath);
7953
8044
  }
7954
8045
  }
7955
8046
  }
@@ -7965,7 +8056,7 @@ async function mergePluginAgentKitGuides(projectRoot, mode) {
7965
8056
  const roleSourceDir = path__default.join(pluginAgentKitDir, role);
7966
8057
  let files;
7967
8058
  try {
7968
- files = (await fs$2.readdir(roleSourceDir)).filter(
8059
+ files = (await fs$1$1.readdir(roleSourceDir)).filter(
7969
8060
  (f) => f.endsWith(".md") && f !== "INSTRUCTIONS.md"
7970
8061
  );
7971
8062
  } catch {
@@ -7973,13 +8064,13 @@ async function mergePluginAgentKitGuides(projectRoot, mode) {
7973
8064
  }
7974
8065
  if (files.length === 0) continue;
7975
8066
  const roleOutputDir = path__default.join(agentKitDir, role);
7976
- await fs$2.mkdir(roleOutputDir, { recursive: true });
8067
+ await fs$1$1.mkdir(roleOutputDir, { recursive: true });
7977
8068
  for (const filename of files) {
7978
8069
  const sourcePath = path__default.join(roleSourceDir, filename);
7979
- await fs$2.copyFile(sourcePath, path__default.join(roleOutputDir, filename));
8070
+ await fs$1$1.copyFile(sourcePath, path__default.join(roleOutputDir, filename));
7980
8071
  let description = "";
7981
8072
  try {
7982
- const content = await fs$2.readFile(sourcePath, "utf-8");
8073
+ const content = await fs$1$1.readFile(sourcePath, "utf-8");
7983
8074
  const lines = content.split("\n");
7984
8075
  let pastHeading = false;
7985
8076
  for (const line of lines) {
@@ -8018,7 +8109,7 @@ async function mergePluginAgentKitGuides(projectRoot, mode) {
8018
8109
  lines.push(`| [${filename}](${filename}) | ${pluginName} | ${description} |`);
8019
8110
  }
8020
8111
  lines.push("");
8021
- await fs$2.appendFile(instructionsPath, lines.join("\n"));
8112
+ await fs$1$1.appendFile(instructionsPath, lines.join("\n"));
8022
8113
  }
8023
8114
  }
8024
8115
  async function generatePluginAgentKit(projectRoot, options, initErrors, viteServer) {