@brandlab-digital/conformance 1.0.0-rc.2 → 1.0.0-rc.21

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 (48) hide show
  1. package/dist/analytics-contract.d.ts +20 -0
  2. package/dist/analytics-contract.d.ts.map +1 -0
  3. package/dist/analytics-contract.js +211 -0
  4. package/dist/analytics-contract.js.map +1 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +91 -20
  8. package/dist/cli.js.map +1 -1
  9. package/dist/project-config-v3.d.ts +9 -1
  10. package/dist/project-config-v3.d.ts.map +1 -1
  11. package/dist/project-config-v3.js +246 -37
  12. package/dist/project-config-v3.js.map +1 -1
  13. package/dist/project-config.d.ts +1 -1
  14. package/dist/project-config.d.ts.map +1 -1
  15. package/dist/project-config.js +1 -1
  16. package/dist/project-config.js.map +1 -1
  17. package/dist/project-documentation.d.ts +28 -0
  18. package/dist/project-documentation.d.ts.map +1 -0
  19. package/dist/project-documentation.js +398 -0
  20. package/dist/project-documentation.js.map +1 -0
  21. package/dist/project.d.ts +9 -2
  22. package/dist/project.d.ts.map +1 -1
  23. package/dist/project.js +290 -44
  24. package/dist/project.js.map +1 -1
  25. package/dist/release-catalog.d.ts.map +1 -1
  26. package/dist/release-catalog.js +1405 -0
  27. package/dist/release-catalog.js.map +1 -1
  28. package/dist/release-contract-compatibility.d.ts +133 -0
  29. package/dist/release-contract-compatibility.d.ts.map +1 -1
  30. package/dist/release-contract-compatibility.js +133 -0
  31. package/dist/release-contract-compatibility.js.map +1 -1
  32. package/dist/release-manifest.d.ts +1 -0
  33. package/dist/release-manifest.d.ts.map +1 -1
  34. package/dist/release-manifest.js +7 -0
  35. package/dist/release-manifest.js.map +1 -1
  36. package/dist/rendered-content.d.ts +38 -0
  37. package/dist/rendered-content.d.ts.map +1 -0
  38. package/dist/rendered-content.js +149 -0
  39. package/dist/rendered-content.js.map +1 -0
  40. package/dist/source-safety-contracts.d.ts +1 -1
  41. package/dist/source-safety-contracts.d.ts.map +1 -1
  42. package/dist/source-safety-contracts.js +5 -1
  43. package/dist/source-safety-contracts.js.map +1 -1
  44. package/dist/verification-policy.d.ts +99 -0
  45. package/dist/verification-policy.d.ts.map +1 -0
  46. package/dist/verification-policy.js +219 -0
  47. package/dist/verification-policy.js.map +1 -0
  48. package/package.json +20 -2
@@ -7,6 +7,8 @@ import { createHash } from "node:crypto";
7
7
  import { extname, join, relative } from "node:path";
8
8
  import * as ts from "typescript";
9
9
  export const BRANDLAB_CONFIG_V3_RULESET_ID = "project-config-v3@1.0.0-rc.2";
10
+ export const BRANDLAB_CONFIG_V4_RULESET_ID = "project-config-v4@1.0.0-rc.12";
11
+ export const BRANDLAB_CONFIG_V5_RULESET_ID = "project-config-v5@1.0.0-rc.19";
10
12
  export const BRANDLAB_CONFIG_V3_PROFILES = [
11
13
  "next-local-service",
12
14
  "next-root-level",
@@ -32,6 +34,8 @@ const LOCATION_STRATEGIES = new Set([
32
34
  "multi-location",
33
35
  ]);
34
36
  const REVIEWS_PROVIDERS = new Set(["google-places", "project-owned"]);
37
+ const GOOGLE_REVIEWS_MODES = new Set(["link-only", "summary", "full"]);
38
+ const MAXIMUM_GOOGLE_REVIEWS_MONTHLY_REQUEST_LIMIT = 100_000;
35
39
  const ROOT_CONFIG_KEYS = new Set([
36
40
  "projectId",
37
41
  "profile",
@@ -181,6 +185,54 @@ const FORBIDDEN_CANDIDATE_EXPORTS = [
181
185
  },
182
186
  ];
183
187
  const PROTECTED_LOCAL_PATTERNS = [
188
+ {
189
+ ruleId: "brandlab-config-v3-circular-business-evidence",
190
+ minimumContract: "project-config-v5",
191
+ surface: "business",
192
+ pattern: /\b(?:existing-production-config|repository-config|source-code):/i,
193
+ expected: "Verified business facts cite independent evidence, not the repository value being verified.",
194
+ remediation: "Use structured official-registry, GBP, client-document, first-party-record or public-authority evidence; otherwise mark the fact unknown.",
195
+ },
196
+ {
197
+ ruleId: "brandlab-config-v3-service-area-public-address",
198
+ minimumContract: "project-config-v5",
199
+ surface: "business",
200
+ pattern: /\bmodel\s*:\s*["']service-area["'][\s\S]{0,1200}\baddressVisibility\s*:\s*["']public["']|\baddressVisibility\s*:\s*["']public["'][\s\S]{0,1200}\bmodel\s*:\s*["']service-area["']/i,
201
+ expected: "Customer-location-only service-area businesses keep address facts hidden.",
202
+ remediation: "Use model service-area, addressVisibility hidden and an unknown or not-applicable address fact.",
203
+ },
204
+ {
205
+ ruleId: "brandlab-config-v3-synthetic-review-proof",
206
+ minimumContract: "project-config-v5",
207
+ surface: "location-proof",
208
+ pattern: /\bregion-review:[^"'`\n]*[\s\S]{0,2400}\b(?:review\?\.\w+\s*\|\||trust\s+\w+|customer feedback across)\b/i,
209
+ expected: "Review proof exists only when an approved review record is bound to business.proof.",
210
+ remediation: "Remove fallback review proof and use verified review evidence or no review proof.",
211
+ },
212
+ {
213
+ ruleId: "brandlab-config-v3-local-programmatic-proof-derivation",
214
+ minimumContract: "project-config-v5",
215
+ surface: "location-proof",
216
+ pattern: /\bfunction\s+getProofLevelFromAssetIds\s*\(/,
217
+ expected: "Programmatic proof levels are derived by @brandlab-digital/site-core/locations.",
218
+ remediation: "Remove the project-local proof classifier and declare an explicit defineLocationRouteDecision result.",
219
+ },
220
+ {
221
+ ruleId: "brandlab-config-v3-hardcoded-search-demand",
222
+ minimumContract: "project-config-v5",
223
+ surface: "location-quality",
224
+ pattern: /\bsearchDemand\s*:\s*["']medium["']/,
225
+ expected: "Search demand is observed from evidence or explicitly unknown.",
226
+ remediation: "Replace the assumed medium value with unknown or an evidence-backed observed record.",
227
+ },
228
+ {
229
+ ruleId: "brandlab-config-v3-hidden-focusable-content",
230
+ minimumContract: "project-config-v5",
231
+ surface: "accessibility",
232
+ pattern: /\baria-hidden\s*=\s*(?:\{\s*true\s*\}|["']true["'])[\s\S]{0,1200}<\s*(?:a|button|input|select|textarea)\b/i,
233
+ expected: "Collapsed or aria-hidden content contains no focusable controls.",
234
+ remediation: "Remove collapsed controls from the DOM or make every descendant unfocusable until the content is visible.",
235
+ },
184
236
  {
185
237
  ruleId: "brandlab-config-v3-local-contact-transport",
186
238
  surface: "contact",
@@ -193,6 +245,34 @@ const PROTECTED_LOCAL_PATTERNS = [
193
245
  pattern: /\b(?:class|function|const|let|var)\s+(?:ContactFormEmail|renderContactEmail)\b/,
194
246
  expected: "Contact email rendering is owned by @brandlab-digital/site-next/contact/email.",
195
247
  },
248
+ {
249
+ ruleId: "brandlab-config-v3-local-google-reviews-fetch",
250
+ minimumContract: "project-config-v4",
251
+ surface: "google-reviews",
252
+ pattern: /\bplaces\.googleapis\.com\/v1\/places\b/,
253
+ expected: "Google Places fetching is owned by @brandlab-digital/site-next/google-reviews/server.",
254
+ },
255
+ {
256
+ ruleId: "brandlab-config-v3-local-google-reviews-renderer",
257
+ minimumContract: "project-config-v4",
258
+ surface: "google-reviews",
259
+ pattern: /@brandlab-digital\/site-core\/google-reviews[\s\S]{0,12000}\breviews\.map\s*\(/,
260
+ expected: "Google review content and attribution are rendered by the shared policy-complete UI.",
261
+ },
262
+ {
263
+ ruleId: "brandlab-config-v3-hardcoded-google-review-total",
264
+ minimumContract: "project-config-v4",
265
+ surface: "google-reviews",
266
+ pattern: /["'`][^"'`\n]{0,100}\b\d{1,6}\s+Google\s+(?:reviews?|ratings?)\b/i,
267
+ expected: "Rendered Google rating and review totals come from a live no-store shared snapshot.",
268
+ },
269
+ {
270
+ ruleId: "brandlab-config-v3-public-google-review-cache",
271
+ minimumContract: "project-config-v4",
272
+ surface: "google-reviews",
273
+ pattern: /\b(?:googleReviews?|userRatingCount)\b[\s\S]{0,2000}\b(?:s-maxage|stale-while-revalidate)\b|\b(?:s-maxage|stale-while-revalidate)\b[\s\S]{0,2000}\b(?:googleReviews?|userRatingCount)\b/i,
274
+ expected: "Google Places content is returned with private no-store headers and is never publicly cached.",
275
+ },
196
276
  {
197
277
  ruleId: "brandlab-config-v3-local-lead-event",
198
278
  surface: "analytics",
@@ -207,7 +287,8 @@ const PROTECTED_LOCAL_PATTERNS = [
207
287
  expected: "Breadcrumb labels and hierarchy derive from the semantic route catalog.",
208
288
  },
209
289
  ];
210
- export function inspectBrandlabConfigV3(root) {
290
+ export function inspectBrandlabConfigV3(root, options = {}) {
291
+ const contractVersion = options.contractVersion ?? "project-config-v5";
211
292
  const issues = [];
212
293
  const configPath = join(root, "brandlab.config.ts");
213
294
  const usage = collectProjectImports(root, issues);
@@ -280,11 +361,11 @@ export function inspectBrandlabConfigV3(root) {
280
361
  }));
281
362
  return { ...usage, config: null, issues };
282
363
  }
283
- const config = validateConfigValue(value, issues);
364
+ const config = validateConfigValue(value, issues, contractVersion);
284
365
  if (config) {
285
- addImportContractIssues(root, config, usage, issues);
366
+ addImportContractIssues(root, config, usage, issues, contractVersion);
286
367
  }
287
- addProtectedImplementationIssues(root, issues);
368
+ addProtectedImplementationIssues(root, issues, contractVersion);
288
369
  return {
289
370
  config,
290
371
  imports,
@@ -382,7 +463,7 @@ function propertyName(name) {
382
463
  }
383
464
  return null;
384
465
  }
385
- function validateConfigValue(value, issues) {
466
+ function validateConfigValue(value, issues, contractVersion) {
386
467
  if (!isRecord(value)) {
387
468
  issues.push(configIssue({
388
469
  ruleId: "brandlab-config-v3-object",
@@ -537,7 +618,7 @@ function validateConfigValue(value, issues) {
537
618
  }));
538
619
  }
539
620
  else {
540
- validateCapabilities(capabilities, issues);
621
+ validateCapabilities(capabilities, issues, contractVersion);
541
622
  }
542
623
  validateSitemap(value.sitemap, issues);
543
624
  validateExceptions(value.exceptions, issues);
@@ -549,6 +630,13 @@ function validateConfigValue(value, issues) {
549
630
  const locations = isRecord(safeCapabilities.locations) ? safeCapabilities.locations : null;
550
631
  const reviews = isRecord(safeCapabilities.reviews) ? safeCapabilities.reviews : null;
551
632
  const reviewsProvider = typeof reviews?.provider === "string" ? reviews.provider : null;
633
+ const reviewsMode = typeof reviews?.mode === "string" ? reviews.mode : null;
634
+ const reviewsRouteIds = Array.isArray(reviews?.routeIds)
635
+ ? reviews.routeIds.filter((entry) => typeof entry === "string")
636
+ : [];
637
+ const reviewsMonthlyRequestLimit = typeof reviews?.monthlyRequestLimit === "number"
638
+ ? reviews.monthlyRequestLimit
639
+ : null;
552
640
  if (reviewsProvider === "google-places" && !families.includes("legal")) {
553
641
  issues.push(configIssue({
554
642
  ruleId: "brandlab-config-v3-google-places-legal-family",
@@ -571,6 +659,9 @@ function validateConfigValue(value, issues) {
571
659
  contactEnabled: contact !== null,
572
660
  locationStrategy: typeof locations?.strategy === "string" ? locations.strategy : null,
573
661
  reviewsProvider,
662
+ reviewsMode,
663
+ reviewsRouteIds,
664
+ reviewsMonthlyRequestLimit,
574
665
  },
575
666
  };
576
667
  }
@@ -647,7 +738,7 @@ function validateAliases(value, issues) {
647
738
  }));
648
739
  }
649
740
  }
650
- function validateCapabilities(capabilities, issues) {
741
+ function validateCapabilities(capabilities, issues, contractVersion) {
651
742
  addUnknownKeyIssues(capabilities, new Set(["blog", "contact", "locations", "reviews"]), new Set(), "capabilities", issues);
652
743
  if (capabilities.blog !== undefined) {
653
744
  if (!isRecord(capabilities.blog)) {
@@ -717,7 +808,10 @@ function validateCapabilities(capabilities, issues) {
717
808
  addShapeIssue("reviews", issues);
718
809
  }
719
810
  else {
720
- addUnknownKeyIssues(capabilities.reviews, new Set(["provider"]), new Set(), "capabilities.reviews", issues);
811
+ addUnknownKeyIssues(capabilities.reviews, capabilities.reviews.provider === "google-places" &&
812
+ contractVersion !== "project-config-v3"
813
+ ? new Set(["provider", "mode", "routeIds", "monthlyRequestLimit"])
814
+ : new Set(["provider"]), new Set(), "capabilities.reviews", issues);
721
815
  if (typeof capabilities.reviews.provider !== "string" ||
722
816
  !REVIEWS_PROVIDERS.has(capabilities.reviews.provider)) {
723
817
  issues.push(configIssue({
@@ -728,6 +822,64 @@ function validateCapabilities(capabilities, issues) {
728
822
  path: "brandlab.config.ts",
729
823
  }));
730
824
  }
825
+ if (capabilities.reviews.provider === "google-places" &&
826
+ contractVersion !== "project-config-v3") {
827
+ if (typeof capabilities.reviews.mode !== "string" ||
828
+ !GOOGLE_REVIEWS_MODES.has(capabilities.reviews.mode)) {
829
+ issues.push(configIssue({
830
+ ruleId: "brandlab-config-v3-google-reviews-mode",
831
+ expected: `One Google reviews mode: ${[...GOOGLE_REVIEWS_MODES].join(", ")}.`,
832
+ actual: `reviews.mode is ${JSON.stringify(capabilities.reviews.mode)}.`,
833
+ remediation: "Select link-only, summary or full explicitly.",
834
+ path: "brandlab.config.ts",
835
+ }));
836
+ }
837
+ const routeIds = Array.isArray(capabilities.reviews.routeIds)
838
+ ? capabilities.reviews.routeIds.filter((entry) => typeof entry === "string")
839
+ : [];
840
+ if (!Array.isArray(capabilities.reviews.routeIds) ||
841
+ routeIds.length === 0 ||
842
+ routeIds.length !== capabilities.reviews.routeIds.length ||
843
+ routeIds.some((routeId) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(routeId))) {
844
+ issues.push(configIssue({
845
+ ruleId: "brandlab-config-v3-google-reviews-routes",
846
+ expected: "At least one unique lowercase kebab-case review placement route ID.",
847
+ actual: `reviews.routeIds is ${JSON.stringify(capabilities.reviews.routeIds)}.`,
848
+ remediation: "Declare only the route IDs where Google review proof is intentionally displayed.",
849
+ path: "brandlab.config.ts",
850
+ }));
851
+ }
852
+ else {
853
+ addDuplicateIssue(routeIds, "brandlab-config-v3-google-reviews-route-duplicate", "reviews.routeIds", issues);
854
+ }
855
+ if (capabilities.reviews.mode === "link-only") {
856
+ if (capabilities.reviews.monthlyRequestLimit !== undefined) {
857
+ issues.push(configIssue({
858
+ ruleId: "brandlab-config-v3-google-reviews-link-only-quota",
859
+ expected: "No API request limit for link-only review proof.",
860
+ actual: "reviews.monthlyRequestLimit is configured for link-only mode.",
861
+ remediation: "Remove monthlyRequestLimit because link-only mode never calls Places.",
862
+ path: "brandlab.config.ts",
863
+ }));
864
+ }
865
+ }
866
+ else if (capabilities.reviews.mode === "summary" ||
867
+ capabilities.reviews.mode === "full") {
868
+ const limit = capabilities.reviews.monthlyRequestLimit;
869
+ if (typeof limit !== "number" ||
870
+ !Number.isSafeInteger(limit) ||
871
+ limit < 1 ||
872
+ limit > MAXIMUM_GOOGLE_REVIEWS_MONTHLY_REQUEST_LIMIT) {
873
+ issues.push(configIssue({
874
+ ruleId: "brandlab-config-v3-google-reviews-quota",
875
+ expected: `monthlyRequestLimit from 1 to ${MAXIMUM_GOOGLE_REVIEWS_MONTHLY_REQUEST_LIMIT}.`,
876
+ actual: `reviews.monthlyRequestLimit is ${JSON.stringify(limit)}.`,
877
+ remediation: "Set an explicit project-level monthly request ceiling.",
878
+ path: "brandlab.config.ts",
879
+ }));
880
+ }
881
+ }
882
+ }
731
883
  }
732
884
  }
733
885
  }
@@ -905,7 +1057,7 @@ function isIsoDate(value) {
905
1057
  return false;
906
1058
  return !Number.isNaN(Date.parse(value));
907
1059
  }
908
- function addImportContractIssues(root, config, usage, issues) {
1060
+ function addImportContractIssues(root, config, usage, issues, contractVersion) {
909
1061
  if (config.profile === "legacy-php") {
910
1062
  const nextEvidence = detectNextPackageBackedEvidence(root, usage);
911
1063
  if (nextEvidence.length === 0)
@@ -935,7 +1087,7 @@ function addImportContractIssues(root, config, usage, issues) {
935
1087
  requireStrictUsage({
936
1088
  moduleName: "@brandlab-digital/site-core/locations",
937
1089
  surface: "location-proof",
938
- exports: ["defineLocationRouteQuality"],
1090
+ exports: ["defineLocationRouteDecision", "defineLocationRouteQuality"],
939
1091
  }, usage, issues);
940
1092
  }
941
1093
  const contactEvidence = detectContactSurfaceEvidence(root, usage);
@@ -962,16 +1114,30 @@ function addImportContractIssues(root, config, usage, issues) {
962
1114
  }));
963
1115
  }
964
1116
  if (contactRequired) {
965
- requireStrictUsage({
966
- moduleName: "@brandlab-digital/site-next/contact/production",
967
- surface: "contact-production",
968
- exports: ["createBrandlabProductionContactRuntime"],
969
- }, usage, issues);
970
- requireStrictUsage({
971
- moduleName: "@brandlab-digital/site-next/contact/client",
972
- surface: "contact-client",
973
- exports: ["createContactHttpSubmissionController"],
974
- }, usage, issues);
1117
+ requireAnyStrictUsage([
1118
+ {
1119
+ moduleName: "@brandlab-digital/site-next/contact/server",
1120
+ surface: "contact-production",
1121
+ exports: ["createBrandlabProductionContactRuntime"],
1122
+ },
1123
+ {
1124
+ moduleName: "@brandlab-digital/site-next/contact/production",
1125
+ surface: "contact-production",
1126
+ exports: ["createBrandlabProductionContactRuntime"],
1127
+ },
1128
+ ], usage, issues);
1129
+ requireAnyStrictUsage([
1130
+ {
1131
+ moduleName: "@brandlab-digital/site-next/contact",
1132
+ surface: "contact-client",
1133
+ exports: ["createContactHttpSubmissionController"],
1134
+ },
1135
+ {
1136
+ moduleName: "@brandlab-digital/site-next/contact/client",
1137
+ surface: "contact-client",
1138
+ exports: ["createContactHttpSubmissionController"],
1139
+ },
1140
+ ], usage, issues);
975
1141
  }
976
1142
  if (config.capabilities.blogAdapter && config.capabilities.blogAdapter !== "none") {
977
1143
  const adapterExport = config.capabilities.blogAdapter === "mdx"
@@ -984,16 +1150,37 @@ function addImportContractIssues(root, config, usage, issues) {
984
1150
  }, usage, issues);
985
1151
  }
986
1152
  if (config.capabilities.reviewsProvider === "google-places") {
987
- requireStrictUsage({
988
- moduleName: "@brandlab-digital/site-next/google-reviews",
989
- surface: "google-reviews-route",
990
- exports: ["createGooglePlaceReviewsRouteResponse"],
991
- }, usage, issues);
992
- requireStrictUsage({
993
- moduleName: "@brandlab-digital/site-next/google-reviews",
994
- surface: "google-reviews-display",
995
- exports: ["GooglePlaceReviews"],
996
- }, usage, issues);
1153
+ if (contractVersion === "project-config-v3") {
1154
+ requireStrictUsage({
1155
+ moduleName: "@brandlab-digital/site-next/google-reviews",
1156
+ surface: "google-reviews-route",
1157
+ exports: ["createGooglePlaceReviewsRouteResponse"],
1158
+ }, usage, issues);
1159
+ requireStrictUsage({
1160
+ moduleName: "@brandlab-digital/site-next/google-reviews",
1161
+ surface: "google-reviews-display",
1162
+ exports: ["GooglePlaceReviews"],
1163
+ }, usage, issues);
1164
+ }
1165
+ else if (config.capabilities.reviewsMode === "link-only") {
1166
+ requireStrictUsage({
1167
+ moduleName: "@brandlab-digital/site-next/google-reviews/ui",
1168
+ surface: "google-reviews-link",
1169
+ exports: ["GoogleReviewsLink"],
1170
+ }, usage, issues);
1171
+ }
1172
+ else {
1173
+ requireStrictUsage({
1174
+ moduleName: "@brandlab-digital/site-next/google-reviews/server",
1175
+ surface: "google-reviews-production",
1176
+ exports: ["createBrandlabGooglePlaceReviewsProductionRuntime"],
1177
+ }, usage, issues);
1178
+ requireStrictUsage({
1179
+ moduleName: "@brandlab-digital/site-next/google-reviews/client",
1180
+ surface: "google-reviews-display",
1181
+ exports: ["GooglePlaceReviewsClient"],
1182
+ }, usage, issues);
1183
+ }
997
1184
  }
998
1185
  else if (config.capabilities.reviewsProvider === "project-owned") {
999
1186
  requireStrictUsage({
@@ -1002,7 +1189,7 @@ function addImportContractIssues(root, config, usage, issues) {
1002
1189
  exports: ["preparePublicReviews"],
1003
1190
  }, usage, issues);
1004
1191
  }
1005
- else if (usage.imports.has("@brandlab-digital/site-next/google-reviews")) {
1192
+ else if ([...usage.imports].some((name) => name.startsWith("@brandlab-digital/site-next/google-reviews"))) {
1006
1193
  issues.push(configIssue({
1007
1194
  ruleId: "brandlab-config-v3-google-reviews-capability",
1008
1195
  surface: "google-reviews",
@@ -1069,7 +1256,8 @@ function detectNextPackageBackedEvidence(root, usage) {
1069
1256
  function detectContactSurfaceEvidence(root, usage) {
1070
1257
  const evidence = new Set();
1071
1258
  for (const moduleName of usage.imports) {
1072
- if (moduleName.startsWith("@brandlab-digital/site-next/contact/")) {
1259
+ if (moduleName === "@brandlab-digital/site-next/contact" ||
1260
+ moduleName.startsWith("@brandlab-digital/site-next/contact/")) {
1073
1261
  evidence.add(`shared contact import ${moduleName}`);
1074
1262
  }
1075
1263
  }
@@ -1153,6 +1341,13 @@ function requireStrictUsage(requirement, usage, issues) {
1153
1341
  }));
1154
1342
  }
1155
1343
  }
1344
+ function requireAnyStrictUsage(requirements, usage, issues) {
1345
+ const selected = requirements.find((requirement) => usage.imports.has(requirement.moduleName)) ??
1346
+ requirements[0];
1347
+ if (!selected)
1348
+ return;
1349
+ requireStrictUsage(selected, usage, issues);
1350
+ }
1156
1351
  function frameworkOwnerExpectation(owner) {
1157
1352
  if (owner === "metadata") {
1158
1353
  return "The strict metadata builder reaches metadata or generateMetadata in an App Router page or layout entrypoint.";
@@ -1328,7 +1523,8 @@ function collectProjectImports(root, issues) {
1328
1523
  return;
1329
1524
  executableImports.add(binding.moduleName);
1330
1525
  addExportUsage(executableExports, binding.moduleName, binding.exportName);
1331
- const isSharedContactClientController = binding.moduleName === "@brandlab-digital/site-next/contact/client" &&
1526
+ const isSharedContactClientController = (binding.moduleName === "@brandlab-digital/site-next/contact" ||
1527
+ binding.moduleName === "@brandlab-digital/site-next/contact/client") &&
1332
1528
  binding.exportName === "createContactHttpSubmissionController";
1333
1529
  if (isOutputOwned(node) ||
1334
1530
  (isSharedContactClientController && isContactClientRuntimeOwned(node))) {
@@ -2752,11 +2948,14 @@ function isRuntimeIdentifierReference(identifier) {
2752
2948
  }
2753
2949
  return true;
2754
2950
  }
2755
- function addProtectedImplementationIssues(root, issues) {
2951
+ function addProtectedImplementationIssues(root, issues, contractVersion) {
2756
2952
  for (const file of walkProjectRuntimeFiles(root)) {
2757
2953
  const relativePath = normalizePath(relative(root, file));
2758
2954
  const source = readFileSync(file, "utf8");
2759
2955
  for (const rule of PROTECTED_LOCAL_PATTERNS) {
2956
+ if ("minimumContract" in rule &&
2957
+ !configurationContractIncludes(contractVersion, rule.minimumContract))
2958
+ continue;
2760
2959
  const match = rule.pattern.exec(source);
2761
2960
  if (!match || match.index === undefined)
2762
2961
  continue;
@@ -2766,9 +2965,11 @@ function addProtectedImplementationIssues(root, issues) {
2766
2965
  surface: rule.surface,
2767
2966
  expected: rule.expected,
2768
2967
  actual: `A protected local implementation was found in ${relativePath}.`,
2769
- remediation: rule.ruleId === "brandlab-config-v3-url-derived-breadcrumbs"
2770
- ? "Use BrandlabRouteBreadcrumbs with the typed route catalog; keep path labels only as a development fallback."
2771
- : "Keep only the thin project binding and consume the shared runtime.",
2968
+ remediation: "remediation" in rule
2969
+ ? rule.remediation
2970
+ : rule.ruleId === "brandlab-config-v3-url-derived-breadcrumbs"
2971
+ ? "Use BrandlabRouteBreadcrumbs with the typed route catalog; keep path labels only as a development fallback."
2972
+ : "Keep only the thin project binding and consume the shared runtime.",
2772
2973
  severity: "severity" in rule ? rule.severity : "blocker",
2773
2974
  path: relativePath,
2774
2975
  line,
@@ -2776,6 +2977,14 @@ function addProtectedImplementationIssues(root, issues) {
2776
2977
  }
2777
2978
  }
2778
2979
  }
2980
+ function configurationContractIncludes(actual, minimum) {
2981
+ const order = {
2982
+ "project-config-v3": 3,
2983
+ "project-config-v4": 4,
2984
+ "project-config-v5": 5,
2985
+ };
2986
+ return order[actual] >= order[minimum];
2987
+ }
2779
2988
  function walkSourceFiles(root) {
2780
2989
  if (!existsSync(root))
2781
2990
  return [];