@terra-symposium/standards-quality 0.0.0-stage → 0.5.0
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/README.md +89 -2
- package/dist/collapse.d.ts +13 -0
- package/dist/collapse.d.ts.map +1 -0
- package/dist/collapse.js +151 -0
- package/dist/collapse.js.map +1 -0
- package/dist/documentation.d.ts +84 -0
- package/dist/documentation.d.ts.map +1 -0
- package/dist/documentation.js +1244 -0
- package/dist/documentation.js.map +1 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +90 -0
- package/dist/files.js.map +1 -0
- package/dist/github-report.d.ts +89 -0
- package/dist/github-report.d.ts.map +1 -0
- package/dist/github-report.js +469 -0
- package/dist/github-report.js.map +1 -0
- package/dist/history-report.d.ts +52 -0
- package/dist/history-report.d.ts.map +1 -0
- package/dist/history-report.js +317 -0
- package/dist/history-report.js.map +1 -0
- package/dist/imports.d.ts +19 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +90 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/inline-if.d.ts +13 -0
- package/dist/inline-if.d.ts.map +1 -0
- package/dist/inline-if.js +48 -0
- package/dist/inline-if.js.map +1 -0
- package/dist/naming.d.ts +148 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +642 -0
- package/dist/naming.js.map +1 -0
- package/dist/object-forwarding.d.ts +44 -0
- package/dist/object-forwarding.d.ts.map +1 -0
- package/dist/object-forwarding.js +182 -0
- package/dist/object-forwarding.js.map +1 -0
- package/dist/patterns.d.ts +85 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +905 -0
- package/dist/patterns.js.map +1 -0
- package/dist/port.d.ts +21 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +81 -0
- package/dist/port.js.map +1 -0
- package/dist/progress.d.ts +16 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +30 -0
- package/dist/progress.js.map +1 -0
- package/dist/runner.d.ts +257 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +2481 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +19 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +76 -0
- package/dist/security.js.map +1 -0
- package/dist/single-use.d.ts +60 -0
- package/dist/single-use.d.ts.map +1 -0
- package/dist/single-use.js +1209 -0
- package/dist/single-use.js.map +1 -0
- package/package.json +45 -5
- package/quality.schema.json +130 -0
package/dist/patterns.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/quality/src/patterns.ts
|
|
3
|
+
* Generic source-pattern checks: repeated static classes and forbidden UI patterns.
|
|
4
|
+
* Kept together because both are repository-wide text inventories.
|
|
5
|
+
*/
|
|
6
|
+
/*//////////////////////////////////////////////////
|
|
7
|
+
IMPORTS
|
|
8
|
+
//////////////////////////////////////////////////*/
|
|
9
|
+
// API
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
13
|
+
import { displayPath, srcFiles } from './files.js';
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
/*//////////////////////////////////////////////////
|
|
17
|
+
REGEX
|
|
18
|
+
//////////////////////////////////////////////////*/
|
|
19
|
+
const A_ZA_Z0_9_G_REGEX = () => /[^A-Za-z0-9]+/g;
|
|
20
|
+
const G_REGEX = () => /^_+|_+$/g;
|
|
21
|
+
const G_CLASS_2 = () => /\\/g;
|
|
22
|
+
const EXPORT_CONST_A_ZA_Z_W_B_GM_REGEX = () => /^export const ([A-Za-z_$][\w$]*)\b/gm;
|
|
23
|
+
/*//////////////////////////////////////////////////
|
|
24
|
+
REGEX
|
|
25
|
+
//////////////////////////////////////////////////*/
|
|
26
|
+
const S_G_REGEX = () => /\s+/g;
|
|
27
|
+
/*//////////////////////////////////////////////////
|
|
28
|
+
REGEX
|
|
29
|
+
//////////////////////////////////////////////////*/
|
|
30
|
+
const TEST_TESTS_NODE_MODULES_DIST_BUILD_COVERAGE_REGEX = /(?:^|\/)(?:test|tests|node_modules|dist|build|coverage)(?:\/|$)/;
|
|
31
|
+
/*//////////////////////////////////////////////////
|
|
32
|
+
HELPERS
|
|
33
|
+
//////////////////////////////////////////////////*/
|
|
34
|
+
/**
|
|
35
|
+
* Reports repeated static class strings without assuming a framework.
|
|
36
|
+
*
|
|
37
|
+
* @param cfg Quality configuration defining the source roots and repeated-class policy.
|
|
38
|
+
* @param analysis Optional shared analysis context for reusing source text.
|
|
39
|
+
*
|
|
40
|
+
* @return Repeated static class strings that meet the configured minimum count.
|
|
41
|
+
*/
|
|
42
|
+
export function repeatedClassFindings(cfg, analysis) {
|
|
43
|
+
const settings = cfg.repeatedClasses ?? {};
|
|
44
|
+
const minimum = settings.minimumCount ?? 3;
|
|
45
|
+
const exts = new Set(settings.extensions ?? ['.astro', '.html', '.js', '.jsx', '.ts', '.tsx']);
|
|
46
|
+
const ignored = new Set(['test', 'tests', 'generated', 'node_modules', 'dist', 'build', 'coverage', ...(settings.ignoredDirectories ?? [])]);
|
|
47
|
+
const vals = new Map();
|
|
48
|
+
for (const file of (analysis?.files ?? srcFiles({ ...cfg, extensions: [...exts] })).filter((f) => exts.has(f.slice(f.lastIndexOf('.'))))) {
|
|
49
|
+
if ([...ignored].some((d) => file.split('/').includes(d)))
|
|
50
|
+
continue;
|
|
51
|
+
const src = analysis?.texts.get(file) ?? readFileSync(file, 'utf8');
|
|
52
|
+
// Markup embedded in a template literal is generated data, not a live
|
|
53
|
+
// attribute. The text-only inventory cannot safely rewrite it.
|
|
54
|
+
if (src.includes('`'))
|
|
55
|
+
continue;
|
|
56
|
+
for (const match of src.matchAll(/\b(?:class|className)\s*=\s*(['"])([^'"\n]+)\1/g)) {
|
|
57
|
+
const val = match[2].replace(S_G_REGEX(), ' ').trim();
|
|
58
|
+
if (val.includes('{') || val.split(' ').length < 2)
|
|
59
|
+
continue;
|
|
60
|
+
const location = `${displayPath(cfg, file)}:${src.slice(0, match.index ?? 0).split('\n').length}`;
|
|
61
|
+
vals.set(val, [...(vals.get(val) ?? []), location]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...vals.entries()].filter(([, l]) => l.length >= minimum).map(([v, l]) => ({ value: v, count: l.length, locations: l.sort((l, r) => l.localeCompare(r, undefined, { numeric: true })) }));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Converts a static class string into a stable, collision-resistant constant identifier.
|
|
68
|
+
*
|
|
69
|
+
* @param val Normalized class string being named.
|
|
70
|
+
* @param used Identifiers already present in the file.
|
|
71
|
+
*
|
|
72
|
+
* @return Upper-case identifier suitable for a generated class constant.
|
|
73
|
+
*/
|
|
74
|
+
function classConstName(val, used) {
|
|
75
|
+
const words = val
|
|
76
|
+
.split(/\s+/)
|
|
77
|
+
.map((t) => t.replace(A_ZA_Z0_9_G_REGEX(), '_').replace(G_REGEX(), ''))
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
let name = `${words.join('_') || 'SHARED'}_CLASS`.toUpperCase();
|
|
80
|
+
if (/^\d/.test(name))
|
|
81
|
+
name = `CLASS_${name}`;
|
|
82
|
+
if (name.length > 80)
|
|
83
|
+
name = `${name.slice(0, 70)}_${Math.abs([...val].reduce((n, c) => (n * 31 + c.charCodeAt(0)) | 0, 0))
|
|
84
|
+
.toString(36)
|
|
85
|
+
.toUpperCase()}`;
|
|
86
|
+
let suffix = 2;
|
|
87
|
+
while (used.has(name))
|
|
88
|
+
name = `${name}_${suffix++}`;
|
|
89
|
+
used.add(name);
|
|
90
|
+
return name;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Determines whether a source offset is inside a JavaScript template literal.
|
|
94
|
+
* Template-generated markup is data, so replacing its class text with a source
|
|
95
|
+
* identifier would change the generated markup rather than reference a value.
|
|
96
|
+
*
|
|
97
|
+
* @param src Source text containing the candidate match.
|
|
98
|
+
* @param offset Candidate offset in the source text.
|
|
99
|
+
*
|
|
100
|
+
* @return Whether the offset is enclosed by an unescaped backtick.
|
|
101
|
+
*/
|
|
102
|
+
function isInsideTemplateLiteral(src, offset) {
|
|
103
|
+
let open = false;
|
|
104
|
+
for (let i = 0; i < offset; i++) {
|
|
105
|
+
if (src[i] !== '`' || (i > 0 && src[i - 1] === '\\'))
|
|
106
|
+
continue;
|
|
107
|
+
open = !open;
|
|
108
|
+
}
|
|
109
|
+
return open;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Adds generated declarations to the existing CONFIG boundary without changing imports or prose.
|
|
113
|
+
*
|
|
114
|
+
* @param src Source text to update.
|
|
115
|
+
* @param label Generated subgroup label.
|
|
116
|
+
* @param declarations Constant declarations to insert.
|
|
117
|
+
* @param file Optional source filename used to preserve its syntax and formatting conventions.
|
|
118
|
+
*
|
|
119
|
+
* @return Source text containing the generated subgroup, or the original source when no CONFIG group exists.
|
|
120
|
+
*/
|
|
121
|
+
function insertCfgGroup(src, label, declarations, file) {
|
|
122
|
+
if (!declarations.length)
|
|
123
|
+
return src;
|
|
124
|
+
const cfg = /\/\*\/{10,}\r?\n[ \t]*CONFIG[ \t]*\r?\n[ \t]*\/{10,}\*\//.exec(src);
|
|
125
|
+
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
126
|
+
const group = [`/*${'/'.repeat(50)}`, label, `${'/'.repeat(50)}*/`, ...declarations].join(eol);
|
|
127
|
+
if (cfg) {
|
|
128
|
+
const at = cfg.index + cfg[0].length;
|
|
129
|
+
return `${src.slice(0, at)}${eol}${group}${eol}${src.slice(at)}`;
|
|
130
|
+
}
|
|
131
|
+
// Add generated constants only to JavaScript-family sources, after imports
|
|
132
|
+
// and directive prologues so module and directive semantics remain unchanged.
|
|
133
|
+
if (!file || file.endsWith('.html'))
|
|
134
|
+
return src;
|
|
135
|
+
if (file.endsWith('.astro')) {
|
|
136
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(src);
|
|
137
|
+
if (frontmatter) {
|
|
138
|
+
const at = frontmatter[0].lastIndexOf('---');
|
|
139
|
+
return `${src.slice(0, at)}${group}${eol}${src.slice(at)}`;
|
|
140
|
+
}
|
|
141
|
+
return `---${eol}${group}${eol}---${eol}${src}`;
|
|
142
|
+
}
|
|
143
|
+
const ast = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (const statement of ast.statements) {
|
|
146
|
+
if (ts.isImportDeclaration(statement) || (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)))
|
|
147
|
+
at = statement.end;
|
|
148
|
+
else
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
return `${src.slice(0, at)}${at ? eol : ''}${group}${eol}${src.slice(at)}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Writes a generated JavaScript-family update only after TypeScript accepts its syntax.
|
|
155
|
+
* Astro files are validated structurally by the narrowly scoped attribute edits instead.
|
|
156
|
+
*
|
|
157
|
+
* @param file Source file to update.
|
|
158
|
+
* @param src Candidate source text.
|
|
159
|
+
*
|
|
160
|
+
* @return `true` when the candidate was written.
|
|
161
|
+
*/
|
|
162
|
+
function writePatternSrc(file, src) {
|
|
163
|
+
if (!file.endsWith('.astro')) {
|
|
164
|
+
if (ts.transpileModule(src, { fileName: file, reportDiagnostics: true, compilerOptions: { allowJs: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ESNext } }).diagnostics?.length)
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
writeFileSync(file, src);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Replaces repeated static class attributes with file-local constants in the CONFIG/CLASSES group.
|
|
172
|
+
* Dynamic expressions, HTML files, and files without an existing CONFIG boundary are left unchanged.
|
|
173
|
+
*
|
|
174
|
+
* @param cfg Quality configuration defining the class threshold and source scope.
|
|
175
|
+
* @param analysis Optional shared analysis context used for discovery.
|
|
176
|
+
*
|
|
177
|
+
* @return Counts of replacements made and candidates deliberately left advisory.
|
|
178
|
+
*/
|
|
179
|
+
export function fixRepeatedClasses(cfg, analysis) {
|
|
180
|
+
const settings = cfg.repeatedClasses ?? {};
|
|
181
|
+
const minimum = settings.minimumCount ?? 3;
|
|
182
|
+
const exts = new Set(settings.extensions ?? ['.astro', '.html', '.js', '.jsx', '.ts', '.tsx']);
|
|
183
|
+
let fixed = 0;
|
|
184
|
+
let skipped = 0;
|
|
185
|
+
for (const file of analysis?.files ?? srcFiles({ ...cfg, extensions: [...exts] })) {
|
|
186
|
+
if (!exts.has(file.slice(file.lastIndexOf('.'))) || TEST_TESTS_NODE_MODULES_DIST_BUILD_COVERAGE_REGEX.test(file))
|
|
187
|
+
continue;
|
|
188
|
+
if (file.endsWith('.html'))
|
|
189
|
+
continue;
|
|
190
|
+
const src = analysis?.texts.get(file) ?? readFileSync(file, 'utf8');
|
|
191
|
+
// A text matcher cannot safely distinguish live JSX/HTML attributes from
|
|
192
|
+
// markup nested in template literals (including nested interpolations).
|
|
193
|
+
// Leave those files advisory rather than risking a literal `{CONST}` being
|
|
194
|
+
// written into generated output.
|
|
195
|
+
if (src.includes('`')) {
|
|
196
|
+
skipped += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const matches = [...src.matchAll(/\b(class|className)(\s*=\s*)(['"])([^'"\n]+)\3/g)]
|
|
200
|
+
.filter((m) => !isInsideTemplateLiteral(src, m.index ?? 0))
|
|
201
|
+
.map((m) => ({ match: m, value: m[4].replace(S_G_REGEX(), ' ').trim() }));
|
|
202
|
+
const groups = new Map();
|
|
203
|
+
for (const entry of matches)
|
|
204
|
+
if (!entry.value.includes('{') && entry.value.split(' ').length >= 2)
|
|
205
|
+
groups.set(entry.value, [...(groups.get(entry.value) ?? []), entry]);
|
|
206
|
+
const candidates = [...groups.entries()].filter(([, e]) => e.length >= minimum);
|
|
207
|
+
if (!candidates.length)
|
|
208
|
+
continue;
|
|
209
|
+
const used = new Set();
|
|
210
|
+
const parsed = analysis?.sources.get(file) ?? ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
211
|
+
parsed.forEachChild((n) => {
|
|
212
|
+
if (!ts.isVariableStatement(n))
|
|
213
|
+
return;
|
|
214
|
+
for (const declaration of n.declarationList.declarations)
|
|
215
|
+
if (ts.isIdentifier(declaration.name))
|
|
216
|
+
used.add(declaration.name.text);
|
|
217
|
+
});
|
|
218
|
+
const declarations = [];
|
|
219
|
+
const edits = [];
|
|
220
|
+
for (const [val, entries] of candidates) {
|
|
221
|
+
const name = classConstName(val, used);
|
|
222
|
+
declarations.push(`const ${name} = '${val.replace(G_CLASS_2(), '\\\\').replace(/'/g, "\\'")}';`);
|
|
223
|
+
for (const { match } of entries)
|
|
224
|
+
edits.push({ start: match.index, end: match.index + match[0].length, replacement: `${match[1]}${match[2]}{${name}}` });
|
|
225
|
+
fixed += entries.length;
|
|
226
|
+
}
|
|
227
|
+
let updated = src;
|
|
228
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
229
|
+
updated = `${updated.slice(0, edit.start)}${edit.replacement}${updated.slice(edit.end)}`;
|
|
230
|
+
const withGroup = insertCfgGroup(updated, 'CLASSES', declarations, file);
|
|
231
|
+
const candidateCount = candidates.reduce((n, [, e]) => n + e.length, 0);
|
|
232
|
+
if (withGroup === updated) {
|
|
233
|
+
fixed -= candidateCount;
|
|
234
|
+
skipped += candidateCount;
|
|
235
|
+
}
|
|
236
|
+
else if (!writePatternSrc(file, withGroup)) {
|
|
237
|
+
fixed -= candidateCount;
|
|
238
|
+
skipped += candidateCount;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { fixed, skipped };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Reports configured UI patterns that should use a shared primitive.
|
|
245
|
+
*
|
|
246
|
+
* @param cfg Quality configuration defining forbidden UI patterns and source scope.
|
|
247
|
+
* @param analysis Optional shared analysis context for reusing discovered files and text.
|
|
248
|
+
*
|
|
249
|
+
* @return Matching file/pattern pairs that should use shared UI primitives.
|
|
250
|
+
*/
|
|
251
|
+
export function uiPatternFindings(cfg, analysis) {
|
|
252
|
+
const settings = cfg.uiPatterns ?? {};
|
|
253
|
+
const patterns = (settings.forbidden ?? []).map((p) => [p, new RegExp(p, 'm')]);
|
|
254
|
+
const exts = settings.extensions ?? ['.astro', '.html', '.jsx', '.tsx'];
|
|
255
|
+
const ignored = new Set(settings.ignoredFiles ?? []);
|
|
256
|
+
return (analysis?.files ?? srcFiles({ ...cfg, extensions: exts }))
|
|
257
|
+
.filter((f) => exts.includes(f.slice(f.lastIndexOf('.'))))
|
|
258
|
+
.flatMap((f) => {
|
|
259
|
+
const name = displayPath(cfg, f);
|
|
260
|
+
if (ignored.has(name))
|
|
261
|
+
return [];
|
|
262
|
+
const src = analysis?.texts.get(f) ?? readFileSync(f, 'utf8');
|
|
263
|
+
return patterns.filter(([, p]) => p.test(src)).map(([p]) => ({ file: name, pattern: p }));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/*//////////////////////////////////////////////////
|
|
267
|
+
TAILWIND
|
|
268
|
+
//////////////////////////////////////////////////*/
|
|
269
|
+
/**
|
|
270
|
+
* Resolves a stylesheet identifier using known package locations and Node's module resolver.
|
|
271
|
+
*
|
|
272
|
+
* @param root Repository root from which package resolution should begin.
|
|
273
|
+
* @param id Stylesheet package identifier or path.
|
|
274
|
+
* @param base Directory used for relative fallback resolution.
|
|
275
|
+
*
|
|
276
|
+
* @return Absolute path to the stylesheet candidate.
|
|
277
|
+
*/
|
|
278
|
+
function resolveStylesheet(root, id, base) {
|
|
279
|
+
const require = createRequire(resolve(root, 'package.json'));
|
|
280
|
+
const known = { tailwindcss: 'tailwindcss/index.css', 'tw-animate-css': 'tw-animate-css/dist/tw-animate.css', 'shadcn/tailwind.css': 'shadcn/dist/tailwind.css' };
|
|
281
|
+
const candidates = [known[id], id].filter((c) => Boolean(c));
|
|
282
|
+
for (const candidate of candidates) {
|
|
283
|
+
for (const packageRoot of [resolve(root, 'node_modules'), resolve(root, 'astro/node_modules')]) {
|
|
284
|
+
const directPath = resolve(packageRoot, candidate);
|
|
285
|
+
if (existsSync(directPath))
|
|
286
|
+
return directPath;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
return require.resolve(candidate, { paths: [base, root, resolve(root, 'astro')] });
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Try the next package-resolution strategy.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return resolve(base, id);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Loads a stylesheet and records the directory used to resolve its nested imports.
|
|
299
|
+
*
|
|
300
|
+
* @param root Repository root used for package resolution.
|
|
301
|
+
* @param id Stylesheet package identifier or path.
|
|
302
|
+
* @param base Base directory for fallback resolution.
|
|
303
|
+
*
|
|
304
|
+
* @return Loaded stylesheet path, resolution base, and source content.
|
|
305
|
+
*/
|
|
306
|
+
async function loadStylesheet(root, id, base) {
|
|
307
|
+
const path = resolveStylesheet(root, id, base);
|
|
308
|
+
return { path, base: dirname(path), content: readFileSync(path, 'utf8') };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Resolves the consumer repository's Tailwind package entry from its package manifest.
|
|
312
|
+
*
|
|
313
|
+
* @param consumerRequire CommonJS resolver rooted at the consumer package.
|
|
314
|
+
* @param root Repository root whose dependency tree should be searched.
|
|
315
|
+
*
|
|
316
|
+
* @return Absolute path to the Tailwind import entry.
|
|
317
|
+
*/
|
|
318
|
+
function resolveConsumerImport(consumerRequire, root) {
|
|
319
|
+
const paths = [root, resolve(root, 'astro')];
|
|
320
|
+
const manifestPath = consumerRequire.resolve('tailwindcss/package.json', { paths });
|
|
321
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
322
|
+
const entry = manifest.exports?.['.']?.import ?? manifest.module ?? manifest.main;
|
|
323
|
+
if (!entry)
|
|
324
|
+
throw new Error('Tailwind package does not expose an import entry.');
|
|
325
|
+
return resolve(dirname(manifestPath), entry);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Extracts static class-string segments from class attributes and common class-composition calls.
|
|
329
|
+
*
|
|
330
|
+
* @param src Source text to scan.
|
|
331
|
+
*
|
|
332
|
+
* @return Class segments and their offsets in the source text.
|
|
333
|
+
*/
|
|
334
|
+
function classVals(src) {
|
|
335
|
+
const vals = [];
|
|
336
|
+
const attrPattern = /\b(?:class|className)\s*=\s*(?:(['"])([\s\S]*?)\1|\{\s*`([\s\S]*?)`\s*\})/g;
|
|
337
|
+
for (const match of src.matchAll(attrPattern)) {
|
|
338
|
+
const value = match[2] ?? match[3] ?? '';
|
|
339
|
+
const valStart = match.index + match[0].indexOf(value);
|
|
340
|
+
let segmentStart = 0;
|
|
341
|
+
for (const interpolation of value.matchAll(/\$\{[\s\S]*?\}/g)) {
|
|
342
|
+
if (interpolation.index > segmentStart)
|
|
343
|
+
vals.push({ start: valStart + segmentStart, value: value.slice(segmentStart, interpolation.index) });
|
|
344
|
+
segmentStart = interpolation.index + interpolation[0].length;
|
|
345
|
+
}
|
|
346
|
+
if (segmentStart < value.length)
|
|
347
|
+
vals.push({ start: valStart + segmentStart, value: value.slice(segmentStart) });
|
|
348
|
+
}
|
|
349
|
+
const callPattern = /\b(?:cn|clsx|classnames|cva)\s*\(/g;
|
|
350
|
+
for (const call of src.matchAll(callPattern)) {
|
|
351
|
+
let depth = 1;
|
|
352
|
+
let cursor = call.index + call[0].length;
|
|
353
|
+
while (cursor < src.length && depth > 0) {
|
|
354
|
+
if (src[cursor] === '(')
|
|
355
|
+
depth += 1;
|
|
356
|
+
if (src[cursor] === ')')
|
|
357
|
+
depth -= 1;
|
|
358
|
+
cursor += 1;
|
|
359
|
+
}
|
|
360
|
+
const bodyStart = call.index + call[0].length;
|
|
361
|
+
const body = src.slice(bodyStart, cursor - 1);
|
|
362
|
+
for (const value of body.matchAll(/(['"`])([\s\S]*?)\1/g))
|
|
363
|
+
if (!value[2].includes('${'))
|
|
364
|
+
vals.push({ start: bodyStart + value.index + 1, value: value[2] });
|
|
365
|
+
}
|
|
366
|
+
return vals;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Converts a source offset into the one-based line and column used by diagnostics.
|
|
370
|
+
*
|
|
371
|
+
* @param src Source text containing the offset.
|
|
372
|
+
* @param offset Zero-based character offset in the source.
|
|
373
|
+
*
|
|
374
|
+
* @return One-based source location.
|
|
375
|
+
*/
|
|
376
|
+
function locationAt(src, offset) {
|
|
377
|
+
const before = src.slice(0, offset);
|
|
378
|
+
return { line: before.split('\n').length, column: offset - before.lastIndexOf('\n') };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Runs the Tailwind v4 canonical-class diagnostic used by the quality CLI.
|
|
382
|
+
*
|
|
383
|
+
* @param cfg Quality configuration containing the Tailwind stylesheet and source roots.
|
|
384
|
+
* @param fix Whether canonical replacements should be written to source files.
|
|
385
|
+
*
|
|
386
|
+
* @return Promise resolving to zero after diagnostics or fixes complete.
|
|
387
|
+
*/
|
|
388
|
+
export async function runTailwindCanonical(cfg, fix = false) {
|
|
389
|
+
if (!cfg.tailwindCanonical)
|
|
390
|
+
return 0;
|
|
391
|
+
const stylesheetPath = resolve(cfg.root, cfg.tailwindCanonical.stylesheet);
|
|
392
|
+
if (!existsSync(stylesheetPath))
|
|
393
|
+
throw new Error(`Tailwind stylesheet not found: ${stylesheetPath}`);
|
|
394
|
+
const consumerRequire = createRequire(resolve(cfg.root, 'package.json'));
|
|
395
|
+
let tailwind;
|
|
396
|
+
try {
|
|
397
|
+
const packagePath = resolveConsumerImport(consumerRequire, cfg.root);
|
|
398
|
+
tailwind = (await import(pathToFileURL(packagePath).href));
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
throw new Error('Tailwind canonical check could not load the consumer repository Tailwind package.');
|
|
402
|
+
}
|
|
403
|
+
if (!tailwind.__unstable__loadDesignSystem) {
|
|
404
|
+
console.info('Tailwind canonical check skipped: installed Tailwind does not expose v4 canonical-class diagnostics.');
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
const stylesheet = readFileSync(stylesheetPath, 'utf8');
|
|
408
|
+
const designSystem = await tailwind.__unstable__loadDesignSystem(stylesheet, { from: stylesheetPath, loadStylesheet: (id, base) => loadStylesheet(cfg.root, id, base) });
|
|
409
|
+
const scanCfg = cfg.tailwindCanonical.sourceRoots ? { ...cfg, sourceRoots: cfg.tailwindCanonical.sourceRoots.map((r) => resolve(cfg.root, r)) } : cfg;
|
|
410
|
+
const files = srcFiles(scanCfg).filter((f) => /\.(?:astro|cjs|js|jsx|mjs|ts|tsx)$/.test(f));
|
|
411
|
+
const findings = [];
|
|
412
|
+
for (const file of files) {
|
|
413
|
+
const source = readFileSync(file, 'utf8');
|
|
414
|
+
for (const val of classVals(source)) {
|
|
415
|
+
for (const token of val.value.matchAll(/\S+/g)) {
|
|
416
|
+
const original = token[0];
|
|
417
|
+
const replacement = designSystem.canonicalizeCandidates([original])[0];
|
|
418
|
+
if (!replacement || replacement === original || replacement.includes(' '))
|
|
419
|
+
continue;
|
|
420
|
+
const start = val.start + token.index;
|
|
421
|
+
findings.push({ file, start, end: start + original.length, original, replacement, ...locationAt(source, start) });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const uniqueFindings = findings.filter((f, i, a) => a.findIndex((c) => c.file === f.file && c.start === f.start && c.original === f.original) === i);
|
|
426
|
+
if (fix) {
|
|
427
|
+
for (const file of new Set(uniqueFindings.map((f) => f.file))) {
|
|
428
|
+
let source = readFileSync(file, 'utf8');
|
|
429
|
+
for (const finding of uniqueFindings.filter((c) => c.file === file).sort((l, r) => r.start - l.start))
|
|
430
|
+
source = `${source.slice(0, finding.start)}${finding.replacement}${source.slice(finding.end)}`;
|
|
431
|
+
writeFileSync(file, source);
|
|
432
|
+
}
|
|
433
|
+
console.info(`Tailwind canonical classes fixed: ${uniqueFindings.length}`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
for (const finding of uniqueFindings)
|
|
437
|
+
console.error(`${relative(cfg.root, finding.file)}:${finding.line}:${finding.column} ${finding.original} -> ${finding.replacement}`);
|
|
438
|
+
if (uniqueFindings.length)
|
|
439
|
+
throw new Error(`Tailwind canonical class check failed with ${uniqueFindings.length} suggestion${uniqueFindings.length === 1 ? '' : 's'}.`);
|
|
440
|
+
console.info(`Tailwind canonical class check passed: ${files.length} source files scanned.`);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
const REGEX_REGISTRY_MARKER = '// Generated by @terra-symposium/standards-quality regex-dedup. Do not edit.';
|
|
444
|
+
const REGEX_DECLARATION_MARKER = '// Generated by @terra-symposium/standards-quality regex-dedup. Do not edit.';
|
|
445
|
+
/**
|
|
446
|
+
* Selects the TypeScript parser mode for the given source extension.
|
|
447
|
+
* @param file Source filename used to identify JavaScript, JSX, or TypeScript syntax.
|
|
448
|
+
* @returns The matching TypeScript compiler script kind.
|
|
449
|
+
*/
|
|
450
|
+
function regexScriptKind(file) {
|
|
451
|
+
if (/\.tsx$/.test(file))
|
|
452
|
+
return ts.ScriptKind.TSX;
|
|
453
|
+
if (/\.jsx$/.test(file))
|
|
454
|
+
return ts.ScriptKind.JSX;
|
|
455
|
+
return /\.(?:mjs|cjs|js)$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Collects the supported literal and static-constructor candidates used by both reporting and fixing.
|
|
459
|
+
*
|
|
460
|
+
* @param cfg Quality configuration defining the regex source scope.
|
|
461
|
+
* @param analysis Optional shared analysis context for reusing source text and ASTs.
|
|
462
|
+
*
|
|
463
|
+
* @return Regex candidates with exact source keys and source locations.
|
|
464
|
+
*/
|
|
465
|
+
function regexCandidates(cfg, analysis) {
|
|
466
|
+
const settings = cfg.regexDedup ?? {};
|
|
467
|
+
const exts = new Set(settings.extensions ?? ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.mts', '.cts']);
|
|
468
|
+
const ignored = new Set(settings.ignoredFiles ?? []);
|
|
469
|
+
const registryPath = settings.sharedModule ? resolve(cfg.root, settings.sharedModule) : '';
|
|
470
|
+
const candidates = [];
|
|
471
|
+
for (const file of analysis?.files ?? srcFiles({ ...cfg, extensions: [...exts] })) {
|
|
472
|
+
if (!exts.has(file.slice(file.lastIndexOf('.'))) || file.endsWith('.d.mts') || TEST_TESTS_NODE_MODULES_DIST_BUILD_COVERAGE_REGEX.test(file) || (registryPath && resolve(file) === registryPath))
|
|
473
|
+
continue;
|
|
474
|
+
if (ignored.has(displayPath(cfg, file)))
|
|
475
|
+
continue;
|
|
476
|
+
const src = analysis?.texts.get(file) ?? readFileSync(file, 'utf8');
|
|
477
|
+
const ast = analysis?.sources.get(file) ?? ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, regexScriptKind(file));
|
|
478
|
+
const shadowedRegExp = hasRegExpBinding(ast);
|
|
479
|
+
const visit = (node) => {
|
|
480
|
+
if (ts.isRegularExpressionLiteral(node) && !isGenLocalRegex(node, ast)) {
|
|
481
|
+
const literal = node.getText(ast);
|
|
482
|
+
const end = literal.lastIndexOf('/');
|
|
483
|
+
const pattern = literal.slice(1, end);
|
|
484
|
+
const flags = literal.slice(end + 1);
|
|
485
|
+
candidates.push({ file, source: flags ? `${pattern}/${flags}` : pattern, expression: literal, node, ast, rewritable: true });
|
|
486
|
+
}
|
|
487
|
+
else if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'RegExp' && !node.typeArguments?.length) {
|
|
488
|
+
const [pattern, flags, ...rest] = node.arguments ?? [];
|
|
489
|
+
if (!rest.length && (ts.isStringLiteral(pattern) || ts.isNoSubstitutionTemplateLiteral(pattern)) && (!flags || ts.isStringLiteral(flags))) {
|
|
490
|
+
candidates.push({ file, source: flags ? `${pattern.getText(ast)}, ${flags.getText(ast)}` : pattern.getText(ast), expression: node.getText(ast), node, ast, rewritable: !shadowedRegExp });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
ts.forEachChild(node, visit);
|
|
494
|
+
};
|
|
495
|
+
visit(ast);
|
|
496
|
+
}
|
|
497
|
+
return candidates;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Checks whether a regex literal belongs to a generated local `_REGEX` declaration.
|
|
501
|
+
* @param node Regular-expression literal to classify.
|
|
502
|
+
* @param ast Parsed source file containing the literal.
|
|
503
|
+
* @returns Whether the declaration is in the generated `REGEX` section.
|
|
504
|
+
*/
|
|
505
|
+
function isGenLocalRegex(node, ast) {
|
|
506
|
+
let declaration;
|
|
507
|
+
for (let curr = node; curr && !ts.isSourceFile(curr); curr = curr.parent) {
|
|
508
|
+
if (ts.isVariableDeclaration(curr)) {
|
|
509
|
+
declaration = curr;
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (!declaration || !ts.isIdentifier(declaration.name) || !declaration.name.text.endsWith('_REGEX'))
|
|
514
|
+
return false;
|
|
515
|
+
const before = ast.text.slice(0, declaration.getStart(ast));
|
|
516
|
+
const sections = [...before.matchAll(/\/\*\/{10,}\r?\n[ \t]*([A-Z][A-Z _-]*)[ \t]*\r?\n[ \t]*\/{10,}\*\//g)];
|
|
517
|
+
return sections.at(-1)?.[1] === 'REGEX';
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Detects bindings that shadow the global `RegExp` constructor.
|
|
521
|
+
* @param ast Parsed source file to inspect.
|
|
522
|
+
* @returns Whether the file declares, imports, or catches a binding named `RegExp`.
|
|
523
|
+
*/
|
|
524
|
+
function hasRegExpBinding(ast) {
|
|
525
|
+
const bindingHasRegExp = (name) => {
|
|
526
|
+
if (ts.isIdentifier(name))
|
|
527
|
+
return name.text === 'RegExp';
|
|
528
|
+
return name.elements.some((e) => ts.isBindingElement(e) && bindingHasRegExp(e.name));
|
|
529
|
+
};
|
|
530
|
+
let found = false;
|
|
531
|
+
const visit = (node) => {
|
|
532
|
+
if (found)
|
|
533
|
+
return;
|
|
534
|
+
if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
|
|
535
|
+
node.name &&
|
|
536
|
+
bindingHasRegExp(node.name)) {
|
|
537
|
+
found = true;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (ts.isImportClause(node) && node.name?.text === 'RegExp')
|
|
541
|
+
found = true;
|
|
542
|
+
if (ts.isImportSpecifier(node) && node.name.text === 'RegExp')
|
|
543
|
+
found = true;
|
|
544
|
+
if (ts.isCatchClause(node) && node.variableDeclaration && bindingHasRegExp(node.variableDeclaration.name))
|
|
545
|
+
found = true;
|
|
546
|
+
ts.forEachChild(node, visit);
|
|
547
|
+
};
|
|
548
|
+
visit(ast);
|
|
549
|
+
return found;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Groups parsed regex candidates by their exact source representation.
|
|
553
|
+
* @param cfg Quality configuration defining files, extensions, and exclusions.
|
|
554
|
+
* @param analysis Optional shared analysis context for reusing parsed sources.
|
|
555
|
+
* @returns Candidate groups keyed by regex source and flags.
|
|
556
|
+
*/
|
|
557
|
+
function regexGroups(cfg, analysis) {
|
|
558
|
+
const groups = new Map();
|
|
559
|
+
for (const candidate of regexCandidates(cfg, analysis))
|
|
560
|
+
groups.set(candidate.source, [...(groups.get(candidate.source) ?? []), candidate]);
|
|
561
|
+
return groups;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Reports exact duplicate regular-expression sources across source files.
|
|
565
|
+
*
|
|
566
|
+
* @param cfg Quality configuration defining regex extensions, exclusions, and threshold.
|
|
567
|
+
* @param analysis Optional shared analysis context for reusing source text and ASTs.
|
|
568
|
+
*
|
|
569
|
+
* @return Duplicate regular-expression sources and their source locations.
|
|
570
|
+
*/
|
|
571
|
+
export function regexFindings(cfg, analysis) {
|
|
572
|
+
const minimum = cfg.regexDedup?.minimumCount ?? 2;
|
|
573
|
+
return [...regexGroups(cfg, analysis)]
|
|
574
|
+
.filter(([, c]) => c.length >= minimum)
|
|
575
|
+
.map(([s, c]) => ({
|
|
576
|
+
source: s,
|
|
577
|
+
count: c.length,
|
|
578
|
+
locations: c
|
|
579
|
+
.map(({ file: f, ast: a, node: n }) => `${displayPath(cfg, f)}:${a.getLineAndCharacterOfPosition(n.getStart(a)).line + 1}`)
|
|
580
|
+
.sort((l, r) => l.localeCompare(r, undefined, { numeric: true })),
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Determines whether a consumer file uses ECMAScript module syntax.
|
|
585
|
+
* @param cfg Quality configuration defining the repository root.
|
|
586
|
+
* @param file Consumer source filename whose nearest package metadata is inspected.
|
|
587
|
+
* @returns Whether the file is an ESM consumer.
|
|
588
|
+
*/
|
|
589
|
+
function isEsmConsumer(cfg, file) {
|
|
590
|
+
if (/\.(?:mjs|mts)$/.test(file))
|
|
591
|
+
return true;
|
|
592
|
+
if (/\.(?:cjs|cts)$/.test(file))
|
|
593
|
+
return false;
|
|
594
|
+
for (let dir = dirname(file); dir.startsWith(cfg.root); dir = dirname(dir)) {
|
|
595
|
+
const packageFile = resolve(dir, 'package.json');
|
|
596
|
+
if (existsSync(packageFile)) {
|
|
597
|
+
try {
|
|
598
|
+
return JSON.parse(readFileSync(packageFile, 'utf8')).type === 'module';
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (dir === cfg.root)
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Creates a unique exported constant name from a regex source string.
|
|
611
|
+
* @param src Regex source used to derive a readable identifier.
|
|
612
|
+
* @param used Identifiers already present in the shared module.
|
|
613
|
+
* @returns The reserved, collision-free export name.
|
|
614
|
+
*/
|
|
615
|
+
function regexExportName(src, used) {
|
|
616
|
+
const slug = src.replace(A_ZA_Z0_9_G_REGEX(), '_').replace(G_REGEX(), '').slice(0, 60).toUpperCase() || 'PATTERN';
|
|
617
|
+
const base = `REGEX_${slug}_REGEX`;
|
|
618
|
+
let name = base;
|
|
619
|
+
let suffix = 2;
|
|
620
|
+
while (used.has(name))
|
|
621
|
+
name = `${base}_${suffix++}`;
|
|
622
|
+
used.add(name);
|
|
623
|
+
return name;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Reads the generated shared-regex registry when both files are owned by this tool.
|
|
627
|
+
* @param modulePath Shared ECMAScript module path.
|
|
628
|
+
* @param declarationPath TypeScript declaration path paired with the module.
|
|
629
|
+
* @returns Parsed registry state, or `null` when an existing file is user-owned.
|
|
630
|
+
*/
|
|
631
|
+
function readRegexRegistry(modulePath, declarationPath) {
|
|
632
|
+
const moduleExists = existsSync(modulePath);
|
|
633
|
+
const declarationExists = existsSync(declarationPath);
|
|
634
|
+
const module = moduleExists ? readFileSync(modulePath, 'utf8') : `${REGEX_REGISTRY_MARKER}\n`;
|
|
635
|
+
const declaration = declarationExists ? readFileSync(declarationPath, 'utf8') : `${REGEX_DECLARATION_MARKER}\n`;
|
|
636
|
+
const owned = module.startsWith(REGEX_REGISTRY_MARKER) && declaration.startsWith(REGEX_DECLARATION_MARKER);
|
|
637
|
+
if ((moduleExists || declarationExists) && !owned)
|
|
638
|
+
return null;
|
|
639
|
+
const names = new Set([...module.matchAll(EXPORT_CONST_A_ZA_Z_W_B_GM_REGEX())].map((m) => m[1]));
|
|
640
|
+
const keys = new Map();
|
|
641
|
+
for (const match of module.matchAll(/^\/\/ @terra-quality-regex-key (.+)\r?\nexport const ([A-Za-z_$][\w$]*)\b/gm)) {
|
|
642
|
+
try {
|
|
643
|
+
keys.set(JSON.parse(match[1]), match[2]);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
// An invalid generated annotation is not used as a key; the export remains collision-protected.
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return { module, declaration, names, keys, owned };
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Builds the declaration file matching the registry's exported regex factories.
|
|
653
|
+
* @param module Shared module source whose exports determine the declarations.
|
|
654
|
+
* @returns TypeScript declaration source for the exported constants.
|
|
655
|
+
*/
|
|
656
|
+
function declarationFor(module) {
|
|
657
|
+
const names = [...new Set([...module.matchAll(EXPORT_CONST_A_ZA_Z_W_B_GM_REGEX())].map((m) => m[1]))].sort();
|
|
658
|
+
return `${REGEX_DECLARATION_MARKER}\n${names.map((n) => `export declare const ${n}: () => RegExp;`).join('\n')}${names.length ? '\n' : ''}`;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Creates the module specifier from a consumer file to the shared registry module.
|
|
662
|
+
* @param file Consumer source filename.
|
|
663
|
+
* @param modulePath Shared module filename.
|
|
664
|
+
* @returns A normalized relative module specifier.
|
|
665
|
+
*/
|
|
666
|
+
function moduleSpecifier(file, modulePath) {
|
|
667
|
+
let specifier = relative(dirname(file), modulePath).replace(G_CLASS_2(), '/');
|
|
668
|
+
if (!specifier.startsWith('.'))
|
|
669
|
+
specifier = `./${specifier}`;
|
|
670
|
+
return specifier;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Finds an existing namespace import for the shared registry module.
|
|
674
|
+
* @param ast Parsed consumer source file.
|
|
675
|
+
* @param file Consumer source filename used to resolve import specifiers.
|
|
676
|
+
* @param modulePath Resolved shared module filename.
|
|
677
|
+
* @returns The imported namespace identifier, if the module is already imported.
|
|
678
|
+
*/
|
|
679
|
+
function namespaceImport(ast, file, modulePath) {
|
|
680
|
+
for (const statement of ast.statements) {
|
|
681
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
682
|
+
continue;
|
|
683
|
+
if (resolve(dirname(file), statement.moduleSpecifier.text) !== modulePath)
|
|
684
|
+
continue;
|
|
685
|
+
if (!statement.importClause || statement.importClause.isTypeOnly || !statement.importClause.namedBindings || !ts.isNamespaceImport(statement.importClause.namedBindings))
|
|
686
|
+
continue;
|
|
687
|
+
return statement.importClause.namedBindings.name.text;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Adds a namespace import after directives and existing imports.
|
|
692
|
+
* @param src Consumer source text to update.
|
|
693
|
+
* @param ast Parsed consumer source used to locate the import boundary.
|
|
694
|
+
* @param alias Local name for the shared module namespace.
|
|
695
|
+
* @param specifier Module specifier to import.
|
|
696
|
+
* @returns Source text with the import inserted in the existing import group.
|
|
697
|
+
*/
|
|
698
|
+
function insertNamespaceImport(src, ast, alias, specifier) {
|
|
699
|
+
const line = `import * as ${alias} from '${specifier}';`;
|
|
700
|
+
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
701
|
+
let at = 0;
|
|
702
|
+
for (const statement of ast.statements) {
|
|
703
|
+
const directive = ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression);
|
|
704
|
+
if (ts.isImportDeclaration(statement) || directive)
|
|
705
|
+
at = statement.end;
|
|
706
|
+
else
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
if (!at && ast.statements.length) {
|
|
710
|
+
const first = ast.statements[0];
|
|
711
|
+
at = first.getStart(ast);
|
|
712
|
+
const leading = src.slice(first.getFullStart(), first.getStart(ast));
|
|
713
|
+
const directive = [...leading.matchAll(/^[ \t]*\/\/[^\r\n]*(?:eslint-disable-next-line|@ts-ignore|@ts-expect-error)[^\r\n]*/gm)].at(-1);
|
|
714
|
+
if (directive?.index !== undefined)
|
|
715
|
+
at = first.getFullStart() + directive.index;
|
|
716
|
+
}
|
|
717
|
+
if (!at && src.startsWith('#!')) {
|
|
718
|
+
const shebangEnd = src.indexOf('\n');
|
|
719
|
+
at = shebangEnd < 0 ? src.length : shebangEnd + 1;
|
|
720
|
+
}
|
|
721
|
+
const before = src.slice(0, at);
|
|
722
|
+
const after = src.slice(at);
|
|
723
|
+
return `${before}${at && !before.endsWith('\n') ? eol : ''}${line}${after.startsWith('\n') || after.startsWith('\r\n') ? '' : eol}${after}`;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Collects every identifier spelling already used in a source file.
|
|
727
|
+
* @param ast Parsed source file to inspect.
|
|
728
|
+
* @returns Identifier names used for collision checks.
|
|
729
|
+
*/
|
|
730
|
+
function identifierNames(ast) {
|
|
731
|
+
const names = new Set();
|
|
732
|
+
const visit = (node) => {
|
|
733
|
+
if (ts.isIdentifier(node))
|
|
734
|
+
names.add(node.text);
|
|
735
|
+
ts.forEachChild(node, visit);
|
|
736
|
+
};
|
|
737
|
+
visit(ast);
|
|
738
|
+
return names;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Checks that generated JavaScript or TypeScript source parses successfully.
|
|
742
|
+
* @param file Output filename used to select the parser mode.
|
|
743
|
+
* @param src Candidate source text to validate.
|
|
744
|
+
* @returns Whether the candidate has no parse diagnostics.
|
|
745
|
+
*/
|
|
746
|
+
function validRegexOutput(file, src) {
|
|
747
|
+
if (file.endsWith('.d.mts')) {
|
|
748
|
+
const parsed = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
749
|
+
return parsed.parseDiagnostics.length === 0;
|
|
750
|
+
}
|
|
751
|
+
if (ts.transpileModule(src, { fileName: file, reportDiagnostics: true, compilerOptions: { allowJs: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } })
|
|
752
|
+
.diagnostics?.length)
|
|
753
|
+
return false;
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Replaces duplicate regex candidates with file-local constants or a configured shared ESM registry.
|
|
758
|
+
* Shared factories preserve a fresh RegExp instance at every original evaluation site.
|
|
759
|
+
*
|
|
760
|
+
* @param cfg Quality configuration defining the regex threshold, source scope, and optional registry.
|
|
761
|
+
* @param analysis Optional shared analysis context used for discovery.
|
|
762
|
+
*
|
|
763
|
+
* @return Counts of regex expressions replaced and candidates left advisory.
|
|
764
|
+
*/
|
|
765
|
+
export function fixRegexDedup(cfg, analysis) {
|
|
766
|
+
if (cfg.regexDedup?.sharedModule)
|
|
767
|
+
return fixRegexDedupShared(cfg, analysis);
|
|
768
|
+
const settings = cfg.regexDedup ?? {};
|
|
769
|
+
const minimum = settings.minimumCount ?? 2;
|
|
770
|
+
const groups = regexGroups(cfg, analysis);
|
|
771
|
+
let fixed = 0;
|
|
772
|
+
let skipped = 0;
|
|
773
|
+
const files = new Map();
|
|
774
|
+
for (const candidate of [...groups.values()].flat()) {
|
|
775
|
+
if (!ts.isRegularExpressionLiteral(candidate.node))
|
|
776
|
+
continue;
|
|
777
|
+
files.set(candidate.file, [...(files.get(candidate.file) ?? []), candidate]);
|
|
778
|
+
}
|
|
779
|
+
for (const [file, candidates] of files) {
|
|
780
|
+
const src = analysis?.texts.get(file) ?? readFileSync(file, 'utf8');
|
|
781
|
+
const localGroups = new Map();
|
|
782
|
+
for (const candidate of candidates)
|
|
783
|
+
localGroups.set(candidate.source, [...(localGroups.get(candidate.source) ?? []), candidate]);
|
|
784
|
+
const matches = [...localGroups].filter(([, n]) => n.length >= minimum);
|
|
785
|
+
const used = identifierNames(candidates[0].ast);
|
|
786
|
+
const declarations = [];
|
|
787
|
+
const edits = [];
|
|
788
|
+
for (const [, nodes] of matches) {
|
|
789
|
+
const name = classConstName(nodes[0].expression.replace(/\W+/g, '_'), used).replace(/_CLASS$/, '_REGEX');
|
|
790
|
+
const flags = nodes[0].expression.slice(nodes[0].expression.lastIndexOf('/') + 1);
|
|
791
|
+
const stateful = flags.includes('g') || flags.includes('y');
|
|
792
|
+
declarations.push(`const ${name} = ${stateful ? `() => ${nodes[0].expression}` : nodes[0].expression};`);
|
|
793
|
+
for (const candidate of nodes)
|
|
794
|
+
edits.push({ start: candidate.node.getStart(candidate.ast), end: candidate.node.end, replacement: stateful ? `${name}()` : name });
|
|
795
|
+
fixed += nodes.length;
|
|
796
|
+
}
|
|
797
|
+
const matchingCount = matches.reduce((c, [, n]) => c + n.length, 0);
|
|
798
|
+
if (!edits.length)
|
|
799
|
+
continue;
|
|
800
|
+
let updated = src;
|
|
801
|
+
for (const edit of edits.sort((l, r) => r.start - l.start))
|
|
802
|
+
updated = `${updated.slice(0, edit.start)}${edit.replacement}${updated.slice(edit.end)}`;
|
|
803
|
+
const withGroup = insertCfgGroup(updated, 'REGEX', declarations, file);
|
|
804
|
+
if (withGroup === updated || !writePatternSrc(file, withGroup)) {
|
|
805
|
+
fixed -= matchingCount;
|
|
806
|
+
skipped += matchingCount;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { fixed, skipped };
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Rewrites eligible duplicate regexes to factories in the configured shared module.
|
|
813
|
+
* @param cfg Quality configuration containing the shared module and source scope.
|
|
814
|
+
* @param analysis Optional shared analysis context for reusing parsed sources.
|
|
815
|
+
* @returns Counts of shared fixes applied and candidates left for review.
|
|
816
|
+
*/
|
|
817
|
+
function fixRegexDedupShared(cfg, analysis) {
|
|
818
|
+
const sharedModule = cfg.regexDedup.sharedModule;
|
|
819
|
+
if (!sharedModule.endsWith('.mjs'))
|
|
820
|
+
throw new Error('regexDedup.sharedModule must name an ESM .mjs file.');
|
|
821
|
+
const modulePath = resolve(cfg.root, sharedModule);
|
|
822
|
+
const moduleRelative = relative(cfg.root, modulePath);
|
|
823
|
+
if (moduleRelative === '..' || moduleRelative.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`))
|
|
824
|
+
throw new Error('regexDedup.sharedModule must stay inside the repository root.');
|
|
825
|
+
const declarationPath = modulePath.replace(/\.mjs$/, '.d.mts');
|
|
826
|
+
const minimum = cfg.regexDedup?.minimumCount ?? 2;
|
|
827
|
+
const groups = [...regexGroups(cfg, analysis)].filter(([, c]) => c.length >= minimum);
|
|
828
|
+
if (!groups.length)
|
|
829
|
+
return { fixed: 0, skipped: 0 };
|
|
830
|
+
const registry = readRegexRegistry(modulePath, declarationPath);
|
|
831
|
+
if (!registry)
|
|
832
|
+
return { fixed: 0, skipped: groups.reduce((c, [, v]) => c + v.length, 0) };
|
|
833
|
+
const rewritable = groups.filter(([, c]) => c.every((c) => c.rewritable && isEsmConsumer(cfg, c.file)));
|
|
834
|
+
let skipped = groups.filter(([, c]) => !c.every((c) => c.rewritable && isEsmConsumer(cfg, c.file))).reduce((c, [, v]) => c + v.length, 0);
|
|
835
|
+
if (!rewritable.length)
|
|
836
|
+
return { fixed: 0, skipped };
|
|
837
|
+
const usedExports = new Set(registry.names);
|
|
838
|
+
const namesByKey = new Map(registry.keys);
|
|
839
|
+
const newEntries = [];
|
|
840
|
+
const groupNames = new Map();
|
|
841
|
+
for (const [key, candidates] of rewritable.sort(([l], [r]) => l.localeCompare(r))) {
|
|
842
|
+
let name = namesByKey.get(key);
|
|
843
|
+
if (!name) {
|
|
844
|
+
name = regexExportName(key, usedExports);
|
|
845
|
+
namesByKey.set(key, name);
|
|
846
|
+
newEntries.push({ key, name, expression: candidates[0].expression });
|
|
847
|
+
}
|
|
848
|
+
groupNames.set(key, name);
|
|
849
|
+
}
|
|
850
|
+
const eol = '\n';
|
|
851
|
+
const moduleAdditions = newEntries
|
|
852
|
+
.map(({ key: k, name: n, expression: e }) => `// @terra-quality-regex-key ${JSON.stringify(k)}\nexport const ${n} = () => (\n${e
|
|
853
|
+
.split('\n')
|
|
854
|
+
.map((l) => `\t${l}`)
|
|
855
|
+
.join('\n')}\n);`)
|
|
856
|
+
.join(`${eol}${eol}`);
|
|
857
|
+
const moduleSrc = `${registry.module.replace(/\s*$/, '')}${moduleAdditions ? `${eol}${eol}${moduleAdditions}${eol}` : `${eol}`}`;
|
|
858
|
+
const declarationSrc = declarationFor(moduleSrc);
|
|
859
|
+
const srcEdits = new Map();
|
|
860
|
+
let fixed = 0;
|
|
861
|
+
for (const [key, candidates] of rewritable) {
|
|
862
|
+
const name = groupNames.get(key);
|
|
863
|
+
for (const candidate of candidates) {
|
|
864
|
+
let fileEdits = srcEdits.get(candidate.file);
|
|
865
|
+
if (!fileEdits) {
|
|
866
|
+
const source = analysis?.texts.get(candidate.file) ?? readFileSync(candidate.file, 'utf8');
|
|
867
|
+
const existingAlias = namespaceImport(candidate.ast, candidate.file, modulePath);
|
|
868
|
+
const names = identifierNames(candidate.ast);
|
|
869
|
+
let alias = existingAlias ?? 'TerraQualityRegex';
|
|
870
|
+
let suffix = 2;
|
|
871
|
+
while (!existingAlias && names.has(alias))
|
|
872
|
+
alias = `TerraQualityRegex${suffix++}`;
|
|
873
|
+
fileEdits = { source, ast: candidate.ast, edits: [], alias, specifier: moduleSpecifier(candidate.file, modulePath) };
|
|
874
|
+
srcEdits.set(candidate.file, fileEdits);
|
|
875
|
+
}
|
|
876
|
+
fileEdits.edits.push({ start: candidate.node.getStart(candidate.ast), end: candidate.node.end, replacement: `${fileEdits.alias}.${name}()` });
|
|
877
|
+
fixed++;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const updatedSrcs = new Map();
|
|
881
|
+
for (const [file, fileEdits] of srcEdits) {
|
|
882
|
+
let updated = fileEdits.source;
|
|
883
|
+
for (const edit of fileEdits.edits.sort((l, r) => r.start - l.start))
|
|
884
|
+
updated = `${updated.slice(0, edit.start)}${edit.replacement}${updated.slice(edit.end)}`;
|
|
885
|
+
if (!namespaceImport(fileEdits.ast, file, modulePath)) {
|
|
886
|
+
const updatedAst = ts.createSourceFile(file, updated, ts.ScriptTarget.Latest, true, regexScriptKind(file));
|
|
887
|
+
updated = insertNamespaceImport(updated, updatedAst, fileEdits.alias, fileEdits.specifier);
|
|
888
|
+
}
|
|
889
|
+
if (!validRegexOutput(file, updated)) {
|
|
890
|
+
skipped += fileEdits.edits.length;
|
|
891
|
+
fixed -= fileEdits.edits.length;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
updatedSrcs.set(file, updated);
|
|
895
|
+
}
|
|
896
|
+
if ([...updatedSrcs].length !== srcEdits.size || !validRegexOutput(modulePath, moduleSrc) || !validRegexOutput(declarationPath, declarationSrc))
|
|
897
|
+
return { fixed: 0, skipped: groups.filter(([, c]) => c.every((c) => c.rewritable && isEsmConsumer(cfg, c.file))).reduce((c, [, v]) => c + v.length, 0) + skipped };
|
|
898
|
+
mkdirSync(dirname(modulePath), { recursive: true });
|
|
899
|
+
writeFileSync(modulePath, moduleSrc);
|
|
900
|
+
writeFileSync(declarationPath, declarationSrc);
|
|
901
|
+
for (const [file, source] of updatedSrcs)
|
|
902
|
+
writeFileSync(file, source);
|
|
903
|
+
return { fixed, skipped };
|
|
904
|
+
}
|
|
905
|
+
//# sourceMappingURL=patterns.js.map
|