@doneisbetter/gds-compliance 2.6.1
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/bin/gds-compliance.js +36 -0
- package/index.js +279 -0
- package/package.json +24 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureManifestExists, formatReport, runComplianceCheck } from '../index.js';
|
|
3
|
+
|
|
4
|
+
const [, , command, ...rest] = process.argv;
|
|
5
|
+
|
|
6
|
+
function getArg(name, fallback) {
|
|
7
|
+
const flagIndex = rest.findIndex((arg) => arg === name);
|
|
8
|
+
if (flagIndex === -1) {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
return rest[flagIndex + 1] ?? fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const manifestPath = getArg('--manifest', './gds-adoption.json');
|
|
15
|
+
const format = getArg('--format', 'text');
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
ensureManifestExists(manifestPath);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(String(error));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (command !== 'check' && command !== 'validate-manifest') {
|
|
25
|
+
console.error('Usage: gds-compliance <check|validate-manifest> --manifest ./gds-adoption.json [--format text|json]');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const report = runComplianceCheck({ manifestPath });
|
|
30
|
+
const output = formatReport(report, format);
|
|
31
|
+
if (output) {
|
|
32
|
+
console.log(output);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasErrors = report.findings.some((finding) => finding.severity === 'error');
|
|
36
|
+
process.exit(hasErrors ? 1 : 0);
|
package/index.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
|
5
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'coverage']);
|
|
6
|
+
const RAW_COLOR_PATTERN = /#(?:[0-9a-fA-F]{3,8})\b|rgb[a]?\s*\(/;
|
|
7
|
+
const IMPORT_SOURCE_PATTERN = /(?:import\s+[^'"]*?from\s*|import\s*)['"]([^'"]+)['"]/g;
|
|
8
|
+
const DEFAULT_FORBIDDEN_IMPORTS = ['@/components/ui/', '@radix-ui/', 'tailwindcss', 'lucide-react'];
|
|
9
|
+
const DEFAULT_STALE_DOCUMENTATION_REFERENCES = [
|
|
10
|
+
'/Users/Shared/Projects/GENERAL_DESIGN_SYSTEM',
|
|
11
|
+
'GENERAL_DESIGN_SYSTEM',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function validateManifest(manifest) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
|
|
17
|
+
const requiredFields = [
|
|
18
|
+
'schemaVersion',
|
|
19
|
+
'gdsVersion',
|
|
20
|
+
'productArchetype',
|
|
21
|
+
'requiredContracts',
|
|
22
|
+
'localAdapters',
|
|
23
|
+
'approvedExceptions',
|
|
24
|
+
'migrationStatus',
|
|
25
|
+
'owner',
|
|
26
|
+
'lastReviewedAt',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const field of requiredFields) {
|
|
30
|
+
if (!(field in manifest)) {
|
|
31
|
+
findings.push({
|
|
32
|
+
rule: 'manifest.missingField',
|
|
33
|
+
severity: 'error',
|
|
34
|
+
message: `Missing required manifest field: ${field}`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const exception of manifest.approvedExceptions ?? []) {
|
|
40
|
+
for (const field of ['surface', 'reason', 'owner', 'reviewDate']) {
|
|
41
|
+
if (!exception[field]) {
|
|
42
|
+
findings.push({
|
|
43
|
+
rule: 'manifest.invalidException',
|
|
44
|
+
severity: 'error',
|
|
45
|
+
message: `Approved exception is missing ${field}.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const [field, value] of Object.entries({
|
|
52
|
+
documentationPaths: manifest.compliance?.documentationPaths ?? [],
|
|
53
|
+
staleDocumentationReferences: manifest.compliance?.staleDocumentationReferences ?? [],
|
|
54
|
+
protectedSurfacePaths: manifest.compliance?.protectedSurfacePaths ?? [],
|
|
55
|
+
bannedImports: manifest.compliance?.bannedImports ?? [],
|
|
56
|
+
})) {
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
findings.push({
|
|
59
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
60
|
+
severity: 'error',
|
|
61
|
+
message: `compliance.${field} must be an array when provided.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function walk(dir, files = []) {
|
|
70
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
walk(join(dir, entry.name), files);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (SOURCE_EXTENSIONS.has(extname(entry.name))) {
|
|
80
|
+
files.push(join(dir, entry.name));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return files;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizePath(value) {
|
|
88
|
+
return value.replace(/\\/g, '/');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isForbiddenImport(source, allowedImports, forbiddenImports) {
|
|
92
|
+
if (allowedImports.has(source)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return forbiddenImports.some((entry) => {
|
|
97
|
+
if (entry.endsWith('/')) {
|
|
98
|
+
return source.startsWith(entry);
|
|
99
|
+
}
|
|
100
|
+
if (entry === 'tailwindcss') {
|
|
101
|
+
return source === 'tailwindcss' || source.startsWith('tailwindcss/');
|
|
102
|
+
}
|
|
103
|
+
return source === entry;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scanSourceFile(filePath, allowedImports, forbiddenImports) {
|
|
108
|
+
const findings = [];
|
|
109
|
+
const content = readFileSync(filePath, 'utf8');
|
|
110
|
+
|
|
111
|
+
if (!/(?:^|\/)(?:theme|tokens)\//.test(filePath) && RAW_COLOR_PATTERN.test(content)) {
|
|
112
|
+
findings.push({
|
|
113
|
+
rule: 'forbidden-color',
|
|
114
|
+
severity: 'error',
|
|
115
|
+
file: filePath,
|
|
116
|
+
message: 'Raw color literal found outside approved theme/token files.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const match of content.matchAll(IMPORT_SOURCE_PATTERN)) {
|
|
121
|
+
const source = match[1];
|
|
122
|
+
if (source && isForbiddenImport(source, allowedImports, forbiddenImports)) {
|
|
123
|
+
findings.push({
|
|
124
|
+
rule: 'forbidden-import',
|
|
125
|
+
severity: 'error',
|
|
126
|
+
file: filePath,
|
|
127
|
+
message: `Forbidden UI import detected (${source}); use canonical GDS surfaces instead.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return findings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scanDocumentationFile(filePath, staleReferences) {
|
|
136
|
+
const findings = [];
|
|
137
|
+
const content = readFileSync(filePath, 'utf8');
|
|
138
|
+
|
|
139
|
+
for (const staleReference of staleReferences) {
|
|
140
|
+
if (staleReference && content.includes(staleReference)) {
|
|
141
|
+
findings.push({
|
|
142
|
+
rule: 'stale-documentation-reference',
|
|
143
|
+
severity: 'error',
|
|
144
|
+
file: filePath,
|
|
145
|
+
message: `Stale GDS reference detected (${staleReference}). Update local docs to the active SSOT structure.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function runComplianceCheck({ manifestPath }) {
|
|
154
|
+
const absoluteManifestPath = resolve(manifestPath);
|
|
155
|
+
const manifestRoot = dirname(absoluteManifestPath);
|
|
156
|
+
const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8'));
|
|
157
|
+
const findings = validateManifest(manifest);
|
|
158
|
+
const allowedImports = new Set();
|
|
159
|
+
const documentationPaths = manifest.compliance?.documentationPaths ?? [];
|
|
160
|
+
const staleDocumentationReferences = [
|
|
161
|
+
...DEFAULT_STALE_DOCUMENTATION_REFERENCES,
|
|
162
|
+
...(manifest.compliance?.staleDocumentationReferences ?? []),
|
|
163
|
+
];
|
|
164
|
+
const protectedSurfacePaths = manifest.compliance?.protectedSurfacePaths ?? [];
|
|
165
|
+
const forbiddenImports = [
|
|
166
|
+
...DEFAULT_FORBIDDEN_IMPORTS,
|
|
167
|
+
...(manifest.compliance?.bannedImports ?? []),
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
for (const exception of manifest.approvedExceptions ?? []) {
|
|
171
|
+
if (exception.dependency) {
|
|
172
|
+
allowedImports.add(exception.dependency);
|
|
173
|
+
}
|
|
174
|
+
for (const value of exception.allowImports ?? []) {
|
|
175
|
+
allowedImports.add(value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const adapter of manifest.localAdapters ?? []) {
|
|
180
|
+
if (adapter.status === 'active' || adapter.status === 'exception') {
|
|
181
|
+
const adapterPath = resolve(manifestRoot, adapter.path);
|
|
182
|
+
if (!existsSync(adapterPath)) {
|
|
183
|
+
findings.push({
|
|
184
|
+
rule: 'missing-adapter',
|
|
185
|
+
severity: 'error',
|
|
186
|
+
file: adapter.path,
|
|
187
|
+
message: `Declared adapter path does not exist: ${adapter.path}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const documentationPath of documentationPaths) {
|
|
194
|
+
const absoluteDocumentationPath = resolve(manifestRoot, documentationPath);
|
|
195
|
+
if (!existsSync(absoluteDocumentationPath)) {
|
|
196
|
+
findings.push({
|
|
197
|
+
rule: 'missing-documentation-path',
|
|
198
|
+
severity: 'error',
|
|
199
|
+
file: documentationPath,
|
|
200
|
+
message: `Declared documentation path does not exist: ${documentationPath}`,
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
findings.push(...scanDocumentationFile(absoluteDocumentationPath, staleDocumentationReferences));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const protectedSurfacePath of protectedSurfacePaths) {
|
|
209
|
+
const absoluteProtectedSurfacePath = resolve(manifestRoot, protectedSurfacePath);
|
|
210
|
+
if (!existsSync(absoluteProtectedSurfacePath)) {
|
|
211
|
+
findings.push({
|
|
212
|
+
rule: 'missing-protected-surface',
|
|
213
|
+
severity: 'error',
|
|
214
|
+
file: protectedSurfacePath,
|
|
215
|
+
message: `Declared protected surface path does not exist: ${protectedSurfacePath}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const sourceFiles = walk(manifestRoot);
|
|
221
|
+
for (const filePath of sourceFiles) {
|
|
222
|
+
findings.push(...scanSourceFile(filePath, allowedImports, forbiddenImports));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (protectedSurfacePaths.length) {
|
|
226
|
+
const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
|
|
227
|
+
|
|
228
|
+
for (const filePath of sourceFiles) {
|
|
229
|
+
const normalizedFilePath = normalizePath(filePath);
|
|
230
|
+
const isProtectedSurface = normalizedProtectedSurfacePaths.some((protectedSurfacePath) =>
|
|
231
|
+
normalizedFilePath === protectedSurfacePath || normalizedFilePath.startsWith(`${protectedSurfacePath}/`));
|
|
232
|
+
|
|
233
|
+
if (!isProtectedSurface) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const content = readFileSync(filePath, 'utf8');
|
|
238
|
+
if (/className\s*=\s*["'`][^"'`]*(?:bg-|text-|border-|rounded-|shadow-|grid |flex |px-|py-|mx-|my-)/.test(content)) {
|
|
239
|
+
findings.push({
|
|
240
|
+
rule: 'protected-surface-utility-drift',
|
|
241
|
+
severity: 'warn',
|
|
242
|
+
file: filePath,
|
|
243
|
+
message: 'Protected surface contains utility-style className tokens. Prefer canonical GDS surfaces or Mantine-native styling for governed files.',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
manifest,
|
|
251
|
+
findings,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function formatReport(report, format = 'text') {
|
|
256
|
+
if (format === 'json') {
|
|
257
|
+
return JSON.stringify(report, null, 2);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!report.findings.length) {
|
|
261
|
+
return `GDS compliance check passed for ${report.manifest.owner}.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return [
|
|
265
|
+
`GDS compliance check found ${report.findings.length} issue(s):`,
|
|
266
|
+
...report.findings.map((finding) => {
|
|
267
|
+
const location = finding.file ? ` (${finding.file})` : '';
|
|
268
|
+
return `- [${finding.severity}] ${finding.rule}${location}: ${finding.message}`;
|
|
269
|
+
}),
|
|
270
|
+
].join('\n');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function ensureManifestExists(manifestPath) {
|
|
274
|
+
const absoluteManifestPath = resolve(manifestPath);
|
|
275
|
+
if (!existsSync(absoluteManifestPath) || !statSync(absoluteManifestPath).isFile()) {
|
|
276
|
+
throw new Error(`Manifest not found: ${manifestPath}`);
|
|
277
|
+
}
|
|
278
|
+
return absoluteManifestPath;
|
|
279
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doneisbetter/gds-compliance",
|
|
3
|
+
"version": "2.6.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gds-compliance": "bin/gds-compliance.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"index.js"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/sovereignsquad/general-design-system.git",
|
|
22
|
+
"directory": "packages/gds-compliance"
|
|
23
|
+
}
|
|
24
|
+
}
|