@selfcure/cli 0.1.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 +42 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +1562 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1562 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk6 from "chalk";
|
|
6
|
+
import ora6 from "ora";
|
|
7
|
+
import path8 from "path";
|
|
8
|
+
import fs6 from "fs/promises";
|
|
9
|
+
import { pathToFileURL as pathToFileURL5 } from "url";
|
|
10
|
+
import { execSync as execSync3 } from "child_process";
|
|
11
|
+
|
|
12
|
+
// src/init.ts
|
|
13
|
+
import { input, select, password, confirm } from "@inquirer/prompts";
|
|
14
|
+
import { writeFile, stat } from "fs/promises";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { PROVIDERS } from "@selfcure/generator";
|
|
17
|
+
import { discoverProject } from "@selfcure/crawler";
|
|
18
|
+
var PROVIDER_CHOICES = Object.values(PROVIDERS).map((p) => ({
|
|
19
|
+
name: p.envVar && process.env[p.envVar] ? `${p.label} \u2713 (${p.envVar} detected)` : `${p.label} \u2014 ${p.hint}`,
|
|
20
|
+
value: p.id
|
|
21
|
+
}));
|
|
22
|
+
async function detectSourceDir(cwd, projectRoot) {
|
|
23
|
+
const abs = path.resolve(cwd, projectRoot);
|
|
24
|
+
for (const candidate of ["src", "app", "pages", "components"]) {
|
|
25
|
+
try {
|
|
26
|
+
const s = await stat(path.join(abs, candidate));
|
|
27
|
+
if (s.isDirectory()) return `./${candidate}`;
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return projectRoot === "." ? "./src" : projectRoot;
|
|
32
|
+
}
|
|
33
|
+
async function detectExtensions(cwd) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await import("fs/promises").then((m) => m.readFile(path.join(cwd, "package.json"), "utf-8"));
|
|
36
|
+
const pkg = JSON.parse(raw);
|
|
37
|
+
const deps = { ...pkg["dependencies"] ?? {}, ...pkg["devDependencies"] ?? {} };
|
|
38
|
+
if ("@angular/core" in deps) return ["**/*.html", "**/*.ts"];
|
|
39
|
+
if ("vue" in deps) return ["**/*.vue"];
|
|
40
|
+
if ("nuxt" in deps) return ["**/*.vue"];
|
|
41
|
+
if ("svelte" in deps) return ["**/*.svelte"];
|
|
42
|
+
if ("react" in deps && "typescript" in deps) return ["**/*.tsx", "**/*.ts"];
|
|
43
|
+
if ("react" in deps) return ["**/*.jsx", "**/*.js"];
|
|
44
|
+
if ("next" in deps) return ["**/*.tsx", "**/*.ts"];
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
return ["**/*.tsx", "**/*.jsx"];
|
|
48
|
+
}
|
|
49
|
+
function buildConfigContent(projectRoot, sourceDir, extensions, baseUrl, provider, model) {
|
|
50
|
+
const pr = JSON.stringify(projectRoot);
|
|
51
|
+
const sd = JSON.stringify(sourceDir);
|
|
52
|
+
const bu = JSON.stringify(baseUrl);
|
|
53
|
+
const pv = JSON.stringify(provider);
|
|
54
|
+
const mo = JSON.stringify(model);
|
|
55
|
+
const inc = JSON.stringify(extensions);
|
|
56
|
+
return [
|
|
57
|
+
"// selfcure.config.mjs",
|
|
58
|
+
"// See: https://github.com/ricardofrancocustodio/selfcure",
|
|
59
|
+
"export default {",
|
|
60
|
+
` projectRoot: ${pr},`,
|
|
61
|
+
` baseUrl: ${bu},`,
|
|
62
|
+
"",
|
|
63
|
+
" // Required by selfcure lint, selfcure web, selfcure crawl",
|
|
64
|
+
` rootDir: ${sd},`,
|
|
65
|
+
` include: ${inc},`,
|
|
66
|
+
` exclude: ["**/*.test.*", "**/*.spec.*", "**/*.stories.*"],`,
|
|
67
|
+
` baseURL: ${bu},`,
|
|
68
|
+
"",
|
|
69
|
+
" ai: {",
|
|
70
|
+
` provider: ${pv},`,
|
|
71
|
+
` model: ${mo},`,
|
|
72
|
+
" },",
|
|
73
|
+
"",
|
|
74
|
+
" discovery: {",
|
|
75
|
+
' mode: "agentic",',
|
|
76
|
+
" static: true,",
|
|
77
|
+
" runtime: false,",
|
|
78
|
+
" maxRoutes: 50,",
|
|
79
|
+
" maxDepth: 3,",
|
|
80
|
+
" includeHiddenStates: true,",
|
|
81
|
+
" routeHints: [],",
|
|
82
|
+
' ignore: ["node_modules", "dist", "coverage", ".git"],',
|
|
83
|
+
" },",
|
|
84
|
+
"",
|
|
85
|
+
" testability: {",
|
|
86
|
+
" preferRoleLocators: true,",
|
|
87
|
+
" suggestTestIds: true,",
|
|
88
|
+
" minimumScore: 80,",
|
|
89
|
+
" },",
|
|
90
|
+
"};",
|
|
91
|
+
""
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
async function runInitWizard(cwd) {
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log("selfcure init \u2014 agentic discovery setup");
|
|
97
|
+
console.log("");
|
|
98
|
+
const projectRoot = await input({
|
|
99
|
+
message: "Project root (relative to cwd)?",
|
|
100
|
+
default: "."
|
|
101
|
+
});
|
|
102
|
+
const baseUrl = await input({
|
|
103
|
+
message: "App base URL (dev server)?",
|
|
104
|
+
default: "http://localhost:3000"
|
|
105
|
+
});
|
|
106
|
+
const suggestedProvider = Object.values(PROVIDERS).find((p) => p.envVar && process.env[p.envVar])?.id ?? "anthropic";
|
|
107
|
+
const provider = await select({
|
|
108
|
+
message: "AI provider?",
|
|
109
|
+
choices: PROVIDER_CHOICES,
|
|
110
|
+
default: suggestedProvider
|
|
111
|
+
});
|
|
112
|
+
const meta = PROVIDERS[provider];
|
|
113
|
+
const model = await input({
|
|
114
|
+
message: "Model?",
|
|
115
|
+
default: meta.defaultGenerationModel
|
|
116
|
+
});
|
|
117
|
+
if (meta.envVar && !process.env[meta.envVar]) {
|
|
118
|
+
const apiKey = await password({
|
|
119
|
+
message: `${meta.label} API key (${meta.envVar})?`,
|
|
120
|
+
mask: "*"
|
|
121
|
+
});
|
|
122
|
+
if (apiKey) {
|
|
123
|
+
const envPath = path.join(cwd, ".env");
|
|
124
|
+
const line = `
|
|
125
|
+
${meta.envVar}=${apiKey}
|
|
126
|
+
`;
|
|
127
|
+
const { appendFile } = await import("fs/promises");
|
|
128
|
+
await appendFile(envPath, line, "utf-8");
|
|
129
|
+
console.log(` \u2714 appended to ${path.relative(cwd, envPath)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const runNow = await confirm({
|
|
133
|
+
message: "Run static project discovery now?",
|
|
134
|
+
default: true
|
|
135
|
+
});
|
|
136
|
+
const sourceDir = await detectSourceDir(cwd, projectRoot);
|
|
137
|
+
const extensions = await detectExtensions(cwd);
|
|
138
|
+
const configPath = path.join(cwd, "selfcure.config.mjs");
|
|
139
|
+
const configContent = buildConfigContent(projectRoot, sourceDir, extensions, baseUrl, provider, model);
|
|
140
|
+
await writeFile(configPath, configContent, "utf-8");
|
|
141
|
+
console.log(`
|
|
142
|
+
\u2714 selfcure.config.mjs created`);
|
|
143
|
+
console.log(` rootDir: ${sourceDir} include: ${extensions.join(", ")}`);
|
|
144
|
+
if (runNow) {
|
|
145
|
+
console.log("");
|
|
146
|
+
const { default: ora7 } = await import("ora");
|
|
147
|
+
const spinner = ora7("Discovering project structure\u2026").start();
|
|
148
|
+
try {
|
|
149
|
+
const root = path.resolve(cwd, projectRoot);
|
|
150
|
+
const map = await discoverProject({ projectRoot: root });
|
|
151
|
+
const outDir = path.join(cwd, ".selfcure");
|
|
152
|
+
const { mkdir, writeFile: wf } = await import("fs/promises");
|
|
153
|
+
await mkdir(outDir, { recursive: true });
|
|
154
|
+
await wf(path.join(outDir, "project-map.json"), JSON.stringify(map, null, 2), "utf-8");
|
|
155
|
+
spinner.succeed(`Discovered ${map.routeCandidates.length} route candidate(s) \xB7 ${map.componentCandidates.length} component(s)`);
|
|
156
|
+
console.log(` Framework: ${map.framework} Package manager: ${map.packageManager}`);
|
|
157
|
+
if (map.devCommand) console.log(` Dev: ${map.devCommand}`);
|
|
158
|
+
console.log(` Artifacts: .selfcure/project-map.json`);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
spinner.warn(`Discovery skipped: ${String(err)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
console.log("\nNext steps:");
|
|
164
|
+
console.log(" selfcure discover \u2014 (re)run static discovery");
|
|
165
|
+
console.log(" selfcure lint \u2014 testability lint");
|
|
166
|
+
console.log(" selfcure a11y scan \u2014 WCAG accessibility scan");
|
|
167
|
+
console.log("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/lint.ts
|
|
171
|
+
import { crawl, extractA11yEvidenceFromAll } from "@selfcure/crawler";
|
|
172
|
+
import { analyze, runStaticAnalysis } from "@selfcure/analyzer";
|
|
173
|
+
import { readFile, writeFile as writeFile2, mkdtemp, rm } from "fs/promises";
|
|
174
|
+
import os from "os";
|
|
175
|
+
import path2 from "path";
|
|
176
|
+
import { execSync as execSync2 } from "child_process";
|
|
177
|
+
|
|
178
|
+
// src/git-providers.ts
|
|
179
|
+
import { execSync } from "child_process";
|
|
180
|
+
var githubProvider = {
|
|
181
|
+
id: "github",
|
|
182
|
+
displayName: "GitHub",
|
|
183
|
+
prKindLabel: "pull request",
|
|
184
|
+
ensureReady(_gitRoot) {
|
|
185
|
+
try {
|
|
186
|
+
execSync("gh auth status", { stdio: "pipe" });
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"GitHub CLI (gh) is not installed or not authenticated. Install from https://cli.github.com then run: gh auth login"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
resolveBaseBranch(configured, gitRoot) {
|
|
194
|
+
if (configured) return configured;
|
|
195
|
+
try {
|
|
196
|
+
const out = execSync(
|
|
197
|
+
"gh repo view --json defaultBranchRef --jq .defaultBranchRef.name",
|
|
198
|
+
{ cwd: gitRoot, stdio: "pipe", encoding: "utf-8" }
|
|
199
|
+
);
|
|
200
|
+
return out.trim() || void 0;
|
|
201
|
+
} catch {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
openPr({ gitRoot, branch, baseBranch, title, bodyFile }) {
|
|
206
|
+
const baseFlag = baseBranch ? ` --base ${JSON.stringify(baseBranch)}` : "";
|
|
207
|
+
const raw = execSync(
|
|
208
|
+
`gh pr create --title ${JSON.stringify(title)} --body-file ${JSON.stringify(bodyFile)} --head ${JSON.stringify(branch)}${baseFlag}`,
|
|
209
|
+
{ cwd: gitRoot, stdio: "pipe", encoding: "utf-8" }
|
|
210
|
+
);
|
|
211
|
+
return raw.split("\n").filter((l) => l.startsWith("https://")).pop()?.trim() ?? raw.trim();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
var gitlabProvider = {
|
|
215
|
+
id: "gitlab",
|
|
216
|
+
displayName: "GitLab",
|
|
217
|
+
prKindLabel: "merge request",
|
|
218
|
+
ensureReady(_gitRoot) {
|
|
219
|
+
try {
|
|
220
|
+
execSync("glab auth status", { stdio: "pipe" });
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"GitLab CLI (glab) is not installed or not authenticated. Install from https://gitlab.com/gitlab-org/cli then run: glab auth login"
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
resolveBaseBranch(configured, gitRoot) {
|
|
228
|
+
if (configured) return configured;
|
|
229
|
+
try {
|
|
230
|
+
const out = execSync(
|
|
231
|
+
"glab repo view --output json",
|
|
232
|
+
{ cwd: gitRoot, stdio: "pipe", encoding: "utf-8" }
|
|
233
|
+
);
|
|
234
|
+
const json = JSON.parse(out);
|
|
235
|
+
return json.default_branch || void 0;
|
|
236
|
+
} catch {
|
|
237
|
+
return void 0;
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
openPr({ gitRoot, branch, baseBranch, title, bodyFile }) {
|
|
241
|
+
const baseFlag = baseBranch ? ` --target-branch ${JSON.stringify(baseBranch)}` : "";
|
|
242
|
+
const raw = execSync(
|
|
243
|
+
`glab mr create --title ${JSON.stringify(title)} --description-file ${JSON.stringify(bodyFile)} --source-branch ${JSON.stringify(branch)}${baseFlag} --yes`,
|
|
244
|
+
{ cwd: gitRoot, stdio: "pipe", encoding: "utf-8" }
|
|
245
|
+
);
|
|
246
|
+
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
247
|
+
const urlLine = lines.reverse().find((l) => /^https?:\/\//.test(l) || /https?:\/\/\S+/.test(l));
|
|
248
|
+
if (!urlLine) return raw.trim();
|
|
249
|
+
const match = urlLine.match(/https?:\/\/\S+/);
|
|
250
|
+
return match ? match[0] : urlLine;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function detectProvider(gitRoot, override = "auto") {
|
|
254
|
+
if (override === "github") return githubProvider;
|
|
255
|
+
if (override === "gitlab") return gitlabProvider;
|
|
256
|
+
try {
|
|
257
|
+
const url = execSync("git remote get-url origin", {
|
|
258
|
+
cwd: gitRoot,
|
|
259
|
+
stdio: "pipe",
|
|
260
|
+
encoding: "utf-8"
|
|
261
|
+
}).toString().trim();
|
|
262
|
+
if (/gitlab/i.test(url)) return gitlabProvider;
|
|
263
|
+
return githubProvider;
|
|
264
|
+
} catch {
|
|
265
|
+
return githubProvider;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/lint.ts
|
|
270
|
+
function toKebab(s) {
|
|
271
|
+
return s.trim().replace(/([A-Z])/g, "-$1").replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
272
|
+
}
|
|
273
|
+
function suggestTestId(el, index) {
|
|
274
|
+
if (el.label) return toKebab(el.label);
|
|
275
|
+
if (el.selectors?.id) return toKebab(el.selectors.id.replace(/^#/, ""));
|
|
276
|
+
if (el.selectors?.name) {
|
|
277
|
+
const m = el.selectors.name.match(/\[name=["']?([^"'\]]+)["']?\]/);
|
|
278
|
+
if (m) return toKebab(m[1]);
|
|
279
|
+
}
|
|
280
|
+
if (el.selectors?.ariaLabel) {
|
|
281
|
+
const m = el.selectors.ariaLabel.match(/\[aria-label=["']?([^"'\]]+)["']?\]/);
|
|
282
|
+
if (m) return toKebab(m[1]);
|
|
283
|
+
}
|
|
284
|
+
return `${el.type}-${index + 1}`;
|
|
285
|
+
}
|
|
286
|
+
function escRe(s) {
|
|
287
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
288
|
+
}
|
|
289
|
+
function unwrap(sel, attr) {
|
|
290
|
+
if (!sel) return void 0;
|
|
291
|
+
const m = sel.match(new RegExp(`\\[${attr}=["']?([^"'\\]]+)["']?\\]`));
|
|
292
|
+
return m?.[1];
|
|
293
|
+
}
|
|
294
|
+
function existingTestId(el) {
|
|
295
|
+
const m = el.selectors.dataTestId?.match(/\[data-testid=["']?([^"'\]]+)["']?\]/);
|
|
296
|
+
return m?.[1];
|
|
297
|
+
}
|
|
298
|
+
function dedupeTestIdsPerFile(issuesByFile) {
|
|
299
|
+
for (const fileIssues of issuesByFile.values()) {
|
|
300
|
+
const used = /* @__PURE__ */ new Map();
|
|
301
|
+
for (const issue of fileIssues) {
|
|
302
|
+
const base = issue.suggestedTestId;
|
|
303
|
+
const n = (used.get(base) ?? 0) + 1;
|
|
304
|
+
used.set(base, n);
|
|
305
|
+
if (n > 1) issue.suggestedTestId = `${base}-${n}`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function patchSource(source, issue, testId) {
|
|
310
|
+
const existing = existingTestId(issue.element);
|
|
311
|
+
if (issue.kind === "ambiguous" && existing && existing !== testId) {
|
|
312
|
+
const pat2 = new RegExp(`(\\bdata-testid=["'])${escRe(existing)}(["'])`, "g");
|
|
313
|
+
let done2 = false;
|
|
314
|
+
return source.replace(pat2, (m, pre, post) => {
|
|
315
|
+
if (done2) return m;
|
|
316
|
+
done2 = true;
|
|
317
|
+
return `${pre}${testId}${post}`;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const sels = issue.element.selectors;
|
|
321
|
+
let attr;
|
|
322
|
+
let val;
|
|
323
|
+
if (sels.id) {
|
|
324
|
+
attr = "id";
|
|
325
|
+
val = sels.id.replace(/^#/, "");
|
|
326
|
+
} else if (val = unwrap(sels.name, "name")) {
|
|
327
|
+
attr = "name";
|
|
328
|
+
} else if (val = unwrap(sels.ariaLabel, "aria-label")) {
|
|
329
|
+
attr = "aria-label";
|
|
330
|
+
}
|
|
331
|
+
if (!attr || !val) return source;
|
|
332
|
+
const pat = new RegExp(
|
|
333
|
+
`(<[a-zA-Z][^>]*?\\b${escRe(attr)}=["']${escRe(val)}["'][^>]*?)(?=\\s*/?>)`,
|
|
334
|
+
"g"
|
|
335
|
+
);
|
|
336
|
+
let done = false;
|
|
337
|
+
return source.replace(pat, (match) => {
|
|
338
|
+
if (done || match.includes("data-testid")) return match;
|
|
339
|
+
done = true;
|
|
340
|
+
return `${match} data-testid="${testId}"`;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async function runLint(config, opts) {
|
|
344
|
+
const components = await crawl({
|
|
345
|
+
rootDir: config.rootDir,
|
|
346
|
+
include: config.include,
|
|
347
|
+
exclude: config.exclude,
|
|
348
|
+
framework: config.framework
|
|
349
|
+
});
|
|
350
|
+
const results = await analyze(components);
|
|
351
|
+
let a11yFindings;
|
|
352
|
+
if (opts.a11y) {
|
|
353
|
+
const evidenceList = extractA11yEvidenceFromAll(components);
|
|
354
|
+
a11yFindings = runStaticAnalysis(evidenceList, { level: opts.wcag ?? "AA" });
|
|
355
|
+
}
|
|
356
|
+
const issues = [];
|
|
357
|
+
for (const r of results) {
|
|
358
|
+
r.interactiveElements.forEach((el, i) => {
|
|
359
|
+
const isAmbiguous = el.ambiguous;
|
|
360
|
+
const isLowScore = el.testabilityScore < opts.threshold;
|
|
361
|
+
if (!isAmbiguous && !isLowScore) return;
|
|
362
|
+
issues.push({
|
|
363
|
+
filePath: r.component.filePath,
|
|
364
|
+
componentName: r.component.componentName,
|
|
365
|
+
element: el,
|
|
366
|
+
suggestedTestId: suggestTestId(el, i),
|
|
367
|
+
kind: isAmbiguous ? "ambiguous" : "low-score",
|
|
368
|
+
ambiguityReason: el.ambiguityReason
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
373
|
+
for (const issue of issues) {
|
|
374
|
+
if (!byFile.has(issue.filePath)) byFile.set(issue.filePath, []);
|
|
375
|
+
byFile.get(issue.filePath).push(issue);
|
|
376
|
+
}
|
|
377
|
+
dedupeTestIdsPerFile(byFile);
|
|
378
|
+
let fixedCount = 0;
|
|
379
|
+
let skippedCount = 0;
|
|
380
|
+
if (opts.fix && issues.length) {
|
|
381
|
+
for (const [filePath, fileIssues] of byFile) {
|
|
382
|
+
let source = await readFile(filePath, "utf-8");
|
|
383
|
+
let updated = source;
|
|
384
|
+
for (const issue of fileIssues) {
|
|
385
|
+
const patched = patchSource(updated, issue, issue.suggestedTestId);
|
|
386
|
+
if (patched !== updated) {
|
|
387
|
+
updated = patched;
|
|
388
|
+
issue.fixApplied = true;
|
|
389
|
+
fixedCount++;
|
|
390
|
+
} else {
|
|
391
|
+
skippedCount++;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (updated !== source) {
|
|
395
|
+
await writeFile2(filePath, updated, "utf-8");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
let prUrl;
|
|
400
|
+
if (opts.pr && fixedCount > 0) {
|
|
401
|
+
const cwd = path2.resolve(config.rootDir);
|
|
402
|
+
let gitRoot;
|
|
403
|
+
try {
|
|
404
|
+
gitRoot = execSync2("git rev-parse --show-toplevel", {
|
|
405
|
+
cwd,
|
|
406
|
+
stdio: "pipe",
|
|
407
|
+
encoding: "utf-8"
|
|
408
|
+
}).trim();
|
|
409
|
+
} catch {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"[selfcure --pr] Not inside a git repository. Run selfcure from a directory tracked by git."
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
const provider = detectProvider(gitRoot, config.lint?.gitProvider ?? "auto");
|
|
415
|
+
try {
|
|
416
|
+
provider.ensureReady(gitRoot);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
throw new Error(`[selfcure --pr] ${err instanceof Error ? err.message : String(err)}`);
|
|
419
|
+
}
|
|
420
|
+
const baseBranch = provider.resolveBaseBranch(config.lint?.prBaseBranch, gitRoot);
|
|
421
|
+
let originalBranch;
|
|
422
|
+
try {
|
|
423
|
+
originalBranch = execSync2("git rev-parse --abbrev-ref HEAD", {
|
|
424
|
+
cwd: gitRoot,
|
|
425
|
+
stdio: "pipe",
|
|
426
|
+
encoding: "utf-8"
|
|
427
|
+
}).trim();
|
|
428
|
+
if (originalBranch === "HEAD") originalBranch = void 0;
|
|
429
|
+
} catch {
|
|
430
|
+
}
|
|
431
|
+
const patchedFiles = [...byFile.keys()].filter((f) => byFile.get(f).some((i) => i.fixApplied));
|
|
432
|
+
try {
|
|
433
|
+
const statusOut = execSync2("git status --porcelain", {
|
|
434
|
+
cwd: gitRoot,
|
|
435
|
+
stdio: "pipe",
|
|
436
|
+
encoding: "utf-8"
|
|
437
|
+
}).trim();
|
|
438
|
+
if (statusOut) {
|
|
439
|
+
const patchedSet = new Set(
|
|
440
|
+
patchedFiles.map((f) => path2.relative(gitRoot, f).replace(/\\/g, "/"))
|
|
441
|
+
);
|
|
442
|
+
const dirtyUnrelated = statusOut.split("\n").map((l) => l.slice(3).trim()).filter((f) => !patchedSet.has(f));
|
|
443
|
+
if (dirtyUnrelated.length > 0) {
|
|
444
|
+
console.warn(
|
|
445
|
+
`[selfcure --pr] Warning: ${dirtyUnrelated.length} unrelated file(s) have uncommitted changes \u2014 they will NOT be included in the PR:
|
|
446
|
+
` + dirtyUnrelated.map((f) => ` \u2022 ${f}`).join("\n")
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
const applied = issues.filter((i) => i.fixApplied);
|
|
453
|
+
const ambiguousCnt = applied.filter((i) => i.kind === "ambiguous").length;
|
|
454
|
+
const lowScoreCnt = applied.length - ambiguousCnt;
|
|
455
|
+
const fileRows = patchedFiles.map((f) => {
|
|
456
|
+
const rel = path2.relative(gitRoot, f).replace(/\\/g, "/");
|
|
457
|
+
const fileIssues = byFile.get(f).filter((i) => i.fixApplied);
|
|
458
|
+
const ids = fileIssues.map((i) => `\`data-testid="${i.suggestedTestId}"\``).join(", ");
|
|
459
|
+
return `| \`${rel}\` | ${fileIssues.length} | ${ids} |`;
|
|
460
|
+
});
|
|
461
|
+
const whySummary = [];
|
|
462
|
+
if (lowScoreCnt > 0) {
|
|
463
|
+
whySummary.push(
|
|
464
|
+
`- **${lowScoreCnt}** element(s) had no stable selector (score < ${opts.threshold}/100) \u2014 \`data-testid\` added.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (ambiguousCnt > 0) {
|
|
468
|
+
whySummary.push(
|
|
469
|
+
`- **${ambiguousCnt}** locator(s) matched multiple elements in the same component \u2014 \`data-testid\` rewritten to a unique value.`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const body = [
|
|
473
|
+
"## selfcure lint \u2014 automated `data-testid` patches",
|
|
474
|
+
"",
|
|
475
|
+
"| Metric | Value |",
|
|
476
|
+
"|--------|-------|",
|
|
477
|
+
`| Files changed | ${patchedFiles.length} |`,
|
|
478
|
+
`| Elements patched | ${fixedCount} |`,
|
|
479
|
+
`| Unstable selectors fixed | ${lowScoreCnt} |`,
|
|
480
|
+
`| Ambiguous locators fixed | ${ambiguousCnt} |`,
|
|
481
|
+
"",
|
|
482
|
+
"### Changed files",
|
|
483
|
+
"",
|
|
484
|
+
"| File | Elements | Attributes added |",
|
|
485
|
+
"|------|----------|-----------------|",
|
|
486
|
+
...fileRows,
|
|
487
|
+
"",
|
|
488
|
+
"### Why these changes?",
|
|
489
|
+
"",
|
|
490
|
+
...whySummary,
|
|
491
|
+
"",
|
|
492
|
+
"> **Review before merging** \u2014 rename any `data-testid` value that does not match your project's naming convention.",
|
|
493
|
+
"> _Generated automatically by [selfcure](https://github.com/ricardofrancocustodio/selfcure)._"
|
|
494
|
+
].join("\n");
|
|
495
|
+
const tmpDir = await mkdtemp(path2.join(os.tmpdir(), "selfcure-pr-"));
|
|
496
|
+
const bodyFile = path2.join(tmpDir, "body.md");
|
|
497
|
+
await writeFile2(bodyFile, body, "utf-8");
|
|
498
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
499
|
+
const short = Date.now().toString(36).slice(-4);
|
|
500
|
+
const branch = `selfcure/lint-fix-${date}-${short}`;
|
|
501
|
+
const title = `chore(testids): add data-testid to ${fixedCount} element(s) \u2014 selfcure lint`;
|
|
502
|
+
try {
|
|
503
|
+
execSync2(`git checkout -b ${JSON.stringify(branch)}`, { cwd: gitRoot, stdio: "pipe" });
|
|
504
|
+
const relPaths = patchedFiles.map((f) => JSON.stringify(path2.relative(gitRoot, f))).join(" ");
|
|
505
|
+
execSync2(`git add -- ${relPaths}`, { cwd: gitRoot, stdio: "pipe" });
|
|
506
|
+
const commitMsg = `chore(testids): add data-testid via selfcure lint
|
|
507
|
+
|
|
508
|
+
Patched ${fixedCount} element(s) across ${patchedFiles.length} file(s).
|
|
509
|
+
Threshold: ${opts.threshold}/100`;
|
|
510
|
+
execSync2(`git commit -m ${JSON.stringify(commitMsg)}`, { cwd: gitRoot, stdio: "pipe" });
|
|
511
|
+
execSync2(`git push -u origin ${JSON.stringify(branch)}`, { cwd: gitRoot, stdio: "pipe" });
|
|
512
|
+
prUrl = provider.openPr({ gitRoot, branch, baseBranch, title, bodyFile });
|
|
513
|
+
} finally {
|
|
514
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
515
|
+
if (originalBranch) {
|
|
516
|
+
try {
|
|
517
|
+
execSync2(`git checkout ${JSON.stringify(originalBranch)}`, { cwd: gitRoot, stdio: "pipe" });
|
|
518
|
+
} catch {
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return { issues, totalFiles: results.length, fixedCount, skippedCount, prUrl, a11yFindings };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/a11y.ts
|
|
527
|
+
import chalk from "chalk";
|
|
528
|
+
import ora from "ora";
|
|
529
|
+
import path3 from "path";
|
|
530
|
+
import fs from "fs/promises";
|
|
531
|
+
import { pathToFileURL } from "url";
|
|
532
|
+
import { crawl as crawl2, extractA11yEvidenceFromAll as extractA11yEvidenceFromAll2 } from "@selfcure/crawler";
|
|
533
|
+
import {
|
|
534
|
+
runStaticAnalysis as runStaticAnalysis2,
|
|
535
|
+
loadFindings,
|
|
536
|
+
saveFindings,
|
|
537
|
+
mergeFindings,
|
|
538
|
+
emptyInventory,
|
|
539
|
+
runAudit
|
|
540
|
+
} from "@selfcure/analyzer";
|
|
541
|
+
import { runDynamicScan } from "@selfcure/runner";
|
|
542
|
+
var SEVERITY_COLOR = {
|
|
543
|
+
critical: chalk.red,
|
|
544
|
+
major: chalk.yellow,
|
|
545
|
+
minor: chalk.cyan,
|
|
546
|
+
info: chalk.dim
|
|
547
|
+
};
|
|
548
|
+
function severityCounts(findings) {
|
|
549
|
+
return {
|
|
550
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
551
|
+
major: findings.filter((f) => f.severity === "major").length,
|
|
552
|
+
minor: findings.filter((f) => f.severity === "minor").length,
|
|
553
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function printA11ySection(findings, opts) {
|
|
557
|
+
const { isPro, wcagLevel, cwd } = opts;
|
|
558
|
+
const counts = severityCounts(findings);
|
|
559
|
+
console.log(chalk.bold(`Accessibility WCAG ${wcagLevel}`));
|
|
560
|
+
if (findings.length === 0) {
|
|
561
|
+
console.log(chalk.dim(" 0 issues"));
|
|
562
|
+
console.log("");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const parts = [];
|
|
566
|
+
if (counts.critical > 0) parts.push(chalk.red.bold(`critical ${counts.critical}`));
|
|
567
|
+
if (counts.major > 0) parts.push(chalk.yellow.bold(`major ${counts.major}`));
|
|
568
|
+
if (counts.minor > 0) parts.push(chalk.cyan(`minor ${counts.minor}`));
|
|
569
|
+
if (counts.info > 0) parts.push(chalk.dim(`info ${counts.info}`));
|
|
570
|
+
console.log(" " + parts.join(" \xB7 "));
|
|
571
|
+
console.log("");
|
|
572
|
+
if (!isPro) {
|
|
573
|
+
console.log(chalk.bold.yellow(" \u2726 Full accessibility report available on the Pro plan"));
|
|
574
|
+
console.log(chalk.dim(" Enable with SELFCURE_PRO=1 or pro: true in your config."));
|
|
575
|
+
console.log("");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
579
|
+
for (const f of findings) {
|
|
580
|
+
const list = byFile.get(f.sourceFile) ?? [];
|
|
581
|
+
list.push(f);
|
|
582
|
+
byFile.set(f.sourceFile, list);
|
|
583
|
+
}
|
|
584
|
+
for (const [filePath, filefindings] of byFile) {
|
|
585
|
+
const rel = path3.relative(cwd, filePath);
|
|
586
|
+
console.log(chalk.underline(rel));
|
|
587
|
+
for (const f of filefindings) {
|
|
588
|
+
const col = SEVERITY_COLOR[f.severity];
|
|
589
|
+
const rule = chalk.bold(f.ruleId.replace("a11y.", ""));
|
|
590
|
+
const loc = chalk.dim(`:${f.line}${f.column ? `:${f.column}` : ""}`);
|
|
591
|
+
console.log(` ${col(f.severity.padEnd(8))} ${rule}`);
|
|
592
|
+
console.log(chalk.dim(` ${rel}${loc} \u2014 ${f.message}`));
|
|
593
|
+
}
|
|
594
|
+
console.log("");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function tryLoadConfig(configPath) {
|
|
598
|
+
const resolved = path3.resolve(configPath);
|
|
599
|
+
const exists = await fs.stat(resolved).then(() => true).catch(() => false);
|
|
600
|
+
if (!exists) return null;
|
|
601
|
+
try {
|
|
602
|
+
const { default: cfg } = await import(pathToFileURL(resolved).href);
|
|
603
|
+
return cfg;
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
var DEFAULT_SOURCE_GLOBS = ["**/*.{tsx,jsx,ts,js,vue}"];
|
|
609
|
+
var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/.nuxt/**"];
|
|
610
|
+
function registerA11yCommands(program2) {
|
|
611
|
+
const a11y = program2.command("a11y").description("Accessibility WCAG commands \u2014 scan, audit, and CI gate");
|
|
612
|
+
a11y.command("scan").description("Scan source files for WCAG accessibility issues and update the findings inventory").option("-c, --config <path>", "path to selfcure.config.mjs", "./selfcure.config.mjs").option("--root <dir>", "project root (overrides config.rootDir)").option("--wcag <level>", "WCAG target level: A, AA, or AAA", "AA").option("--out <dir>", "output directory for findings file", ".selfcure").option("--app <name>", "application name for the findings file").option("--dynamic", "[Pro] also run live Playwright + axe-core scan").option("--base-url <url>", "app base URL for dynamic scan (e.g. http://localhost:3000)").option("--routes <routes>", "comma-separated routes to scan (default: /)", "/").option("--axe-source <path>", "local path or URL to axe-core script (default: CDN)").action(async (opts) => {
|
|
613
|
+
const spinner = ora("Scanning for accessibility issues\u2026").start();
|
|
614
|
+
try {
|
|
615
|
+
const config = await tryLoadConfig(opts.config);
|
|
616
|
+
const rootDir = opts.root ? path3.resolve(opts.root) : config?.["rootDir"] ? path3.resolve(String(config["rootDir"])) : process.cwd();
|
|
617
|
+
const include = config?.["include"] ?? DEFAULT_SOURCE_GLOBS;
|
|
618
|
+
const exclude = config?.["exclude"] ?? DEFAULT_EXCLUDE;
|
|
619
|
+
const wcagLevel = opts.wcag ?? "AA";
|
|
620
|
+
const components = await crawl2({ rootDir, include, exclude });
|
|
621
|
+
const evidenceList = extractA11yEvidenceFromAll2(components);
|
|
622
|
+
const newFindings = runStaticAnalysis2(evidenceList, { level: wcagLevel });
|
|
623
|
+
if (opts.dynamic) {
|
|
624
|
+
const baseUrl = opts.baseUrl;
|
|
625
|
+
if (!baseUrl) {
|
|
626
|
+
spinner.warn(chalk.yellow("--dynamic requires --base-url. Skipping dynamic scan."));
|
|
627
|
+
} else {
|
|
628
|
+
spinner.text = "Running dynamic Playwright + axe-core scan\u2026";
|
|
629
|
+
try {
|
|
630
|
+
const routes = opts.routes.split(",").map((r) => r.trim()).filter(Boolean);
|
|
631
|
+
const dynResult = await runDynamicScan({
|
|
632
|
+
baseURL: baseUrl,
|
|
633
|
+
routes,
|
|
634
|
+
level: wcagLevel,
|
|
635
|
+
axeSource: opts.axeSource
|
|
636
|
+
});
|
|
637
|
+
newFindings.push(...dynResult.findings);
|
|
638
|
+
if (dynResult.errors.length > 0) {
|
|
639
|
+
for (const e of dynResult.errors) {
|
|
640
|
+
console.warn(chalk.yellow(` dynamic scan error on ${e.route}: ${e.error}`));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} catch (err) {
|
|
644
|
+
spinner.warn(chalk.yellow(`Dynamic scan failed: ${String(err)}`));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const outDir = path3.resolve(opts.out);
|
|
649
|
+
const outFile = path3.join(outDir, "a11y-findings.json");
|
|
650
|
+
const existing = await loadFindings(outFile);
|
|
651
|
+
const appName = opts.app ?? path3.basename(rootDir);
|
|
652
|
+
const base = existing.ok ? existing.inventory : emptyInventory({ app: appName, targetLevel: wcagLevel });
|
|
653
|
+
const merged = mergeFindings(base, newFindings);
|
|
654
|
+
await saveFindings(outFile, merged);
|
|
655
|
+
const open = merged.findings.filter((f) => f.status === "open");
|
|
656
|
+
const counts = severityCounts(open);
|
|
657
|
+
spinner.succeed(chalk.green(`Scan complete \u2014 ${components.length} file(s) scanned`));
|
|
658
|
+
console.log("");
|
|
659
|
+
if (open.length === 0) {
|
|
660
|
+
console.log(chalk.green(" 0 accessibility issues"));
|
|
661
|
+
} else {
|
|
662
|
+
const parts = [];
|
|
663
|
+
if (counts.critical > 0) parts.push(chalk.red(`critical ${counts.critical}`));
|
|
664
|
+
if (counts.major > 0) parts.push(chalk.yellow(`major ${counts.major}`));
|
|
665
|
+
if (counts.minor > 0) parts.push(chalk.cyan(`minor ${counts.minor}`));
|
|
666
|
+
if (counts.info > 0) parts.push(chalk.dim(`info ${counts.info}`));
|
|
667
|
+
console.log(" " + parts.join(" \xB7 "));
|
|
668
|
+
}
|
|
669
|
+
const resolved = merged.findings.filter((f) => f.status === "resolved").length;
|
|
670
|
+
const suppressed = merged.findings.filter((f) => f.status === "suppressed").length;
|
|
671
|
+
if (resolved > 0) console.log(chalk.dim(` ${resolved} resolved since last scan`));
|
|
672
|
+
if (suppressed > 0) console.log(chalk.dim(` ${suppressed} suppressed`));
|
|
673
|
+
console.log(chalk.dim(`
|
|
674
|
+
findings written to ${path3.relative(process.cwd(), outFile)}`));
|
|
675
|
+
console.log("");
|
|
676
|
+
} catch (err) {
|
|
677
|
+
spinner.fail(chalk.red(String(err)));
|
|
678
|
+
process.exit(1);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
a11y.command("audit").description("Audit the accessibility findings inventory and optionally gate CI").option("--findings <path>", "path to findings file", ".selfcure/a11y-findings.json").option("--fail-on <sev>", "minimum severity that fails CI: info, minor, major, critical", "major").option("--ci", "exit non-zero when findings meet or exceed --fail-on severity").action(async (opts) => {
|
|
682
|
+
const spinner = ora("Loading accessibility findings\u2026").start();
|
|
683
|
+
try {
|
|
684
|
+
const findingsPath = path3.resolve(opts.findings);
|
|
685
|
+
const result = await loadFindings(findingsPath);
|
|
686
|
+
if (!result.ok) {
|
|
687
|
+
spinner.fail(chalk.red("Failed to load findings:"));
|
|
688
|
+
for (const e of result.errors) console.error(chalk.red(` ${e}`));
|
|
689
|
+
process.exit(1);
|
|
690
|
+
}
|
|
691
|
+
spinner.stop();
|
|
692
|
+
const inventory = result.inventory;
|
|
693
|
+
const auditResult = runAudit(inventory, { failOn: opts.failOn });
|
|
694
|
+
const { counts, wouldFailCI } = auditResult;
|
|
695
|
+
console.log("");
|
|
696
|
+
console.log(chalk.bold("selfcure a11y audit"));
|
|
697
|
+
console.log(chalk.dim(` ${path3.relative(process.cwd(), findingsPath)} \xB7 WCAG ${inventory.targetLevel}`));
|
|
698
|
+
console.log("");
|
|
699
|
+
console.log(
|
|
700
|
+
chalk.dim("Open: ") + chalk.white(counts.open) + " " + chalk.dim("Resolved: ") + chalk.white(counts.resolved) + " " + chalk.dim("Suppressed: ") + chalk.white(counts.suppressed)
|
|
701
|
+
);
|
|
702
|
+
console.log("");
|
|
703
|
+
if (counts.open === 0) {
|
|
704
|
+
console.log(chalk.green.bold("\u2714 No open accessibility findings"));
|
|
705
|
+
console.log("");
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const open = inventory.findings.filter((f) => f.status === "open");
|
|
709
|
+
printA11ySection(open, { isPro: true, wcagLevel: inventory.targetLevel, cwd: process.cwd() });
|
|
710
|
+
if (opts.ci) {
|
|
711
|
+
if (wouldFailCI) {
|
|
712
|
+
console.log(chalk.red.bold(
|
|
713
|
+
`CI mode: exiting with code 1 (fail-on: ${opts.failOn} \u2014 found ` + [
|
|
714
|
+
counts.bySeverity.critical > 0 ? `${counts.bySeverity.critical} critical` : "",
|
|
715
|
+
counts.bySeverity.major > 0 ? `${counts.bySeverity.major} major` : ""
|
|
716
|
+
].filter(Boolean).join(", ") + ")"
|
|
717
|
+
));
|
|
718
|
+
process.exit(1);
|
|
719
|
+
} else {
|
|
720
|
+
console.log(chalk.green(`CI mode: passed (no findings at or above "${opts.failOn}")`));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} catch (err) {
|
|
724
|
+
spinner.fail(chalk.red(String(err)));
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/testids.ts
|
|
731
|
+
import chalk2 from "chalk";
|
|
732
|
+
import ora2 from "ora";
|
|
733
|
+
import path4 from "path";
|
|
734
|
+
import fs2 from "fs/promises";
|
|
735
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
736
|
+
import { extractTestIds } from "@selfcure/crawler";
|
|
737
|
+
import { loadInventory, audit } from "@selfcure/analyzer";
|
|
738
|
+
var DEFAULT_SOURCE_GLOBS2 = ["**/*.{tsx,jsx,ts,js,vue,html}"];
|
|
739
|
+
var DEFAULT_TEST_GLOBS = ["**/*.{spec,test}.{ts,js,tsx,jsx}", "**/*.e2e.{ts,js}"];
|
|
740
|
+
var DEFAULT_EXCLUDE2 = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/.nuxt/**"];
|
|
741
|
+
async function tryLoadConfig2(configPath) {
|
|
742
|
+
const resolved = path4.resolve(configPath);
|
|
743
|
+
const exists = await fs2.stat(resolved).then(() => true).catch(() => false);
|
|
744
|
+
if (!exists) return null;
|
|
745
|
+
try {
|
|
746
|
+
const { default: cfg } = await import(pathToFileURL2(resolved).href);
|
|
747
|
+
return cfg;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function registerTestIdsCommands(program2) {
|
|
753
|
+
const testids = program2.command("testids").description("Test ID inventory \u2014 scan source for data-testid usage and audit against a governed contract");
|
|
754
|
+
testids.command("scan").description("Scan source and test files for data-testid usage and write observed results").option("-c, --config <path>", "path to selfcure.config.mjs", "./selfcure.config.mjs").option("--root <dir>", "project root (overrides config.rootDir)", "").option("--out <dir>", "output directory for scan results", ".selfcure").action(async (opts) => {
|
|
755
|
+
const spinner = ora2("Scanning for data-testid usage\u2026").start();
|
|
756
|
+
try {
|
|
757
|
+
const config = await tryLoadConfig2(opts.config);
|
|
758
|
+
const rootDir = opts.root ? path4.resolve(opts.root) : config?.["rootDir"] ? path4.resolve(String(config["rootDir"])) : process.cwd();
|
|
759
|
+
const sourceGlobs = config?.["include"] ?? DEFAULT_SOURCE_GLOBS2;
|
|
760
|
+
const exclude = config?.["exclude"] ?? DEFAULT_EXCLUDE2;
|
|
761
|
+
const result = await extractTestIds({
|
|
762
|
+
rootDir,
|
|
763
|
+
sourceGlobs,
|
|
764
|
+
testGlobs: DEFAULT_TEST_GLOBS,
|
|
765
|
+
exclude
|
|
766
|
+
});
|
|
767
|
+
const outDir = path4.resolve(opts.out);
|
|
768
|
+
await fs2.mkdir(outDir, { recursive: true });
|
|
769
|
+
const outFile = path4.join(outDir, "testids-scan.json");
|
|
770
|
+
await fs2.writeFile(outFile, JSON.stringify({ rootDir, ...result, generatedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2), "utf-8");
|
|
771
|
+
spinner.succeed(chalk2.green(`Scan complete \u2014 ${result.scannedFiles} file(s) scanned`));
|
|
772
|
+
console.log("");
|
|
773
|
+
const frontendCount = result.usages.filter((u) => u.kind === "frontend").length;
|
|
774
|
+
const testCount = result.usages.filter((u) => u.kind === "test").length;
|
|
775
|
+
const uniqueFe = new Set(result.usages.filter((u) => u.kind === "frontend").map((u) => u.value)).size;
|
|
776
|
+
console.log(chalk2.dim(` frontend data-testid: `) + chalk2.white(`${uniqueFe} unique (${frontendCount} occurrences)`));
|
|
777
|
+
console.log(chalk2.dim(` test getByTestId: `) + chalk2.white(`${testCount} calls`));
|
|
778
|
+
console.log(chalk2.dim(` results written to: `) + chalk2.white(path4.relative(process.cwd(), outFile)));
|
|
779
|
+
console.log("");
|
|
780
|
+
} catch (err) {
|
|
781
|
+
spinner.fail(chalk2.red(String(err)));
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
testids.command("audit").description("Audit the testid-inventory.json contract against observed source usage").option("-c, --config <path>", "path to selfcure.config.mjs", "./selfcure.config.mjs").option("--root <dir>", "project root (overrides config.rootDir)", "").option("--inventory <path>", "path to inventory file", ".selfcure/testid-inventory.json").option("--ci", "exit non-zero when error-level issues exist").action(async (opts) => {
|
|
786
|
+
const spinner = ora2("Loading inventory and scanning source\u2026").start();
|
|
787
|
+
try {
|
|
788
|
+
const inventoryPath = path4.resolve(opts.inventory);
|
|
789
|
+
const inventoryResult = await loadInventory(inventoryPath);
|
|
790
|
+
if (!inventoryResult.ok) {
|
|
791
|
+
spinner.fail(chalk2.red("Failed to load inventory:"));
|
|
792
|
+
for (const e of inventoryResult.errors) console.error(chalk2.red(` ${e}`));
|
|
793
|
+
process.exit(1);
|
|
794
|
+
}
|
|
795
|
+
const inventory = inventoryResult.inventory;
|
|
796
|
+
const config = await tryLoadConfig2(opts.config);
|
|
797
|
+
const rootDir = opts.root ? path4.resolve(opts.root) : config?.["rootDir"] ? path4.resolve(String(config["rootDir"])) : process.cwd();
|
|
798
|
+
const sourceGlobs = config?.["include"] ?? DEFAULT_SOURCE_GLOBS2;
|
|
799
|
+
const exclude = config?.["exclude"] ?? DEFAULT_EXCLUDE2;
|
|
800
|
+
const { usages, scannedFiles } = await extractTestIds({
|
|
801
|
+
rootDir,
|
|
802
|
+
sourceGlobs,
|
|
803
|
+
testGlobs: DEFAULT_TEST_GLOBS,
|
|
804
|
+
exclude
|
|
805
|
+
});
|
|
806
|
+
spinner.stop();
|
|
807
|
+
const result = audit(inventory, usages);
|
|
808
|
+
const { summary, issues } = result;
|
|
809
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
810
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
811
|
+
console.log("");
|
|
812
|
+
console.log(chalk2.bold("selfcure testids audit"));
|
|
813
|
+
console.log(chalk2.dim(` ${scannedFiles} file(s) scanned \xB7 ${summary.totalObserved} unique frontend test IDs observed`));
|
|
814
|
+
console.log("");
|
|
815
|
+
if (issues.length === 0) {
|
|
816
|
+
console.log(chalk2.green.bold("\u2714 No issues found"));
|
|
817
|
+
console.log("");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
console.log(
|
|
821
|
+
(errors.length > 0 ? chalk2.red(`Errors: ${errors.length}`) : chalk2.dim("Errors: 0")) + " " + (warnings.length > 0 ? chalk2.yellow(`Warnings: ${warnings.length}`) : chalk2.dim("Warnings: 0"))
|
|
822
|
+
);
|
|
823
|
+
console.log("");
|
|
824
|
+
for (const issue of issues) {
|
|
825
|
+
printIssue(issue);
|
|
826
|
+
}
|
|
827
|
+
if (opts.ci && errors.length > 0) {
|
|
828
|
+
console.log(chalk2.red.bold(`
|
|
829
|
+
CI mode: exiting with code 1 (${errors.length} error(s) found)`));
|
|
830
|
+
process.exit(1);
|
|
831
|
+
}
|
|
832
|
+
} catch (err) {
|
|
833
|
+
spinner.fail(chalk2.red(String(err)));
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function printIssue(issue) {
|
|
839
|
+
const severity = issue.severity === "error" ? chalk2.red("error") : chalk2.yellow("warn ");
|
|
840
|
+
const extra = issue.message ? chalk2.dim(` \u2014 ${issue.message}`) : "";
|
|
841
|
+
console.log(`${severity} ${chalk2.bold(issue.rule)} ${chalk2.white(issue.testId)}${extra}`);
|
|
842
|
+
if (issue.locations) {
|
|
843
|
+
for (const loc of issue.locations) {
|
|
844
|
+
console.log(chalk2.dim(` ${loc}`));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/discover.ts
|
|
850
|
+
import chalk3 from "chalk";
|
|
851
|
+
import ora3 from "ora";
|
|
852
|
+
import path5 from "path";
|
|
853
|
+
import fs3 from "fs/promises";
|
|
854
|
+
import { discoverProject as discoverProject2 } from "@selfcure/crawler";
|
|
855
|
+
import { runRuntimeDiscovery } from "@selfcure/runner";
|
|
856
|
+
import { buildTestabilityReport, summarizeReport } from "@selfcure/analyzer";
|
|
857
|
+
import { buildDiscoveryInput, shouldUseLlm, runLlmDiscovery } from "@selfcure/generator";
|
|
858
|
+
function frameworkLabel(f) {
|
|
859
|
+
const map = {
|
|
860
|
+
next: "Next.js",
|
|
861
|
+
nuxt: "Nuxt",
|
|
862
|
+
vue: "Vue",
|
|
863
|
+
angular: "Angular",
|
|
864
|
+
svelte: "Svelte",
|
|
865
|
+
react: "React (SPA)",
|
|
866
|
+
unknown: "Unknown"
|
|
867
|
+
};
|
|
868
|
+
return map[f] ?? f;
|
|
869
|
+
}
|
|
870
|
+
function scoreColor(score) {
|
|
871
|
+
if (score >= 80) return chalk3.green(String(score));
|
|
872
|
+
if (score >= 60) return chalk3.yellow(String(score));
|
|
873
|
+
return chalk3.red(String(score));
|
|
874
|
+
}
|
|
875
|
+
function confidenceBar(c) {
|
|
876
|
+
const pct = Math.round(c * 100);
|
|
877
|
+
const col = pct >= 90 ? chalk3.green : pct >= 70 ? chalk3.yellow : chalk3.dim;
|
|
878
|
+
return col(`${pct}%`);
|
|
879
|
+
}
|
|
880
|
+
function printSummary(map, cwd) {
|
|
881
|
+
console.log("");
|
|
882
|
+
console.log(chalk3.bold("selfcure discover"));
|
|
883
|
+
console.log("");
|
|
884
|
+
console.log(chalk3.dim(" Framework: ") + chalk3.white(frameworkLabel(map.framework)));
|
|
885
|
+
console.log(chalk3.dim(" Package manager: ") + chalk3.white(map.packageManager));
|
|
886
|
+
if (map.devCommand) console.log(chalk3.dim(" Dev command: ") + chalk3.white(map.devCommand));
|
|
887
|
+
if (map.buildCommand) console.log(chalk3.dim(" Build command: ") + chalk3.white(map.buildCommand));
|
|
888
|
+
if (map.testCommand) console.log(chalk3.dim(" Test command: ") + chalk3.white(map.testCommand));
|
|
889
|
+
console.log("");
|
|
890
|
+
const routes = map.routeCandidates;
|
|
891
|
+
if (routes.length === 0) {
|
|
892
|
+
console.log(chalk3.dim(" No route candidates found."));
|
|
893
|
+
} else {
|
|
894
|
+
console.log(chalk3.bold(` Routes (${routes.length}):`));
|
|
895
|
+
for (const r of routes) {
|
|
896
|
+
const rel = path5.relative(cwd, r.filePath);
|
|
897
|
+
const dyn = r.isDynamic ? chalk3.dim(" [dynamic]") : "";
|
|
898
|
+
const conf = confidenceBar(r.confidence);
|
|
899
|
+
console.log(` ${chalk3.cyan(r.path.padEnd(35))} ${conf.padEnd(8)} ${chalk3.dim(rel)}${dyn}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (map.componentCandidates.length > 0) {
|
|
903
|
+
console.log("");
|
|
904
|
+
console.log(chalk3.dim(` ${map.componentCandidates.length} component candidate(s) found.`));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function registerDiscoverCommand(program2) {
|
|
908
|
+
program2.command("discover").description("Discover project structure, framework, and route candidates (static analysis)").option("--root <dir>", "project root to analyse (default: cwd)").option("--out <dir>", "output directory for discovery artifacts", ".selfcure").option("--runtime", "also render routes with Playwright (requires a running app)").option("--base-url <url>", "app base URL for runtime discovery (e.g. http://localhost:3000)").option("--screenshots", "capture screenshots during runtime discovery").option("--llm", "use LLM to suggest routes and hidden states when confidence is low").option("--provider <id>", "AI provider for --llm (anthropic|openai|google|groq|deepseek|ollama)").option("--model <name>", "model name for --llm").action(async (opts) => {
|
|
909
|
+
const cwd = process.cwd();
|
|
910
|
+
const root = opts.root ? path5.resolve(opts.root) : cwd;
|
|
911
|
+
const outDir = path5.resolve(opts.out);
|
|
912
|
+
const spinner = ora3("Discovering project structure\u2026").start();
|
|
913
|
+
let map;
|
|
914
|
+
try {
|
|
915
|
+
map = await discoverProject2({ projectRoot: root });
|
|
916
|
+
} catch (err) {
|
|
917
|
+
spinner.fail(chalk3.red(String(err)));
|
|
918
|
+
process.exit(1);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
await fs3.mkdir(outDir, { recursive: true });
|
|
922
|
+
const mapFile = path5.join(outDir, "project-map.json");
|
|
923
|
+
await fs3.writeFile(mapFile, JSON.stringify(map, null, 2), "utf-8");
|
|
924
|
+
spinner.stop();
|
|
925
|
+
printSummary(map, cwd);
|
|
926
|
+
console.log(chalk3.dim(` Artifacts: ${path5.relative(cwd, mapFile)}`));
|
|
927
|
+
if (opts.runtime) {
|
|
928
|
+
const baseURL = opts.baseUrl;
|
|
929
|
+
if (!baseURL) {
|
|
930
|
+
console.log("");
|
|
931
|
+
console.log(chalk3.yellow(" --runtime requires --base-url <url> (e.g. http://localhost:3000)"));
|
|
932
|
+
console.log("");
|
|
933
|
+
} else {
|
|
934
|
+
const routes = map.routeCandidates.map((r) => r.path);
|
|
935
|
+
const rtSpinner = ora3(`Runtime discovery \u2014 ${routes.length} route(s) via Playwright\u2026`).start();
|
|
936
|
+
try {
|
|
937
|
+
const rtResult2 = await runRuntimeDiscovery({
|
|
938
|
+
baseURL,
|
|
939
|
+
routes,
|
|
940
|
+
outDir,
|
|
941
|
+
screenshots: opts.screenshots ?? false
|
|
942
|
+
});
|
|
943
|
+
const routeMapFile = path5.join(outDir, "route-map.json");
|
|
944
|
+
await fs3.writeFile(routeMapFile, JSON.stringify(rtResult2, null, 2), "utf-8");
|
|
945
|
+
const trReport = buildTestabilityReport(rtResult2);
|
|
946
|
+
const trFile = path5.join(outDir, "testability-report.json");
|
|
947
|
+
await fs3.writeFile(trFile, JSON.stringify(trReport, null, 2), "utf-8");
|
|
948
|
+
const summary = summarizeReport(trReport);
|
|
949
|
+
rtSpinner.stop();
|
|
950
|
+
console.log("");
|
|
951
|
+
console.log(chalk3.bold(` Runtime results (${rtResult2.reachable}/${routes.length} reachable):`));
|
|
952
|
+
for (const r of rtResult2.routes) {
|
|
953
|
+
const icon = r.status === "reachable" ? chalk3.green("\u2713") : chalk3.red("\u2717");
|
|
954
|
+
const title = r.title ? chalk3.dim(` "${r.title.slice(0, 40)}"`) : "";
|
|
955
|
+
const els = r.status === "reachable" ? chalk3.dim(` \xB7 ${r.interactiveElements.length} elements`) : "";
|
|
956
|
+
const routeResult = trReport.routes.find((x) => x.route === r.route);
|
|
957
|
+
const score = routeResult && r.status === "reachable" ? " score: " + scoreColor(routeResult.score) : "";
|
|
958
|
+
console.log(` ${icon} ${chalk3.cyan(r.route.padEnd(30))}${title}${els}${score}`);
|
|
959
|
+
}
|
|
960
|
+
console.log("");
|
|
961
|
+
console.log(chalk3.bold(" Testability:"));
|
|
962
|
+
if (summary.critical.length > 0) {
|
|
963
|
+
console.log(chalk3.red(` Critical (< 60): ${summary.critical.map((r) => r.route).join(", ")}`));
|
|
964
|
+
}
|
|
965
|
+
if (summary.warning.length > 0) {
|
|
966
|
+
console.log(chalk3.yellow(` Warning (< 80): ${summary.warning.map((r) => r.route).join(", ")}`));
|
|
967
|
+
}
|
|
968
|
+
if (summary.healthy.length > 0) {
|
|
969
|
+
console.log(chalk3.green(` Healthy (\u2265 80): ${summary.healthy.map((r) => r.route).join(", ")}`));
|
|
970
|
+
}
|
|
971
|
+
console.log(chalk3.dim(` Overall score: ${trReport.overall.score}/100`));
|
|
972
|
+
console.log("");
|
|
973
|
+
console.log(chalk3.dim(` Artifacts: ${path5.relative(cwd, routeMapFile)}`));
|
|
974
|
+
console.log(chalk3.dim(` ${path5.relative(cwd, trFile)}`));
|
|
975
|
+
} catch (err) {
|
|
976
|
+
rtSpinner.fail(chalk3.red(`Runtime discovery failed: ${String(err)}`));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
} else if (map.routeCandidates.length > 0) {
|
|
980
|
+
console.log("");
|
|
981
|
+
console.log(chalk3.dim(" Tip: run with --runtime --base-url <url> to render routes live"));
|
|
982
|
+
}
|
|
983
|
+
const rtResult = void 0;
|
|
984
|
+
if (opts.llm) {
|
|
985
|
+
const needsLlm = shouldUseLlm(map, rtResult);
|
|
986
|
+
if (!needsLlm) {
|
|
987
|
+
console.log(chalk3.dim(" LLM skipped \u2014 deterministic confidence is high enough."));
|
|
988
|
+
} else {
|
|
989
|
+
const aiConfig = {
|
|
990
|
+
provider: opts.provider ?? "anthropic",
|
|
991
|
+
generationModel: opts.model
|
|
992
|
+
};
|
|
993
|
+
const llmSpinner = ora3("LLM discovery \u2014 asking model for route + hidden-state hints\u2026").start();
|
|
994
|
+
try {
|
|
995
|
+
const llmInput = buildDiscoveryInput(map, rtResult);
|
|
996
|
+
const llmOutput = await runLlmDiscovery(llmInput, aiConfig);
|
|
997
|
+
llmSpinner.stop();
|
|
998
|
+
console.log("");
|
|
999
|
+
console.log(chalk3.bold(` LLM suggestions (confidence: ${Math.round(llmOutput.confidence * 100)}%):`));
|
|
1000
|
+
if (llmOutput.routesToVisit.length > 0) {
|
|
1001
|
+
console.log(chalk3.dim(" Routes to prioritise:"));
|
|
1002
|
+
for (const r of llmOutput.routesToVisit) {
|
|
1003
|
+
console.log(` ${chalk3.cyan(r)}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (llmOutput.hiddenStatesToExplore.length > 0) {
|
|
1007
|
+
console.log(chalk3.dim(" Hidden states to explore:"));
|
|
1008
|
+
for (const h of llmOutput.hiddenStatesToExplore) {
|
|
1009
|
+
console.log(` ${chalk3.cyan(h.route)} \u2192 ${chalk3.dim(h.triggerHint)}`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (llmOutput.notes.length > 0) {
|
|
1013
|
+
console.log(chalk3.dim(" Notes:"));
|
|
1014
|
+
for (const n of llmOutput.notes) console.log(` ${chalk3.dim(n)}`);
|
|
1015
|
+
}
|
|
1016
|
+
const hintsFile = path5.join(outDir, "llm-hints.json");
|
|
1017
|
+
await fs3.writeFile(hintsFile, JSON.stringify(llmOutput, null, 2), "utf-8");
|
|
1018
|
+
console.log(chalk3.dim(` Artifacts: ${path5.relative(cwd, hintsFile)}`));
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
llmSpinner.fail(chalk3.red(`LLM discovery failed: ${String(err)}`));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
} else if (!opts.runtime) {
|
|
1024
|
+
console.log(chalk3.dim(" Tip: add --llm --provider anthropic to get AI route hints"));
|
|
1025
|
+
}
|
|
1026
|
+
console.log("");
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/tml.ts
|
|
1031
|
+
import chalk4 from "chalk";
|
|
1032
|
+
import ora4 from "ora";
|
|
1033
|
+
import path6 from "path";
|
|
1034
|
+
import fs4 from "fs/promises";
|
|
1035
|
+
import { pathToFileURL as pathToFileURL3 } from "url";
|
|
1036
|
+
import { crawl as crawl3 } from "@selfcure/crawler";
|
|
1037
|
+
import { analyze as analyze2, enrichTmlWithInventory, loadInventory as loadInventory2, enrichTmlWithRuntime, loadRuntimeMap } from "@selfcure/analyzer";
|
|
1038
|
+
import { reportTml } from "@selfcure/reporter";
|
|
1039
|
+
async function resolveConfig(cwd, provided) {
|
|
1040
|
+
const names = provided ? [path6.resolve(cwd, provided)] : [path6.resolve(cwd, "selfcure.config.mjs"), path6.resolve(cwd, "selfcure.config.js")];
|
|
1041
|
+
for (const p of names) {
|
|
1042
|
+
const exists = await fs4.stat(p).then(() => true).catch(() => false);
|
|
1043
|
+
if (exists) {
|
|
1044
|
+
const mod = await import(`${pathToFileURL3(p).href}?t=${Date.now()}`);
|
|
1045
|
+
return { config: mod.default, resolved: p };
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
throw new Error(`selfcure.config.mjs not found in ${cwd}. Run selfcure init first.`);
|
|
1049
|
+
}
|
|
1050
|
+
var TML_COLORS = {
|
|
1051
|
+
0: chalk4.red,
|
|
1052
|
+
1: chalk4.yellow,
|
|
1053
|
+
2: chalk4.blue,
|
|
1054
|
+
3: chalk4.cyan,
|
|
1055
|
+
4: chalk4.green
|
|
1056
|
+
};
|
|
1057
|
+
function tmlBadge(level, label) {
|
|
1058
|
+
const color = TML_COLORS[level];
|
|
1059
|
+
return color(`[TML-${level}:${label}]`);
|
|
1060
|
+
}
|
|
1061
|
+
function tmlBadgeShort(level) {
|
|
1062
|
+
return TML_COLORS[level](`TML-${level}`);
|
|
1063
|
+
}
|
|
1064
|
+
async function runTmlAnalysis(cwd, configPath, inventoryPath, runtimeMapPath) {
|
|
1065
|
+
const { config } = await resolveConfig(cwd, configPath);
|
|
1066
|
+
const rootDir = path6.resolve(cwd, String(config["rootDir"] ?? config["projectRoot"] ?? "."));
|
|
1067
|
+
const include = config["include"] ?? ["**/*.tsx", "**/*.jsx"];
|
|
1068
|
+
const exclude = config["exclude"] ?? [];
|
|
1069
|
+
const framework = config["framework"];
|
|
1070
|
+
const components = await crawl3({ rootDir, include, exclude, framework });
|
|
1071
|
+
const results = await analyze2(components);
|
|
1072
|
+
const invFile = inventoryPath ? path6.resolve(cwd, inventoryPath) : path6.join(cwd, ".selfcure", "testid-inventory.json");
|
|
1073
|
+
const invResult = await loadInventory2(invFile).catch(() => null);
|
|
1074
|
+
if (invResult?.ok) enrichTmlWithInventory(results, invResult.inventory);
|
|
1075
|
+
const rtFile = runtimeMapPath ? path6.resolve(cwd, runtimeMapPath) : path6.join(cwd, ".selfcure", "route-map.json");
|
|
1076
|
+
const rtMap = await loadRuntimeMap(rtFile).catch(() => null);
|
|
1077
|
+
if (rtMap) enrichTmlWithRuntime(results, rtMap, invResult?.ok ? invResult.inventory : void 0);
|
|
1078
|
+
return { results, rootDir };
|
|
1079
|
+
}
|
|
1080
|
+
function printDistribution(results, cwd, minimumLevel) {
|
|
1081
|
+
const dist = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
|
|
1082
|
+
let total = 0;
|
|
1083
|
+
for (const r of results) {
|
|
1084
|
+
for (const el of r.interactiveElements) {
|
|
1085
|
+
if (!el.tml) continue;
|
|
1086
|
+
dist[el.tml.level]++;
|
|
1087
|
+
total++;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (total === 0) {
|
|
1091
|
+
console.log(chalk4.dim(" No interactive elements found."));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
console.log("");
|
|
1095
|
+
console.log(chalk4.bold("Tag Maturity Distribution"));
|
|
1096
|
+
console.log("");
|
|
1097
|
+
const LABELS = {
|
|
1098
|
+
0: "unusable",
|
|
1099
|
+
1: "fragile",
|
|
1100
|
+
2: "usable",
|
|
1101
|
+
3: "stable",
|
|
1102
|
+
4: "governed"
|
|
1103
|
+
};
|
|
1104
|
+
for (const lvl of [4, 3, 2, 1, 0]) {
|
|
1105
|
+
const cnt = dist[lvl];
|
|
1106
|
+
if (cnt === 0 && lvl > 1) continue;
|
|
1107
|
+
const bar = "\u2588".repeat(Math.round(cnt / total * 20));
|
|
1108
|
+
const pct = Math.round(cnt / total * 100);
|
|
1109
|
+
const color = TML_COLORS[lvl];
|
|
1110
|
+
const flag = lvl < minimumLevel && cnt > 0 ? chalk4.red(" \u2190 below minimum") : "";
|
|
1111
|
+
console.log(
|
|
1112
|
+
` ${color(`TML-${lvl}`)} ${LABELS[lvl].padEnd(9)} ${color(bar.padEnd(20))} ${String(cnt).padStart(4)} (${String(pct).padStart(3)}%)${flag}`
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
console.log("");
|
|
1116
|
+
console.log(chalk4.dim(` Total interactive elements: ${total}`));
|
|
1117
|
+
}
|
|
1118
|
+
function printElementList(results, cwd, minimumLevel) {
|
|
1119
|
+
const flagged = [];
|
|
1120
|
+
for (const r of results) {
|
|
1121
|
+
const rel = path6.relative(cwd, r.component.filePath);
|
|
1122
|
+
for (const el of r.interactiveElements) {
|
|
1123
|
+
if (!el.tml || el.tml.level >= minimumLevel) continue;
|
|
1124
|
+
flagged.push({
|
|
1125
|
+
filePath: r.component.filePath,
|
|
1126
|
+
rel,
|
|
1127
|
+
elType: el.type,
|
|
1128
|
+
selector: el.selector,
|
|
1129
|
+
level: el.tml.level,
|
|
1130
|
+
label: el.tml.label,
|
|
1131
|
+
changes: el.tml.requiredChanges.slice(0, 2).map((c) => c.description)
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (flagged.length === 0) {
|
|
1136
|
+
console.log(chalk4.green(` \u2713 All elements meet TML-${minimumLevel} or above.`));
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
console.log(chalk4.bold(`Elements below TML-${minimumLevel} (${flagged.length}):`));
|
|
1140
|
+
console.log("");
|
|
1141
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1142
|
+
for (const f of flagged) {
|
|
1143
|
+
if (!byFile.has(f.filePath)) byFile.set(f.filePath, []);
|
|
1144
|
+
byFile.get(f.filePath).push(f);
|
|
1145
|
+
}
|
|
1146
|
+
for (const [, items] of byFile) {
|
|
1147
|
+
console.log(chalk4.underline(items[0].rel));
|
|
1148
|
+
for (const item of items) {
|
|
1149
|
+
console.log(
|
|
1150
|
+
` ${tmlBadgeShort(item.level).padEnd(16)} ${chalk4.cyan(item.elType.padEnd(8))} ${chalk4.dim(item.selector.slice(0, 32).padEnd(32))}`
|
|
1151
|
+
);
|
|
1152
|
+
for (const change of item.changes) {
|
|
1153
|
+
console.log(` ${chalk4.dim("\u2192")} ${chalk4.dim(change)}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
console.log("");
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
function registerTmlCommands(program2) {
|
|
1160
|
+
const tml = program2.command("tml").description("Tag Maturity Level \u2014 testability governance");
|
|
1161
|
+
tml.command("report").description("Generate HTML + JSON TML report at .selfcure/tml-report.html").option("--config <path>", "Path to selfcure.config.mjs").option("--inventory <path>", "Path to testid-inventory.json").option("--out <dir>", "Output directory", ".selfcure").option("--minimum <level>", "Minimum TML level for findings (0\u20134)", "2").action(async (opts) => {
|
|
1162
|
+
const cwd = process.cwd();
|
|
1163
|
+
const minLvl = Math.max(0, Math.min(4, parseInt(opts.minimum, 10) || 2));
|
|
1164
|
+
const outDir = path6.resolve(cwd, opts.out);
|
|
1165
|
+
const spinner = ora4("Generating TML report\u2026").start();
|
|
1166
|
+
try {
|
|
1167
|
+
const { results } = await runTmlAnalysis(cwd, opts.config, opts.inventory, opts.runtimeMap ?? void 0);
|
|
1168
|
+
const summary = await reportTml(results, { outputDir: outDir, minimumLevel: minLvl });
|
|
1169
|
+
spinner.stop();
|
|
1170
|
+
console.log("");
|
|
1171
|
+
console.log(chalk4.green(` \u2713 Report written`));
|
|
1172
|
+
console.log(chalk4.dim(` ${path6.relative(cwd, path6.join(outDir, "tml-report.html"))}`));
|
|
1173
|
+
console.log(chalk4.dim(` ${path6.relative(cwd, path6.join(outDir, "tml-report.json"))}`));
|
|
1174
|
+
console.log("");
|
|
1175
|
+
console.log(chalk4.dim(` Total: ${summary.totalElements} Violations: ${summary.violations} TML \u2265 ${minLvl}`));
|
|
1176
|
+
console.log("");
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
spinner.fail(chalk4.red(String(err)));
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
tml.command("scan").description("Compute TML for all interactive elements and print a distribution summary").option("--config <path>", "Path to selfcure.config.mjs").option("--inventory <path>", "Path to testid-inventory.json (default: .selfcure/testid-inventory.json)").option("--runtime-map <path>", "Path to route-map.json from selfcure discover --runtime (default: .selfcure/route-map.json)").option("--minimum <level>", "Minimum acceptable TML level for display (0\u20134)", "2").action(async (opts) => {
|
|
1183
|
+
const cwd = process.cwd();
|
|
1184
|
+
const minLvl = Math.max(0, Math.min(4, parseInt(opts.minimum, 10) || 2));
|
|
1185
|
+
const spinner = ora4("Computing Tag Maturity Levels\u2026").start();
|
|
1186
|
+
try {
|
|
1187
|
+
const { results } = await runTmlAnalysis(cwd, opts.config, opts.inventory, opts.runtimeMap);
|
|
1188
|
+
spinner.stop();
|
|
1189
|
+
console.log("");
|
|
1190
|
+
printDistribution(results, cwd, minLvl);
|
|
1191
|
+
printElementList(results, cwd, minLvl);
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
spinner.fail(chalk4.red(String(err)));
|
|
1194
|
+
process.exit(1);
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
tml.command("audit").description("Fail (exit 1) when elements fall below the configured minimum TML level").option("--config <path>", "Path to selfcure.config.mjs").option("--inventory <path>", "Path to testid-inventory.json").option("--minimum-level <level>", "Minimum acceptable TML (default: 2)", "2").option("--critical-minimum <lvl>", "Stricter minimum for critical routes (default: same as --minimum-level)").option("--ci", "Machine-readable output; exit 1 on failures").action(async (opts) => {
|
|
1198
|
+
const cwd = process.cwd();
|
|
1199
|
+
const minLvl = Math.max(0, Math.min(4, parseInt(opts.minimumLevel, 10) || 2));
|
|
1200
|
+
const spinner = opts.ci ? null : ora4("Running TML audit\u2026").start();
|
|
1201
|
+
try {
|
|
1202
|
+
const { results } = await runTmlAnalysis(cwd, opts.config, opts.inventory, opts.runtimeMap ?? void 0);
|
|
1203
|
+
spinner?.stop();
|
|
1204
|
+
let violations = 0;
|
|
1205
|
+
let total = 0;
|
|
1206
|
+
for (const r of results) {
|
|
1207
|
+
for (const el of r.interactiveElements) {
|
|
1208
|
+
if (!el.tml) continue;
|
|
1209
|
+
total++;
|
|
1210
|
+
if (el.tml.level < minLvl) violations++;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (opts.ci) {
|
|
1214
|
+
console.log(JSON.stringify({ total, violations, minimumLevel: minLvl, passed: violations === 0 }));
|
|
1215
|
+
} else {
|
|
1216
|
+
printDistribution(results, cwd, minLvl);
|
|
1217
|
+
if (violations === 0) {
|
|
1218
|
+
console.log(chalk4.green(` \u2713 TML audit passed \u2014 all ${total} element(s) meet TML-${minLvl} or above.`));
|
|
1219
|
+
} else {
|
|
1220
|
+
printElementList(results, cwd, minLvl);
|
|
1221
|
+
console.log(chalk4.red(` \u2717 TML audit failed \u2014 ${violations}/${total} element(s) below TML-${minLvl}.`));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (violations > 0) process.exit(1);
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
spinner?.fail(chalk4.red(String(err)));
|
|
1227
|
+
process.exit(1);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/export.ts
|
|
1233
|
+
import chalk5 from "chalk";
|
|
1234
|
+
import ora5 from "ora";
|
|
1235
|
+
import path7 from "path";
|
|
1236
|
+
import fs5 from "fs/promises";
|
|
1237
|
+
import { pathToFileURL as pathToFileURL4 } from "url";
|
|
1238
|
+
import { exportSonarQube } from "@selfcure/reporter";
|
|
1239
|
+
import { extractTestIds as extractTestIds2 } from "@selfcure/crawler";
|
|
1240
|
+
import { loadInventory as loadInventory3, audit as audit2 } from "@selfcure/analyzer";
|
|
1241
|
+
var DEFAULT_OUT = ".selfcure/sonar-issues.json";
|
|
1242
|
+
var DEFAULT_TEST_GLOBS2 = ["**/*.{spec,test}.{ts,js,tsx,jsx}", "**/*.e2e.{ts,js}"];
|
|
1243
|
+
var DEFAULT_EXCLUDE3 = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/.nuxt/**"];
|
|
1244
|
+
function parseLocation(loc) {
|
|
1245
|
+
if (!loc) return {};
|
|
1246
|
+
const m = loc.match(/^(.*):(\d+)$/);
|
|
1247
|
+
if (m) return { filePath: m[1], line: Number(m[2]) };
|
|
1248
|
+
return { filePath: loc };
|
|
1249
|
+
}
|
|
1250
|
+
function registerExportCommand(program2) {
|
|
1251
|
+
program2.command("export").description("Export findings in an external tool format (SonarQube Generic Issue Import Format)").requiredOption("--format <name>", 'output format \u2014 currently only "sonarqube"').option("-c, --config <path>", "path to selfcure.config.mjs", "./selfcure.config.mjs").option("--out <path>", "output file", DEFAULT_OUT).option("--threshold <n>", "testability score below which an element is flagged", "65").option("--a11y", "include WCAG accessibility findings", false).option("--wcag <level>", "WCAG target level when using --a11y: A, AA, or AAA", "AA").option("--base-dir <dir>", "sonar.projectBaseDir \u2014 filePaths are made relative to it", "").option("--inventory <path>", "testid-inventory.json \u2014 include missing-testid (orphaned contract) issues").action(async (opts) => {
|
|
1252
|
+
if (opts.format !== "sonarqube") {
|
|
1253
|
+
console.error(chalk5.red(`Unsupported format "${opts.format}". Currently only "sonarqube" is supported.`));
|
|
1254
|
+
process.exit(1);
|
|
1255
|
+
}
|
|
1256
|
+
const spinner = ora5("Analysing source files\u2026").start();
|
|
1257
|
+
try {
|
|
1258
|
+
const configPath = path7.resolve(opts.config);
|
|
1259
|
+
const exists = await fs5.stat(configPath).then(() => true).catch(() => false);
|
|
1260
|
+
if (!exists) {
|
|
1261
|
+
spinner.fail(chalk5.red(`Config not found: ${opts.config}`));
|
|
1262
|
+
process.exit(1);
|
|
1263
|
+
}
|
|
1264
|
+
const { default: config } = await import(pathToFileURL4(configPath).href);
|
|
1265
|
+
const threshold = Number(opts.threshold ?? 65);
|
|
1266
|
+
const wcag = opts.wcag ?? "AA";
|
|
1267
|
+
const baseDir = opts.baseDir ? path7.resolve(opts.baseDir) : process.cwd();
|
|
1268
|
+
const issues = [];
|
|
1269
|
+
const summary = await runLint(config, {
|
|
1270
|
+
threshold,
|
|
1271
|
+
fix: false,
|
|
1272
|
+
pr: false,
|
|
1273
|
+
a11y: opts.a11y,
|
|
1274
|
+
wcag
|
|
1275
|
+
});
|
|
1276
|
+
for (const issue of summary.issues) {
|
|
1277
|
+
issues.push({
|
|
1278
|
+
kind: issue.kind,
|
|
1279
|
+
// 'ambiguous' | 'low-score'
|
|
1280
|
+
filePath: issue.filePath,
|
|
1281
|
+
message: issue.kind === "ambiguous" ? `Ambiguous selector "${issue.element.selector}" in ${issue.componentName} \u2014 ${(issue.ambiguityReason ?? "matches multiple elements").replace(/\.\s*$/, "")}. Add a unique data-testid="${issue.suggestedTestId}".` : `Low testability (score ${issue.element.testabilityScore}/100) for ${issue.element.type} in ${issue.componentName}. Add data-testid="${issue.suggestedTestId}".`
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
for (const f of summary.a11yFindings ?? []) {
|
|
1285
|
+
issues.push({
|
|
1286
|
+
kind: "a11y-violation",
|
|
1287
|
+
filePath: f.sourceFile,
|
|
1288
|
+
line: f.line,
|
|
1289
|
+
wcagLevel: f.level,
|
|
1290
|
+
ruleId: f.ruleId,
|
|
1291
|
+
message: `${f.message} (WCAG ${f.level}: ${f.wcag.join(", ")}). ${f.remediation}`
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
if (opts.inventory) {
|
|
1295
|
+
const inv = await loadInventory3(path7.resolve(opts.inventory));
|
|
1296
|
+
if (!inv.ok) {
|
|
1297
|
+
spinner.warn(chalk5.yellow(`Skipping --inventory: failed to load ${opts.inventory}`));
|
|
1298
|
+
} else {
|
|
1299
|
+
const rootDir = config.rootDir ? path7.resolve(config.rootDir) : process.cwd();
|
|
1300
|
+
const { usages } = await extractTestIds2({
|
|
1301
|
+
rootDir,
|
|
1302
|
+
sourceGlobs: config.include ?? ["**/*.{tsx,jsx,ts,js,vue,html}"],
|
|
1303
|
+
testGlobs: DEFAULT_TEST_GLOBS2,
|
|
1304
|
+
exclude: config.exclude ?? DEFAULT_EXCLUDE3
|
|
1305
|
+
});
|
|
1306
|
+
const { issues: auditIssues } = audit2(inv.inventory, usages);
|
|
1307
|
+
for (const ai of auditIssues.filter((i) => i.rule === "orphaned-inventory")) {
|
|
1308
|
+
const loc = parseLocation(ai.locations?.[0]);
|
|
1309
|
+
issues.push({
|
|
1310
|
+
kind: "missing-testid",
|
|
1311
|
+
filePath: loc.filePath ?? path7.resolve(opts.inventory),
|
|
1312
|
+
line: loc.line,
|
|
1313
|
+
message: ai.message ?? `data-testid="${ai.testId}" is in the inventory but missing from source.`
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const report = await exportSonarQube(issues, opts.out, { projectBaseDir: baseDir });
|
|
1319
|
+
spinner.stop();
|
|
1320
|
+
console.log("");
|
|
1321
|
+
console.log(chalk5.green.bold(`\u2714 SonarQube report written \u2014 ${report.issues.length} issue(s)`));
|
|
1322
|
+
console.log(chalk5.dim(` ${path7.relative(process.cwd(), path7.resolve(opts.out))}`));
|
|
1323
|
+
console.log("");
|
|
1324
|
+
console.log(chalk5.dim(" Point SonarQube at it with:"));
|
|
1325
|
+
console.log(chalk5.dim(` sonar.externalIssuesReportPaths=${opts.out}`));
|
|
1326
|
+
console.log("");
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
spinner.fail(chalk5.red(String(err)));
|
|
1329
|
+
process.exit(1);
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/index.ts
|
|
1335
|
+
import { startWebServer } from "@selfcure/web";
|
|
1336
|
+
import { crawl as crawl4 } from "@selfcure/crawler";
|
|
1337
|
+
import { buildIdePrompt } from "@selfcure/analyzer";
|
|
1338
|
+
async function resolveConfigPath(provided) {
|
|
1339
|
+
const DEFAULT = "./selfcure.config.mjs";
|
|
1340
|
+
const LEGACY = "./selfcure.config.js";
|
|
1341
|
+
if (provided !== DEFAULT) {
|
|
1342
|
+
return path8.resolve(provided);
|
|
1343
|
+
}
|
|
1344
|
+
const mjs = path8.resolve(DEFAULT);
|
|
1345
|
+
const js = path8.resolve(LEGACY);
|
|
1346
|
+
const mjsExists = await fs6.stat(mjs).then(() => true).catch(() => false);
|
|
1347
|
+
if (mjsExists) return mjs;
|
|
1348
|
+
const jsExists = await fs6.stat(js).then(() => true).catch(() => false);
|
|
1349
|
+
if (jsExists) return js;
|
|
1350
|
+
return mjs;
|
|
1351
|
+
}
|
|
1352
|
+
var program = new Command();
|
|
1353
|
+
program.name("selfcure").description("AI-powered self-healing Playwright test CLI").version("0.1.0");
|
|
1354
|
+
program.command("init").description("Scaffold selfcure.config.mjs in the current project").action(async () => {
|
|
1355
|
+
try {
|
|
1356
|
+
await runInitWizard(process.cwd());
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
console.error(chalk6.red(String(err)));
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
program.command("crawl [file]").description("Crawl source files and extract component metadata").option("-c, --config <path>", "path to selfcure.config.mjs (falls back to selfcure.config.js)", "./selfcure.config.mjs").action(async (file, opts) => {
|
|
1363
|
+
const spinner = ora6("Crawling source files\u2026").start();
|
|
1364
|
+
try {
|
|
1365
|
+
const configPath = await resolveConfigPath(opts.config);
|
|
1366
|
+
const configUrl = pathToFileURL5(configPath).href;
|
|
1367
|
+
const { default: config } = await import(configUrl);
|
|
1368
|
+
let components;
|
|
1369
|
+
if (file) {
|
|
1370
|
+
const abs = path8.resolve(file);
|
|
1371
|
+
const stat2 = await fs6.stat(abs).catch(() => null);
|
|
1372
|
+
if (!stat2) {
|
|
1373
|
+
throw new Error(`Path not found: ${file}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (stat2.isDirectory()) {
|
|
1376
|
+
components = await crawl4({
|
|
1377
|
+
rootDir: abs,
|
|
1378
|
+
include: config.include,
|
|
1379
|
+
exclude: config.exclude,
|
|
1380
|
+
framework: config.framework
|
|
1381
|
+
});
|
|
1382
|
+
} else {
|
|
1383
|
+
components = await crawl4({
|
|
1384
|
+
rootDir: path8.dirname(abs),
|
|
1385
|
+
include: [path8.basename(abs)],
|
|
1386
|
+
exclude: [],
|
|
1387
|
+
framework: config.framework
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
} else {
|
|
1391
|
+
components = await crawl4({
|
|
1392
|
+
rootDir: config.rootDir,
|
|
1393
|
+
include: config.include,
|
|
1394
|
+
exclude: config.exclude,
|
|
1395
|
+
framework: config.framework
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
spinner.succeed(chalk6.green(`Crawl complete \u2014 ${components.length} component(s) found`));
|
|
1399
|
+
for (const c of components) {
|
|
1400
|
+
const propsInfo = c.props.length ? chalk6.dim(` (${c.props.length} prop${c.props.length > 1 ? "s" : ""}: ${c.props.map((p) => p.name).join(", ")})`) : "";
|
|
1401
|
+
console.log(chalk6.dim(` ${c.framework} ${chalk6.white(c.componentName)} \u2192 ${c.filePath}${propsInfo}`));
|
|
1402
|
+
}
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
spinner.fail(chalk6.red(String(err)));
|
|
1405
|
+
process.exit(1);
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
program.command("run").description("Generate tests, run them, and self-heal failures automatically").option("-c, --config <path>", "path to selfcure.config.mjs (falls back to selfcure.config.js)", "./selfcure.config.mjs").action(async (_opts) => {
|
|
1409
|
+
const spinner = ora6("Running selfcure pipeline\u2026").start();
|
|
1410
|
+
try {
|
|
1411
|
+
spinner.succeed(chalk6.green("Pipeline complete"));
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
spinner.fail(chalk6.red(String(err)));
|
|
1414
|
+
process.exit(1);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
program.command("heal").description("Attempt to heal failing tests without re-generating the full suite").option("-c, --config <path>", "path to selfcure.config.mjs (falls back to selfcure.config.js)", "./selfcure.config.mjs").action(async (_opts) => {
|
|
1418
|
+
const spinner = ora6("Healing failing tests\u2026").start();
|
|
1419
|
+
try {
|
|
1420
|
+
spinner.succeed(chalk6.green("Healing complete"));
|
|
1421
|
+
} catch (err) {
|
|
1422
|
+
spinner.fail(chalk6.red(String(err)));
|
|
1423
|
+
process.exit(1);
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
program.command("report").description("Generate HTML report from the last run results").option("-c, --config <path>", "path to selfcure.config.mjs (falls back to selfcure.config.js)", "./selfcure.config.mjs").action(async (_opts) => {
|
|
1427
|
+
const spinner = ora6("Generating report\u2026").start();
|
|
1428
|
+
try {
|
|
1429
|
+
spinner.succeed(chalk6.green("Report generated"));
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
spinner.fail(chalk6.red(String(err)));
|
|
1432
|
+
process.exit(1);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
program.command("lint").description("[Pro] Lint source files for unstable test selectors and suggest data-testid patches").option("-c, --config <path>", "path to selfcure.config.mjs (falls back to selfcure.config.js)", "./selfcure.config.mjs").option("--threshold <n>", "testability score below which an element is flagged", "65").option("--fix", "[Pro] apply data-testid patches to source files automatically").option("--pr", "[Pro] create a GitHub PR with the applied fixes (requires --fix)").option("--a11y", "[Pro] also run WCAG accessibility lint alongside testability").option("--wcag <level>", "WCAG target level when using --a11y: A, AA, or AAA", "AA").option("--prompt", "print a paste-ready prompt for your IDE AI agent (Copilot/Cursor) instead of the report \u2014 no API key needed").action(async (opts) => {
|
|
1436
|
+
const spinner = ora6("Analysing source files\u2026").start();
|
|
1437
|
+
try {
|
|
1438
|
+
const configPath = await resolveConfigPath(opts.config);
|
|
1439
|
+
const configUrl = pathToFileURL5(configPath).href;
|
|
1440
|
+
const { default: config } = await import(configUrl);
|
|
1441
|
+
const threshold = Number(opts.threshold ?? 65);
|
|
1442
|
+
const isPro = config.pro === true || process.env["SELFCURE_PRO"] === "1";
|
|
1443
|
+
if ((opts.fix || opts.pr) && !isPro) {
|
|
1444
|
+
spinner.stop();
|
|
1445
|
+
console.log("");
|
|
1446
|
+
console.log(chalk6.bold.yellow("\u2726 Pro feature"));
|
|
1447
|
+
console.log(chalk6.dim(" --fix and --pr are available on the Pro plan and above."));
|
|
1448
|
+
console.log(chalk6.dim(" Enable Pro by setting SELFCURE_PRO=1 or pro: true in your config."));
|
|
1449
|
+
console.log("");
|
|
1450
|
+
}
|
|
1451
|
+
const fix = opts.fix && isPro;
|
|
1452
|
+
const pr = opts.pr && isPro && fix;
|
|
1453
|
+
const a11y = Boolean(opts.a11y);
|
|
1454
|
+
const summary = await runLint(config, { threshold, fix, pr, a11y, wcag: opts.wcag });
|
|
1455
|
+
spinner.stop();
|
|
1456
|
+
const { issues, totalFiles, fixedCount, skippedCount, prUrl, a11yFindings } = summary;
|
|
1457
|
+
if (opts.prompt) {
|
|
1458
|
+
if (issues.length === 0) {
|
|
1459
|
+
console.error(chalk6.green(`No issues \u2014 nothing to fix (${totalFiles} file(s) scanned).`));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
const promptIssues = issues.map((i) => ({
|
|
1463
|
+
filePath: i.filePath,
|
|
1464
|
+
componentName: i.componentName,
|
|
1465
|
+
elementType: i.element.type,
|
|
1466
|
+
selector: i.element.selector,
|
|
1467
|
+
kind: i.kind,
|
|
1468
|
+
ambiguityReason: i.ambiguityReason,
|
|
1469
|
+
suggestedTestId: i.suggestedTestId
|
|
1470
|
+
}));
|
|
1471
|
+
console.log(buildIdePrompt(promptIssues, { baseDir: process.cwd() }));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
console.log("");
|
|
1475
|
+
if (issues.length === 0 && (!a11yFindings || a11yFindings.length === 0)) {
|
|
1476
|
+
console.log(chalk6.green.bold("\u2714 selfcure lint \u2014 no issues found"));
|
|
1477
|
+
console.log(chalk6.dim(` ${totalFiles} file(s) scanned \u2014 all elements have a testability score \u2265 ${threshold}`));
|
|
1478
|
+
if (a11yFindings) {
|
|
1479
|
+
console.log(chalk6.dim(` WCAG ${opts.wcag ?? "AA"} \u2014 0 accessibility issues`));
|
|
1480
|
+
}
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
console.log(
|
|
1484
|
+
chalk6.bold(`selfcure lint \u2014 ${chalk6.yellow(issues.length)} issue(s) across `) + chalk6.bold(`${[...new Set(issues.map((i) => i.filePath))].length} file(s)`) + chalk6.dim(` \xB7 threshold: ${threshold}/100`)
|
|
1485
|
+
);
|
|
1486
|
+
console.log("");
|
|
1487
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1488
|
+
for (const issue of issues) {
|
|
1489
|
+
if (!byFile.has(issue.filePath)) byFile.set(issue.filePath, []);
|
|
1490
|
+
byFile.get(issue.filePath).push(issue);
|
|
1491
|
+
}
|
|
1492
|
+
for (const [filePath, fileIssues] of byFile) {
|
|
1493
|
+
console.log(chalk6.underline(path8.relative(process.cwd(), filePath)));
|
|
1494
|
+
for (const issue of fileIssues) {
|
|
1495
|
+
const score = issue.element.testabilityScore;
|
|
1496
|
+
const scoreTxt = score >= 50 ? chalk6.yellow(`score: ${score}`) : chalk6.red(`score: ${score}`);
|
|
1497
|
+
const fixTxt = issue.fixApplied ? chalk6.green(" \u2714 patched") : chalk6.dim(` \u2192 add data-testid="${issue.suggestedTestId}"`);
|
|
1498
|
+
const tml = issue.element.tml;
|
|
1499
|
+
const tmlTxt = tml ? " " + tmlBadge(tml.level, tml.label) : "";
|
|
1500
|
+
console.log(
|
|
1501
|
+
` ${chalk6.cyan(issue.element.type.padEnd(8))} ${chalk6.dim(issue.element.selector.padEnd(30))} ${scoreTxt}${tmlTxt}` + fixTxt
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
console.log("");
|
|
1505
|
+
}
|
|
1506
|
+
if (a11yFindings && a11yFindings.length > 0) {
|
|
1507
|
+
console.log(chalk6.dim("\u2500".repeat(60)));
|
|
1508
|
+
console.log("");
|
|
1509
|
+
printA11ySection(a11yFindings, {
|
|
1510
|
+
isPro,
|
|
1511
|
+
wcagLevel: opts.wcag ?? "AA",
|
|
1512
|
+
cwd: process.cwd()
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
if (fix) {
|
|
1516
|
+
if (fixedCount > 0) {
|
|
1517
|
+
console.log(chalk6.green(`\u2714 ${fixedCount} element(s) patched`) + (skippedCount > 0 ? chalk6.dim(` \xB7 ${skippedCount} skipped (no unique identifier found)`) : ""));
|
|
1518
|
+
}
|
|
1519
|
+
if (prUrl) {
|
|
1520
|
+
console.log(chalk6.bold.green(`\u2714 PR opened: ${prUrl}`));
|
|
1521
|
+
} else if (pr) {
|
|
1522
|
+
console.log(chalk6.yellow(" PR creation skipped (no files changed)."));
|
|
1523
|
+
}
|
|
1524
|
+
} else if (!isPro) {
|
|
1525
|
+
console.log(chalk6.bold.yellow("\u2726 Run with --fix to auto-patch source files (Pro)"));
|
|
1526
|
+
console.log(chalk6.bold.yellow("\u2726 Run with --fix --pr to open a GitHub PR (Pro)"));
|
|
1527
|
+
}
|
|
1528
|
+
console.log("");
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
spinner.fail(chalk6.red(String(err)));
|
|
1531
|
+
process.exit(1);
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
program.command("mcp").description("Start the selfcure MCP server on stdio (consumable by Claude Desktop, Cursor, VS Code, etc.)").action(async () => {
|
|
1535
|
+
await import("@selfcure/mcp");
|
|
1536
|
+
});
|
|
1537
|
+
program.command("web").description("Open the selfcure dashboard in the browser (zero-config: discover, crawl, lint, TML \u2014 all from the UI)").option("-p, --port <number>", "port to listen on", "3333").option("--no-open", "don't auto-open the browser").action((opts) => {
|
|
1538
|
+
const port = Number(opts.port);
|
|
1539
|
+
startWebServer(port, process.cwd(), { openBrowser: opts.open !== false });
|
|
1540
|
+
});
|
|
1541
|
+
program.command("stop").description("Kill any selfcure web server running on the given port").option("-p, --port <number>", "port to kill", "3333").action((opts) => {
|
|
1542
|
+
const port = Number(opts.port);
|
|
1543
|
+
try {
|
|
1544
|
+
if (process.platform === "win32") {
|
|
1545
|
+
execSync3(
|
|
1546
|
+
`powershell -NonInteractive -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | Sort-Object -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }"`,
|
|
1547
|
+
{ stdio: "inherit" }
|
|
1548
|
+
);
|
|
1549
|
+
} else {
|
|
1550
|
+
execSync3(`lsof -ti :${port} | xargs kill -9`, { stdio: "inherit" });
|
|
1551
|
+
}
|
|
1552
|
+
console.log(chalk6.green(`Port ${port} cleared.`));
|
|
1553
|
+
} catch {
|
|
1554
|
+
console.log(chalk6.yellow(`No process found on port ${port}.`));
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
registerDiscoverCommand(program);
|
|
1558
|
+
registerTmlCommands(program);
|
|
1559
|
+
registerTestIdsCommands(program);
|
|
1560
|
+
registerA11yCommands(program);
|
|
1561
|
+
registerExportCommand(program);
|
|
1562
|
+
program.parse();
|