@brandlab-digital/conformance 0.1.0-alpha.9 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vitest.js CHANGED
@@ -12,6 +12,7 @@ const DEFAULT_EXTENSIONS = new Set([
12
12
  const DEFAULT_IGNORED_DIRS = new Set([
13
13
  "node_modules",
14
14
  ".next",
15
+ ".vercel",
15
16
  "dist",
16
17
  "coverage",
17
18
  ]);
@@ -49,6 +50,19 @@ const DEFAULT_TOPOLOGY_ALLOWED_LIB_DIRS = [
49
50
  ];
50
51
  const DEFAULT_TOPOLOGY_ALLOWED_TYPE_FILES = [];
51
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
+ };
52
66
  const STANDARD_TYPESCRIPT_TARGET = "ES2022";
53
67
  const CONTENT_ARRAY_NAMES = [
54
68
  "faqs",
@@ -430,11 +444,44 @@ const STANDARD_CONTACT_MODULE_FILES = new Set([
430
444
  "useContactAttachments.ts",
431
445
  "useFormDraftPersistence.ts",
432
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
+ ];
433
479
  const ALLOWED_RICH_DATA_CONTENT_PATTERNS = [
434
480
  /^src\/data\/site\/legal\/[^/]+\.(?:ts|tsx)$/,
435
481
  ];
436
482
  const LEGACY_ENV_ARTIFACT_PATTERNS = [
437
483
  /\bNEXT_PUBLIC_RECAPTCHA_SITE_KEY\b/,
484
+ /\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
438
485
  /\bRECAPTCHA_SECRET_KEY\b/,
439
486
  /\bOUTLOOK_EMAIL\b/,
440
487
  /\bOUTLOOK_PASSWORD\b/,
@@ -459,6 +506,10 @@ export const BRANDLAB_FORBIDDEN_SOURCE_PATTERNS = [
459
506
  name: "sitemap filesystem freshness",
460
507
  pattern: /\bfs\.statSync\b|\.mtime\b|\bmtime\b/,
461
508
  },
509
+ {
510
+ name: "public Google Maps API key",
511
+ pattern: /\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
512
+ },
462
513
  ];
463
514
  function fileExtension(path) {
464
515
  const match = path.match(/\.[^.]+$/);
@@ -502,6 +553,36 @@ function appFamilyForPage(path) {
502
553
  return "home";
503
554
  return withoutPrefix.split("/")[0] ?? "unknown";
504
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
+ }
505
586
  function extractQuotedStrings(line) {
506
587
  const strings = [];
507
588
  let quote = null;
@@ -721,6 +802,65 @@ function getPackageVersion(packageJson, packageName) {
721
802
  packageJson.devDependencies?.[packageName] ??
722
803
  null);
723
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
+ }
724
864
  export function evaluateSharedPackageVersionSpecs(options) {
725
865
  const issues = [];
726
866
  for (const packageName of options.packageNames) {
@@ -1180,6 +1320,7 @@ export function evaluateBrandlabArchitecture(options) {
1180
1320
  severity: "error",
1181
1321
  code: "brandlab-architecture-component-page-module",
1182
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.",
1183
1324
  path: relativePath,
1184
1325
  });
1185
1326
  }
@@ -1188,6 +1329,7 @@ export function evaluateBrandlabArchitecture(options) {
1188
1329
  severity: "error",
1189
1330
  code: "brandlab-architecture-component-static-page",
1190
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.",
1191
1333
  path: relativePath,
1192
1334
  });
1193
1335
  }
@@ -1319,7 +1461,7 @@ export function evaluateBrandlabArchitecture(options) {
1319
1461
  const directComponentImports = [
1320
1462
  ...source.matchAll(/from\s+['"](@\/components\/(?:sections|contact|ui)\/[^'"]+)['"]/g),
1321
1463
  ].map((match) => match[1]);
1322
- if (directComponentImports.length > 0) {
1464
+ if (appRouteFileIsDynamic(relativePath) && directComponentImports.length > 0) {
1323
1465
  issues.push({
1324
1466
  severity: "error",
1325
1467
  code: "brandlab-architecture-route-direct-component-composition",
@@ -1476,6 +1618,15 @@ export function evaluateBrandlabArchitecture(options) {
1476
1618
  path: relativePath,
1477
1619
  });
1478
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
+ }
1479
1630
  if (!/export\s+function\s+createSchema(?:Graph)?\b/.test(source))
1480
1631
  continue;
1481
1632
  if (source.includes("@brandlab-digital/site-core/schema"))
@@ -1803,6 +1954,16 @@ export function evaluateBrandlabArchitecture(options) {
1803
1954
  path: "src/lib/contact/ContactFormEmail.tsx",
1804
1955
  });
1805
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
+ }
1806
1967
  if (/message\s*:\s*error\s+instanceof\s+Error\s*&&\s*error\.message/.test(actionsSource)) {
1807
1968
  issues.push({
1808
1969
  severity: "error",
@@ -1923,6 +2084,49 @@ export function evaluateBrandlabArchitecture(options) {
1923
2084
  path: "package.json",
1924
2085
  });
1925
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
+ }
1926
2130
  }
1927
2131
  const tsconfigPath = join(options.root, "tsconfig.json");
1928
2132
  if (existsSync(tsconfigPath)) {
@@ -2133,6 +2337,48 @@ export function evaluateBrandlabArchitecture(options) {
2133
2337
  const brandlabConfigSource = existsSync(brandlabConfigPath)
2134
2338
  ? readFileSync(brandlabConfigPath, "utf8")
2135
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
+ : "";
2136
2382
  if (brandlabConfigSource.includes("contactAttachments") &&
2137
2383
  !/serverActions\s*:\s*\{[\s\S]*bodySizeLimit\s*:\s*['"]20mb['"]/.test(nextConfigSource)) {
2138
2384
  issues.push({
@@ -2142,6 +2388,67 @@ export function evaluateBrandlabArchitecture(options) {
2142
2388
  path: "next.config.ts",
2143
2389
  });
2144
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
+ }
2145
2452
  if (/const\s+staticLegacyMappings\s*[:=]/.test(nextConfigSource)) {
2146
2453
  issues.push({
2147
2454
  severity: "error",
@@ -2150,6 +2457,50 @@ export function evaluateBrandlabArchitecture(options) {
2150
2457
  path: "next.config.ts",
2151
2458
  });
2152
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
+ }
2153
2504
  const envExamplePath = join(options.root, ".env.example");
2154
2505
  if (existsSync(envExamplePath) &&
2155
2506
  LEGACY_ENV_ARTIFACT_PATTERNS.some((pattern) => pattern.test(readFileSync(envExamplePath, "utf8")))) {
@@ -2194,6 +2545,7 @@ export function evaluateBrandlabArchitecture(options) {
2194
2545
  }
2195
2546
  }
2196
2547
  const familyCounts = new Map();
2548
+ const templateImportRouteCounts = new Map();
2197
2549
  for (const filePath of pageFiles) {
2198
2550
  const family = appFamilyForPage(filePath);
2199
2551
  const current = familyCounts.get(family) ?? {
@@ -2208,7 +2560,16 @@ export function evaluateBrandlabArchitecture(options) {
2208
2560
  const source = readFileSync(join(options.root, filePath), "utf8");
2209
2561
  const lineCount = source.split(/\r?\n/).length;
2210
2562
  const routeOwnedContentChars = countRouteOwnedContentChars(source);
2211
- if (lineCount > largeRouteFileLineLimit) {
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) {
2212
2573
  issues.push({
2213
2574
  severity: "error",
2214
2575
  code: "large-route-file",
@@ -2216,15 +2577,15 @@ export function evaluateBrandlabArchitecture(options) {
2216
2577
  path: filePath,
2217
2578
  });
2218
2579
  }
2219
- if (routeOwnedContentChars > routeFileContentCharLimit) {
2580
+ if (appRouteFileIsDynamic(filePath) && routeOwnedContentChars > routeFileContentCharLimit) {
2220
2581
  issues.push({
2221
2582
  severity: "error",
2222
2583
  code: "route-owned-page-copy",
2223
- detail: `Route file contains about ${routeOwnedContentChars} characters of customer-facing copy. Standard route files should orchestrate data and components, not own 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.`,
2224
2585
  path: filePath,
2225
2586
  });
2226
2587
  }
2227
- if (CONTENT_ARRAY_PATTERN.test(source)) {
2588
+ if (appRouteFileIsDynamic(filePath) && CONTENT_ARRAY_PATTERN.test(source)) {
2228
2589
  issues.push({
2229
2590
  severity: "error",
2230
2591
  code: "embedded-route-content-array",
@@ -2240,7 +2601,9 @@ export function evaluateBrandlabArchitecture(options) {
2240
2601
  path: filePath,
2241
2602
  });
2242
2603
  }
2243
- const copyMatches = routeOwnedCopyPropertyMatches(source);
2604
+ const copyMatches = appRouteFileIsDynamic(filePath)
2605
+ ? routeOwnedCopyPropertyMatches(source)
2606
+ : [];
2244
2607
  if (copyMatches.length > 0) {
2245
2608
  const propertyNames = [
2246
2609
  ...new Set(copyMatches.map((match) => match.propertyName)),
@@ -2277,6 +2640,19 @@ export function evaluateBrandlabArchitecture(options) {
2277
2640
  });
2278
2641
  }
2279
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
+ }
2280
2656
  const staticRouteFileLimit = architecture?.staticRouteFileLimit ?? DEFAULT_STATIC_ROUTE_FILE_LIMIT;
2281
2657
  for (const [family, counts] of familyCounts.entries()) {
2282
2658
  if (counts.staticCount > staticRouteFileLimit &&