@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.
Files changed (2) hide show
  1. package/index.js +149 -0
  2. 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)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doneisbetter/gds-compliance",
3
- "version": "2.6.4",
3
+ "version": "2.6.5",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "bin": {