@brandlab-digital/conformance 0.1.0-alpha.8 → 1.0.0-rc.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.
package/dist/vitest.js CHANGED
@@ -1,69 +1,519 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join, relative } from 'node:path';
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
3
  const DEFAULT_EXTENSIONS = new Set([
4
- '.ts',
5
- '.tsx',
6
- '.js',
7
- '.jsx',
8
- '.md',
9
- '.mdx',
10
- '.json',
4
+ ".ts",
5
+ ".tsx",
6
+ ".js",
7
+ ".jsx",
8
+ ".md",
9
+ ".mdx",
10
+ ".json",
11
+ ]);
12
+ const DEFAULT_IGNORED_DIRS = new Set([
13
+ "node_modules",
14
+ ".next",
15
+ ".vercel",
16
+ "dist",
17
+ "coverage",
11
18
  ]);
12
- const DEFAULT_IGNORED_DIRS = new Set(['node_modules', '.next', 'dist', 'coverage']);
13
19
  const ARCHITECTURE_PROFILES = new Set([
14
- 'dynamic-data-driven',
15
- 'root-level-data-driven',
16
- 'static-route-heavy-legacy',
20
+ "dynamic-data-driven",
21
+ "root-level-data-driven",
22
+ "static-route-heavy-legacy",
17
23
  ]);
18
- const ARCHITECTURE_STATUSES = new Set(['standard', 'legacy-exception']);
24
+ const ARCHITECTURE_STATUSES = new Set(["standard", "legacy-exception"]);
19
25
  const DEFAULT_STATIC_ROUTE_FILE_LIMIT = 50;
20
- const DEFAULT_LARGE_ROUTE_FILE_LINE_LIMIT = 220;
26
+ const DEFAULT_LARGE_ROUTE_FILE_LINE_LIMIT = 150;
27
+ const DEFAULT_ROUTE_FILE_CONTENT_CHAR_LIMIT = 500;
28
+ const DEFAULT_COMPONENT_CONTENT_CHAR_LIMIT = 1400;
21
29
  const DEFAULT_TOPOLOGY_ALLOWED_ROOT_DIRS = [
22
- 'app',
23
- 'components',
24
- 'config',
25
- 'content',
26
- 'data',
27
- 'lib',
30
+ "app",
31
+ "components",
32
+ "config",
33
+ "content",
34
+ "data",
35
+ "lib",
28
36
  ];
29
37
  const DEFAULT_TOPOLOGY_ALLOWED_COMPONENT_DIRS = [
30
- 'analytics',
31
- 'contact',
32
- 'layout',
33
- 'sections',
34
- 'templates',
35
- 'ui',
38
+ "analytics",
39
+ "contact",
40
+ "layout",
41
+ "sections",
42
+ "templates",
43
+ "ui",
36
44
  ];
37
45
  const DEFAULT_TOPOLOGY_ALLOWED_LIB_DIRS = [
38
- 'analytics',
39
- 'contact',
40
- 'content',
41
- 'media',
42
- 'seo',
46
+ "analytics",
47
+ "contact",
48
+ "content",
49
+ "seo",
50
+ ];
51
+ const DEFAULT_TOPOLOGY_ALLOWED_TYPE_FILES = [];
52
+ const STANDARD_PACKAGE_MANAGER = "npm@11.14.1";
53
+ const STANDARD_NEXT_BASELINE = {
54
+ baseline: "2026-07",
55
+ nextVersion: "16.2.9",
56
+ reactVersion: "19.2.7",
57
+ reactDomVersion: "19.2.7",
58
+ node: "24.x",
59
+ minimumNode: "20.9.0",
60
+ router: "app",
61
+ bundler: "turbopack",
62
+ cacheComponents: false,
63
+ analyzeBundleScript: "next experimental-analyze",
64
+ imageQualities: [75, 90],
65
+ };
66
+ const STANDARD_TYPESCRIPT_TARGET = "ES2022";
67
+ const CONTENT_ARRAY_NAMES = [
68
+ "faqs",
69
+ "faqItems",
70
+ "testimonials",
71
+ "defaultTestimonials",
72
+ "features",
73
+ "defaultFeatures",
74
+ "serviceFeatures",
75
+ "advantages",
76
+ "processSteps",
77
+ "seoContent",
78
+ "featuredServices",
79
+ "locationHighlights",
80
+ ];
81
+ const CONTENT_JSX_ARRAY_PROP_NAMES = [
82
+ "faqs",
83
+ "faqItems",
84
+ "testimonials",
85
+ "features",
86
+ "items",
87
+ "cards",
88
+ "stats",
89
+ ];
90
+ const ROUTE_OWNED_COPY_PROPERTY_NAMES = [
91
+ "alt",
92
+ "ariaLabel",
93
+ "bio",
94
+ "buttonLabel",
95
+ "buttonText",
96
+ "contactButtonText",
97
+ "description",
98
+ "emptyMessage",
99
+ "heading",
100
+ "keywords",
101
+ "loadingText",
102
+ "mapLabel",
103
+ "message",
104
+ "metaDescription",
105
+ "metaKeywords",
106
+ "metaTitle",
107
+ "pageLabel",
108
+ "placeholder",
109
+ "quoteButtonText",
110
+ "reviewButtonLabel",
111
+ "schemaDescription",
112
+ "service",
113
+ "submitButtonText",
114
+ "subtitle",
115
+ "tag",
116
+ "text",
117
+ "title",
118
+ ];
119
+ const COMPONENT_COPY_OWNERSHIP_ROOTS = [
120
+ "src/components/contact/",
121
+ "src/components/layout/",
122
+ "src/components/sections/",
123
+ "src/components/templates/",
124
+ ];
125
+ const COMPONENT_COPY_ALLOWED_VALUES = new Set([
126
+ "Back to top",
127
+ "Close image gallery",
128
+ "Close modal",
129
+ "Close quote modal",
130
+ "Collapse section",
131
+ "Email *",
132
+ "Expand section",
133
+ "How can we help",
134
+ "Maximum 5 images",
135
+ "Message sent successfully.",
136
+ "Message sent successfully! Redirecting...",
137
+ "Mobile *",
138
+ "Name *",
139
+ "Sending your message...",
140
+ "Share on Facebook",
141
+ "Share on LinkedIn",
142
+ "Share on X",
143
+ "Share via Email",
144
+ "Site navigation",
145
+ "Suburb *",
146
+ "View next image",
147
+ "View previous image",
148
+ ]);
149
+ const CONTENT_ARRAY_PATTERN = new RegExp(`\\b(?:const|let|var)\\s+(?:${CONTENT_ARRAY_NAMES.join("|")})\\s*(?::[^=]+)?=\\s*\\[`, "m");
150
+ const CONTENT_JSX_ARRAY_PROP_PATTERN = new RegExp(`\\b(?:${CONTENT_JSX_ARRAY_PROP_NAMES.join("|")})\\s*=\\s*\\{\\s*\\[`, "m");
151
+ const REQUIRED_STANDARD_FILES = [
152
+ { path: "src/app/robots.ts", code: "brandlab-architecture-required-robots" },
153
+ {
154
+ path: "src/app/sitemap.ts",
155
+ code: "brandlab-architecture-required-sitemap",
156
+ },
157
+ {
158
+ path: "src/app/manifest.ts",
159
+ code: "brandlab-architecture-required-manifest",
160
+ },
161
+ {
162
+ path: "src/app/error.tsx",
163
+ code: "brandlab-architecture-required-error-boundary",
164
+ },
165
+ {
166
+ path: "src/app/global-error.tsx",
167
+ code: "brandlab-architecture-required-global-error-boundary",
168
+ },
169
+ {
170
+ path: "src/config/business.ts",
171
+ code: "brandlab-architecture-required-business-config",
172
+ },
173
+ {
174
+ path: "src/config/env.d.ts",
175
+ code: "brandlab-architecture-required-env-types",
176
+ },
177
+ {
178
+ path: "src/lib/contact/actions.ts",
179
+ code: "brandlab-architecture-required-contact-actions",
180
+ },
181
+ {
182
+ path: "src/lib/contact/ContactFormEmail.tsx",
183
+ code: "brandlab-architecture-required-contact-email-template",
184
+ },
185
+ {
186
+ path: "src/lib/contact/attachments.ts",
187
+ code: "brandlab-architecture-required-contact-attachments",
188
+ },
189
+ {
190
+ path: "src/lib/contact/client.ts",
191
+ code: "brandlab-architecture-required-contact-client-entrypoint",
192
+ },
193
+ {
194
+ path: "src/lib/contact/constants.ts",
195
+ code: "brandlab-architecture-required-contact-constants",
196
+ },
197
+ {
198
+ path: "src/lib/contact/rateLimit.ts",
199
+ code: "brandlab-architecture-required-contact-rate-limit",
200
+ },
201
+ {
202
+ path: "src/lib/contact/schema.ts",
203
+ code: "brandlab-architecture-required-contact-schema",
204
+ },
205
+ {
206
+ path: "src/lib/contact/tracking.ts",
207
+ code: "brandlab-architecture-required-contact-tracking",
208
+ },
209
+ {
210
+ path: "src/lib/contact/imageUploads.ts",
211
+ code: "brandlab-architecture-required-contact-image-uploads",
212
+ },
213
+ {
214
+ path: "src/lib/contact/useContactAttachments.ts",
215
+ code: "brandlab-architecture-required-contact-attachment-hook",
216
+ },
217
+ {
218
+ path: "src/lib/contact/useFormDraftPersistence.ts",
219
+ code: "brandlab-architecture-required-contact-draft-hook",
220
+ },
221
+ {
222
+ path: "src/components/contact/index.ts",
223
+ code: "brandlab-architecture-required-contact-component-entrypoint",
224
+ },
225
+ {
226
+ path: "src/components/contact/HeroQuoteAside.tsx",
227
+ code: "brandlab-architecture-required-hero-quote-aside",
228
+ },
229
+ {
230
+ path: "src/components/contact/QuoteTriggerButton.tsx",
231
+ code: "brandlab-architecture-required-quote-trigger-button",
232
+ },
233
+ {
234
+ path: "src/components/ui/OverlayDialog.tsx",
235
+ code: "brandlab-architecture-required-overlay-dialog",
236
+ },
237
+ {
238
+ path: "src/components/ui/FAQAccordion.tsx",
239
+ code: "brandlab-architecture-required-faq-accordion",
240
+ },
241
+ {
242
+ path: "src/components/ui/button/index.ts",
243
+ code: "brandlab-architecture-required-button-module",
244
+ },
245
+ {
246
+ path: "src/components/ui/button/Button.tsx",
247
+ code: "brandlab-architecture-required-button-component",
248
+ },
249
+ {
250
+ path: "src/components/ui/button/ButtonLink.tsx",
251
+ code: "brandlab-architecture-required-button-link-component",
252
+ },
253
+ {
254
+ path: "src/components/ui/button/ButtonContent.tsx",
255
+ code: "brandlab-architecture-required-button-content",
256
+ },
257
+ {
258
+ path: "src/components/ui/button/buttonStyles.ts",
259
+ code: "brandlab-architecture-required-button-styles",
260
+ },
261
+ {
262
+ path: "src/lib/seo/schema/blog.ts",
263
+ code: "brandlab-architecture-required-schema-blog-module",
264
+ },
265
+ {
266
+ path: "src/lib/seo/schema/marketing.ts",
267
+ code: "brandlab-architecture-required-schema-marketing-module",
268
+ },
269
+ {
270
+ path: "src/lib/seo/schema/serviceLocation.ts",
271
+ code: "brandlab-architecture-required-schema-service-location-module",
272
+ },
273
+ {
274
+ path: "src/lib/seo/sitemaps/staticRoutes.ts",
275
+ code: "brandlab-architecture-required-sitemap-static-routes",
276
+ },
277
+ {
278
+ path: "src/lib/seo/sitemaps/constants.ts",
279
+ code: "brandlab-architecture-required-sitemap-constants",
280
+ },
281
+ ];
282
+ const REQUIRED_STANDARD_TEST_FILES = [
283
+ "tests/config/brandlab-config.test.ts",
284
+ "tests/config/brandlab-conformance.test.ts",
285
+ "tests/config/config.test.ts",
286
+ "tests/config/tooling.test.ts",
287
+ "tests/config/env.test.ts",
288
+ "tests/config/server-env.test.ts",
289
+ "tests/app/customer-copy-leakage.test.ts",
290
+ "tests/app/content-ownership.test.ts",
291
+ "tests/app/robots.test.ts",
292
+ "tests/lib/entrypoints.test.ts",
293
+ "tests/lib/seo/metadata.test.ts",
294
+ "tests/lib/seo/schema-file-contract.test.ts",
295
+ "tests/lib/seo/schema-id-stability.test.ts",
296
+ "tests/lib/seo/sitemaps.test.ts",
297
+ "tests/lib/contact/actions.test.ts",
298
+ "tests/lib/contact/schema.test.ts",
299
+ "tests/lib/contact/tracking.test.ts",
300
+ "tests/instrumentation-client.test.ts",
301
+ ];
302
+ const REQUIRED_ALTERNATIVE_TEST_FILES = [
303
+ {
304
+ code: "brandlab-architecture-required-analytics-tests",
305
+ detail: "Brandlab standard projects must test canonical lead analytics events and fake-success suppression.",
306
+ paths: ["tests/lib/analytics.test.ts", "tests/lib/analytics.test.tsx"],
307
+ },
308
+ ];
309
+ const CANONICAL_SOURCE_ROOTS = [
310
+ "src/app",
311
+ "src/data/blog/pageModels.tsx",
312
+ "src/data/site/pages",
313
+ "src/lib/seo",
314
+ ];
315
+ const FORBIDDEN_STANDARD_FILES = [
316
+ { path: "CLAUDE.md", code: "brandlab-architecture-root-agent-doc" },
317
+ { path: ".node-version", code: "brandlab-architecture-root-node-version" },
318
+ {
319
+ path: "tailwind.config.js",
320
+ code: "brandlab-architecture-legacy-tailwind-config",
321
+ },
322
+ {
323
+ path: "tsconfig.tsbuildinfo",
324
+ code: "brandlab-architecture-build-artifact",
325
+ },
326
+ {
327
+ path: "src/lib/metadata.ts",
328
+ code: "brandlab-architecture-legacy-metadata-wrapper",
329
+ },
330
+ { path: "src/lib/api.ts", code: "brandlab-architecture-legacy-api-wrapper" },
331
+ {
332
+ path: "src/lib/seo/useBreadcrumbs.ts",
333
+ code: "brandlab-architecture-legacy-breadcrumb-hook",
334
+ },
335
+ {
336
+ path: "src/components/ui/StructuredData.tsx",
337
+ code: "brandlab-architecture-legacy-structured-data",
338
+ },
339
+ {
340
+ path: "src/components/ui/ContactForm.tsx",
341
+ code: "brandlab-architecture-contact-component-ownership",
342
+ },
343
+ {
344
+ path: "src/components/ui/QuoteModal.tsx",
345
+ code: "brandlab-architecture-contact-component-ownership",
346
+ },
347
+ {
348
+ path: "src/components/ui/QuoteSidebar.tsx",
349
+ code: "brandlab-architecture-contact-component-ownership",
350
+ },
351
+ {
352
+ path: "src/components/contact/QuoteSidebar.tsx",
353
+ code: "brandlab-architecture-contact-aside-name",
354
+ },
355
+ {
356
+ path: "src/components/layout/Layout.tsx",
357
+ code: "brandlab-architecture-layout-wrapper",
358
+ },
359
+ {
360
+ path: "src/components/templates/ServicePageModels.tsx",
361
+ code: "brandlab-architecture-component-service-page-models",
362
+ },
363
+ {
364
+ path: "src/components/templates/blogHeadings.tsx",
365
+ code: "brandlab-architecture-component-helper-file",
366
+ },
367
+ {
368
+ path: "src/app/api/contact/route.ts",
369
+ code: "brandlab-architecture-contact-api-route",
370
+ },
371
+ {
372
+ path: "src/lib/contact/index.ts",
373
+ code: "brandlab-architecture-mixed-contact-barrel",
374
+ },
375
+ {
376
+ path: "src/lib/contact/server-actions.ts",
377
+ code: "brandlab-architecture-contact-compat-action-wrapper",
378
+ },
379
+ {
380
+ path: "src/lib/seo/schema/base.ts",
381
+ code: "brandlab-architecture-legacy-schema-module",
382
+ },
383
+ {
384
+ path: "src/lib/seo/schema/pages.ts",
385
+ code: "brandlab-architecture-legacy-schema-module",
386
+ },
387
+ {
388
+ path: "src/lib/seo/schema/services.ts",
389
+ code: "brandlab-architecture-legacy-schema-module",
390
+ },
391
+ {
392
+ path: "src/lib/seo/schema/site.ts",
393
+ code: "brandlab-architecture-legacy-schema-module",
394
+ },
395
+ ];
396
+ const FORBIDDEN_STANDARD_DIRECTORIES = [
397
+ {
398
+ path: ".cursor",
399
+ code: "brandlab-architecture-root-cursor-directory",
400
+ },
401
+ {
402
+ path: "docs",
403
+ code: "brandlab-architecture-root-docs-directory",
404
+ },
405
+ {
406
+ path: "files",
407
+ code: "brandlab-architecture-root-files-directory",
408
+ },
409
+ {
410
+ path: "PROJECT_STANDARDISATION",
411
+ code: "brandlab-architecture-root-standardisation-directory",
412
+ },
413
+ {
414
+ path: "src/components/templates/pages",
415
+ code: "brandlab-architecture-component-page-modules-directory",
416
+ },
417
+ {
418
+ path: "src/components/templates/pageModels",
419
+ code: "brandlab-architecture-component-page-models-directory",
420
+ },
421
+ {
422
+ path: "src/components/templates/staticPages",
423
+ code: "brandlab-architecture-component-static-pages-directory",
424
+ },
425
+ {
426
+ path: "src/lib/content/pageModels",
427
+ code: "brandlab-architecture-content-page-models-directory",
428
+ },
429
+ {
430
+ path: "src/lib/seo/schema/builders",
431
+ code: "brandlab-architecture-legacy-schema-builders-directory",
432
+ },
433
+ ];
434
+ const STANDARD_CONTACT_MODULE_FILES = new Set([
435
+ "ContactFormEmail.tsx",
436
+ "actions.ts",
437
+ "attachments.ts",
438
+ "client.ts",
439
+ "constants.ts",
440
+ "imageUploads.ts",
441
+ "rateLimit.ts",
442
+ "schema.ts",
443
+ "tracking.ts",
444
+ "useContactAttachments.ts",
445
+ "useFormDraftPersistence.ts",
446
+ ]);
447
+ const CONTACT_EMAIL_CONTRACT_REQUIREMENTS = [
448
+ {
449
+ code: "brandlab-architecture-contact-email-submission-id",
450
+ pattern: /\bsubmissionId\b/,
451
+ detail: "src/lib/contact/ContactFormEmail.tsx must include the shared submissionId field.",
452
+ },
453
+ {
454
+ code: "brandlab-architecture-contact-email-page-path",
455
+ pattern: /\bpagePath\b/,
456
+ detail: "src/lib/contact/ContactFormEmail.tsx must include the submitted pagePath field.",
457
+ },
458
+ {
459
+ code: "brandlab-architecture-contact-email-page-url",
460
+ pattern: /\bpageUrl\b/,
461
+ detail: "src/lib/contact/ContactFormEmail.tsx must include the submitted pageUrl field.",
462
+ },
463
+ {
464
+ code: "brandlab-architecture-contact-email-traffic-source",
465
+ pattern: /\btrafficSource\b/,
466
+ detail: "src/lib/contact/ContactFormEmail.tsx must include the trafficSource field.",
467
+ },
468
+ {
469
+ code: "brandlab-architecture-contact-email-ad-click-rows",
470
+ pattern: /\b(adClickIds|getAdClickIdRows|formatAdClickIdsForEmailText)\b/,
471
+ detail: "src/lib/contact/ContactFormEmail.tsx must include ad-click ID rows.",
472
+ },
473
+ {
474
+ code: "brandlab-architecture-contact-email-attachments",
475
+ pattern: /\b(attachmentCount|attachments|formatContactFormEmailAttachmentSummary|getContactFormEmailContextRows)\b/,
476
+ detail: "src/lib/contact/ContactFormEmail.tsx must include an attachment summary.",
477
+ },
43
478
  ];
44
- const DEFAULT_TOPOLOGY_ALLOWED_TYPE_FILES = ['src/types/env.d.ts'];
45
- const CONTENT_ARRAY_PATTERN = /\b(?:const|let|var)\s+(?:faqs|faqItems|testimonials|serviceFeatures|advantages|processSteps|featuredServices|locationHighlights)\s*=\s*\[/m;
479
+ const ALLOWED_RICH_DATA_CONTENT_PATTERNS = [
480
+ /^src\/data\/site\/legal\/[^/]+\.(?:ts|tsx)$/,
481
+ ];
482
+ const LEGACY_ENV_ARTIFACT_PATTERNS = [
483
+ /\bNEXT_PUBLIC_RECAPTCHA_SITE_KEY\b/,
484
+ /\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
485
+ /\bRECAPTCHA_SECRET_KEY\b/,
486
+ /\bOUTLOOK_EMAIL\b/,
487
+ /\bOUTLOOK_PASSWORD\b/,
488
+ /\bEMAIL_USER\b/,
489
+ /\bEMAIL_PASSWORD\b/,
490
+ ];
491
+ const LEGACY_CSS_ARTIFACT_PATTERNS = [/\.grecaptcha-badge\b/];
46
492
  export const BRANDLAB_FORBIDDEN_SOURCE_PATTERNS = [
47
493
  {
48
- name: 'legacy analytics event',
494
+ name: "legacy analytics event",
49
495
  pattern: /\b(contact_form_submission|phone_calls|form_start|cta_click)\b/,
50
496
  },
51
497
  {
52
- name: 'non-standard indexing override',
498
+ name: "non-standard indexing override",
53
499
  pattern: /\bALLOW_INDEXING\s*=\s*true\b|ALLOW_INDEXING=true/,
54
500
  },
55
501
  {
56
- name: 'disabled service-location sentinel',
502
+ name: "disabled service-location sentinel",
57
503
  pattern: /__service-location-disabled__/,
58
504
  },
59
505
  {
60
- name: 'sitemap filesystem freshness',
506
+ name: "sitemap filesystem freshness",
61
507
  pattern: /\bfs\.statSync\b|\.mtime\b|\bmtime\b/,
62
508
  },
509
+ {
510
+ name: "public Google Maps API key",
511
+ pattern: /\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
512
+ },
63
513
  ];
64
514
  function fileExtension(path) {
65
515
  const match = path.match(/\.[^.]+$/);
66
- return match?.[0] ?? '';
516
+ return match?.[0] ?? "";
67
517
  }
68
518
  function walkFiles(root, files = []) {
69
519
  for (const entry of readdirSync(root)) {
@@ -98,10 +548,114 @@ function walkAllFiles(root, files = []) {
98
548
  return files;
99
549
  }
100
550
  function appFamilyForPage(path) {
101
- const withoutPrefix = path.replace(/^src\/app\//, '');
102
- if (withoutPrefix === 'page.tsx')
103
- return 'home';
104
- return withoutPrefix.split('/')[0] ?? 'unknown';
551
+ const withoutPrefix = path.replace(/^src\/app\//, "");
552
+ if (withoutPrefix === "page.tsx")
553
+ return "home";
554
+ return withoutPrefix.split("/")[0] ?? "unknown";
555
+ }
556
+ function appRouteFileIsDynamic(path) {
557
+ return /\/\[[^\]]+\]\//.test(path) || /\[[^\]]+\]/.test(path);
558
+ }
559
+ function routeTemplateImportMatches(source) {
560
+ return [
561
+ ...source.matchAll(/^\s*import\s+([^'"\n]+?)\s+from\s+['"](@\/components\/templates\/[^'"]+)['"]/gm),
562
+ ].flatMap((match) => {
563
+ const importClause = match[1].trim();
564
+ const modulePath = match[2];
565
+ const names = [];
566
+ const defaultName = importClause
567
+ .replace(/\{[\s\S]*?\}/g, "")
568
+ .split(",")[0]
569
+ ?.trim();
570
+ if (defaultName)
571
+ names.push(defaultName);
572
+ const namedClause = importClause.match(/\{([\s\S]*?)\}/)?.[1];
573
+ if (namedClause) {
574
+ for (const part of namedClause.split(",")) {
575
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
576
+ if (name)
577
+ names.push(name);
578
+ }
579
+ }
580
+ return names.map((name) => ({
581
+ localName: name,
582
+ modulePath,
583
+ }));
584
+ });
585
+ }
586
+ function extractQuotedStrings(line) {
587
+ const strings = [];
588
+ let quote = null;
589
+ let start = -1;
590
+ let escaped = false;
591
+ for (let index = 0; index < line.length; index += 1) {
592
+ const char = line[index];
593
+ if (!quote) {
594
+ if (char === '"' || char === "'" || char === "`") {
595
+ quote = char;
596
+ start = index;
597
+ }
598
+ continue;
599
+ }
600
+ if (escaped)
601
+ escaped = false;
602
+ else if (char === "\\")
603
+ escaped = true;
604
+ else if (char === quote) {
605
+ strings.push({
606
+ value: line.slice(start + 1, index),
607
+ prefix: line.slice(Math.max(0, start - 32), start),
608
+ });
609
+ quote = null;
610
+ start = -1;
611
+ }
612
+ }
613
+ return strings;
614
+ }
615
+ function looksLikeCustomerFacingText(value) {
616
+ const cleanValue = value.replace(/\s+/g, " ").trim();
617
+ if (cleanValue.length < 12)
618
+ return false;
619
+ if (!/[A-Za-z]/.test(cleanValue))
620
+ return false;
621
+ if (!/\s/.test(cleanValue))
622
+ return false;
623
+ if (/^(?:https?:|mailto:|tel:|\/|\.\/|\.\.\/|@\/)/.test(cleanValue))
624
+ return false;
625
+ if (/^[a-z0-9_:/.[\]()%#,-]+$/i.test(cleanValue) && !/[.!?]/.test(cleanValue))
626
+ return false;
627
+ return true;
628
+ }
629
+ function countRouteOwnedContentChars(source) {
630
+ let total = 0;
631
+ for (const line of source.split(/\r?\n/)) {
632
+ const trimmed = line.trim();
633
+ if (trimmed.startsWith("import ") ||
634
+ trimmed.startsWith("export ") ||
635
+ trimmed.startsWith("//") ||
636
+ trimmed.startsWith("*") ||
637
+ trimmed.startsWith("/*")) {
638
+ continue;
639
+ }
640
+ for (const { value, prefix } of extractQuotedStrings(line)) {
641
+ if (/(?:className|style|d|viewBox|fill|stroke|xmlns|aria-hidden|role|key|id|htmlFor|type|name|href|src)\s*=\s*{?\s*$/.test(prefix) ||
642
+ /(?:className|style)\s*:\s*$/.test(prefix)) {
643
+ continue;
644
+ }
645
+ if (looksLikeCustomerFacingText(value)) {
646
+ total += value.replace(/\s+/g, " ").trim().length;
647
+ }
648
+ }
649
+ }
650
+ const withoutCodeComments = source
651
+ .replace(/\/\*[\s\S]*?\*\//g, "")
652
+ .replace(/\/\/.*$/gm, "");
653
+ for (const match of withoutCodeComments.matchAll(/>\s*([^<>{}][^<>{}]*)\s*</g)) {
654
+ const text = match[1].replace(/\s+/g, " ").trim();
655
+ if (looksLikeCustomerFacingText(text))
656
+ total += text.length;
657
+ }
658
+ return total;
105
659
  }
106
660
  function listImmediateDirectories(root) {
107
661
  if (!existsSync(root))
@@ -111,24 +665,114 @@ function listImmediateDirectories(root) {
111
665
  .map((entry) => entry.name)
112
666
  .sort();
113
667
  }
668
+ function listImmediateFiles(root) {
669
+ if (!existsSync(root))
670
+ return [];
671
+ return readdirSync(root, { withFileTypes: true })
672
+ .filter((entry) => entry.isFile())
673
+ .map((entry) => entry.name)
674
+ .sort();
675
+ }
114
676
  function topologyArray(value, fallback = []) {
115
677
  return Array.isArray(value) ? [...value] : [...fallback];
116
678
  }
117
679
  function addTopologyIssue(options) {
118
- const isAcceptedLegacy = options.architecture?.status === 'legacy-exception' && options.legacyAllowed;
119
680
  options.issues.push({
120
- severity: isAcceptedLegacy ? 'legacy-accepted' : 'error',
681
+ severity: "error",
121
682
  code: options.code,
122
683
  detail: options.detail,
123
684
  path: options.path,
124
685
  });
125
686
  }
687
+ function isAllowedRichDataContent(relativePath) {
688
+ return ALLOWED_RICH_DATA_CONTENT_PATTERNS.some((pattern) => pattern.test(relativePath));
689
+ }
690
+ function hasDataValueComponentImport(source) {
691
+ return /^\s*import\s+(?!type\b)[^\n]*\s+from\s+['"](?:@\/components\/|\.\.?\/.*components\/)/m.test(source);
692
+ }
693
+ function hasDataIconImport(source) {
694
+ return /^\s*import\s+(?!type\b)[^\n]*\s+from\s+['"](?:react-icons(?:\/[^'"]*)?|lucide-react)['"]/m.test(source);
695
+ }
696
+ function hasDataRenderFunction(source) {
697
+ return /\brender[A-Za-z0-9_]*\s*:\s*(?:\([^)]*\)\s*)?=>|\brender[A-Za-z0-9_]*\s*:\s*function\b|\b(?:React\.)?ReactNode\b/.test(source);
698
+ }
699
+ function hasDataJsxRendering(source) {
700
+ return /\bcreateElement\s*\(|(?<![A-Za-z0-9_])<[A-Z][A-Za-z0-9_.]*(?:\s+[A-Za-z_{]|>|\/>)/m.test(source);
701
+ }
702
+ function hasUseClientDirective(source) {
703
+ return /^\s*['"]use client['"];?/m.test(source);
704
+ }
705
+ function hasMetadataExport(source) {
706
+ return /\bexport\s+(?:const|let|var)\s+metadata\b|\bexport\s+(?:async\s+)?function\s+generateMetadata\b/.test(source);
707
+ }
708
+ function hasPageMetadataExport(source) {
709
+ return /\bexport\s+(?:const|let|var)\s+pageMetadata\b/.test(source);
710
+ }
711
+ function hasMetadataBuilderUsage(source) {
712
+ return /\bbuild(?:Page)?Metadata\s*\(/.test(source);
713
+ }
714
+ function hasRoutePageModuleReexport(source) {
715
+ return (/export\s+\{[^}]+\}\s+from\s+['"]@\/components\/templates\/pages\//.test(source) ||
716
+ /export\s+\{[^}]+\}\s+from\s+['"]@\/lib\/content\/pageModels\//.test(source));
717
+ }
718
+ function hasRawMetadataObjectExport(source) {
719
+ return /\bexport\s+const\s+metadata(?:\s*:\s*Metadata)?\s*=\s*\{/.test(source);
720
+ }
721
+ function routeOwnedCopyPropertyMatches(source) {
722
+ const matches = [];
723
+ const propertyPattern = ROUTE_OWNED_COPY_PROPERTY_NAMES.join("|");
724
+ const objectPropertyPattern = new RegExp(`\\b(${propertyPattern})\\s*:\\s*(['"\`])((?:\\\\.|(?!\\2)[\\s\\S])*?)\\2`, "g");
725
+ const jsxPropertyPattern = new RegExp(`\\b(${propertyPattern})\\s*=\\s*(['"\`])((?:\\\\.|(?!\\2)[\\s\\S])*?)\\2`, "g");
726
+ for (const pattern of [objectPropertyPattern, jsxPropertyPattern]) {
727
+ for (const match of source.matchAll(pattern)) {
728
+ const propertyName = match[1];
729
+ const value = match[3];
730
+ if (!looksLikeCustomerFacingText(value))
731
+ continue;
732
+ matches.push({
733
+ propertyName,
734
+ value: value.replace(/\s+/g, " ").trim(),
735
+ });
736
+ }
737
+ }
738
+ return matches;
739
+ }
740
+ function componentOwnedVisibleCopyPropertyMatches(source) {
741
+ return routeOwnedCopyPropertyMatches(source).filter((match) => !COMPONENT_COPY_ALLOWED_VALUES.has(match.value));
742
+ }
743
+ function hasComponentOwnedContentArray(source) {
744
+ return (CONTENT_ARRAY_PATTERN.test(source) ||
745
+ CONTENT_JSX_ARRAY_PROP_PATTERN.test(source));
746
+ }
747
+ function hasComponentExportedContentRecord(source) {
748
+ return (/\bexport\s+const\s+[A-Za-z0-9_]*(?:Content|Copy|Text)\s*(?::[^=]+)?=\s*\{/.test(source) && routeOwnedCopyPropertyMatches(source).length > 0);
749
+ }
750
+ function componentValueDataImportMatches(source) {
751
+ return [
752
+ ...source.matchAll(/^\s*import\s+(?!type\b)(?:(?!^\s*import\s)[\s\S])*?\s+from\s+['"](@\/data(?:\/[^'"]*)?)['"]/gm),
753
+ ].map((match) => match[1]);
754
+ }
755
+ function modulePathExists(root, modulePath) {
756
+ if (!modulePath)
757
+ return false;
758
+ return [
759
+ modulePath,
760
+ `${modulePath}.ts`,
761
+ `${modulePath}.tsx`,
762
+ `${modulePath}.js`,
763
+ `${modulePath}.jsx`,
764
+ `${modulePath}/index.ts`,
765
+ `${modulePath}/index.tsx`,
766
+ `${modulePath}/index.js`,
767
+ `${modulePath}/index.jsx`,
768
+ ].some((candidate) => existsSync(join(root, candidate)));
769
+ }
126
770
  export function scanTextFiles(options) {
127
771
  return options.roots
128
772
  .flatMap((root) => walkFiles(root))
129
773
  .map((path) => ({
130
774
  path,
131
- text: readFileSync(path, 'utf8'),
775
+ text: readFileSync(path, "utf8"),
132
776
  }));
133
777
  }
134
778
  export function findForbiddenBrandlabPatterns(options) {
@@ -154,30 +798,91 @@ export function findForbiddenBrandlabPatterns(options) {
154
798
  return matches;
155
799
  }
156
800
  function getPackageVersion(packageJson, packageName) {
157
- return packageJson.dependencies?.[packageName] ?? packageJson.devDependencies?.[packageName] ?? null;
801
+ return (packageJson.dependencies?.[packageName] ??
802
+ packageJson.devDependencies?.[packageName] ??
803
+ null);
804
+ }
805
+ function isExactVersionSpec(version) {
806
+ return typeof version === "string" && !/^[~^*]|latest|workspace:|file:|link:/.test(version);
807
+ }
808
+ function extractDelimitedProperty(source, propertyName, openChar, closeChar) {
809
+ const propertyMatch = source.match(new RegExp(`${propertyName}\\s*:`));
810
+ if (!propertyMatch || propertyMatch.index === undefined)
811
+ return null;
812
+ const start = source.indexOf(openChar, propertyMatch.index + propertyMatch[0].length);
813
+ if (start === -1)
814
+ return null;
815
+ let depth = 0;
816
+ let quote = null;
817
+ let escaped = false;
818
+ for (let index = start; index < source.length; index += 1) {
819
+ const char = source[index];
820
+ if (quote) {
821
+ if (escaped)
822
+ escaped = false;
823
+ else if (char === "\\")
824
+ escaped = true;
825
+ else if (char === quote)
826
+ quote = null;
827
+ continue;
828
+ }
829
+ if (char === '"' || char === "'" || char === "`")
830
+ quote = char;
831
+ else if (char === openChar)
832
+ depth += 1;
833
+ else if (char === closeChar) {
834
+ depth -= 1;
835
+ if (depth === 0)
836
+ return source.slice(start + 1, index);
837
+ }
838
+ }
839
+ return null;
840
+ }
841
+ function extractStringProperty(source, propertyName) {
842
+ return source.match(new RegExp(`${propertyName}\\s*:\\s*['"\`]([^'"\`]+)['"\`]`, "m"))?.[1] ?? null;
843
+ }
844
+ function parseBrandlabNextContract(source) {
845
+ const nextSource = extractDelimitedProperty(source, "next", "{", "}");
846
+ if (!nextSource)
847
+ return null;
848
+ return {
849
+ baseline: extractStringProperty(nextSource, "baseline"),
850
+ nextVersion: extractStringProperty(nextSource, "nextVersion"),
851
+ reactVersion: extractStringProperty(nextSource, "reactVersion"),
852
+ reactDomVersion: extractStringProperty(nextSource, "reactDomVersion"),
853
+ node: extractStringProperty(nextSource, "node"),
854
+ minimumNode: extractStringProperty(nextSource, "minimumNode"),
855
+ router: extractStringProperty(nextSource, "router"),
856
+ bundler: extractStringProperty(nextSource, "bundler"),
857
+ cacheComponents: /cacheComponents\s*:\s*true/m.test(nextSource)
858
+ ? true
859
+ : /cacheComponents\s*:\s*false/m.test(nextSource)
860
+ ? false
861
+ : null,
862
+ };
158
863
  }
159
864
  export function evaluateSharedPackageVersionSpecs(options) {
160
865
  const issues = [];
161
866
  for (const packageName of options.packageNames) {
162
867
  const installedVersion = getPackageVersion(options.packageJson, packageName);
163
868
  if (!installedVersion) {
164
- issues.push({ packageName, installedVersion, issue: 'missing' });
869
+ issues.push({ packageName, installedVersion, issue: "missing" });
165
870
  continue;
166
871
  }
167
872
  if (installedVersion !== options.expectedVersion) {
168
873
  issues.push({
169
874
  packageName,
170
875
  installedVersion,
171
- issue: installedVersion.replace(/^[~^]/, '') === options.expectedVersion
172
- ? 'not-exact'
173
- : 'mismatch',
876
+ issue: installedVersion.replace(/^[~^]/, "") === options.expectedVersion
877
+ ? "not-exact"
878
+ : "mismatch",
174
879
  });
175
880
  }
176
881
  }
177
882
  return issues;
178
883
  }
179
884
  export function evaluateNpmDistTags(options) {
180
- const tags = options.tags ?? ['alpha', 'latest'];
885
+ const tags = options.tags ?? ["alpha", "latest"];
181
886
  return tags.flatMap((tag) => {
182
887
  const actualVersion = options.distTags[tag] ?? null;
183
888
  return actualVersion === options.expectedVersion
@@ -202,81 +907,140 @@ export function isSearchCertificationFresh(evidence, now = Date.now(), maxAgeDay
202
907
  }
203
908
  export function evaluateBrandlabArchitecture(options) {
204
909
  const architecture = options.architecture ?? null;
205
- const largeRouteFileLineLimit = options.largeRouteFileLineLimit ?? DEFAULT_LARGE_ROUTE_FILE_LINE_LIMIT;
206
- const appRoot = join(options.root, 'src/app');
910
+ const largeRouteFileLineLimit = options.largeRouteFileLineLimit ??
911
+ architecture?.largeRouteFileLineLimit ??
912
+ DEFAULT_LARGE_ROUTE_FILE_LINE_LIMIT;
913
+ const routeFileContentCharLimit = options.routeFileContentCharLimit ??
914
+ architecture?.routeFileContentCharLimit ??
915
+ DEFAULT_ROUTE_FILE_CONTENT_CHAR_LIMIT;
916
+ const appRoot = join(options.root, "src/app");
207
917
  const pageFiles = walkAllFiles(appRoot)
208
- .filter((path) => basename(path) === 'page.tsx')
209
- .map((path) => relative(options.root, path).split(/[/\\]/).join('/'))
918
+ .filter((path) => basename(path) === "page.tsx")
919
+ .map((path) => relative(options.root, path).split(/[/\\]/).join("/"))
920
+ .sort();
921
+ const routeBoundaryFiles = walkAllFiles(appRoot)
922
+ .filter((path) => /[/\\](?:page|not-found)\.(?:ts|tsx|js|jsx)$/.test(path))
923
+ .map((path) => relative(options.root, path).split(/[/\\]/).join("/"))
210
924
  .sort();
211
925
  const issues = [];
212
926
  if (!architecture) {
213
927
  issues.push({
214
- severity: 'error',
215
- code: 'brandlab-architecture-config-missing',
216
- detail: 'brandlab.config.ts is missing the architecture contract.',
928
+ severity: "error",
929
+ code: "brandlab-architecture-config-missing",
930
+ detail: "brandlab.config.ts is missing the architecture contract.",
217
931
  });
218
932
  }
219
933
  else {
220
- if (!ARCHITECTURE_PROFILES.has(architecture.profile ?? '')) {
934
+ if (!ARCHITECTURE_PROFILES.has(architecture.profile ?? "")) {
221
935
  issues.push({
222
- severity: 'error',
223
- code: 'brandlab-architecture-profile',
224
- detail: `Invalid architecture profile: ${architecture.profile ?? 'missing'}.`,
936
+ severity: "error",
937
+ code: "brandlab-architecture-profile",
938
+ detail: `Invalid architecture profile: ${architecture.profile ?? "missing"}.`,
225
939
  });
226
940
  }
227
- if (!ARCHITECTURE_STATUSES.has(architecture.status ?? '')) {
941
+ if (!ARCHITECTURE_STATUSES.has(architecture.status ?? "")) {
228
942
  issues.push({
229
- severity: 'error',
230
- code: 'brandlab-architecture-status',
231
- detail: `Invalid architecture status: ${architecture.status ?? 'missing'}.`,
943
+ severity: "error",
944
+ code: "brandlab-architecture-status",
945
+ detail: `Invalid architecture status: ${architecture.status ?? "missing"}.`,
232
946
  });
233
947
  }
234
- if (architecture.status === 'standard' && architecture.profile === 'static-route-heavy-legacy') {
948
+ if (architecture.status === "standard" &&
949
+ architecture.profile === "static-route-heavy-legacy") {
235
950
  issues.push({
236
- severity: 'error',
237
- code: 'brandlab-architecture-static-standard',
238
- detail: 'static-route-heavy-legacy cannot be marked as standard.',
951
+ severity: "error",
952
+ code: "brandlab-architecture-static-standard",
953
+ detail: "static-route-heavy-legacy cannot be marked as standard.",
239
954
  });
240
955
  }
241
- if (architecture.status === 'legacy-exception' &&
956
+ if (architecture.status === "legacy-exception") {
957
+ issues.push({
958
+ severity: "error",
959
+ code: "brandlab-architecture-legacy-exception-status",
960
+ detail: "legacy-exception projects are not rollout-ready under the Brandlab Architecture Standard.",
961
+ });
962
+ }
963
+ if (architecture.status === "legacy-exception" &&
242
964
  (!architecture.targetProfile ||
243
965
  architecture.targetProfile === architecture.profile ||
244
966
  !ARCHITECTURE_PROFILES.has(architecture.targetProfile))) {
245
967
  issues.push({
246
- severity: 'error',
247
- code: 'brandlab-architecture-target-profile',
248
- detail: 'Legacy architecture exceptions must declare a different valid targetProfile.',
968
+ severity: "error",
969
+ code: "brandlab-architecture-target-profile",
970
+ detail: "Legacy architecture exceptions must declare a different valid targetProfile.",
249
971
  });
250
972
  }
251
- if (architecture.status === 'legacy-exception' &&
252
- (!architecture.migrationRationale || architecture.migrationRationale.length < 20)) {
973
+ if (architecture.status === "legacy-exception" &&
974
+ (!architecture.migrationRationale ||
975
+ architecture.migrationRationale.length < 20)) {
253
976
  issues.push({
254
- severity: 'error',
255
- code: 'brandlab-architecture-migration-rationale',
256
- detail: 'Legacy architecture exceptions must explain why the exception exists.',
977
+ severity: "error",
978
+ code: "brandlab-architecture-migration-rationale",
979
+ detail: "Legacy architecture exceptions must explain why the exception exists.",
257
980
  });
258
981
  }
259
- if (!architecture.routeFamilies || architecture.routeFamilies.length === 0) {
982
+ if (!architecture.routeFamilies ||
983
+ architecture.routeFamilies.length === 0) {
260
984
  issues.push({
261
- severity: 'error',
262
- code: 'brandlab-architecture-route-families',
263
- detail: 'Architecture contract is missing route-family declarations.',
985
+ severity: "error",
986
+ code: "brandlab-architecture-route-families",
987
+ detail: "Architecture contract is missing route-family declarations.",
264
988
  });
265
989
  }
266
- if (architecture.contentOwnership?.routeFiles !== 'thin' ||
267
- architecture.contentOwnership?.pageData !== 'src/data' ||
268
- architecture.contentOwnership?.componentCopy !== 'data-props') {
990
+ if (architecture.status === "standard") {
991
+ for (const family of architecture.routeFamilies ?? []) {
992
+ if (family.model &&
993
+ !["dynamic", "dynamic-root"].includes(family.model)) {
994
+ issues.push({
995
+ severity: "error",
996
+ code: "brandlab-architecture-route-family-model",
997
+ detail: `Standard architecture route families must be dynamic/data-driven; found model "${family.model}".`,
998
+ });
999
+ }
1000
+ if (family.name === "blog" &&
1001
+ family.route &&
1002
+ family.route !== "/blog/[slug]") {
1003
+ issues.push({
1004
+ severity: "error",
1005
+ code: "brandlab-architecture-route-family-convention",
1006
+ detail: `Blog route family uses "${family.route}" instead of the standard /blog/[slug] route convention.`,
1007
+ path: family.route,
1008
+ });
1009
+ }
1010
+ if (family.dataSource &&
1011
+ /\/(?:catalog|posts)$/.test(family.dataSource)) {
1012
+ issues.push({
1013
+ severity: "error",
1014
+ code: "brandlab-architecture-route-family-data-source-root",
1015
+ detail: `Route family "${family.name ?? "unknown"}" points to a leaf data module instead of the owning data folder.`,
1016
+ path: family.dataSource,
1017
+ });
1018
+ }
1019
+ if (!family.dataSource ||
1020
+ !modulePathExists(options.root, family.dataSource)) {
1021
+ issues.push({
1022
+ severity: "error",
1023
+ code: "brandlab-architecture-route-family-data-source",
1024
+ detail: `Route family "${family.name ?? "unknown"}" points to a missing dataSource module.`,
1025
+ path: family.dataSource,
1026
+ });
1027
+ }
1028
+ }
1029
+ }
1030
+ if (architecture.contentOwnership?.routeFiles !== "thin" ||
1031
+ architecture.contentOwnership?.pageData !== "src/data" ||
1032
+ architecture.contentOwnership?.componentCopy !== "data-props") {
269
1033
  issues.push({
270
- severity: 'error',
271
- code: 'brandlab-architecture-content-ownership',
272
- detail: 'Architecture contract must declare thin routes, src/data page ownership, and data-prop component copy.',
1034
+ severity: "error",
1035
+ code: "brandlab-architecture-content-ownership",
1036
+ detail: "Architecture contract must declare thin routes, src/data page ownership, and data-prop component copy.",
273
1037
  });
274
1038
  }
275
1039
  if (!architecture.topology) {
276
1040
  issues.push({
277
- severity: 'error',
278
- code: 'brandlab-architecture-topology-config',
279
- detail: 'Architecture contract is missing the source topology declaration.',
1041
+ severity: "error",
1042
+ code: "brandlab-architecture-topology-config",
1043
+ detail: "Architecture contract is missing the source topology declaration.",
280
1044
  });
281
1045
  }
282
1046
  }
@@ -290,93 +1054,1612 @@ export function evaluateBrandlabArchitecture(options) {
290
1054
  const legacyAllowedComponentDirs = new Set(topologyArray(topology.legacyAllowedComponentDirs));
291
1055
  const legacyAllowedLibDirs = new Set(topologyArray(topology.legacyAllowedLibDirs));
292
1056
  const legacyAllowedTypeFiles = new Set(topologyArray(topology.legacyAllowedTypeFiles));
293
- for (const directory of listImmediateDirectories(join(options.root, 'src'))) {
294
- if (directory === 'types')
1057
+ for (const directory of listImmediateDirectories(join(options.root, "src"))) {
1058
+ if (directory === "types")
295
1059
  continue;
296
1060
  if (allowedRootDirs.has(directory))
297
1061
  continue;
298
1062
  addTopologyIssue({
299
1063
  issues,
300
1064
  architecture,
301
- code: 'brandlab-architecture-root-directory',
1065
+ code: "brandlab-architecture-root-directory",
302
1066
  detail: `src/${directory} is outside the Brandlab source topology.`,
303
1067
  path: `src/${directory}`,
304
1068
  legacyAllowed: legacyAllowedRootDirs.has(directory),
305
1069
  });
306
1070
  }
307
- for (const directory of listImmediateDirectories(join(options.root, 'src/components'))) {
1071
+ for (const directory of listImmediateDirectories(join(options.root, "src/components"))) {
308
1072
  if (allowedComponentDirs.has(directory))
309
1073
  continue;
310
1074
  addTopologyIssue({
311
1075
  issues,
312
1076
  architecture,
313
- code: 'brandlab-architecture-component-directory',
1077
+ code: "brandlab-architecture-component-directory",
314
1078
  detail: `src/components/${directory} is outside the Brandlab component topology.`,
315
1079
  path: `src/components/${directory}`,
316
1080
  legacyAllowed: legacyAllowedComponentDirs.has(directory),
317
1081
  });
318
1082
  }
319
- for (const directory of listImmediateDirectories(join(options.root, 'src/lib'))) {
1083
+ for (const directory of listImmediateDirectories(join(options.root, "src/lib"))) {
320
1084
  if (allowedLibDirs.has(directory))
321
1085
  continue;
322
1086
  addTopologyIssue({
323
1087
  issues,
324
1088
  architecture,
325
- code: 'brandlab-architecture-lib-directory',
1089
+ code: "brandlab-architecture-lib-directory",
326
1090
  detail: `src/lib/${directory} is outside the Brandlab library topology.`,
327
1091
  path: `src/lib/${directory}`,
328
1092
  legacyAllowed: legacyAllowedLibDirs.has(directory),
329
1093
  });
330
1094
  }
331
- for (const filePath of walkAllFiles(join(options.root, 'src/types'))
332
- .map((path) => relative(options.root, path).split(/[/\\]/).join('/'))
1095
+ for (const fileName of listImmediateFiles(join(options.root, "src/lib"))) {
1096
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(fileName))
1097
+ continue;
1098
+ addTopologyIssue({
1099
+ issues,
1100
+ architecture,
1101
+ code: "brandlab-architecture-root-lib-file",
1102
+ detail: `src/lib/${fileName} sits directly under src/lib instead of an owning library namespace.`,
1103
+ path: `src/lib/${fileName}`,
1104
+ legacyAllowed: false,
1105
+ });
1106
+ }
1107
+ for (const filePath of walkAllFiles(join(options.root, "src/types"))
1108
+ .map((path) => relative(options.root, path).split(/[/\\]/).join("/"))
333
1109
  .sort()) {
334
1110
  if (allowedTypeFiles.has(filePath))
335
1111
  continue;
336
1112
  addTopologyIssue({
337
1113
  issues,
338
1114
  architecture,
339
- code: 'brandlab-architecture-type-file',
1115
+ code: "brandlab-architecture-type-file",
340
1116
  detail: `${filePath} is outside the Brandlab type ownership topology.`,
341
1117
  path: filePath,
342
1118
  legacyAllowed: legacyAllowedTypeFiles.has(filePath),
343
1119
  });
344
1120
  }
345
1121
  }
346
- const familyCounts = new Map();
347
- for (const filePath of pageFiles) {
348
- const family = appFamilyForPage(filePath);
349
- const current = familyCounts.get(family) ?? { staticCount: 0, dynamicCount: 0 };
350
- if (filePath.includes('['))
351
- current.dynamicCount += 1;
352
- else
353
- current.staticCount += 1;
354
- familyCounts.set(family, current);
355
- const source = readFileSync(join(options.root, filePath), 'utf8');
356
- const lineCount = source.split(/\r?\n/).length;
357
- if (lineCount > largeRouteFileLineLimit && !source.includes('@/data/site/pages')) {
1122
+ if (architecture?.status === "standard") {
1123
+ for (const forbiddenFile of FORBIDDEN_STANDARD_FILES) {
1124
+ if (!existsSync(join(options.root, forbiddenFile.path)))
1125
+ continue;
358
1126
  issues.push({
359
- severity: architecture?.status === 'legacy-exception' ? 'legacy-accepted' : 'warning',
360
- code: 'large-route-file',
361
- detail: `Route file has ${lineCount} lines and appears to own page content instead of delegating to src/data/page models.`,
362
- path: filePath,
1127
+ severity: "error",
1128
+ code: forbiddenFile.code,
1129
+ detail: `${forbiddenFile.path} is a legacy or mixed-boundary file outside the Brandlab standard architecture.`,
1130
+ path: forbiddenFile.path,
363
1131
  });
364
1132
  }
365
- if (CONTENT_ARRAY_PATTERN.test(source)) {
1133
+ for (const requiredFile of REQUIRED_STANDARD_FILES) {
1134
+ if (existsSync(join(options.root, requiredFile.path)))
1135
+ continue;
366
1136
  issues.push({
367
- severity: architecture?.status === 'legacy-exception' ? 'legacy-accepted' : 'warning',
368
- code: 'embedded-route-content-array',
369
- detail: 'Route file declares customer-facing FAQ, testimonial, feature, or process arrays instead of importing page data.',
370
- path: filePath,
1137
+ severity: "error",
1138
+ code: requiredFile.code,
1139
+ detail: `Brandlab standard projects must include ${requiredFile.path}.`,
1140
+ path: requiredFile.path,
1141
+ });
1142
+ }
1143
+ for (const relativePath of REQUIRED_STANDARD_TEST_FILES) {
1144
+ if (existsSync(join(options.root, relativePath)))
1145
+ continue;
1146
+ issues.push({
1147
+ severity: "error",
1148
+ code: "brandlab-architecture-required-test",
1149
+ detail: `${relativePath} is missing from the Brandlab standard test contract.`,
1150
+ path: relativePath,
371
1151
  });
372
1152
  }
1153
+ for (const alternative of REQUIRED_ALTERNATIVE_TEST_FILES) {
1154
+ if (alternative.paths.some((relativePath) => existsSync(join(options.root, relativePath)))) {
1155
+ continue;
1156
+ }
1157
+ issues.push({
1158
+ severity: "error",
1159
+ code: alternative.code,
1160
+ detail: alternative.detail,
1161
+ });
1162
+ }
1163
+ for (const forbiddenDirectory of FORBIDDEN_STANDARD_DIRECTORIES) {
1164
+ const directoryPath = join(options.root, forbiddenDirectory.path);
1165
+ if (!existsSync(directoryPath) || !statSync(directoryPath).isDirectory())
1166
+ continue;
1167
+ issues.push({
1168
+ severity: "error",
1169
+ code: forbiddenDirectory.code,
1170
+ detail: `${forbiddenDirectory.path} is a legacy or project-local directory outside the Brandlab standard architecture.`,
1171
+ path: forbiddenDirectory.path,
1172
+ });
1173
+ }
1174
+ for (const fileName of listImmediateFiles(join(options.root, "src/data"))
1175
+ .filter((name) => /\.(?:ts|tsx|js|jsx)$/.test(name))
1176
+ .filter((name) => name !== "index.ts")) {
1177
+ issues.push({
1178
+ severity: "error",
1179
+ code: "brandlab-architecture-top-level-data-file",
1180
+ detail: `src/data/${fileName} is a top-level data file. Standard projects keep content in owned data families.`,
1181
+ path: `src/data/${fileName}`,
1182
+ });
1183
+ }
1184
+ for (const file of walkAllFiles(join(options.root, "src/data"))) {
1185
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(file))
1186
+ continue;
1187
+ const relativePath = relative(options.root, file)
1188
+ .split(/[/\\]/)
1189
+ .join("/");
1190
+ const source = readFileSync(file, "utf8");
1191
+ if (hasDataValueComponentImport(source) || hasDataIconImport(source)) {
1192
+ issues.push({
1193
+ severity: "error",
1194
+ code: "brandlab-architecture-data-component-import",
1195
+ detail: `${relativePath} imports render/component dependencies from data. Standard data modules must expose plain page models and leave component selection to templates.`,
1196
+ path: relativePath,
1197
+ });
1198
+ }
1199
+ if (/export\s+(?:const|let|var)\s+metadata\b|export\s+(?:async\s+)?function\s+generateMetadata\b/.test(source)) {
1200
+ issues.push({
1201
+ severity: "error",
1202
+ code: "brandlab-architecture-data-metadata-export",
1203
+ detail: `${relativePath} exports Next.js metadata from data. Standard data modules may provide SEO records, but route files own the Next.js metadata export.`,
1204
+ path: relativePath,
1205
+ });
1206
+ }
1207
+ if (!isAllowedRichDataContent(relativePath) &&
1208
+ hasDataRenderFunction(source)) {
1209
+ issues.push({
1210
+ severity: "error",
1211
+ code: "brandlab-architecture-data-render-function",
1212
+ detail: `${relativePath} exposes render functions or React return types from data. Standard data modules should not own page rendering.`,
1213
+ path: relativePath,
1214
+ });
1215
+ }
1216
+ if (!isAllowedRichDataContent(relativePath) &&
1217
+ hasDataJsxRendering(source)) {
1218
+ issues.push({
1219
+ severity: "error",
1220
+ code: "brandlab-architecture-data-rendering-file",
1221
+ detail: `${relativePath} is JSX-rendering data. Standard projects keep reusable rendering in src/components/templates and plain data in src/data.`,
1222
+ path: relativePath,
1223
+ });
1224
+ }
1225
+ }
1226
+ for (const file of walkAllFiles(join(options.root, "src/components"))) {
1227
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(file))
1228
+ continue;
1229
+ const relativePath = relative(options.root, file)
1230
+ .split(/[/\\]/)
1231
+ .join("/");
1232
+ const source = readFileSync(file, "utf8");
1233
+ const lineCount = source.split(/\r?\n/).length;
1234
+ const componentOwnedContentChars = countRouteOwnedContentChars(source);
1235
+ const visibleCopyMatches = componentOwnedVisibleCopyPropertyMatches(source);
1236
+ const isComponentCopyOwnershipRoot = COMPONENT_COPY_OWNERSHIP_ROOTS.some((root) => relativePath.startsWith(root));
1237
+ if ((relativePath.startsWith("src/components/templates/") ||
1238
+ relativePath.startsWith("src/components/sections/")) &&
1239
+ /\bSchemaScript\b|\bcreateSchema(?:Graph)?\b|\bschemas\./.test(source)) {
1240
+ issues.push({
1241
+ severity: "error",
1242
+ code: "brandlab-architecture-component-schema-ownership",
1243
+ detail: `${relativePath} emits or builds JSON-LD from a component/template module. Standard schema emission belongs in src/app route files.`,
1244
+ path: relativePath,
1245
+ });
1246
+ }
1247
+ if (relativePath.startsWith("src/components/templates/") ||
1248
+ relativePath.startsWith("src/components/sections/")) {
1249
+ const dataImports = componentValueDataImportMatches(source);
1250
+ if (dataImports.length > 0) {
1251
+ issues.push({
1252
+ severity: "error",
1253
+ code: "brandlab-architecture-component-data-import",
1254
+ detail: `${relativePath} imports project data directly (${[...new Set(dataImports)].join(", ")}). Standard templates and sections must render props only; src/app routes or src/data page-model helpers own data selection.`,
1255
+ path: relativePath,
1256
+ });
1257
+ }
1258
+ }
1259
+ if ((relativePath.startsWith("src/components/templates/") ||
1260
+ relativePath.startsWith("src/components/sections/")) &&
1261
+ hasComponentOwnedContentArray(source)) {
1262
+ issues.push({
1263
+ severity: "error",
1264
+ code: "brandlab-architecture-component-owned-content-array",
1265
+ detail: `${relativePath} declares or inlines customer-facing content arrays. Standard components must receive FAQs, testimonials, features, process steps, and SEO copy from src/data page models.`,
1266
+ path: relativePath,
1267
+ });
1268
+ }
1269
+ if ((relativePath.startsWith("src/components/templates/") ||
1270
+ relativePath.startsWith("src/components/sections/")) &&
1271
+ hasComponentExportedContentRecord(source)) {
1272
+ issues.push({
1273
+ severity: "error",
1274
+ code: "brandlab-architecture-component-owned-content-record",
1275
+ detail: `${relativePath} exports a customer-facing content record from a component/template module. Standard components must receive page and section copy from src/data.`,
1276
+ path: relativePath,
1277
+ });
1278
+ }
1279
+ if ((relativePath.startsWith("src/components/templates/") ||
1280
+ relativePath.startsWith("src/components/sections/")) &&
1281
+ componentOwnedContentChars > DEFAULT_COMPONENT_CONTENT_CHAR_LIMIT &&
1282
+ !relativePath.startsWith("src/components/templates/LegalPageTemplate")) {
1283
+ issues.push({
1284
+ severity: "error",
1285
+ code: "brandlab-architecture-component-owned-page-copy",
1286
+ detail: `${relativePath} contains about ${componentOwnedContentChars} characters of customer-facing copy. Standard components and templates should render data-prop content, not own page copy.`,
1287
+ path: relativePath,
1288
+ });
1289
+ }
1290
+ if (relativePath.startsWith("src/components/templates/") &&
1291
+ lineCount > 900) {
1292
+ issues.push({
1293
+ severity: "error",
1294
+ code: "brandlab-architecture-monolithic-template",
1295
+ detail: `${relativePath} has ${lineCount} lines. Standard templates should stay focused and delegate page content to src/data and smaller presentational sections.`,
1296
+ path: relativePath,
1297
+ });
1298
+ }
1299
+ if (isComponentCopyOwnershipRoot && visibleCopyMatches.length > 0) {
1300
+ const propertyNames = [
1301
+ ...new Set(visibleCopyMatches.map((match) => match.propertyName)),
1302
+ ].sort();
1303
+ issues.push({
1304
+ severity: "error",
1305
+ code: "brandlab-architecture-component-owned-visible-copy-property",
1306
+ detail: `${relativePath} owns customer-facing copy in component properties: ${propertyNames.join(", ")}. Standard components must receive finished copy from src/data page models or site layout data.`,
1307
+ path: relativePath,
1308
+ });
1309
+ }
1310
+ if (relativePath.startsWith("src/components/templates/pageModels/")) {
1311
+ issues.push({
1312
+ severity: "error",
1313
+ code: "brandlab-architecture-component-page-model",
1314
+ detail: `${relativePath} keeps route/page models inside components. Standard page models and route-family content belong under src/data.`,
1315
+ path: relativePath,
1316
+ });
1317
+ }
1318
+ if (relativePath.startsWith("src/components/templates/pages/")) {
1319
+ issues.push({
1320
+ severity: "error",
1321
+ code: "brandlab-architecture-component-page-module",
1322
+ detail: `${relativePath} is a route-owned page module inside components. Standard projects keep route files in src/app, page data in src/data, and reusable templates directly under src/components/templates.`,
1323
+ fix: "Move page-specific content and SEO records into src/data/site/pages, compose one-off static pages directly in the matching src/app route, and keep src/components/templates for dynamic route families or templates shared by multiple routes.",
1324
+ path: relativePath,
1325
+ });
1326
+ }
1327
+ if (relativePath.startsWith("src/components/templates/staticPages/")) {
1328
+ issues.push({
1329
+ severity: "error",
1330
+ code: "brandlab-architecture-component-static-page",
1331
+ detail: `${relativePath} keeps a static page module inside components. Standard projects keep route ownership in src/app and page models in src/data.`,
1332
+ fix: "Move static page model data into src/data/site/pages and compose the one-off static page directly in the matching src/app route. Keep src/components/templates for dynamic route families or templates shared by multiple routes.",
1333
+ path: relativePath,
1334
+ });
1335
+ }
1336
+ if (hasPageMetadataExport(source) || hasMetadataBuilderUsage(source)) {
1337
+ issues.push({
1338
+ severity: "error",
1339
+ code: "brandlab-architecture-component-metadata-ownership",
1340
+ detail: `${relativePath} owns page metadata from a component module. Standard metadata values belong in src/data and Next.js metadata exports belong in src/app routes.`,
1341
+ path: relativePath,
1342
+ });
1343
+ }
1344
+ if (hasMetadataExport(source)) {
1345
+ issues.push({
1346
+ severity: "error",
1347
+ code: "brandlab-architecture-component-metadata-export",
1348
+ detail: `${relativePath} exports Next.js metadata from a component module. Metadata exports must stay in src/app route files.`,
1349
+ path: relativePath,
1350
+ });
1351
+ }
1352
+ if (relativePath.startsWith("src/components/templates/") &&
1353
+ hasUseClientDirective(source)) {
1354
+ issues.push({
1355
+ severity: "error",
1356
+ code: "brandlab-architecture-client-template",
1357
+ detail: `${relativePath} is a client component template. Standard templates must be server components by default, with interactivity isolated to leaf components.`,
1358
+ path: relativePath,
1359
+ });
1360
+ }
1361
+ if (relativePath.startsWith("src/components/templates/") &&
1362
+ /\bmetaTitle\b|\bmetaDescription\b|\bmetaKeywords\b/.test(source)) {
1363
+ issues.push({
1364
+ severity: "error",
1365
+ code: "brandlab-architecture-template-seo-props",
1366
+ detail: `${relativePath} still declares page metadata props on a reusable template component.`,
1367
+ path: relativePath,
1368
+ });
1369
+ }
1370
+ if (relativePath.startsWith("src/components/ui/") &&
1371
+ /\bSchemaScript\b/.test(source)) {
1372
+ issues.push({
1373
+ severity: "error",
1374
+ code: "brandlab-architecture-generic-ui-schema-script",
1375
+ detail: `${relativePath} emits schema from a generic UI component. Standard JSON-LD emission belongs at the route/template boundary, not reusable UI widgets.`,
1376
+ path: relativePath,
1377
+ });
1378
+ }
1379
+ if (relativePath === "src/components/contact/QuoteModalContext.tsx") {
1380
+ issues.push({
1381
+ severity: "error",
1382
+ code: "brandlab-architecture-contact-modal-context",
1383
+ detail: "src/components/contact/QuoteModalContext.tsx is a project-local modal context wrapper outside the standard contact component entrypoint.",
1384
+ path: relativePath,
1385
+ });
1386
+ }
1387
+ if (relativePath === "src/components/layout/ThemeProvider.tsx") {
1388
+ issues.push({
1389
+ severity: "error",
1390
+ code: "brandlab-architecture-client-theme-provider",
1391
+ detail: "src/components/layout/ThemeProvider.tsx adds a site-wide client provider for static theme values.",
1392
+ path: relativePath,
1393
+ });
1394
+ }
1395
+ }
1396
+ for (const file of walkAllFiles(join(options.root, "src/lib/content"))) {
1397
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(file))
1398
+ continue;
1399
+ const relativePath = relative(options.root, file)
1400
+ .split(/[/\\]/)
1401
+ .join("/");
1402
+ const source = readFileSync(file, "utf8");
1403
+ if (relativePath.startsWith("src/lib/content/pageModels/")) {
1404
+ issues.push({
1405
+ severity: "error",
1406
+ code: "brandlab-architecture-content-page-model",
1407
+ detail: `${relativePath} keeps page models in src/lib/content. Standard page models and route-family content belong under src/data.`,
1408
+ path: relativePath,
1409
+ });
1410
+ }
1411
+ if (/\.(?:tsx|jsx)$/.test(relativePath)) {
1412
+ issues.push({
1413
+ severity: "error",
1414
+ code: "brandlab-architecture-content-jsx-file",
1415
+ detail: `${relativePath} is a JSX content helper. Standard src/lib/content modules must be pure TypeScript helpers, not rendering modules.`,
1416
+ path: relativePath,
1417
+ });
1418
+ }
1419
+ if (hasDataValueComponentImport(source)) {
1420
+ issues.push({
1421
+ severity: "error",
1422
+ code: "brandlab-architecture-content-component-import",
1423
+ detail: `${relativePath} imports component dependencies from the content helper layer.`,
1424
+ path: relativePath,
1425
+ });
1426
+ }
1427
+ if (hasPageMetadataExport(source) ||
1428
+ hasMetadataBuilderUsage(source) ||
1429
+ hasMetadataExport(source)) {
1430
+ issues.push({
1431
+ severity: "error",
1432
+ code: "brandlab-architecture-content-metadata-ownership",
1433
+ detail: `${relativePath} owns page metadata from src/lib/content. Standard metadata records belong in src/data and Next.js metadata exports belong in src/app routes.`,
1434
+ path: relativePath,
1435
+ });
1436
+ }
1437
+ if (hasDataRenderFunction(source) || hasDataJsxRendering(source)) {
1438
+ issues.push({
1439
+ severity: "error",
1440
+ code: "brandlab-architecture-content-rendering",
1441
+ detail: `${relativePath} owns rendering from src/lib/content. Standard content helpers must not expose React nodes, JSX, or render functions.`,
1442
+ path: relativePath,
1443
+ });
1444
+ }
1445
+ }
1446
+ for (const file of walkAllFiles(join(options.root, "src/app"))) {
1447
+ if (!/\/page\.(?:ts|tsx|js|jsx)$/.test(file.split(/[/\\]/).join("/")))
1448
+ continue;
1449
+ const relativePath = relative(options.root, file)
1450
+ .split(/[/\\]/)
1451
+ .join("/");
1452
+ const source = readFileSync(file, "utf8");
1453
+ if (hasRoutePageModuleReexport(source)) {
1454
+ issues.push({
1455
+ severity: "error",
1456
+ code: "brandlab-architecture-route-page-module-reexport",
1457
+ detail: `${relativePath} re-exports a page module from components or content helpers instead of owning the route boundary.`,
1458
+ path: relativePath,
1459
+ });
1460
+ }
1461
+ const directComponentImports = [
1462
+ ...source.matchAll(/from\s+['"](@\/components\/(?:sections|contact|ui)\/[^'"]+)['"]/g),
1463
+ ].map((match) => match[1]);
1464
+ if (appRouteFileIsDynamic(relativePath) && directComponentImports.length > 0) {
1465
+ issues.push({
1466
+ severity: "error",
1467
+ code: "brandlab-architecture-route-direct-component-composition",
1468
+ detail: `${relativePath} imports section, contact, or UI components directly: ${directComponentImports.join(", ")}.`,
1469
+ path: relativePath,
1470
+ });
1471
+ }
1472
+ const staticMdxImports = [
1473
+ ...source.matchAll(/from\s+['"](@\/content\/blog\/[^'"]+\.mdx)['"]/g),
1474
+ ].map((match) => match[1]);
1475
+ if (staticMdxImports.length > 0) {
1476
+ issues.push({
1477
+ severity: "error",
1478
+ code: "brandlab-architecture-static-mdx-route-import",
1479
+ detail: `${relativePath} imports MDX article files one-by-one: ${staticMdxImports.join(", ")}.`,
1480
+ path: relativePath,
1481
+ });
1482
+ }
1483
+ if (/from\s+['"]@\/lib\/seo\/schema['"]/.test(source) &&
1484
+ /\b(createSchema|createSchemaGraph|build[A-Z]\w*Schema|buildBreadcrumbListSchema)\b/.test(source)) {
1485
+ issues.push({
1486
+ severity: "error",
1487
+ code: "brandlab-architecture-route-schema-primitives",
1488
+ detail: `${relativePath} imports low-level schema primitives from the route-facing schema barrel.`,
1489
+ path: relativePath,
1490
+ });
1491
+ }
1492
+ }
1493
+ const hasBlogRoute = existsSync(join(options.root, "src/app/blog")) ||
1494
+ existsSync(join(options.root, "src/data/blog")) ||
1495
+ existsSync(join(options.root, "src/data/blogPosts.ts"));
1496
+ if (hasBlogRoute && !existsSync(join(options.root, "src/content/blog"))) {
1497
+ issues.push({
1498
+ severity: "error",
1499
+ code: "brandlab-architecture-blog-content-source",
1500
+ detail: "Blog content is not owned under src/content/blog. Standard Brandlab projects keep article body content in MDX and metadata/page models in src/data/blog.",
1501
+ path: "src/content/blog",
1502
+ });
1503
+ }
1504
+ const metadataSourcePath = join(options.root, "src/lib/seo/metadata.ts");
1505
+ const metadataSource = existsSync(metadataSourcePath)
1506
+ ? readFileSync(metadataSourcePath, "utf8")
1507
+ : "";
1508
+ if (metadataSource &&
1509
+ !/\bconst\s+\{[\s\S]*resolvedTitle[\s\S]*\.\.\.metadata[\s\S]*\}\s*=\s*buildSeoMetadata\s*\(/.test(metadataSource)) {
1510
+ issues.push({
1511
+ severity: "error",
1512
+ code: "brandlab-architecture-metadata-wrapper-contract",
1513
+ detail: "src/lib/seo/metadata.ts does not follow the standard buildSeoMetadata delegation wrapper pattern.",
1514
+ path: "src/lib/seo/metadata.ts",
1515
+ });
1516
+ }
1517
+ if (metadataSource &&
1518
+ !/title\s*:\s*\{\s*absolute\s*:\s*resolvedTitle\s*\}/.test(metadataSource)) {
1519
+ issues.push({
1520
+ severity: "error",
1521
+ code: "brandlab-architecture-metadata-absolute-title",
1522
+ detail: "src/lib/seo/metadata.ts does not force the final resolved title into title.absolute.",
1523
+ path: "src/lib/seo/metadata.ts",
1524
+ });
1525
+ }
1526
+ for (const [code, pattern, detail] of [
1527
+ [
1528
+ "brandlab-architecture-metadata-business-config-brand",
1529
+ /businessConfig\.company\.name/,
1530
+ "src/lib/seo/metadata.ts does not source the Brandlab title brand from businessConfig.company.name.",
1531
+ ],
1532
+ [
1533
+ "brandlab-architecture-metadata-path-validation",
1534
+ /normalizePath\s*\(\s*pathname\s*,\s*\{\s*validate\s*:\s*true\s*\}\s*\)/,
1535
+ "src/lib/seo/metadata.ts does not validate canonical pathnames before passing them into metadata/OG helpers.",
1536
+ ],
1537
+ ]) {
1538
+ if (!metadataSource || pattern.test(metadataSource))
1539
+ continue;
1540
+ issues.push({
1541
+ severity: "error",
1542
+ code,
1543
+ detail,
1544
+ path: "src/lib/seo/metadata.ts",
1545
+ });
1546
+ }
1547
+ if (/\bkeywords\?\s*:\s*string\s*\|\s*string\[\]/.test(metadataSource) ||
1548
+ /\bkeywordList\b/.test(metadataSource)) {
1549
+ issues.push({
1550
+ severity: "error",
1551
+ code: "brandlab-architecture-metadata-keyword-contract",
1552
+ detail: "src/lib/seo/metadata.ts accepts comma-separated keyword strings or keeps a local keywordList compatibility conversion.",
1553
+ path: "src/lib/seo/metadata.ts",
1554
+ });
1555
+ }
1556
+ for (const helperName of [
1557
+ "generateMetadata",
1558
+ "generatePageMetadata",
1559
+ "generateLocationMetadata",
1560
+ ]) {
1561
+ if (!new RegExp(`export\\s+function\\s+${helperName}\\b`).test(metadataSource))
1562
+ continue;
1563
+ issues.push({
1564
+ severity: "error",
1565
+ code: "brandlab-architecture-legacy-metadata-helper",
1566
+ detail: `src/lib/seo/metadata.ts exports ${helperName}, a legacy compatibility helper outside the standard buildMetadata wrapper contract.`,
1567
+ path: "src/lib/seo/metadata.ts",
1568
+ });
1569
+ }
1570
+ const breadcrumbsSourcePath = join(options.root, "src/lib/seo/breadcrumbs.ts");
1571
+ const breadcrumbsSource = existsSync(breadcrumbsSourcePath)
1572
+ ? readFileSync(breadcrumbsSourcePath, "utf8")
1573
+ : "";
1574
+ if (/export\s+function\s+generateBreadcrumbs\b/.test(breadcrumbsSource)) {
1575
+ issues.push({
1576
+ severity: "error",
1577
+ code: "brandlab-architecture-legacy-breadcrumb-helper",
1578
+ detail: "src/lib/seo/breadcrumbs.ts exports generateBreadcrumbs, a legacy project-local helper name outside the Brandlab breadcrumb contract.",
1579
+ path: "src/lib/seo/breadcrumbs.ts",
1580
+ });
1581
+ }
1582
+ const routeLabelsSourcePath = join(options.root, "src/lib/seo/routeLabels.ts");
1583
+ const routeLabelsSource = existsSync(routeLabelsSourcePath)
1584
+ ? readFileSync(routeLabelsSourcePath, "utf8")
1585
+ : "";
1586
+ if (routeLabelsSource &&
1587
+ !/export\s+function\s+getRouteLabel\b/.test(routeLabelsSource)) {
1588
+ issues.push({
1589
+ severity: "error",
1590
+ code: "brandlab-architecture-route-label-api",
1591
+ detail: "src/lib/seo/routeLabels.ts does not expose the standard getRouteLabel(segment, { silent }) API.",
1592
+ path: "src/lib/seo/routeLabels.ts",
1593
+ });
1594
+ }
1595
+ if (routeLabelsSource &&
1596
+ existsSync(join(options.root, "src/app/thank-you/page.tsx")) &&
1597
+ !routeLabelsSource.includes("thank-you")) {
1598
+ issues.push({
1599
+ severity: "error",
1600
+ code: "brandlab-architecture-route-label-coverage",
1601
+ detail: "src/lib/seo/routeLabels.ts is missing the thank-you static route label.",
1602
+ path: "src/lib/seo/routeLabels.ts",
1603
+ });
1604
+ }
1605
+ for (const file of walkAllFiles(join(options.root, "src/lib/seo/schema"))) {
1606
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(file))
1607
+ continue;
1608
+ const source = readFileSync(file, "utf8");
1609
+ const relativePath = relative(options.root, file)
1610
+ .split(/[/\\]/)
1611
+ .join("/");
1612
+ if (relativePath !== "src/lib/seo/schema/graph.ts" &&
1613
+ /['"]@id['"]\s*:\s*`?\$\{?siteConfig\.url/.test(source)) {
1614
+ issues.push({
1615
+ severity: "error",
1616
+ code: "brandlab-architecture-inline-schema-id",
1617
+ detail: `${relativePath} builds schema @id values inline from siteConfig.url. Standard schema IDs must use shared helper functions so @id anchors stay stable across projects.`,
1618
+ path: relativePath,
1619
+ });
1620
+ }
1621
+ if (/\baggregateRating\b|\bAggregateRating\b|['"]Review['"]|\breviewRating\b|\breview\s*:/.test(source)) {
1622
+ issues.push({
1623
+ severity: "error",
1624
+ code: "brandlab-architecture-self-serving-review-schema",
1625
+ detail: `${relativePath} emits Review or AggregateRating schema from a local service-business site. Google treats business-controlled LocalBusiness/Organization review markup as self-serving and ineligible for review snippets.`,
1626
+ fix: "Keep customer testimonials/reviews as visible page content only. Do not emit Review or AggregateRating JSON-LD for the client's own local business or services.",
1627
+ path: relativePath,
1628
+ });
1629
+ }
1630
+ if (!/export\s+function\s+createSchema(?:Graph)?\b/.test(source))
1631
+ continue;
1632
+ if (source.includes("@brandlab-digital/site-core/schema"))
1633
+ continue;
1634
+ issues.push({
1635
+ severity: "error",
1636
+ code: "brandlab-architecture-local-schema-graph-helper",
1637
+ detail: `${relativePath} defines local createSchema/createSchemaGraph helpers. Standard projects must delegate schema context, graph filtering, and @id helpers to @brandlab-digital/site-core.`,
1638
+ path: relativePath,
1639
+ });
1640
+ }
1641
+ const seoIndexSourcePath = join(options.root, "src/lib/seo/index.ts");
1642
+ const seoIndexSource = existsSync(seoIndexSourcePath)
1643
+ ? readFileSync(seoIndexSourcePath, "utf8")
1644
+ : "";
1645
+ for (const requiredExport of [
1646
+ "SchemaScript",
1647
+ "schemas",
1648
+ "sanitizeJsonLd",
1649
+ ]) {
1650
+ if (seoIndexSource.includes(requiredExport))
1651
+ continue;
1652
+ issues.push({
1653
+ severity: "error",
1654
+ code: "brandlab-architecture-seo-barrel-contract",
1655
+ detail: `src/lib/seo/index.ts does not expose ${requiredExport}. Standard projects should have the same SEO barrel surface for routes and tests.`,
1656
+ path: "src/lib/seo/index.ts",
1657
+ });
1658
+ }
1659
+ const schemaIndexPath = join(options.root, "src/lib/seo/schema/index.ts");
1660
+ const schemaIndexSource = existsSync(schemaIndexPath)
1661
+ ? readFileSync(schemaIndexPath, "utf8")
1662
+ : "";
1663
+ if (/export\s+\*\s+from\s+['"]\.\/(?:graph|marketing|serviceLocation|blog)['"]/.test(schemaIndexSource) ||
1664
+ /export\s+\{[^}]*\b(?:createSchema|createSchemaGraph|build[A-Z]\w*Schema|buildBreadcrumbListSchema)\b[^}]*\}\s+from\s+['"]\.\/(?:graph|marketing|serviceLocation|blog)['"]/.test(schemaIndexSource)) {
1665
+ issues.push({
1666
+ severity: "error",
1667
+ code: "brandlab-architecture-schema-barrel-primitives",
1668
+ detail: "src/lib/seo/schema/index.ts exposes low-level schema primitives or builder modules through the route-facing schema barrel.",
1669
+ path: "src/lib/seo/schema/index.ts",
1670
+ });
1671
+ }
1672
+ const schemaScriptSourcePath = join(options.root, "src/lib/seo/schema/SchemaScript.tsx");
1673
+ const schemaScriptSource = existsSync(schemaScriptSourcePath)
1674
+ ? readFileSync(schemaScriptSourcePath, "utf8")
1675
+ : "";
1676
+ if (schemaScriptSource &&
1677
+ !/export\s+\{[\s\S]*SchemaScript[\s\S]*\}\s+from\s+['"]@brandlab-digital\/site-next\/schema['"]/.test(schemaScriptSource)) {
1678
+ issues.push({
1679
+ severity: "error",
1680
+ code: "brandlab-architecture-schema-script-wrapper-contract",
1681
+ detail: "src/lib/seo/schema/SchemaScript.tsx wraps or renames the shared SchemaScript implementation instead of directly re-exporting it.",
1682
+ path: "src/lib/seo/schema/SchemaScript.tsx",
1683
+ });
1684
+ }
1685
+ const actionsPath = join(options.root, "src/lib/contact/actions.ts");
1686
+ const actionsSource = existsSync(actionsPath)
1687
+ ? readFileSync(actionsPath, "utf8")
1688
+ : "";
1689
+ const schemaPath = join(options.root, "src/lib/contact/schema.ts");
1690
+ const schemaSource = existsSync(schemaPath)
1691
+ ? readFileSync(schemaPath, "utf8")
1692
+ : "";
1693
+ const contactClientPath = join(options.root, "src/lib/contact/client.ts");
1694
+ const contactClientSource = existsSync(contactClientPath)
1695
+ ? readFileSync(contactClientPath, "utf8")
1696
+ : "";
1697
+ const contactComponentPath = join(options.root, "src/components/contact/ContactForm.tsx");
1698
+ const contactComponentSource = existsSync(contactComponentPath)
1699
+ ? readFileSync(contactComponentPath, "utf8")
1700
+ : "";
1701
+ const contactEmailPath = join(options.root, "src/lib/contact/ContactFormEmail.tsx");
1702
+ const contactEmailSource = existsSync(contactEmailPath)
1703
+ ? readFileSync(contactEmailPath, "utf8")
1704
+ : "";
1705
+ const serverEnvPath = join(options.root, "src/config/server-env.ts");
1706
+ const serverEnvSource = existsSync(serverEnvPath)
1707
+ ? readFileSync(serverEnvPath, "utf8")
1708
+ : "";
1709
+ for (const fileName of listImmediateFiles(join(options.root, "src/lib/contact"))) {
1710
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(fileName))
1711
+ continue;
1712
+ if (STANDARD_CONTACT_MODULE_FILES.has(fileName))
1713
+ continue;
1714
+ issues.push({
1715
+ severity: "error",
1716
+ code: "brandlab-architecture-contact-module-contract",
1717
+ detail: `src/lib/contact/${fileName} is outside the standard contact module surface. Project-local compatibility modules hide client/server and delivery-contract drift.`,
1718
+ path: `src/lib/contact/${fileName}`,
1719
+ });
1720
+ }
1721
+ if (/useContactForm|submitContactForm|trackContactFormSubmission|initialContactFormState/.test(contactClientSource)) {
1722
+ issues.push({
1723
+ severity: "error",
1724
+ code: "brandlab-architecture-contact-client-state-wrapper",
1725
+ detail: "src/lib/contact/client.ts owns form submission or analytics behavior instead of remaining a thin client-safe contact helper entrypoint.",
1726
+ path: "src/lib/contact/client.ts",
1727
+ });
1728
+ }
1729
+ if (/name\s*=\s*["']service["']|readFormString\s*\(\s*formData\s*,\s*["']service["']\s*\)|\bservice\s*:\s*z\./.test(contactComponentSource + schemaSource)) {
1730
+ issues.push({
1731
+ severity: "error",
1732
+ code: "brandlab-architecture-contact-service-field-drift",
1733
+ detail: "Contact form code still exposes or accepts the legacy service hidden field instead of the standard serviceInterest field.",
1734
+ path: "src/lib/contact/schema.ts",
1735
+ });
1736
+ }
1737
+ const contactConstantsPath = join(options.root, "src/lib/contact/constants.ts");
1738
+ const contactConstantsSource = existsSync(contactConstantsPath)
1739
+ ? readFileSync(contactConstantsPath, "utf8")
1740
+ : "";
1741
+ if (/SERENITY_CONTACT_ATTACHMENT_POLICY|\b(?!DEFAULT_)[A-Z][A-Z0-9_]*_CONTACT_ATTACHMENT_POLICY\b/.test(actionsSource +
1742
+ schemaSource +
1743
+ contactComponentSource +
1744
+ contactConstantsSource)) {
1745
+ issues.push({
1746
+ severity: "error",
1747
+ code: "brandlab-architecture-contact-project-policy-alias",
1748
+ detail: "Contact attachment policy is exported through a project-specific policy alias.",
1749
+ path: "src/lib/contact/constants.ts",
1750
+ });
1751
+ }
1752
+ if (/phone\s*:\s*40\b|max\s*\(\s*40\s*,\s*['"]Phone number is too long/.test(schemaSource + contactConstantsSource)) {
1753
+ issues.push({
1754
+ severity: "error",
1755
+ code: "brandlab-architecture-contact-phone-limit",
1756
+ detail: "Contact phone validation still allows the older 40-character phone field limit.",
1757
+ path: "src/lib/contact/constants.ts",
1758
+ });
1759
+ }
1760
+ if (/message\s*:\s*z\.[\s\S]*?\.optional\s*\(|message\s*:\s*z\.[\s\S]*?\.default\s*\(/.test(schemaSource)) {
1761
+ issues.push({
1762
+ severity: "error",
1763
+ code: "brandlab-architecture-contact-message-required",
1764
+ detail: "Contact message validation is optional or defaults to an empty value.",
1765
+ path: "src/lib/contact/schema.ts",
1766
+ });
1767
+ }
1768
+ if (!/contactEmails\s*:\s*z\.array\s*\(\s*z\.string\s*\(\s*\)\.email\s*\(\s*\)\s*\)\.nonempty\s*\(\s*\)/.test(serverEnvSource) ||
1769
+ !/contactEmail\s*:\s*contactEmails\s*\[\s*0\s*\]/.test(serverEnvSource)) {
1770
+ issues.push({
1771
+ severity: "error",
1772
+ code: "brandlab-architecture-contact-recipient-contract",
1773
+ detail: "src/config/server-env.ts does not expose the standard contactEmails[] recipient list with a primary contactEmail alias.",
1774
+ path: "src/config/server-env.ts",
1775
+ });
1776
+ }
1777
+ for (const [pattern, detail] of [
1778
+ [
1779
+ /useActionState/,
1780
+ "ContactForm must submit through useActionState so the Server Action contract is the only submission path.",
1781
+ ],
1782
+ [
1783
+ /submitContactForm/,
1784
+ "ContactForm must call the standard submitContactForm Server Action.",
1785
+ ],
1786
+ [
1787
+ /useContactAttachments/,
1788
+ "ContactForm must use the standard attachment hook.",
1789
+ ],
1790
+ [
1791
+ /shouldTrackLead\s*!==\s*false/,
1792
+ "ContactForm must suppress analytics for fake-success bot-trap responses.",
1793
+ ],
1794
+ [
1795
+ /trackContactFormSubmission/,
1796
+ "ContactForm must fire the canonical contact_form analytics event after real success.",
1797
+ ],
1798
+ [
1799
+ /loadedAt/,
1800
+ "ContactForm must submit the loadedAt timestamp used by the server-side time trap.",
1801
+ ],
1802
+ ]) {
1803
+ if (pattern.test(contactComponentSource))
1804
+ continue;
1805
+ issues.push({
1806
+ severity: "error",
1807
+ code: "brandlab-architecture-contact-component-contract",
1808
+ detail,
1809
+ path: "src/components/contact/ContactForm.tsx",
1810
+ });
1811
+ }
1812
+ for (const requirement of [
1813
+ [
1814
+ /ContactFormState/,
1815
+ "Contact actions must use the shared ContactFormState-style result contract.",
1816
+ ],
1817
+ [
1818
+ /export\s+async\s+function\s+submitContactForm\b/,
1819
+ "Contact actions must export submitContactForm as the Server Action entrypoint.",
1820
+ ],
1821
+ [
1822
+ /shouldTrackLead\s*:\s*false|getFakeContactSuccessResponse/,
1823
+ "Contact actions must suppress lead tracking for fake bot-trap success.",
1824
+ ],
1825
+ [
1826
+ /buildMailtoFallback/,
1827
+ "Contact actions must provide a populated mailto fallback on delivery failure.",
1828
+ ],
1829
+ [
1830
+ /checkRateLimit/,
1831
+ "Contact actions must use the standard contact rate limiter.",
1832
+ ],
1833
+ [
1834
+ /parseContactFormData/,
1835
+ "Contact actions must parse and validate submitted form data through src/lib/contact/schema.ts.",
1836
+ ],
1837
+ [
1838
+ /initialLandingPath/,
1839
+ "Contact actions must include initial landing path in the notification and success result.",
1840
+ ],
1841
+ [
1842
+ /initialLandingUrl/,
1843
+ "Contact actions must include initial landing URL in the notification payload.",
1844
+ ],
1845
+ [
1846
+ /initialReferrer\s*\|\|\s*input\.referrer|initialReferrer/,
1847
+ "Contact actions must prefer initial referrer when resolving traffic source attribution.",
1848
+ ],
1849
+ [
1850
+ /resolveAdClickIds/,
1851
+ "Contact actions must capture Google Ads click IDs from query params or page URL.",
1852
+ ],
1853
+ ]) {
1854
+ const [pattern, detail] = requirement;
1855
+ if (pattern.test(actionsSource))
1856
+ continue;
1857
+ issues.push({
1858
+ severity: "error",
1859
+ code: "brandlab-architecture-contact-action-contract",
1860
+ detail,
1861
+ path: "src/lib/contact/actions.ts",
1862
+ });
1863
+ }
1864
+ for (const requirement of [
1865
+ [
1866
+ /\bz\.object\s*\(/,
1867
+ "Contact validation must be backed by a Zod object schema.",
1868
+ ],
1869
+ [
1870
+ /isValidAustralianPhoneNumber/,
1871
+ "Contact phone validation must use the shared Australian phone helper.",
1872
+ ],
1873
+ [
1874
+ /fieldErrors/,
1875
+ "Contact validation must return fieldErrors for visible fields.",
1876
+ ],
1877
+ [
1878
+ /\bsuccess\s*:\s*true\b|\bsuccess\s*:\s*false\b/,
1879
+ "parseContactFormData must use the shared success true/false parse result shape.",
1880
+ ],
1881
+ [
1882
+ /contactFieldLimits/,
1883
+ "Contact validation must apply the standard field-length limits.",
1884
+ ],
1885
+ [
1886
+ /initialLandingPath/,
1887
+ "Contact validation must preserve the initial landing path for attribution.",
1888
+ ],
1889
+ [
1890
+ /initialLandingUrl/,
1891
+ "Contact validation must preserve the initial landing URL for attribution.",
1892
+ ],
1893
+ [
1894
+ /initialReferrer/,
1895
+ "Contact validation must preserve the initial referrer for attribution.",
1896
+ ],
1897
+ ]) {
1898
+ const [pattern, detail] = requirement;
1899
+ if (pattern.test(schemaSource))
1900
+ continue;
1901
+ issues.push({
1902
+ severity: "error",
1903
+ code: "brandlab-architecture-contact-schema-contract",
1904
+ detail,
1905
+ path: "src/lib/contact/schema.ts",
1906
+ });
1907
+ }
1908
+ if (/export\s+async\s+function\s+submitContactFormData\b/.test(actionsSource)) {
1909
+ issues.push({
1910
+ severity: "error",
1911
+ code: "brandlab-architecture-contact-compat-action",
1912
+ detail: "src/lib/contact/actions.ts exports submitContactFormData, an API-style compatibility action outside the standard Server Action contract.",
1913
+ path: "src/lib/contact/actions.ts",
1914
+ });
1915
+ }
1916
+ if (/to\s*:\s*serverEnv\.contactEmail\b/.test(actionsSource)) {
1917
+ issues.push({
1918
+ severity: "error",
1919
+ code: "brandlab-architecture-contact-single-recipient-send",
1920
+ detail: "src/lib/contact/actions.ts sends Resend notifications to serverEnv.contactEmail instead of the standard contactEmails[] recipient list.",
1921
+ path: "src/lib/contact/actions.ts",
1922
+ });
1923
+ }
1924
+ if (/mailto:\$\{serverEnv\.contactEmail\}/.test(actionsSource)) {
1925
+ issues.push({
1926
+ severity: "error",
1927
+ code: "brandlab-architecture-contact-single-recipient-mailto",
1928
+ detail: "src/lib/contact/actions.ts builds the fallback mailto URL from only serverEnv.contactEmail.",
1929
+ path: "src/lib/contact/actions.ts",
1930
+ });
1931
+ }
1932
+ if (/verifyRecaptcha|RECAPTCHA_SECRET_KEY|recaptchaToken/i.test(actionsSource + contactComponentSource)) {
1933
+ issues.push({
1934
+ severity: "error",
1935
+ code: "brandlab-architecture-contact-recaptcha-drift",
1936
+ detail: "Contact form reCAPTCHA handling is implemented as project-local behavior instead of the shared contact contract.",
1937
+ path: "src/lib/contact/actions.ts",
1938
+ });
1939
+ }
1940
+ if (contactEmailSource &&
1941
+ !/@react-email\/components/.test(contactEmailSource)) {
1942
+ issues.push({
1943
+ severity: "error",
1944
+ code: "brandlab-architecture-contact-email-react-email",
1945
+ detail: "src/lib/contact/ContactFormEmail.tsx does not use the standard React Email component template.",
1946
+ path: "src/lib/contact/ContactFormEmail.tsx",
1947
+ });
1948
+ }
1949
+ if (/function\s+escapeHtml\b|buildContactFormEmailHtml\b/.test(contactEmailSource)) {
1950
+ issues.push({
1951
+ severity: "error",
1952
+ code: "brandlab-architecture-contact-email-string-html",
1953
+ detail: "src/lib/contact/ContactFormEmail.tsx still owns string-built email HTML or manual escaping.",
1954
+ path: "src/lib/contact/ContactFormEmail.tsx",
1955
+ });
1956
+ }
1957
+ for (const requirement of CONTACT_EMAIL_CONTRACT_REQUIREMENTS) {
1958
+ if (contactEmailSource && !requirement.pattern.test(contactEmailSource)) {
1959
+ issues.push({
1960
+ severity: "error",
1961
+ code: requirement.code,
1962
+ detail: requirement.detail,
1963
+ path: "src/lib/contact/ContactFormEmail.tsx",
1964
+ });
1965
+ }
1966
+ }
1967
+ if (/message\s*:\s*error\s+instanceof\s+Error\s*&&\s*error\.message/.test(actionsSource)) {
1968
+ issues.push({
1969
+ severity: "error",
1970
+ code: "brandlab-architecture-contact-raw-error-message",
1971
+ detail: "src/lib/contact/actions.ts can expose raw delivery errors to the submitted form response.",
1972
+ path: "src/lib/contact/actions.ts",
1973
+ });
1974
+ }
1975
+ const analyticsIndexPath = join(options.root, "src/lib/analytics/index.tsx");
1976
+ const analyticsIndexSource = existsSync(analyticsIndexPath)
1977
+ ? readFileSync(analyticsIndexPath, "utf8")
1978
+ : "";
1979
+ if (analyticsIndexSource) {
1980
+ for (const [code, pattern, detail] of [
1981
+ [
1982
+ "brandlab-architecture-analytics-lead-service-group",
1983
+ /lead_service_group/,
1984
+ "Contact form analytics does not include the standard lead_service_group payload field.",
1985
+ ],
1986
+ [
1987
+ "brandlab-architecture-analytics-web-vital-delta",
1988
+ /metric_delta/,
1989
+ "Web vital analytics does not send metric_delta in the standard payload.",
1990
+ ],
1991
+ [
1992
+ "brandlab-architecture-analytics-navigation-type",
1993
+ /navigation_type/,
1994
+ "Web vital analytics does not send navigation_type in the standard payload.",
1995
+ ],
1996
+ [
1997
+ "brandlab-architecture-analytics-web-vital-label",
1998
+ /event_label\s*:\s*metric\.name/,
1999
+ "Web vital analytics does not use metric.name as the standard event_label.",
2000
+ ],
2001
+ ]) {
2002
+ if (pattern.test(analyticsIndexSource))
2003
+ continue;
2004
+ issues.push({
2005
+ severity: "error",
2006
+ code,
2007
+ detail,
2008
+ path: "src/lib/analytics/index.tsx",
2009
+ });
2010
+ }
2011
+ if (/\bhasImages\b|\btransactionId\b|export\s+function\s+trackContactForm\b/.test(analyticsIndexSource)) {
2012
+ issues.push({
2013
+ severity: "error",
2014
+ code: "brandlab-architecture-analytics-compat-fields",
2015
+ detail: "src/lib/analytics/index.tsx still contains legacy contact analytics field names or compatibility exports.",
2016
+ path: "src/lib/analytics/index.tsx",
2017
+ });
2018
+ }
2019
+ }
2020
+ const googleTagPath = join(options.root, "src/components/analytics/GoogleTag.tsx");
2021
+ const googleTagSource = existsSync(googleTagPath)
2022
+ ? readFileSync(googleTagPath, "utf8")
2023
+ : "";
2024
+ if (googleTagSource) {
2025
+ if (!/normalizeGoogleAdsConversionId/.test(googleTagSource) ||
2026
+ !/@brandlab-digital\/site-core\/analytics/.test(googleTagSource)) {
2027
+ issues.push({
2028
+ severity: "error",
2029
+ code: "brandlab-architecture-google-tag-shared-ads-id",
2030
+ detail: "GoogleTag normalizes Google Ads conversion IDs locally instead of using the shared analytics helper.",
2031
+ path: "src/components/analytics/GoogleTag.tsx",
2032
+ });
2033
+ }
2034
+ if (!/id\s*=\s*['"]gtag-init['"]/.test(googleTagSource)) {
2035
+ issues.push({
2036
+ severity: "error",
2037
+ code: "brandlab-architecture-google-tag-script-id",
2038
+ detail: "GoogleTag does not use the standard gtag-init script id.",
2039
+ path: "src/components/analytics/GoogleTag.tsx",
2040
+ });
2041
+ }
2042
+ }
2043
+ const coreWebVitalsPath = join(options.root, "src/components/analytics/CoreWebVitalsReporter.tsx");
2044
+ const coreWebVitalsSource = existsSync(coreWebVitalsPath)
2045
+ ? readFileSync(coreWebVitalsPath, "utf8")
2046
+ : "";
2047
+ if (coreWebVitalsSource) {
2048
+ for (const [pattern, detail] of [
2049
+ [
2050
+ /usePathname/,
2051
+ "CoreWebVitalsReporter must include the current pathname in metric payloads.",
2052
+ ],
2053
+ [
2054
+ /delta\s*:/,
2055
+ "CoreWebVitalsReporter must pass metric.delta to trackWebVital.",
2056
+ ],
2057
+ [
2058
+ /navigationType\s*:/,
2059
+ "CoreWebVitalsReporter must pass metric.navigationType to trackWebVital.",
2060
+ ],
2061
+ [
2062
+ /pagePath\s*:/,
2063
+ "CoreWebVitalsReporter must pass pagePath to trackWebVital.",
2064
+ ],
2065
+ ]) {
2066
+ if (pattern.test(coreWebVitalsSource))
2067
+ continue;
2068
+ issues.push({
2069
+ severity: "error",
2070
+ code: "brandlab-architecture-core-web-vitals-contract",
2071
+ detail,
2072
+ path: "src/components/analytics/CoreWebVitalsReporter.tsx",
2073
+ });
2074
+ }
2075
+ }
2076
+ const packageJsonPath = join(options.root, "package.json");
2077
+ if (existsSync(packageJsonPath)) {
2078
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
2079
+ if (packageJson.packageManager !== STANDARD_PACKAGE_MANAGER) {
2080
+ issues.push({
2081
+ severity: "error",
2082
+ code: "brandlab-architecture-package-manager-contract",
2083
+ detail: `package.json packageManager is "${packageJson.packageManager ?? "missing"}"; expected "${STANDARD_PACKAGE_MANAGER}".`,
2084
+ path: "package.json",
2085
+ });
2086
+ }
2087
+ for (const [packageName, expectedVersion] of [
2088
+ ["next", STANDARD_NEXT_BASELINE.nextVersion],
2089
+ ["react", STANDARD_NEXT_BASELINE.reactVersion],
2090
+ ["react-dom", STANDARD_NEXT_BASELINE.reactDomVersion],
2091
+ ]) {
2092
+ const installedVersion = getPackageVersion(packageJson, packageName);
2093
+ if (installedVersion === expectedVersion && isExactVersionSpec(installedVersion))
2094
+ continue;
2095
+ issues.push({
2096
+ severity: "error",
2097
+ code: "brandlab-architecture-next-package-version",
2098
+ detail: `package.json installs ${packageName} as "${installedVersion ?? "missing"}"; expected exact "${expectedVersion}".`,
2099
+ path: "package.json",
2100
+ });
2101
+ }
2102
+ if (packageJson.engines?.node !== STANDARD_NEXT_BASELINE.node) {
2103
+ issues.push({
2104
+ severity: "error",
2105
+ code: "brandlab-architecture-next-node-engine",
2106
+ detail: `package.json engines.node is "${packageJson.engines?.node ?? "missing"}"; expected "${STANDARD_NEXT_BASELINE.node}".`,
2107
+ path: "package.json",
2108
+ });
2109
+ }
2110
+ const webpackScripts = Object.entries(packageJson.scripts ?? {})
2111
+ .filter(([, script]) => typeof script === "string" && /--webpack/.test(script))
2112
+ .map(([scriptName]) => scriptName);
2113
+ if (webpackScripts.length > 0) {
2114
+ issues.push({
2115
+ severity: "error",
2116
+ code: "brandlab-architecture-next-webpack-script",
2117
+ detail: `package.json contains webpack fallback scripts: ${webpackScripts.join(", ")}.`,
2118
+ path: "package.json",
2119
+ });
2120
+ }
2121
+ if (packageJson.scripts?.["analyze:bundle"] &&
2122
+ packageJson.scripts["analyze:bundle"] !== STANDARD_NEXT_BASELINE.analyzeBundleScript) {
2123
+ issues.push({
2124
+ severity: "error",
2125
+ code: "brandlab-architecture-next-bundle-analysis-script",
2126
+ detail: `package.json analyze:bundle is "${packageJson.scripts["analyze:bundle"]}"; expected "${STANDARD_NEXT_BASELINE.analyzeBundleScript}".`,
2127
+ path: "package.json",
2128
+ });
2129
+ }
2130
+ }
2131
+ const tsconfigPath = join(options.root, "tsconfig.json");
2132
+ if (existsSync(tsconfigPath)) {
2133
+ const tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf8"));
2134
+ if (tsconfig.compilerOptions?.target !== STANDARD_TYPESCRIPT_TARGET) {
2135
+ issues.push({
2136
+ severity: "error",
2137
+ code: "brandlab-architecture-typescript-target",
2138
+ detail: `tsconfig.json compilerOptions.target is "${tsconfig.compilerOptions?.target ?? "missing"}"; expected "${STANDARD_TYPESCRIPT_TARGET}".`,
2139
+ path: "tsconfig.json",
2140
+ });
2141
+ }
2142
+ if (tsconfig.compilerOptions?.tsBuildInfoFile !==
2143
+ ".next/cache/tsconfig.tsbuildinfo") {
2144
+ issues.push({
2145
+ severity: "error",
2146
+ code: "brandlab-architecture-typescript-build-info",
2147
+ detail: "tsconfig.json does not write incremental TypeScript build info under .next/cache.",
2148
+ path: "tsconfig.json",
2149
+ });
2150
+ }
2151
+ }
2152
+ const eslintConfigPath = join(options.root, "eslint.config.mjs");
2153
+ if (!existsSync(eslintConfigPath)) {
2154
+ issues.push({
2155
+ severity: "error",
2156
+ code: "brandlab-architecture-eslint-config-missing",
2157
+ detail: "eslint.config.mjs is missing from the Brandlab standard tooling contract.",
2158
+ path: "eslint.config.mjs",
2159
+ });
2160
+ }
2161
+ else {
2162
+ const eslintConfigSource = readFileSync(eslintConfigPath, "utf8");
2163
+ for (const [code, pattern, detail] of [
2164
+ [
2165
+ "brandlab-architecture-eslint-brandlab-plugin",
2166
+ /brandlab\.configs\.recommended/,
2167
+ "eslint.config.mjs does not enable @brandlab-digital/eslint-plugin recommended rules.",
2168
+ ],
2169
+ [
2170
+ "brandlab-architecture-eslint-hooks-rule",
2171
+ /['"]react-hooks\/rules-of-hooks['"]\s*:\s*['"]error['"]/,
2172
+ "eslint.config.mjs does not fail on React Rules of Hooks violations.",
2173
+ ],
2174
+ [
2175
+ "brandlab-architecture-eslint-hooks-deps",
2176
+ /['"]react-hooks\/exhaustive-deps['"]\s*:\s*['"]warn['"]/,
2177
+ "eslint.config.mjs does not warn on React hook dependency drift.",
2178
+ ],
2179
+ [
2180
+ "brandlab-architecture-eslint-next-link-rule",
2181
+ /['"]@next\/next\/no-html-link-for-pages['"]\s*:\s*['"]off['"]/,
2182
+ "eslint.config.mjs does not explicitly use the Brandlab no-html-link-for-pages setting.",
2183
+ ],
2184
+ [
2185
+ "brandlab-architecture-eslint-next-image-rule",
2186
+ /['"]@next\/next\/no-img-element['"]\s*:\s*['"]warn['"]/,
2187
+ "eslint.config.mjs does not explicitly warn on raw img elements.",
2188
+ ],
2189
+ ]) {
2190
+ if (pattern.test(eslintConfigSource))
2191
+ continue;
2192
+ issues.push({
2193
+ severity: "error",
2194
+ code,
2195
+ detail,
2196
+ path: "eslint.config.mjs",
2197
+ });
2198
+ }
2199
+ for (const ignoredPattern of [
2200
+ "*.config.js",
2201
+ "*.config.mjs",
2202
+ "*.config.ts",
2203
+ "tsconfig.tsbuildinfo",
2204
+ ]) {
2205
+ if (!eslintConfigSource.includes(`'${ignoredPattern}'`) &&
2206
+ !eslintConfigSource.includes(`"${ignoredPattern}"`)) {
2207
+ continue;
2208
+ }
2209
+ issues.push({
2210
+ severity: "error",
2211
+ code: "brandlab-architecture-eslint-ignore-contract",
2212
+ detail: `eslint.config.mjs ignores ${ignoredPattern}, which can hide standard tooling drift.`,
2213
+ path: "eslint.config.mjs",
2214
+ });
2215
+ }
2216
+ }
2217
+ const appSitemapPath = join(options.root, "src/app/sitemap.ts");
2218
+ if (existsSync(appSitemapPath) &&
2219
+ !/\bgetAllSitemapEntries\b/.test(readFileSync(appSitemapPath, "utf8"))) {
2220
+ issues.push({
2221
+ severity: "error",
2222
+ code: "brandlab-architecture-sitemap-entrypoint-contract",
2223
+ detail: "src/app/sitemap.ts does not delegate to the standard getAllSitemapEntries sitemap aggregator.",
2224
+ path: "src/app/sitemap.ts",
2225
+ });
2226
+ }
2227
+ if (existsSync(join(options.root, "src/lib/seo/sitemaps/routes.ts"))) {
2228
+ issues.push({
2229
+ severity: "error",
2230
+ code: "brandlab-architecture-monolithic-sitemap-builder",
2231
+ detail: "src/lib/seo/sitemaps/routes.ts is a monolithic sitemap builder. Standard projects keep sitemap ownership split by route family.",
2232
+ path: "src/lib/seo/sitemaps/routes.ts",
2233
+ });
2234
+ }
2235
+ const sitemapIndexPath = join(options.root, "src/lib/seo/sitemaps/index.ts");
2236
+ const sitemapIndexSource = existsSync(sitemapIndexPath)
2237
+ ? readFileSync(sitemapIndexPath, "utf8")
2238
+ : "";
2239
+ if (sitemapIndexSource &&
2240
+ !/MetadataRoute\.Sitemap/.test(sitemapIndexSource)) {
2241
+ issues.push({
2242
+ severity: "error",
2243
+ code: "brandlab-architecture-sitemap-metadata-route-type",
2244
+ detail: "src/lib/seo/sitemaps/index.ts does not type the sitemap aggregator as MetadataRoute.Sitemap.",
2245
+ path: "src/lib/seo/sitemaps/index.ts",
2246
+ });
2247
+ }
2248
+ const sitemapConstantsPath = join(options.root, "src/lib/seo/sitemaps/constants.ts");
2249
+ const sitemapConstantsSource = existsSync(sitemapConstantsPath)
2250
+ ? readFileSync(sitemapConstantsPath, "utf8")
2251
+ : "";
2252
+ for (const requiredExport of [
2253
+ "SITEMAP_PRIORITY",
2254
+ "SITEMAP_FREQUENCY",
2255
+ "STATIC_ROUTE_SITEMAP",
2256
+ ]) {
2257
+ if (sitemapConstantsSource.includes(requiredExport))
2258
+ continue;
2259
+ issues.push({
2260
+ severity: "error",
2261
+ code: "brandlab-architecture-sitemap-local-constants",
2262
+ detail: `src/lib/seo/sitemaps/constants.ts does not export ${requiredExport}.`,
2263
+ path: "src/lib/seo/sitemaps/constants.ts",
2264
+ });
2265
+ }
2266
+ for (const filePath of walkAllFiles(join(options.root, "src/lib/seo/sitemaps"))) {
2267
+ if (!/[/\\]sitemap-[^/\\]+\.ts$/.test(filePath))
2268
+ continue;
2269
+ const source = readFileSync(filePath, "utf8");
2270
+ const relativePath = relative(options.root, filePath)
2271
+ .split(/[/\\]/)
2272
+ .join("/");
2273
+ if (!/MetadataRoute\.Sitemap/.test(source)) {
2274
+ issues.push({
2275
+ severity: "error",
2276
+ code: "brandlab-architecture-sitemap-family-type",
2277
+ detail: `${relativePath} does not return MetadataRoute.Sitemap.`,
2278
+ path: relativePath,
2279
+ });
2280
+ }
2281
+ if (/from\s+['"]@brandlab-digital\/site-core\/sitemaps['"]/.test(source)) {
2282
+ issues.push({
2283
+ severity: "error",
2284
+ code: "brandlab-architecture-sitemap-shared-constant-import",
2285
+ detail: `${relativePath} imports sitemap priority/frequency constants directly from the shared package.`,
2286
+ path: relativePath,
2287
+ });
2288
+ }
2289
+ }
2290
+ const instrumentationPath = join(options.root, "src/instrumentation-client.ts");
2291
+ const instrumentationSource = existsSync(instrumentationPath)
2292
+ ? readFileSync(instrumentationPath, "utf8")
2293
+ : "";
2294
+ if (!instrumentationSource) {
2295
+ issues.push({
2296
+ severity: "error",
2297
+ code: "brandlab-architecture-required-instrumentation-client",
2298
+ detail: "src/instrumentation-client.ts is missing.",
2299
+ path: "src/instrumentation-client.ts",
2300
+ });
2301
+ }
2302
+ else {
2303
+ for (const requirement of [
2304
+ [
2305
+ /installPhoneClickListener\s+as\s+installBrandlabPhoneClickListener|installBrandlabPhoneClickListener/,
2306
+ "Phone click tracking should delegate to the shared installPhoneClickListener helper.",
2307
+ ],
2308
+ [
2309
+ /persistInitialLanding\s+as\s+persistBrandlabInitialLanding|persistBrandlabInitialLanding/,
2310
+ "Initial landing attribution should delegate to the shared persistInitialLanding helper.",
2311
+ ],
2312
+ [
2313
+ /siteConfig\.gaMeasurementId/,
2314
+ "Phone tracking installation should be gated by the configured GA measurement ID.",
2315
+ ],
2316
+ [
2317
+ /__brandlabPhoneCallTrackingInitialized/,
2318
+ "Phone tracking should use the standard global initialization flag.",
2319
+ ],
2320
+ ]) {
2321
+ const [pattern, detail] = requirement;
2322
+ if (pattern.test(instrumentationSource))
2323
+ continue;
2324
+ issues.push({
2325
+ severity: "error",
2326
+ code: "brandlab-architecture-instrumentation-contract",
2327
+ detail,
2328
+ path: "src/instrumentation-client.ts",
2329
+ });
2330
+ }
2331
+ }
2332
+ const nextConfigPath = join(options.root, "next.config.ts");
2333
+ const nextConfigSource = existsSync(nextConfigPath)
2334
+ ? readFileSync(nextConfigPath, "utf8")
2335
+ : "";
2336
+ const brandlabConfigPath = join(options.root, "brandlab.config.ts");
2337
+ const brandlabConfigSource = existsSync(brandlabConfigPath)
2338
+ ? readFileSync(brandlabConfigPath, "utf8")
2339
+ : "";
2340
+ const brandlabNextContract = parseBrandlabNextContract(brandlabConfigSource);
2341
+ if (!brandlabNextContract) {
2342
+ issues.push({
2343
+ severity: "error",
2344
+ code: "brandlab-architecture-next-contract",
2345
+ detail: "brandlab.config.ts does not declare the Brandlab Next Baseline 2026-07 next contract.",
2346
+ path: "brandlab.config.ts",
2347
+ });
2348
+ }
2349
+ else {
2350
+ for (const [fieldName, expectedValue] of [
2351
+ ["baseline", STANDARD_NEXT_BASELINE.baseline],
2352
+ ["nextVersion", STANDARD_NEXT_BASELINE.nextVersion],
2353
+ ["reactVersion", STANDARD_NEXT_BASELINE.reactVersion],
2354
+ ["reactDomVersion", STANDARD_NEXT_BASELINE.reactDomVersion],
2355
+ ["node", STANDARD_NEXT_BASELINE.node],
2356
+ ["minimumNode", STANDARD_NEXT_BASELINE.minimumNode],
2357
+ ["router", STANDARD_NEXT_BASELINE.router],
2358
+ ["bundler", STANDARD_NEXT_BASELINE.bundler],
2359
+ ]) {
2360
+ if (brandlabNextContract[fieldName] === expectedValue)
2361
+ continue;
2362
+ issues.push({
2363
+ severity: "error",
2364
+ code: "brandlab-architecture-next-contract",
2365
+ detail: `brandlab.config.ts next.${fieldName} is "${brandlabNextContract[fieldName] ?? "missing"}"; expected "${expectedValue}".`,
2366
+ path: "brandlab.config.ts",
2367
+ });
2368
+ }
2369
+ if (brandlabNextContract.cacheComponents !== STANDARD_NEXT_BASELINE.cacheComponents) {
2370
+ issues.push({
2371
+ severity: "error",
2372
+ code: "brandlab-architecture-next-cache-components",
2373
+ detail: "brandlab.config.ts enables cacheComponents outside the approved Brandlab pilot track.",
2374
+ path: "brandlab.config.ts",
2375
+ });
2376
+ }
2377
+ }
2378
+ const contactAttachmentsPath = join(options.root, "src/lib/contact/attachments.ts");
2379
+ const contactAttachmentsSource = existsSync(contactAttachmentsPath)
2380
+ ? readFileSync(contactAttachmentsPath, "utf8")
2381
+ : "";
2382
+ if (brandlabConfigSource.includes("contactAttachments") &&
2383
+ !/serverActions\s*:\s*\{[\s\S]*bodySizeLimit\s*:\s*['"]20mb['"]/.test(nextConfigSource)) {
2384
+ issues.push({
2385
+ severity: "error",
2386
+ code: "brandlab-architecture-next-server-action-body-limit",
2387
+ detail: "next.config.ts does not set the standard 20mb Server Action body size for attachment-enabled contact forms.",
2388
+ path: "next.config.ts",
2389
+ });
2390
+ }
2391
+ if (brandlabConfigSource.includes("contactAttachments") &&
2392
+ !/filenamePrefix\s*:\s*['"`][a-z0-9][a-z0-9-]{2,48}['"`]/.test(contactConstantsSource)) {
2393
+ issues.push({
2394
+ severity: "error",
2395
+ code: "brandlab-architecture-contact-attachment-prefix",
2396
+ detail: "Contact attachment policy does not define an explicit project-specific filenamePrefix.",
2397
+ path: "src/lib/contact/constants.ts",
2398
+ });
2399
+ }
2400
+ if (existsSync(join(options.root, "pages")) || existsSync(join(options.root, "src/pages"))) {
2401
+ issues.push({
2402
+ severity: "error",
2403
+ code: "brandlab-architecture-pages-router-directory",
2404
+ detail: "Project contains pages/ or src/pages/. Brandlab Next Baseline is App Router only.",
2405
+ });
2406
+ }
2407
+ for (const filePath of walkAllFiles(options.root)) {
2408
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(filePath))
2409
+ continue;
2410
+ const relativePath = relative(options.root, filePath).split(/[/\\]/).join("/");
2411
+ if (relativePath.startsWith("node_modules/") ||
2412
+ relativePath.startsWith(".next/") ||
2413
+ relativePath.startsWith("dist/")) {
2414
+ continue;
2415
+ }
2416
+ const source = readFileSync(filePath, "utf8");
2417
+ const pagesRouterApiPatterns = [
2418
+ ["next/head", /\bfrom\s+['"]next\/head['"]/],
2419
+ ["getStaticProps", /\bgetStaticProps\b/],
2420
+ ["getServerSideProps", /\bgetServerSideProps\b/],
2421
+ ];
2422
+ const pagesRouterApis = pagesRouterApiPatterns.filter(([, pattern]) => pattern.test(source));
2423
+ if (pagesRouterApis.length > 0) {
2424
+ issues.push({
2425
+ severity: "error",
2426
+ code: "brandlab-architecture-pages-router-api",
2427
+ detail: `${relativePath} references Pages Router APIs: ${pagesRouterApis.map(([label]) => label).join(", ")}.`,
2428
+ path: relativePath,
2429
+ });
2430
+ }
2431
+ const invalidQualities = [...new Set([...source.matchAll(/\bquality\s*=\s*\{?\s*(\d+)\s*\}?/g)]
2432
+ .map((match) => Number(match[1]))
2433
+ .filter((quality) => !STANDARD_NEXT_BASELINE.imageQualities.includes(quality)))].sort((a, b) => a - b);
2434
+ if (invalidQualities.length > 0) {
2435
+ issues.push({
2436
+ severity: "error",
2437
+ code: "brandlab-architecture-next-image-quality",
2438
+ detail: `${relativePath} uses unsupported next/image quality values: ${invalidQualities.join(", ")}.`,
2439
+ path: relativePath,
2440
+ });
2441
+ }
2442
+ }
2443
+ if (/createContactAttachmentFilename\s*\(/.test(contactAttachmentsSource) &&
2444
+ !/(prefix\s*:\s*contactAttachmentPolicy\.filenamePrefix|policy\s*:\s*contactAttachmentPolicy)/.test(contactAttachmentsSource)) {
2445
+ issues.push({
2446
+ severity: "error",
2447
+ code: "brandlab-architecture-contact-attachment-filename",
2448
+ detail: "Contact attachment filename generation does not use the project-specific attachment filename prefix.",
2449
+ path: "src/lib/contact/attachments.ts",
2450
+ });
2451
+ }
2452
+ if (/const\s+staticLegacyMappings\s*[:=]/.test(nextConfigSource)) {
2453
+ issues.push({
2454
+ severity: "error",
2455
+ code: "brandlab-architecture-inline-redirect-registry",
2456
+ detail: "next.config.ts owns a large inline legacy redirect registry.",
2457
+ path: "next.config.ts",
2458
+ });
2459
+ }
2460
+ if (/cacheComponents\s*:\s*true/.test(nextConfigSource)) {
2461
+ issues.push({
2462
+ severity: "error",
2463
+ code: "brandlab-architecture-next-cache-components",
2464
+ detail: "next.config.ts enables cacheComponents outside the approved Brandlab pilot track.",
2465
+ path: "next.config.ts",
2466
+ });
2467
+ }
2468
+ if (!/images\s*:\s*\{/.test(nextConfigSource)) {
2469
+ issues.push({
2470
+ severity: "error",
2471
+ code: "brandlab-architecture-next-image-config",
2472
+ detail: "next.config.ts must define an explicit images config.",
2473
+ path: "next.config.ts",
2474
+ });
2475
+ }
2476
+ else {
2477
+ for (const [code, detail, pattern] of [
2478
+ [
2479
+ "brandlab-architecture-next-image-qualities",
2480
+ "next.config.ts images.qualities must be [75, 90].",
2481
+ /qualities\s*:\s*\[\s*75\s*,\s*90\s*\]/,
2482
+ ],
2483
+ [
2484
+ "brandlab-architecture-next-image-cache-ttl",
2485
+ "next.config.ts images.minimumCacheTTL is missing.",
2486
+ /minimumCacheTTL\s*:/,
2487
+ ],
2488
+ [
2489
+ "brandlab-architecture-next-image-remote-patterns",
2490
+ "next.config.ts images.remotePatterns is missing.",
2491
+ /remotePatterns\s*:\s*\[/,
2492
+ ],
2493
+ ]) {
2494
+ if (pattern.test(nextConfigSource))
2495
+ continue;
2496
+ issues.push({
2497
+ severity: "error",
2498
+ code,
2499
+ detail,
2500
+ path: "next.config.ts",
2501
+ });
2502
+ }
2503
+ }
2504
+ const envExamplePath = join(options.root, ".env.example");
2505
+ if (existsSync(envExamplePath) &&
2506
+ LEGACY_ENV_ARTIFACT_PATTERNS.some((pattern) => pattern.test(readFileSync(envExamplePath, "utf8")))) {
2507
+ issues.push({
2508
+ severity: "error",
2509
+ code: "brandlab-architecture-legacy-env-artifact",
2510
+ detail: ".env.example still contains legacy reCAPTCHA or SMTP variables from an older contact implementation.",
2511
+ path: ".env.example",
2512
+ });
2513
+ }
2514
+ const globalsCssPath = join(options.root, "src/app/globals.css");
2515
+ if (existsSync(globalsCssPath) &&
2516
+ LEGACY_CSS_ARTIFACT_PATTERNS.some((pattern) => pattern.test(readFileSync(globalsCssPath, "utf8")))) {
2517
+ issues.push({
2518
+ severity: "error",
2519
+ code: "brandlab-architecture-legacy-css-artifact",
2520
+ detail: "src/app/globals.css still contains legacy reCAPTCHA CSS.",
2521
+ path: "src/app/globals.css",
2522
+ });
2523
+ }
2524
+ for (const root of CANONICAL_SOURCE_ROOTS) {
2525
+ const absoluteRoot = join(options.root, root);
2526
+ const files = existsSync(absoluteRoot) && statSync(absoluteRoot).isFile()
2527
+ ? [absoluteRoot]
2528
+ : walkAllFiles(absoluteRoot);
2529
+ for (const file of files) {
2530
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(file))
2531
+ continue;
2532
+ const source = readFileSync(file, "utf8");
2533
+ if (!source.includes("companyInfo.website"))
2534
+ continue;
2535
+ const relativePath = relative(options.root, file)
2536
+ .split(/[/\\]/)
2537
+ .join("/");
2538
+ issues.push({
2539
+ severity: "error",
2540
+ code: "brandlab-architecture-canonical-source",
2541
+ detail: `${relativePath} uses companyInfo.website in an SEO, schema, or route surface. Canonical URLs must come from the env-backed site config.`,
2542
+ path: relativePath,
2543
+ });
2544
+ }
2545
+ }
2546
+ }
2547
+ const familyCounts = new Map();
2548
+ const templateImportRouteCounts = new Map();
2549
+ for (const filePath of pageFiles) {
2550
+ const family = appFamilyForPage(filePath);
2551
+ const current = familyCounts.get(family) ?? {
2552
+ staticCount: 0,
2553
+ dynamicCount: 0,
2554
+ };
2555
+ if (filePath.includes("["))
2556
+ current.dynamicCount += 1;
2557
+ else
2558
+ current.staticCount += 1;
2559
+ familyCounts.set(family, current);
2560
+ const source = readFileSync(join(options.root, filePath), "utf8");
2561
+ const lineCount = source.split(/\r?\n/).length;
2562
+ const routeOwnedContentChars = countRouteOwnedContentChars(source);
2563
+ for (const templateImport of routeTemplateImportMatches(source)) {
2564
+ const key = `${templateImport.modulePath}::${templateImport.localName}`;
2565
+ const currentImport = templateImportRouteCounts.get(key) ?? {
2566
+ modulePath: templateImport.modulePath,
2567
+ routes: new Set(),
2568
+ };
2569
+ currentImport.routes.add(filePath);
2570
+ templateImportRouteCounts.set(key, currentImport);
2571
+ }
2572
+ if (appRouteFileIsDynamic(filePath) && lineCount > largeRouteFileLineLimit) {
2573
+ issues.push({
2574
+ severity: "error",
2575
+ code: "large-route-file",
2576
+ detail: `Route file has ${lineCount} lines and appears to own page content instead of delegating to src/data/page models.`,
2577
+ path: filePath,
2578
+ });
2579
+ }
2580
+ if (appRouteFileIsDynamic(filePath) && routeOwnedContentChars > routeFileContentCharLimit) {
2581
+ issues.push({
2582
+ severity: "error",
2583
+ code: "route-owned-page-copy",
2584
+ detail: `Dynamic route file contains about ${routeOwnedContentChars} characters of customer-facing copy. Standard dynamic route families should orchestrate data and components, not own repeated page copy.`,
2585
+ path: filePath,
2586
+ });
2587
+ }
2588
+ if (appRouteFileIsDynamic(filePath) && CONTENT_ARRAY_PATTERN.test(source)) {
2589
+ issues.push({
2590
+ severity: "error",
2591
+ code: "embedded-route-content-array",
2592
+ detail: "Route file declares customer-facing FAQ, testimonial, feature, or process arrays instead of importing page data.",
2593
+ path: filePath,
2594
+ });
2595
+ }
2596
+ if (hasRawMetadataObjectExport(source)) {
2597
+ issues.push({
2598
+ severity: "error",
2599
+ code: "brandlab-architecture-raw-route-metadata",
2600
+ detail: `${filePath} exports a raw metadata object. Standard routes should use the shared metadata builder so titles, canonical URLs, robots, Open Graph, and Twitter values follow one contract.`,
2601
+ path: filePath,
2602
+ });
2603
+ }
2604
+ const copyMatches = appRouteFileIsDynamic(filePath)
2605
+ ? routeOwnedCopyPropertyMatches(source)
2606
+ : [];
2607
+ if (copyMatches.length > 0) {
2608
+ const propertyNames = [
2609
+ ...new Set(copyMatches.map((match) => match.propertyName)),
2610
+ ].sort();
2611
+ issues.push({
2612
+ severity: "error",
2613
+ code: "brandlab-architecture-route-owned-copy-property",
2614
+ detail: `${filePath} owns customer-facing copy in route properties: ${propertyNames.join(", ")}.`,
2615
+ path: filePath,
2616
+ });
2617
+ }
2618
+ }
2619
+ const pageFileSet = new Set(pageFiles);
2620
+ for (const filePath of routeBoundaryFiles.filter((candidate) => !pageFileSet.has(candidate))) {
2621
+ const source = readFileSync(join(options.root, filePath), "utf8");
2622
+ if (hasRawMetadataObjectExport(source)) {
2623
+ issues.push({
2624
+ severity: "error",
2625
+ code: "brandlab-architecture-raw-route-metadata",
2626
+ detail: `${filePath} exports a raw metadata object. Standard routes should use the shared metadata builder so titles, canonical URLs, robots, Open Graph, and Twitter values follow one contract.`,
2627
+ path: filePath,
2628
+ });
2629
+ }
2630
+ const copyMatches = routeOwnedCopyPropertyMatches(source);
2631
+ if (copyMatches.length > 0) {
2632
+ const propertyNames = [
2633
+ ...new Set(copyMatches.map((match) => match.propertyName)),
2634
+ ].sort();
2635
+ issues.push({
2636
+ severity: "error",
2637
+ code: "brandlab-architecture-route-owned-copy-property",
2638
+ detail: `${filePath} owns customer-facing copy in route properties: ${propertyNames.join(", ")}.`,
2639
+ path: filePath,
2640
+ });
2641
+ }
2642
+ }
2643
+ for (const { modulePath, routes } of templateImportRouteCounts.values()) {
2644
+ if (routes.size !== 1)
2645
+ continue;
2646
+ const [routePath] = [...routes];
2647
+ if (appRouteFileIsDynamic(routePath))
2648
+ continue;
2649
+ issues.push({
2650
+ severity: "error",
2651
+ code: "brandlab-architecture-single-use-page-template",
2652
+ detail: `${routePath} imports ${modulePath}, but that template is only used by one static route. Static one-off pages should compose page sections directly in src/app; keep src/components/templates for reusable dynamic route families or templates shared by multiple routes.`,
2653
+ path: routePath,
2654
+ });
373
2655
  }
374
2656
  const staticRouteFileLimit = architecture?.staticRouteFileLimit ?? DEFAULT_STATIC_ROUTE_FILE_LIMIT;
375
2657
  for (const [family, counts] of familyCounts.entries()) {
376
- if (counts.staticCount > staticRouteFileLimit && counts.dynamicCount === 0) {
2658
+ if (counts.staticCount > staticRouteFileLimit &&
2659
+ counts.dynamicCount === 0) {
377
2660
  issues.push({
378
- severity: architecture?.status === 'legacy-exception' ? 'legacy-accepted' : 'error',
379
- code: 'brandlab-architecture-static-heavy-family',
2661
+ severity: "error",
2662
+ code: "brandlab-architecture-static-heavy-family",
380
2663
  detail: `${family} has ${counts.staticCount} static route files and no dynamic route boundary.`,
381
2664
  });
382
2665
  }
@@ -385,8 +2668,10 @@ export function evaluateBrandlabArchitecture(options) {
385
2668
  profile: architecture?.profile ?? null,
386
2669
  status: architecture?.status ?? null,
387
2670
  pageFileCount: pageFiles.length,
388
- staticPageFileCount: pageFiles.filter((filePath) => !filePath.includes('[')).length,
389
- dynamicPageFileCount: pageFiles.filter((filePath) => filePath.includes('[')).length,
2671
+ staticPageFileCount: pageFiles.filter((filePath) => !filePath.includes("["))
2672
+ .length,
2673
+ dynamicPageFileCount: pageFiles.filter((filePath) => filePath.includes("["))
2674
+ .length,
390
2675
  issues,
391
2676
  };
392
2677
  }