test(repo): guard critical ownership surfaces
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { resolveRealWorkspaceRoot } from "./load-env.mjs";
|
import { resolveRealWorkspaceRoot } from "./load-env.mjs";
|
||||||
|
|
||||||
const rootDir = resolveRealWorkspaceRoot();
|
const rootDir = resolveRealWorkspaceRoot();
|
||||||
|
|
||||||
const rules = [
|
export const rules = [
|
||||||
{
|
{
|
||||||
file: "apps/web/src/server/auth.ts",
|
file: "apps/web/src/server/auth.ts",
|
||||||
required: [
|
required: [
|
||||||
@@ -60,6 +61,7 @@ const rules = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "packages/api/src/sse/subscription-policy.ts",
|
file: "packages/api/src/sse/subscription-policy.ts",
|
||||||
|
maxLines: 80,
|
||||||
required: [
|
required: [
|
||||||
{
|
{
|
||||||
pattern: /\bderiveUserSseSubscription\b/,
|
pattern: /\bderiveUserSseSubscription\b/,
|
||||||
@@ -83,6 +85,21 @@ const rules = [
|
|||||||
{ pattern: /\baudience\b/, message: "timeline SSE route must not parse raw audience values from the client" },
|
{ pattern: /\baudience\b/, message: "timeline SSE route must not parse raw audience values from the client" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: "apps/web/src/hooks/useTimelineSSE.ts",
|
||||||
|
maxLines: 120,
|
||||||
|
required: [
|
||||||
|
{
|
||||||
|
pattern: /\bgetTimelineSseInvalidationKeys\s*\(/,
|
||||||
|
message: "timeline SSE hook must keep invalidation policy delegated to the extracted policy helper",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bparseTimelineSseEvent\s*\(/,
|
||||||
|
message: "timeline SSE hook must keep event parsing delegated to the extracted policy helper",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
forbidden: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
file: "docker-compose.prod.yml",
|
file: "docker-compose.prod.yml",
|
||||||
required: [
|
required: [
|
||||||
@@ -184,11 +201,12 @@ const rules = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const violations = [];
|
export function countLines(source) {
|
||||||
|
return source.split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
for (const rule of rules) {
|
export function evaluateRule(rule, source) {
|
||||||
const absolutePath = path.join(rootDir, rule.file);
|
const violations = [];
|
||||||
const source = await readFile(absolutePath, "utf8");
|
|
||||||
|
|
||||||
for (const requirement of rule.required) {
|
for (const requirement of rule.required) {
|
||||||
if (!requirement.pattern.test(source)) {
|
if (!requirement.pattern.test(source)) {
|
||||||
@@ -201,7 +219,36 @@ for (const rule of rules) {
|
|||||||
violations.push(`${rule.file}: forbidden pattern matched: ${forbidden.message}`);
|
violations.push(`${rule.file}: forbidden pattern matched: ${forbidden.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof rule.maxLines === "number") {
|
||||||
|
const lines = countLines(source);
|
||||||
|
if (lines > rule.maxLines) {
|
||||||
|
violations.push(
|
||||||
|
`${rule.file}: file grew to ${lines} lines and exceeds maxLines=${rule.maxLines}; split the ownership surface before expanding it further`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectArchitectureGuardrailViolations(
|
||||||
|
architectureRules = rules,
|
||||||
|
{ readSource = readFile, workspaceRoot = rootDir } = {},
|
||||||
|
) {
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
for (const rule of architectureRules) {
|
||||||
|
const absolutePath = path.join(workspaceRoot, rule.file);
|
||||||
|
const source = await readSource(absolutePath, "utf8");
|
||||||
|
violations.push(...evaluateRule(rule, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const violations = await collectArchitectureGuardrailViolations();
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
console.error("Architecture guardrail check failed:");
|
console.error("Architecture guardrail check failed:");
|
||||||
@@ -212,3 +259,8 @@ if (violations.length > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("Architecture guardrails passed.");
|
console.log("Architecture guardrails passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
collectArchitectureGuardrailViolations,
|
||||||
|
countLines,
|
||||||
|
evaluateRule,
|
||||||
|
} from "./check-architecture-guardrails.mjs";
|
||||||
|
|
||||||
|
describe("architecture guardrails", () => {
|
||||||
|
it("counts lines consistently for maxLines checks", () => {
|
||||||
|
assert.equal(countLines("a\nb\nc"), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports required, forbidden, and maxLines violations together", () => {
|
||||||
|
const violations = evaluateRule(
|
||||||
|
{
|
||||||
|
file: "apps/web/src/example.ts",
|
||||||
|
maxLines: 2,
|
||||||
|
required: [{ pattern: /\bexpectedHelper\b/, message: "must call the extracted helper" }],
|
||||||
|
forbidden: [{ pattern: /\binlineLogic\b/, message: "must not re-inline complex logic" }],
|
||||||
|
},
|
||||||
|
"inlineLogic();\nconst a = 1;\nconst b = 2;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(violations, [
|
||||||
|
"apps/web/src/example.ts: missing guardrail anchor: must call the extracted helper",
|
||||||
|
"apps/web/src/example.ts: forbidden pattern matched: must not re-inline complex logic",
|
||||||
|
"apps/web/src/example.ts: file grew to 4 lines and exceeds maxLines=2; split the ownership surface before expanding it further",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no violations when a rule is satisfied", () => {
|
||||||
|
const violations = evaluateRule(
|
||||||
|
{
|
||||||
|
file: "packages/api/src/example.ts",
|
||||||
|
maxLines: 4,
|
||||||
|
required: [{ pattern: /\bderiveThing\b/, message: "must keep derivation centralized" }],
|
||||||
|
forbidden: [{ pattern: /\bunsafeThing\b/, message: "must not use unsafe helper" }],
|
||||||
|
},
|
||||||
|
"export function deriveThing() {\n return true;\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(violations, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads sources through the injected reader when collecting violations", async () => {
|
||||||
|
const violations = await collectArchitectureGuardrailViolations(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
file: "apps/web/src/example.ts",
|
||||||
|
maxLines: 2,
|
||||||
|
required: [],
|
||||||
|
forbidden: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
workspaceRoot: "/virtual/repo",
|
||||||
|
readSource: async (filePath) => {
|
||||||
|
assert.equal(filePath, "/virtual/repo/apps/web/src/example.ts");
|
||||||
|
return "line1\nline2\nline3\n";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(violations, [
|
||||||
|
"apps/web/src/example.ts: file grew to 4 lines and exceeds maxLines=2; split the ownership surface before expanding it further",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user