@doneisbetter/gds-compliance 2.6.3 → 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 +325 -0
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -10,6 +10,43 @@ const DEFAULT_STALE_DOCUMENTATION_REFERENCES = [
10
10
  '/Users/Shared/Projects/GENERAL_DESIGN_SYSTEM',
11
11
  'GENERAL_DESIGN_SYSTEM',
12
12
  ];
13
+ const STRICT_COMPLIANCE_FIELDS = [
14
+ 'approvedShellPrimitives',
15
+ 'approvedDetailPrimitives',
16
+ 'approvedListingPrimitives',
17
+ 'approvedActionPrimitives',
18
+ 'approvedTemporaryExceptions',
19
+ 'approvedThemeLanes',
20
+ 'themeOwnershipPaths',
21
+ ];
22
+ const DEFAULT_APPROVED_THEME_LANES = [
23
+ 'gdsTheme',
24
+ 'gdsDarkPublicTheme',
25
+ 'gdsFlatSurfaceTheme',
26
+ 'gdsEditorialPublicTheme',
27
+ 'createPublicBrandTheme',
28
+ ];
29
+ const EXCEPTION_CATEGORIES = new Set([
30
+ 'runtime-constraint',
31
+ 'product-authored-experience',
32
+ 'package-coverage-gap',
33
+ 'migration-bridge',
34
+ ]);
35
+ const EXCEPTION_STATUSES = new Set(['temporary', 'approved', 'deprecated', 'removed']);
36
+ const EXCEPTION_REQUIRED_FIELDS = [
37
+ 'category',
38
+ 'scope',
39
+ 'allowedImplementation',
40
+ 'mustStillUse',
41
+ 'mustNotDo',
42
+ 'exitCondition',
43
+ 'status',
44
+ ];
45
+ const PRODUCT_AUTHORED_REQUIRED_FIELDS = [
46
+ 'a11yRequirements',
47
+ 'testingRequirements',
48
+ 'observabilityRequirements',
49
+ ];
13
50
 
14
51
  export function validateManifest(manifest) {
15
52
  const findings = [];
@@ -63,6 +100,99 @@ export function validateManifest(manifest) {
63
100
  }
64
101
  }
65
102
 
103
+ if (manifest.compliance?.strictMode != null && typeof manifest.compliance.strictMode !== 'boolean') {
104
+ findings.push({
105
+ rule: 'manifest.invalidComplianceConfig',
106
+ severity: 'error',
107
+ message: 'compliance.strictMode must be a boolean when provided.',
108
+ });
109
+ }
110
+
111
+ for (const field of STRICT_COMPLIANCE_FIELDS) {
112
+ const value = manifest.compliance?.[field];
113
+ if (value != null && !Array.isArray(value)) {
114
+ findings.push({
115
+ rule: 'manifest.invalidComplianceConfig',
116
+ severity: 'error',
117
+ message: `compliance.${field} must be an array when provided.`,
118
+ });
119
+ }
120
+ }
121
+
122
+ return findings;
123
+ }
124
+
125
+ function hasBroadScope(scope) {
126
+ return scope.some((entry) =>
127
+ ['*', '**', 'app/**', 'src/**', './**', '/**'].includes(entry) || /(^|\/)\*\*$/.test(entry));
128
+ }
129
+
130
+ function validateApprovedExceptions(manifest) {
131
+ const findings = [];
132
+
133
+ for (const exception of manifest.approvedExceptions ?? []) {
134
+ const missingFields = EXCEPTION_REQUIRED_FIELDS.filter((field) => {
135
+ const value = exception[field];
136
+ if (Array.isArray(value)) {
137
+ return value.length === 0;
138
+ }
139
+ return !value;
140
+ });
141
+
142
+ if (missingFields.length > 0) {
143
+ findings.push({
144
+ rule: 'exception-required-fields',
145
+ severity: 'error',
146
+ file: exception.surface,
147
+ message: `Approved exception "${exception.surface}" must define ${missingFields.join(', ')}. Upgrade legacy exception entries to the canonical exception-surface contract.`,
148
+ });
149
+ continue;
150
+ }
151
+
152
+ if (!EXCEPTION_CATEGORIES.has(exception.category)) {
153
+ findings.push({
154
+ rule: 'exception-invalid-category',
155
+ severity: 'error',
156
+ file: exception.surface,
157
+ message: `Approved exception "${exception.surface}" uses unsupported category "${exception.category}".`,
158
+ });
159
+ }
160
+
161
+ if (!EXCEPTION_STATUSES.has(exception.status)) {
162
+ findings.push({
163
+ rule: 'exception-invalid-status',
164
+ severity: 'error',
165
+ file: exception.surface,
166
+ message: `Approved exception "${exception.surface}" uses unsupported status "${exception.status}".`,
167
+ });
168
+ }
169
+
170
+ if (hasBroadScope(exception.scope ?? [])) {
171
+ findings.push({
172
+ rule: 'exception-broad-scope',
173
+ severity: 'error',
174
+ file: exception.surface,
175
+ message: `Approved exception "${exception.surface}" has an over-broad scope. Exception scopes must stay narrow and reviewable.`,
176
+ });
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
+ }
194
+ }
195
+
66
196
  return findings;
67
197
  }
68
198
 
@@ -88,6 +218,23 @@ function normalizePath(value) {
88
218
  return value.replace(/\\/g, '/');
89
219
  }
90
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
+
91
238
  function isForbiddenImport(source, allowedImports, forbiddenImports) {
92
239
  if (allowedImports.has(source)) {
93
240
  return false;
@@ -150,6 +297,175 @@ function scanDocumentationFile(filePath, staleReferences) {
150
297
  return findings;
151
298
  }
152
299
 
300
+ function inferStrictSurface(contract) {
301
+ const normalized = contract.toLowerCase();
302
+ if (normalized.includes('shell')) return 'shell';
303
+ if (normalized.includes('detail') || normalized.includes('profile')) return 'detail';
304
+ if (normalized.includes('card') || normalized.includes('listing')) return 'listing';
305
+ if (normalized.includes('action') || normalized.includes('button')) return 'action';
306
+ return null;
307
+ }
308
+
309
+ function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
310
+ const findings = [];
311
+ const strict = manifest.compliance ?? {};
312
+ const approvedBySurface = {
313
+ shell: new Set(strict.approvedShellPrimitives ?? []),
314
+ detail: new Set(strict.approvedDetailPrimitives ?? []),
315
+ listing: new Set(strict.approvedListingPrimitives ?? []),
316
+ action: new Set(strict.approvedActionPrimitives ?? []),
317
+ };
318
+ const approvedTemporaryExceptions = new Set(strict.approvedTemporaryExceptions ?? []);
319
+
320
+ for (const adapter of manifest.localAdapters ?? []) {
321
+ if (!['active', 'exception'].includes(adapter.status)) {
322
+ continue;
323
+ }
324
+
325
+ const surface = inferStrictSurface(adapter.contract);
326
+ if (!surface) {
327
+ continue;
328
+ }
329
+
330
+ if (approvedBySurface[surface].has(adapter.contract) || approvedTemporaryExceptions.has(adapter.contract)) {
331
+ continue;
332
+ }
333
+
334
+ findings.push({
335
+ rule: `strict.${surface}.local-adapter`,
336
+ severity: 'error',
337
+ file: adapter.path,
338
+ message: `Strict mode forbids local ${surface} adapter "${adapter.contract}". Migrate to the approved GDS primitive or declare a reviewed temporary exception.`,
339
+ });
340
+ }
341
+
342
+ for (const filePath of sourceFiles) {
343
+ const content = readFileSync(filePath, 'utf8');
344
+
345
+ if (/AppShell\s+as\s+MantineAppShell|<MantineAppShell\b|from\s+['"]@mantine\/core['"][\s\S]{0,120}AppShell/.test(content)) {
346
+ findings.push({
347
+ rule: 'strict.shell.mantine-app-shell',
348
+ severity: 'error',
349
+ file: filePath,
350
+ message: 'Strict mode forbids local Mantine AppShell wrappers. Use DiscoveryShell or the approved GDS shell wrapper.',
351
+ });
352
+ }
353
+
354
+ if (/(interface|type)\s+\w*(ActionBar|ButtonGroup|ButtonStack|Cta)\w*\s*[\{=]|export function \w*(ActionBar|ButtonGroup|ButtonStack|Cta)\w*/.test(content)
355
+ && /from\s+['"]@mantine\/core['"][\s\S]{0,200}\bButton\b/.test(content)
356
+ && !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,200}\bActionBar\b/.test(content)) {
357
+ findings.push({
358
+ rule: 'strict.action.legacy-wrapper',
359
+ severity: 'error',
360
+ file: filePath,
361
+ message: 'Strict mode forbids local button/action wrapper implementations. Use the canonical GDS ActionBar and semantic actions.',
362
+ });
363
+ }
364
+ }
365
+
366
+ return findings;
367
+ }
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
+
153
469
  export function runComplianceCheck({ manifestPath }) {
154
470
  const absoluteManifestPath = resolve(manifestPath);
155
471
  const manifestRoot = dirname(absoluteManifestPath);
@@ -166,6 +482,7 @@ export function runComplianceCheck({ manifestPath }) {
166
482
  ...DEFAULT_FORBIDDEN_IMPORTS,
167
483
  ...(manifest.compliance?.bannedImports ?? []),
168
484
  ];
485
+ const strictMode = manifest.compliance?.strictMode === true;
169
486
 
170
487
  for (const exception of manifest.approvedExceptions ?? []) {
171
488
  if (exception.dependency) {
@@ -222,6 +539,10 @@ export function runComplianceCheck({ manifestPath }) {
222
539
  findings.push(...scanSourceFile(filePath, allowedImports, forbiddenImports));
223
540
  }
224
541
 
542
+ findings.push(...validateApprovedExceptions(manifest));
543
+ findings.push(...validateApprovedExceptionsAgainstRepo({ manifestRoot, manifest, sourceFiles }));
544
+ findings.push(...scanThemeGovernance({ manifestRoot, manifest, sourceFiles }));
545
+
225
546
  if (protectedSurfacePaths.length) {
226
547
  const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
227
548
 
@@ -246,6 +567,10 @@ export function runComplianceCheck({ manifestPath }) {
246
567
  }
247
568
  }
248
569
 
570
+ if (strictMode) {
571
+ findings.push(...runStrictCompliance({ manifest, manifestRoot, sourceFiles }));
572
+ }
573
+
249
574
  return {
250
575
  manifest,
251
576
  findings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doneisbetter/gds-compliance",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "bin": {