@jay-framework/jay-stack-cli 0.25.5 → 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 = {
@@ -3312,7 +3313,7 @@ async function validateSchema(context, result) {
3312
3313
  }
3313
3314
  }
3314
3315
  const hasCapability = Boolean(
3315
- 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
3316
3317
  );
3317
3318
  if (!hasCapability) {
3318
3319
  if (manifest.global === true) {
@@ -3328,7 +3329,7 @@ async function validateSchema(context, result) {
3328
3329
  } else {
3329
3330
  result.warnings.push({
3330
3331
  type: "schema",
3331
- 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)",
3332
3333
  location: "plugin.yaml",
3333
3334
  suggestion: "Declare at least one capability. See agent-kit/plugin/plugin-structure.md"
3334
3335
  });
@@ -3518,6 +3519,43 @@ async function validateSchema(context, result) {
3518
3519
  });
3519
3520
  }
3520
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
+ }
3521
3559
  if (manifest.setup) {
3522
3560
  if (typeof manifest.setup !== "string") {
3523
3561
  result.errors.push({
@@ -3599,7 +3637,7 @@ function hasComponentCapability(context) {
3599
3637
  return Boolean(m.contracts || m.dynamic_contracts || m.routes);
3600
3638
  }
3601
3639
  function needsToolsEntry(manifest) {
3602
- if (manifest.validators || manifest.commands || manifest.agentkit || manifest.setup) {
3640
+ if (manifest.validators || manifest.projectValidators || manifest.commands || manifest.agentkit || manifest.setup) {
3603
3641
  return true;
3604
3642
  }
3605
3643
  if (manifest.actions) {
@@ -3701,6 +3739,25 @@ function resolveContractFile(contractSpec, context) {
3701
3739
  }
3702
3740
  async function validateContract(contract, index, context, generateTypes, result) {
3703
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
+ }
3704
3761
  const contractPath = resolveContractFile(contract.contract, context);
3705
3762
  if (!contractPath) {
3706
3763
  result.errors.push({
@@ -4204,7 +4261,7 @@ function facetLabel(f) {
4204
4261
  }
4205
4262
  }
4206
4263
  const CONTENT_MARKER = "jay-content";
4207
- 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"]);
4208
4265
  function isMetaAttr(name) {
4209
4266
  return META_ATTRS.has(name.toLowerCase());
4210
4267
  }
@@ -4827,6 +4884,8 @@ function normalizeCssBody(body) {
4827
4884
  }
4828
4885
  const SUPPRESS_KEY = "allow-broken-links";
4829
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;
4830
4889
  function normalizePath(p) {
4831
4890
  let s2 = p.split("#")[0].split("?")[0];
4832
4891
  s2 = s2.replace(/\/{2,}/g, "/");
@@ -4922,64 +4981,137 @@ function originOf(baseUrl) {
4922
4981
  return void 0;
4923
4982
  }
4924
4983
  }
4984
+ function fragmentOf(raw) {
4985
+ const hash = raw.indexOf("#");
4986
+ return hash === -1 ? "" : raw.slice(hash + 1);
4987
+ }
4925
4988
  function classifyHref(raw, baseUrl) {
4926
4989
  if (raw.includes("{")) return { kind: "skip" };
4927
4990
  const t = raw.trim();
4928
4991
  if (t === "" || t === "#") return { kind: "degenerate" };
4929
- 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) };
4930
4994
  if (/^(mailto:|tel:|javascript:|data:|sms:|ftp:)/i.test(t)) return { kind: "skip" };
4931
4995
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || t.startsWith("//")) {
4932
4996
  const sameOrigin = baseUrl ? originOf(baseUrl) : void 0;
4933
4997
  if (sameOrigin) {
4934
4998
  try {
4935
4999
  const u2 = new URL(t.startsWith("//") ? "https:" + t : t);
4936
- if (u2.origin === sameOrigin)
4937
- 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
+ }
4938
5004
  } catch {
4939
5005
  }
4940
5006
  }
4941
5007
  return { kind: "skip" };
4942
5008
  }
4943
- 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
+ }
4944
5013
  return { kind: "skip" };
4945
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
+ }
4946
5031
  function checkInternalLinks(options) {
4947
5032
  const { parsedFiles, oracle, assetUrls, baseUrl, projectRoot } = options;
4948
5033
  const findings = [];
5034
+ const idsByUrl = /* @__PURE__ */ new Map();
4949
5035
  for (const { relativePath, parsed } of parsedFiles) {
4950
- 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;
4951
5041
  const ownUrl = oracle.selfUrlByPath.get(path$1.resolve(projectRoot ?? ".", relativePath));
5042
+ const ownIds = ownUrl !== void 0 ? idsByUrl.get(ownUrl) : void 0;
4952
5043
  for (const anchor of parsed.body.querySelectorAll("a[href]")) {
4953
5044
  const raw = anchor.getAttribute("href");
4954
5045
  if (raw === void 0 || raw === null) continue;
4955
5046
  const verdict = classifyHref(raw, baseUrl);
4956
5047
  if (verdict.kind === "skip") continue;
4957
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
+ }
4958
5071
  if (verdict.kind === "degenerate") {
5072
+ if (allowBroken) continue;
4959
5073
  findings.push({
4960
5074
  file: relativePath,
5075
+ severity: "error",
4961
5076
  message: `Anchor ${tag} has a placeholder href "${raw}" — it links nowhere and ships as a dead link.`,
4962
5077
  suggestion: `Point the link at a real page, use <button> for a JS-driven action, or ${SUPPRESS_HINT}.`
4963
5078
  });
4964
5079
  continue;
4965
5080
  }
4966
5081
  const target = verdict.targetPath;
4967
- 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;
4968
5085
  findings.push({
4969
5086
  file: relativePath,
5087
+ severity: "warning",
4970
5088
  message: `Anchor ${tag} points to the page's own route "${target}" — a self-link goes nowhere.`,
4971
- 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.`
4972
5090
  });
4973
5091
  continue;
4974
5092
  }
4975
- if (oracle.staticUrls.has(target)) continue;
4976
- if (assetUrls.has(target)) continue;
4977
- if (oracle.dynamicMatchers.some((re) => re.test(target))) continue;
4978
- findings.push({
4979
- file: relativePath,
4980
- message: `Broken internal link in anchor ${tag} — no page or public asset produces the URL "${target}".`,
4981
- suggestion: `Create a placeholder page/route for "${target}", or remove the link. Otherwise ${SUPPRESS_HINT}.`
4982
- });
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
+ }
4983
5115
  }
4984
5116
  }
4985
5117
  return findings;
@@ -4989,6 +5121,47 @@ const LINK_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true
4989
5121
  const UNRESOLVED_BINDING = /\{[A-Za-z_$][\w$]*(?:\.[\w$]+)*\}/;
4990
5122
  const TITLE_MAX = 60;
4991
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
+ }
4992
5165
  function buildUrlFromManifest(pattern, params) {
4993
5166
  return pattern.replace(/\[\[(\w+)\]\]/g, (_, name) => params[name] || "").replace(/\[\.\.\.(\w+)\]/g, (_, name) => params[name] || "").replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
4994
5167
  }
@@ -5059,16 +5232,15 @@ async function loadBuildManifest(backendDir) {
5059
5232
  }
5060
5233
  return { manifest, metadata };
5061
5234
  }
5062
- function buildBuildOracle(manifest, backendDir) {
5235
+ async function buildBuildOracle(manifest, backendDir) {
5236
+ const store = new FilesystemArtifactStore(backendDir);
5063
5237
  const urls = /* @__PURE__ */ new Set();
5064
5238
  const instances = [];
5065
5239
  for (const route of manifest.routes) {
5066
5240
  const hasDynamic = route.segments.some((s2) => s2.type !== "static");
5067
- if (route.instances.length === 0) {
5068
- if (!hasDynamic) urls.add(normalizePath(route.pattern));
5069
- continue;
5070
- }
5071
- for (const instance of route.instances) {
5241
+ let count = 0;
5242
+ for await (const instance of store.listInstances(route, { fresh: true })) {
5243
+ count++;
5072
5244
  const url = routeInstanceUrl(route, instance);
5073
5245
  urls.add(url);
5074
5246
  instances.push({
@@ -5078,6 +5250,7 @@ function buildBuildOracle(manifest, backendDir) {
5078
5250
  headMeta: route.headMeta
5079
5251
  });
5080
5252
  }
5253
+ if (count === 0 && !hasDynamic) urls.add(normalizePath(route.pattern));
5081
5254
  }
5082
5255
  return { urls, instances };
5083
5256
  }
@@ -5335,122 +5508,6 @@ async function newestSourceMtime(dirs) {
5335
5508
  for (const dir of dirs) await walk(dir);
5336
5509
  return newest;
5337
5510
  }
5338
- const URN_PATTERN = /^urn:air:[^:\s]+:[^:\s]+:[^:\s]+$/;
5339
- const MEDIA_TYPE_PATTERN = /^[a-z]+\/[A-Za-z0-9.+-]+$/;
5340
- const ARD_SUGGEST = "See developer/seo-guide.md (ARD v0.91) for the schema and a worked example.";
5341
- function validateArdDocument(doc) {
5342
- const findings = [];
5343
- if (!doc || typeof doc !== "object") {
5344
- return [
5345
- {
5346
- message: "ard.json must be a JSON object with an `entries` array.",
5347
- severity: "error",
5348
- suggestion: ARD_SUGGEST
5349
- }
5350
- ];
5351
- }
5352
- const entries = doc.entries;
5353
- if (!Array.isArray(entries)) {
5354
- return [
5355
- {
5356
- message: "ard.json is missing the required `entries` array.",
5357
- severity: "error",
5358
- suggestion: ARD_SUGGEST
5359
- }
5360
- ];
5361
- }
5362
- entries.forEach((entry, i) => {
5363
- const at = `ard.json entries[${i}]`;
5364
- if (!entry || typeof entry !== "object") {
5365
- findings.push({ message: `${at} must be an object.`, severity: "error" });
5366
- return;
5367
- }
5368
- if (typeof entry.identifier !== "string" || !URN_PATTERN.test(entry.identifier)) {
5369
- findings.push({
5370
- message: `${at}.identifier must match urn:air:<publisher>:<namespace>:<name>.`,
5371
- severity: "error",
5372
- suggestion: ARD_SUGGEST
5373
- });
5374
- }
5375
- if (typeof entry.displayName !== "string" || entry.displayName.trim() === "") {
5376
- findings.push({
5377
- message: `${at}.displayName is required.`,
5378
- severity: "error"
5379
- });
5380
- }
5381
- if (typeof entry.type !== "string" || !MEDIA_TYPE_PATTERN.test(entry.type)) {
5382
- findings.push({
5383
- message: `${at}.type must be an IANA media type (e.g. application/mcp-server-card+json).`,
5384
- severity: "error"
5385
- });
5386
- }
5387
- const hasUrl = entry.url !== void 0;
5388
- const hasData = entry.data !== void 0;
5389
- if (hasUrl === hasData) {
5390
- findings.push({
5391
- message: `${at} must have exactly one of \`url\` or \`data\`.`,
5392
- severity: "error"
5393
- });
5394
- }
5395
- const rq = entry.representativeQueries;
5396
- if (rq === void 0) {
5397
- findings.push({
5398
- message: `${at} is missing representativeQueries (2–5 recommended) — agents may not surface this resource.`,
5399
- severity: "warning",
5400
- suggestion: ARD_SUGGEST
5401
- });
5402
- } else if (!Array.isArray(rq) || rq.length < 2 || rq.length > 5) {
5403
- findings.push({
5404
- message: `${at}.representativeQueries should list 2–5 example queries.`,
5405
- severity: "warning"
5406
- });
5407
- }
5408
- });
5409
- return findings;
5410
- }
5411
- async function checkArdFiles(projectRoot) {
5412
- const wellKnown = path$1.resolve(projectRoot, "public/.well-known");
5413
- const ardPath = path$1.join(wellKnown, "ard.json");
5414
- const aliasPath = path$1.join(wellKnown, "ai-catalog.json");
5415
- const ardRaw = await readIfExists(ardPath);
5416
- const aliasRaw = await readIfExists(aliasPath);
5417
- if (ardRaw === null && aliasRaw === null) {
5418
- return { findings: [], absent: true };
5419
- }
5420
- const findings = [];
5421
- if (ardRaw === null && aliasRaw !== null) {
5422
- findings.push({
5423
- 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).",
5424
- severity: "warning",
5425
- suggestion: ARD_SUGGEST
5426
- });
5427
- } else if (ardRaw !== null && aliasRaw !== null && ardRaw.trim() !== aliasRaw.trim()) {
5428
- findings.push({
5429
- message: "public/.well-known/ard.json and ai-catalog.json differ — the alias should be a copy of ard.json.",
5430
- severity: "warning"
5431
- });
5432
- }
5433
- const canonicalRaw = ardRaw ?? aliasRaw;
5434
- let parsed;
5435
- try {
5436
- parsed = JSON.parse(canonicalRaw);
5437
- } catch (e2) {
5438
- findings.push({
5439
- message: `${ardRaw !== null ? "ard.json" : "ai-catalog.json"} is not valid JSON: ${e2.message}`,
5440
- severity: "error"
5441
- });
5442
- return { findings, absent: false };
5443
- }
5444
- findings.push(...validateArdDocument(parsed));
5445
- return { findings, absent: false };
5446
- }
5447
- async function readIfExists(p) {
5448
- try {
5449
- return await promises.readFile(p, "utf8");
5450
- } catch {
5451
- return null;
5452
- }
5453
- }
5454
5511
  async function findJayFiles(dir) {
5455
5512
  return await glob(`${dir}/**/*${JAY_EXTENSION$1}`);
5456
5513
  }
@@ -6454,6 +6511,37 @@ function resolveContractLinks(contract, contractPath) {
6454
6511
  const contractDir = path$1.dirname(contractPath);
6455
6512
  return { ...contract, tags: resolveLinkedTags(contract.tags, contractDir) };
6456
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
+ }
6457
6545
  async function runPluginValidators(projectRoot, parsedFiles, errors, warnings) {
6458
6546
  const scannedPlugins = await scanPlugins({ projectRoot, includeDevDeps: true });
6459
6547
  const loadedValidators = [];
@@ -6461,37 +6549,16 @@ async function runPluginValidators(projectRoot, parsedFiles, errors, warnings) {
6461
6549
  if (!plugin.manifest.validators) continue;
6462
6550
  for (const validatorDef of plugin.manifest.validators) {
6463
6551
  const source = `${plugin.name}/${validatorDef.name}`;
6464
- let validatorFn;
6465
- try {
6466
- let handlerModule;
6467
- if (plugin.isLocal) {
6468
- const handlerPath = path$1.resolve(plugin.pluginPath, validatorDef.handler);
6469
- handlerModule = await import(handlerPath);
6470
- } else {
6471
- handlerModule = await import(`${plugin.packageName}/tools`);
6472
- }
6473
- validatorFn = plugin.isLocal ? handlerModule.validate ?? handlerModule.default : handlerModule[validatorDef.handler];
6474
- if (typeof validatorFn !== "function") {
6475
- errors.push({
6476
- file: `plugin:${plugin.name}`,
6477
- message: `Validator "${validatorDef.name}" handler does not export a "validate" function`,
6478
- stage: "plugin",
6479
- source
6480
- });
6481
- loadedValidators.push(source);
6482
- continue;
6483
- }
6484
- } catch (loadErr) {
6485
- errors.push({
6486
- file: `plugin:${plugin.name}`,
6487
- message: `Failed to load validator "${validatorDef.name}": ${loadErr.message}`,
6488
- stage: "plugin",
6489
- source
6490
- });
6491
- loadedValidators.push(source);
6492
- continue;
6493
- }
6552
+ const validatorFn = await loadPluginToolsHandler(
6553
+ plugin,
6554
+ validatorDef.handler,
6555
+ "Validator",
6556
+ validatorDef.name,
6557
+ source,
6558
+ errors
6559
+ );
6494
6560
  loadedValidators.push(source);
6561
+ if (!validatorFn) continue;
6495
6562
  for (const { relativePath, parsed } of parsedFiles) {
6496
6563
  const pageContractPath = parsed.contractRef ? path$1.resolve(
6497
6564
  path$1.dirname(path$1.resolve(projectRoot, relativePath)),
@@ -6574,6 +6641,56 @@ async function runPluginValidators(projectRoot, parsedFiles, errors, warnings) {
6574
6641
  }
6575
6642
  return loadedValidators;
6576
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
+ }
6577
6694
  async function validateJayFiles(options = {}) {
6578
6695
  const projectRoot = options.projectRoot ?? process.cwd();
6579
6696
  const config = loadConfig(projectRoot);
@@ -6930,56 +7047,6 @@ async function validateJayFiles(options = {}) {
6930
7047
  }
6931
7048
  };
6932
7049
  }
6933
- const robotsTxtPath = path$1.resolve(projectRoot, "public/robots.txt");
6934
- if (!fs$1.existsSync(robotsTxtPath)) {
6935
- warnings.push({
6936
- file: "public/robots.txt",
6937
- message: "public/robots.txt not found — search engines may crawl pages you don't intend to expose.",
6938
- suggestion: "Create public/robots.txt with: User-agent: *\nAllow: /\nSitemap: https://your-domain.com/sitemap.xml"
6939
- });
6940
- }
6941
- if (!config.site?.baseUrl) {
6942
- warnings.push({
6943
- file: ".jay",
6944
- message: "site.baseUrl not configured — sitemap.xml will not be generated in production.",
6945
- suggestion: "Add to .jay config:\n site:\n baseUrl: https://your-domain.com"
6946
- });
6947
- }
6948
- if (!config.site?.name) {
6949
- warnings.push({
6950
- file: ".jay",
6951
- 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.",
6952
- suggestion: "Add to .jay config:\n site:\n name: Your Site Name\n logo: /logo.png\n description: What your site is about"
6953
- });
6954
- }
6955
- const allowNoArd = config.validations?.["allow-no-ard"] === true;
6956
- const ardResult = await checkArdFiles(projectRoot);
6957
- if (ardResult.absent) {
6958
- if (!allowNoArd) {
6959
- warnings.push({
6960
- file: "public/.well-known/ard.json",
6961
- message: "public/.well-known/ard.json not found — agents cannot discover this site’s resources/APIs via Agentic Resource Discovery.",
6962
- 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."
6963
- });
6964
- }
6965
- } else {
6966
- for (const finding of ardResult.findings) {
6967
- if (finding.severity === "error") {
6968
- errors.push({
6969
- file: "public/.well-known/ard.json",
6970
- message: finding.message,
6971
- stage: "generate",
6972
- suggestion: finding.suggestion
6973
- });
6974
- } else {
6975
- warnings.push({
6976
- file: "public/.well-known/ard.json",
6977
- message: finding.message,
6978
- suggestion: finding.suggestion
6979
- });
6980
- }
6981
- }
6982
- }
6983
7050
  const allowNoErrorPages = config.validations?.["allow-no-error-pages"] === true;
6984
7051
  if (!allowNoErrorPages) {
6985
7052
  const scanResult = await scanRoutes(scanDir, {
@@ -7066,13 +7133,22 @@ async function validateJayFiles(options = {}) {
7066
7133
  projectRoot
7067
7134
  });
7068
7135
  for (const finding of linkFindings) {
7069
- errors.push({
7070
- file: finding.file,
7071
- message: finding.message,
7072
- stage: "generate",
7073
- source: "internal-links",
7074
- suggestion: finding.suggestion
7075
- });
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
+ }
7076
7152
  }
7077
7153
  if (options.tier2) {
7078
7154
  await runBuildOutputValidation({
@@ -7087,6 +7163,7 @@ async function validateJayFiles(options = {}) {
7087
7163
  });
7088
7164
  }
7089
7165
  const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
7166
+ const projectValidators = await runProjectValidators(projectRoot, errors, warnings);
7090
7167
  return {
7091
7168
  valid: errors.length === 0,
7092
7169
  jayHtmlFilesScanned: jayHtmlFiles.length,
@@ -7094,7 +7171,7 @@ async function validateJayFiles(options = {}) {
7094
7171
  errors,
7095
7172
  warnings,
7096
7173
  coverage,
7097
- pluginValidators,
7174
+ pluginValidators: [...pluginValidators, ...projectValidators],
7098
7175
  designSystemCoverage,
7099
7176
  designSystemReuse
7100
7177
  };
@@ -7133,7 +7210,7 @@ async function runBuildOutputValidation(args) {
7133
7210
  suggestion: "Re-run `jay-stack build` for accurate results."
7134
7211
  });
7135
7212
  }
7136
- const oracle = buildBuildOracle(loaded.manifest, backendDir);
7213
+ const oracle = await buildBuildOracle(loaded.manifest, backendDir);
7137
7214
  const findings = [];
7138
7215
  findings.push(
7139
7216
  ...checkTemplateLinksAgainstBuild({
@@ -7145,6 +7222,14 @@ async function runBuildOutputValidation(args) {
7145
7222
  })
7146
7223
  );
7147
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
+ }
7148
7233
  for (const instance of oracle.instances) {
7149
7234
  const slowViewState = await loadSlowViewState(instance.cacheAbsPath);
7150
7235
  findings.push(
@@ -7513,7 +7598,7 @@ async function resolveProductionContext(projectPath, versionOverride) {
7513
7598
  let siteBaseUrl;
7514
7599
  let site;
7515
7600
  try {
7516
- const jayConfig = YAML.parse(await fs$2.readFile(jayConfigPath, "utf-8"));
7601
+ const jayConfig = YAML.parse(await fs$1$1.readFile(jayConfigPath, "utf-8"));
7517
7602
  pagesBase = jayConfig?.devServer?.pagesBase || pagesBase;
7518
7603
  siteBaseUrl = jayConfig?.site?.baseUrl;
7519
7604
  site = jayConfig?.site;
@@ -7533,7 +7618,7 @@ async function resolveProductionContext(projectPath, versionOverride) {
7533
7618
  async function resolveVersionFromPackageJson(projectRoot) {
7534
7619
  try {
7535
7620
  const pkgJson = JSON.parse(
7536
- 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")
7537
7622
  );
7538
7623
  if (pkgJson.version) {
7539
7624
  return pkgJson.version;
@@ -7554,7 +7639,7 @@ async function runBuild(projectPath, options) {
7554
7639
  }
7555
7640
  const ctx = await resolveProductionContext(projectPath, options.version);
7556
7641
  await generateDefinitionFiles(ctx);
7557
- const { buildVersion } = await import("./index-_Gyf630q.js");
7642
+ const { buildVersion } = await import("@jay-framework/production-build");
7558
7643
  await buildVersion({
7559
7644
  version: ctx.version,
7560
7645
  projectRoot: ctx.resolvedPath,
@@ -7920,22 +8005,22 @@ async function ensureAgentKitDocs(projectRoot, _force, mode) {
7920
8005
  const roleOutputDir = path__default.join(agentKitDir, role);
7921
8006
  let files;
7922
8007
  try {
7923
- files = (await fs$2.readdir(roleTemplateDir)).filter((f) => f.endsWith(".md"));
8008
+ files = (await fs$1$1.readdir(roleTemplateDir)).filter((f) => f.endsWith(".md"));
7924
8009
  } catch {
7925
8010
  continue;
7926
8011
  }
7927
- await fs$2.mkdir(roleOutputDir, { recursive: true });
8012
+ await fs$1$1.mkdir(roleOutputDir, { recursive: true });
7928
8013
  for (const filename of files) {
7929
- await fs$2.copyFile(
8014
+ await fs$1$1.copyFile(
7930
8015
  path__default.join(roleTemplateDir, filename),
7931
8016
  path__default.join(roleOutputDir, filename)
7932
8017
  );
7933
8018
  getLogger().info(chalk.gray(` Created agent-kit/${role}/${filename}`));
7934
8019
  }
7935
8020
  }
7936
- 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"));
7937
8022
  for (const filename of topLevelFiles) {
7938
- 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));
7939
8024
  getLogger().info(chalk.gray(` Created agent-kit/${filename}`));
7940
8025
  }
7941
8026
  const sharedDirs = ["contracts"];
@@ -7947,15 +8032,15 @@ async function ensureAgentKitDocs(projectRoot, _force, mode) {
7947
8032
  }
7948
8033
  }
7949
8034
  async function copyDirRecursive(src, dest) {
7950
- await fs$2.mkdir(dest, { recursive: true });
7951
- 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 });
7952
8037
  for (const entry of entries) {
7953
8038
  const srcPath = path__default.join(src, entry.name);
7954
8039
  const destPath = path__default.join(dest, entry.name);
7955
8040
  if (entry.isDirectory()) {
7956
8041
  await copyDirRecursive(srcPath, destPath);
7957
8042
  } else if (entry.name.endsWith(".md")) {
7958
- await fs$2.copyFile(srcPath, destPath);
8043
+ await fs$1$1.copyFile(srcPath, destPath);
7959
8044
  }
7960
8045
  }
7961
8046
  }
@@ -7971,7 +8056,7 @@ async function mergePluginAgentKitGuides(projectRoot, mode) {
7971
8056
  const roleSourceDir = path__default.join(pluginAgentKitDir, role);
7972
8057
  let files;
7973
8058
  try {
7974
- files = (await fs$2.readdir(roleSourceDir)).filter(
8059
+ files = (await fs$1$1.readdir(roleSourceDir)).filter(
7975
8060
  (f) => f.endsWith(".md") && f !== "INSTRUCTIONS.md"
7976
8061
  );
7977
8062
  } catch {
@@ -7979,13 +8064,13 @@ async function mergePluginAgentKitGuides(projectRoot, mode) {
7979
8064
  }
7980
8065
  if (files.length === 0) continue;
7981
8066
  const roleOutputDir = path__default.join(agentKitDir, role);
7982
- await fs$2.mkdir(roleOutputDir, { recursive: true });
8067
+ await fs$1$1.mkdir(roleOutputDir, { recursive: true });
7983
8068
  for (const filename of files) {
7984
8069
  const sourcePath = path__default.join(roleSourceDir, filename);
7985
- await fs$2.copyFile(sourcePath, path__default.join(roleOutputDir, filename));
8070
+ await fs$1$1.copyFile(sourcePath, path__default.join(roleOutputDir, filename));
7986
8071
  let description = "";
7987
8072
  try {
7988
- const content = await fs$2.readFile(sourcePath, "utf-8");
8073
+ const content = await fs$1$1.readFile(sourcePath, "utf-8");
7989
8074
  const lines = content.split("\n");
7990
8075
  let pastHeading = false;
7991
8076
  for (const line of lines) {
@@ -8024,7 +8109,7 @@ async function mergePluginAgentKitGuides(projectRoot, mode) {
8024
8109
  lines.push(`| [${filename}](${filename}) | ${pluginName} | ${description} |`);
8025
8110
  }
8026
8111
  lines.push("");
8027
- await fs$2.appendFile(instructionsPath, lines.join("\n"));
8112
+ await fs$1$1.appendFile(instructionsPath, lines.join("\n"));
8028
8113
  }
8029
8114
  }
8030
8115
  async function generatePluginAgentKit(projectRoot, options, initErrors, viteServer) {