@doneisbetter/gds-compliance 2.6.1 → 2.6.4
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 +176 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -10,6 +10,29 @@ 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
|
+
];
|
|
20
|
+
const EXCEPTION_CATEGORIES = new Set([
|
|
21
|
+
'runtime-constraint',
|
|
22
|
+
'product-authored-experience',
|
|
23
|
+
'package-coverage-gap',
|
|
24
|
+
'migration-bridge',
|
|
25
|
+
]);
|
|
26
|
+
const EXCEPTION_STATUSES = new Set(['temporary', 'approved', 'deprecated', 'removed']);
|
|
27
|
+
const EXCEPTION_REQUIRED_FIELDS = [
|
|
28
|
+
'category',
|
|
29
|
+
'scope',
|
|
30
|
+
'allowedImplementation',
|
|
31
|
+
'mustStillUse',
|
|
32
|
+
'mustNotDo',
|
|
33
|
+
'exitCondition',
|
|
34
|
+
'status',
|
|
35
|
+
];
|
|
13
36
|
|
|
14
37
|
export function validateManifest(manifest) {
|
|
15
38
|
const findings = [];
|
|
@@ -63,6 +86,83 @@ export function validateManifest(manifest) {
|
|
|
63
86
|
}
|
|
64
87
|
}
|
|
65
88
|
|
|
89
|
+
if (manifest.compliance?.strictMode != null && typeof manifest.compliance.strictMode !== 'boolean') {
|
|
90
|
+
findings.push({
|
|
91
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
92
|
+
severity: 'error',
|
|
93
|
+
message: 'compliance.strictMode must be a boolean when provided.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const field of STRICT_COMPLIANCE_FIELDS) {
|
|
98
|
+
const value = manifest.compliance?.[field];
|
|
99
|
+
if (value != null && !Array.isArray(value)) {
|
|
100
|
+
findings.push({
|
|
101
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
102
|
+
severity: 'error',
|
|
103
|
+
message: `compliance.${field} must be an array when provided.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hasBroadScope(scope) {
|
|
112
|
+
return scope.some((entry) =>
|
|
113
|
+
['*', '**', 'app/**', 'src/**', './**', '/**'].includes(entry) || /(^|\/)\*\*$/.test(entry));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateApprovedExceptions(manifest) {
|
|
117
|
+
const findings = [];
|
|
118
|
+
|
|
119
|
+
for (const exception of manifest.approvedExceptions ?? []) {
|
|
120
|
+
const missingFields = EXCEPTION_REQUIRED_FIELDS.filter((field) => {
|
|
121
|
+
const value = exception[field];
|
|
122
|
+
if (Array.isArray(value)) {
|
|
123
|
+
return value.length === 0;
|
|
124
|
+
}
|
|
125
|
+
return !value;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (missingFields.length > 0) {
|
|
129
|
+
findings.push({
|
|
130
|
+
rule: 'exception-required-fields',
|
|
131
|
+
severity: 'error',
|
|
132
|
+
file: exception.surface,
|
|
133
|
+
message: `Approved exception "${exception.surface}" must define ${missingFields.join(', ')}. Upgrade legacy exception entries to the canonical exception-surface contract.`,
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!EXCEPTION_CATEGORIES.has(exception.category)) {
|
|
139
|
+
findings.push({
|
|
140
|
+
rule: 'exception-invalid-category',
|
|
141
|
+
severity: 'error',
|
|
142
|
+
file: exception.surface,
|
|
143
|
+
message: `Approved exception "${exception.surface}" uses unsupported category "${exception.category}".`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!EXCEPTION_STATUSES.has(exception.status)) {
|
|
148
|
+
findings.push({
|
|
149
|
+
rule: 'exception-invalid-status',
|
|
150
|
+
severity: 'error',
|
|
151
|
+
file: exception.surface,
|
|
152
|
+
message: `Approved exception "${exception.surface}" uses unsupported status "${exception.status}".`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (hasBroadScope(exception.scope ?? [])) {
|
|
157
|
+
findings.push({
|
|
158
|
+
rule: 'exception-broad-scope',
|
|
159
|
+
severity: 'error',
|
|
160
|
+
file: exception.surface,
|
|
161
|
+
message: `Approved exception "${exception.surface}" has an over-broad scope. Exception scopes must stay narrow and reviewable.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
66
166
|
return findings;
|
|
67
167
|
}
|
|
68
168
|
|
|
@@ -150,6 +250,75 @@ function scanDocumentationFile(filePath, staleReferences) {
|
|
|
150
250
|
return findings;
|
|
151
251
|
}
|
|
152
252
|
|
|
253
|
+
function inferStrictSurface(contract) {
|
|
254
|
+
const normalized = contract.toLowerCase();
|
|
255
|
+
if (normalized.includes('shell')) return 'shell';
|
|
256
|
+
if (normalized.includes('detail') || normalized.includes('profile')) return 'detail';
|
|
257
|
+
if (normalized.includes('card') || normalized.includes('listing')) return 'listing';
|
|
258
|
+
if (normalized.includes('action') || normalized.includes('button')) return 'action';
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
|
|
263
|
+
const findings = [];
|
|
264
|
+
const strict = manifest.compliance ?? {};
|
|
265
|
+
const approvedBySurface = {
|
|
266
|
+
shell: new Set(strict.approvedShellPrimitives ?? []),
|
|
267
|
+
detail: new Set(strict.approvedDetailPrimitives ?? []),
|
|
268
|
+
listing: new Set(strict.approvedListingPrimitives ?? []),
|
|
269
|
+
action: new Set(strict.approvedActionPrimitives ?? []),
|
|
270
|
+
};
|
|
271
|
+
const approvedTemporaryExceptions = new Set(strict.approvedTemporaryExceptions ?? []);
|
|
272
|
+
|
|
273
|
+
for (const adapter of manifest.localAdapters ?? []) {
|
|
274
|
+
if (!['active', 'exception'].includes(adapter.status)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const surface = inferStrictSurface(adapter.contract);
|
|
279
|
+
if (!surface) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (approvedBySurface[surface].has(adapter.contract) || approvedTemporaryExceptions.has(adapter.contract)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
findings.push({
|
|
288
|
+
rule: `strict.${surface}.local-adapter`,
|
|
289
|
+
severity: 'error',
|
|
290
|
+
file: adapter.path,
|
|
291
|
+
message: `Strict mode forbids local ${surface} adapter "${adapter.contract}". Migrate to the approved GDS primitive or declare a reviewed temporary exception.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const filePath of sourceFiles) {
|
|
296
|
+
const content = readFileSync(filePath, 'utf8');
|
|
297
|
+
|
|
298
|
+
if (/AppShell\s+as\s+MantineAppShell|<MantineAppShell\b|from\s+['"]@mantine\/core['"][\s\S]{0,120}AppShell/.test(content)) {
|
|
299
|
+
findings.push({
|
|
300
|
+
rule: 'strict.shell.mantine-app-shell',
|
|
301
|
+
severity: 'error',
|
|
302
|
+
file: filePath,
|
|
303
|
+
message: 'Strict mode forbids local Mantine AppShell wrappers. Use DiscoveryShell or the approved GDS shell wrapper.',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (/(interface|type)\s+\w*(ActionBar|ButtonGroup|ButtonStack|Cta)\w*\s*[\{=]|export function \w*(ActionBar|ButtonGroup|ButtonStack|Cta)\w*/.test(content)
|
|
308
|
+
&& /from\s+['"]@mantine\/core['"][\s\S]{0,200}\bButton\b/.test(content)
|
|
309
|
+
&& !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,200}\bActionBar\b/.test(content)) {
|
|
310
|
+
findings.push({
|
|
311
|
+
rule: 'strict.action.legacy-wrapper',
|
|
312
|
+
severity: 'error',
|
|
313
|
+
file: filePath,
|
|
314
|
+
message: 'Strict mode forbids local button/action wrapper implementations. Use the canonical GDS ActionBar and semantic actions.',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return findings;
|
|
320
|
+
}
|
|
321
|
+
|
|
153
322
|
export function runComplianceCheck({ manifestPath }) {
|
|
154
323
|
const absoluteManifestPath = resolve(manifestPath);
|
|
155
324
|
const manifestRoot = dirname(absoluteManifestPath);
|
|
@@ -166,6 +335,7 @@ export function runComplianceCheck({ manifestPath }) {
|
|
|
166
335
|
...DEFAULT_FORBIDDEN_IMPORTS,
|
|
167
336
|
...(manifest.compliance?.bannedImports ?? []),
|
|
168
337
|
];
|
|
338
|
+
const strictMode = manifest.compliance?.strictMode === true;
|
|
169
339
|
|
|
170
340
|
for (const exception of manifest.approvedExceptions ?? []) {
|
|
171
341
|
if (exception.dependency) {
|
|
@@ -222,6 +392,8 @@ export function runComplianceCheck({ manifestPath }) {
|
|
|
222
392
|
findings.push(...scanSourceFile(filePath, allowedImports, forbiddenImports));
|
|
223
393
|
}
|
|
224
394
|
|
|
395
|
+
findings.push(...validateApprovedExceptions(manifest));
|
|
396
|
+
|
|
225
397
|
if (protectedSurfacePaths.length) {
|
|
226
398
|
const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
|
|
227
399
|
|
|
@@ -246,6 +418,10 @@ export function runComplianceCheck({ manifestPath }) {
|
|
|
246
418
|
}
|
|
247
419
|
}
|
|
248
420
|
|
|
421
|
+
if (strictMode) {
|
|
422
|
+
findings.push(...runStrictCompliance({ manifest, manifestRoot, sourceFiles }));
|
|
423
|
+
}
|
|
424
|
+
|
|
249
425
|
return {
|
|
250
426
|
manifest,
|
|
251
427
|
findings,
|