Files
prowler/ui/scripts/check-tour-alignment.mjs
T
Alan Buscaglia 49309b43d3 feat(ui): UI onboarding system (#11430)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-15 13:53:48 +02:00

179 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
// Tour alignment check (syntactic). Extracts `data-tour-id` values from every
// `ui/lib/tours/*.tour.ts` and verifies a matching attribute exists under `ui/`,
// in either the JSX form (`data-tour-id="..."`) or the object-property form
// (`"data-tour-id": "..."`) used for dynamically-spread anchors. Two directions:
// - Tour → DOM: fails on any tour `target` with no matching attribute.
// - DOM → tour: warns on any `data-tour-id` not referenced by any tour
// (does not fail — staged anchors during multi-PR rollouts are OK).
// Complements the semantic `prowler-tour` skill for CI/local runs without
// invoking Claude Code.
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { join, dirname, relative, resolve } from "node:path";
const UI_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const TOURS_DIR = join(UI_DIR, "lib", "tours");
const TOUR_FILE_SUFFIX = ".tour.ts";
const EXCLUDED_DIRS = new Set([
"node_modules",
".next",
"dist",
"build",
"coverage",
]);
async function readDirEntries(dir) {
try {
return await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === "ENOENT") return [];
throw err;
}
}
async function findTourFiles(dir) {
const entries = await readDirEntries(dir);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(TOUR_FILE_SUFFIX))
.map((entry) => join(dir, entry.name));
}
const TOUR_ID_PATTERN = /\bid\s*:\s*["']([a-z0-9-]+)["']/m;
const TARGET_PATTERN = /\btarget\s*:\s*["']([a-z0-9-]+)["']/g;
async function parseTour(filePath) {
const source = await readFile(filePath, "utf8");
const idMatch = source.match(TOUR_ID_PATTERN);
if (!idMatch) {
throw new Error(
`${relative(UI_DIR, filePath)}: cannot find tour 'id' — expected a literal like \`id: "attack-paths"\``,
);
}
const tourId = idMatch[1];
const targets = [];
for (const match of source.matchAll(TARGET_PATTERN)) {
targets.push(match[1]);
}
return {
file: relative(UI_DIR, filePath),
tourId,
selectors: targets.map((target) => `${tourId}-${target}`),
};
}
async function* walk(dir) {
const entries = await readDirEntries(dir);
for (const entry of entries) {
if (EXCLUDED_DIRS.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.isFile()) {
yield full;
}
}
}
const ATTRIBUTE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
async function collectAttributeValues() {
const values = new Map();
for await (const file of walk(UI_DIR)) {
if (!ATTRIBUTE_EXTENSIONS.some((ext) => file.endsWith(ext))) continue;
if (file.endsWith(TOUR_FILE_SUFFIX)) continue;
const source = await readFile(file, "utf8");
// Match both the JSX attribute form (`data-tour-id="x"`) and the
// object-property form (`"data-tour-id": "x"`) used when an anchor is spread
// dynamically — e.g. a table's per-row getRowAttributes returning the id.
const pattern = /["']?data-tour-id["']?\s*[=:]\s*["']([a-z0-9-]+)["']/g;
for (const match of source.matchAll(pattern)) {
const value = match[1];
const existing = values.get(value);
if (existing) {
existing.push(file);
} else {
values.set(value, [file]);
}
}
}
return values;
}
async function main() {
const tourFiles = await findTourFiles(TOURS_DIR);
if (tourFiles.length === 0) {
console.log("No tour files under ui/lib/tours/ — nothing to check.");
return;
}
const tours = await Promise.all(tourFiles.map(parseTour));
const attrValues = await collectAttributeValues();
const tourOrphans = [];
for (const tour of tours) {
for (const selector of tour.selectors) {
if (!attrValues.has(selector)) {
tourOrphans.push({ tour: tour.file, selector });
}
}
}
const referencedSelectors = new Set(tours.flatMap((t) => t.selectors));
const domOrphans = [];
for (const [value, files] of attrValues) {
if (referencedSelectors.has(value)) continue;
domOrphans.push({
value,
files: [...files].sort((a, b) => a.localeCompare(b)),
});
}
domOrphans.sort((a, b) => a.value.localeCompare(b.value));
tourOrphans.sort(
(a, b) =>
a.tour.localeCompare(b.tour) || a.selector.localeCompare(b.selector),
);
if (domOrphans.length > 0) {
console.warn(
`⚠ ${domOrphans.length} data-tour-id attribute(s) not referenced by any tour:`,
);
for (const orphan of domOrphans) {
const locations = orphan.files
.map((file) => relative(UI_DIR, file))
.join(", ");
console.warn(` data-tour-id="${orphan.value}" — ${locations}`);
}
console.warn(
" Wire it into a tour step or remove the attribute (warning only, does not fail).\n",
);
}
if (tourOrphans.length === 0) {
const referenced = tours.reduce((sum, t) => sum + t.selectors.length, 0);
console.log(
`✓ Tour alignment OK — ${tours.length} tour(s), ${referenced} anchored step(s).`,
);
return;
}
console.error("✗ Tour alignment failed.\n");
for (const orphan of tourOrphans) {
console.error(
` ${orphan.tour} references data-tour-id="${orphan.selector}" but no element carries that attribute.`,
);
}
console.error(
"\nFix the drift before merging: add the attribute, fix the tour's `target`, or remove the orphan step.",
);
process.exit(1);
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});