@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
@@ -1,11 +1,38 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
- import { join, relative } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, posix } from 'node:path';
3
3
  import ts from 'typescript';
4
+ import { globMatch } from './glob.js';
5
+ import { collectBrandlabSource, createBrandlabModuleResolver, findImportedCalls, isDirectory, lineOfNode, listModuleReferences, objectPropertyValue, parseBrandlabSourceFile, walkProjectFiles, } from './source-files.js';
6
+ import { describeCapabilities } from './static-rules.js';
4
7
  export const ENGINEERING_FAST_COMMANDS = ['typecheck', 'lint', 'test', 'check:unused', 'check:unused:production', 'check:standards'];
8
+ /** The exact fast verification command from the Brandlab 3.0 target (section 6). */
9
+ export const BRANDLAB_EXPECTED_VERIFY_SCRIPT = 'npm run check:standards && npm run typecheck && npm run lint && npm run test && npm run check:unused && npm run check:unused:production';
10
+ /** Scripts whose exact values the 3.0 target fixes; dev and start must exist. */
11
+ export const BRANDLAB_EXPECTED_SCRIPTS = Object.freeze({
12
+ build: 'next build',
13
+ typecheck: 'next typegen && tsc --noEmit',
14
+ lint: 'eslint .',
15
+ test: 'vitest run',
16
+ 'test:e2e': 'playwright test',
17
+ 'check:unused': 'knip',
18
+ 'check:unused:production': 'knip --production',
19
+ 'check:standards': 'brandlab-verify --mode ci --engineering',
20
+ });
21
+ const SRC_DIRECTORIES = new Set(['app', 'config', 'data', 'content', 'lib', 'components', 'types']);
22
+ const SRC_ROOT_FILES = /^(?:instrumentation(?:-client)?|proxy|mdx-components)\.(?:ts|tsx)$/;
23
+ const COMPONENT_ROLES = new Set(['layout', 'ui', 'sections', 'features', 'page-families']);
24
+ const TEST_DIRECTORIES = new Set(['config', 'data', 'lib', 'components', 'e2e', 'support']);
25
+ const RETIRED_ROOT_DIRECTORIES = ['app', 'components', 'data', 'lib', 'config', 'content', 'hooks', 'context', 'utils', 'src/components/templates'];
26
+ const DEV_TOOLING = ['eslint', 'vitest', '@playwright/test', 'knip'];
27
+ const NEXT_SPECIAL_FILE = /^(?:page|layout|template|loading|error|global-error|not-found|global-not-found|forbidden|unauthorized|default|route|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(?:ts|tsx|js|jsx|mdx)$/;
28
+ const CODE_OR_STYLE = /\.(?:[cm]?[jt]sx?|mdx|css)$/;
29
+ const PASCAL_COMPONENT = /^[A-Z][A-Za-z0-9]*\.tsx$/;
30
+ const CAMEL_MODULE = /^[a-z][A-Za-z0-9]*(?:\.d)?\.ts$/;
31
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
5
32
  /** Opt-in structural check. Static success must never masquerade as whole-project acceptance. */
6
33
  export function checkEngineeringStructure(root) {
7
34
  const findings = [];
8
- const add = (path, rule, message) => findings.push({ path, rule, message });
35
+ const add = (path, rule, message, extra = {}) => findings.push({ path, rule, message, severity: extra.severity ?? 'blocker', ...(extra.line ? { line: extra.line } : {}) });
9
36
  let pkg = {};
10
37
  try {
11
38
  pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
@@ -13,74 +40,443 @@ export function checkEngineeringStructure(root) {
13
40
  catch {
14
41
  add('package.json', 'package-readable', 'A readable package.json is required.');
15
42
  }
16
- for (const path of ['src/app', 'src/data', 'src/config', 'src/components']) {
17
- if (!existsSync(join(root, path)))
18
- add(path, 'source-organisation', `Place the corresponding implementation under ${path}.`);
19
- }
20
- for (const path of ['app', 'components', 'data', 'lib', 'config', 'content', 'src/components/templates']) {
21
- if (existsSync(join(root, path)))
22
- add(path, 'retired-source-layout', 'Migrate consumers to the current src responsibilities and remove the superseded directory.');
23
- }
43
+ const config = existsSync(join(root, 'brandlab.config.ts')) ? readFileSync(join(root, 'brandlab.config.ts'), 'utf8') : null;
44
+ const capabilities = describeCapabilities(root, config);
45
+ inspectCommands(pkg, add);
46
+ inspectDependencies(pkg, add);
47
+ inspectLayout(root, capabilities, add);
48
+ inspectSourceModules(root, add);
49
+ inspectTests(root, capabilities, add);
50
+ inspectToolPresets(root, add);
51
+ inspectDelivery(root, add);
52
+ return { status: findings.some(finding => finding.severity === 'blocker') ? 'blocked' : 'pass', findings,
53
+ coverage: 'Static layout, naming, command, dependency-pin, tool-preset, CI, deployment and documentation structure only. This is not engineering-standard completion.',
54
+ outstandingEvidence: ['Usage and deletion review including assets, configuration and dynamic references', 'Business data and component/style ownership review', 'Clean install, fast checks and production build', 'Full discovered technical URL estate', 'All distinct page families and important browser journeys', 'Exact-source protected Preview', 'Reporting/Ads provider cutover and authorised production verification'] };
55
+ }
56
+ // --- commands (section 6) ----------------------------------------------------
57
+ function inspectCommands(pkg, add) {
24
58
  const scripts = pkg.scripts ?? {};
25
- for (const command of [...ENGINEERING_FAST_COMMANDS, 'verify', 'build', 'test:e2e']) {
59
+ for (const command of ['dev', 'start', ...ENGINEERING_FAST_COMMANDS, 'verify', 'build', 'test:e2e']) {
26
60
  if (!scripts[command]?.trim())
27
61
  add('package.json', 'command-contract', `Missing ${command} command.`);
28
62
  }
63
+ for (const [name, expected] of Object.entries(BRANDLAB_EXPECTED_SCRIPTS)) {
64
+ const actual = scripts[name]?.trim();
65
+ if (actual && actual.replace(/\s+/g, ' ') !== expected) {
66
+ add('package.json', 'script-contract', `${name} must be exactly "${expected}" (found "${actual}").`);
67
+ }
68
+ }
69
+ const verify = scripts.verify?.trim();
70
+ if (verify) {
71
+ const unrecognised = splitCommandChain(verify).filter(segment => scriptReferences(segment, scripts) === null);
72
+ if (unrecognised.length > 0) {
73
+ add('package.json', 'verification-command-form', `verify may only chain package scripts (npm run <script>, npm test, run-s, run-p); "${unrecognised.join('", "')}" is not recognised. Expected "${BRANDLAB_EXPECTED_VERIFY_SCRIPT}".`);
74
+ }
75
+ }
29
76
  const reachable = new Set();
30
- function visit(name) {
77
+ const visit = (name) => {
31
78
  if (reachable.has(name))
32
79
  return;
33
80
  reachable.add(name);
34
- for (const match of (scripts[name] ?? '').matchAll(/\bnpm\s+run\s+([\w:-]+)/g))
35
- visit(match[1]);
36
- }
81
+ for (const segment of splitCommandChain(scripts[name] ?? '')) {
82
+ for (const reference of scriptReferences(segment, scripts) ?? [])
83
+ visit(reference);
84
+ }
85
+ };
37
86
  visit('verify');
38
87
  for (const command of ENGINEERING_FAST_COMMANDS) {
39
88
  if (!reachable.has(command))
40
- add('package.json', 'verification-wiring', `verify must execute ${command}.`);
89
+ add('package.json', 'verification-wiring', `verify must execute ${command}. Expected "${BRANDLAB_EXPECTED_VERIFY_SCRIPT}".`);
41
90
  }
42
91
  if (reachable.has('build'))
43
92
  add('package.json', 'single-build', 'Keep the production build separate from fast verify; run it once for the final candidate.');
44
- if (!/\bknip\b/.test(scripts['check:unused'] ?? '') || /--(?:no-exit-code|max-issues|fix)\b/.test(scripts['check:unused'] ?? '')) {
45
- add('package.json', 'unused-graph', 'check:unused must run Knip without bypassing failures or automatically deleting files.');
46
- }
47
- if (!/\bknip\b[^\n]*--production\b/.test(scripts['check:unused:production'] ?? ''))
48
- add('package.json', 'production-graph', 'check:unused:production must run Knip production analysis.');
49
- const knip = pkg.devDependencies?.knip;
50
- if (!knip || !/^\d+\.\d+\.\d+$/.test(knip))
51
- add('package.json', 'unused-tool-pin', 'Pin Knip exactly as a development dependency.');
52
- if (!/\bbrandlab-verify\b[^\n]*--engineering\b/.test(scripts['check:standards'] ?? ''))
53
- add('package.json', 'standards-wiring', 'check:standards must execute brandlab-verify --engineering.');
54
- function inspect(directory) {
55
- if (!existsSync(directory))
56
- return;
57
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
58
- const path = join(directory, entry.name);
59
- if (entry.isSymbolicLink())
93
+ }
94
+ /** Split a shell command on its chaining operators. */
95
+ export function splitCommandChain(command) {
96
+ return command.split(/&&|\|\||[;|&]/).map(segment => segment.trim()).filter(Boolean);
97
+ }
98
+ /**
99
+ * The package scripts one command segment runs, or null when the segment is
100
+ * not a recognised script invocation (npm run, npm test, run-s, run-p, npm-run-all).
101
+ */
102
+ export function scriptReferences(segment, scripts) {
103
+ const words = segment.split(/\s+/).filter(Boolean);
104
+ while (words[0] && /^[A-Z_][A-Z0-9_]*=/.test(words[0]))
105
+ words.shift();
106
+ const [tool, ...rest] = words;
107
+ if (tool === 'npm') {
108
+ const [subcommand, ...args] = rest;
109
+ if (subcommand === 'test' || subcommand === 't')
110
+ return ['test'];
111
+ if (subcommand === 'start' || subcommand === 'stop' || subcommand === 'restart')
112
+ return [subcommand];
113
+ if (subcommand === 'run' || subcommand === 'run-script') {
114
+ const name = args.find(arg => !arg.startsWith('-'));
115
+ return name ? [name] : null;
116
+ }
117
+ return null;
118
+ }
119
+ if (tool === 'run-s' || tool === 'run-p' || tool === 'npm-run-all') {
120
+ const patterns = [];
121
+ for (const word of rest) {
122
+ if (word === '--')
123
+ break;
124
+ if (!word.startsWith('-'))
125
+ patterns.push(word.replace(/^['"]|['"]$/g, ''));
126
+ }
127
+ if (patterns.length === 0)
128
+ return null;
129
+ // npm-run-all globs: `*` stays within one `:` segment; a whole `**` segment spans several.
130
+ return patterns.flatMap(pattern => pattern.includes('*') || pattern.includes('?') || pattern.includes('[')
131
+ ? Object.keys(scripts).filter(name => globMatch(pattern, name, { separator: ':', globstar: true }))
132
+ : [pattern]);
133
+ }
134
+ return null;
135
+ }
136
+ // --- dependencies (section 1) ------------------------------------------------
137
+ function inspectDependencies(pkg, add) {
138
+ if (pkg.type !== 'module')
139
+ add('package.json', 'module-type', 'Declare "type": "module".');
140
+ const dependencies = { ...pkg.dependencies, ...pkg.devDependencies };
141
+ for (const tool of DEV_TOOLING) {
142
+ const spec = pkg.devDependencies?.[tool];
143
+ if (!spec || !SEMVER.test(spec))
144
+ add('package.json', 'dev-tooling', `Pin ${tool} exactly as a development dependency.`);
145
+ }
146
+ // Packed preflight binds the four Brandlab packages to local file: archives; the evaluator verifies those.
147
+ const ranges = Object.entries(dependencies)
148
+ .filter(([name, spec]) => !SEMVER.test(spec) && !(name.startsWith('@brandlab-digital/') && spec.startsWith('file:')))
149
+ .map(([name, spec]) => `${name}@${spec}`);
150
+ if (ranges.length > 0)
151
+ add('package.json', 'exact-pins', `Pin every dependency to the exact locked version: ${ranges.sort().join(', ')}.`);
152
+ const next = dependencies.next;
153
+ for (const companion of ['eslint-config-next', '@next/mdx']) {
154
+ const spec = dependencies[companion];
155
+ if (spec && spec !== next)
156
+ add('package.json', 'framework-companion-pin', `${companion} must equal the next version (${next ?? 'missing'}); found ${spec}.`);
157
+ }
158
+ if (!dependencies['eslint-config-next'])
159
+ add('package.json', 'framework-companion-pin', 'Declare eslint-config-next at the exact next version for brandlab.configs.next.');
160
+ if (pkg.dependencies?.sharp || pkg.devDependencies?.sharp)
161
+ add('package.json', 'sharp-override-only', 'Remove the direct sharp dependency; the release-owned overrides.sharp pin is the only declaration.');
162
+ }
163
+ // --- layout and naming (section 2) --------------------------------------------
164
+ function inspectLayout(root, capabilities, add) {
165
+ for (const path of ['src/app', 'src/config', 'src/data', 'src/lib', 'src/components', 'tests']) {
166
+ if (!isDirectory(join(root, path)))
167
+ add(path, 'source-organisation', `Place the corresponding implementation under ${path}.`);
168
+ }
169
+ for (const path of RETIRED_ROOT_DIRECTORIES) {
170
+ if (existsSync(join(root, path)))
171
+ add(path, 'retired-source-layout', 'Migrate consumers to the current src responsibilities and remove the superseded directory.');
172
+ }
173
+ const required = [
174
+ 'src/config/site.ts', 'src/config/analytics.ts', 'src/data/business.ts', 'src/data/routes.ts', 'src/data/redirects.ts',
175
+ 'src/lib/seo/metadata.ts', 'src/instrumentation-client.ts', 'src/app/layout.tsx', 'src/app/robots.ts', 'src/app/sitemap.xml/route.ts',
176
+ ...(capabilities.contactEnabled ? ['src/config/contact.ts', 'src/app/api/contact/route.ts'] : []),
177
+ ...(capabilities.reviewsEnabled ? ['src/config/reviews.ts'] : []),
178
+ ...(capabilities.locationsDeclared ? ['src/data/locations/estate.ts'] : []),
179
+ ];
180
+ for (const path of required) {
181
+ if (!existsSync(join(root, path)))
182
+ add(path, 'owner-file', `The 3.0 layout places this owner at ${path}.`);
183
+ }
184
+ for (const [path, message] of [
185
+ ['src/config/env.ts', 'Remove the local environment parser; site configuration is a source constant.'],
186
+ ['src/lib/analytics', 'Remove the local analytics layer; installBrandlabInstrumentation owns it.'],
187
+ ['src/lib/contact/definition.ts', 'Remove the local contact definition; the contract lives in src/config/contact.ts.'],
188
+ ['src/lib/contact/constants.ts', 'Remove the local contact constants; the contract lives in src/config/contact.ts.'],
189
+ ['src/lib/contact/runtime.ts', 'Remove the local contact runtime; POST uses createBrandlabContactRouteHandler.'],
190
+ ['src/components/analytics/GoogleTag.tsx', 'Render BrandlabGoogleTag directly in src/app/layout.tsx.'],
191
+ ['src/components/analytics/CoreWebVitalsReporter.tsx', 'Remove the web-vitals reporter.'],
192
+ ['src/lib/seo/sitemaps', 'Remove the local sitemap builders; the sitemap route uses createBrandlabSitemapRoute.'],
193
+ ['src/lib/seo/sitemaps.ts', 'Remove the local sitemap builders; the sitemap route uses createBrandlabSitemapRoute.'],
194
+ ['src/lib/seo/schema/sanitize.ts', 'Remove the local schema sanitiser; the packages own sanitising.'],
195
+ ['src/lib/seo/schema/SchemaScript.tsx', 'Import SchemaScript from @brandlab-digital/site-next/schema.'],
196
+ ['src/lib/seo/index.ts', 'Remove the SEO barrel and import each module directly.'],
197
+ ]) {
198
+ if (existsSync(join(root, path)))
199
+ add(path, 'retired-file', message);
200
+ }
201
+ if (isDirectory(join(root, 'src'))) {
202
+ for (const entry of readdirSync(join(root, 'src'), { withFileTypes: true })) {
203
+ if (entry.name.startsWith('.'))
60
204
  continue;
61
- if (entry.isDirectory()) {
62
- inspect(path);
205
+ const path = `src/${entry.name}`;
206
+ if (entry.isDirectory() && SRC_DIRECTORIES.has(entry.name)) {
207
+ if (entry.name === 'types' && walkProjectFiles(root, path).some(file => !file.endsWith('.d.ts'))) {
208
+ add(path, 'standard-layout', 'src/types may hold only ambient .d.ts declarations.');
209
+ }
63
210
  continue;
64
211
  }
65
- if (!/\.(?:tsx?|jsx?|mjs|cjs)$/.test(entry.name) || /\.(?:test|spec)\./.test(entry.name))
212
+ if (entry.isFile() && SRC_ROOT_FILES.test(entry.name))
66
213
  continue;
67
- const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
68
- const location = relative(root, path).replaceAll('\\', '/');
69
- function walk(node) {
70
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith('@brandlab-digital/')) {
71
- const bindings = node.importClause?.namedBindings;
72
- if (bindings && ts.isNamedImports(bindings) && bindings.elements.some(element => (element.propertyName ?? element.name).text === 'trackPhoneCall')) {
73
- add(location, 'retired-phone-api', 'Use trackPhoneLinkClick; the deprecated alias is removed in the current major.');
74
- }
214
+ add(path, 'standard-layout', `${path} is outside the standard layout (src/{app,config,data,content,lib,components}).`);
215
+ }
216
+ }
217
+ if (isDirectory(join(root, 'src/components'))) {
218
+ for (const entry of readdirSync(join(root, 'src/components'), { withFileTypes: true })) {
219
+ if (entry.name.startsWith('.') || entry.name === 'templates')
220
+ continue;
221
+ if (!entry.isDirectory() || !COMPONENT_ROLES.has(entry.name)) {
222
+ add(`src/components/${entry.name}`, 'component-role', 'Components belong to one role: layout, ui, sections, features or page-families.');
223
+ }
224
+ }
225
+ }
226
+ for (const path of walkProjectFiles(root, 'src'))
227
+ inspectFileName(root, path, add);
228
+ }
229
+ function inspectFileName(root, path, add) {
230
+ const name = posix.basename(path);
231
+ const directory = posix.dirname(path);
232
+ if (directory === 'src' && SRC_ROOT_FILES.test(name))
233
+ return;
234
+ if (name.endsWith('.module.css')) {
235
+ const component = name.replace(/\.module\.css$/, '');
236
+ if (!['tsx', 'ts', 'jsx'].some(extension => existsSync(join(root, directory, `${component}.${extension}`)))) {
237
+ add(path, 'file-naming', `Keep ${name} beside the ${component} component it styles.`);
238
+ }
239
+ return;
240
+ }
241
+ if (path.startsWith('src/app/')) {
242
+ // Images, fonts and metadata assets are framework-consumed; only code and styles are placed.
243
+ if (!CODE_OR_STYLE.test(name))
244
+ return;
245
+ const segments = path.split('/');
246
+ const privateFolder = segments.slice(2, -1).find(segment => segment.startsWith('_') && segment !== '_components');
247
+ if (privateFolder) {
248
+ add(path, 'route-local-code', `Route-local code belongs in src/app/<route>/_components/, not ${privateFolder}/.`);
249
+ return;
250
+ }
251
+ if (!segments.includes('_components')) {
252
+ if (NEXT_SPECIAL_FILE.test(name) || path === 'src/app/globals.css')
253
+ return;
254
+ add(path, 'route-local-code', 'Route-local code belongs in src/app/<route>/_components/; framework files keep their reserved names.');
255
+ return;
256
+ }
257
+ }
258
+ if (!/\.(?:ts|tsx)$/.test(name) || path.startsWith('src/content/'))
259
+ return;
260
+ if (name.endsWith('.tsx') && !PASCAL_COMPONENT.test(name)) {
261
+ add(path, 'file-naming', 'Components use PascalCase .tsx file names.');
262
+ }
263
+ else if (name.endsWith('.ts') && !CAMEL_MODULE.test(name)) {
264
+ add(path, 'file-naming', 'Data and helper modules use camelCase .ts file names.');
265
+ }
266
+ if (path.startsWith('src/components/page-families/') && name.endsWith('.tsx') && !/^[A-Z][A-Za-z0-9]*Page\.tsx$/.test(name)) {
267
+ add(path, 'file-naming', 'Page families are named <RouteKind>Page.tsx.');
268
+ }
269
+ }
270
+ // --- module structure: barrels, ui data imports, estate ----------------------
271
+ function inspectSourceModules(root, add) {
272
+ const source = collectBrandlabSource(root);
273
+ const resolver = createBrandlabModuleResolver(root, new Set(source.byPath.keys()));
274
+ for (const file of source.files.filter(candidate => candidate.path.startsWith('src/'))) {
275
+ const references = listModuleReferences(file);
276
+ const forwarding = forwardingExports(file);
277
+ if (isBarrel(file)) {
278
+ add(file.path, 'no-barrels', 'This module only re-exports other modules; import each owner directly.');
279
+ }
280
+ else {
281
+ for (const line of forwarding) {
282
+ add(file.path, 'no-forwarding-exports', 'This export forwards another module; import the owner directly.', { line });
283
+ }
284
+ }
285
+ if (file.path.startsWith('src/components/ui/')) {
286
+ for (const reference of references.filter(candidate => candidate.kind !== 'export')) {
287
+ const resolved = resolver(file.path, reference.specifier);
288
+ if (resolved?.startsWith('src/data/') || /^@\/data(?:\/|$)/.test(reference.specifier)) {
289
+ add(file.path, 'ui-imports-data', 'UI primitives never import data modules; pass prepared props from a section or route.', { line: reference.line });
75
290
  }
76
- ts.forEachChild(node, walk);
77
291
  }
78
- walk(source);
79
292
  }
293
+ inspectRegistryDerivedEstate(file, add);
80
294
  }
81
- inspect(join(root, 'src'));
82
- return { status: findings.length ? 'blocked' : 'pass', findings,
83
- coverage: 'Static source organisation, command wiring and retired API imports only. This is not engineering-standard completion.',
84
- outstandingEvidence: ['Usage and deletion review including assets, configuration and dynamic references', 'Business data and component/style ownership review', 'Clean install, fast checks and production build', 'Full discovered technical URL estate', 'All distinct page families and important browser journeys', 'Exact-source protected Preview', 'Reporting/Ads provider cutover and authorised production verification'] };
295
+ const estate = source.byPath.get('src/data/locations/estate.ts');
296
+ if (estate) {
297
+ inspectRetainedExclusions(estate, add);
298
+ inspectSiteOwnedLocations(estate, add);
299
+ }
300
+ }
301
+ /** Lines of `export ... from` statements and exports of names this module only imported. */
302
+ function forwardingExports(file) {
303
+ const source = parseBrandlabSourceFile(file);
304
+ const imported = new Set(listModuleReferences(file).filter(reference => reference.kind === 'import').flatMap(reference => reference.bindings.map(binding => binding.local)));
305
+ const lines = [];
306
+ for (const statement of source.statements) {
307
+ const forwards = (ts.isExportDeclaration(statement) && (statement.moduleSpecifier !== undefined ||
308
+ (statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause) &&
309
+ statement.exportClause.elements.some(element => imported.has((element.propertyName ?? element.name).text))))) ||
310
+ (ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression) && imported.has(statement.expression.text));
311
+ if (forwards)
312
+ lines.push(lineOfNode(file, statement));
313
+ }
314
+ return lines;
315
+ }
316
+ /** A module with no declarations or statements of its own besides imports and exports. */
317
+ function isBarrel(file) {
318
+ const statements = parseBrandlabSourceFile(file).statements;
319
+ return statements.length > 0 && forwardingExports(file).length > 0 &&
320
+ statements.every(statement => ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement) || ts.isExportAssignment(statement));
321
+ }
322
+ function inspectRegistryDerivedEstate(file, add) {
323
+ const derived = listModuleReferences(file)
324
+ .filter(reference => reference.kind === 'import' && /^@brandlab-digital\/site-core\//.test(reference.specifier))
325
+ .flatMap(reference => reference.bindings.filter(binding => /WebsiteSuburbs$/.test(binding.imported)).map(binding => ({ ...binding, line: reference.line })));
326
+ const namespaceUse = file.executable.match(/\.\s*([a-z][A-Za-z]*WebsiteSuburbs)\b/);
327
+ const first = derived[0] ?? (namespaceUse ? { imported: namespaceUse[1], line: lineAtIndex(file.text, namespaceUse.index ?? 0) } : null);
328
+ if (!first)
329
+ return;
330
+ add(file.path, 'estate-registry-derived', `${first.imported} is a registry candidate list; the published estate in src/data/locations/estate.ts must be literal data, never derived from it.`, { line: first.line });
331
+ }
332
+ function inspectRetainedExclusions(file, add) {
333
+ for (const call of findImportedCalls(file, /^@brandlab-digital\/site-core\/locations$/, 'defineLocationEstate')) {
334
+ const retained = objectPropertyValue(call.arguments[0], 'retainedExclusions');
335
+ if (!retained || !ts.isArrayLiteralExpression(retained) || retained.elements.length === 0)
336
+ continue;
337
+ const slugs = retained.elements.map(element => {
338
+ if (ts.isStringLiteralLike(element))
339
+ return element.text;
340
+ const slug = objectPropertyValue(element, 'slug');
341
+ return slug && ts.isStringLiteralLike(slug) ? slug.text : null;
342
+ }).filter((slug) => Boolean(slug));
343
+ add(file.path, 'estate-retained-exclusions', `${retained.elements.length} policy-excluded location(s) stay published for a later SEO decision${slugs.length ? `: ${slugs.join(', ')}` : ''}.`, { severity: 'info', line: lineOfNode(file, retained) });
344
+ }
345
+ }
346
+ /** An array literal, directly or through a top-level `const name = [...]` (optionally `as const`) in the same file. */
347
+ function arrayLiteral(file, node) {
348
+ const unwrap = (expression) => {
349
+ let current = expression;
350
+ while (current && (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current))) {
351
+ current = current.expression;
352
+ }
353
+ return current;
354
+ };
355
+ const value = unwrap(node);
356
+ if (value && ts.isArrayLiteralExpression(value))
357
+ return value;
358
+ if (!value || !ts.isIdentifier(value))
359
+ return undefined;
360
+ for (const statement of parseBrandlabSourceFile(file).statements) {
361
+ if (!ts.isVariableStatement(statement))
362
+ continue;
363
+ for (const declaration of statement.declarationList.declarations) {
364
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === value.text) {
365
+ const initializer = unwrap(declaration.initializer);
366
+ return initializer && ts.isArrayLiteralExpression(initializer) ? initializer : undefined;
367
+ }
368
+ }
369
+ }
370
+ return undefined;
371
+ }
372
+ /** Report the locations the site publishes outside the shared registry, with each group's reason. */
373
+ function inspectSiteOwnedLocations(file, add) {
374
+ for (const call of findImportedCalls(file, /^@brandlab-digital\/site-core\/locations$/, 'defineLocationEstate')) {
375
+ const declared = objectPropertyValue(call.arguments[0], 'siteOwned');
376
+ if (!declared)
377
+ continue;
378
+ const groups = arrayLiteral(file, declared);
379
+ if (!groups) {
380
+ add(file.path, 'estate-site-owned', 'siteOwned must be literal data in src/data/locations/estate.ts: [{ reason, slugs }], never derived.', { line: lineOfNode(file, declared) });
381
+ continue;
382
+ }
383
+ if (groups.elements.length === 0)
384
+ continue;
385
+ let total = 0;
386
+ const summaries = groups.elements.map(element => {
387
+ const reason = objectPropertyValue(element, 'reason');
388
+ const slugs = arrayLiteral(file, objectPropertyValue(element, 'slugs'));
389
+ const count = slugs?.elements.length;
390
+ total += count ?? 0;
391
+ const label = reason && ts.isStringLiteralLike(reason) ? reason.text : 'a reason that is not a string literal';
392
+ return `${count ?? 'an unknown number of'} for "${label}"`;
393
+ });
394
+ add(file.path, 'estate-site-owned', `${total} location(s) are published outside the shared geography registry, declared site-owned: ${summaries.join('; ')}.`, { severity: 'info', line: lineOfNode(file, groups) });
395
+ }
396
+ }
397
+ function lineAtIndex(text, index) {
398
+ return text.slice(0, index).split('\n').length;
399
+ }
400
+ // --- tests (sections 2 and 6) -------------------------------------------------
401
+ function inspectTests(root, capabilities, add) {
402
+ if (!isDirectory(join(root, 'tests')))
403
+ return;
404
+ for (const entry of readdirSync(join(root, 'tests'), { withFileTypes: true })) {
405
+ if (entry.name.startsWith('.'))
406
+ continue;
407
+ if (!entry.isDirectory() || !TEST_DIRECTORIES.has(entry.name)) {
408
+ add(`tests/${entry.name}`, 'test-layout', 'Tests live in tests/{config,data,lib,components,e2e,support}.');
409
+ }
410
+ }
411
+ const required = [
412
+ 'tests/data/routes.test.ts', 'tests/config/analytics.test.ts',
413
+ 'tests/e2e/estate.spec.ts', 'tests/e2e/analytics.spec.ts', 'tests/e2e/families.spec.ts',
414
+ ...(capabilities.contactEnabled ? ['tests/config/contact.test.ts', 'tests/e2e/contact.spec.ts'] : []),
415
+ ];
416
+ for (const path of required) {
417
+ if (!existsSync(join(root, path)))
418
+ add(path, 'shared-tests', `The shared 3.0 test set includes ${path}.`);
419
+ }
420
+ for (const path of walkProjectFiles(root, 'tests')) {
421
+ if (/-darwin\.png$/.test(path))
422
+ add(path, 'committed-snapshots', 'Delete committed darwin snapshots; rendered checks run against the CI build.');
423
+ }
424
+ }
425
+ // --- dev presets (section 4) -------------------------------------------------
426
+ function inspectToolPresets(root, add) {
427
+ const eslint = readOptional(join(root, 'eslint.config.mjs'));
428
+ if (eslint === null)
429
+ add('eslint.config.mjs', 'tool-preset', 'Add eslint.config.mjs: export default brandlab.configs.next.');
430
+ else if (!/@brandlab-digital\/eslint-plugin/.test(eslint) || !/export\s+default\s+[A-Za-z_$][\w$]*\.configs\.next\s*;?\s*$/.test(eslint.trim())) {
431
+ add('eslint.config.mjs', 'tool-preset', 'eslint.config.mjs is exactly `export default brandlab.configs.next` (imported from @brandlab-digital/eslint-plugin).');
432
+ }
433
+ const knip = readOptional(join(root, 'knip.ts'));
434
+ if (knip === null)
435
+ add('knip.ts', 'tool-preset', 'Add knip.ts: export default brandlabKnip({ mdx?, entry? }).');
436
+ else if (!/@brandlab-digital\/conformance\/knip/.test(knip) || !/export\s+default\s+brandlabKnip\s*\(/.test(knip)) {
437
+ add('knip.ts', 'tool-preset', 'knip.ts exports brandlabKnip({ mdx?, entry? }) from @brandlab-digital/conformance/knip.');
438
+ }
439
+ if (!['playwright.config.ts', 'playwright.config.mts'].some(name => existsSync(join(root, name)))) {
440
+ add('playwright.config.ts', 'tool-preset', 'Add the Playwright configuration for the shared e2e specs.');
441
+ }
442
+ if (!['vitest.config.ts', 'vitest.config.mts'].some(name => existsSync(join(root, name)))) {
443
+ add('vitest.config.ts', 'tool-preset', 'Add the Vitest configuration for the binding tests.');
444
+ }
445
+ }
446
+ // --- CI, dependencies, Vercel and docs (section 6) ---------------------------
447
+ function inspectDelivery(root, add) {
448
+ const workflowDirectory = join(root, '.github/workflows');
449
+ const workflows = isDirectory(workflowDirectory) ? readdirSync(workflowDirectory).filter(name => /\.ya?ml$/.test(name)).sort() : [];
450
+ const callers = workflows.filter(name => /uses\s*:\s*['"]?RomieDandan\/brandlab\/\.github\/workflows\/verify-next\.yml@/.test(readFileSync(join(workflowDirectory, name), 'utf8')));
451
+ if (callers.length !== 1 || callers[0] !== 'verify.yml') {
452
+ add('.github/workflows/verify.yml', 'ci-workflow', `Exactly one .github/workflows/verify.yml calls RomieDandan/brandlab/.github/workflows/verify-next.yml at the fleet SHA (found ${callers.length ? callers.join(', ') : 'none'}).`);
453
+ }
454
+ for (const path of ['.github/dependabot.yml', '.github/dependabot.yaml', 'renovate.json', 'renovate.json5', '.github/renovate.json', '.renovaterc', '.renovaterc.json']) {
455
+ if (existsSync(join(root, path)))
456
+ add(path, 'dependency-bots', 'Exact pins move with Brandlab releases; remove Dependabot/Renovate version updates.');
457
+ }
458
+ const vercel = readOptional(join(root, 'vercel.json'));
459
+ let installCommand;
460
+ try {
461
+ installCommand = vercel === null ? undefined : JSON.parse(vercel).installCommand;
462
+ }
463
+ catch {
464
+ installCommand = undefined;
465
+ }
466
+ if (installCommand !== 'npm ci')
467
+ add('vercel.json', 'vercel-install', 'vercel.json sets installCommand to "npm ci" (keep any existing regions).');
468
+ if (!existsSync(join(root, 'README.md')))
469
+ add('README.md', 'project-docs', 'Add the generated README.md (commands, architecture, environment names, deployment and rollback).');
470
+ const agents = join(root, 'AGENTS.md');
471
+ if (!existsSync(agents))
472
+ add('AGENTS.md', 'project-docs', 'Add AGENTS.md with the fixed sources of truth and guardrails.');
473
+ else if (statSync(agents).size > 3_584)
474
+ add('AGENTS.md', 'project-docs', `AGENTS.md is ${statSync(agents).size} bytes; keep it to about 3 KB and archive history to the control repo.`);
475
+ const claude = readOptional(join(root, 'CLAUDE.md'));
476
+ if (claude === null || claude.trim() !== '@AGENTS.md')
477
+ add('CLAUDE.md', 'project-docs', 'CLAUDE.md is the single line @AGENTS.md.');
478
+ }
479
+ function readOptional(path) {
480
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
85
481
  }
86
482
  //# sourceMappingURL=engineering.js.map