Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
772 lines
23 KiB
JavaScript
Executable File
772 lines
23 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Domain DAG project validator
|
|
* Domains: source graph, architecture validation, command-line diagnostics
|
|
* Validates local import acyclicity, composition-root boundaries, shared-bucket drift, headers, and optional configured rules
|
|
*/
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import {
|
|
basename,
|
|
dirname,
|
|
extname,
|
|
join,
|
|
relative,
|
|
resolve,
|
|
sep,
|
|
} from "node:path";
|
|
|
|
const DEFAULT_CONFIG = {
|
|
sourceRoots: ["src", "lib"],
|
|
entrypoints: [
|
|
"index.ts",
|
|
"index.tsx",
|
|
"index.js",
|
|
"index.mjs",
|
|
"src/index.ts",
|
|
"src/index.tsx",
|
|
"src/main.ts",
|
|
"src/main.tsx",
|
|
"lib/index.ts",
|
|
],
|
|
sourceExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".svelte"],
|
|
ignoreDirs: [
|
|
".git",
|
|
".hg",
|
|
".svn",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
".next",
|
|
".nuxt",
|
|
"target",
|
|
"vendor",
|
|
],
|
|
excludePatterns: [
|
|
"**/*.d.ts",
|
|
"**/*.test.ts",
|
|
"**/*.test.tsx",
|
|
"**/*.test.js",
|
|
"**/*.spec.ts",
|
|
"**/*.spec.tsx",
|
|
"**/*.spec.js",
|
|
"**/__tests__/**",
|
|
],
|
|
importAliases: {},
|
|
requireHeaders: true,
|
|
headerPattern: "\\b(?:Domain|Domains|Zone|Zones|Owns)" + ":\\s*\\S",
|
|
headerSeverity: "warn",
|
|
headerRequiredClauses: [],
|
|
headerRequiredClausesSeverity: "warn",
|
|
flatRoots: false,
|
|
flatRootSeverity: "warn",
|
|
sharedBucketNames: [
|
|
"types",
|
|
"constants",
|
|
"utils",
|
|
"helpers",
|
|
"shared",
|
|
"common",
|
|
],
|
|
allowedSharedBuckets: [],
|
|
sharedBucketSeverity: "warn",
|
|
forbiddenEdges: [],
|
|
layers: [],
|
|
surfaceRules: [],
|
|
};
|
|
const DEFAULT_CONFIG_NAMES = ["domain-dag.json", ".domain-dag.json"];
|
|
const MAX_LISTED_ITEMS = 30;
|
|
|
|
function toPosixPath(path) {
|
|
return path.split(sep).join("/");
|
|
}
|
|
|
|
function escapeRegExp(text) {
|
|
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
}
|
|
|
|
function globToRegExp(glob) {
|
|
const pattern = toPosixPath(glob);
|
|
let output = "^";
|
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
const char = pattern[index];
|
|
if (
|
|
char === "*" &&
|
|
pattern[index + 1] === "*" &&
|
|
pattern[index + 2] === "/"
|
|
) {
|
|
output += "(?:.*/)?";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (char === "*" && pattern[index + 1] === "*") {
|
|
output += ".*";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === "*") {
|
|
output += "[^/]*";
|
|
continue;
|
|
}
|
|
if (char === "?") {
|
|
output += "[^/]";
|
|
continue;
|
|
}
|
|
output += escapeRegExp(char);
|
|
}
|
|
return new RegExp(`${output}$`);
|
|
}
|
|
|
|
function matchesAnyGlob(path, patterns) {
|
|
const normalized = toPosixPath(path);
|
|
return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
json: false,
|
|
strict: false,
|
|
root: undefined,
|
|
config: undefined,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--json") {
|
|
args.json = true;
|
|
continue;
|
|
}
|
|
if (arg === "--strict") {
|
|
args.strict = true;
|
|
continue;
|
|
}
|
|
if (arg === "--root") {
|
|
args.root = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--config") {
|
|
args.config = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--help" || arg === "-h") {
|
|
args.help = true;
|
|
continue;
|
|
}
|
|
if (!arg.startsWith("-") && !args.root) {
|
|
args.root = arg;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(
|
|
[
|
|
"Domain DAG validator",
|
|
"",
|
|
"Usage:",
|
|
" validate-domain-dag.sh [--root <path>] [--config <path>] [--strict] [--json]",
|
|
"",
|
|
"Checks local source import cycles, composition-root reach-through, shared buckets, headers, and configured layer/edge rules.",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function readJsonFile(path) {
|
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
}
|
|
|
|
function findConfigPath(root, explicitConfig) {
|
|
if (explicitConfig) return resolve(root, explicitConfig);
|
|
for (const name of DEFAULT_CONFIG_NAMES) {
|
|
const candidate = join(root, name);
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function loadConfig(root, explicitConfig) {
|
|
const configPath = findConfigPath(root, explicitConfig);
|
|
if (!configPath)
|
|
return {
|
|
config: { ...DEFAULT_CONFIG },
|
|
configPath: undefined,
|
|
};
|
|
const loadedConfig = readJsonFile(configPath);
|
|
return { config: { ...DEFAULT_CONFIG, ...loadedConfig }, configPath };
|
|
}
|
|
|
|
function createReporter({ json, strict }) {
|
|
const state = {
|
|
errors: 0,
|
|
warnings: 0,
|
|
items: [],
|
|
};
|
|
const color = (code, text) =>
|
|
json || process.env.NO_COLOR || !process.stdout.isTTY
|
|
? text
|
|
: `\x1b[${code}m${text}\x1b[0m`;
|
|
const emit = (level, message, details = {}) => {
|
|
const effectiveLevel = strict && level === "warn" ? "error" : level;
|
|
if (effectiveLevel === "error") state.errors += 1;
|
|
if (effectiveLevel === "warn") state.warnings += 1;
|
|
state.items.push({ level: effectiveLevel, message, details });
|
|
if (json) return;
|
|
const label =
|
|
effectiveLevel === "error"
|
|
? color("0;31", "[FAIL]")
|
|
: effectiveLevel === "warn"
|
|
? color("1;33", "[WARN]")
|
|
: effectiveLevel === "pass"
|
|
? color("0;32", "[PASS]")
|
|
: color("0;36", "[INFO]");
|
|
console.log(`${label} ${message}`);
|
|
};
|
|
return {
|
|
state,
|
|
info: (message, details) => emit("info", message, details),
|
|
pass: (message, details) => emit("pass", message, details),
|
|
warn: (message, details) => emit("warn", message, details),
|
|
error: (message, details) => emit("error", message, details),
|
|
bySeverity: (severity, message, details) => {
|
|
if (severity === "off") return;
|
|
if (severity === "error") emit("error", message, details);
|
|
else emit("warn", message, details);
|
|
},
|
|
};
|
|
}
|
|
|
|
function isIgnoredDirectory(name, config) {
|
|
return config.ignoreDirs.includes(name);
|
|
}
|
|
|
|
function isSourceFile(path, root, config) {
|
|
const relativePath = toPosixPath(relative(root, path));
|
|
const extension = extname(path);
|
|
if (!config.sourceExtensions.includes(extension)) return false;
|
|
if (relativePath.endsWith(".d.ts")) return false;
|
|
return !matchesAnyGlob(relativePath, config.excludePatterns);
|
|
}
|
|
|
|
function walkSourceFiles(directory, root, config, files) {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const absolutePath = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!isIgnoredDirectory(entry.name, config))
|
|
walkSourceFiles(absolutePath, root, config, files);
|
|
continue;
|
|
}
|
|
if (entry.isFile() && isSourceFile(absolutePath, root, config))
|
|
files.add(resolve(absolutePath));
|
|
}
|
|
}
|
|
|
|
function collectSourceFiles(root, config) {
|
|
const files = new Set();
|
|
for (const sourceRoot of config.sourceRoots) {
|
|
const absoluteRoot = resolve(root, sourceRoot);
|
|
if (!existsSync(absoluteRoot)) continue;
|
|
const stats = statSync(absoluteRoot);
|
|
if (stats.isFile() && isSourceFile(absoluteRoot, root, config))
|
|
files.add(resolve(absoluteRoot));
|
|
if (stats.isDirectory()) walkSourceFiles(absoluteRoot, root, config, files);
|
|
}
|
|
for (const entrypoint of config.entrypoints) {
|
|
const absolutePath = resolve(root, entrypoint);
|
|
if (
|
|
existsSync(absolutePath) &&
|
|
statSync(absolutePath).isFile() &&
|
|
isSourceFile(absolutePath, root, config)
|
|
)
|
|
files.add(resolve(absolutePath));
|
|
}
|
|
return [...files].sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function stripSourceComments(source) {
|
|
return source
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
.replace(/(^|[^:])\/\/[^\n\r]*/gm, "$1");
|
|
}
|
|
|
|
function getImportSpecifiers(source) {
|
|
const cleaned = stripSourceComments(source);
|
|
const specifiers = new Set();
|
|
const patterns = [
|
|
/(import|export)\s+(type\s+)?[^"']*?\s+from\s*["']([^"']+)["']/g,
|
|
/import\s*["']([^"']+)["']/g,
|
|
/import\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
/require\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
];
|
|
for (const pattern of patterns) {
|
|
for (const match of cleaned.matchAll(pattern)) {
|
|
specifiers.add(match[match.length - 1]);
|
|
}
|
|
}
|
|
return [...specifiers].filter(Boolean);
|
|
}
|
|
|
|
function getImportResolutionCandidates(basePath, config) {
|
|
const candidates = [basePath];
|
|
for (const extension of config.sourceExtensions)
|
|
candidates.push(`${basePath}${extension}`);
|
|
for (const extension of config.sourceExtensions)
|
|
candidates.push(join(basePath, `index${extension}`));
|
|
return candidates;
|
|
}
|
|
|
|
function getImportAliasBase(root, specifier, config) {
|
|
const aliases = config.importAliases ?? {};
|
|
for (const [aliasPattern, targetPattern] of Object.entries(aliases)) {
|
|
if (aliasPattern.endsWith("/*") && targetPattern.endsWith("/*")) {
|
|
const aliasPrefix = aliasPattern.slice(0, -1);
|
|
if (specifier.startsWith(aliasPrefix)) {
|
|
const suffix = specifier.slice(aliasPrefix.length);
|
|
return resolve(root, targetPattern.slice(0, -1), suffix);
|
|
}
|
|
continue;
|
|
}
|
|
if (specifier === aliasPattern) return resolve(root, targetPattern);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveLocalImport(fromFile, specifier, fileSet, config, root) {
|
|
const basePath = specifier.startsWith(".")
|
|
? resolve(dirname(fromFile), specifier)
|
|
: getImportAliasBase(root, specifier, config);
|
|
if (!basePath) return undefined;
|
|
for (const candidate of getImportResolutionCandidates(basePath, config)) {
|
|
const normalized = resolve(candidate);
|
|
if (fileSet.has(normalized)) return normalized;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function buildImportGraph(root, files, config) {
|
|
const fileSet = new Set(files.map((file) => resolve(file)));
|
|
const graph = new Map();
|
|
for (const file of files) {
|
|
const deps = [];
|
|
const source = readFileSync(file, "utf8");
|
|
for (const specifier of getImportSpecifiers(source)) {
|
|
const resolved = resolveLocalImport(
|
|
file,
|
|
specifier,
|
|
fileSet,
|
|
config,
|
|
root,
|
|
);
|
|
if (resolved) deps.push(toPosixPath(relative(root, resolved)));
|
|
}
|
|
graph.set(toPosixPath(relative(root, file)), [...new Set(deps)].sort());
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
function findCycles(graph) {
|
|
const cycles = [];
|
|
const visited = new Set();
|
|
const active = [];
|
|
const visit = (file) => {
|
|
const activeIndex = active.indexOf(file);
|
|
if (activeIndex !== -1) {
|
|
cycles.push([...active.slice(activeIndex), file]);
|
|
return;
|
|
}
|
|
if (visited.has(file)) return;
|
|
visited.add(file);
|
|
active.push(file);
|
|
for (const dep of graph.get(file) ?? []) visit(dep);
|
|
active.pop();
|
|
};
|
|
for (const file of graph.keys()) visit(file);
|
|
return cycles;
|
|
}
|
|
|
|
function getEntrypointSet(root, files, config) {
|
|
const fileSet = new Set(files.map((file) => resolve(file)));
|
|
const entrypoints = new Set();
|
|
for (const entrypoint of config.entrypoints) {
|
|
const absolutePath = resolve(root, entrypoint);
|
|
if (fileSet.has(absolutePath))
|
|
entrypoints.add(toPosixPath(relative(root, absolutePath)));
|
|
}
|
|
return entrypoints;
|
|
}
|
|
|
|
function validateEntrypointReachThrough(graph, entrypoints, reporter) {
|
|
const violations = [];
|
|
for (const [file, deps] of graph.entries()) {
|
|
if (entrypoints.has(file)) continue;
|
|
for (const dep of deps)
|
|
if (entrypoints.has(dep)) violations.push(`${file} -> ${dep}`);
|
|
}
|
|
if (violations.length === 0) {
|
|
reporter.pass("Domain modules do not import configured entrypoints");
|
|
return;
|
|
}
|
|
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.error(`Domain module imports composition root: ${violation}`);
|
|
if (violations.length > MAX_LISTED_ITEMS)
|
|
reporter.error(
|
|
`Additional entrypoint reach-through violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function validateHeaders(root, files, config, reporter) {
|
|
if (!config.requireHeaders) return;
|
|
const pattern = new RegExp(config.headerPattern, "im");
|
|
const missing = [];
|
|
const missingClauses = [];
|
|
const requiredClauses = Array.isArray(config.headerRequiredClauses)
|
|
? config.headerRequiredClauses
|
|
: [];
|
|
for (const file of files) {
|
|
const relativePath = toPosixPath(relative(root, file));
|
|
const sourceHead = readFileSync(file, "utf8").slice(0, 1600);
|
|
const hasHeader = pattern.test(sourceHead);
|
|
if (!hasHeader) {
|
|
missing.push(relativePath);
|
|
continue;
|
|
}
|
|
for (const clause of requiredClauses) {
|
|
if (clause && !sourceHead.includes(clause))
|
|
missingClauses.push(`${relativePath} missing '${clause}'`);
|
|
}
|
|
}
|
|
if (missing.length === 0) reporter.pass("Domain headers are present");
|
|
else {
|
|
for (const file of missing.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.bySeverity(
|
|
config.headerSeverity,
|
|
`Missing domain header: ${file}`,
|
|
);
|
|
if (missing.length > MAX_LISTED_ITEMS)
|
|
reporter.bySeverity(
|
|
config.headerSeverity,
|
|
`Additional files without domain headers: ${missing.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
if (requiredClauses.length === 0) return;
|
|
if (missingClauses.length === 0) {
|
|
reporter.pass("Required domain-header clauses are present");
|
|
return;
|
|
}
|
|
for (const item of missingClauses.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.bySeverity(
|
|
config.headerRequiredClausesSeverity,
|
|
`Domain header clause missing: ${item}`,
|
|
);
|
|
if (missingClauses.length > MAX_LISTED_ITEMS)
|
|
reporter.bySeverity(
|
|
config.headerRequiredClausesSeverity,
|
|
`Additional missing domain-header clauses: ${missingClauses.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function validateSharedBuckets(root, files, config, reporter) {
|
|
const violations = [];
|
|
const names = new Set(config.sharedBucketNames);
|
|
for (const file of files) {
|
|
const relativePath = toPosixPath(relative(root, file));
|
|
if (matchesAnyGlob(relativePath, config.allowedSharedBuckets)) continue;
|
|
const parts = relativePath.split("/");
|
|
const baseName = basename(relativePath, extname(relativePath));
|
|
if (names.has(baseName)) violations.push(`file ${relativePath}`);
|
|
for (const part of parts.slice(0, -1))
|
|
if (names.has(part)) violations.push(`folder ${part} in ${relativePath}`);
|
|
}
|
|
if (violations.length === 0) {
|
|
reporter.pass("No shared-bucket candidates found");
|
|
return;
|
|
}
|
|
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.bySeverity(
|
|
config.sharedBucketSeverity,
|
|
`Shared-bucket candidate: ${violation}`,
|
|
);
|
|
if (violations.length > MAX_LISTED_ITEMS)
|
|
reporter.bySeverity(
|
|
config.sharedBucketSeverity,
|
|
`Additional shared-bucket candidates: ${violations.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function validateFlatRoots(root, files, config, reporter) {
|
|
if (!config.flatRoots) return;
|
|
const violations = [];
|
|
const sourceRoots = config.sourceRoots
|
|
.map((sourceRoot) => resolve(root, sourceRoot))
|
|
.filter(
|
|
(sourceRoot) =>
|
|
existsSync(sourceRoot) && statSync(sourceRoot).isDirectory(),
|
|
);
|
|
for (const file of files) {
|
|
for (const sourceRoot of sourceRoots) {
|
|
const inside = relative(sourceRoot, file);
|
|
if (
|
|
inside.startsWith("..") ||
|
|
resolve(sourceRoot, inside) !== resolve(file)
|
|
)
|
|
continue;
|
|
if (toPosixPath(inside).includes("/"))
|
|
violations.push(toPosixPath(relative(root, file)));
|
|
}
|
|
}
|
|
if (violations.length === 0) {
|
|
reporter.pass("Configured flat roots have no nested source files");
|
|
return;
|
|
}
|
|
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.bySeverity(
|
|
config.flatRootSeverity,
|
|
`Nested source file in flat root: ${violation}`,
|
|
);
|
|
if (violations.length > MAX_LISTED_ITEMS)
|
|
reporter.bySeverity(
|
|
config.flatRootSeverity,
|
|
`Additional nested flat-root files: ${violations.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function getLayerForFile(file, layers) {
|
|
for (let index = 0; index < layers.length; index += 1) {
|
|
const layer = layers[index];
|
|
const rank = Number.isFinite(layer.rank) ? layer.rank : index;
|
|
if (matchesAnyGlob(file, layer.files ?? []))
|
|
return { name: layer.name ?? `layer-${index}`, rank };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function validateLayers(graph, config, reporter) {
|
|
if (!Array.isArray(config.layers) || config.layers.length === 0) return;
|
|
const violations = [];
|
|
for (const [file, deps] of graph.entries()) {
|
|
const fromLayer = getLayerForFile(file, config.layers);
|
|
if (!fromLayer) continue;
|
|
for (const dep of deps) {
|
|
const toLayer = getLayerForFile(dep, config.layers);
|
|
if (toLayer && fromLayer.rank < toLayer.rank)
|
|
violations.push(
|
|
`${file} (${fromLayer.name}) -> ${dep} (${toLayer.name})`,
|
|
);
|
|
}
|
|
}
|
|
if (violations.length === 0) {
|
|
reporter.pass("Configured layer direction holds");
|
|
return;
|
|
}
|
|
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.error(`Layer direction violation: ${violation}`);
|
|
if (violations.length > MAX_LISTED_ITEMS)
|
|
reporter.error(
|
|
`Additional layer violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function collectConfiguredGlobs(config) {
|
|
const globs = [];
|
|
const add = (owner, value) => {
|
|
if (!value) return;
|
|
const values = Array.isArray(value) ? value : [value];
|
|
for (const item of values) {
|
|
if (typeof item === "string") globs.push({ owner, value: item });
|
|
}
|
|
};
|
|
add("sourceRoots", config.sourceRoots);
|
|
add("entrypoints", config.entrypoints);
|
|
add("allowedSharedBuckets", config.allowedSharedBuckets);
|
|
for (const edge of config.forbiddenEdges ?? []) {
|
|
add("forbiddenEdges.from", edge.from);
|
|
add("forbiddenEdges.to", edge.to);
|
|
}
|
|
for (const layer of config.layers ?? [])
|
|
add(`layers.${layer.name ?? "unnamed"}`, layer.files);
|
|
for (const rule of config.surfaceRules ?? [])
|
|
add(`surfaceRules.${rule.name ?? "unnamed"}`, rule.files);
|
|
return globs;
|
|
}
|
|
|
|
function validateConfigSyntax(config, reporter) {
|
|
const unsupportedBraceGlobs = collectConfiguredGlobs(config).filter(
|
|
({ value }) => /[{}]/.test(value),
|
|
);
|
|
for (const glob of unsupportedBraceGlobs.slice(0, MAX_LISTED_ITEMS)) {
|
|
reporter.warn(
|
|
`Unsupported brace glob syntax in ${glob.owner}: ${glob.value}; expand it into separate glob entries`,
|
|
);
|
|
}
|
|
if (unsupportedBraceGlobs.length > MAX_LISTED_ITEMS)
|
|
reporter.warn(
|
|
`Additional unsupported brace globs: ${unsupportedBraceGlobs.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function validateSurfaceRules(root, files, config, reporter) {
|
|
if (!Array.isArray(config.surfaceRules) || config.surfaceRules.length === 0)
|
|
return;
|
|
for (const rule of config.surfaceRules) {
|
|
const max = Number.isFinite(rule.max) ? rule.max : undefined;
|
|
if (max === undefined) continue;
|
|
const filesGlob = Array.isArray(rule.files)
|
|
? rule.files
|
|
: rule.files
|
|
? [rule.files]
|
|
: ["**/*"];
|
|
const violations = [];
|
|
for (const file of files) {
|
|
const relativePath = toPosixPath(relative(root, file));
|
|
if (!matchesAnyGlob(relativePath, filesGlob)) continue;
|
|
const source = readFileSync(file, "utf8");
|
|
let count = 0;
|
|
if (rule.metric === "lines") {
|
|
count = source.split(/\r?\n/).length;
|
|
} else {
|
|
if (!rule.pattern) continue;
|
|
const pattern = new RegExp(rule.pattern, rule.flags ?? "g");
|
|
const matches = new Set();
|
|
for (const match of source.matchAll(pattern)) {
|
|
matches.add(match[1] ?? match[0]);
|
|
}
|
|
count = matches.size;
|
|
}
|
|
if (count > max)
|
|
violations.push(`${relativePath} measured ${count}/${max}`);
|
|
}
|
|
if (violations.length === 0) {
|
|
reporter.pass(
|
|
rule.passMessage ??
|
|
`Surface rule holds: ${rule.name ?? rule.pattern ?? rule.metric}`,
|
|
);
|
|
continue;
|
|
}
|
|
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.bySeverity(
|
|
rule.severity ?? "warn",
|
|
`${rule.message ?? "Wide interface surface"}: ${violation}`,
|
|
);
|
|
if (violations.length > MAX_LISTED_ITEMS)
|
|
reporter.bySeverity(
|
|
rule.severity ?? "warn",
|
|
`Additional surface-rule violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateForbiddenEdges(graph, config, reporter) {
|
|
if (
|
|
!Array.isArray(config.forbiddenEdges) ||
|
|
config.forbiddenEdges.length === 0
|
|
)
|
|
return;
|
|
const violations = [];
|
|
for (const [file, deps] of graph.entries()) {
|
|
for (const dep of deps) {
|
|
for (const rule of config.forbiddenEdges) {
|
|
const fromMatches =
|
|
!rule.from ||
|
|
matchesAnyGlob(
|
|
file,
|
|
Array.isArray(rule.from) ? rule.from : [rule.from],
|
|
);
|
|
const toMatches =
|
|
!rule.to ||
|
|
matchesAnyGlob(dep, Array.isArray(rule.to) ? rule.to : [rule.to]);
|
|
if (fromMatches && toMatches)
|
|
violations.push({
|
|
severity: rule.severity ?? "error",
|
|
message: rule.message ?? "Forbidden edge",
|
|
edge: `${file} -> ${dep}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (violations.length === 0) {
|
|
reporter.pass("Configured forbidden edges are absent");
|
|
return;
|
|
}
|
|
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.bySeverity(
|
|
violation.severity,
|
|
`${violation.message}: ${violation.edge}`,
|
|
);
|
|
if (violations.length > MAX_LISTED_ITEMS)
|
|
reporter.error(
|
|
`Additional forbidden-edge violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
}
|
|
|
|
function countEdges(graph) {
|
|
let count = 0;
|
|
for (const deps of graph.values()) count += deps.length;
|
|
return count;
|
|
}
|
|
|
|
function runValidation(args) {
|
|
const root = resolve(
|
|
args.root ?? process.env.DOMAIN_DAG_ROOT ?? process.cwd(),
|
|
);
|
|
const { config, configPath } = loadConfig(root, args.config);
|
|
const reporter = createReporter(args);
|
|
const files = collectSourceFiles(root, config);
|
|
const graph = buildImportGraph(root, files, config);
|
|
const cycles = findCycles(graph);
|
|
if (!args.json) console.log("--- DOMAIN DAG VALIDATOR ---\n");
|
|
reporter.info(`Root: ${root}`);
|
|
if (configPath) reporter.info(`Config: ${configPath}`);
|
|
validateConfigSyntax(config, reporter);
|
|
reporter.info(
|
|
`Source files ${files.length}, local edges ${countEdges(graph)}`,
|
|
);
|
|
if (files.length === 0)
|
|
reporter.warn("No source files found in configured roots");
|
|
if (cycles.length === 0)
|
|
reporter.pass("Local source import graph is acyclic");
|
|
for (const cycle of cycles.slice(0, MAX_LISTED_ITEMS))
|
|
reporter.error(`Import cycle: ${cycle.join(" -> ")}`);
|
|
if (cycles.length > MAX_LISTED_ITEMS)
|
|
reporter.error(
|
|
`Additional import cycles: ${cycles.length - MAX_LISTED_ITEMS}`,
|
|
);
|
|
validateEntrypointReachThrough(
|
|
graph,
|
|
getEntrypointSet(root, files, config),
|
|
reporter,
|
|
);
|
|
validateHeaders(root, files, config, reporter);
|
|
validateSharedBuckets(root, files, config, reporter);
|
|
validateFlatRoots(root, files, config, reporter);
|
|
validateLayers(graph, config, reporter);
|
|
validateSurfaceRules(root, files, config, reporter);
|
|
validateForbiddenEdges(graph, config, reporter);
|
|
if (args.json) console.log(JSON.stringify(reporter.state, null, 2));
|
|
else
|
|
console.log(
|
|
`\nResult: ${reporter.state.errors} error(s), ${reporter.state.warnings} warning(s)`,
|
|
);
|
|
return reporter.state.errors === 0 ? 0 : 1;
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
printHelp();
|
|
return 0;
|
|
}
|
|
return runValidation(args);
|
|
}
|
|
|
|
try {
|
|
process.exitCode = main();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`RUNTIME ERROR: ${message}`);
|
|
process.exitCode = 1;
|
|
}
|