@brandlab-digital/conformance 1.0.0-rc.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli.d.ts +0 -3
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +35 -93
  4. package/dist/cli.js.map +1 -1
  5. package/dist/current-release.d.ts +3 -0
  6. package/dist/current-release.d.ts.map +1 -0
  7. package/dist/current-release.js +54 -0
  8. package/dist/current-release.js.map +1 -0
  9. package/dist/project.d.ts +19 -24
  10. package/dist/project.d.ts.map +1 -1
  11. package/dist/project.js +444 -1070
  12. package/dist/project.js.map +1 -1
  13. package/dist/release-manifest.d.ts +29 -82
  14. package/dist/release-manifest.d.ts.map +1 -1
  15. package/dist/release-manifest.js +141 -348
  16. package/dist/release-manifest.js.map +1 -1
  17. package/dist/rendered-content.d.ts +36 -0
  18. package/dist/rendered-content.d.ts.map +1 -0
  19. package/dist/rendered-content.js +146 -0
  20. package/dist/rendered-content.js.map +1 -0
  21. package/dist/verification-policy.d.ts +162 -0
  22. package/dist/verification-policy.d.ts.map +1 -0
  23. package/dist/verification-policy.js +309 -0
  24. package/dist/verification-policy.js.map +1 -0
  25. package/package.json +6 -22
  26. package/dist/legacy-architecture-contracts.d.ts +0 -31
  27. package/dist/legacy-architecture-contracts.d.ts.map +0 -1
  28. package/dist/legacy-architecture-contracts.js +0 -49
  29. package/dist/legacy-architecture-contracts.js.map +0 -1
  30. package/dist/locations.d.ts +0 -63
  31. package/dist/locations.d.ts.map +0 -1
  32. package/dist/locations.js +0 -105
  33. package/dist/locations.js.map +0 -1
  34. package/dist/project-config-v2-contract.d.ts +0 -32
  35. package/dist/project-config-v2-contract.d.ts.map +0 -1
  36. package/dist/project-config-v2-contract.js +0 -926
  37. package/dist/project-config-v2-contract.js.map +0 -1
  38. package/dist/project-config-v3.d.ts +0 -43
  39. package/dist/project-config-v3.d.ts.map +0 -1
  40. package/dist/project-config-v3.js +0 -2876
  41. package/dist/project-config-v3.js.map +0 -1
  42. package/dist/project-config.d.ts +0 -3
  43. package/dist/project-config.d.ts.map +0 -1
  44. package/dist/project-config.js +0 -3
  45. package/dist/project-config.js.map +0 -1
  46. package/dist/release-catalog.d.ts +0 -4
  47. package/dist/release-catalog.d.ts.map +0 -1
  48. package/dist/release-catalog.js +0 -714
  49. package/dist/release-catalog.js.map +0 -1
  50. package/dist/release-contract-compatibility.d.ts +0 -97
  51. package/dist/release-contract-compatibility.d.ts.map +0 -1
  52. package/dist/release-contract-compatibility.js +0 -112
  53. package/dist/release-contract-compatibility.js.map +0 -1
  54. package/dist/schema-hygiene.d.ts +0 -26
  55. package/dist/schema-hygiene.d.ts.map +0 -1
  56. package/dist/schema-hygiene.js +0 -28
  57. package/dist/schema-hygiene.js.map +0 -1
  58. package/dist/source-safety-contracts.d.ts +0 -20
  59. package/dist/source-safety-contracts.d.ts.map +0 -1
  60. package/dist/source-safety-contracts.js +0 -60
  61. package/dist/source-safety-contracts.js.map +0 -1
  62. package/dist/vitest.d.ts +0 -110
  63. package/dist/vitest.d.ts.map +0 -1
  64. package/dist/vitest.js +0 -2678
  65. package/dist/vitest.js.map +0 -1
package/dist/vitest.js DELETED
@@ -1,2678 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
- import { basename, join, relative } from "node:path";
3
- const DEFAULT_EXTENSIONS = new Set([
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",
18
- ]);
19
- const ARCHITECTURE_PROFILES = new Set([
20
- "dynamic-data-driven",
21
- "root-level-data-driven",
22
- "static-route-heavy-legacy",
23
- ]);
24
- const ARCHITECTURE_STATUSES = new Set(["standard", "legacy-exception"]);
25
- const DEFAULT_STATIC_ROUTE_FILE_LIMIT = 50;
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;
29
- const DEFAULT_TOPOLOGY_ALLOWED_ROOT_DIRS = [
30
- "app",
31
- "components",
32
- "config",
33
- "content",
34
- "data",
35
- "lib",
36
- ];
37
- const DEFAULT_TOPOLOGY_ALLOWED_COMPONENT_DIRS = [
38
- "analytics",
39
- "contact",
40
- "layout",
41
- "sections",
42
- "templates",
43
- "ui",
44
- ];
45
- const DEFAULT_TOPOLOGY_ALLOWED_LIB_DIRS = [
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
- },
478
- ];
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/];
492
- export const BRANDLAB_FORBIDDEN_SOURCE_PATTERNS = [
493
- {
494
- name: "legacy analytics event",
495
- pattern: /\b(contact_form_submission|phone_calls|form_start|cta_click)\b/,
496
- },
497
- {
498
- name: "non-standard indexing override",
499
- pattern: /\bALLOW_INDEXING\s*=\s*true\b|ALLOW_INDEXING=true/,
500
- },
501
- {
502
- name: "disabled service-location sentinel",
503
- pattern: /__service-location-disabled__/,
504
- },
505
- {
506
- name: "sitemap filesystem freshness",
507
- pattern: /\bfs\.statSync\b|\.mtime\b|\bmtime\b/,
508
- },
509
- {
510
- name: "public Google Maps API key",
511
- pattern: /\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
512
- },
513
- ];
514
- function fileExtension(path) {
515
- const match = path.match(/\.[^.]+$/);
516
- return match?.[0] ?? "";
517
- }
518
- function walkFiles(root, files = []) {
519
- for (const entry of readdirSync(root)) {
520
- if (DEFAULT_IGNORED_DIRS.has(entry))
521
- continue;
522
- const path = join(root, entry);
523
- const stats = statSync(path);
524
- if (stats.isDirectory()) {
525
- walkFiles(path, files);
526
- }
527
- else if (stats.isFile() && DEFAULT_EXTENSIONS.has(fileExtension(path))) {
528
- files.push(path);
529
- }
530
- }
531
- return files;
532
- }
533
- function walkAllFiles(root, files = []) {
534
- if (!existsSync(root))
535
- return files;
536
- for (const entry of readdirSync(root)) {
537
- if (DEFAULT_IGNORED_DIRS.has(entry))
538
- continue;
539
- const path = join(root, entry);
540
- const stats = statSync(path);
541
- if (stats.isDirectory()) {
542
- walkAllFiles(path, files);
543
- }
544
- else if (stats.isFile()) {
545
- files.push(path);
546
- }
547
- }
548
- return files;
549
- }
550
- function appFamilyForPage(path) {
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;
659
- }
660
- function listImmediateDirectories(root) {
661
- if (!existsSync(root))
662
- return [];
663
- return readdirSync(root, { withFileTypes: true })
664
- .filter((entry) => entry.isDirectory())
665
- .map((entry) => entry.name)
666
- .sort();
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
- }
676
- function topologyArray(value, fallback = []) {
677
- return Array.isArray(value) ? [...value] : [...fallback];
678
- }
679
- function addTopologyIssue(options) {
680
- options.issues.push({
681
- severity: "error",
682
- code: options.code,
683
- detail: options.detail,
684
- path: options.path,
685
- });
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
- }
770
- export function scanTextFiles(options) {
771
- return options.roots
772
- .flatMap((root) => walkFiles(root))
773
- .map((path) => ({
774
- path,
775
- text: readFileSync(path, "utf8"),
776
- }));
777
- }
778
- export function findForbiddenBrandlabPatterns(options) {
779
- const patterns = options.patterns ?? BRANDLAB_FORBIDDEN_SOURCE_PATTERNS;
780
- const matches = [];
781
- for (const file of scanTextFiles({ roots: options.roots })) {
782
- const lines = file.text.split(/\r?\n/);
783
- lines.forEach((lineText, lineIndex) => {
784
- for (const sourcePattern of patterns) {
785
- const match = sourcePattern.pattern.exec(lineText);
786
- if (match) {
787
- matches.push({
788
- file: file.path,
789
- patternName: sourcePattern.name,
790
- line: lineIndex + 1,
791
- column: match.index + 1,
792
- text: lineText,
793
- });
794
- }
795
- }
796
- });
797
- }
798
- return matches;
799
- }
800
- function getPackageVersion(packageJson, packageName) {
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
- };
863
- }
864
- export function evaluateSharedPackageVersionSpecs(options) {
865
- const issues = [];
866
- for (const packageName of options.packageNames) {
867
- const installedVersion = getPackageVersion(options.packageJson, packageName);
868
- if (!installedVersion) {
869
- issues.push({ packageName, installedVersion, issue: "missing" });
870
- continue;
871
- }
872
- if (installedVersion !== options.expectedVersion) {
873
- issues.push({
874
- packageName,
875
- installedVersion,
876
- issue: installedVersion.replace(/^[~^]/, "") === options.expectedVersion
877
- ? "not-exact"
878
- : "mismatch",
879
- });
880
- }
881
- }
882
- return issues;
883
- }
884
- export function evaluateNpmDistTags(options) {
885
- const tags = options.tags ?? ["alpha", "latest"];
886
- return tags.flatMap((tag) => {
887
- const actualVersion = options.distTags[tag] ?? null;
888
- return actualVersion === options.expectedVersion
889
- ? []
890
- : [
891
- {
892
- packageName: options.packageName,
893
- tag,
894
- expectedVersion: options.expectedVersion,
895
- actualVersion,
896
- },
897
- ];
898
- });
899
- }
900
- export function isSearchCertificationFresh(evidence, now = Date.now(), maxAgeDays = 14) {
901
- if (!evidence?.evidenceRecordedAt)
902
- return false;
903
- const recordedAt = Date.parse(evidence.evidenceRecordedAt);
904
- if (!Number.isFinite(recordedAt))
905
- return false;
906
- return now - recordedAt <= maxAgeDays * 24 * 60 * 60 * 1000;
907
- }
908
- export function evaluateBrandlabArchitecture(options) {
909
- const architecture = options.architecture ?? null;
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");
917
- const pageFiles = walkAllFiles(appRoot)
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("/"))
924
- .sort();
925
- const issues = [];
926
- if (!architecture) {
927
- issues.push({
928
- severity: "error",
929
- code: "brandlab-architecture-config-missing",
930
- detail: "brandlab.config.ts is missing the architecture contract.",
931
- });
932
- }
933
- else {
934
- if (!ARCHITECTURE_PROFILES.has(architecture.profile ?? "")) {
935
- issues.push({
936
- severity: "error",
937
- code: "brandlab-architecture-profile",
938
- detail: `Invalid architecture profile: ${architecture.profile ?? "missing"}.`,
939
- });
940
- }
941
- if (!ARCHITECTURE_STATUSES.has(architecture.status ?? "")) {
942
- issues.push({
943
- severity: "error",
944
- code: "brandlab-architecture-status",
945
- detail: `Invalid architecture status: ${architecture.status ?? "missing"}.`,
946
- });
947
- }
948
- if (architecture.status === "standard" &&
949
- architecture.profile === "static-route-heavy-legacy") {
950
- issues.push({
951
- severity: "error",
952
- code: "brandlab-architecture-static-standard",
953
- detail: "static-route-heavy-legacy cannot be marked as standard.",
954
- });
955
- }
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" &&
964
- (!architecture.targetProfile ||
965
- architecture.targetProfile === architecture.profile ||
966
- !ARCHITECTURE_PROFILES.has(architecture.targetProfile))) {
967
- issues.push({
968
- severity: "error",
969
- code: "brandlab-architecture-target-profile",
970
- detail: "Legacy architecture exceptions must declare a different valid targetProfile.",
971
- });
972
- }
973
- if (architecture.status === "legacy-exception" &&
974
- (!architecture.migrationRationale ||
975
- architecture.migrationRationale.length < 20)) {
976
- issues.push({
977
- severity: "error",
978
- code: "brandlab-architecture-migration-rationale",
979
- detail: "Legacy architecture exceptions must explain why the exception exists.",
980
- });
981
- }
982
- if (!architecture.routeFamilies ||
983
- architecture.routeFamilies.length === 0) {
984
- issues.push({
985
- severity: "error",
986
- code: "brandlab-architecture-route-families",
987
- detail: "Architecture contract is missing route-family declarations.",
988
- });
989
- }
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") {
1033
- issues.push({
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.",
1037
- });
1038
- }
1039
- if (!architecture.topology) {
1040
- issues.push({
1041
- severity: "error",
1042
- code: "brandlab-architecture-topology-config",
1043
- detail: "Architecture contract is missing the source topology declaration.",
1044
- });
1045
- }
1046
- }
1047
- if (architecture?.topology) {
1048
- const topology = architecture.topology;
1049
- const allowedRootDirs = new Set(topologyArray(topology.allowedRootDirs, DEFAULT_TOPOLOGY_ALLOWED_ROOT_DIRS));
1050
- const allowedComponentDirs = new Set(topologyArray(topology.allowedComponentDirs, DEFAULT_TOPOLOGY_ALLOWED_COMPONENT_DIRS));
1051
- const allowedLibDirs = new Set(topologyArray(topology.allowedLibDirs, DEFAULT_TOPOLOGY_ALLOWED_LIB_DIRS));
1052
- const allowedTypeFiles = new Set(topologyArray(topology.allowedTypeFiles, DEFAULT_TOPOLOGY_ALLOWED_TYPE_FILES));
1053
- const legacyAllowedRootDirs = new Set(topologyArray(topology.legacyAllowedRootDirs));
1054
- const legacyAllowedComponentDirs = new Set(topologyArray(topology.legacyAllowedComponentDirs));
1055
- const legacyAllowedLibDirs = new Set(topologyArray(topology.legacyAllowedLibDirs));
1056
- const legacyAllowedTypeFiles = new Set(topologyArray(topology.legacyAllowedTypeFiles));
1057
- for (const directory of listImmediateDirectories(join(options.root, "src"))) {
1058
- if (directory === "types")
1059
- continue;
1060
- if (allowedRootDirs.has(directory))
1061
- continue;
1062
- addTopologyIssue({
1063
- issues,
1064
- architecture,
1065
- code: "brandlab-architecture-root-directory",
1066
- detail: `src/${directory} is outside the Brandlab source topology.`,
1067
- path: `src/${directory}`,
1068
- legacyAllowed: legacyAllowedRootDirs.has(directory),
1069
- });
1070
- }
1071
- for (const directory of listImmediateDirectories(join(options.root, "src/components"))) {
1072
- if (allowedComponentDirs.has(directory))
1073
- continue;
1074
- addTopologyIssue({
1075
- issues,
1076
- architecture,
1077
- code: "brandlab-architecture-component-directory",
1078
- detail: `src/components/${directory} is outside the Brandlab component topology.`,
1079
- path: `src/components/${directory}`,
1080
- legacyAllowed: legacyAllowedComponentDirs.has(directory),
1081
- });
1082
- }
1083
- for (const directory of listImmediateDirectories(join(options.root, "src/lib"))) {
1084
- if (allowedLibDirs.has(directory))
1085
- continue;
1086
- addTopologyIssue({
1087
- issues,
1088
- architecture,
1089
- code: "brandlab-architecture-lib-directory",
1090
- detail: `src/lib/${directory} is outside the Brandlab library topology.`,
1091
- path: `src/lib/${directory}`,
1092
- legacyAllowed: legacyAllowedLibDirs.has(directory),
1093
- });
1094
- }
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("/"))
1109
- .sort()) {
1110
- if (allowedTypeFiles.has(filePath))
1111
- continue;
1112
- addTopologyIssue({
1113
- issues,
1114
- architecture,
1115
- code: "brandlab-architecture-type-file",
1116
- detail: `${filePath} is outside the Brandlab type ownership topology.`,
1117
- path: filePath,
1118
- legacyAllowed: legacyAllowedTypeFiles.has(filePath),
1119
- });
1120
- }
1121
- }
1122
- if (architecture?.status === "standard") {
1123
- for (const forbiddenFile of FORBIDDEN_STANDARD_FILES) {
1124
- if (!existsSync(join(options.root, forbiddenFile.path)))
1125
- continue;
1126
- issues.push({
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,
1131
- });
1132
- }
1133
- for (const requiredFile of REQUIRED_STANDARD_FILES) {
1134
- if (existsSync(join(options.root, requiredFile.path)))
1135
- continue;
1136
- issues.push({
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,
1151
- });
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
- });
2655
- }
2656
- const staticRouteFileLimit = architecture?.staticRouteFileLimit ?? DEFAULT_STATIC_ROUTE_FILE_LIMIT;
2657
- for (const [family, counts] of familyCounts.entries()) {
2658
- if (counts.staticCount > staticRouteFileLimit &&
2659
- counts.dynamicCount === 0) {
2660
- issues.push({
2661
- severity: "error",
2662
- code: "brandlab-architecture-static-heavy-family",
2663
- detail: `${family} has ${counts.staticCount} static route files and no dynamic route boundary.`,
2664
- });
2665
- }
2666
- }
2667
- return {
2668
- profile: architecture?.profile ?? null,
2669
- status: architecture?.status ?? null,
2670
- pageFileCount: pageFiles.length,
2671
- staticPageFileCount: pageFiles.filter((filePath) => !filePath.includes("["))
2672
- .length,
2673
- dynamicPageFileCount: pageFiles.filter((filePath) => filePath.includes("["))
2674
- .length,
2675
- issues,
2676
- };
2677
- }
2678
- //# sourceMappingURL=vitest.js.map