@jay-framework/jay-stack-cli 0.25.0 → 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.
@@ -60,11 +60,11 @@ See [syntax.md](syntax.md) for the full YAML format: tag types, phases, data typ
60
60
 
61
61
  Start with the simplest example that matches your use case, then add complexity as needed:
62
62
 
63
- | Example | Complexity | Key patterns |
64
- | ------------------------------------------------------ | -------------- | ----------------------------------------------------------- |
65
- | [mini-cart](examples/mini-cart.md) | Trivial | Variant + interactive refs |
66
- | [cart-indicator](examples/cart-indicator.md) | Simple | Flat data, variants, phase choices |
67
- | [category-list](examples/category-list.md) | Medium | Props, repeated sub-contracts |
68
- | [product-card](examples/product-card.md) | Medium-complex | Linked sub-contracts, variants, multiple ref types |
69
- | [product-page](examples/product-page.md) | Complex | Params, nested repeated sub-contracts, linked sub-contracts |
70
- | [composing-contracts](examples/composing-contracts.md) | Pattern | How contracts link together into a hierarchy |
63
+ | Example | Complexity | Key patterns |
64
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------- |
65
+ | [mini-cart](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/mini-cart.md) | Trivial | Variant + interactive refs |
66
+ | [cart-indicator](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/cart-indicator.md) | Simple | Flat data, variants, phase choices |
67
+ | [category-list](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/category-list.md) | Medium | Props, repeated sub-contracts |
68
+ | [product-card](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/product-card.md) | Medium-complex | Linked sub-contracts, variants, multiple ref types |
69
+ | [product-page](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/product-page.md) | Complex | Params, nested repeated sub-contracts, linked sub-contracts |
70
+ | [composing-contracts](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/composing-contracts.md) | Pattern | How contracts link together into a hierarchy |
@@ -72,4 +72,4 @@ The linked contract must have a tag matching the `trackBy` field.
72
72
 
73
73
  ## See Also
74
74
 
75
- - [composing-contracts example](examples/composing-contracts.md) — Real-world composition hierarchy from wix-stores
75
+ - [composing-contracts example](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/composing-contracts.md) — Real-world composition hierarchy from wix-stores
@@ -69,8 +69,15 @@ jay-stack validate -v
69
69
 
70
70
  # JSON output
71
71
  jay-stack validate --json
72
+
73
+ # Tier 2: deeper checks against an existing build (needs `jay-stack build` first)
74
+ jay-stack validate --tier-2
72
75
  ```
73
76
 
77
+ The default run is **Tier 1** (fast, template-only). `--tier-2` adds deep checks against a build's output
78
+ (dynamic-slug links, rendered-content links, per-instance meta) — see
79
+ [validation-tier-2-guide.md](validation-tier-2-guide.md).
80
+
74
81
  Example output:
75
82
 
76
83
  ```
@@ -327,4 +327,4 @@ For repeated lists, add a **boolean variant** (e.g. `hasItems`, `hasCategories`)
327
327
  <div if="hasCategories" forEach="categories" trackBy="_id">...</div>
328
328
  ```
329
329
 
330
- Alternatively, expose a **number** data tag (`itemCount`) and use numeric comparison: `if="itemCount===0"`. See [jay-html-template-syntax.md](jay-html-template-syntax.md#expression-limits-important) and [contracts/examples/category-list.md](../contracts/examples/category-list.md).
330
+ Alternatively, expose a **number** data tag (`itemCount`) and use numeric comparison: `if="itemCount===0"`. See [jay-html-template-syntax.md](jay-html-template-syntax.md#expression-limits-important) and [contracts/examples/category-list.md](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/category-list.md).
@@ -240,7 +240,7 @@ Jay-html expressions resolve **contract or page tag names only**. They are **not
240
240
  | Empty list hint | `hasItems: boolean` variant | `if="!hasItems"` |
241
241
  | Count-based UI | `itemCount: number` data tag | `if="itemCount===0"` |
242
242
 
243
- See [contracts/examples/category-list.md](../contracts/examples/category-list.md) (`hasCategories`).
243
+ See [contracts/examples/category-list.md](https://github.com/jay-framework/jay/blob/main/packages/jay-stack/stack-cli/agent-kit-template/contracts/examples/category-list.md) (`hasCategories`).
244
244
 
245
245
  ### Common Errors
246
246
 
@@ -27,6 +27,13 @@ So a warning about an `<img>` might come from a full-stack component's template,
27
27
  - **Errors** block the build. They must be fixed — there is no way to suppress them.
28
28
  - **Warnings** must be either fixed or explicitly suppressed. Do not ignore warnings — each one has a clear resolution path (add the missing attribute, or suppress via `<script type="application/jay-validations">`).
29
29
 
30
+ **Broken internal links are errors** (DL#210). `validate` resolves every `<a href>` against the project's
31
+ **static routes + public assets**. An href that resolves to nothing is an error: a placeholder (`href="#"` or
32
+ empty), a typo, or a link to a page/asset that does not exist. Links that match a **dynamic** route pattern
33
+ (e.g. `/blog/[slug]`) are **not** checked here — the concrete slug list isn't known at validate time — so they
34
+ never false-error. External URLs, `mailto:`/`tel:`, in-page `#fragments`, and `{binding}` hrefs are skipped.
35
+ Fix the path, use `<button>` for JS-driven anchors, or suppress per-page (see the `jay-stack` table below).
36
+
30
37
  One design-system rule is a **hard error**, not a warning: `REGION-CSS-NO-REF` (DL#209) — a `<jay:X>` region
31
38
  whose `template=` ships CSS but which has no `ref=`. Without a `ref` the CSS has no scope anchor, so `sync`
32
39
  silently drops it; the fix is to add a `ref` (see `design-system-guide.md` → _The shape of a region's CSS_).
@@ -126,6 +133,7 @@ The core `jay-stack validate` emits four warnings that nudge you to reuse UI thr
126
133
  | `REGION-OVERRIDE-NON-CONTENT` | `jay-validations="REGION-OVERRIDE-NON-CONTENT"` on the import |
127
134
  | `COMPONENT-NO-TEMPLATE` | `jay-stack: allow-no-template: [Contract]` |
128
135
  | `NO-DESIGN-SYSTEM` | `jay-stack: allow-no-design-system: true` |
136
+ | Broken internal link (DL#210) | `jay-stack: allow-broken-links: true` (per-page — skips all link checks for that page) |
129
137
 
130
138
  ```html
131
139
  <script type="application/jay-validations">
@@ -167,12 +175,28 @@ By default the scorecard prints **one-line totals** for the whole project:
167
175
  Run with `-v` / `--verbose` to expand the **per-page** breakdown (coverage per file, which tags are unused,
168
176
  per-template reuse counts, and `⚠ low` flags on pages under 50% coverage).
169
177
 
178
+ ## Two validation tiers
179
+
180
+ Validation comes in two tiers. This guide covers **Tier 1**, the always-on gate.
181
+
182
+ **Tier 1 — always on (`jay-stack validate`)** is the fast, template-only gate. It reads your `.jay-html` files and
183
+ route scan only — **no build needed** — so it's cheap enough for the hot agent loop and runs on every `validate`.
184
+ Everything described in this guide is Tier 1. For links, Tier 1 (DL#210) resolves every hand-authored `<a href>`
185
+ against the project's static routes + public assets and **errors** on broken internal links (typos, missing
186
+ pages), degenerate `#` / empty placeholder links, and self-links. Links matching a **dynamic** route pattern
187
+ (`/design-log/[...slug]`) are deferred — the concrete slug set isn't known without a build.
188
+
189
+ **Tier 2 — opt-in, against a build (`jay-stack validate --tier-2`)** adds deeper checks that need a build:
190
+ dynamic-slug links, broken links in rendered content, and per-instance meta/SEO. It's documented separately to
191
+ keep this guide focused — see **`validation-tier-2-guide.md`**. Run it pre-deploy / in CI.
192
+
170
193
  ## Running Validation
171
194
 
172
195
  ```bash
173
- jay-stack validate # validate all pages (+ one-line scorecard totals)
174
- jay-stack validate -v # add per-page tag coverage + scorecard detail
175
- jay-stack validate --strict # treat warnings as errors
196
+ jay-stack validate # Tier 1: all pages (+ one-line scorecard totals)
197
+ jay-stack validate -v # add per-page tag coverage + scorecard detail
198
+ jay-stack validate --strict # treat warnings as errors
199
+ jay-stack validate --tier-2 # also run Tier 2 — see validation-tier-2-guide.md
176
200
  ```
177
201
 
178
202
  Validation runs automatically during `jay-stack build`. Warnings don't block the build; errors do (with `--strict`).
@@ -0,0 +1,64 @@
1
+ # Tier 2 Validation — Deep Checks Against a Build
2
+
3
+ `jay-stack validate` runs **Tier 1**: the fast, template-only gate (links, placeholders, coverage) that needs no
4
+ build. See `validation-guide.md` for Tier 1.
5
+
6
+ **Tier 2** (`jay-stack validate --tier-2`) adds deeper checks that are only possible once a build exists. This
7
+ guide covers Tier 2 end to end.
8
+
9
+ ## What Tier 2 is
10
+
11
+ Deep validation against an **existing build's output** (DL#211). It reads
12
+ `build/v<version>/backend/route-manifest.json` plus the per-instance `*.cache.json` slow-render results. It does
13
+ **not** run any render, so it's cheap — but it **requires a prior `jay-stack build`**. If no build is found, Tier 2
14
+ reports a single actionable error (run `jay-stack build` first, or drop `--tier-2`).
15
+
16
+ Run it **pre-deploy / in CI**, not in the hot authoring loop.
17
+
18
+ ```bash
19
+ jay-stack validate --tier-2 # validate against the highest build/v* output
20
+ jay-stack validate --t2 # short alias
21
+ jay-stack validate --tier-2 --build-dir <dir> # point at a specific build backend dir or build root
22
+ ```
23
+
24
+ Tier 2 findings print under their own `📦 Tier 2 — build-output validation (--tier-2)` section, separate from the
25
+ Tier 1 `📦 jay-stack (core)` section.
26
+
27
+ ## The three checks
28
+
29
+ All run against the concrete URLs the build actually produced (routes × instances).
30
+
31
+ 1. **Deferred dynamic-slug links** (ERROR) — the template links Tier 1 could only defer (because they matched a
32
+ dynamic pattern like `/design-log/[...slug]`) are resolved against the real URL set. A link to a dynamic route
33
+ with a slug the build never generated (e.g. `/design-log/wix/index`) is caught here.
34
+
35
+ 2. **Broken links in rendered content** (WARNING) — `<a href>` found _inside_ the slow-rendered ViewState
36
+ (markdown bodies, descriptions) that resolve to a URL the build does not produce. Relative hrefs are resolved
37
+ against the instance's own URL. These are **warnings**, not errors, because content often doubles as repo/GitHub
38
+ docs where repo-relative links like `../pkg/foo.ts` are legitimately correct.
39
+
40
+ 3. **Per-instance meta/SEO** (mostly WARNING) — resolves each route's `headMeta` template against each instance's
41
+ slow ViewState and validates the concrete `<title>` / `<meta name="description">`:
42
+ - Empty title/description, a title > 60 chars, or a description > 160 chars → **warning**.
43
+ - A leftover unresolved `{binding}` (a field never produced at any phase) → **error**.
44
+ - Note (DL#189): a field bound to the fast/interactive phase is legitimately empty at slow render, so
45
+ **emptiness is only ever a warning, never an error**.
46
+
47
+ If the build is older than your current source files, a **staleness warning** is emitted ("validated against a
48
+ build from &lt;timestamp&gt;; source has changed since") suggesting a rebuild.
49
+
50
+ ## Suppressing Tier 2 findings
51
+
52
+ Per-page, via the same `<script type="application/jay-validations">` block used for Tier 1, under the `jay-stack:`
53
+ key:
54
+
55
+ - `allow-broken-links: true` — suppresses link findings (both template and rendered-content links) for that page.
56
+ - `allow-meta-issues: true` — suppresses meta/SEO findings for that page.
57
+
58
+ ```html
59
+ <script type="application/jay-validations">
60
+ jay-stack:
61
+ allow-broken-links: true
62
+ allow-meta-issues: true
63
+ </script>
64
+ ```
@@ -411,6 +411,102 @@ function buildPagePartsConfig(pageParts, pageServerModule, pageExportName, build
411
411
  }))
412
412
  };
413
413
  }
414
+ function crossProductParams(parts) {
415
+ if (parts.length === 0) return [];
416
+ if (parts.length === 1) return parts[0].values;
417
+ const logger = getLogger();
418
+ for (let i = 0; i < parts.length; i++) {
419
+ for (let j = i + 1; j < parts.length; j++) {
420
+ for (const key of parts[i].keys) {
421
+ if (parts[j].keys.has(key)) {
422
+ logger.warn(
423
+ `[Build] Multiple loadParams provide key "${key}" — using first provider`
424
+ );
425
+ }
426
+ }
427
+ }
428
+ }
429
+ let result = parts[0].values;
430
+ for (let i = 1; i < parts.length; i++) {
431
+ const next = parts[i].values;
432
+ const combined = [];
433
+ for (const a of result) {
434
+ for (const b of next) {
435
+ combined.push({ ...a, ...b });
436
+ }
437
+ }
438
+ result = combined;
439
+ }
440
+ return result;
441
+ }
442
+ function paramsMatchInferred(params, inferredParams, optionalSegments) {
443
+ return Object.entries(inferredParams).every(([k, v]) => {
444
+ if (optionalSegments?.has(k)) return true;
445
+ return params[k] === v;
446
+ });
447
+ }
448
+ function computeSpecificity(route) {
449
+ const dynamicCount = (route.rawRoute.match(/\[/g) || []).length;
450
+ const inferredCount = route.inferredParams ? Object.keys(route.inferredParams).length : 0;
451
+ const unresolvedCount = Math.max(0, dynamicCount - inferredCount);
452
+ return 0 - unresolvedCount;
453
+ }
454
+ function buildUrl(route, params) {
455
+ return route.rawRoute.replace(/\[\[(\w+)\]\]/g, (_, name) => {
456
+ const value = params[name];
457
+ if (!value) return "";
458
+ if (route.inferredParams?.[name] === value) return "";
459
+ return value;
460
+ }).replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
461
+ }
462
+ function materializeRouteParams(routes, loadParamsResults) {
463
+ const entries = [];
464
+ for (const route of routes) {
465
+ const specificity = computeSpecificity(route);
466
+ if (!route.hasDynamicParams) {
467
+ const params = route.inferredParams || {};
468
+ entries.push({ route, params, url: route.rawRoute, specificity });
469
+ continue;
470
+ }
471
+ const allParams = loadParamsResults.get(route) || [];
472
+ for (const params of allParams) {
473
+ if (route.inferredParams && !paramsMatchInferred(params, route.inferredParams, route.optionalSegments)) {
474
+ continue;
475
+ }
476
+ let mergedParams = params;
477
+ if (route.inferredParams) {
478
+ mergedParams = { ...params };
479
+ for (const [k, v] of Object.entries(route.inferredParams)) {
480
+ if (!(k in mergedParams)) {
481
+ mergedParams[k] = v;
482
+ }
483
+ }
484
+ }
485
+ const url = buildUrl(route, mergedParams);
486
+ entries.push({ route, params: mergedParams, url, specificity });
487
+ }
488
+ }
489
+ return entries;
490
+ }
491
+ function dedupeByUrl(entries) {
492
+ const logger = getLogger();
493
+ const byUrl = /* @__PURE__ */ new Map();
494
+ for (const entry of entries) {
495
+ const existing = byUrl.get(entry.url);
496
+ if (!existing) {
497
+ byUrl.set(entry.url, entry);
498
+ } else if (entry.specificity > existing.specificity) {
499
+ byUrl.set(entry.url, entry);
500
+ }
501
+ }
502
+ const deduped = [...byUrl.values()];
503
+ if (deduped.length < entries.length) {
504
+ logger.info(
505
+ `[Build] Deduplication: ${entries.length} materialized → ${deduped.length} unique URLs`
506
+ );
507
+ }
508
+ return deduped;
509
+ }
414
510
  function hashParams(params, suffix) {
415
511
  const sorted = Object.keys(params).sort().reduce(
416
512
  (acc, key) => {
@@ -472,10 +568,11 @@ async function buildInstance(route, params, pageModule, ctx, routeServerElementP
472
568
  );
473
569
  await fs.writeFile(pagePartsConfigPath, JSON.stringify(config, null, 2));
474
570
  logger.info(`[Build] Page parts config: ${routeDir}/page-parts.json`);
571
+ const pageUrl = buildUrl(route, params);
475
572
  const slowPhase = new DevSlowlyChangingPhase();
476
573
  const slowResult = await slowPhase.runSlowlyForPage(
477
574
  params,
478
- { params },
575
+ { language: "en", url: pageUrl },
479
576
  pageParts.parts,
480
577
  pageParts.discoveredInstances,
481
578
  pageParts.headlessInstanceComponents,
@@ -501,7 +598,7 @@ async function buildInstance(route, params, pageModule, ctx, routeServerElementP
501
598
  {
502
599
  pageViewState: slowViewState,
503
600
  pageParams: params,
504
- pageProps: { language: "en", url: "" }
601
+ pageProps: { language: "en", url: pageUrl }
505
602
  }
506
603
  );
507
604
  if (slowResult2) {
@@ -764,7 +861,12 @@ async function compileServerElement(jayHtmlContent, jayHtmlFilename, jayHtmlDir,
764
861
  cssFile = cssFilename;
765
862
  }
766
863
  getLogger().info(`[Build] Compiled server element: ${path__default.basename(outputPath)}`);
767
- return { cssFile, cssImports, headMeta: parsedJayFile.headMeta };
864
+ return {
865
+ cssFile,
866
+ cssImports,
867
+ headMeta: parsedJayFile.headMeta,
868
+ scripts: parsedJayFile.scripts
869
+ };
768
870
  }
769
871
  async function compileRouteServerElement(jayHtmlPath, outputPath, projectRoot, tsConfigFilePath, minifyCss = true) {
770
872
  const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
@@ -974,102 +1076,6 @@ async function buildInstanceClient(hydrateEntryPath, instanceId, outputDir, proj
974
1076
  logger.info(`[Build] Client bundle: ${result.jsFile}`);
975
1077
  return result;
976
1078
  }
977
- function crossProductParams(parts) {
978
- if (parts.length === 0) return [];
979
- if (parts.length === 1) return parts[0].values;
980
- const logger = getLogger();
981
- for (let i = 0; i < parts.length; i++) {
982
- for (let j = i + 1; j < parts.length; j++) {
983
- for (const key of parts[i].keys) {
984
- if (parts[j].keys.has(key)) {
985
- logger.warn(
986
- `[Build] Multiple loadParams provide key "${key}" — using first provider`
987
- );
988
- }
989
- }
990
- }
991
- }
992
- let result = parts[0].values;
993
- for (let i = 1; i < parts.length; i++) {
994
- const next = parts[i].values;
995
- const combined = [];
996
- for (const a of result) {
997
- for (const b of next) {
998
- combined.push({ ...a, ...b });
999
- }
1000
- }
1001
- result = combined;
1002
- }
1003
- return result;
1004
- }
1005
- function paramsMatchInferred(params, inferredParams, optionalSegments) {
1006
- return Object.entries(inferredParams).every(([k, v]) => {
1007
- if (optionalSegments?.has(k)) return true;
1008
- return params[k] === v;
1009
- });
1010
- }
1011
- function computeSpecificity(route) {
1012
- const dynamicCount = (route.rawRoute.match(/\[/g) || []).length;
1013
- const inferredCount = route.inferredParams ? Object.keys(route.inferredParams).length : 0;
1014
- const unresolvedCount = Math.max(0, dynamicCount - inferredCount);
1015
- return 0 - unresolvedCount;
1016
- }
1017
- function buildUrl(route, params) {
1018
- return route.rawRoute.replace(/\[\[(\w+)\]\]/g, (_, name) => {
1019
- const value = params[name];
1020
- if (!value) return "";
1021
- if (route.inferredParams?.[name] === value) return "";
1022
- return value;
1023
- }).replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
1024
- }
1025
- function materializeRouteParams(routes, loadParamsResults) {
1026
- const entries = [];
1027
- for (const route of routes) {
1028
- const specificity = computeSpecificity(route);
1029
- if (!route.hasDynamicParams) {
1030
- const params = route.inferredParams || {};
1031
- entries.push({ route, params, url: route.rawRoute, specificity });
1032
- continue;
1033
- }
1034
- const allParams = loadParamsResults.get(route) || [];
1035
- for (const params of allParams) {
1036
- if (route.inferredParams && !paramsMatchInferred(params, route.inferredParams, route.optionalSegments)) {
1037
- continue;
1038
- }
1039
- let mergedParams = params;
1040
- if (route.inferredParams) {
1041
- mergedParams = { ...params };
1042
- for (const [k, v] of Object.entries(route.inferredParams)) {
1043
- if (!(k in mergedParams)) {
1044
- mergedParams[k] = v;
1045
- }
1046
- }
1047
- }
1048
- const url = buildUrl(route, mergedParams);
1049
- entries.push({ route, params: mergedParams, url, specificity });
1050
- }
1051
- }
1052
- return entries;
1053
- }
1054
- function dedupeByUrl(entries) {
1055
- const logger = getLogger();
1056
- const byUrl = /* @__PURE__ */ new Map();
1057
- for (const entry of entries) {
1058
- const existing = byUrl.get(entry.url);
1059
- if (!existing) {
1060
- byUrl.set(entry.url, entry);
1061
- } else if (entry.specificity > existing.specificity) {
1062
- byUrl.set(entry.url, entry);
1063
- }
1064
- }
1065
- const deduped = [...byUrl.values()];
1066
- if (deduped.length < entries.length) {
1067
- logger.info(
1068
- `[Build] Deduplication: ${entries.length} materialized → ${deduped.length} unique URLs`
1069
- );
1070
- }
1071
- return deduped;
1072
- }
1073
1079
  async function discoverPluginClientPackages(projectRoot) {
1074
1080
  const projectRequire = createRequire(path__default.join(projectRoot, "package.json"));
1075
1081
  const seen = /* @__PURE__ */ new Set();
@@ -1404,6 +1410,9 @@ async function buildVersion(options) {
1404
1410
  entry.noIndex = true;
1405
1411
  }
1406
1412
  }
1413
+ if (seResult.scripts?.length) {
1414
+ entry.scripts = seResult.scripts;
1415
+ }
1407
1416
  logger.important(`[Build] Route server element: ${routeDir}`);
1408
1417
  } catch (err) {
1409
1418
  logger.error(`[Build] Route server element FAILED ${route.rawRoute}: ${err.message}`);
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 = {
@@ -4745,6 +4746,428 @@ function scopeInnerBody(block) {
4745
4746
  function normalizeCssBody(body) {
4746
4747
  return body.replace(/\s+/g, " ").replace(/\s*([{}:;,])\s*/g, "$1").replace(/;}/g, "}").trim();
4747
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
+ }
4748
5171
  async function findJayFiles(dir) {
4749
5172
  return await glob(`${dir}/**/*${JAY_EXTENSION$1}`);
4750
5173
  }
@@ -6235,6 +6658,38 @@ async function validateJayFiles(options = {}) {
6235
6658
  (p) => (reuseByTemplate.get(p) ?? 0) >= 2
6236
6659
  ).length
6237
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
+ }
6238
6693
  const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
6239
6694
  return {
6240
6695
  valid: errors.length === 0,
@@ -6248,6 +6703,89 @@ async function validateJayFiles(options = {}) {
6248
6703
  designSystemReuse
6249
6704
  };
6250
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
+ }
6251
6789
  function printJayValidationResult(result, options) {
6252
6790
  const logger = getLogger();
6253
6791
  if (options.json) {
@@ -6255,9 +6793,11 @@ function printJayValidationResult(result, options) {
6255
6793
  return;
6256
6794
  }
6257
6795
  logger.important("");
6258
- 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
+ );
6259
6799
  const coreWarnings = result.warnings.filter((w) => !w.source);
6260
- logger.important(chalk.bold("📦 jay-stack (core)"));
6800
+ logger.important(chalk.bold("📦 Tier 1 — jay-stack (core)"));
6261
6801
  if (coreErrors.length === 0) {
6262
6802
  logger.important(
6263
6803
  chalk.green(
@@ -6280,6 +6820,40 @@ function printJayValidationResult(result, options) {
6280
6820
  logger.important(chalk.blue(` Suggestion: ${warning.suggestion}`));
6281
6821
  }
6282
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
+ }
6283
6857
  const pluginErrors = result.errors.filter((e2) => e2.stage === "plugin");
6284
6858
  const pluginWarnings = result.warnings.filter((w) => !!w.source);
6285
6859
  for (const validatorName of result.pluginValidators) {
@@ -6422,12 +6996,22 @@ function printJayValidationResult(result, options) {
6422
6996
  )
6423
6997
  );
6424
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
+ }
6425
7007
  }
6426
7008
  async function runValidate(scanPath, options) {
6427
7009
  const result = await validateJayFiles({
6428
7010
  path: scanPath,
6429
7011
  verbose: options.verbose,
6430
- json: options.json
7012
+ json: options.json,
7013
+ tier2: options.tier2,
7014
+ buildDir: options.buildDir
6431
7015
  });
6432
7016
  printJayValidationResult(result, options);
6433
7017
  if (!result.valid) {
@@ -6574,7 +7158,7 @@ async function runBuild(projectPath, options) {
6574
7158
  }
6575
7159
  const ctx = await resolveProductionContext(projectPath, options.version);
6576
7160
  await generateDefinitionFiles(ctx);
6577
- const { buildVersion } = await import("./index-l6QVpvnY.js");
7161
+ const { buildVersion } = await import("./index-BQbkAEhi.js");
6578
7162
  await buildVersion({
6579
7163
  version: ctx.version,
6580
7164
  projectRoot: ctx.resolvedPath,
@@ -7694,9 +8278,17 @@ program.command("cleanup").description("Delete orphaned files from previous rebu
7694
8278
  process.exit(1);
7695
8279
  }
7696
8280
  });
7697
- 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) => {
7698
8290
  try {
7699
- await runValidate(options.path, options);
8291
+ await runValidate(options.path, { ...options, tier2: options.tier2 || options.t2 });
7700
8292
  } catch (error) {
7701
8293
  if (options.json) {
7702
8294
  getLogger().important(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jay-framework/jay-stack-cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,17 +25,18 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@inquirer/prompts": "^8.5.2",
28
- "@jay-framework/compiler-inline-composition": "^0.25.0",
29
- "@jay-framework/compiler-jay-html": "^0.25.0",
30
- "@jay-framework/compiler-shared": "^0.25.0",
31
- "@jay-framework/dev-server": "^0.25.0",
32
- "@jay-framework/fullstack-component": "^0.25.0",
33
- "@jay-framework/logger": "^0.25.0",
34
- "@jay-framework/plugin-validator": "^0.25.0",
35
- "@jay-framework/production-build": "^0.25.0",
36
- "@jay-framework/production-server": "^0.25.0",
37
- "@jay-framework/stack-server-build": "^0.25.0",
38
- "@jay-framework/stack-server-runtime": "^0.25.0",
28
+ "@jay-framework/compiler-inline-composition": "^0.25.1",
29
+ "@jay-framework/compiler-jay-html": "^0.25.1",
30
+ "@jay-framework/compiler-shared": "^0.25.1",
31
+ "@jay-framework/dev-server": "^0.25.1",
32
+ "@jay-framework/fullstack-component": "^0.25.1",
33
+ "@jay-framework/logger": "^0.25.1",
34
+ "@jay-framework/plugin-validator": "^0.25.1",
35
+ "@jay-framework/production-build": "^0.25.1",
36
+ "@jay-framework/production-server": "^0.25.1",
37
+ "@jay-framework/stack-route-scanner": "^0.25.1",
38
+ "@jay-framework/stack-server-build": "^0.25.1",
39
+ "@jay-framework/stack-server-runtime": "^0.25.1",
39
40
  "chalk": "^4.1.2",
40
41
  "commander": "^14.0.0",
41
42
  "express": "^5.0.1",
@@ -46,7 +47,7 @@
46
47
  "yaml": "^2.3.4"
47
48
  },
48
49
  "devDependencies": {
49
- "@jay-framework/dev-environment": "^0.25.0",
50
+ "@jay-framework/dev-environment": "^0.25.1",
50
51
  "@types/express": "^5.0.2",
51
52
  "@types/node": "^22.15.21",
52
53
  "nodemon": "^3.0.3",