Files
govoplan/tools/checks/check-heading-help.mjs
T
zemion 2ffdb23f69
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m30s
feat(devkit): add resumable workspace automation and UI review tooling
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:17 +02:00

162 lines
9.4 KiB
JavaScript
Executable File

#!/usr/bin/env node
/** UI-01: contextual documentation belongs beside text, never in action slots. */
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const workspaceRoot = resolve(import.meta.dirname, "../../..");
const require = createRequire(resolve(workspaceRoot, "govoplan-core/webui/package.json"));
const ts = require("typescript");
const titleOwners = new Set(["PageLayout", "PageHeader", "PageTitle", "AdminPageLayout", "Card", "Dialog", "PageActionBar", "WorkspaceActionBar"]);
const interactiveOwners = new Set(["a", "button", "Button", "IconButton"]);
// Existing domain dialog adapter; its owning structural tests must retain
// forwarding to Core Dialog.titleHelp (not a module-local heading definition).
const domainTitleOwners = new Map([["FileDialog", "/govoplan-files/webui/src/"]]);
export function findDetachedDocumentation(sources) {
const files = new Map(sources.map(({ path, source }) => [resolve(path),
ts.createSourceFile(resolve(path), source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)]));
const options = { noEmit: true, noResolve: true, noLib: true, types: [], jsx: ts.JsxEmit.Preserve };
const host = ts.createCompilerHost(options);
host.getSourceFile = (path) => files.get(resolve(path));
const checker = ts.createProgram([...files.keys()], options, host).getTypeChecker();
const findings = [];
let links = 0;
for (const [path, source] of files) {
const identifiers = [];
const helpNodes = [];
function importedName(node) {
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
const namespace = checker.getSymbolAtLocation(node.expression)?.declarations?.find(ts.isNamespaceImport);
if (namespace) return node.name.text;
}
const declarations = checker.getSymbolAtLocation(node)?.declarations ?? [];
const declaration = declarations.find(ts.isImportSpecifier);
if (declaration) return (declaration.propertyName ?? declaration.name).text;
const defaultImport = declarations.find(ts.isImportClause);
if (defaultImport && ts.isStringLiteral(defaultImport.parent.moduleSpecifier)) {
const component = defaultImport.parent.moduleSpecifier.text.split("/").at(-1).replace(/\.[cm]?[jt]sx?$/, "");
if (component === "DocumentationHelpLink" || titleOwners.has(component) || component === "TextWithHelp" || interactiveOwners.has(component)) return component;
}
return node.getText(source);
}
function tag(node) {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return opening ? importedName(opening.tagName) : null;
}
function collect(node) {
if (ts.isIdentifier(node)) identifiers.push(node);
if ((ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) && tag(node) === "DocumentationHelpLink") helpNodes.push(node);
ts.forEachChild(node, collect);
}
collect(source);
function staticallyHidden(opening) {
const hidden = opening.attributes.properties.find((attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === "hidden");
if (!hidden) return false;
if (!hidden.initializer || ts.isStringLiteral(hidden.initializer)) return true;
return ts.isJsxExpression(hidden.initializer) && hidden.initializer.expression?.kind === ts.SyntaxKind.TrueKeyword;
}
// Reject text that is definitely absent while preserving dynamic translated
// titles and components whose rendered text cannot be established statically.
function emptyText(node, seen = new Set()) {
if (!node) return true;
if (ts.isJsxText(node)) return !node.getText(source).trim();
if (ts.isJsxExpression(node)) return emptyText(node.expression, seen);
if (ts.isStringLiteralLike(node)) return !node.text.trim();
if ([ts.SyntaxKind.NullKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.TrueKeyword].includes(node.kind) || ts.isVoidExpression(node)) return true;
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node)) return emptyText(node.expression, seen);
if (ts.isIdentifier(node)) {
const symbol = checker.getSymbolAtLocation(node);
const declaration = symbol?.declarations?.find(ts.isVariableDeclaration);
const immutable = declaration && ts.isVariableDeclarationList(declaration.parent) && Boolean(declaration.parent.flags & ts.NodeFlags.Const);
if (immutable && declaration.initializer && !seen.has(symbol)) return emptyText(declaration.initializer, new Set(seen).add(symbol));
return node.text === "undefined" && !symbol?.declarations?.length;
}
if (ts.isConditionalExpression(node)) return emptyText(node.whenTrue, seen) && emptyText(node.whenFalse, seen);
if (ts.isJsxFragment(node)) return node.children.every((child) => emptyText(child, seen));
if (ts.isArrayLiteralExpression(node)) return node.elements.every((child) => emptyText(child, seen));
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
const opening = ts.isJsxElement(node) ? node.openingElement : node;
if (staticallyHidden(opening)) return true;
if (/^[a-z]/.test(opening.tagName.getText(source))) return !ts.isJsxElement(node) || node.children.every((child) => emptyText(child, seen));
}
return false;
}
function isAnchored(node, seen = new Set()) {
// Check the complete rendered ancestry before returning at a recognized
// slot: the entire heading/text contract may itself be inside a button.
for (let parent = node.parent; parent; parent = parent.parent) {
if (interactiveOwners.has(tag(parent))) return false;
const opening = ts.isJsxElement(parent) ? parent.openingElement : ts.isJsxSelfClosingElement(parent) ? parent : null;
if (opening && staticallyHidden(opening)) return false;
}
for (let parent = node.parent; parent; parent = parent.parent) {
if (ts.isJsxAttribute(parent)) {
const owner = parent.parent.parent;
const name = importedName(owner.tagName);
const slot = parent.name.getText(source);
if (slot === "titleHelp" && (titleOwners.has(name) || (domainTitleOwners.has(name) && path.includes(domainTitleOwners.get(name))))) {
if (name === "PageTitle") {
const element = ts.isJsxOpeningElement(owner) ? owner.parent : null;
return Boolean(element?.children.some((child) => !emptyText(child)));
}
const title = owner.attributes.properties.find((attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === "title");
return Boolean(title && !emptyText(title.initializer));
}
if (slot === "help" && name === "TextWithHelp") {
const element = ts.isJsxOpeningElement(owner) ? owner.parent : null;
return Boolean(element?.children.some((child) => !emptyText(child)));
}
return false;
}
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
const symbol = checker.getSymbolAtLocation(parent.name);
if (!symbol || seen.has(symbol)) return false;
const next = new Set(seen).add(symbol);
const references = identifiers.filter((identifier) => identifier !== parent.name && checker.getSymbolAtLocation(identifier) === symbol);
return references.length > 0 && references.every((reference) => isAnchored(reference, next));
}
}
return false;
}
for (const node of helpNodes) {
links += 1;
// FieldLabel is the central label+book implementation; its browser/component
// contract verifies sibling text and prevents nested interactive controls.
if (path.endsWith("/govoplan-core/webui/src/components/help/FieldLabel.tsx")) continue;
if (!isAnchored(node)) {
const position = source.getLineAndCharacterOfPosition(node.getStart(source));
findings.push({ path, line: position.line + 1, column: position.character + 1 });
}
}
}
return { findings, links };
}
function sourceFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".tsx") ? [path] : [];
});
}
export function checkWorkspace(root = workspaceRoot) {
const modules = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan"))
.map((entry) => resolve(root, entry.name, "webui/src")).filter(existsSync);
const paths = modules.flatMap(sourceFiles);
const { findings, links } = findDetachedDocumentation(paths.map((path) => ({ path, source: readFileSync(path, "utf8") })));
for (const finding of findings) {
console.error(`${relative(root, finding.path)}:${finding.line}:${finding.column}: UI-01 documentation must use a heading's titleHelp or TextWithHelp beside visible text, not an action slot or detached row.`);
}
if (!findings.length) console.log(`Heading-help contract passed: ${links} documentation links in ${paths.length} TSX files across ${modules.length} WebUI modules.`);
return findings.length ? 1 : 0;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = checkWorkspace();