Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .claude/harness/baselines/file-too-large.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"entries": [
{
"ruleId": "file-too-large",
"filePath": "apps/admin-console/src/pages/AdminDeploymentDetail.tsx",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "apps/application-admin-console/src/pages/CompetitorAccounts.tsx",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "apps/application-admin-console/src/pages/DeploymentDetail.tsx",
"line": 1,
"match": "ge-800-lines"
},
{
"ruleId": "file-too-large",
"filePath": "apps/application-admin-console/src/pages/EventCreate.tsx",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "apps/application-admin-console/src/pages/EventDetail.tsx",
"line": 1,
"match": "ge-800-lines"
},
{
"ruleId": "file-too-large",
"filePath": "apps/participant-portal/src/api/portal-client.ts",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "infrastructure/lib/admin-insight/handlers/admin-insight-handler/index.ts",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "infrastructure/lib/problem-deploy/handlers/competitor-accounts-handler/index.ts",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "infrastructure/lib/problem-deploy/handlers/event-handler/bulk-deploy.ts",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "infrastructure/lib/problem-deploy/handlers/event-handler/index.ts",
"line": 1,
"match": "ge-500-lines"
},
{
"ruleId": "file-too-large",
"filePath": "scripts/tenkacloud-problem.ts",
"line": 1,
"match": "ge-800-lines"
}
]
}
52 changes: 52 additions & 0 deletions .claude/harness/baselines/handler-no-direct-sdk-import.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"entries": [
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/describe-stack-handler/index.ts",
"line": 1,
"match": "@aws-sdk/client-cloudformation"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/describe-stack-handler/index.ts",
"line": 2,
"match": "@aws-sdk/client-ssm"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/describe-stack-handler/index.ts",
"line": 3,
"match": "@aws-sdk/client-sts"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/describe-stack-handler/index.ts",
"line": 4,
"match": "@aws-sdk/client-sts"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/external-id-audit-handler/index.ts",
"line": 5,
"match": "@aws-sdk/client-cloudwatch"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/external-id-audit-handler/index.ts",
"line": 6,
"match": "@aws-sdk/client-dynamodb"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/external-id-audit-handler/index.ts",
"line": 7,
"match": "@aws-sdk/lib-dynamodb"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/generic-scoring-handler/index.ts",
"line": 7,
"match": "@aws-sdk/lib-dynamodb"
}
]
}
63 changes: 63 additions & 0 deletions .claude/harness/bin/regenerate-baselines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bun
/**
* Regenerate baseline files for the architecture harness.
*
* Strategy: for each rule, write the current findings as that rule's baseline.
* This is a one-shot tool for adopting a new rule (= "freeze the current debt,
* block future debt"). Re-running it for an existing rule is also valid when
* deliberately re-baselining after a refactor batch.
*
* Excludes ADR self-contained baseline by default (= already hand-curated).
*
* Usage:
* bun run .claude/harness/bin/regenerate-baselines.ts <ruleId> [<ruleId>...]
*/

import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { adrMustBeHtml } from "../src/rules/adr-must-be-html.ts";
import { adrSelfContained } from "../src/rules/adr-self-contained.ts";
import { fileTooLarge } from "../src/rules/file-too-large.ts";
import { handlerNoDirectSdkImport } from "../src/rules/handler-no-direct-sdk-import.ts";
import { iamWildcardNeedsJustify } from "../src/rules/iam-wildcard-needs-justify.ts";
import { listAllTrackedFiles } from "../src/utils/staged-files.ts";

const RULES = {
"file-too-large": fileTooLarge,
"handler-no-direct-sdk-import": handlerNoDirectSdkImport,
"adr-must-be-html": adrMustBeHtml,
"adr-self-contained": adrSelfContained,
"iam-wildcard-needs-justify": iamWildcardNeedsJustify,
} as const;

const args = process.argv.slice(2);
if (args.length === 0) {
console.error(
"usage: regenerate-baselines.ts <ruleId> [<ruleId>...]\nrules: " +
Object.keys(RULES).join(", "),
);
process.exit(1);
}

const cwd = process.cwd();
const files = listAllTrackedFiles({ cwd });
const readFile = (path: string): string => readFileSync(resolve(cwd, path), "utf8");
const ctx = { files, readFile };

for (const ruleId of args) {
const rule = RULES[ruleId as keyof typeof RULES];
if (!rule) {
console.error(`unknown rule: ${ruleId}`);
process.exit(1);
}
const findings = rule.check(ctx);
const entries = findings.map((f) => ({
ruleId: f.ruleId,
filePath: f.filePath,
line: f.line ?? 1,
match: f.match ?? "",
}));
const outPath = resolve(cwd, `.claude/harness/baselines/${ruleId}.json`);
writeFileSync(outPath, `${JSON.stringify({ entries }, null, 2)}\n`);
console.log(`wrote ${outPath} (${entries.length} entries)`);
}
58 changes: 50 additions & 8 deletions .claude/harness/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { isBaselined, loadBaseline } from "./baseline.ts";
import { type BaselineFile, isBaselined, loadBaseline } from "./baseline.ts";
import { adrMustBeHtml } from "./rules/adr-must-be-html.ts";
import { adrSelfContained } from "./rules/adr-self-contained.ts";
import { fileTooLarge } from "./rules/file-too-large.ts";
import { handlerNoDirectSdkImport } from "./rules/handler-no-direct-sdk-import.ts";
import { iamWildcardNeedsJustify } from "./rules/iam-wildcard-needs-justify.ts";
import type { Finding, Rule, Severity } from "./types.ts";
import { listAllTrackedFiles, listStagedFiles } from "./utils/staged-files.ts";
Expand All @@ -18,7 +20,14 @@ export interface RunResult {
readonly exitCode: number;
}

const ALL_RULES: readonly Rule[] = [adrMustBeHtml, adrSelfContained, iamWildcardNeedsJustify];
const ALL_RULES: readonly Rule[] = [
adrMustBeHtml,
adrSelfContained,
iamWildcardNeedsJustify,
// Issue #986 / SOLID 規律強制
fileTooLarge,
handlerNoDirectSdkImport,
];

const SEVERITY_RANK: Record<Severity, number> = {
info: 0,
Expand Down Expand Up @@ -61,8 +70,17 @@ Options:
-h, --help Show this message.

Rules:
adr-must-be-html docs/architecture/adr-*.md must not exist (use handwritten .html).
adr-self-contained ADR HTML files must not contain chat / phased-rollout traces.
adr-must-be-html docs/architecture/adr-*.md must not exist (use handwritten .html).
adr-self-contained ADR HTML files must not contain chat / phased-rollout traces.
iam-wildcard-needs-justify Wildcard IAM policies need an inline justification comment.
file-too-large Single .ts/.tsx files must not exceed 500 (warn) / 800 (error) lines.
handler-no-direct-sdk-import handlers/<x>/index.ts must not import @aws-sdk/client-* directly.

Baselines:
Each rule may have a baseline file at .claude/harness/baselines/<rule-id>.json.
Findings that match a baseline entry are suppressed (= legacy debt allowed,
new violations blocked). Regenerate via:
bun run .claude/harness/bin/regenerate-baselines.ts <rule-id>
`;

export function run(opts: RunOptions): RunResult {
Expand All @@ -75,15 +93,39 @@ export function run(opts: RunOptions): RunResult {
for (const rule of ALL_RULES) {
findings.push(...rule.check(ctx));
}
const baseline = loadBaseline(
resolve(opts.cwd, ".claude/harness/baselines/adr-self-contained.json"),
);
const baseline = loadAllBaselines(resolve(opts.cwd, ".claude/harness/baselines"));
const activeFindings = findings.filter((finding) => !isBaselined(finding, baseline));
const failThreshold = SEVERITY_RANK[opts.failOn];
const triggered = activeFindings.some((f) => SEVERITY_RANK[f.severity] >= failThreshold);
return { findings: activeFindings, exitCode: triggered ? 2 : 0 };
}

/**
* Loads all *.json baseline files from `dir` and merges entries.
*
* Each rule is encouraged to keep its own baseline file (e.g.
* `adr-self-contained.json`, `file-too-large.json`,
* `handler-no-direct-sdk-import.json`) so PRs that ratchet one rule don't
* collide with PRs that ratchet another.
*/
export function loadAllBaselines(dir: string): BaselineFile {
let names: readonly string[];
try {
names = readdirSync(dir);
} catch (err) {
const code = (err as { code?: string })?.code;
if (code === "ENOENT") return { entries: [] };
throw err;
}
const entries: BaselineFile["entries"][number][] = [];
for (const name of names) {
if (!name.endsWith(".json")) continue;
const file = loadBaseline(join(dir, name));
entries.push(...file.entries);
}
return { entries };
}

export function formatFindings(findings: readonly Finding[]): string {
if (findings.length === 0) return "harness: no findings.\n";
const lines: string[] = [];
Expand Down
85 changes: 85 additions & 0 deletions .claude/harness/src/rules/file-too-large.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { fileTooLarge } from "./file-too-large.ts";

function makeLines(n: number): string {
return new Array(n).fill("x").join("\n");
}

describe("file-too-large", () => {
it("infrastructure/lib/ 配下の 500 行超の .ts は warning すべき", () => {
const findings = fileTooLarge.check({
files: ["infrastructure/lib/big.ts"],
readFile: () => makeLines(600),
});

expect(findings).toHaveLength(1);
expect(findings[0]?.ruleId).toBe("file-too-large");
expect(findings[0]?.severity).toBe("warning");
expect(findings[0]?.match).toBe("ge-500-lines");
});

it("800 行超は error にすべき", () => {
const findings = fileTooLarge.check({
files: ["infrastructure/lib/huge.ts"],
readFile: () => makeLines(900),
});

expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("error");
expect(findings[0]?.match).toBe("ge-800-lines");
});

it("499 行までは inspect しないべき", () => {
const findings = fileTooLarge.check({
files: ["infrastructure/lib/ok.ts"],
readFile: () => makeLines(499),
});
expect(findings).toHaveLength(0);
});

it(".test.ts は対象外にすべき", () => {
const findings = fileTooLarge.check({
files: ["infrastructure/lib/big.test.ts"],
readFile: () => makeLines(900),
});
expect(findings).toHaveLength(0);
});

it("対象 path prefix の外 (= references/, node_modules/) は inspect しないべき", () => {
const findings = fileTooLarge.check({
files: ["references/some-big-file.ts", "node_modules/lib/index.ts"],
readFile: () => makeLines(900),
});
expect(findings).toHaveLength(0);
});

it("apps/admin-console/src/ も対象に入るべき", () => {
const findings = fileTooLarge.check({
files: ["apps/admin-console/src/pages/Big.tsx"],
readFile: () => makeLines(600),
});
expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("warning");
});

it("scripts/ 配下も対象に入るべき", () => {
const findings = fileTooLarge.check({
files: ["scripts/big-tool.ts"],
readFile: () => makeLines(850),
});
expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("error");
});

it("match は bucket 文字列にして 1 行増減で baseline match を外さない設計とすべき", () => {
const a = fileTooLarge.check({
files: ["infrastructure/lib/x.ts"],
readFile: () => makeLines(550),
});
const b = fileTooLarge.check({
files: ["infrastructure/lib/x.ts"],
readFile: () => makeLines(700),
});
expect(a[0]?.match).toBe(b[0]?.match);
});
});
Loading
Loading