#!/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();