@doneisbetter/gds-compliance 2.6.4 → 2.6.5
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/index.js +149 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -16,6 +16,15 @@ const STRICT_COMPLIANCE_FIELDS = [
|
|
|
16
16
|
'approvedListingPrimitives',
|
|
17
17
|
'approvedActionPrimitives',
|
|
18
18
|
'approvedTemporaryExceptions',
|
|
19
|
+
'approvedThemeLanes',
|
|
20
|
+
'themeOwnershipPaths',
|
|
21
|
+
];
|
|
22
|
+
const DEFAULT_APPROVED_THEME_LANES = [
|
|
23
|
+
'gdsTheme',
|
|
24
|
+
'gdsDarkPublicTheme',
|
|
25
|
+
'gdsFlatSurfaceTheme',
|
|
26
|
+
'gdsEditorialPublicTheme',
|
|
27
|
+
'createPublicBrandTheme',
|
|
19
28
|
];
|
|
20
29
|
const EXCEPTION_CATEGORIES = new Set([
|
|
21
30
|
'runtime-constraint',
|
|
@@ -33,6 +42,11 @@ const EXCEPTION_REQUIRED_FIELDS = [
|
|
|
33
42
|
'exitCondition',
|
|
34
43
|
'status',
|
|
35
44
|
];
|
|
45
|
+
const PRODUCT_AUTHORED_REQUIRED_FIELDS = [
|
|
46
|
+
'a11yRequirements',
|
|
47
|
+
'testingRequirements',
|
|
48
|
+
'observabilityRequirements',
|
|
49
|
+
];
|
|
36
50
|
|
|
37
51
|
export function validateManifest(manifest) {
|
|
38
52
|
const findings = [];
|
|
@@ -161,6 +175,22 @@ function validateApprovedExceptions(manifest) {
|
|
|
161
175
|
message: `Approved exception "${exception.surface}" has an over-broad scope. Exception scopes must stay narrow and reviewable.`,
|
|
162
176
|
});
|
|
163
177
|
}
|
|
178
|
+
|
|
179
|
+
if (exception.category === 'product-authored-experience') {
|
|
180
|
+
const missingProductAuthoredFields = PRODUCT_AUTHORED_REQUIRED_FIELDS.filter((field) => {
|
|
181
|
+
const value = exception[field];
|
|
182
|
+
return !Array.isArray(value) || value.length === 0;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (missingProductAuthoredFields.length > 0) {
|
|
186
|
+
findings.push({
|
|
187
|
+
rule: 'exception-product-authored-metadata',
|
|
188
|
+
severity: 'error',
|
|
189
|
+
file: exception.surface,
|
|
190
|
+
message: `Creator-authored experience exception "${exception.surface}" must define ${missingProductAuthoredFields.join(', ')} so accessibility, testing, and observability obligations remain explicit.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
164
194
|
}
|
|
165
195
|
|
|
166
196
|
return findings;
|
|
@@ -188,6 +218,23 @@ function normalizePath(value) {
|
|
|
188
218
|
return value.replace(/\\/g, '/');
|
|
189
219
|
}
|
|
190
220
|
|
|
221
|
+
function escapeRegex(value) {
|
|
222
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function globToRegExp(pattern) {
|
|
226
|
+
const normalized = normalizePath(pattern).replace(/^\.\//, '');
|
|
227
|
+
const escaped = escapeRegex(normalized)
|
|
228
|
+
.replace(/\\\*\\\*/g, '.*')
|
|
229
|
+
.replace(/\\\*/g, '[^/]*');
|
|
230
|
+
return new RegExp(`^${escaped}$`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function matchesScope(relativePath, scopes) {
|
|
234
|
+
const normalizedRelativePath = normalizePath(relativePath).replace(/^\.\//, '');
|
|
235
|
+
return scopes.some((scope) => globToRegExp(scope).test(normalizedRelativePath));
|
|
236
|
+
}
|
|
237
|
+
|
|
191
238
|
function isForbiddenImport(source, allowedImports, forbiddenImports) {
|
|
192
239
|
if (allowedImports.has(source)) {
|
|
193
240
|
return false;
|
|
@@ -319,6 +366,106 @@ function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
|
|
|
319
366
|
return findings;
|
|
320
367
|
}
|
|
321
368
|
|
|
369
|
+
function validateApprovedExceptionsAgainstRepo({ manifestRoot, manifest, sourceFiles }) {
|
|
370
|
+
const findings = [];
|
|
371
|
+
const normalizedRoot = normalizePath(manifestRoot).replace(/\/$/, '');
|
|
372
|
+
const normalizedSourceFiles = sourceFiles.map((absolutePath) => ({
|
|
373
|
+
absolutePath,
|
|
374
|
+
relativePath: normalizePath(absolutePath).replace(`${normalizedRoot}/`, ''),
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
for (const exception of manifest.approvedExceptions ?? []) {
|
|
378
|
+
const scopes = exception.scope ?? [];
|
|
379
|
+
if (!scopes.length) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const matchingFiles = normalizedSourceFiles.filter((file) => matchesScope(file.relativePath, scopes));
|
|
384
|
+
if (!matchingFiles.length) {
|
|
385
|
+
findings.push({
|
|
386
|
+
rule: 'exception-scope-no-matches',
|
|
387
|
+
severity: 'error',
|
|
388
|
+
file: exception.surface,
|
|
389
|
+
message: `Approved exception "${exception.surface}" does not match any files in the repository. Remove the stale exception or narrow it to the real implementation path.`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const adapter of manifest.localAdapters ?? []) {
|
|
395
|
+
if (adapter.status !== 'exception') {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const normalizedAdapterPath = normalizePath(adapter.path).replace(/^\.\//, '');
|
|
400
|
+
const coveredByApprovedException = (manifest.approvedExceptions ?? []).some((exception) =>
|
|
401
|
+
matchesScope(normalizedAdapterPath, exception.scope ?? []));
|
|
402
|
+
|
|
403
|
+
if (!coveredByApprovedException) {
|
|
404
|
+
findings.push({
|
|
405
|
+
rule: 'exception-adapter-outside-scope',
|
|
406
|
+
severity: 'error',
|
|
407
|
+
file: adapter.path,
|
|
408
|
+
message: `Local adapter exception "${adapter.contract}" is not covered by any approved exception scope. Tie exception adapters to a reviewed narrow exception instead of leaving local authority unbounded.`,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return findings;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isCoveredByApprovedException(relativePath, approvedExceptions = []) {
|
|
417
|
+
return approvedExceptions.some((exception) => matchesScope(relativePath, exception.scope ?? []));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function findThemeOwnershipFiles({ manifestRoot, sourceFiles, themeOwnershipPaths = [] }) {
|
|
421
|
+
if (!themeOwnershipPaths.length) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const normalizedRoot = normalizePath(manifestRoot).replace(/\/$/, '');
|
|
426
|
+
return sourceFiles.filter((absolutePath) => {
|
|
427
|
+
const relativePath = normalizePath(absolutePath).replace(`${normalizedRoot}/`, '');
|
|
428
|
+
return themeOwnershipPaths.some((scope) => matchesScope(relativePath, [scope]));
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function scanThemeGovernance({ manifestRoot, manifest, sourceFiles }) {
|
|
433
|
+
const findings = [];
|
|
434
|
+
const approvedThemeLanes = new Set(manifest.compliance?.approvedThemeLanes ?? DEFAULT_APPROVED_THEME_LANES);
|
|
435
|
+
const themeOwnershipPaths = manifest.compliance?.themeOwnershipPaths ?? [];
|
|
436
|
+
const themeFiles = findThemeOwnershipFiles({ manifestRoot, sourceFiles, themeOwnershipPaths });
|
|
437
|
+
const normalizedRoot = normalizePath(manifestRoot).replace(/\/$/, '');
|
|
438
|
+
|
|
439
|
+
for (const filePath of themeFiles) {
|
|
440
|
+
const content = readFileSync(filePath, 'utf8');
|
|
441
|
+
const relativePath = normalizePath(filePath).replace(`${normalizedRoot}/`, '');
|
|
442
|
+
|
|
443
|
+
if (isCoveredByApprovedException(relativePath, manifest.approvedExceptions ?? [])) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (/import\s*\{[^}]*\bextendGdsTheme\b[^}]*\}\s*from\s*['"]@doneisbetter\/gds(?:-theme)?(?:\/(?:client|server))?['"]/.test(content)) {
|
|
448
|
+
findings.push({
|
|
449
|
+
rule: 'theme.noncanonical-extend-helper',
|
|
450
|
+
severity: 'error',
|
|
451
|
+
file: relativePath,
|
|
452
|
+
message: `Theme ownership file "${relativePath}" imports extendGdsTheme(...). Consumer repos must use approved theme lanes (${[...approvedThemeLanes].join(', ')}) instead of a custom branding-layer helper.`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (/\b(createTheme|mergeMantineTheme|mergeThemeOverrides)\s*\(/.test(content)) {
|
|
457
|
+
findings.push({
|
|
458
|
+
rule: 'theme.parallel-branding-layer',
|
|
459
|
+
severity: 'error',
|
|
460
|
+
file: relativePath,
|
|
461
|
+
message: `Theme ownership file "${relativePath}" creates a local Mantine theme layer outside the approved GDS theme lanes. Use a shipped preset or createPublicBrandTheme(...) instead of a parallel branding authority.`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return findings;
|
|
467
|
+
}
|
|
468
|
+
|
|
322
469
|
export function runComplianceCheck({ manifestPath }) {
|
|
323
470
|
const absoluteManifestPath = resolve(manifestPath);
|
|
324
471
|
const manifestRoot = dirname(absoluteManifestPath);
|
|
@@ -393,6 +540,8 @@ export function runComplianceCheck({ manifestPath }) {
|
|
|
393
540
|
}
|
|
394
541
|
|
|
395
542
|
findings.push(...validateApprovedExceptions(manifest));
|
|
543
|
+
findings.push(...validateApprovedExceptionsAgainstRepo({ manifestRoot, manifest, sourceFiles }));
|
|
544
|
+
findings.push(...scanThemeGovernance({ manifestRoot, manifest, sourceFiles }));
|
|
396
545
|
|
|
397
546
|
if (protectedSurfacePaths.length) {
|
|
398
547
|
const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
|