@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.
- package/index.js +325 -0
- 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,
|