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.
162 lines
9.4 KiB
JavaScript
Executable File
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();
|