@caelo-cms/shared 0.10.21 → 0.10.23
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/ai-tools.d.ts +291 -231
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +349 -281
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
package/src/snapshots.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod input schemas for the P4 snapshot ops. Lives in @caelo-cms/shared so that
|
|
5
|
+
* route actions and the ops themselves import from a single source — same
|
|
6
|
+
* pattern as packages/shared/src/content.ts for P3.
|
|
7
|
+
*
|
|
8
|
+
* Every schema is `.strict()` so unknown keys are rejected at the Validator
|
|
9
|
+
* before the handler runs. Snapshot state JSONB is *not* validated by Zod
|
|
10
|
+
* here — it is emitted by trusted ops, never user-supplied (see CMS plan
|
|
11
|
+
* §"Validator rules").
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
export const snapshotsListInput = z
|
|
17
|
+
.object({
|
|
18
|
+
/** ISO timestamp; only snapshots strictly before this are returned. */
|
|
19
|
+
before: z.string().datetime().optional(),
|
|
20
|
+
limit: z.number().int().min(1).max(200).default(50),
|
|
21
|
+
/** When set, only snapshots that touched this module are returned. */
|
|
22
|
+
forModuleId: z.string().uuid().optional(),
|
|
23
|
+
/** When set, only snapshots that touched this page (metadata or layout). */
|
|
24
|
+
forPageId: z.string().uuid().optional(),
|
|
25
|
+
/** When set, only snapshots that touched this template (incl. block changes). */
|
|
26
|
+
forTemplateId: z.string().uuid().optional(),
|
|
27
|
+
/** When set, only snapshots whose op_kind matches one of these values. */
|
|
28
|
+
opKinds: z.array(z.string()).optional(),
|
|
29
|
+
/** When true, archived snapshots are also returned. Defaults to false. */
|
|
30
|
+
includeArchived: z.boolean().default(false),
|
|
31
|
+
/** v0.6.0 — only snapshots tagged with this chat's branch are returned.
|
|
32
|
+
* Used by `revert_chat_changes` to enumerate everything one chat did
|
|
33
|
+
* without scanning the full timeline. */
|
|
34
|
+
forChatBranchId: z.string().uuid().optional(),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
37
|
+
|
|
38
|
+
export const archiveOlderThanInput = z
|
|
39
|
+
.object({
|
|
40
|
+
/** ISO timestamp; snapshots with `created_at < before` get archived_at set. */
|
|
41
|
+
before: z.string().datetime(),
|
|
42
|
+
/** Hard cap to keep one call from updating the whole table by mistake. */
|
|
43
|
+
limit: z.number().int().min(1).max(10_000).default(1000),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
export type ArchiveOlderThanInput = z.infer<typeof archiveOlderThanInput>;
|
|
47
|
+
|
|
48
|
+
export const snapshotGetInput = z.object({ snapshotId: z.string().uuid() }).strict();
|
|
49
|
+
|
|
50
|
+
export const moduleImpactInput = z.object({ moduleId: z.string().uuid() }).strict();
|
|
51
|
+
|
|
52
|
+
export const revertSiteInput = z.object({ snapshotId: z.string().uuid() }).strict();
|
|
53
|
+
|
|
54
|
+
export const revertModuleInput = z
|
|
55
|
+
.object({
|
|
56
|
+
moduleId: z.string().uuid(),
|
|
57
|
+
snapshotId: z.string().uuid(),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
|
|
61
|
+
export const revertTemplateInput = z
|
|
62
|
+
.object({
|
|
63
|
+
templateId: z.string().uuid(),
|
|
64
|
+
snapshotId: z.string().uuid(),
|
|
65
|
+
})
|
|
66
|
+
.strict();
|
|
67
|
+
|
|
68
|
+
export const revertPageInput = z
|
|
69
|
+
.object({
|
|
70
|
+
pageId: z.string().uuid(),
|
|
71
|
+
snapshotId: z.string().uuid(),
|
|
72
|
+
})
|
|
73
|
+
.strict();
|
|
74
|
+
|
|
75
|
+
export type SnapshotsListInput = z.infer<typeof snapshotsListInput>;
|
|
76
|
+
export type SnapshotGetInput = z.infer<typeof snapshotGetInput>;
|
|
77
|
+
export type ModuleImpactInput = z.infer<typeof moduleImpactInput>;
|
|
78
|
+
export type RevertSiteInput = z.infer<typeof revertSiteInput>;
|
|
79
|
+
export type RevertModuleInput = z.infer<typeof revertModuleInput>;
|
|
80
|
+
export type RevertTemplateInput = z.infer<typeof revertTemplateInput>;
|
|
81
|
+
export type RevertPageInput = z.infer<typeof revertPageInput>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { stripCdataGuards } from "./strip-cdata.js";
|
|
5
|
+
|
|
6
|
+
describe("stripCdataGuards", () => {
|
|
7
|
+
it("leaves clean HTML untouched (fast path)", () => {
|
|
8
|
+
const html = '<nav><a href="/docs">Docs</a></nav>';
|
|
9
|
+
expect(stripCdataGuards(html)).toBe(html);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("removes a bare `<![CDATA[` / `]]>` pair, preserving inner content", () => {
|
|
13
|
+
const html = "<style><![CDATA[.a{color:red}]]></style>";
|
|
14
|
+
expect(stripCdataGuards(html)).toBe("<style>.a{color:red}</style>");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("removes the stray `]]>` that leaks into chrome slots (the bug signature)", () => {
|
|
18
|
+
const html = "<header>Site name]]></header>";
|
|
19
|
+
expect(stripCdataGuards(html)).toBe("<header>Site name</header>");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("unwraps the block-comment guard form", () => {
|
|
23
|
+
const html = "<script>/*<![CDATA[*/ doStuff(); /*]]>*/</script>";
|
|
24
|
+
expect(stripCdataGuards(html)).toBe("<script> doStuff(); </script>");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("unwraps the line-comment guard form", () => {
|
|
28
|
+
const html = "<script>\n//<![CDATA[\n doStuff();\n//]]>\n</script>";
|
|
29
|
+
expect(stripCdataGuards(html)).toBe("<script>\n\n doStuff();\n\n</script>");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles multiple guards in one document", () => {
|
|
33
|
+
const html = "<style><![CDATA[.a{}]]></style><script>//<![CDATA[\nx()\n//]]>\n</script>";
|
|
34
|
+
expect(stripCdataGuards(html)).toBe("<style>.a{}</style><script>\nx()\n\n</script>");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not touch a legitimate `CDATA` substring in text", () => {
|
|
38
|
+
const html = "<p>The CDATA section in XML is explained here.</p>";
|
|
39
|
+
expect(stripCdataGuards(html)).toBe(html);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CDATA-guard normalization for module HTML.
|
|
5
|
+
*
|
|
6
|
+
* The AI sometimes authors chrome/layout module HTML with XHTML-style
|
|
7
|
+
* CDATA guards wrapping inline `<style>`/`<script>` content — e.g.
|
|
8
|
+
*
|
|
9
|
+
* <script>
|
|
10
|
+
* //<![CDATA[
|
|
11
|
+
* doStuff();
|
|
12
|
+
* //]]>
|
|
13
|
+
* </script>
|
|
14
|
+
*
|
|
15
|
+
* or the block-comment / bare forms (`/*<![CDATA[* /`, `<![CDATA[`).
|
|
16
|
+
* These are legal in XHTML but MEANINGLESS in HTML5: the compose
|
|
17
|
+
* pipeline is byte-preserving by design (see `preview-scanner.ts`), so
|
|
18
|
+
* the guards survive verbatim into the rendered page. Chrome/layout
|
|
19
|
+
* modules render straight from their stored HTML (no content_instance
|
|
20
|
+
* indirection), so a bare `<![CDATA[` in the body is parsed by the
|
|
21
|
+
* browser as a bogus comment up to the first `>`, leaving a stray,
|
|
22
|
+
* visible `]]>` in the header/footer/nav.
|
|
23
|
+
*
|
|
24
|
+
* We normalize at the module-HTML write boundary (canonical) and again
|
|
25
|
+
* defensively at render, so both freshly-authored and already-stored
|
|
26
|
+
* modules stay clean. Only the DELIMITERS are removed — the wrapped
|
|
27
|
+
* CSS/JS/markup content is preserved.
|
|
28
|
+
*
|
|
29
|
+
* This is normalization of malformed authored markup, not a data
|
|
30
|
+
* fallback (CLAUDE.md §2): there is no "missing" data to point at, and
|
|
31
|
+
* the inner content is kept intact.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Matches every CDATA-guard delimiter form the model emits. The
|
|
36
|
+
* comment-wrapped variants come first so they're consumed as a unit
|
|
37
|
+
* before the bare `<![CDATA[` / `]]>` alternatives can match a subset.
|
|
38
|
+
*/
|
|
39
|
+
const CDATA_GUARDS =
|
|
40
|
+
/\/\*\s*<!\[CDATA\[\s*\*\/|\/\*\s*\]\]>\s*\*\/|\/\/[ \t]*<!\[CDATA\[|\/\/[ \t]*\]\]>|<!\[CDATA\[|\]\]>/g;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strip XHTML-style CDATA-section guard delimiters from module HTML,
|
|
44
|
+
* preserving the content they wrapped. Returns the input unchanged when
|
|
45
|
+
* no guard is present (fast path for the overwhelming clean-HTML case).
|
|
46
|
+
*/
|
|
47
|
+
export function stripCdataGuards(html: string): string {
|
|
48
|
+
if (!html.includes("CDATA") && !html.includes("]]>")) return html;
|
|
49
|
+
return html.replace(CDATA_GUARDS, "");
|
|
50
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P6.7.5 — typed schemas for the `structured_sets` table.
|
|
5
|
+
*
|
|
6
|
+
* One database table holds N kinds of named lists (nav-menus, taxonomies,
|
|
7
|
+
* theme tokens, tag lists, footer link blocks, ...). The `kind`
|
|
8
|
+
* discriminator drives both the Zod validator at the Query API layer
|
|
9
|
+
* and the renderer that turns the items into HTML at preview / deploy.
|
|
10
|
+
*
|
|
11
|
+
* Adding a new kind: add a Zod schema below, add it to the
|
|
12
|
+
* `structuredSetItem` discriminated union, add a renderer in
|
|
13
|
+
* `packages/shared/src/structured-set-renderer.ts`. No DB migration
|
|
14
|
+
* required — the table already accepts arbitrary `kind` text.
|
|
15
|
+
*
|
|
16
|
+
* Item shapes are deliberately **structured data**, not HTML strings.
|
|
17
|
+
* That's what makes `change_page_slug` able to retarget every menu
|
|
18
|
+
* link automatically: walk the JSON, swap `href` matches.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
|
|
23
|
+
/** Recursive nav menu — supports arbitrary submenu nesting. */
|
|
24
|
+
export const navMenuItem: z.ZodType<{
|
|
25
|
+
label: string;
|
|
26
|
+
href: string;
|
|
27
|
+
target?: "_self" | "_blank";
|
|
28
|
+
children?: { label: string; href: string }[];
|
|
29
|
+
/** When set, the renderer delegates this slot to the named ad plugin. */
|
|
30
|
+
adSlotId?: string;
|
|
31
|
+
}> = z.lazy(() =>
|
|
32
|
+
z
|
|
33
|
+
.object({
|
|
34
|
+
label: z.string().min(1).max(120),
|
|
35
|
+
href: z.string().min(1).max(500),
|
|
36
|
+
target: z.enum(["_self", "_blank"]).optional(),
|
|
37
|
+
children: z.array(navMenuItem).optional(),
|
|
38
|
+
adSlotId: z.string().min(1).max(100).optional(),
|
|
39
|
+
})
|
|
40
|
+
.strict(),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Tree-shaped category list. Used by typed-content / tag pages (P12A). */
|
|
44
|
+
export const taxonomyItem = z
|
|
45
|
+
.object({
|
|
46
|
+
slug: z.string().min(1).max(120),
|
|
47
|
+
displayName: z.string().min(1).max(200),
|
|
48
|
+
parentSlug: z.string().min(1).max(120).optional(),
|
|
49
|
+
description: z.string().max(2000).optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|
|
52
|
+
|
|
53
|
+
/** Flat tag — used by post taxonomies, content filtering. */
|
|
54
|
+
export const tagItem = z
|
|
55
|
+
.object({
|
|
56
|
+
slug: z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1)
|
|
59
|
+
.max(120)
|
|
60
|
+
.regex(/^[a-z0-9-]+$/, "lowercase letters/digits/hyphens"),
|
|
61
|
+
displayName: z.string().min(1).max(200),
|
|
62
|
+
color: z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(/^#[0-9a-fA-F]{3,8}$/, "hex color")
|
|
65
|
+
.optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict();
|
|
68
|
+
|
|
69
|
+
/** Footer "legal" / related-resources block. */
|
|
70
|
+
export const linkListItem = z
|
|
71
|
+
.object({
|
|
72
|
+
label: z.string().min(1).max(200),
|
|
73
|
+
href: z.string().min(1).max(500),
|
|
74
|
+
description: z.string().max(500).optional(),
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* P9 review-pass — `language-selector` kind. Items are user-supplied
|
|
80
|
+
* overrides (e.g. force a specific display label per locale); empty
|
|
81
|
+
* array means "auto-populate from the locale registry at render time."
|
|
82
|
+
* The renderer (apps/static-generator + preview op) reads the locale
|
|
83
|
+
* registry + the current page's slug + resolveLocaleUrl to emit
|
|
84
|
+
* `<a hreflang lang href>` rows. Closes CMS_REQUIREMENTS §7.8.
|
|
85
|
+
*/
|
|
86
|
+
export const languageSelectorOverride = z
|
|
87
|
+
.object({
|
|
88
|
+
/** BCP-47 locale code; must match a row in the locales registry. */
|
|
89
|
+
locale: z.string().min(2).max(10),
|
|
90
|
+
/** Override the locale's display_name when rendering this entry. */
|
|
91
|
+
label: z.string().min(1).max(120).optional(),
|
|
92
|
+
/** Hide this locale from the rendered selector even if it has a published page. */
|
|
93
|
+
hidden: z.boolean().optional(),
|
|
94
|
+
})
|
|
95
|
+
.strict();
|
|
96
|
+
|
|
97
|
+
/** Discriminated kind → items array. The Query API op picks the
|
|
98
|
+
* validator at runtime by reading `kind` first.
|
|
99
|
+
*
|
|
100
|
+
* v0.11.0 (#45) — `theme` is no longer a structured-set kind. The
|
|
101
|
+
* theme primitive moved to its own `themes` table with DTCG-shaped
|
|
102
|
+
* jsonb tokens; see `themes.ts` + `theme-render.ts`. */
|
|
103
|
+
export const structuredSetKind = z.enum([
|
|
104
|
+
"nav-menu",
|
|
105
|
+
"taxonomy",
|
|
106
|
+
"tags",
|
|
107
|
+
"link-list",
|
|
108
|
+
"language-selector",
|
|
109
|
+
]);
|
|
110
|
+
export type StructuredSetKind = z.infer<typeof structuredSetKind>;
|
|
111
|
+
|
|
112
|
+
/** Validate `items` against the right schema for `kind`. Throws a
|
|
113
|
+
* ZodError on mismatch — callers wrap and return `Err(ValidationFailed)`. */
|
|
114
|
+
export function validateStructuredSetItems(kind: StructuredSetKind, items: unknown): unknown[] {
|
|
115
|
+
if (!Array.isArray(items)) {
|
|
116
|
+
throw new z.ZodError([
|
|
117
|
+
{
|
|
118
|
+
code: "custom",
|
|
119
|
+
path: ["items"],
|
|
120
|
+
message: "items must be an array",
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
switch (kind) {
|
|
125
|
+
case "nav-menu":
|
|
126
|
+
return items.map((it) => navMenuItem.parse(it));
|
|
127
|
+
case "taxonomy":
|
|
128
|
+
return items.map((it) => taxonomyItem.parse(it));
|
|
129
|
+
case "tags":
|
|
130
|
+
return items.map((it) => tagItem.parse(it));
|
|
131
|
+
case "link-list":
|
|
132
|
+
return items.map((it) => linkListItem.parse(it));
|
|
133
|
+
case "language-selector":
|
|
134
|
+
return items.map((it) => languageSelectorOverride.parse(it));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type NavMenuItem = z.infer<typeof navMenuItem>;
|
|
139
|
+
export type TaxonomyItem = z.infer<typeof taxonomyItem>;
|
|
140
|
+
export type TagItem = z.infer<typeof tagItem>;
|
|
141
|
+
export type LinkListItem = z.infer<typeof linkListItem>;
|
|
142
|
+
export type LanguageSelectorOverride = z.infer<typeof languageSelectorOverride>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render a `<nav class="caelo-language-selector">` with one `<a>`
|
|
146
|
+
* per locale that has a published variant of the current page. Pure
|
|
147
|
+
* function — caller threads the page's published-locale list and a
|
|
148
|
+
* URL resolver. Used by `composePagePreview` (admin) and the static
|
|
149
|
+
* generator (deploy) so byte-for-byte output parity holds.
|
|
150
|
+
*
|
|
151
|
+
* Per CMS_REQUIREMENTS §7.8: locales with no published variant for the
|
|
152
|
+
* current page are excluded.
|
|
153
|
+
*/
|
|
154
|
+
export function renderLanguageSelector(args: {
|
|
155
|
+
/** Each locale that has a published variant of the current page. */
|
|
156
|
+
readonly availableLocales: ReadonlyArray<{
|
|
157
|
+
code: string;
|
|
158
|
+
displayName: string;
|
|
159
|
+
href: string;
|
|
160
|
+
isCurrent: boolean;
|
|
161
|
+
}>;
|
|
162
|
+
/** Owner-supplied overrides (relabel a locale, hide one). */
|
|
163
|
+
readonly overrides?: ReadonlyArray<LanguageSelectorOverride>;
|
|
164
|
+
}): string {
|
|
165
|
+
const overrideByLocale = new Map<string, LanguageSelectorOverride>(
|
|
166
|
+
(args.overrides ?? []).map((o) => [o.locale, o]),
|
|
167
|
+
);
|
|
168
|
+
const enc = (s: string): string =>
|
|
169
|
+
s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
170
|
+
const items = args.availableLocales
|
|
171
|
+
.filter((l) => !overrideByLocale.get(l.code)?.hidden)
|
|
172
|
+
.map((l) => {
|
|
173
|
+
const override = overrideByLocale.get(l.code);
|
|
174
|
+
const label = override?.label ?? l.displayName;
|
|
175
|
+
const aria = l.isCurrent ? ' aria-current="true"' : "";
|
|
176
|
+
return `<a hreflang="${enc(l.code)}" lang="${enc(l.code)}" href="${enc(l.href)}"${aria}>${enc(label)}</a>`;
|
|
177
|
+
});
|
|
178
|
+
if (items.length === 0) return "";
|
|
179
|
+
return `<nav class="caelo-language-selector" aria-label="Language">${items.join("")}</nav>`;
|
|
180
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { parseSubagentResult, subagentSpec, validateSubagentResultValue } from "./subagents.js";
|
|
5
|
+
|
|
6
|
+
describe("subagentSpec", () => {
|
|
7
|
+
it("accepts a minimal spec with role + task", () => {
|
|
8
|
+
const r = subagentSpec.safeParse({ role: "qa", task: "QA the page" });
|
|
9
|
+
expect(r.success).toBe(true);
|
|
10
|
+
if (!r.success) return;
|
|
11
|
+
expect(r.data.expectedReturnShape).toBe("verdict");
|
|
12
|
+
// issue #304 — no schema default: an omitted cap means "derive from
|
|
13
|
+
// the armed run budget" in the spawn orchestrator, and a default
|
|
14
|
+
// here (the old 50M µ¢) would make omission indistinguishable from
|
|
15
|
+
// an explicit choice.
|
|
16
|
+
expect(r.data.maxCostMicrocents).toBeUndefined();
|
|
17
|
+
// Default 300s — build children (Genesis draft, migration rebuild) need
|
|
18
|
+
// minutes; the old 60s aborted them mid-work (0 drafts saved).
|
|
19
|
+
expect(r.data.timeoutMs).toBe(300_000);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects empty role + task", () => {
|
|
23
|
+
expect(subagentSpec.safeParse({ role: "", task: "x" }).success).toBe(false);
|
|
24
|
+
expect(subagentSpec.safeParse({ role: "x", task: "" }).success).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects unknown fields (.strict)", () => {
|
|
28
|
+
expect(subagentSpec.safeParse({ role: "x", task: "y", randomField: "z" }).success).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("validateSubagentResultValue — double-encoded payload (submit_result bug)", () => {
|
|
33
|
+
// Bug: a provider double-encodes the verdict as a JSON STRING
|
|
34
|
+
// (`{"result":"{\"pass\":true,...}"}`); with `result` untyped in the
|
|
35
|
+
// submit_result inputSchema the encoding-repair layer couldn't decode
|
|
36
|
+
// it, so a well-formed verdict reached the validator as a string and
|
|
37
|
+
// was rejected as "string (scalar)". The validator now decodes a
|
|
38
|
+
// JSON-looking string for structured shapes as defense-in-depth.
|
|
39
|
+
it("verdict — accepts a JSON-string-encoded object", () => {
|
|
40
|
+
const encoded = JSON.stringify({ pass: true, issues: [], suggestions: ["add CTA"] });
|
|
41
|
+
const r = validateSubagentResultValue(encoded, "verdict");
|
|
42
|
+
expect(r.ok).toBe(true);
|
|
43
|
+
if (!r.ok) return;
|
|
44
|
+
expect(r.shape).toBe("verdict");
|
|
45
|
+
expect(r.value.pass).toBe(true);
|
|
46
|
+
expect(r.value.suggestions).toEqual(["add CTA"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("verdict — still accepts a real object (unchanged path)", () => {
|
|
50
|
+
const r = validateSubagentResultValue({ pass: false, issues: ["x"] }, "verdict");
|
|
51
|
+
expect(r.ok).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rebuild — accepts a JSON-string-encoded object", () => {
|
|
55
|
+
const encoded = JSON.stringify({ pages: [{ slug: "/home", status: "rebuilt" }] });
|
|
56
|
+
const r = validateSubagentResultValue(encoded, "rebuild");
|
|
57
|
+
expect(r.ok).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("verdict — a genuine non-JSON string still fails with the scalar hint", () => {
|
|
61
|
+
const r = validateSubagentResultValue("looks good to me", "verdict");
|
|
62
|
+
expect(r.ok).toBe(false);
|
|
63
|
+
if (r.ok) return;
|
|
64
|
+
expect(r.error).toContain("scalar");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("freeform — a bare prose string is NOT treated as JSON", () => {
|
|
68
|
+
const r = validateSubagentResultValue("plain prose", "freeform");
|
|
69
|
+
expect(r.ok).toBe(true);
|
|
70
|
+
if (!r.ok) return;
|
|
71
|
+
expect(r.value.text).toBe("plain prose");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("parseSubagentResult — verdict", () => {
|
|
76
|
+
it("extracts JSON from a code fence", () => {
|
|
77
|
+
const text = `\`\`\`json\n${JSON.stringify({ pass: true, issues: [], suggestions: ["add CTA"] })}\n\`\`\``;
|
|
78
|
+
const r = parseSubagentResult(text, "verdict");
|
|
79
|
+
expect(r.ok).toBe(true);
|
|
80
|
+
if (!r.ok) return;
|
|
81
|
+
expect(r.shape).toBe("verdict");
|
|
82
|
+
expect(r.value.pass).toBe(true);
|
|
83
|
+
expect(r.value.suggestions).toEqual(["add CTA"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("extracts JSON from prose-wrapped output", () => {
|
|
87
|
+
const text =
|
|
88
|
+
'Here is my verdict:\n{"pass": false, "issues": ["missing disclaimer"], "suggestions": []}\nHope that helps.';
|
|
89
|
+
const r = parseSubagentResult(text, "verdict");
|
|
90
|
+
expect(r.ok).toBe(true);
|
|
91
|
+
if (!r.ok) return;
|
|
92
|
+
expect(r.value.pass).toBe(false);
|
|
93
|
+
expect(r.value.issues).toEqual(["missing disclaimer"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns error on shape mismatch", () => {
|
|
97
|
+
const text = JSON.stringify({ pass: "yes", issues: [] });
|
|
98
|
+
const r = parseSubagentResult(text, "verdict");
|
|
99
|
+
expect(r.ok).toBe(false);
|
|
100
|
+
if (r.ok) return;
|
|
101
|
+
expect(r.error).toContain("pass");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns error on non-JSON garbage", () => {
|
|
105
|
+
const r = parseSubagentResult("not json at all", "verdict");
|
|
106
|
+
expect(r.ok).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("parseSubagentResult — tree", () => {
|
|
111
|
+
it("validates tree shape", () => {
|
|
112
|
+
const text = JSON.stringify({
|
|
113
|
+
tree: [{ label: "Root", children: [] }],
|
|
114
|
+
rationale: "minimal",
|
|
115
|
+
});
|
|
116
|
+
const r = parseSubagentResult(text, "tree");
|
|
117
|
+
expect(r.ok).toBe(true);
|
|
118
|
+
if (!r.ok) return;
|
|
119
|
+
expect(r.shape).toBe("tree");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("parseSubagentResult — rebuild (issue #264)", () => {
|
|
124
|
+
it("validates a full migration-batch summary", () => {
|
|
125
|
+
const text = JSON.stringify({
|
|
126
|
+
pages: [
|
|
127
|
+
{
|
|
128
|
+
pageId: "11111111-1111-4111-8111-222222222222",
|
|
129
|
+
slug: "pricing",
|
|
130
|
+
status: "rebuilt",
|
|
131
|
+
notes: "table modernised; footnote folded into caption",
|
|
132
|
+
},
|
|
133
|
+
{ slug: "pricing-archive", status: "skipped", notes: "operator marked obsolete" },
|
|
134
|
+
],
|
|
135
|
+
contentNotes: ["source hero carried a rotating quote widget; kept the first quote only"],
|
|
136
|
+
skipped: [{ item: "legacy price calculator embed", reason: "third-party script, no data" }],
|
|
137
|
+
summary: "2-page batch: 1 rebuilt, 1 skipped.",
|
|
138
|
+
});
|
|
139
|
+
const r = parseSubagentResult(text, "rebuild");
|
|
140
|
+
expect(r.ok).toBe(true);
|
|
141
|
+
if (!r.ok) return;
|
|
142
|
+
expect(r.shape).toBe("rebuild");
|
|
143
|
+
if (r.shape !== "rebuild") return;
|
|
144
|
+
expect(r.value.pages).toHaveLength(2);
|
|
145
|
+
expect(r.value.pages[0]?.status).toBe("rebuilt");
|
|
146
|
+
expect(r.value.skipped[0]?.reason).toContain("third-party");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("defaults contentNotes/skipped/summary when omitted", () => {
|
|
150
|
+
const text = `\`\`\`json\n${JSON.stringify({ pages: [{ slug: "home", status: "rebuilt" }] })}\n\`\`\``;
|
|
151
|
+
const r = parseSubagentResult(text, "rebuild");
|
|
152
|
+
expect(r.ok).toBe(true);
|
|
153
|
+
if (!r.ok) return;
|
|
154
|
+
if (r.shape !== "rebuild") return;
|
|
155
|
+
expect(r.value.contentNotes).toEqual([]);
|
|
156
|
+
expect(r.value.skipped).toEqual([]);
|
|
157
|
+
expect(r.value.summary).toBe("");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects an empty pages array — a rebuild subagent must account for its batch", () => {
|
|
161
|
+
const r = parseSubagentResult(JSON.stringify({ pages: [] }), "rebuild");
|
|
162
|
+
expect(r.ok).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("names the expected shape + observed keys on mismatch", () => {
|
|
166
|
+
const r = parseSubagentResult(JSON.stringify({ pass: true, issues: [] }), "rebuild");
|
|
167
|
+
expect(r.ok).toBe(false);
|
|
168
|
+
if (r.ok) return;
|
|
169
|
+
expect(r.error).toContain("rebuild shape mismatch");
|
|
170
|
+
expect(r.error).toContain("pass");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("rejects an out-of-enum page status", () => {
|
|
174
|
+
const r = parseSubagentResult(
|
|
175
|
+
JSON.stringify({ pages: [{ slug: "home", status: "done" }] }),
|
|
176
|
+
"rebuild",
|
|
177
|
+
);
|
|
178
|
+
expect(r.ok).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("subagentSpec — rebuild shape accepted", () => {
|
|
183
|
+
it("accepts expectedReturnShape rebuild with migration-scale caps", () => {
|
|
184
|
+
const r = subagentSpec.safeParse({
|
|
185
|
+
role: "rebuild:blog",
|
|
186
|
+
task: "REBUILD TASK — rebuild the blog cluster",
|
|
187
|
+
expectedReturnShape: "rebuild",
|
|
188
|
+
timeoutMs: 600_000,
|
|
189
|
+
maxCostMicrocents: 100_000_000,
|
|
190
|
+
});
|
|
191
|
+
expect(r.success).toBe(true);
|
|
192
|
+
if (!r.success) return;
|
|
193
|
+
expect(r.data.expectedReturnShape).toBe("rebuild");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("parseSubagentResult — freeform", () => {
|
|
198
|
+
it("accepts {text: ...} JSON", () => {
|
|
199
|
+
const r = parseSubagentResult(JSON.stringify({ text: "summary here" }), "freeform");
|
|
200
|
+
expect(r.ok).toBe(true);
|
|
201
|
+
if (!r.ok) return;
|
|
202
|
+
expect(r.value.text).toBe("summary here");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("falls back to raw text when JSON parse fails", () => {
|
|
206
|
+
const r = parseSubagentResult("plain text response from the subagent.", "freeform");
|
|
207
|
+
expect(r.ok).toBe(true);
|
|
208
|
+
if (!r.ok) return;
|
|
209
|
+
expect(r.value.text).toBe("plain text response from the subagent.");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns error on empty text", () => {
|
|
213
|
+
expect(parseSubagentResult(" ", "freeform").ok).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("subagentSpec — model tier (issue #306)", () => {
|
|
218
|
+
it("defaults tier to inherit (single-model behaviour unchanged when omitted)", () => {
|
|
219
|
+
const r = subagentSpec.safeParse({ role: "qa", task: "QA the page" });
|
|
220
|
+
expect(r.success).toBe(true);
|
|
221
|
+
if (!r.success) return;
|
|
222
|
+
expect(r.data.tier).toBe("inherit");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("accepts mid and small, rejects unknown tiers", () => {
|
|
226
|
+
for (const tier of ["mid", "small", "inherit"]) {
|
|
227
|
+
expect(subagentSpec.safeParse({ role: "x", task: "y", tier }).success).toBe(true);
|
|
228
|
+
}
|
|
229
|
+
expect(subagentSpec.safeParse({ role: "x", task: "y", tier: "large" }).success).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("parseSubagentResult — needs_escalation (issue #306)", () => {
|
|
234
|
+
it("accepts a needs_escalation page WITH a reason in notes", () => {
|
|
235
|
+
const r = parseSubagentResult(
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
pages: [
|
|
238
|
+
{ slug: "home", status: "rebuilt" },
|
|
239
|
+
{ slug: "pricing", status: "needs_escalation", notes: "no matching table module" },
|
|
240
|
+
],
|
|
241
|
+
}),
|
|
242
|
+
"rebuild",
|
|
243
|
+
);
|
|
244
|
+
expect(r.ok).toBe(true);
|
|
245
|
+
if (!r.ok) return;
|
|
246
|
+
if (r.shape !== "rebuild") return;
|
|
247
|
+
expect(r.value.pages[1]?.status).toBe("needs_escalation");
|
|
248
|
+
expect(r.value.pages[1]?.notes).toBe("no matching table module");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("REJECTS needs_escalation without notes — a blind escalation cannot be briefed", () => {
|
|
252
|
+
for (const notes of [undefined, "", " "]) {
|
|
253
|
+
const r = parseSubagentResult(
|
|
254
|
+
JSON.stringify({ pages: [{ slug: "pricing", status: "needs_escalation", notes }] }),
|
|
255
|
+
"rebuild",
|
|
256
|
+
);
|
|
257
|
+
expect(r.ok).toBe(false);
|
|
258
|
+
if (r.ok) continue;
|
|
259
|
+
expect(r.error).toContain("needs_escalation");
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|