@usefragments/core 1.10.2 → 1.11.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.
Files changed (126) hide show
  1. package/dist/{chunk-MZ4SW3TP.js → chunk-3IOWHECM.js} +8 -1
  2. package/dist/{chunk-MZ4SW3TP.js.map → chunk-3IOWHECM.js.map} +1 -1
  3. package/dist/chunk-7ULGH74M.js +66 -0
  4. package/dist/chunk-7ULGH74M.js.map +1 -0
  5. package/dist/{chunk-YF65VYRY.js → chunk-ML5S6QNU.js} +191 -169
  6. package/dist/chunk-ML5S6QNU.js.map +1 -0
  7. package/dist/chunk-PWIJMOI4.js +202 -0
  8. package/dist/chunk-PWIJMOI4.js.map +1 -0
  9. package/dist/{chunk-XN3LSDPY.js → chunk-RPSEABY3.js} +622 -344
  10. package/dist/chunk-RPSEABY3.js.map +1 -0
  11. package/dist/chunk-RYFULE43.js +578 -0
  12. package/dist/chunk-RYFULE43.js.map +1 -0
  13. package/dist/codes/index.d.ts +2 -2
  14. package/dist/codes/index.js +3 -2
  15. package/dist/compiled-types/index.d.ts +327 -2
  16. package/dist/compiled-types/index.js +1 -1
  17. package/dist/generate/index.d.ts +3 -2
  18. package/dist/{governance-CLk_wkP9.d.ts → governance-BAsy1k2H.d.ts} +472 -513
  19. package/dist/governance-telemetry.d.ts +6 -0
  20. package/dist/governance-telemetry.js +1 -1
  21. package/dist/{index-_sxhUNqx.d.ts → index-C8bcXVav.d.ts} +448 -448
  22. package/dist/index.d.ts +9165 -1297
  23. package/dist/index.js +3041 -350
  24. package/dist/index.js.map +1 -1
  25. package/dist/manifest.d.ts +228 -0
  26. package/dist/manifest.js +24 -0
  27. package/dist/manifest.js.map +1 -0
  28. package/dist/preview/index.js +45 -1
  29. package/dist/preview/index.js.map +1 -1
  30. package/dist/preview-runtime.d.ts +1 -2
  31. package/dist/preview-runtime.js +150 -14
  32. package/dist/preview-runtime.js.map +1 -1
  33. package/dist/react-types.d.ts +1 -2
  34. package/dist/registry.d.ts +1412 -203
  35. package/dist/registry.js +30 -3
  36. package/dist/schemas/index.d.ts +1 -1
  37. package/dist/schemas/index.js +3 -2
  38. package/dist/storyAdapter.d.ts +1 -2
  39. package/dist/storyAdapter.js +11 -49
  40. package/dist/storyAdapter.js.map +1 -1
  41. package/dist/test-utils.d.ts +3 -2
  42. package/package.json +8 -2
  43. package/src/__tests__/contract-parser.test.ts +318 -277
  44. package/src/__tests__/preview-runtime-hook.test.tsx +315 -0
  45. package/src/__tests__/preview-runtime.test.tsx +30 -8
  46. package/src/__tests__/schema.test.ts +191 -14
  47. package/src/analysis-plan/analysis-plan-v1.test.ts +320 -0
  48. package/src/analysis-plan/coverage.ts +181 -0
  49. package/src/analysis-plan/digest.ts +141 -0
  50. package/src/analysis-plan/index.ts +34 -0
  51. package/src/analysis-plan/types.ts +207 -0
  52. package/src/codes/__tests__/codes.test.ts +9 -0
  53. package/src/codes/codes.ts +30 -0
  54. package/src/compiled-types/index.ts +640 -39
  55. package/src/compiled-types/parse.test.ts +145 -4
  56. package/src/component-contract.ts +95 -53
  57. package/src/composition.ts +7 -13
  58. package/src/constants.ts +3 -6
  59. package/src/contract/hash.test.ts +20 -0
  60. package/src/contract/hash.ts +66 -9
  61. package/src/contract/index.ts +24 -1
  62. package/src/contract/manifest.test.ts +94 -0
  63. package/src/contract/manifest.ts +68 -0
  64. package/src/contract/preimage.test.ts +219 -1
  65. package/src/contract/preimage.ts +326 -6
  66. package/src/contract/stamp.test.ts +3 -0
  67. package/src/contract/stamp.ts +1 -1
  68. package/src/contract-parser.ts +54 -30
  69. package/src/defineFragment.test.ts +476 -91
  70. package/src/defineFragment.ts +204 -114
  71. package/src/domain-ids.test.ts +52 -0
  72. package/src/domain-ids.ts +106 -0
  73. package/src/evaluation/evaluate.test.ts +522 -0
  74. package/src/evaluation/evaluate.ts +680 -0
  75. package/src/evaluation/evaluation-v2-receipt-v1.test.ts +760 -0
  76. package/src/evaluation/index.ts +58 -0
  77. package/src/evaluation/receipt.ts +753 -0
  78. package/src/evaluation/types.ts +406 -0
  79. package/src/facts/builders.ts +2 -0
  80. package/src/facts/compile.ts +29 -6
  81. package/src/facts/fact-index.ts +13 -3
  82. package/src/facts/fact-integrity-v1.test.ts +172 -0
  83. package/src/facts/facts.test.ts +15 -0
  84. package/src/facts/ids.ts +46 -3
  85. package/src/facts/index.ts +14 -1
  86. package/src/facts/integrity.ts +134 -0
  87. package/src/facts/types.ts +36 -0
  88. package/src/feature-plan/digest.ts +217 -0
  89. package/src/feature-plan/feature-plan-v1.test.ts +529 -0
  90. package/src/feature-plan/index.ts +65 -0
  91. package/src/feature-plan/types.ts +628 -0
  92. package/src/governance-integrity.test.ts +1 -0
  93. package/src/governance-integrity.ts +5 -3
  94. package/src/governance-telemetry.ts +8 -0
  95. package/src/governance.ts +52 -8
  96. package/src/index.ts +299 -37
  97. package/src/preview/validation.test.ts +62 -0
  98. package/src/preview/validation.ts +48 -2
  99. package/src/preview-runtime.tsx +227 -20
  100. package/src/registry-install-plan.ts +200 -109
  101. package/src/registry-shards.test.ts +263 -0
  102. package/src/registry.ts +237 -0
  103. package/src/repository-binding.test.ts +50 -0
  104. package/src/repository-binding.ts +96 -0
  105. package/src/rules/families.test.ts +36 -0
  106. package/src/rules/finding.ts +7 -2
  107. package/src/rules/index.ts +17 -1
  108. package/src/rules/rule-config.test.ts +66 -0
  109. package/src/rules/rule-config.ts +73 -0
  110. package/src/rules/rules.test.ts +26 -0
  111. package/src/rules/tokens-css-vars-must-be-defined.test.ts +51 -2
  112. package/src/rules/tokens-css-vars-must-be-defined.ts +34 -1
  113. package/src/schema.ts +293 -113
  114. package/src/schemas/index.ts +1 -1
  115. package/src/storyAdapter.test.ts +68 -12
  116. package/src/storyAdapter.ts +44 -75
  117. package/src/types.ts +258 -37
  118. package/dist/chunk-RANPUC6C.js +0 -72
  119. package/dist/chunk-RANPUC6C.js.map +0 -1
  120. package/dist/chunk-XN3LSDPY.js.map +0 -1
  121. package/dist/chunk-YF65VYRY.js.map +0 -1
  122. package/src/fragment-types.ts +0 -214
  123. package/src/react-create-element.test.ts +0 -22
  124. package/src/react-create-element.ts +0 -12
  125. package/src/storyFilters.test.ts +0 -350
  126. package/src/storyFilters.ts +0 -253
@@ -153,6 +153,21 @@ describe("fact IR — content-addressed IDs", () => {
153
153
  expect(a).toBe(b);
154
154
  });
155
155
 
156
+ it("rejects circular and runtime-only fact identity values deterministically", () => {
157
+ const circular: Record<string, unknown> = {};
158
+ circular.self = circular;
159
+
160
+ expect(() => factId("component", circular)).toThrow(
161
+ "Circular fact identity value at (root).i.self"
162
+ );
163
+ expect(() => factId("component", { componentId: () => "Button" })).toThrow(
164
+ "Unsupported fact identity value at (root).i.componentId: function"
165
+ );
166
+ expect(() => factId("component", { confidence: Number.NaN })).toThrow(
167
+ "Unsupported fact identity value at (root).i.confidence: non-finite number"
168
+ );
169
+ });
170
+
156
171
  it("keys definition and identity facts by the portable componentKey", () => {
157
172
  const first = makeComponentDefinitionFact({
158
173
  file: "src/a/Button.tsx",
package/src/facts/ids.ts CHANGED
@@ -22,15 +22,58 @@ import type { ComponentId, FactId } from "./types.js";
22
22
  // ---------------------------------------------------------------------------
23
23
 
24
24
  export function canonicalJson(value: unknown): string {
25
+ return canonicalJsonValue(value, new Set<object>(), "(root)");
26
+ }
27
+
28
+ function canonicalJsonValue(value: unknown, ancestors: Set<object>, path: string): string {
25
29
  if (value === undefined) return "null";
26
- if (value === null || typeof value !== "object") return JSON.stringify(value);
30
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
31
+ return JSON.stringify(value);
32
+ }
33
+ if (typeof value === "number") {
34
+ if (!Number.isFinite(value)) {
35
+ throw new TypeError(`Unsupported fact identity value at ${path}: non-finite number`);
36
+ }
37
+ const serialized = JSON.stringify(value);
38
+ if (serialized === undefined) {
39
+ throw new TypeError(`Unsupported fact identity value at ${path}: number`);
40
+ }
41
+ return serialized;
42
+ }
43
+ if (typeof value !== "object") {
44
+ throw new TypeError(`Unsupported fact identity value at ${path}: ${typeof value}`);
45
+ }
27
46
  if (Array.isArray(value)) {
28
- return `[${value.map(canonicalJson).join(",")}]`;
47
+ if (ancestors.has(value)) throw new TypeError(`Circular fact identity value at ${path}`);
48
+ ancestors.add(value);
49
+ try {
50
+ return `[${value
51
+ .map((entry, index) => canonicalJsonValue(entry, ancestors, `${path}[${index}]`))
52
+ .join(",")}]`;
53
+ } finally {
54
+ ancestors.delete(value);
55
+ }
29
56
  }
57
+ const prototype = Object.getPrototypeOf(value);
58
+ if (prototype !== Object.prototype && prototype !== null) {
59
+ const objectType = prototype?.constructor?.name ?? "unknown";
60
+ throw new TypeError(`Unsupported fact identity object at ${path}: ${objectType}`);
61
+ }
62
+ if (ancestors.has(value)) throw new TypeError(`Circular fact identity value at ${path}`);
63
+ ancestors.add(value);
30
64
  const entries = Object.entries(value as Record<string, unknown>)
31
65
  .filter(([, v]) => v !== undefined)
32
66
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
33
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
67
+ try {
68
+ return `{${entries
69
+ .map(
70
+ ([key, entry]) =>
71
+ `${JSON.stringify(key)}:${canonicalJsonValue(entry, ancestors, `${path}.${key}`)}`
72
+ )
73
+ .join(",")}}`;
74
+ } finally {
75
+ ancestors.delete(value);
76
+ }
34
77
  }
35
78
 
36
79
  // ---------------------------------------------------------------------------
@@ -72,8 +72,21 @@ export type {
72
72
  TailwindTokenResolvedFact,
73
73
  TailwindValue,
74
74
  ThemeDeclarationFact,
75
+ CanonicalFactConflictV1,
76
+ CanonicalFactConflictValueV1,
77
+ CanonicalFactIntegrityV1,
75
78
  } from "./types.js";
76
79
 
80
+ export {
81
+ CANONICAL_FACT_MAX_CONFLICTS_V1,
82
+ CANONICAL_FACT_MAX_PROVENANCE_PER_VALUE_V1,
83
+ CANONICAL_FACT_MAX_VALUES_PER_CONFLICT_V1,
84
+ canonicalFactConflictV1Schema,
85
+ canonicalFactConflictValueV1Schema,
86
+ canonicalFactIntegrityV1Schema,
87
+ normalizeCanonicalFactIntegrityV1,
88
+ } from "./integrity.js";
89
+
77
90
  export { componentId, asComponentId, factId, hash64Hex, canonicalJson } from "./ids.js";
78
91
 
79
92
  export {
@@ -122,7 +135,7 @@ export {
122
135
  } from "./builders.js";
123
136
 
124
137
  export { FactIndex, matchesGlob } from "./fact-index.js";
125
- export type { FactConflict, FactEvidence } from "./fact-index.js";
138
+ export type { FactConflict, FactDuplicate, FactEvidence } from "./fact-index.js";
126
139
 
127
140
  export { compileGlobalGovernanceFacts, compileComponentFacts } from "./compile.js";
128
141
  export { projectSupersededImportPathPreferences } from "./compile.js";
@@ -0,0 +1,134 @@
1
+ import { z } from "zod";
2
+ import { compareCanonicalStrings } from "../contract/hash.js";
3
+ import { digestHexStringSchema } from "../domain-ids.js";
4
+ import type { CanonicalFactIntegrityV1 } from "./types.js";
5
+
6
+ export const CANONICAL_FACT_MAX_CONFLICTS_V1 = 1_000;
7
+ export const CANONICAL_FACT_MAX_VALUES_PER_CONFLICT_V1 = 64;
8
+ export const CANONICAL_FACT_MAX_PROVENANCE_PER_VALUE_V1 = 1_000;
9
+
10
+ const boundedString = z.string().min(1).max(4_096);
11
+ const unique = (values: readonly string[]): boolean => new Set(values).size === values.length;
12
+ const provenance = z
13
+ .array(boundedString)
14
+ .min(1)
15
+ .max(CANONICAL_FACT_MAX_PROVENANCE_PER_VALUE_V1)
16
+ .refine(unique, "provenance values must be unique");
17
+
18
+ export const canonicalFactConflictValueV1Schema = z
19
+ .object({
20
+ valueDigest: digestHexStringSchema,
21
+ factIds: provenance,
22
+ totalFactIdCount: z.number().int().positive().optional(),
23
+ analyzerIds: provenance,
24
+ totalAnalyzerIdCount: z.number().int().positive().optional(),
25
+ evidenceRefs: provenance,
26
+ totalEvidenceRefCount: z.number().int().positive().optional(),
27
+ })
28
+ .strict()
29
+ .superRefine((value, context) => {
30
+ for (const [path, total, retained] of [
31
+ ["totalFactIdCount", value.totalFactIdCount, value.factIds.length],
32
+ ["totalAnalyzerIdCount", value.totalAnalyzerIdCount, value.analyzerIds.length],
33
+ ["totalEvidenceRefCount", value.totalEvidenceRefCount, value.evidenceRefs.length],
34
+ ] as const) {
35
+ if (total !== undefined && total < retained) {
36
+ context.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ path: [path],
39
+ message: `${path} cannot be smaller than retained provenance`,
40
+ });
41
+ }
42
+ }
43
+ });
44
+
45
+ export const canonicalFactConflictV1Schema = z
46
+ .object({
47
+ canonicalKey: boundedString,
48
+ factKind: boundedString,
49
+ state: z.literal("conflict"),
50
+ values: z
51
+ .array(canonicalFactConflictValueV1Schema)
52
+ .min(2)
53
+ .max(CANONICAL_FACT_MAX_VALUES_PER_CONFLICT_V1)
54
+ .refine(
55
+ (values) => new Set(values.map((value) => value.valueDigest)).size === values.length,
56
+ "conflict values must have distinct material digests"
57
+ ),
58
+ totalValueCount: z.number().int().min(2).optional(),
59
+ })
60
+ .strict()
61
+ .superRefine((value, context) => {
62
+ if (value.totalValueCount !== undefined && value.totalValueCount < value.values.length) {
63
+ context.addIssue({
64
+ code: z.ZodIssueCode.custom,
65
+ path: ["totalValueCount"],
66
+ message: "totalValueCount cannot be smaller than retained values",
67
+ });
68
+ }
69
+ });
70
+
71
+ export const canonicalFactIntegrityV1Schema = z.union([
72
+ z
73
+ .object({
74
+ state: z.literal("healthy"),
75
+ coalescedDuplicateCount: z.number().int().nonnegative(),
76
+ })
77
+ .strict(),
78
+ z
79
+ .object({
80
+ state: z.literal("conflict"),
81
+ coalescedDuplicateCount: z.number().int().nonnegative(),
82
+ conflicts: z
83
+ .array(canonicalFactConflictV1Schema)
84
+ .min(1)
85
+ .max(CANONICAL_FACT_MAX_CONFLICTS_V1)
86
+ .refine(
87
+ (conflicts) =>
88
+ new Set(conflicts.map((conflict) => conflict.canonicalKey)).size === conflicts.length,
89
+ "canonical conflict keys must be unique"
90
+ ),
91
+ totalConflictCount: z.number().int().positive().optional(),
92
+ })
93
+ .strict()
94
+ .superRefine((value, context) => {
95
+ if (
96
+ value.totalConflictCount !== undefined &&
97
+ value.totalConflictCount < value.conflicts.length
98
+ ) {
99
+ context.addIssue({
100
+ code: z.ZodIssueCode.custom,
101
+ path: ["totalConflictCount"],
102
+ message: "totalConflictCount cannot be smaller than retained conflicts",
103
+ });
104
+ }
105
+ }),
106
+ ]);
107
+
108
+ const sorted = (values: readonly string[]): string[] => [...values].sort();
109
+
110
+ export function normalizeCanonicalFactIntegrityV1(value: unknown): CanonicalFactIntegrityV1 {
111
+ const parsed = canonicalFactIntegrityV1Schema.parse(value);
112
+ if (parsed.state === "healthy") return parsed;
113
+ return {
114
+ ...parsed,
115
+ totalConflictCount: parsed.totalConflictCount ?? parsed.conflicts.length,
116
+ conflicts: parsed.conflicts
117
+ .map((conflict) => ({
118
+ ...conflict,
119
+ totalValueCount: conflict.totalValueCount ?? conflict.values.length,
120
+ values: conflict.values
121
+ .map((entry) => ({
122
+ ...entry,
123
+ factIds: sorted(entry.factIds),
124
+ totalFactIdCount: entry.totalFactIdCount ?? entry.factIds.length,
125
+ analyzerIds: sorted(entry.analyzerIds),
126
+ totalAnalyzerIdCount: entry.totalAnalyzerIdCount ?? entry.analyzerIds.length,
127
+ evidenceRefs: sorted(entry.evidenceRefs),
128
+ totalEvidenceRefCount: entry.totalEvidenceRefCount ?? entry.evidenceRefs.length,
129
+ }))
130
+ .sort((left, right) => compareCanonicalStrings(left.valueDigest, right.valueDigest)),
131
+ }))
132
+ .sort((left, right) => compareCanonicalStrings(left.canonicalKey, right.canonicalKey)),
133
+ };
134
+ }
@@ -160,6 +160,11 @@ export interface StyleFontSizeScaleFact extends BaseFact, PolicyFactExcludes {
160
160
  export interface StyleCssVarsMustBeDefinedFact extends BaseFact, PolicyFactExcludes {
161
161
  kind: "style_css_vars_must_be_defined";
162
162
  severity: GovernanceSeverity;
163
+ /**
164
+ * Repo-relative path of the resolved fragments config, when the scan knows
165
+ * it. Omitted from fact identity — same policy, different config filename.
166
+ */
167
+ configPath?: string;
163
168
  }
164
169
 
165
170
  /**
@@ -758,3 +763,34 @@ export type FactKind = Fact["kind"];
758
763
 
759
764
  /** Narrows a Fact union by kind. */
760
765
  export type FactOfKind<K extends FactKind> = Extract<Fact, { kind: K }>;
766
+
767
+ // ---------------------------------------------------------------------------
768
+ // V1 canonical integrity vocabulary (additive; current FactIndex is unchanged)
769
+ // ---------------------------------------------------------------------------
770
+
771
+ export interface CanonicalFactConflictValueV1 {
772
+ valueDigest: string;
773
+ factIds: string[];
774
+ totalFactIdCount?: number;
775
+ analyzerIds: string[];
776
+ totalAnalyzerIdCount?: number;
777
+ evidenceRefs: string[];
778
+ totalEvidenceRefCount?: number;
779
+ }
780
+
781
+ export interface CanonicalFactConflictV1 {
782
+ canonicalKey: string;
783
+ factKind: string;
784
+ state: "conflict";
785
+ values: CanonicalFactConflictValueV1[];
786
+ totalValueCount?: number;
787
+ }
788
+
789
+ export type CanonicalFactIntegrityV1 =
790
+ | { state: "healthy"; coalescedDuplicateCount: number }
791
+ | {
792
+ state: "conflict";
793
+ coalescedDuplicateCount: number;
794
+ conflicts: CanonicalFactConflictV1[];
795
+ totalConflictCount?: number;
796
+ };
@@ -0,0 +1,217 @@
1
+ import { canonicalPreimage, compareCanonicalStrings, sha256Hex } from "../contract/hash.js";
2
+ import { featureRevisionIdFromDigest, type DigestHex } from "../domain-ids.js";
3
+ import {
4
+ AGENT_FEATURE_CONTEXT_MAX_BYTES_V1,
5
+ agentFeatureContextV1Schema as structuralAgentFeatureContextV1Schema,
6
+ approvedFeatureManifestInputV1Schema,
7
+ approvedFeatureManifestV1Schema,
8
+ featureProposalInputV1Schema,
9
+ featureProposalV1Schema,
10
+ type AgentFeatureContextV1,
11
+ type ApprovedFeatureManifestInputV1,
12
+ type ApprovedFeatureManifestV1,
13
+ type FeatureProposalInputV1,
14
+ type FeatureProposalV1,
15
+ } from "./types.js";
16
+
17
+ const sortedUnique = (values: readonly string[]): string[] => [...new Set(values)].sort();
18
+
19
+ function normalizeEvidenceRefs<T extends { evidenceRefIds: string[] }>(row: T): T {
20
+ return { ...row, evidenceRefIds: sortedUnique(row.evidenceRefIds) };
21
+ }
22
+
23
+ function normalizeCommon<
24
+ T extends {
25
+ placement: FeatureProposalInputV1["placement"];
26
+ componentUses: FeatureProposalInputV1["componentUses"];
27
+ tokenUses: FeatureProposalInputV1["tokenUses"];
28
+ patternUses: FeatureProposalInputV1["patternUses"];
29
+ layout: FeatureProposalInputV1["layout"];
30
+ responsive: FeatureProposalInputV1["responsive"];
31
+ scenarios: FeatureProposalInputV1["scenarios"];
32
+ gaps: FeatureProposalInputV1["gaps"];
33
+ acceptance: FeatureProposalInputV1["acceptance"];
34
+ evidenceRefs: FeatureProposalInputV1["evidenceRefs"];
35
+ },
36
+ >(value: T): T {
37
+ return {
38
+ ...value,
39
+ placement: normalizeEvidenceRefs(value.placement),
40
+ componentUses: value.componentUses
41
+ .map(normalizeEvidenceRefs)
42
+ .sort((left, right) => compareCanonicalStrings(left.useId, right.useId)),
43
+ tokenUses: value.tokenUses
44
+ .map(normalizeEvidenceRefs)
45
+ .sort((left, right) => compareCanonicalStrings(left.useId, right.useId)),
46
+ patternUses: value.patternUses
47
+ .map(normalizeEvidenceRefs)
48
+ .sort((left, right) => compareCanonicalStrings(left.useId, right.useId)),
49
+ layout: {
50
+ concerns: value.layout.concerns
51
+ .map(normalizeEvidenceRefs)
52
+ .sort((left, right) => compareCanonicalStrings(left.concern, right.concern)),
53
+ },
54
+ responsive: value.responsive
55
+ .map(normalizeEvidenceRefs)
56
+ .sort((left, right) => compareCanonicalStrings(left.decisionId, right.decisionId)),
57
+ scenarios: value.scenarios
58
+ .map((scenario) => ({ ...scenario, acceptanceIds: sortedUnique(scenario.acceptanceIds) }))
59
+ .sort((left, right) => compareCanonicalStrings(left.scenarioId, right.scenarioId)),
60
+ gaps: value.gaps
61
+ .map(normalizeEvidenceRefs)
62
+ .sort((left, right) => compareCanonicalStrings(left.gapId, right.gapId)),
63
+ acceptance: value.acceptance
64
+ .map(normalizeEvidenceRefs)
65
+ .sort((left, right) => compareCanonicalStrings(left.acceptanceId, right.acceptanceId)),
66
+ evidenceRefs: [...value.evidenceRefs].sort((left, right) =>
67
+ compareCanonicalStrings(left.evidenceId, right.evidenceId)
68
+ ),
69
+ };
70
+ }
71
+
72
+ export function normalizeFeatureProposalInputV1(
73
+ value: FeatureProposalInputV1
74
+ ): FeatureProposalInputV1 {
75
+ const parsed = featureProposalInputV1Schema.parse(value);
76
+ return normalizeCommon({
77
+ ...parsed,
78
+ questions: parsed.questions
79
+ .map((question) => ({ ...normalizeEvidenceRefs(question), options: [...question.options] }))
80
+ .sort((left, right) => compareCanonicalStrings(left.decisionId, right.decisionId)),
81
+ }) as FeatureProposalInputV1;
82
+ }
83
+
84
+ function proposalProjection(proposal: FeatureProposalInputV1): Record<string, unknown> {
85
+ return {
86
+ schema: "feature-revision.v1",
87
+ kind: proposal.kind,
88
+ planId: proposal.planId,
89
+ parentRevisionDigest: proposal.parentRevisionDigest,
90
+ pins: proposal.pins,
91
+ placement: proposal.placement,
92
+ componentUses: proposal.componentUses,
93
+ tokenUses: proposal.tokenUses,
94
+ patternUses: proposal.patternUses,
95
+ layout: proposal.layout,
96
+ responsive: proposal.responsive,
97
+ scenarios: proposal.scenarios,
98
+ gaps: proposal.gaps,
99
+ acceptance: proposal.acceptance,
100
+ questions: proposal.questions,
101
+ evidenceRefs: proposal.evidenceRefs,
102
+ };
103
+ }
104
+
105
+ export function featureProposalRevisionDigestV1(value: FeatureProposalInputV1): DigestHex {
106
+ const proposal = normalizeFeatureProposalInputV1(value);
107
+ return sha256Hex(canonicalPreimage(proposalProjection(proposal)));
108
+ }
109
+
110
+ export function buildFeatureProposalV1(value: FeatureProposalInputV1): FeatureProposalV1 {
111
+ const proposal = normalizeFeatureProposalInputV1(value);
112
+ const revisionDigest = sha256Hex(canonicalPreimage(proposalProjection(proposal)));
113
+ return featureProposalV1Schema.parse({
114
+ ...proposal,
115
+ revisionId: featureRevisionIdFromDigest(revisionDigest),
116
+ revisionDigest,
117
+ }) as FeatureProposalV1;
118
+ }
119
+
120
+ export function parseFeatureProposalV1(value: unknown): FeatureProposalV1 {
121
+ const parsed = featureProposalV1Schema.parse(value);
122
+ const { revisionId, revisionDigest, ...input } = parsed;
123
+ const rebuilt = buildFeatureProposalV1(input as FeatureProposalInputV1);
124
+ if (revisionId !== rebuilt.revisionId || revisionDigest !== rebuilt.revisionDigest) {
125
+ throw new TypeError("Feature proposal derived identity does not match its semantic projection");
126
+ }
127
+ return rebuilt;
128
+ }
129
+
130
+ export function normalizeApprovedFeatureManifestInputV1(
131
+ value: ApprovedFeatureManifestInputV1
132
+ ): ApprovedFeatureManifestInputV1 {
133
+ const parsed = approvedFeatureManifestInputV1Schema.parse(value);
134
+ return normalizeCommon({
135
+ ...parsed,
136
+ decisions: [...parsed.decisions].sort((left, right) =>
137
+ compareCanonicalStrings(left.decisionId, right.decisionId)
138
+ ),
139
+ approval: { ...parsed.approval },
140
+ validatedGrounding: { ...parsed.validatedGrounding },
141
+ }) as ApprovedFeatureManifestInputV1;
142
+ }
143
+
144
+ function manifestProjection(manifest: ApprovedFeatureManifestInputV1): Record<string, unknown> {
145
+ return {
146
+ schema: "feature-revision.v1",
147
+ kind: manifest.kind,
148
+ planId: manifest.planId,
149
+ parentRevisionDigest: manifest.parentRevisionDigest,
150
+ proposalRevisionDigest: manifest.proposalRevisionDigest,
151
+ pins: manifest.pins,
152
+ validatedGrounding: manifest.validatedGrounding,
153
+ placement: manifest.placement,
154
+ componentUses: manifest.componentUses,
155
+ tokenUses: manifest.tokenUses,
156
+ patternUses: manifest.patternUses,
157
+ layout: manifest.layout,
158
+ responsive: manifest.responsive,
159
+ scenarios: manifest.scenarios,
160
+ gaps: manifest.gaps,
161
+ acceptance: manifest.acceptance,
162
+ evidenceRefs: manifest.evidenceRefs,
163
+ decisions: manifest.decisions,
164
+ approval: {
165
+ approverUserId: manifest.approval.approverUserId,
166
+ decisionDigest: manifest.approval.decisionDigest,
167
+ expiresAt: manifest.approval.expiresAt,
168
+ },
169
+ };
170
+ }
171
+
172
+ export function approvedFeatureManifestRevisionDigestV1(
173
+ value: ApprovedFeatureManifestInputV1
174
+ ): DigestHex {
175
+ const manifest = normalizeApprovedFeatureManifestInputV1(value);
176
+ return sha256Hex(canonicalPreimage(manifestProjection(manifest)));
177
+ }
178
+
179
+ export function buildApprovedFeatureManifestV1(
180
+ value: ApprovedFeatureManifestInputV1
181
+ ): ApprovedFeatureManifestV1 {
182
+ const manifest = normalizeApprovedFeatureManifestInputV1(value);
183
+ const revisionDigest = sha256Hex(canonicalPreimage(manifestProjection(manifest)));
184
+ return approvedFeatureManifestV1Schema.parse({
185
+ ...manifest,
186
+ revisionId: featureRevisionIdFromDigest(revisionDigest),
187
+ revisionDigest,
188
+ }) as ApprovedFeatureManifestV1;
189
+ }
190
+
191
+ export function parseApprovedFeatureManifestV1(value: unknown): ApprovedFeatureManifestV1 {
192
+ const parsed = approvedFeatureManifestV1Schema.parse(value);
193
+ const { revisionId, revisionDigest, ...input } = parsed;
194
+ const rebuilt = buildApprovedFeatureManifestV1(input as ApprovedFeatureManifestInputV1);
195
+ if (revisionId !== rebuilt.revisionId || revisionDigest !== rebuilt.revisionDigest) {
196
+ throw new TypeError(
197
+ "Approved manifest derived identity does not match its semantic projection"
198
+ );
199
+ }
200
+ return rebuilt;
201
+ }
202
+
203
+ export const agentFeatureContextV1Schema = structuralAgentFeatureContextV1Schema.superRefine(
204
+ (value, context) => {
205
+ const bytes = new TextEncoder().encode(canonicalPreimage(value)).byteLength;
206
+ if (bytes > AGENT_FEATURE_CONTEXT_MAX_BYTES_V1) {
207
+ context.addIssue({
208
+ code: "custom",
209
+ message: `Agent feature context exceeds ${AGENT_FEATURE_CONTEXT_MAX_BYTES_V1} UTF-8 bytes`,
210
+ });
211
+ }
212
+ }
213
+ );
214
+
215
+ export function parseAgentFeatureContextV1(value: unknown): AgentFeatureContextV1 {
216
+ return agentFeatureContextV1Schema.parse(value) as AgentFeatureContextV1;
217
+ }