@brandlab-digital/conformance 2.1.0 → 3.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 (67) hide show
  1. package/dist/analytics-e2e.d.ts +52 -0
  2. package/dist/analytics-e2e.d.ts.map +1 -0
  3. package/dist/analytics-e2e.js +189 -0
  4. package/dist/analytics-e2e.js.map +1 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +40 -3
  8. package/dist/cli.js.map +1 -1
  9. package/dist/contact-e2e.d.ts +43 -0
  10. package/dist/contact-e2e.d.ts.map +1 -0
  11. package/dist/contact-e2e.js +193 -0
  12. package/dist/contact-e2e.js.map +1 -0
  13. package/dist/current-release.js +7 -7
  14. package/dist/current-release.js.map +1 -1
  15. package/dist/e2e-support.d.ts +42 -0
  16. package/dist/e2e-support.d.ts.map +1 -0
  17. package/dist/e2e-support.js +126 -0
  18. package/dist/e2e-support.js.map +1 -0
  19. package/dist/engineering.d.ts +14 -0
  20. package/dist/engineering.d.ts.map +1 -1
  21. package/dist/engineering.js +446 -50
  22. package/dist/engineering.js.map +1 -1
  23. package/dist/estate.d.ts +105 -0
  24. package/dist/estate.d.ts.map +1 -0
  25. package/dist/estate.js +391 -0
  26. package/dist/estate.js.map +1 -0
  27. package/dist/families-e2e.d.ts +45 -0
  28. package/dist/families-e2e.d.ts.map +1 -0
  29. package/dist/families-e2e.js +248 -0
  30. package/dist/families-e2e.js.map +1 -0
  31. package/dist/glob.d.ts +21 -0
  32. package/dist/glob.d.ts.map +1 -0
  33. package/dist/glob.js +138 -0
  34. package/dist/glob.js.map +1 -0
  35. package/dist/knip.d.ts +47 -0
  36. package/dist/knip.d.ts.map +1 -0
  37. package/dist/knip.js +84 -0
  38. package/dist/knip.js.map +1 -0
  39. package/dist/project.d.ts +4 -1
  40. package/dist/project.d.ts.map +1 -1
  41. package/dist/project.js +163 -147
  42. package/dist/project.js.map +1 -1
  43. package/dist/public-copy.d.ts +36 -0
  44. package/dist/public-copy.d.ts.map +1 -0
  45. package/dist/public-copy.js +517 -0
  46. package/dist/public-copy.js.map +1 -0
  47. package/dist/rule-registry.d.ts +292 -0
  48. package/dist/rule-registry.d.ts.map +1 -0
  49. package/dist/rule-registry.js +68 -0
  50. package/dist/rule-registry.js.map +1 -0
  51. package/dist/search-certification.d.ts +4 -0
  52. package/dist/search-certification.d.ts.map +1 -1
  53. package/dist/search-certification.js +17 -3
  54. package/dist/search-certification.js.map +1 -1
  55. package/dist/source-files.d.ts +54 -0
  56. package/dist/source-files.d.ts.map +1 -0
  57. package/dist/source-files.js +323 -0
  58. package/dist/source-files.js.map +1 -0
  59. package/dist/static-rules.d.ts +45 -0
  60. package/dist/static-rules.d.ts.map +1 -0
  61. package/dist/static-rules.js +762 -0
  62. package/dist/static-rules.js.map +1 -0
  63. package/dist/verification-policy.d.ts +13 -4
  64. package/dist/verification-policy.d.ts.map +1 -1
  65. package/dist/verification-policy.js +31 -12
  66. package/dist/verification-policy.js.map +1 -1
  67. package/package.json +37 -1
@@ -0,0 +1,762 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ import ts from "typescript";
4
+ import { globMatch } from "./glob.js";
5
+ import { findWordingInMarkdown, findWordingInSource, isPublicDocumentPath, isPublicModulePath, parseJsonSource, } from "./public-copy.js";
6
+ import { BRANDLAB_SOURCE_ROOT_DIRECTORIES, createBrandlabModuleResolver, findImportedCalls, importedLocalNames, isDirectory, lineAt, stripCommentsPreservingLayout, lineOfNode, listModuleReferences, objectPropertyValue, parseBrandlabSourceFile, stringLiteralValue, walkProjectFiles, } from "./source-files.js";
7
+ const SITE_CORE = (module) => new RegExp(`^@brandlab-digital/site-core/${module}$`);
8
+ const SITE_NEXT = (module) => new RegExp(`^@brandlab-digital/site-next/${module}$`);
9
+ const SITE_NEXT_ANY = /^@brandlab-digital\/site-next(?:\/|$)/;
10
+ const SOURCE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs"];
11
+ export function staticFinding(options) {
12
+ return {
13
+ ruleId: options.ruleId,
14
+ surface: options.surface,
15
+ severity: options.severity ?? "blocker",
16
+ contract: { name: "project", version: "current-only-v2" },
17
+ evidenceKind: "static",
18
+ ...(options.path ? { path: options.path } : {}),
19
+ ...(options.line ? { line: options.line } : {}),
20
+ expected: options.expected,
21
+ actual: options.actual,
22
+ remediation: options.remediation,
23
+ };
24
+ }
25
+ export function recordEvaluation(evaluations, surface, ...ruleIds) {
26
+ for (const ruleId of ruleIds)
27
+ evaluations.push({ ruleId, surface });
28
+ }
29
+ /** The minimal static rules behind the checks that previously passed without evidence. */
30
+ export function inspectStaticRules(context) {
31
+ const project = describeProject(context);
32
+ inspectContactProviderRoute(context, project);
33
+ inspectAnalyticsPrivacy(context);
34
+ inspectGoogleTagOwner(context);
35
+ inspectSearchRouting(context, project);
36
+ inspectBusinessFacts(context);
37
+ inspectSharedBindings(context);
38
+ inspectRouteCatalogue(context, project);
39
+ inspectLocationEstate(context, project);
40
+ inspectImplementationWording(context);
41
+ inspectPlatformReimplementation(context);
42
+ inspectRetiredConfiguration(context);
43
+ inspectWorkflowPins(context);
44
+ inspectVercelIgnore(context);
45
+ }
46
+ /** Capabilities declared by brandlab.config.ts, the contact route and the location data. */
47
+ export function describeCapabilities(root, config) {
48
+ const text = config ?? "";
49
+ const families = text.match(/\bfamilies\s*:\s*\[([^\]]*)\]/)?.[1] ?? "";
50
+ const strategy = text.match(/\blocations\s*:\s*\{[^{}]*\bstrategy\s*:\s*['"]([a-z-]+)['"]/)?.[1];
51
+ return {
52
+ contactEnabled: /\bcontact\s*:\s*\{/.test(text) ||
53
+ routeCandidates("api/contact/route").some((path) => existsSync(join(root, path))),
54
+ reviewsEnabled: /\breviews\s*:\s*\{/.test(text),
55
+ locationsDeclared: /['"](?:locations|service-locations)['"]/.test(families) ||
56
+ (strategy !== undefined && strategy !== "none") ||
57
+ isDirectory(join(root, "src/data/locations")),
58
+ sitemapMode: (text.match(/\bsitemap\s*:\s*\{[^{}]*\bmode\s*:\s*['"](single|split)['"]/)?.[1] ??
59
+ null),
60
+ };
61
+ }
62
+ function describeProject(context) {
63
+ const capabilities = describeCapabilities(context.root, context.config);
64
+ return {
65
+ ...capabilities,
66
+ contactEnabled: capabilities.contactEnabled || findContactContractRecipients(context.source).length > 0,
67
+ resolver: createBrandlabModuleResolver(context.root, new Set(context.source.byPath.keys())),
68
+ catalogueFiles: context.source.files
69
+ .filter((file) => findImportedCalls(file, SITE_CORE("routes"), "defineRouteCatalog").length > 0)
70
+ .map((file) => file.path),
71
+ };
72
+ }
73
+ // --- contact-provider-errors -------------------------------------------------
74
+ function inspectContactProviderRoute(context, project) {
75
+ if (!project.contactEnabled)
76
+ return;
77
+ const ruleId = "brandlab-contact-provider-route";
78
+ recordEvaluation(context.evaluations, "contact", ruleId);
79
+ const expected = "POST /api/contact produced by the shared handler (createBrandlabContactRouteHandler, or createBrandlabContactRuntime during transition)";
80
+ const remediation = "Export POST = createBrandlabContactRouteHandler({ contract, runtime }) from @brandlab-digital/site-next/contact/server.";
81
+ const route = firstExisting(context, routeCandidates("api/contact/route"));
82
+ if (!route) {
83
+ context.findings.push(staticFinding({
84
+ ruleId, surface: "contact", path: "src/app/api/contact/route.ts", expected, remediation,
85
+ actual: "The contact capability is enabled but src/app/api/contact/route.ts is missing.",
86
+ }));
87
+ return;
88
+ }
89
+ const file = context.source.byPath.get(route);
90
+ if (!file || !exportedNames(file).has("POST")) {
91
+ context.findings.push(staticFinding({
92
+ ruleId, surface: "contact", path: route, expected, remediation,
93
+ actual: "The contact route does not export a POST handler.",
94
+ }));
95
+ return;
96
+ }
97
+ const contactServer = SITE_NEXT("contact/server");
98
+ const direct = findImportedCalls(file, contactServer, "createBrandlabContactRouteHandler").length > 0;
99
+ const runtimeModules = [file, ...localImports(context, project.resolver, file)];
100
+ const transitional = /\.contactRouteHandler\b/.test(file.executable) &&
101
+ runtimeModules.some((candidate) => findImportedCalls(candidate, contactServer, "createBrandlabContactRuntime").length > 0);
102
+ if (!direct && !transitional) {
103
+ context.findings.push(staticFinding({
104
+ ruleId, surface: "contact", path: route, expected, remediation,
105
+ actual: "The POST handler is not created by the shared contact runtime, so provider errors are project-owned.",
106
+ }));
107
+ }
108
+ }
109
+ // --- analytics-privacy -------------------------------------------------------
110
+ const ANALYTICS_SINKS = new Set([
111
+ "gtag", "window.gtag", "globalThis.gtag",
112
+ "dataLayer.push", "window.dataLayer.push", "globalThis.dataLayer.push",
113
+ "sendGAEvent", "sendGTMEvent", "trackBrandlabBrowserEvent", "trackBrandlabEvent", "ReactGA.event",
114
+ ]);
115
+ const PII_TOKENS = new Set([
116
+ "attachment", "attachment_name", "contact_email", "contact_name", "customer_name", "email",
117
+ "email_address", "enquiry_message", "file_name", "filename", "first_name", "form_message",
118
+ "full_name", "last_name", "message", "mobile", "mobile_number", "phone", "phone_number",
119
+ "residential_address", "street_address", "user_email", "user_phone",
120
+ ]);
121
+ function inspectAnalyticsPrivacy(context) {
122
+ if (context.source.files.length === 0)
123
+ return;
124
+ const ruleId = "brandlab-analytics-pii-payload";
125
+ recordEvaluation(context.evaluations, "analytics", ruleId);
126
+ for (const file of context.source.files) {
127
+ const visit = (node) => {
128
+ if (ts.isCallExpression(node) && ANALYTICS_SINKS.has(memberPath(node.expression) ?? "")) {
129
+ const userData = stringLiteralValue(node.arguments[0]) === "set" &&
130
+ stringLiteralValue(node.arguments[1]) === "user_data" &&
131
+ node.arguments[2] && !(ts.isObjectLiteralExpression(node.arguments[2]) && node.arguments[2].properties.length === 0);
132
+ const piiNode = userData ? node.arguments[2] : node.arguments.map(findPiiNode).find(Boolean);
133
+ if (piiNode) {
134
+ context.findings.push(staticFinding({
135
+ ruleId,
136
+ surface: "analytics",
137
+ path: file.path,
138
+ line: lineOfNode(file, piiNode),
139
+ expected: "Analytics payloads carry only the shared allowlisted, non-identifying parameters",
140
+ actual: userData
141
+ ? "Project code sets Google user_data; enhanced conversion data is owned by the shared runtime."
142
+ : "A lead contact field, message or attachment detail reaches an analytics payload.",
143
+ remediation: "Send events through the shared Brandlab analytics runtime without lead details.",
144
+ }));
145
+ }
146
+ }
147
+ ts.forEachChild(node, visit);
148
+ };
149
+ visit(parseBrandlabSourceFile(file));
150
+ }
151
+ }
152
+ function findPiiNode(node) {
153
+ if (!node)
154
+ return undefined;
155
+ if (ts.isObjectLiteralExpression(node)) {
156
+ for (const property of node.properties) {
157
+ if (ts.isShorthandPropertyAssignment(property) && isPiiToken(property.name.text))
158
+ return property;
159
+ if (ts.isSpreadAssignment(property) && isPiiReference(property.expression))
160
+ return property;
161
+ if (!ts.isPropertyAssignment(property))
162
+ continue;
163
+ const key = ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name) ? property.name.text : null;
164
+ if ((key && isPiiToken(key)) || isPiiReference(property.initializer))
165
+ return property;
166
+ const nested = findPiiNode(property.initializer);
167
+ if (nested)
168
+ return nested;
169
+ }
170
+ return undefined;
171
+ }
172
+ if (ts.isArrayLiteralExpression(node))
173
+ return node.elements.map(findPiiNode).find(Boolean);
174
+ return isPiiReference(node) ? node : undefined;
175
+ }
176
+ function isPiiReference(node) {
177
+ if (ts.isIdentifier(node))
178
+ return isPiiToken(node.text);
179
+ if (ts.isPropertyAccessExpression(node))
180
+ return isPiiToken(node.name.text);
181
+ if (ts.isTemplateExpression(node))
182
+ return node.templateSpans.some((span) => isPiiReference(span.expression));
183
+ return false;
184
+ }
185
+ function isPiiToken(value) {
186
+ return PII_TOKENS.has(value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_"));
187
+ }
188
+ function memberPath(node) {
189
+ if (ts.isIdentifier(node))
190
+ return node.text;
191
+ if (ts.isPropertyAccessExpression(node)) {
192
+ const object = memberPath(node.expression);
193
+ return object ? `${object}.${node.name.text}` : null;
194
+ }
195
+ return null;
196
+ }
197
+ // --- analytics-conversion: one Google tag owner ------------------------------
198
+ function inspectGoogleTagOwner(context) {
199
+ if (context.source.files.length === 0)
200
+ return;
201
+ const ruleId = "brandlab-analytics-google-tag-owner";
202
+ recordEvaluation(context.evaluations, "analytics", ruleId);
203
+ const usages = context.source.files.flatMap((file) => {
204
+ const locals = new Set(importedLocalNames(file, SITE_NEXT_ANY, "BrandlabGoogleTag"));
205
+ if (locals.size === 0)
206
+ return [];
207
+ const found = [];
208
+ const visit = (node) => {
209
+ if ((ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) &&
210
+ ts.isIdentifier(node.tagName) &&
211
+ locals.has(node.tagName.text)) {
212
+ found.push({ path: file.path, line: lineOfNode(file, node) });
213
+ }
214
+ ts.forEachChild(node, visit);
215
+ };
216
+ visit(parseBrandlabSourceFile(file));
217
+ return found;
218
+ });
219
+ const rootLayouts = new Set(routeCandidates("layout").filter((path) => context.source.byPath.has(path)));
220
+ const expected = "Exactly one <BrandlabGoogleTag /> rendered from the root layout (src/app/layout.tsx)";
221
+ const remediation = "Render BrandlabGoogleTag once, directly in src/app/layout.tsx, and remove wrappers or duplicates.";
222
+ if (usages.length === 1 && rootLayouts.has(usages[0].path))
223
+ return;
224
+ context.findings.push(staticFinding({
225
+ ruleId,
226
+ surface: "analytics",
227
+ ...(usages[0] ? { path: usages[0].path, line: usages[0].line } : { path: "src/app/layout.tsx" }),
228
+ expected,
229
+ actual: usages.length === 0
230
+ ? "No BrandlabGoogleTag is rendered, so the shared tag and its events are absent."
231
+ : usages.length > 1
232
+ ? `BrandlabGoogleTag is rendered ${usages.length} times (${usages.map(({ path, line }) => `${path}:${line}`).join(", ")}).`
233
+ : "BrandlabGoogleTag is rendered outside the root layout.",
234
+ remediation,
235
+ }));
236
+ }
237
+ // --- search-routing ----------------------------------------------------------
238
+ function inspectSearchRouting(context, project) {
239
+ if (context.source.directoryRoots.length === 0)
240
+ return;
241
+ inspectPackageRoute(context, project, {
242
+ ruleId: "brandlab-robots-package-route",
243
+ surface: "robots",
244
+ candidates: routeCandidates("robots"),
245
+ staticFile: "public/robots.txt",
246
+ modules: [SITE_NEXT("robots")],
247
+ packageLabel: "@brandlab-digital/site-next/robots",
248
+ needsCatalogue: false,
249
+ });
250
+ inspectPackageRoute(context, project, {
251
+ ruleId: "brandlab-sitemap-package-route",
252
+ surface: "sitemap",
253
+ candidates: [...routeCandidates("sitemap.xml/route"), ...routeCandidates("sitemap")],
254
+ staticFile: "public/sitemap.xml",
255
+ modules: [SITE_NEXT("sitemaps"), SITE_CORE("sitemaps")],
256
+ packageLabel: "@brandlab-digital/site-next/sitemaps",
257
+ needsCatalogue: true,
258
+ });
259
+ inspectSitemapMode(context, project);
260
+ inspectMetadataBuilder(context, project);
261
+ }
262
+ function inspectPackageRoute(context, project, rule) {
263
+ recordEvaluation(context.evaluations, rule.surface, rule.ruleId);
264
+ const expected = `The ${rule.surface} route is a thin binding of ${rule.packageLabel}${rule.needsCatalogue ? " over the route catalogue" : ""}`;
265
+ const remediation = `Serve ${rule.surface} from ${rule.candidates[0]} using ${rule.packageLabel}${rule.needsCatalogue ? " and the routes from src/data/routes.ts" : ""}.`;
266
+ const report = (actual, path) => context.findings.push(staticFinding({
267
+ ruleId: rule.ruleId, surface: rule.surface, path, expected, actual, remediation,
268
+ }));
269
+ if (existsSync(join(context.root, rule.staticFile))) {
270
+ report(`${rule.staticFile} bypasses the environment-aware shared ${rule.surface} output.`, rule.staticFile);
271
+ }
272
+ const route = firstExisting(context, rule.candidates);
273
+ const file = route ? context.source.byPath.get(route) : undefined;
274
+ if (!route || !file) {
275
+ report(`No ${rule.surface} route was found.`, rule.candidates[0]);
276
+ return;
277
+ }
278
+ const references = listModuleReferences(file);
279
+ if (!references.some((reference) => rule.modules.some((module) => module.test(reference.specifier)))) {
280
+ report(`${route} does not use ${rule.packageLabel}.`, route);
281
+ return;
282
+ }
283
+ if (rule.needsCatalogue && project.catalogueFiles.length > 0 && !reachesCatalogue(context, project, file)) {
284
+ report(`${route} does not read the route catalogue.`, route);
285
+ }
286
+ }
287
+ function inspectSitemapMode(context, project) {
288
+ if (!project.sitemapMode)
289
+ return;
290
+ const ruleId = "brandlab-sitemap-split-mode";
291
+ recordEvaluation(context.evaluations, "sitemap", ruleId);
292
+ const chunkRoute = ["src/app/sitemaps", "app/sitemaps"].find((path) => isDirectory(join(context.root, path)));
293
+ if (project.sitemapMode === "split" ? chunkRoute : !chunkRoute)
294
+ return;
295
+ context.findings.push(staticFinding({
296
+ ruleId,
297
+ surface: "sitemap",
298
+ path: chunkRoute ?? "brandlab.config.ts",
299
+ expected: "/sitemaps/[chunk] exists only when brandlab.config sets sitemap.mode: 'split'",
300
+ actual: project.sitemapMode === "split"
301
+ ? "sitemap.mode is 'split' but no /sitemaps/[chunk] route exists."
302
+ : `sitemap.mode is 'single' but ${chunkRoute} still serves sitemap chunks.`,
303
+ remediation: "Keep the sitemap routes and brandlab.config sitemap.mode in agreement.",
304
+ }));
305
+ }
306
+ function inspectMetadataBuilder(context, project) {
307
+ const ruleId = "brandlab-metadata-route-builder";
308
+ recordEvaluation(context.evaluations, "metadata", ruleId);
309
+ const builders = context.source.files.filter((file) => findImportedCalls(file, SITE_NEXT("metadata"), "createRouteMetadataBuilder").length > 0);
310
+ const expected = "Page metadata built by createRouteMetadataBuilder(site, routes) from @brandlab-digital/site-next/metadata";
311
+ const remediation = "Bind pageMetadata = createRouteMetadataBuilder(site, routes) in src/lib/seo/metadata.ts.";
312
+ if (builders.length === 0) {
313
+ context.findings.push(staticFinding({
314
+ ruleId, surface: "metadata", path: "src/lib/seo/metadata.ts", expected, remediation,
315
+ actual: "No module builds page metadata with createRouteMetadataBuilder.",
316
+ }));
317
+ return;
318
+ }
319
+ if (project.catalogueFiles.length === 0)
320
+ return;
321
+ for (const builder of builders.filter((file) => !reachesCatalogue(context, project, file))) {
322
+ context.findings.push(staticFinding({
323
+ ruleId, surface: "metadata", path: builder.path, expected, remediation,
324
+ actual: "The metadata builder does not read the route catalogue.",
325
+ }));
326
+ }
327
+ }
328
+ function reachesCatalogue(context, project, file) {
329
+ const catalogues = new Set(project.catalogueFiles);
330
+ const seen = new Set([file.path]);
331
+ let frontier = [file];
332
+ for (let depth = 0; depth < 4 && frontier.length > 0; depth += 1) {
333
+ const next = [];
334
+ for (const current of frontier) {
335
+ for (const imported of localImports(context, project.resolver, current)) {
336
+ if (catalogues.has(imported.path))
337
+ return true;
338
+ if (seen.has(imported.path))
339
+ continue;
340
+ seen.add(imported.path);
341
+ next.push(imported);
342
+ }
343
+ }
344
+ frontier = next;
345
+ }
346
+ return false;
347
+ }
348
+ // --- business-identity and route-identity ------------------------------------
349
+ function inspectBusinessFacts(context) {
350
+ if (context.source.files.length === 0)
351
+ return;
352
+ exactlyOneDefinition(context, {
353
+ ruleId: "brandlab-business-facts",
354
+ surface: "business",
355
+ module: SITE_CORE("business"),
356
+ name: "defineBrandlabBusiness",
357
+ expectedPath: "src/data/business.ts",
358
+ subject: "Business facts",
359
+ });
360
+ }
361
+ /** The one-owner 3.0 bindings: site identity, redirects, analytics, next.config and instrumentation. */
362
+ function inspectSharedBindings(context) {
363
+ if (context.source.files.length === 0)
364
+ return;
365
+ exactlyOneDefinition(context, {
366
+ ruleId: "brandlab-site-identity",
367
+ surface: "metadata",
368
+ module: SITE_CORE("site"),
369
+ name: "defineBrandlabSite",
370
+ expectedPath: "src/config/site.ts",
371
+ subject: "The site identity",
372
+ });
373
+ exactlyOneDefinition(context, {
374
+ ruleId: "brandlab-redirects-table",
375
+ surface: "routes",
376
+ module: SITE_CORE("redirects"),
377
+ name: "defineRedirects",
378
+ expectedPath: "src/data/redirects.ts",
379
+ subject: "The redirect table",
380
+ });
381
+ exactlyOneDefinition(context, {
382
+ ruleId: "brandlab-analytics-contract",
383
+ surface: "analytics",
384
+ module: SITE_NEXT("analytics"),
385
+ name: "defineBrandlabAnalytics",
386
+ expectedPath: "src/config/analytics.ts",
387
+ subject: "The analytics contract",
388
+ });
389
+ requireBindingCall(context, {
390
+ ruleId: "brandlab-runtime-next-config",
391
+ surface: "runtime",
392
+ candidates: ["next.config.ts", "next.config.mts", "next.config.mjs", "next.config.js"],
393
+ module: SITE_NEXT("next-config"),
394
+ name: "defineBrandlabNextConfig",
395
+ expected: "next.config exports defineBrandlabNextConfig({ site, redirects, ... }) from @brandlab-digital/site-next/next-config",
396
+ consequence: "headers, image formats and Preview noindex are then project-owned",
397
+ });
398
+ requireBindingCall(context, {
399
+ ruleId: "brandlab-analytics-instrumentation",
400
+ surface: "analytics",
401
+ candidates: ["src/instrumentation-client.ts", "src/instrumentation-client.js", "instrumentation-client.ts", "instrumentation-client.js"],
402
+ module: SITE_NEXT("instrumentation-client"),
403
+ name: "installBrandlabInstrumentation",
404
+ expected: "src/instrumentation-client.ts calls installBrandlabInstrumentation(analytics)",
405
+ consequence: "telephone taps and the initial landing are not reported by the shared runtime",
406
+ });
407
+ }
408
+ function requireBindingCall(context, rule) {
409
+ recordEvaluation(context.evaluations, rule.surface, rule.ruleId);
410
+ const path = rule.candidates.find((candidate) => context.source.byPath.has(candidate));
411
+ const file = path ? context.source.byPath.get(path) : undefined;
412
+ if (file && findImportedCalls(file, rule.module, rule.name).length > 0)
413
+ return;
414
+ context.findings.push(staticFinding({
415
+ ruleId: rule.ruleId,
416
+ surface: rule.surface,
417
+ path: path ?? rule.candidates[0],
418
+ expected: rule.expected,
419
+ actual: file
420
+ ? `${path} does not call ${rule.name}, so ${rule.consequence}.`
421
+ : `${rule.candidates[0]} is missing, so ${rule.consequence}.`,
422
+ remediation: `Call ${rule.name} from the shared package in ${rule.candidates[0]}.`,
423
+ }));
424
+ }
425
+ function inspectRouteCatalogue(context, project) {
426
+ if (context.source.files.length === 0)
427
+ return;
428
+ exactlyOneDefinition(context, {
429
+ ruleId: "brandlab-routes-catalogue",
430
+ surface: "routes",
431
+ module: SITE_CORE("routes"),
432
+ name: "defineRouteCatalog",
433
+ expectedPath: "src/data/routes.ts",
434
+ subject: "The route catalogue",
435
+ known: project.catalogueFiles,
436
+ });
437
+ }
438
+ function exactlyOneDefinition(context, rule) {
439
+ recordEvaluation(context.evaluations, rule.surface, rule.ruleId);
440
+ const owners = rule.known ?? context.source.files
441
+ .filter((file) => findImportedCalls(file, rule.module, rule.name).length > 0)
442
+ .map((file) => file.path);
443
+ if (owners.length === 1)
444
+ return;
445
+ context.findings.push(staticFinding({
446
+ ruleId: rule.ruleId,
447
+ surface: rule.surface,
448
+ path: owners[0] ?? rule.expectedPath,
449
+ expected: `${rule.subject} declared once with ${rule.name}`,
450
+ actual: owners.length === 0
451
+ ? `No module calls ${rule.name}.`
452
+ : `${rule.name} is called in ${owners.length} modules: ${owners.join(", ")}.`,
453
+ remediation: `Keep one ${rule.name} owner in ${rule.expectedPath} and import it everywhere else.`,
454
+ }));
455
+ }
456
+ function inspectLocationEstate(context, project) {
457
+ if (!project.locationsDeclared)
458
+ return;
459
+ const ruleId = "brandlab-routes-location-estate";
460
+ recordEvaluation(context.evaluations, "routes", ruleId);
461
+ const estate = firstExisting(context, SOURCE_EXTENSIONS.slice(0, 2).map((extension) => `src/data/locations/estate.${extension}`));
462
+ const file = estate ? context.source.byPath.get(estate) : undefined;
463
+ if (file && findImportedCalls(file, SITE_CORE("locations"), "defineLocationEstate").length > 0)
464
+ return;
465
+ context.findings.push(staticFinding({
466
+ ruleId,
467
+ surface: "routes",
468
+ path: estate ?? "src/data/locations/estate.ts",
469
+ expected: "The published location list declared once in src/data/locations/estate.ts with defineLocationEstate",
470
+ actual: file
471
+ ? "src/data/locations/estate.ts does not declare the estate with defineLocationEstate."
472
+ : "The project publishes locations but has no src/data/locations/estate.ts.",
473
+ remediation: "Declare the published locations, retained exclusions and any site-owned locations with defineLocationEstate as literal data.",
474
+ }));
475
+ }
476
+ // --- public-output-safety ----------------------------------------------------
477
+ /** Directories that can hold public content documents (MDX, Markdown and JSON data). */
478
+ const DOCUMENT_ROOTS = [...BRANDLAB_SOURCE_ROOT_DIRECTORIES, "content", "posts"];
479
+ /**
480
+ * Implementation wording in anything that can render publicly: JSX text, rendered
481
+ * attributes (alt, title, aria-label and component copy props), copy-bearing string
482
+ * literals in pages, components, metadata, schema, configuration and data, MDX and
483
+ * Markdown content with its frontmatter, and JSON data. Class lists, styles, import
484
+ * paths, keys, types, comparisons, errors and console output are code, not copy.
485
+ */
486
+ function inspectImplementationWording(context) {
487
+ const modules = context.source.files.filter((file) => isPublicModulePath(file.path));
488
+ const documents = [...new Set(DOCUMENT_ROOTS)]
489
+ .flatMap((directory) => walkProjectFiles(context.root, directory))
490
+ .filter(isPublicDocumentPath);
491
+ if (modules.length === 0 && documents.length === 0)
492
+ return;
493
+ const ruleId = "brandlab-content-implementation-wording";
494
+ recordEvaluation(context.evaluations, "content", ruleId);
495
+ const report = (path, finding) => {
496
+ context.findings.push(staticFinding({
497
+ ruleId,
498
+ surface: "content",
499
+ path,
500
+ line: finding.line,
501
+ expected: "Publicly rendered copy is finished customer copy, not implementation wording",
502
+ actual: `Implementation-facing wording (${finding.pattern.source}) appears in ${finding.where}.`,
503
+ remediation: "Replace the wording with the finished customer copy before it can render publicly.",
504
+ }));
505
+ };
506
+ for (const file of modules) {
507
+ for (const finding of findWordingInSource(parseBrandlabSourceFile(file)))
508
+ report(file.path, finding);
509
+ }
510
+ for (const path of documents) {
511
+ const text = readFileSync(join(context.root, path), "utf8");
512
+ const findings = path.endsWith(".json")
513
+ ? findWordingInSource(parseJsonSource(path, text))
514
+ : findWordingInMarkdown(text);
515
+ for (const finding of findings)
516
+ report(path, finding);
517
+ }
518
+ }
519
+ // --- source-safety -----------------------------------------------------------
520
+ /** Shared platform surfaces a project must import rather than declare. */
521
+ const PROTECTED_PLATFORM_NAMES = new Set([
522
+ "BrandlabBreadcrumbs", "BrandlabGoogleTag", "ContactFormEmail", "SafeRichText", "SchemaScript",
523
+ "buildBrandlabMetadata", "buildLocalBusinessSchema", "buildNextMetadata", "buildRobotsMetadata",
524
+ "buildSchemaGraph", "buildSitemapEntries", "createBrandlabContactRouteHandler",
525
+ "createBrandlabContactRuntime", "createBrandlabRobots", "createBrandlabSitemapRoute",
526
+ "createNextMetadataBuilder", "createRouteAwareMdxBlogAdapter", "createRouteAwareStructuredBlogAdapter",
527
+ "createRouteMetadataBuilder", "defineAnalyticsContract", "defineBrandlabAnalytics",
528
+ "defineBrandlabBusiness", "defineBrandlabContact", "defineBrandlabNextConfig", "defineBrandlabProject",
529
+ "defineBrandlabSite", "defineBreadcrumbTrail", "defineContactContract", "defineLocationEstate",
530
+ "defineRedirects", "defineRouteCatalog", "installBrandlabInstrumentation", "installPhoneClickListener",
531
+ "renderContactEmail", "sitemapIndexXmlResponse", "sitemapXmlResponse", "trackBrandlabBrowserEvent",
532
+ "useBrandlabContactForm",
533
+ ]);
534
+ /** Package exports removed in 3.0.0 (web-vitals APIs are reported by the analytics rule). */
535
+ const RETIRED_PACKAGE_EXPORTS = [
536
+ "TrackedTelephoneLink", "shouldIndexSite", "buildRobotsMetadata", "buildSeoMetadata", "buildNextMetadata",
537
+ "buildBrandlabNextMetadata", "createNextMetadataBuilder", "createBrandlabNextMetadataBuilder",
538
+ "isBrandlabProductionDeployment", "resolveBrowserAnalyticsConfig", "resolveBrandlabGoogleTagConfig",
539
+ "BRANDLAB_NEXT_HEADER_PROFILES", "createContactHttpSubmissionController", "trackDeliveredContactResult",
540
+ "trackDeliveredContactWithEnhancedConversions", "trackDeliveredContact", "shouldTrackContactResult",
541
+ "isTrackableContactActionResult",
542
+ ];
543
+ /** APIs, options and paths removed in 3.0.0; their presence means an unfinished migration. */
544
+ const RETIRED_SOURCE_APIS = [
545
+ ...RETIRED_PACKAGE_EXPORTS.map((name) => ({ pattern: new RegExp(`\\b${name}\\b`), label: name })),
546
+ { pattern: /\bALLOW_INDEXING\b/, label: "the ALLOW_INDEXING override" },
547
+ { pattern: /\bNEXT_PUBLIC_SITE_URL\b/, label: "NEXT_PUBLIC_SITE_URL" },
548
+ {
549
+ pattern: /\bbuildBrandlabHeaderRules\s*\(\s*\{[^}]*\b(?:isProduction|profile)\b/,
550
+ label: "buildBrandlabHeaderRules({ isProduction, profile }) (pass the deployment instead)",
551
+ },
552
+ { pattern: /\b(?:xRobotsTag|contentSecurityPolicyMode)\s*:/, label: "the xRobotsTag or contentSecurityPolicyMode header option" },
553
+ { pattern: /\bleadDiagnostics\s*:/, label: "the leadDiagnostics flag (lead diagnostics are always sent)" },
554
+ ];
555
+ function inspectPlatformReimplementation(context) {
556
+ if (context.source.files.length === 0)
557
+ return;
558
+ const reimplementation = "brandlab-source-platform-reimplementation";
559
+ const retired = "brandlab-source-retired-api";
560
+ recordEvaluation(context.evaluations, "source", reimplementation, retired);
561
+ for (const file of context.source.files) {
562
+ const declared = declaredProtectedNames(file);
563
+ if (declared) {
564
+ context.findings.push(staticFinding({
565
+ ruleId: reimplementation,
566
+ surface: "source",
567
+ path: file.path,
568
+ line: declared.line,
569
+ expected: "Protected Brandlab platform surfaces are imported from the shared packages",
570
+ actual: `${declared.name} is declared locally instead of imported from the shared packages.`,
571
+ remediation: `Import ${declared.name} from @brandlab-digital/site-core or site-next and remove the local copy.`,
572
+ }));
573
+ }
574
+ for (const api of RETIRED_SOURCE_APIS) {
575
+ const match = file.executable.match(api.pattern);
576
+ if (!match || match.index === undefined)
577
+ continue;
578
+ context.findings.push(staticFinding({
579
+ ruleId: retired,
580
+ surface: "source",
581
+ path: file.path,
582
+ line: lineAt(file.text, match.index),
583
+ expected: "Only current 3.0 platform APIs",
584
+ actual: `The project still uses ${api.label}, which 3.0 removed.`,
585
+ remediation: "Migrate the caller to the current shared API and delete the retired path.",
586
+ }));
587
+ }
588
+ }
589
+ }
590
+ function declaredProtectedNames(file) {
591
+ const source = parseBrandlabSourceFile(file);
592
+ for (const statement of source.statements) {
593
+ const names = [];
594
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) {
595
+ names.push({ name: statement.name.text, node: statement.name });
596
+ }
597
+ else if (ts.isVariableStatement(statement)) {
598
+ for (const declaration of statement.declarationList.declarations) {
599
+ if (ts.isIdentifier(declaration.name))
600
+ names.push({ name: declaration.name.text, node: declaration.name });
601
+ }
602
+ }
603
+ const protectedName = names.find(({ name }) => PROTECTED_PLATFORM_NAMES.has(name));
604
+ if (protectedName)
605
+ return { name: protectedName.name, line: lineOfNode(file, protectedName.node) };
606
+ }
607
+ return null;
608
+ }
609
+ /** Project configuration keys removed in 3.0.0. */
610
+ const RETIRED_CONFIG_KEYS = [
611
+ {
612
+ pattern: /\broutes\s*:\s*\{[^{}]*\baliases\s*:/,
613
+ key: "aliases",
614
+ label: "routes.aliases",
615
+ remediation: "Move each alias into the redirect table in src/data/redirects.ts.",
616
+ },
617
+ {
618
+ pattern: /\bprofile\s*:/,
619
+ key: "profile",
620
+ label: "the profile field",
621
+ remediation: "Delete the profile field; defineBrandlabProject rejects unknown keys.",
622
+ },
623
+ ];
624
+ function inspectRetiredConfiguration(context) {
625
+ if (!context.config)
626
+ return;
627
+ const ruleId = "brandlab-source-retired-api";
628
+ recordEvaluation(context.evaluations, "source", ruleId);
629
+ const config = stripCommentsPreservingLayout(context.config);
630
+ for (const retired of RETIRED_CONFIG_KEYS) {
631
+ const match = config.match(retired.pattern);
632
+ if (!match || match.index === undefined)
633
+ continue;
634
+ context.findings.push(staticFinding({
635
+ ruleId,
636
+ surface: "source",
637
+ path: "brandlab.config.ts",
638
+ line: lineAt(context.config, match.index + match[0].lastIndexOf(retired.key)),
639
+ expected: "Only current 3.0 platform APIs",
640
+ actual: `brandlab.config.ts still declares ${retired.label}, which 3.0 removed.`,
641
+ remediation: retired.remediation,
642
+ }));
643
+ }
644
+ }
645
+ // --- structural: CI and deployment -------------------------------------------
646
+ function inspectWorkflowPins(context) {
647
+ const directory = join(context.root, ".github/workflows");
648
+ if (!isDirectory(directory))
649
+ return;
650
+ const workflows = readdirSync(directory).filter((name) => /\.ya?ml$/.test(name)).sort();
651
+ if (workflows.length === 0)
652
+ return;
653
+ const ruleId = "brandlab-ci-workflow-pinned";
654
+ recordEvaluation(context.evaluations, "project-verification", ruleId);
655
+ for (const name of workflows) {
656
+ const path = `.github/workflows/${name}`;
657
+ const text = readFileSync(join(context.root, path), "utf8");
658
+ for (const match of text.matchAll(/^[ \t]*-?[ \t]*uses[ \t]*:[ \t]*['"]?([^'"\s#]+)/gm)) {
659
+ const reference = match[1];
660
+ if (reference.startsWith("./") || reference.startsWith("docker://"))
661
+ continue;
662
+ if (/@[0-9a-f]{40}$/.test(reference))
663
+ continue;
664
+ context.findings.push(staticFinding({
665
+ ruleId,
666
+ surface: "project-verification",
667
+ path,
668
+ line: lineAt(text, match.index ?? 0),
669
+ expected: "Every reused workflow and action pinned to a full 40-character commit SHA",
670
+ actual: `${reference} is not pinned to an immutable commit.`,
671
+ remediation: "Pin the reference to the fleet workflow SHA recorded in the control repo.",
672
+ }));
673
+ }
674
+ }
675
+ }
676
+ function inspectVercelIgnore(context) {
677
+ const text = readOptionalFile(join(context.root, ".vercelignore"));
678
+ if (text === null)
679
+ return;
680
+ const ruleId = "brandlab-deployment-vercelignore-lockfile";
681
+ recordEvaluation(context.evaluations, "deployment", ruleId);
682
+ const excluded = ["package.json", "package-lock.json"].filter((file) => isIgnored(text, file));
683
+ if (excluded.length === 0)
684
+ return;
685
+ context.findings.push(staticFinding({
686
+ ruleId,
687
+ surface: "deployment",
688
+ path: ".vercelignore",
689
+ expected: ".vercelignore never excludes package.json or package-lock.json",
690
+ actual: `.vercelignore excludes ${excluded.join(" and ")}, so Vercel cannot install the exact locked release.`,
691
+ remediation: "Remove the package manifest and lockfile patterns from .vercelignore.",
692
+ }));
693
+ }
694
+ /** Evaluate gitignore-style patterns (last match wins) for a root-level file. */
695
+ export function isIgnored(patterns, file) {
696
+ let ignored = false;
697
+ for (const rawLine of patterns.split(/\r?\n/)) {
698
+ const line = rawLine.trim();
699
+ if (!line || line.startsWith("#"))
700
+ continue;
701
+ const negated = line.startsWith("!");
702
+ let pattern = negated ? line.slice(1) : line;
703
+ if (pattern.endsWith("/"))
704
+ continue;
705
+ pattern = pattern.replace(/^\//, "").replace(/^\*\*\//, "");
706
+ if (pattern.includes("/"))
707
+ continue;
708
+ if (globMatch(pattern, file))
709
+ ignored = !negated;
710
+ }
711
+ return ignored;
712
+ }
713
+ /** Every defineContactContract call and the recipient environment key it declares. */
714
+ export function findContactContractRecipients(source) {
715
+ return source.files.flatMap((file) => findImportedCalls(file, SITE_CORE("contact"), "defineContactContract").map((call) => ({
716
+ path: file.path,
717
+ line: lineOfNode(file, call),
718
+ recipientEnv: stringLiteralValue(objectPropertyValue(objectPropertyValue(call.arguments[0], "delivery"), "recipientEnv")),
719
+ })));
720
+ }
721
+ /** Framework route file candidates under src/app, then a legacy root app/. */
722
+ function routeCandidates(name) {
723
+ return ["src/app", "app"].flatMap((directory) => SOURCE_EXTENSIONS.map((extension) => posix.join(directory, `${name}.${extension}`)));
724
+ }
725
+ function firstExisting(context, candidates) {
726
+ return candidates.find((path) => context.source.byPath.has(path) || existsSync(join(context.root, path))) ?? null;
727
+ }
728
+ function localImports(context, resolver, file) {
729
+ return listModuleReferences(file)
730
+ .filter((reference) => !reference.typeOnly)
731
+ .flatMap((reference) => {
732
+ const resolved = resolver(file.path, reference.specifier);
733
+ const target = resolved ? context.source.byPath.get(resolved) : undefined;
734
+ return target ? [target] : [];
735
+ });
736
+ }
737
+ /** Names a module exports, including `export { local as POST }` forms. */
738
+ export function exportedNames(file) {
739
+ const names = new Set();
740
+ for (const statement of parseBrandlabSourceFile(file).statements) {
741
+ const exported = ts.canHaveModifiers(statement) &&
742
+ ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
743
+ if (exported && (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) {
744
+ names.add(statement.name.text);
745
+ }
746
+ else if (exported && ts.isVariableStatement(statement)) {
747
+ for (const declaration of statement.declarationList.declarations) {
748
+ if (ts.isIdentifier(declaration.name))
749
+ names.add(declaration.name.text);
750
+ }
751
+ }
752
+ else if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
753
+ for (const element of statement.exportClause.elements)
754
+ names.add(element.name.text);
755
+ }
756
+ }
757
+ return names;
758
+ }
759
+ function readOptionalFile(path) {
760
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
761
+ }
762
+ //# sourceMappingURL=static-rules.js.map