@brandlab-digital/conformance 1.0.0-rc.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -93
- package/dist/cli.js.map +1 -1
- package/dist/current-release.d.ts +3 -0
- package/dist/current-release.d.ts.map +1 -0
- package/dist/current-release.js +54 -0
- package/dist/current-release.js.map +1 -0
- package/dist/project.d.ts +19 -24
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +444 -1070
- package/dist/project.js.map +1 -1
- package/dist/release-manifest.d.ts +29 -82
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +141 -348
- package/dist/release-manifest.js.map +1 -1
- package/dist/rendered-content.d.ts +36 -0
- package/dist/rendered-content.d.ts.map +1 -0
- package/dist/rendered-content.js +146 -0
- package/dist/rendered-content.js.map +1 -0
- package/dist/verification-policy.d.ts +162 -0
- package/dist/verification-policy.d.ts.map +1 -0
- package/dist/verification-policy.js +309 -0
- package/dist/verification-policy.js.map +1 -0
- package/package.json +6 -22
- package/dist/legacy-architecture-contracts.d.ts +0 -31
- package/dist/legacy-architecture-contracts.d.ts.map +0 -1
- package/dist/legacy-architecture-contracts.js +0 -49
- package/dist/legacy-architecture-contracts.js.map +0 -1
- package/dist/locations.d.ts +0 -63
- package/dist/locations.d.ts.map +0 -1
- package/dist/locations.js +0 -105
- package/dist/locations.js.map +0 -1
- package/dist/project-config-v2-contract.d.ts +0 -32
- package/dist/project-config-v2-contract.d.ts.map +0 -1
- package/dist/project-config-v2-contract.js +0 -926
- package/dist/project-config-v2-contract.js.map +0 -1
- package/dist/project-config-v3.d.ts +0 -43
- package/dist/project-config-v3.d.ts.map +0 -1
- package/dist/project-config-v3.js +0 -2876
- package/dist/project-config-v3.js.map +0 -1
- package/dist/project-config.d.ts +0 -3
- package/dist/project-config.d.ts.map +0 -1
- package/dist/project-config.js +0 -3
- package/dist/project-config.js.map +0 -1
- package/dist/release-catalog.d.ts +0 -4
- package/dist/release-catalog.d.ts.map +0 -1
- package/dist/release-catalog.js +0 -714
- package/dist/release-catalog.js.map +0 -1
- package/dist/release-contract-compatibility.d.ts +0 -97
- package/dist/release-contract-compatibility.d.ts.map +0 -1
- package/dist/release-contract-compatibility.js +0 -112
- package/dist/release-contract-compatibility.js.map +0 -1
- package/dist/schema-hygiene.d.ts +0 -26
- package/dist/schema-hygiene.d.ts.map +0 -1
- package/dist/schema-hygiene.js +0 -28
- package/dist/schema-hygiene.js.map +0 -1
- package/dist/source-safety-contracts.d.ts +0 -20
- package/dist/source-safety-contracts.d.ts.map +0 -1
- package/dist/source-safety-contracts.js +0 -60
- package/dist/source-safety-contracts.js.map +0 -1
- package/dist/vitest.d.ts +0 -110
- package/dist/vitest.d.ts.map +0 -1
- package/dist/vitest.js +0 -2678
- package/dist/vitest.js.map +0 -1
|
@@ -1,926 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Immutable Project Config v2 evaluator snapshot.
|
|
3
|
-
*
|
|
4
|
-
* This module preserves the exact 1.0.0-rc.1 maintenance contract. Newer
|
|
5
|
-
* configuration requirements belong in project-config.ts and must never be
|
|
6
|
-
* applied retroactively to a project whose installed release declares
|
|
7
|
-
* project-config-v2.
|
|
8
|
-
*/
|
|
9
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
-
import { extname, join, relative } from "node:path";
|
|
11
|
-
import * as ts from "typescript";
|
|
12
|
-
export const BRANDLAB_CONFIG_V2_RULESET_ID = "project-config-v2@1.0.0-rc.1:5d6c4a9a27abd65e5a8e55d5f66145ff499ead7d";
|
|
13
|
-
export const BRANDLAB_CONFIG_V2_PROFILES = [
|
|
14
|
-
"next-local-service",
|
|
15
|
-
"next-root-level",
|
|
16
|
-
"next-static-legacy",
|
|
17
|
-
"legacy-php",
|
|
18
|
-
];
|
|
19
|
-
const ROUTE_FAMILIES = new Set([
|
|
20
|
-
"services",
|
|
21
|
-
"locations",
|
|
22
|
-
"service-locations",
|
|
23
|
-
"blog",
|
|
24
|
-
"projects",
|
|
25
|
-
"legal",
|
|
26
|
-
]);
|
|
27
|
-
const BLOG_ADAPTERS = new Set(["none", "mdx", "structured"]);
|
|
28
|
-
const ATTACHMENT_MODES = new Set(["none", "images-optional", "images-required"]);
|
|
29
|
-
const LOCATION_STRATEGIES = new Set([
|
|
30
|
-
"none",
|
|
31
|
-
"approved-service-area",
|
|
32
|
-
"physical-location",
|
|
33
|
-
"multi-location",
|
|
34
|
-
]);
|
|
35
|
-
const ROOT_CONFIG_KEYS = new Set([
|
|
36
|
-
"projectId",
|
|
37
|
-
"profile",
|
|
38
|
-
"routes",
|
|
39
|
-
"capabilities",
|
|
40
|
-
"sitemap",
|
|
41
|
-
"searchCertification",
|
|
42
|
-
"exceptions",
|
|
43
|
-
]);
|
|
44
|
-
const FORBIDDEN_GLOBAL_KEYS = new Set([
|
|
45
|
-
"installedRelease",
|
|
46
|
-
"packageScope",
|
|
47
|
-
"packageVersion",
|
|
48
|
-
"nextVersion",
|
|
49
|
-
"reactVersion",
|
|
50
|
-
"reactDomVersion",
|
|
51
|
-
"nodeVersion",
|
|
52
|
-
"typescriptVersion",
|
|
53
|
-
"packageManager",
|
|
54
|
-
"leadEvents",
|
|
55
|
-
"attachmentLimits",
|
|
56
|
-
"previewIndexing",
|
|
57
|
-
]);
|
|
58
|
-
const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx"]);
|
|
59
|
-
const SKIPPED_DIRECTORIES = new Set([
|
|
60
|
-
".git",
|
|
61
|
-
".next",
|
|
62
|
-
".turbo",
|
|
63
|
-
"coverage",
|
|
64
|
-
"dist",
|
|
65
|
-
"generated",
|
|
66
|
-
"node_modules",
|
|
67
|
-
]);
|
|
68
|
-
const REQUIRED_KERNEL_IMPORTS = [
|
|
69
|
-
["@brandlab-digital/site-core/business", "business"],
|
|
70
|
-
["@brandlab-digital/site-core/routes", "routes"],
|
|
71
|
-
["@brandlab-digital/site-next/metadata", "metadata"],
|
|
72
|
-
["@brandlab-digital/site-next/schema", "schema"],
|
|
73
|
-
["@brandlab-digital/site-next/breadcrumbs", "breadcrumbs"],
|
|
74
|
-
["@brandlab-digital/site-next/sitemaps", "sitemap"],
|
|
75
|
-
["@brandlab-digital/site-next/robots", "robots"],
|
|
76
|
-
["@brandlab-digital/site-next/contact/server", "contact"],
|
|
77
|
-
["@brandlab-digital/site-next/contact/client", "contact"],
|
|
78
|
-
["@brandlab-digital/site-next/analytics", "analytics"],
|
|
79
|
-
["@brandlab-digital/site-next/ui", "accessibility"],
|
|
80
|
-
];
|
|
81
|
-
const PROTECTED_LOCAL_PATTERNS = [
|
|
82
|
-
{
|
|
83
|
-
ruleId: "brandlab-config-v2-local-contact-transport",
|
|
84
|
-
surface: "contact",
|
|
85
|
-
pattern: /\b(?:new\s+Resend|resend\.emails\.send)\b/,
|
|
86
|
-
expected: "Contact delivery is owned by @brandlab-digital/site-next/contact/server.",
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
ruleId: "brandlab-config-v2-local-contact-email",
|
|
90
|
-
surface: "contact",
|
|
91
|
-
pattern: /\b(?:class|function|const|let|var)\s+(?:ContactFormEmail|renderContactEmail)\b/,
|
|
92
|
-
expected: "Contact email rendering is owned by @brandlab-digital/site-next/contact/email.",
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
ruleId: "brandlab-config-v2-local-lead-event",
|
|
96
|
-
surface: "analytics",
|
|
97
|
-
pattern: /\bgtag\s*\([^)]*["'](?:contact_form|phone_call)["']/,
|
|
98
|
-
expected: "Canonical lead events are owned by @brandlab-digital/site-next/analytics.",
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
ruleId: "brandlab-config-v2-url-derived-breadcrumbs",
|
|
102
|
-
surface: "breadcrumbs",
|
|
103
|
-
severity: "warning",
|
|
104
|
-
pattern: /\b(?:BrandlabPathBreadcrumbs|createBreadcrumbItems)\b/,
|
|
105
|
-
expected: "Breadcrumb labels and hierarchy derive from the semantic route catalog.",
|
|
106
|
-
},
|
|
107
|
-
];
|
|
108
|
-
export function inspectBrandlabConfigV2(root) {
|
|
109
|
-
const issues = [];
|
|
110
|
-
const configPath = join(root, "brandlab.config.ts");
|
|
111
|
-
const { imports, executableImports } = collectProjectImports(root, issues);
|
|
112
|
-
if (!existsSync(configPath)) {
|
|
113
|
-
issues.push(configIssue({
|
|
114
|
-
ruleId: "brandlab-config-v2-missing",
|
|
115
|
-
expected: "A constrained brandlab.config.ts using defineBrandlabProject().",
|
|
116
|
-
actual: "brandlab.config.ts is missing.",
|
|
117
|
-
remediation: "Add the variation-only typed project configuration.",
|
|
118
|
-
path: "brandlab.config.ts",
|
|
119
|
-
}));
|
|
120
|
-
return { config: null, imports, executableImports, issues };
|
|
121
|
-
}
|
|
122
|
-
const sourceText = readFileSync(configPath, "utf8");
|
|
123
|
-
const source = ts.createSourceFile(configPath, sourceText, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS);
|
|
124
|
-
for (const diagnostic of getParseDiagnostics(source)) {
|
|
125
|
-
const location = diagnostic.start === undefined
|
|
126
|
-
? undefined
|
|
127
|
-
: source.getLineAndCharacterOfPosition(diagnostic.start);
|
|
128
|
-
issues.push(configIssue({
|
|
129
|
-
ruleId: "brandlab-config-v2-syntax",
|
|
130
|
-
expected: "Syntactically valid TypeScript configuration.",
|
|
131
|
-
actual: ts.flattenDiagnosticMessageText(diagnostic.messageText, " "),
|
|
132
|
-
remediation: "Correct the configuration syntax without executing project code.",
|
|
133
|
-
path: "brandlab.config.ts",
|
|
134
|
-
line: location ? location.line + 1 : undefined,
|
|
135
|
-
}));
|
|
136
|
-
}
|
|
137
|
-
const factoryNames = collectFactoryImportNames(source);
|
|
138
|
-
if (factoryNames.size === 0) {
|
|
139
|
-
issues.push(configIssue({
|
|
140
|
-
ruleId: "brandlab-config-v2-factory-import",
|
|
141
|
-
expected: "defineBrandlabProject imported from @brandlab-digital/site-core/project.",
|
|
142
|
-
actual: "The supported project factory import was not found.",
|
|
143
|
-
remediation: "Import and call the shared project configuration factory.",
|
|
144
|
-
path: "brandlab.config.ts",
|
|
145
|
-
}));
|
|
146
|
-
}
|
|
147
|
-
const assignment = source.statements.find(ts.isExportAssignment);
|
|
148
|
-
const expression = assignment ? unwrapExpression(assignment.expression) : null;
|
|
149
|
-
if (!expression ||
|
|
150
|
-
!ts.isCallExpression(expression) ||
|
|
151
|
-
!ts.isIdentifier(expression.expression) ||
|
|
152
|
-
!factoryNames.has(expression.expression.text) ||
|
|
153
|
-
expression.arguments.length !== 1) {
|
|
154
|
-
issues.push(configIssue({
|
|
155
|
-
ruleId: "brandlab-config-v2-export",
|
|
156
|
-
expected: "One default export calling defineBrandlabProject() with one object literal.",
|
|
157
|
-
actual: "The default export does not match the constrained configuration contract.",
|
|
158
|
-
remediation: "Export the variation-only object directly through defineBrandlabProject().",
|
|
159
|
-
path: "brandlab.config.ts",
|
|
160
|
-
}));
|
|
161
|
-
return { config: null, imports, executableImports, issues };
|
|
162
|
-
}
|
|
163
|
-
let value;
|
|
164
|
-
try {
|
|
165
|
-
value = literalValue(expression.arguments[0], source);
|
|
166
|
-
}
|
|
167
|
-
catch (error) {
|
|
168
|
-
const node = error instanceof ConfigLiteralError ? error.node : expression.arguments[0];
|
|
169
|
-
const location = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
170
|
-
issues.push(configIssue({
|
|
171
|
-
ruleId: "brandlab-config-v2-literal-only",
|
|
172
|
-
expected: "A static object containing only literal values, arrays and nested objects.",
|
|
173
|
-
actual: error instanceof Error ? error.message : "The configuration is not inspectable.",
|
|
174
|
-
remediation: "Remove spreads, computed values, function calls and executable expressions.",
|
|
175
|
-
path: "brandlab.config.ts",
|
|
176
|
-
line: location.line + 1,
|
|
177
|
-
}));
|
|
178
|
-
return { config: null, imports, executableImports, issues };
|
|
179
|
-
}
|
|
180
|
-
const config = validateConfigValue(value, issues);
|
|
181
|
-
if (config) {
|
|
182
|
-
addImportContractIssues(config, imports, executableImports, issues);
|
|
183
|
-
}
|
|
184
|
-
addProtectedImplementationIssues(root, issues);
|
|
185
|
-
return { config, imports, executableImports, issues };
|
|
186
|
-
}
|
|
187
|
-
function collectFactoryImportNames(source) {
|
|
188
|
-
const names = new Set();
|
|
189
|
-
for (const statement of source.statements) {
|
|
190
|
-
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
if (statement.moduleSpecifier.text !== "@brandlab-digital/site-core/project")
|
|
194
|
-
continue;
|
|
195
|
-
const bindings = statement.importClause?.namedBindings;
|
|
196
|
-
if (!bindings || !ts.isNamedImports(bindings))
|
|
197
|
-
continue;
|
|
198
|
-
for (const element of bindings.elements) {
|
|
199
|
-
if ((element.propertyName ?? element.name).text === "defineBrandlabProject") {
|
|
200
|
-
names.add(element.name.text);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return names;
|
|
205
|
-
}
|
|
206
|
-
function unwrapExpression(expression) {
|
|
207
|
-
let current = expression;
|
|
208
|
-
while (ts.isParenthesizedExpression(current) ||
|
|
209
|
-
ts.isAsExpression(current) ||
|
|
210
|
-
ts.isSatisfiesExpression(current) ||
|
|
211
|
-
ts.isTypeAssertionExpression(current)) {
|
|
212
|
-
current = current.expression;
|
|
213
|
-
}
|
|
214
|
-
return current;
|
|
215
|
-
}
|
|
216
|
-
class ConfigLiteralError extends Error {
|
|
217
|
-
node;
|
|
218
|
-
constructor(message, node) {
|
|
219
|
-
super(message);
|
|
220
|
-
this.node = node;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
function literalValue(node, source) {
|
|
224
|
-
const value = unwrapExpression(node);
|
|
225
|
-
if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
|
|
226
|
-
return value.text;
|
|
227
|
-
if (ts.isNumericLiteral(value))
|
|
228
|
-
return Number(value.text);
|
|
229
|
-
if (value.kind === ts.SyntaxKind.TrueKeyword)
|
|
230
|
-
return true;
|
|
231
|
-
if (value.kind === ts.SyntaxKind.FalseKeyword)
|
|
232
|
-
return false;
|
|
233
|
-
if (value.kind === ts.SyntaxKind.NullKeyword)
|
|
234
|
-
return null;
|
|
235
|
-
if (ts.isPrefixUnaryExpression(value) && value.operator === ts.SyntaxKind.MinusToken) {
|
|
236
|
-
const operand = literalValue(value.operand, source);
|
|
237
|
-
if (typeof operand === "number")
|
|
238
|
-
return -operand;
|
|
239
|
-
}
|
|
240
|
-
if (ts.isArrayLiteralExpression(value)) {
|
|
241
|
-
return value.elements.map((element) => {
|
|
242
|
-
if (ts.isSpreadElement(element)) {
|
|
243
|
-
throw new ConfigLiteralError("Array spreads are not allowed.", element);
|
|
244
|
-
}
|
|
245
|
-
return literalValue(element, source);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
if (ts.isObjectLiteralExpression(value)) {
|
|
249
|
-
const output = {};
|
|
250
|
-
for (const property of value.properties) {
|
|
251
|
-
if (!ts.isPropertyAssignment(property)) {
|
|
252
|
-
throw new ConfigLiteralError("Only explicit property assignments are allowed.", property);
|
|
253
|
-
}
|
|
254
|
-
const name = propertyName(property.name);
|
|
255
|
-
if (name === null) {
|
|
256
|
-
throw new ConfigLiteralError("Computed property names are not allowed.", property.name);
|
|
257
|
-
}
|
|
258
|
-
if (Object.hasOwn(output, name)) {
|
|
259
|
-
throw new ConfigLiteralError(`Duplicate property "${name}" is not allowed.`, property.name);
|
|
260
|
-
}
|
|
261
|
-
output[name] = literalValue(property.initializer, source);
|
|
262
|
-
}
|
|
263
|
-
return output;
|
|
264
|
-
}
|
|
265
|
-
throw new ConfigLiteralError(`Unsupported executable expression: ${value.getText(source).slice(0, 80)}.`, value);
|
|
266
|
-
}
|
|
267
|
-
function propertyName(name) {
|
|
268
|
-
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
|
269
|
-
return name.text;
|
|
270
|
-
}
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
function validateConfigValue(value, issues) {
|
|
274
|
-
if (!isRecord(value)) {
|
|
275
|
-
issues.push(configIssue({
|
|
276
|
-
ruleId: "brandlab-config-v2-object",
|
|
277
|
-
expected: "A project variation object.",
|
|
278
|
-
actual: "The configuration value is not an object.",
|
|
279
|
-
remediation: "Pass one constrained object literal to defineBrandlabProject().",
|
|
280
|
-
path: "brandlab.config.ts",
|
|
281
|
-
}));
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
const validationStart = issues.length;
|
|
285
|
-
for (const key of FORBIDDEN_GLOBAL_KEYS) {
|
|
286
|
-
if (Object.hasOwn(value, key)) {
|
|
287
|
-
issues.push(configIssue({
|
|
288
|
-
ruleId: "brandlab-config-v2-global-fact",
|
|
289
|
-
expected: "Project variation only; runtime and release facts come from lockfiles and manifests.",
|
|
290
|
-
actual: `Global fact "${key}" is repeated in project configuration.`,
|
|
291
|
-
remediation: `Remove ${key} from brandlab.config.ts.`,
|
|
292
|
-
path: "brandlab.config.ts",
|
|
293
|
-
}));
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
addUnknownKeyIssues(value, ROOT_CONFIG_KEYS, FORBIDDEN_GLOBAL_KEYS, "config", issues);
|
|
297
|
-
const projectId = typeof value.projectId === "string" ? value.projectId : "";
|
|
298
|
-
const profile = typeof value.profile === "string" ? value.profile : "";
|
|
299
|
-
const routes = isRecord(value.routes) ? value.routes : null;
|
|
300
|
-
const rawFamilies = routes && Array.isArray(routes.families) ? routes.families : [];
|
|
301
|
-
const families = rawFamilies.filter((entry) => typeof entry === "string");
|
|
302
|
-
const searchCertification = isRecord(value.searchCertification)
|
|
303
|
-
? value.searchCertification
|
|
304
|
-
: null;
|
|
305
|
-
const representativeRoutes = searchCertification && isRecord(searchCertification.representativeRoutes)
|
|
306
|
-
? searchCertification.representativeRoutes
|
|
307
|
-
: null;
|
|
308
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(projectId)) {
|
|
309
|
-
issues.push(configIssue({
|
|
310
|
-
ruleId: "brandlab-config-v2-project-id",
|
|
311
|
-
expected: "A canonical lowercase kebab-case projectId.",
|
|
312
|
-
actual: projectId ? `Invalid projectId "${projectId}".` : "projectId is missing.",
|
|
313
|
-
remediation: "Use the canonical entity identifier.",
|
|
314
|
-
path: "brandlab.config.ts",
|
|
315
|
-
}));
|
|
316
|
-
}
|
|
317
|
-
if (!BRANDLAB_CONFIG_V2_PROFILES.includes(profile)) {
|
|
318
|
-
issues.push(configIssue({
|
|
319
|
-
ruleId: "brandlab-config-v2-profile",
|
|
320
|
-
expected: `One supported profile: ${BRANDLAB_CONFIG_V2_PROFILES.join(", ")}.`,
|
|
321
|
-
actual: profile ? `Unsupported profile "${profile}".` : "profile is missing.",
|
|
322
|
-
remediation: "Select the structural profile that matches the project.",
|
|
323
|
-
path: "brandlab.config.ts",
|
|
324
|
-
}));
|
|
325
|
-
}
|
|
326
|
-
if (!routes || !Array.isArray(routes.families) || families.length !== rawFamilies.length) {
|
|
327
|
-
issues.push(configIssue({
|
|
328
|
-
ruleId: "brandlab-config-v2-routes",
|
|
329
|
-
expected: "routes.families as a literal string array.",
|
|
330
|
-
actual: "Route families are missing or invalid.",
|
|
331
|
-
remediation: "Declare only the route families the project owns.",
|
|
332
|
-
path: "brandlab.config.ts",
|
|
333
|
-
}));
|
|
334
|
-
}
|
|
335
|
-
else {
|
|
336
|
-
addUnknownKeyIssues(routes, new Set(["families", "aliases"]), new Set(), "routes", issues);
|
|
337
|
-
const unsupportedFamily = families.find((family) => !ROUTE_FAMILIES.has(family));
|
|
338
|
-
if (unsupportedFamily) {
|
|
339
|
-
issues.push(configIssue({
|
|
340
|
-
ruleId: "brandlab-config-v2-route-family",
|
|
341
|
-
expected: `Supported route families only: ${[...ROUTE_FAMILIES].join(", ")}.`,
|
|
342
|
-
actual: `Unsupported route family "${unsupportedFamily}".`,
|
|
343
|
-
remediation: "Use the closest supported route family or propose a versioned contract extension.",
|
|
344
|
-
path: "brandlab.config.ts",
|
|
345
|
-
}));
|
|
346
|
-
}
|
|
347
|
-
addDuplicateIssue(families, "brandlab-config-v2-route-family-duplicate", "routes.families", issues);
|
|
348
|
-
validateAliases(routes.aliases, issues);
|
|
349
|
-
}
|
|
350
|
-
if (!searchCertification) {
|
|
351
|
-
issues.push(configIssue({
|
|
352
|
-
ruleId: "brandlab-config-v2-search-certification",
|
|
353
|
-
expected: "A searchCertification object containing representativeRoutes.",
|
|
354
|
-
actual: "searchCertification is missing or invalid.",
|
|
355
|
-
remediation: "Declare the inspectable representative route selection.",
|
|
356
|
-
path: "brandlab.config.ts",
|
|
357
|
-
}));
|
|
358
|
-
}
|
|
359
|
-
else {
|
|
360
|
-
addUnknownKeyIssues(searchCertification, new Set(["representativeRoutes"]), new Set(), "searchCertification", issues);
|
|
361
|
-
}
|
|
362
|
-
if (!representativeRoutes || typeof representativeRoutes.home !== "string") {
|
|
363
|
-
issues.push(configIssue({
|
|
364
|
-
ruleId: "brandlab-config-v2-search-routes",
|
|
365
|
-
expected: "A literal home route under searchCertification.representativeRoutes.",
|
|
366
|
-
actual: "Representative Search Certification routes are incomplete.",
|
|
367
|
-
remediation: "Declare the smallest representative route set for this project.",
|
|
368
|
-
path: "brandlab.config.ts",
|
|
369
|
-
}));
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
addUnknownKeyIssues(representativeRoutes, new Set(["home", "contact", "service", "location", "blog"]), new Set(), "searchCertification.representativeRoutes", issues);
|
|
373
|
-
for (const [name, path] of Object.entries(representativeRoutes)) {
|
|
374
|
-
if (typeof path !== "string" || !isRootRelativePath(path)) {
|
|
375
|
-
issues.push(configIssue({
|
|
376
|
-
ruleId: "brandlab-config-v2-search-route-path",
|
|
377
|
-
expected: `${name} as a strict root-relative route without encoded separators, dot segments, query or hash.`,
|
|
378
|
-
actual: `${name} is ${JSON.stringify(path)}.`,
|
|
379
|
-
remediation: "Use a canonical route path from the typed route catalog.",
|
|
380
|
-
path: "brandlab.config.ts",
|
|
381
|
-
}));
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (representativeRoutes.home !== "/") {
|
|
385
|
-
issues.push(configIssue({
|
|
386
|
-
ruleId: "brandlab-config-v2-search-home",
|
|
387
|
-
expected: 'searchCertification.representativeRoutes.home="/".',
|
|
388
|
-
actual: `home is ${JSON.stringify(representativeRoutes.home)}.`,
|
|
389
|
-
remediation: "Use the canonical root route for the representative homepage.",
|
|
390
|
-
path: "brandlab.config.ts",
|
|
391
|
-
}));
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
const capabilities = value.capabilities === undefined
|
|
395
|
-
? {}
|
|
396
|
-
: isRecord(value.capabilities)
|
|
397
|
-
? value.capabilities
|
|
398
|
-
: null;
|
|
399
|
-
if (capabilities === null) {
|
|
400
|
-
issues.push(configIssue({
|
|
401
|
-
ruleId: "brandlab-config-v2-capabilities",
|
|
402
|
-
expected: "capabilities as an optional literal object.",
|
|
403
|
-
actual: "capabilities is not an object.",
|
|
404
|
-
remediation: "Declare only supported project capability variation.",
|
|
405
|
-
path: "brandlab.config.ts",
|
|
406
|
-
}));
|
|
407
|
-
}
|
|
408
|
-
else {
|
|
409
|
-
validateCapabilities(capabilities, issues);
|
|
410
|
-
}
|
|
411
|
-
validateSitemap(value.sitemap, issues);
|
|
412
|
-
validateExceptions(value.exceptions, issues);
|
|
413
|
-
if (issues.slice(validationStart).some((issue) => issue.severity === "blocker"))
|
|
414
|
-
return null;
|
|
415
|
-
const safeCapabilities = capabilities ?? {};
|
|
416
|
-
const blog = isRecord(safeCapabilities.blog) ? safeCapabilities.blog : null;
|
|
417
|
-
const contact = isRecord(safeCapabilities.contact) ? safeCapabilities.contact : null;
|
|
418
|
-
return {
|
|
419
|
-
projectId,
|
|
420
|
-
profile: profile,
|
|
421
|
-
routes: { families },
|
|
422
|
-
capabilities: {
|
|
423
|
-
blogAdapter: typeof blog?.adapter === "string" ? blog.adapter : null,
|
|
424
|
-
contactEnabled: contact !== null,
|
|
425
|
-
},
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
function validateAliases(value, issues) {
|
|
429
|
-
if (value === undefined)
|
|
430
|
-
return;
|
|
431
|
-
if (!Array.isArray(value)) {
|
|
432
|
-
issues.push(configIssue({
|
|
433
|
-
ruleId: "brandlab-config-v2-route-aliases",
|
|
434
|
-
expected: "routes.aliases as an optional literal array.",
|
|
435
|
-
actual: "routes.aliases is not an array.",
|
|
436
|
-
remediation: "Declare each historical alias as a typed from/to record.",
|
|
437
|
-
path: "brandlab.config.ts",
|
|
438
|
-
}));
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
const fromPaths = [];
|
|
442
|
-
const toPaths = [];
|
|
443
|
-
value.forEach((alias, index) => {
|
|
444
|
-
if (!isRecord(alias)) {
|
|
445
|
-
issues.push(configIssue({
|
|
446
|
-
ruleId: "brandlab-config-v2-route-alias",
|
|
447
|
-
expected: `routes.aliases[${index}] as an object.`,
|
|
448
|
-
actual: "The alias is not an object.",
|
|
449
|
-
remediation: "Declare from, to and optional 301 or 308 status.",
|
|
450
|
-
path: "brandlab.config.ts",
|
|
451
|
-
}));
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
addUnknownKeyIssues(alias, new Set(["from", "to", "status"]), new Set(), `routes.aliases[${index}]`, issues);
|
|
455
|
-
const from = typeof alias.from === "string" ? alias.from : "";
|
|
456
|
-
const to = typeof alias.to === "string" ? alias.to : "";
|
|
457
|
-
if (!isRootRelativePath(from) || !isRootRelativePath(to)) {
|
|
458
|
-
issues.push(configIssue({
|
|
459
|
-
ruleId: "brandlab-config-v2-route-alias-path",
|
|
460
|
-
expected: "Strict root-relative from/to alias paths.",
|
|
461
|
-
actual: `Alias ${index} uses from=${JSON.stringify(alias.from)} and to=${JSON.stringify(alias.to)}.`,
|
|
462
|
-
remediation: "Use canonical root-relative paths without encoded separators, dot segments, query or hash.",
|
|
463
|
-
path: "brandlab.config.ts",
|
|
464
|
-
}));
|
|
465
|
-
}
|
|
466
|
-
if (alias.status !== undefined && alias.status !== 301 && alias.status !== 308) {
|
|
467
|
-
issues.push(configIssue({
|
|
468
|
-
ruleId: "brandlab-config-v2-route-alias-status",
|
|
469
|
-
expected: "Alias status 301 or 308 when present.",
|
|
470
|
-
actual: `Alias ${index} uses ${JSON.stringify(alias.status)}.`,
|
|
471
|
-
remediation: "Choose the approved permanent redirect status.",
|
|
472
|
-
path: "brandlab.config.ts",
|
|
473
|
-
}));
|
|
474
|
-
}
|
|
475
|
-
if (from && from === to) {
|
|
476
|
-
issues.push(configIssue({
|
|
477
|
-
ruleId: "brandlab-config-v2-route-alias-self",
|
|
478
|
-
expected: "An alias whose source differs from its destination.",
|
|
479
|
-
actual: `Alias ${index} redirects ${from} to itself.`,
|
|
480
|
-
remediation: "Remove the self-redirect.",
|
|
481
|
-
path: "brandlab.config.ts",
|
|
482
|
-
}));
|
|
483
|
-
}
|
|
484
|
-
if (from)
|
|
485
|
-
fromPaths.push(from);
|
|
486
|
-
if (to)
|
|
487
|
-
toPaths.push(to);
|
|
488
|
-
});
|
|
489
|
-
addDuplicateIssue(fromPaths, "brandlab-config-v2-route-alias-duplicate", "routes.aliases.from", issues);
|
|
490
|
-
const chain = fromPaths.find((from) => toPaths.includes(from));
|
|
491
|
-
if (chain) {
|
|
492
|
-
issues.push(configIssue({
|
|
493
|
-
ruleId: "brandlab-config-v2-route-alias-chain",
|
|
494
|
-
expected: "Direct aliases without redirect chains or cycles.",
|
|
495
|
-
actual: `Alias source ${chain} is also an alias destination.`,
|
|
496
|
-
remediation: "Point every historical alias directly to its final canonical route.",
|
|
497
|
-
path: "brandlab.config.ts",
|
|
498
|
-
}));
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
function validateCapabilities(capabilities, issues) {
|
|
502
|
-
addUnknownKeyIssues(capabilities, new Set(["blog", "contact", "locations"]), new Set(), "capabilities", issues);
|
|
503
|
-
if (capabilities.blog !== undefined) {
|
|
504
|
-
if (!isRecord(capabilities.blog)) {
|
|
505
|
-
addShapeIssue("blog", issues);
|
|
506
|
-
}
|
|
507
|
-
else {
|
|
508
|
-
addUnknownKeyIssues(capabilities.blog, new Set(["adapter"]), new Set(), "capabilities.blog", issues);
|
|
509
|
-
if (typeof capabilities.blog.adapter !== "string" || !BLOG_ADAPTERS.has(capabilities.blog.adapter)) {
|
|
510
|
-
issues.push(configIssue({
|
|
511
|
-
ruleId: "brandlab-config-v2-blog-adapter",
|
|
512
|
-
expected: `One blog adapter: ${[...BLOG_ADAPTERS].join(", ")}.`,
|
|
513
|
-
actual: `blog.adapter is ${JSON.stringify(capabilities.blog.adapter)}.`,
|
|
514
|
-
remediation: "Select the adapter matching the project-owned content source.",
|
|
515
|
-
path: "brandlab.config.ts",
|
|
516
|
-
}));
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
if (capabilities.contact !== undefined) {
|
|
521
|
-
if (!isRecord(capabilities.contact)) {
|
|
522
|
-
addShapeIssue("contact", issues);
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
addUnknownKeyIssues(capabilities.contact, new Set(["fieldProfile", "attachments"]), new Set(), "capabilities.contact", issues);
|
|
526
|
-
if (typeof capabilities.contact.fieldProfile !== "string" ||
|
|
527
|
-
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(capabilities.contact.fieldProfile)) {
|
|
528
|
-
issues.push(configIssue({
|
|
529
|
-
ruleId: "brandlab-config-v2-contact-field-profile",
|
|
530
|
-
expected: "A non-empty lowercase kebab-case contact fieldProfile.",
|
|
531
|
-
actual: `contact.fieldProfile is ${JSON.stringify(capabilities.contact.fieldProfile)}.`,
|
|
532
|
-
remediation: "Use the approved declarative field-profile identifier.",
|
|
533
|
-
path: "brandlab.config.ts",
|
|
534
|
-
}));
|
|
535
|
-
}
|
|
536
|
-
if (typeof capabilities.contact.attachments !== "string" ||
|
|
537
|
-
!ATTACHMENT_MODES.has(capabilities.contact.attachments)) {
|
|
538
|
-
issues.push(configIssue({
|
|
539
|
-
ruleId: "brandlab-config-v2-contact-attachments",
|
|
540
|
-
expected: `One attachment mode: ${[...ATTACHMENT_MODES].join(", ")}.`,
|
|
541
|
-
actual: `contact.attachments is ${JSON.stringify(capabilities.contact.attachments)}.`,
|
|
542
|
-
remediation: "Use an approved shared attachment policy.",
|
|
543
|
-
path: "brandlab.config.ts",
|
|
544
|
-
}));
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
if (capabilities.locations !== undefined) {
|
|
549
|
-
if (!isRecord(capabilities.locations)) {
|
|
550
|
-
addShapeIssue("locations", issues);
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
addUnknownKeyIssues(capabilities.locations, new Set(["strategy"]), new Set(), "capabilities.locations", issues);
|
|
554
|
-
if (typeof capabilities.locations.strategy !== "string" ||
|
|
555
|
-
!LOCATION_STRATEGIES.has(capabilities.locations.strategy)) {
|
|
556
|
-
issues.push(configIssue({
|
|
557
|
-
ruleId: "brandlab-config-v2-location-strategy",
|
|
558
|
-
expected: `One location strategy: ${[...LOCATION_STRATEGIES].join(", ")}.`,
|
|
559
|
-
actual: `locations.strategy is ${JSON.stringify(capabilities.locations.strategy)}.`,
|
|
560
|
-
remediation: "Use the verified business-location model.",
|
|
561
|
-
path: "brandlab.config.ts",
|
|
562
|
-
}));
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
function addShapeIssue(surface, issues) {
|
|
568
|
-
issues.push(configIssue({
|
|
569
|
-
ruleId: `brandlab-config-v2-${surface}-shape`,
|
|
570
|
-
expected: `capabilities.${surface} as a supported literal object.`,
|
|
571
|
-
actual: `capabilities.${surface} is not an object.`,
|
|
572
|
-
remediation: "Use the documented variation-only capability shape.",
|
|
573
|
-
path: "brandlab.config.ts",
|
|
574
|
-
}));
|
|
575
|
-
}
|
|
576
|
-
function validateSitemap(value, issues) {
|
|
577
|
-
if (value === undefined)
|
|
578
|
-
return;
|
|
579
|
-
if (!isRecord(value)) {
|
|
580
|
-
issues.push(configIssue({
|
|
581
|
-
ruleId: "brandlab-config-v2-sitemap",
|
|
582
|
-
expected: 'sitemap as { mode: "single" } or a documented split configuration.',
|
|
583
|
-
actual: "sitemap is not an object.",
|
|
584
|
-
remediation: "Use the shared sitemap variation contract.",
|
|
585
|
-
path: "brandlab.config.ts",
|
|
586
|
-
}));
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
if (value.mode === "single") {
|
|
590
|
-
addUnknownKeyIssues(value, new Set(["mode"]), new Set(), "sitemap", issues);
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
if (value.mode !== "split") {
|
|
594
|
-
issues.push(configIssue({
|
|
595
|
-
ruleId: "brandlab-config-v2-sitemap-mode",
|
|
596
|
-
expected: 'sitemap.mode as "single" or "split".',
|
|
597
|
-
actual: `sitemap.mode is ${JSON.stringify(value.mode)}.`,
|
|
598
|
-
remediation: "Select a supported sitemap mode.",
|
|
599
|
-
path: "brandlab.config.ts",
|
|
600
|
-
}));
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
addUnknownKeyIssues(value, new Set(["mode", "families", "reason"]), new Set(), "sitemap", issues);
|
|
604
|
-
const families = Array.isArray(value.families)
|
|
605
|
-
? value.families.filter((entry) => typeof entry === "string")
|
|
606
|
-
: [];
|
|
607
|
-
if (!Array.isArray(value.families) || families.length === 0 || families.length !== value.families.length) {
|
|
608
|
-
issues.push(configIssue({
|
|
609
|
-
ruleId: "brandlab-config-v2-sitemap-families",
|
|
610
|
-
expected: "At least one literal split-sitemap root-relative family path.",
|
|
611
|
-
actual: "sitemap.families is missing, empty or invalid.",
|
|
612
|
-
remediation: "Declare the approved split boundaries explicitly.",
|
|
613
|
-
path: "brandlab.config.ts",
|
|
614
|
-
}));
|
|
615
|
-
}
|
|
616
|
-
else {
|
|
617
|
-
addDuplicateIssue(families, "brandlab-config-v2-sitemap-family-duplicate", "sitemap.families", issues);
|
|
618
|
-
if (families.some((path) => !isRootRelativePath(path))) {
|
|
619
|
-
issues.push(configIssue({
|
|
620
|
-
ruleId: "brandlab-config-v2-sitemap-family-path",
|
|
621
|
-
expected: "Strict root-relative sitemap family paths.",
|
|
622
|
-
actual: "A split-sitemap family path is invalid.",
|
|
623
|
-
remediation: "Use canonical route-family boundaries without query, hash or encoded separators.",
|
|
624
|
-
path: "brandlab.config.ts",
|
|
625
|
-
}));
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
if (typeof value.reason !== "string" || !value.reason.trim()) {
|
|
629
|
-
issues.push(configIssue({
|
|
630
|
-
ruleId: "brandlab-config-v2-sitemap-reason",
|
|
631
|
-
expected: "A non-empty reason for split sitemap variation.",
|
|
632
|
-
actual: "sitemap.reason is missing or empty.",
|
|
633
|
-
remediation: "Explain the evidence-backed split requirement.",
|
|
634
|
-
path: "brandlab.config.ts",
|
|
635
|
-
}));
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
function validateExceptions(value, issues) {
|
|
639
|
-
if (value === undefined)
|
|
640
|
-
return;
|
|
641
|
-
if (!Array.isArray(value)) {
|
|
642
|
-
issues.push(configIssue({
|
|
643
|
-
ruleId: "brandlab-config-v2-exceptions",
|
|
644
|
-
expected: "exceptions as an optional literal array.",
|
|
645
|
-
actual: "exceptions is not an array.",
|
|
646
|
-
remediation: "Use machine-readable exception records only.",
|
|
647
|
-
path: "brandlab.config.ts",
|
|
648
|
-
}));
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
const ruleIds = [];
|
|
652
|
-
value.forEach((exception, index) => {
|
|
653
|
-
if (!isRecord(exception)) {
|
|
654
|
-
issues.push(configIssue({
|
|
655
|
-
ruleId: "brandlab-config-v2-exception-shape",
|
|
656
|
-
expected: `exceptions[${index}] as a complete record.`,
|
|
657
|
-
actual: "The exception is not an object.",
|
|
658
|
-
remediation: "Declare ruleId, rationale, owner, evidence, reviewTrigger and optional expiresAt.",
|
|
659
|
-
path: "brandlab.config.ts",
|
|
660
|
-
}));
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
addUnknownKeyIssues(exception, new Set(["ruleId", "rationale", "owner", "evidence", "expiresAt", "reviewTrigger"]), new Set(), `exceptions[${index}]`, issues);
|
|
664
|
-
for (const field of ["ruleId", "rationale", "evidence", "reviewTrigger"]) {
|
|
665
|
-
if (typeof exception[field] !== "string" || !exception[field].trim()) {
|
|
666
|
-
issues.push(configIssue({
|
|
667
|
-
ruleId: "brandlab-config-v2-exception-required",
|
|
668
|
-
expected: `exceptions[${index}].${field} as a non-empty string.`,
|
|
669
|
-
actual: `${field} is missing or empty.`,
|
|
670
|
-
remediation: "Complete the owned, evidenced, reviewable exception record.",
|
|
671
|
-
path: "brandlab.config.ts",
|
|
672
|
-
}));
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
if (exception.owner !== "Romie") {
|
|
676
|
-
issues.push(configIssue({
|
|
677
|
-
ruleId: "brandlab-config-v2-exception-owner",
|
|
678
|
-
expected: 'Exception owner "Romie".',
|
|
679
|
-
actual: `owner is ${JSON.stringify(exception.owner)}.`,
|
|
680
|
-
remediation: "Assign the owner explicitly; exceptions never approve themselves.",
|
|
681
|
-
path: "brandlab.config.ts",
|
|
682
|
-
}));
|
|
683
|
-
}
|
|
684
|
-
if (exception.expiresAt !== undefined &&
|
|
685
|
-
(typeof exception.expiresAt !== "string" || !isIsoDate(exception.expiresAt))) {
|
|
686
|
-
issues.push(configIssue({
|
|
687
|
-
ruleId: "brandlab-config-v2-exception-expiry",
|
|
688
|
-
expected: "expiresAt as an ISO date or timestamp when present.",
|
|
689
|
-
actual: `expiresAt is ${JSON.stringify(exception.expiresAt)}.`,
|
|
690
|
-
remediation: "Use a reviewable ISO expiry or omit it and retain the explicit review trigger.",
|
|
691
|
-
path: "brandlab.config.ts",
|
|
692
|
-
}));
|
|
693
|
-
}
|
|
694
|
-
if (typeof exception.ruleId === "string" && exception.ruleId)
|
|
695
|
-
ruleIds.push(exception.ruleId);
|
|
696
|
-
});
|
|
697
|
-
addDuplicateIssue(ruleIds, "brandlab-config-v2-exception-duplicate", "exceptions.ruleId", issues);
|
|
698
|
-
}
|
|
699
|
-
function addUnknownKeyIssues(value, allowed, separatelyReported, field, issues) {
|
|
700
|
-
for (const key of Object.keys(value)) {
|
|
701
|
-
if (allowed.has(key) || separatelyReported.has(key))
|
|
702
|
-
continue;
|
|
703
|
-
issues.push(configIssue({
|
|
704
|
-
ruleId: "brandlab-config-v2-unknown-key",
|
|
705
|
-
expected: `Only documented variation keys under ${field}.`,
|
|
706
|
-
actual: `Unknown key "${field}.${key}".`,
|
|
707
|
-
remediation: "Remove the unversioned key or add it through a reviewed contract release.",
|
|
708
|
-
path: "brandlab.config.ts",
|
|
709
|
-
}));
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
function addDuplicateIssue(values, ruleId, field, issues) {
|
|
713
|
-
const duplicate = values.find((value, index) => values.indexOf(value) !== index);
|
|
714
|
-
if (!duplicate)
|
|
715
|
-
return;
|
|
716
|
-
issues.push(configIssue({
|
|
717
|
-
ruleId,
|
|
718
|
-
expected: `Unique ${field} values.`,
|
|
719
|
-
actual: `${field} contains duplicate "${duplicate}".`,
|
|
720
|
-
remediation: "Remove the duplicate variation.",
|
|
721
|
-
path: "brandlab.config.ts",
|
|
722
|
-
}));
|
|
723
|
-
}
|
|
724
|
-
function isRootRelativePath(value) {
|
|
725
|
-
const trimmed = value.trim();
|
|
726
|
-
return Boolean(trimmed) &&
|
|
727
|
-
trimmed.startsWith("/") &&
|
|
728
|
-
!trimmed.startsWith("//") &&
|
|
729
|
-
!trimmed.includes("\\") &&
|
|
730
|
-
!/%(?:2f|5c)/i.test(trimmed) &&
|
|
731
|
-
!/[\u0000-\u001f\u007f]/.test(trimmed) &&
|
|
732
|
-
!trimmed.includes("?") &&
|
|
733
|
-
!trimmed.includes("#") &&
|
|
734
|
-
!trimmed.split("/").some((segment) => segment === "." || segment === "..");
|
|
735
|
-
}
|
|
736
|
-
function isIsoDate(value) {
|
|
737
|
-
if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)?$/.test(value))
|
|
738
|
-
return false;
|
|
739
|
-
return !Number.isNaN(Date.parse(value));
|
|
740
|
-
}
|
|
741
|
-
function addImportContractIssues(config, imports, executableImports, issues) {
|
|
742
|
-
for (const [moduleName, surface] of REQUIRED_KERNEL_IMPORTS) {
|
|
743
|
-
const acceptedModules = moduleName === "@brandlab-digital/site-next/contact/server"
|
|
744
|
-
? [moduleName, "@brandlab-digital/site-next/contact/route"]
|
|
745
|
-
: [moduleName];
|
|
746
|
-
const importedModule = acceptedModules.find((candidate) => imports.has(candidate));
|
|
747
|
-
const executableModule = acceptedModules.find((candidate) => executableImports.has(candidate));
|
|
748
|
-
const expectedModule = acceptedModules.join(" or ");
|
|
749
|
-
if (!importedModule) {
|
|
750
|
-
issues.push(configIssue({
|
|
751
|
-
ruleId: `brandlab-config-v2-shared-${surface}`,
|
|
752
|
-
surface,
|
|
753
|
-
expected: `An import from ${expectedModule}.`,
|
|
754
|
-
actual: "No project source file consumes the shared executable contract.",
|
|
755
|
-
remediation: `Replace the local ${surface} implementation with ${expectedModule}.`,
|
|
756
|
-
}));
|
|
757
|
-
}
|
|
758
|
-
else if (!executableModule) {
|
|
759
|
-
issues.push(configIssue({
|
|
760
|
-
ruleId: `brandlab-config-v2-shared-${surface}-unused`,
|
|
761
|
-
surface,
|
|
762
|
-
expected: `An executable call or rendered component from ${expectedModule}.`,
|
|
763
|
-
actual: `${importedModule} is only imported or re-exported; no executable consumption was found.`,
|
|
764
|
-
remediation: `Wire the shared ${surface} contract into the rendered or server execution path.`,
|
|
765
|
-
}));
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
if (config.capabilities.blogAdapter &&
|
|
769
|
-
config.capabilities.blogAdapter !== "none" &&
|
|
770
|
-
!imports.has("@brandlab-digital/site-next/blog")) {
|
|
771
|
-
issues.push(configIssue({
|
|
772
|
-
ruleId: "brandlab-config-v2-shared-blog",
|
|
773
|
-
surface: "blog",
|
|
774
|
-
expected: "An import from @brandlab-digital/site-next/blog.",
|
|
775
|
-
actual: `The ${config.capabilities.blogAdapter} blog capability does not consume the shared adapter.`,
|
|
776
|
-
remediation: "Use the shared blog adapter while retaining project-owned content and design.",
|
|
777
|
-
}));
|
|
778
|
-
}
|
|
779
|
-
else if (config.capabilities.blogAdapter &&
|
|
780
|
-
config.capabilities.blogAdapter !== "none" &&
|
|
781
|
-
!executableImports.has("@brandlab-digital/site-next/blog")) {
|
|
782
|
-
issues.push(configIssue({
|
|
783
|
-
ruleId: "brandlab-config-v2-shared-blog-unused",
|
|
784
|
-
surface: "blog",
|
|
785
|
-
expected: "An executable shared blog adapter call.",
|
|
786
|
-
actual: "The blog package is imported but no shared adapter is executed.",
|
|
787
|
-
remediation: "Construct the configured blog adapter through @brandlab-digital/site-next/blog.",
|
|
788
|
-
}));
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
function collectProjectImports(root, issues) {
|
|
792
|
-
const imports = new Set();
|
|
793
|
-
const executableImports = new Set();
|
|
794
|
-
for (const file of walkSourceFiles(join(root, "src"))) {
|
|
795
|
-
const sourceText = readFileSync(file, "utf8");
|
|
796
|
-
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.ES2022, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
797
|
-
const localImportModules = new Map();
|
|
798
|
-
for (const statement of source.statements) {
|
|
799
|
-
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
800
|
-
const moduleName = statement.moduleSpecifier.text;
|
|
801
|
-
imports.add(moduleName);
|
|
802
|
-
const clause = statement.importClause;
|
|
803
|
-
if (clause && !clause.isTypeOnly) {
|
|
804
|
-
if (clause.name)
|
|
805
|
-
localImportModules.set(clause.name.text, moduleName);
|
|
806
|
-
const bindings = clause.namedBindings;
|
|
807
|
-
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
808
|
-
localImportModules.set(bindings.name.text, moduleName);
|
|
809
|
-
}
|
|
810
|
-
else if (bindings && ts.isNamedImports(bindings)) {
|
|
811
|
-
for (const element of bindings.elements) {
|
|
812
|
-
if (!element.isTypeOnly)
|
|
813
|
-
localImportModules.set(element.name.text, moduleName);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
if (ts.isExportDeclaration(statement) &&
|
|
819
|
-
statement.moduleSpecifier &&
|
|
820
|
-
ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
821
|
-
imports.add(statement.moduleSpecifier.text);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
const markExecutableImport = (expression) => {
|
|
825
|
-
let current = expression;
|
|
826
|
-
while (ts.isPropertyAccessExpression(current) ||
|
|
827
|
-
ts.isElementAccessExpression(current)) {
|
|
828
|
-
current = current.expression;
|
|
829
|
-
}
|
|
830
|
-
if (ts.isIdentifier(current)) {
|
|
831
|
-
const moduleName = localImportModules.get(current.text);
|
|
832
|
-
if (moduleName)
|
|
833
|
-
executableImports.add(moduleName);
|
|
834
|
-
}
|
|
835
|
-
};
|
|
836
|
-
const visit = (node) => {
|
|
837
|
-
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
838
|
-
markExecutableImport(node.expression);
|
|
839
|
-
}
|
|
840
|
-
else if (ts.isTaggedTemplateExpression(node)) {
|
|
841
|
-
markExecutableImport(node.tag);
|
|
842
|
-
}
|
|
843
|
-
else if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
844
|
-
markExecutableImport(node.tagName);
|
|
845
|
-
}
|
|
846
|
-
ts.forEachChild(node, visit);
|
|
847
|
-
};
|
|
848
|
-
ts.forEachChild(source, visit);
|
|
849
|
-
if (getParseDiagnostics(source).length > 0) {
|
|
850
|
-
issues.push(configIssue({
|
|
851
|
-
ruleId: "brandlab-config-v2-source-syntax",
|
|
852
|
-
surface: "architecture",
|
|
853
|
-
expected: "Statically parseable project source.",
|
|
854
|
-
actual: `${normalizePath(relative(root, file))} contains a syntax error.`,
|
|
855
|
-
remediation: "Correct source syntax before conformance evaluation.",
|
|
856
|
-
path: normalizePath(relative(root, file)),
|
|
857
|
-
}));
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
return { imports, executableImports };
|
|
861
|
-
}
|
|
862
|
-
function addProtectedImplementationIssues(root, issues) {
|
|
863
|
-
for (const file of walkSourceFiles(join(root, "src"))) {
|
|
864
|
-
const relativePath = normalizePath(relative(root, file));
|
|
865
|
-
const source = readFileSync(file, "utf8");
|
|
866
|
-
for (const rule of PROTECTED_LOCAL_PATTERNS) {
|
|
867
|
-
const match = rule.pattern.exec(source);
|
|
868
|
-
if (!match || match.index === undefined)
|
|
869
|
-
continue;
|
|
870
|
-
const line = source.slice(0, match.index).split("\n").length;
|
|
871
|
-
issues.push(configIssue({
|
|
872
|
-
ruleId: rule.ruleId,
|
|
873
|
-
surface: rule.surface,
|
|
874
|
-
expected: rule.expected,
|
|
875
|
-
actual: `A protected local implementation was found in ${relativePath}.`,
|
|
876
|
-
remediation: rule.ruleId === "brandlab-config-v2-url-derived-breadcrumbs"
|
|
877
|
-
? "Use BrandlabRouteBreadcrumbs with the typed route catalog; keep path labels only as a development fallback."
|
|
878
|
-
: "Keep only the thin project binding and consume the shared runtime.",
|
|
879
|
-
severity: "severity" in rule ? rule.severity : "blocker",
|
|
880
|
-
path: relativePath,
|
|
881
|
-
line,
|
|
882
|
-
}));
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
function walkSourceFiles(root) {
|
|
887
|
-
if (!existsSync(root))
|
|
888
|
-
return [];
|
|
889
|
-
const files = [];
|
|
890
|
-
const visit = (directory) => {
|
|
891
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
892
|
-
if (entry.isDirectory()) {
|
|
893
|
-
if (!SKIPPED_DIRECTORIES.has(entry.name))
|
|
894
|
-
visit(join(directory, entry.name));
|
|
895
|
-
}
|
|
896
|
-
else if (entry.isFile() && SOURCE_EXTENSIONS.has(extname(entry.name))) {
|
|
897
|
-
files.push(join(directory, entry.name));
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
};
|
|
901
|
-
visit(root);
|
|
902
|
-
return files.sort();
|
|
903
|
-
}
|
|
904
|
-
function configIssue(options) {
|
|
905
|
-
return {
|
|
906
|
-
ruleId: options.ruleId,
|
|
907
|
-
surface: options.surface ?? "configuration",
|
|
908
|
-
severity: options.severity ?? "blocker",
|
|
909
|
-
evidenceKind: "static",
|
|
910
|
-
expected: options.expected,
|
|
911
|
-
actual: options.actual,
|
|
912
|
-
remediation: options.remediation,
|
|
913
|
-
...(options.path ? { path: options.path } : {}),
|
|
914
|
-
...(options.line ? { line: options.line } : {}),
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
function isRecord(value) {
|
|
918
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
919
|
-
}
|
|
920
|
-
function normalizePath(value) {
|
|
921
|
-
return value.split(/[/\\]/).join("/");
|
|
922
|
-
}
|
|
923
|
-
function getParseDiagnostics(source) {
|
|
924
|
-
return source.parseDiagnostics ?? [];
|
|
925
|
-
}
|
|
926
|
-
//# sourceMappingURL=project-config-v2-contract.js.map
|