mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
49309b43d3
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>
179 lines
5.5 KiB
JavaScript
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);
|
|
});
|