Ctrl K
ComunidadGuías…

frontend/scripts/check-layering.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// frontend/scripts/check-layering.mjs
//
// Enforces the semantic overlay layering policy introduced by PR #20012.
// Feature code must not create global z-index overlays directly. Use shared UI
// primitives or portal into the semantic layer roots instead.
//
// This is a conservative policy scanner, not a complete TypeScript evaluator.
// It resolves direct string literals, unique static identifiers, simple static
// string concatenations, named createPortal imports, and simple
// document/document.body aliases. Complex, dynamic, imported, or shadowed
// values may be unresolved; passing this check does not permit bypassing the
// policy.

import { readFileSync, readdirSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// typescript 7 (the native compiler) no longer ships the JS compiler API,
// so this scanner parses with the pinned typescript 6 alias instead.
import ts from "typescript-6";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const SOURCE_DIR = resolve(ROOT, "src");
const REPORT_ONLY = process.argv.includes("--report-only");

const APPROVED_PREFIXES = [
  "src/components/ui/",
  "src/modules/agent/",
];

const APPROVED_FILES = new Set([
  "src/app/components/SessionExpiredSurface.tsx",
]);

const CLASS_ATTR_PATTERN = /\bclass(Name)?\s*=\s*/g;

const findFiles = (dir) => {
  const files = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const full = resolve(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...findFiles(full));
    } else if (/\.(ts|tsx)$/.test(entry.name)) {
      files.push(full);
    }
  }
  return files;
};

const isApprovedPath = (path) =>
  APPROVED_FILES.has(path) ||
  APPROVED_PREFIXES.some((prefix) => path.startsWith(prefix));

const hasInlineZIndex = (line) => /\bzIndex\s*:/.test(line);

const getRawOverlayViolation = (block) => {
  const fixedMatch = block.match(/\bfixed\b/);
  const absoluteMatch = block.match(/\babsolute\b/);
  const zMatches = [...block.matchAll(/\bz-auto\b|\bz-(\d+)\b|\bz-\[([^\]]+)\]/g)];
  if (zMatches.length === 0 || (!fixedMatch && !absoluteMatch)) {
    return null;
  }

  const isAbsoluteGlobal = zMatches.some((zMatch) => {
    if (zMatch[2] !== undefined) {
      return true;
    }
    const value = Number(zMatch[1]);
    return Number.isFinite(value) && value >= 40;
  });
  if (!fixedMatch && !isAbsoluteGlobal) {
    return null;
  }

  const tokenMatch = fixedMatch ?? absoluteMatch ?? zMatches.find((zMatch) => {
    if (isRawZToken(zMatch)) {
      return true;
    }
    const value = Number(zMatch[1]);
    return Number.isFinite(value) && value >= 40;
  });

  return {
    reason: fixedMatch
      ? "feature-owned fixed overlay uses raw z-index"
      : "feature-owned absolute overlay uses high raw z-index",
    index: tokenMatch?.index ?? 0,
  };
};

const buildLineStarts = (source) => {
  const lineStarts = [0];
  for (let index = source.indexOf("\n"); index !== -1; index = source.indexOf("\n", index + 1)) {
    lineStarts.push(index + 1);
  }
  return lineStarts;
};

const getLineNumber = (lineStarts, index) => {
  let low = 0;
  let high = lineStarts.length - 1;
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (lineStarts[mid] <= index) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return high + 1;
};

const skipWhitespace = (source, startIndex) => {
  let index = startIndex;
  while (index < source.length && /\s/.test(source[index])) {
    index++;
  }
  return index;
};

const scanQuotedExpression = (source, index, quote) => {
  const start = index;
  let current = index + 1;
  let escaped = false;
  while (current < source.length) {
    const ch = source[current];
    if (escaped) {
      escaped = false;
    } else if (ch === "\\") {
      escaped = true;
    } else if (ch === quote) {
      return { start, end: current + 1 };
    }
    current++;
  }
  return { start, end: source.length };
};

const scanBareExpression = (source, index) => {
  let current = index;
  while (current < source.length && !/[\s>]/.test(source[current])) {
    current++;
  }
  return { start: index, end: current };
};

const isStringQuote = (ch) => ch === "\"" || ch === "'" || ch === "`";

const scanStringState = (ch, stringQuote, escaped) => {
  if (escaped) {
    return { stringQuote, escaped: false };
  }
  if (ch === "\\") {
    return { stringQuote, escaped: true };
  }
  if (ch === stringQuote) {
    return { stringQuote: null, escaped: false };
  }
  return { stringQuote, escaped: false };
};

const getNextBraceDepth = (ch, depth) => {
  if (ch === "{") {
    return depth + 1;
  }
  if (ch === "}") {
    return depth - 1;
  }
  return depth;
};

const scanBalancedExpression = (source, index) => {
  let current = index + 1;
  let depth = 1;
  let stringQuote = null;
  let escaped = false;
  while (current < source.length) {
    const ch = source[current];
    if (stringQuote !== null) {
      ({ stringQuote, escaped } = scanStringState(ch, stringQuote, escaped));
      current++;
      continue;
    }

    if (isStringQuote(ch)) {
      stringQuote = ch;
      current++;
      continue;
    }
    depth = getNextBraceDepth(ch, depth);
    if (depth === 0) {
      return { start: index, end: current + 1 };
    }
    current++;
  }
  return { start: index, end: source.length };
};

const extractClassExpression = (source, startIndex) => {
  const index = skipWhitespace(source, startIndex);
  if (index >= source.length) {
    return null;
  }

  const startChar = source[index];
  if (startChar === "\"" || startChar === "'" || startChar === "`") {
    return scanQuotedExpression(source, index, startChar);
  }
  if (startChar === "{") {
    return scanBalancedExpression(source, index);
  }
  return scanBareExpression(source, index);
};

const isRawZToken = (zMatch) =>
  zMatch[0] === "z-auto" ||
  zMatch[2] !== undefined ||
  (zMatch[1] !== undefined && Number.isFinite(Number(zMatch[1])));

const scanClassExpressions = (source, rel, lines) => {
  const violations = [];
  const lineStarts = buildLineStarts(source);

  CLASS_ATTR_PATTERN.lastIndex = 0;
  let match;
  while ((match = CLASS_ATTR_PATTERN.exec(source))) {
    const expr = extractClassExpression(source, match.index + match[0].length);
    if (!expr) {
      continue;
    }

    const block = source.slice(expr.start, expr.end);
    const violation = getRawOverlayViolation(block);
    if (!violation) {
      continue;
    }

    const tokenIndex = expr.start + violation.index;
    const lineNumber = getLineNumber(lineStarts, tokenIndex);
    violations.push({
      rel,
      lineNumber,
      reason: violation.reason,
      line: lines[lineNumber - 1] ?? "",
    });
  }

  return violations;
};

const createSourceFile = (source, rel) =>
  ts.createSourceFile(
    rel,
    source,
    ts.ScriptTarget.Latest,
    true,
    rel.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
  );

const getUniqueStaticString = (staticStrings, name) => {
  const entry = staticStrings.get(name);
  if (!entry || entry.count !== 1) {
    return null;
  }
  return entry.value;
};

const getDirectStaticString = (expression) => {
  const current = skipExpressionWrappers(expression);
  if (ts.isStringLiteral(current) || ts.isNoSubstitutionTemplateLiteral(current)) {
    return current.text;
  }
  return null;
};

const getStaticString = (expression, staticStrings = new Map()) => {
  const current = skipExpressionWrappers(expression);
  const directValue = getDirectStaticString(current);
  if (directValue !== null) {
    return directValue;
  }
  if (ts.isIdentifier(current)) {
    return getUniqueStaticString(staticStrings, current.text);
  }
  if (
    ts.isBinaryExpression(current) &&
    current.operatorToken.kind === ts.SyntaxKind.PlusToken
  ) {
    const left = getStaticString(current.left, staticStrings);
    const right = getStaticString(current.right, staticStrings);
    if (left !== null && right !== null) {
      return `${left}${right}`;
    }
  }
  return null;
};

const recordStaticString = (staticStrings, name, value) => {
  const current = staticStrings.get(name);
  staticStrings.set(name, {
    count: (current?.count ?? 0) + 1,
    value: current ? null : value,
  });
};

const collectStaticStrings = (sourceFile) => {
  const staticStrings = new Map();

  const visit = (node) => {
    if (
      ts.isVariableDeclaration(node) &&
      ts.isIdentifier(node.name)
    ) {
      recordStaticString(
        staticStrings,
        node.name.text,
        node.initializer ? getStaticString(node.initializer, staticStrings) : null
      );
    } else if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
      recordStaticString(staticStrings, node.name.text, null);
    }
    ts.forEachChild(node, visit);
  };

  visit(sourceFile);
  return staticStrings;
};

const getTemplateParts = (sourceFile, node, staticStrings) => {
  const headPart = {
    text: node.head.text,
    start: node.head.getStart(sourceFile) + 1,
  };

  const spanParts = node.templateSpans.flatMap((span) => {
    const expressionValue = getStaticString(span.expression, staticStrings);
    const literalPart = {
      text: span.literal.text,
      start: span.literal.getStart(sourceFile) + 1,
    };
    if (expressionValue === null) {
      return [literalPart];
    }
    return [
      {
        text: expressionValue,
        start: span.expression.getStart(sourceFile),
      },
      literalPart,
    ];
  });

  return [
    headPart,
    ...spanParts,
  ];
};

const combineTemplateParts = (parts, fallbackIndex) => {
  const placeholder = " ";
  const combinedLineIndexes = [];
  const combinedText = parts
    .map((part) => part.text)
    .join(placeholder);

  let combinedOffset = 0;
  parts.forEach((part, partIndex) => {
    for (let index = 0; index < part.text.length; index++) {
      combinedLineIndexes[combinedOffset + index] = part.start + index;
    }
    combinedOffset += part.text.length;
    if (partIndex < parts.length - 1) {
      combinedLineIndexes[combinedOffset] = part.start;
      combinedOffset += placeholder.length;
    }
  });

  return {
    text: combinedText,
    getAbsoluteIndex: (index) => combinedLineIndexes[index] ?? fallbackIndex,
  };
};

const scanTemplateExpression = (sourceFile, node, staticStrings, scanText) => {
  const combined = combineTemplateParts(
    getTemplateParts(sourceFile, node, staticStrings),
    node.getStart(sourceFile)
  );
  const violation = getRawOverlayViolation(combined.text);
  if (violation) {
    scanText(combined.text, node.getStart(sourceFile), combined.getAbsoluteIndex);
  }
};

const scanStringLiteralNode = (sourceFile, node, scanText) => {
  scanText(node.text, node.getStart(sourceFile) + 1);
};

const scanLiteralNode = (sourceFile, node, staticStrings, scanText) => {
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
    scanStringLiteralNode(sourceFile, node, scanText);
    return;
  }
  if (ts.isTemplateExpression(node)) {
    scanTemplateExpression(sourceFile, node, staticStrings, scanText);
  }
};

const scanStringLiterals = (sourceFile, rel, lines) => {
  const violations = [];
  const staticStrings = collectStaticStrings(sourceFile);

  const scanText = (
    text,
    startIndex,
    getAbsoluteIndex = (index) => startIndex + index
  ) => {
    const violation = getRawOverlayViolation(text);
    if (!violation) {
      return;
    }

    const lineNumber =
      sourceFile.getLineAndCharacterOfPosition(
        getAbsoluteIndex(violation.index)
      ).line + 1;
    violations.push({
      rel,
      lineNumber,
      reason: violation.reason,
      line: lines[lineNumber - 1] ?? "",
    });
  };

  const visit = (node) => {
    if (ts.isVariableDeclaration(node) && node.initializer) {
      const staticValue = getStaticString(node.initializer, staticStrings);
      if (staticValue !== null) {
        scanText(staticValue, node.initializer.getStart(sourceFile));
      }
    }
    scanLiteralNode(sourceFile, node, staticStrings, scanText);
    ts.forEachChild(node, visit);
  };

  visit(sourceFile);
  return violations;
};

const skipExpressionWrappers = (expression) => {
  let current = expression;
  while (
    ts.isParenthesizedExpression(current) ||
    ts.isAsExpression(current) ||
    ts.isNonNullExpression(current) ||
    ts.isTypeAssertionExpression(current)
  ) {
    current = current.expression;
  }
  return current;
};

const isDocumentExpression = (expression, documentAliases) => {
  const current = skipExpressionWrappers(expression);
  return (
    ts.isIdentifier(current) &&
    (current.text === "document" || documentAliases.has(current.text))
  );
};

const isDocumentBodyExpression = (
  expression,
  documentAliases,
  documentBodyAliases
) => {
  const current = skipExpressionWrappers(expression);
  if (ts.isIdentifier(current)) {
    return documentBodyAliases.has(current.text);
  }
  return (
    ts.isPropertyAccessExpression(current) &&
    current.name.text === "body" &&
    isDocumentExpression(current.expression, documentAliases)
  );
};

const collectCreatePortalAliases = (sourceFile) => {
  const aliases = new Set(["createPortal"]);

  const visit = (node) => {
    if (
      ts.isImportDeclaration(node) &&
      ts.isStringLiteral(node.moduleSpecifier) &&
      node.moduleSpecifier.text === "react-dom" &&
      node.importClause?.namedBindings &&
      ts.isNamedImports(node.importClause.namedBindings)
    ) {
      node.importClause.namedBindings.elements.forEach((element) => {
        const importedName = element.propertyName?.text ?? element.name.text;
        if (importedName === "createPortal") {
          aliases.add(element.name.text);
        }
      });
    }

    ts.forEachChild(node, visit);
  };

  visit(sourceFile);
  return aliases;
};

const isCreatePortalCall = (node, createPortalAliases) => {
  const expression = skipExpressionWrappers(node.expression);
  return (
    (ts.isIdentifier(expression) && createPortalAliases.has(expression.text)) ||
    (ts.isPropertyAccessExpression(expression) &&
      expression.name.text === "createPortal")
  );
};

const recordDocumentAliases = (
  declaration,
  documentAliases,
  documentBodyAliases
) => {
  if (!declaration.initializer) {
    return;
  }

  if (ts.isIdentifier(declaration.name)) {
    if (isDocumentExpression(declaration.initializer, documentAliases)) {
      documentAliases.add(declaration.name.text);
    }
    if (
      isDocumentBodyExpression(
        declaration.initializer,
        documentAliases,
        documentBodyAliases
      )
    ) {
      documentBodyAliases.add(declaration.name.text);
    }
    return;
  }

  if (
    !ts.isObjectBindingPattern(declaration.name) ||
    !isDocumentExpression(declaration.initializer, documentAliases)
  ) {
    return;
  }

  declaration.name.elements.forEach((element) => {
    if (!ts.isIdentifier(element.name)) {
      return;
    }
    const propertyName = element.propertyName;
    const sourceName =
      propertyName && ts.isIdentifier(propertyName)
        ? propertyName.text
        : element.name.text;
    if (sourceName === "body") {
      documentBodyAliases.add(element.name.text);
    }
  });
};

const scanPortalTargets = (sourceFile, rel, lines) => {
  const violations = [];
  const createPortalAliases = collectCreatePortalAliases(sourceFile);
  const documentAliases = new Set();
  const documentBodyAliases = new Set();

  const visit = (node) => {
    if (ts.isVariableDeclaration(node)) {
      recordDocumentAliases(node, documentAliases, documentBodyAliases);
    }

    if (
      ts.isCallExpression(node) &&
      isCreatePortalCall(node, createPortalAliases) &&
      node.arguments[1] &&
      isDocumentBodyExpression(
        node.arguments[1],
        documentAliases,
        documentBodyAliases
      )
    ) {
      const lineNumber =
        sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
          .line + 1;
      violations.push({
        rel,
        lineNumber,
        reason: "feature-owned portal targets document.body directly",
        line: lines[lineNumber - 1] ?? "",
      });
    }

    ts.forEachChild(node, visit);
  };

  visit(sourceFile);
  return violations;
};

const dedupeViolations = (violations) => {
  const seen = new Set();
  return violations.filter((violation) => {
    const key = `${violation.rel}:${violation.lineNumber}:${violation.reason}`;
    if (seen.has(key)) {
      return false;
    }
    seen.add(key);
    return true;
  });
};

const scanInlineZIndex = (rel, lines) =>
  lines.flatMap((line, index) => {
    if (!hasInlineZIndex(line)) {
      return [];
    }
    return [
      {
        rel,
        lineNumber: index + 1,
        reason: "inline zIndex bypasses semantic layer ownership",
        line,
      },
    ];
  });

export const scanSource = (source, rel) => {
  if (isApprovedPath(rel)) {
    return [];
  }

  const violations = [];
  const lines = source.split("\n");
  const sourceFile = createSourceFile(source, rel);
  violations.push(
    ...scanClassExpressions(source, rel, lines),
    ...scanStringLiterals(sourceFile, rel, lines),
    ...scanInlineZIndex(rel, lines),
    ...scanPortalTargets(sourceFile, rel, lines)
  );

  return dedupeViolations(violations);
};

export const scanFile = (file) => {
  const rel = relative(ROOT, file);
  const source = readFileSync(file, "utf-8");
  return scanSource(source, rel);
};

export const scanReactLayering = () => findFiles(SOURCE_DIR).flatMap(scanFile);

const main = () => {
  const violations = scanReactLayering();

  if (violations.length > 0) {
    console.error(
      `Layering policy violations (${violations.length}). ` +
        "Use shared overlay primitives or getLayerRoot(\"overlay\").\n"
    );
    for (const violation of violations) {
      console.error(
        `${violation.rel}:${violation.lineNumber}: ${violation.reason}\n` +
          `  ${violation.line.trim()}\n`
      );
    }
    if (!REPORT_ONLY) {
      process.exit(1);
    }
  }

  console.log(
    violations.length === 0
      ? "Layering policy: all checks passed."
      : "Layering policy: report-only mode completed with violations."
  );
};

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  main();
}