diff --git a/.claude/harness/baselines/file-too-large.json b/.claude/harness/baselines/file-too-large.json new file mode 100644 index 000000000..e490c21bd --- /dev/null +++ b/.claude/harness/baselines/file-too-large.json @@ -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" + } + ] +} diff --git a/.claude/harness/baselines/handler-no-direct-sdk-import.json b/.claude/harness/baselines/handler-no-direct-sdk-import.json new file mode 100644 index 000000000..f6bb69c02 --- /dev/null +++ b/.claude/harness/baselines/handler-no-direct-sdk-import.json @@ -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" + } + ] +} diff --git a/.claude/harness/bin/regenerate-baselines.ts b/.claude/harness/bin/regenerate-baselines.ts new file mode 100644 index 000000000..945b6fb01 --- /dev/null +++ b/.claude/harness/bin/regenerate-baselines.ts @@ -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 [...] + */ + +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 [...]\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)`); +} diff --git a/.claude/harness/src/cli.ts b/.claude/harness/src/cli.ts index 1c1037c1e..11ef1d492 100644 --- a/.claude/harness/src/cli.ts +++ b/.claude/harness/src/cli.ts @@ -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"; @@ -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 = { info: 0, @@ -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//index.ts must not import @aws-sdk/client-* directly. + +Baselines: + Each rule may have a baseline file at .claude/harness/baselines/.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 `; export function run(opts: RunOptions): RunResult { @@ -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[] = []; diff --git a/.claude/harness/src/rules/file-too-large.test.ts b/.claude/harness/src/rules/file-too-large.test.ts new file mode 100644 index 000000000..386a0fb49 --- /dev/null +++ b/.claude/harness/src/rules/file-too-large.test.ts @@ -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); + }); +}); diff --git a/.claude/harness/src/rules/file-too-large.ts b/.claude/harness/src/rules/file-too-large.ts new file mode 100644 index 000000000..4f6c30006 --- /dev/null +++ b/.claude/harness/src/rules/file-too-large.ts @@ -0,0 +1,94 @@ +import type { Finding, Rule, RuleContext } from "../types.ts"; + +/** + * Issue #986 / SOLID 規律強制: 単一ファイルが 500 行を超えると Single Responsibility Principle + * (SRP) 違反候補として警告する。 800 行を超えると error。 + * + * SRP 違反は 1 ファイルに複数の責務が同居している状態 (= テストしにくい / 変更時に副作用が読めない + * / レビュー困難)。 ある程度の閾値を harness で機械検査することで、 1 PR で 「ついで」 に + * 500 行超のファイルを作るのを防ぐ。 + * + * 既存違反は baseline で許容、 新規の違反だけ block する。 既存 file を baseline に登録した + * あと、 同 file への新規追記で行数増加した場合は baseline match から外れるため再警告 (= 抑止力)。 + * + * 対象拡張子: .ts / .tsx (= TypeScript の compile unit)。 .json / .md / .html は target 外 + * (= configuration / doc は責務分割の指標が違う)。 + * + * 対象 path: \`infrastructure/lib/\`、 \`apps//src/\`、 \`scripts/\`、 \`packages//src/\`。 + * test ファイル (`*.test.ts` / `*.test.tsx`) は除外 (= test は集中することが多く、 SRP 観点で + * 別 axis)。 generated / dist / cdk.out も除外。 + * + * 閾値: + * - 500 行超: warning (= 分割を検討) + * - 800 行超: error (= 必ず分割) + * + * 既存実態: + * - tenkacloud-problem.ts 1154 行、 EventDetail.tsx 1150 行、 DeploymentDetail.tsx 830 行 等 + * - これらは baseline で許容しつつ、 issue #986 Phase C / D で順次分割する + */ + +const WARNING_LINES = 500; +const ERROR_LINES = 800; + +const INCLUDE_PATH_PREFIXES = [ + "infrastructure/lib/", + "apps/admin-console/src/", + "apps/application-admin-console/src/", + "apps/participant-portal/src/", + "scripts/", + "packages/portal-plugin-sdk/src/", + "packages/trust-bridge/src/", +] as const; + +const EXCLUDE_PATTERNS = [ + /\.test\.tsx?$/, // test 系は SRP 別軸 + /\/node_modules\//, + /\/dist\//, + /\/cdk\.out\//, + /\/__generated__\//, + /\/__mocks__\//, +]; + +function shouldInspect(path: string): boolean { + if (!/\.tsx?$/.test(path)) return false; + if (EXCLUDE_PATTERNS.some((re) => re.test(path))) return false; + return INCLUDE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +export const fileTooLarge: Rule = { + id: "file-too-large", + severity: "warning", + check(ctx: RuleContext): readonly Finding[] { + const findings: Finding[] = []; + for (const path of ctx.files) { + if (!shouldInspect(path)) continue; + let content: string; + try { + content = ctx.readFile(path); + } catch { + continue; + } + const lineCount = content.split("\n").length; + if (lineCount < WARNING_LINES) continue; + const severity = lineCount >= ERROR_LINES ? "error" : "warning"; + findings.push({ + ruleId: "file-too-large", + severity, + filePath: path, + line: 1, + // match は baseline 識別に使う。 行数を含めると 1 行増減で baseline が外れて再警告するため、 + // bucket (= "≥500" / "≥800") だけにする。 同じ閾値範囲なら baseline match。 + match: severity === "error" ? "ge-800-lines" : "ge-500-lines", + message: `${path} は ${lineCount} 行 (= ${ + severity === "error" ? `${ERROR_LINES}+ 行 SRP 違反` : `${WARNING_LINES}+ 行 SRP 候補` + })。 単一ファイルに複数責務が同居している可能性が高い。`, + recommendation: + "責務単位で sub-module / sub-component に分割を検討してください。 例: " + + "Lambda handler index.ts は routes (Hono routing) / service (business rule) / repository (SDK adapter) の 3 層に分割。 " + + "React page は modal / table / form 等を sub-component に切り出し、 page 自体は orchestrator にする。 " + + "Issue #986 (= SOLID 監査 epic) Phase B / C を参照。", + }); + } + return findings; + }, +}; diff --git a/.claude/harness/src/rules/handler-no-direct-sdk-import.test.ts b/.claude/harness/src/rules/handler-no-direct-sdk-import.test.ts new file mode 100644 index 000000000..c98fcbe5a --- /dev/null +++ b/.claude/harness/src/rules/handler-no-direct-sdk-import.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { handlerNoDirectSdkImport } from "./handler-no-direct-sdk-import.ts"; + +describe("handler-no-direct-sdk-import", () => { + it("handler/index.ts での @aws-sdk/client-* import を warning にすべき", () => { + const code = [ + 'import { CloudFormationClient } from "@aws-sdk/client-cloudformation";', + 'import { Hono } from "hono";', + ].join("\n"); + const findings = handlerNoDirectSdkImport.check({ + files: ["infrastructure/lib/foo/handlers/bar/index.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(1); + expect(findings[0]?.ruleId).toBe("handler-no-direct-sdk-import"); + expect(findings[0]?.severity).toBe("warning"); + expect(findings[0]?.match).toBe("@aws-sdk/client-cloudformation"); + }); + + it("@aws-sdk/lib-* import も warning にすべき", () => { + const code = 'import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";'; + const findings = handlerNoDirectSdkImport.check({ + files: ["infrastructure/lib/foo/handlers/bar/index.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(1); + expect(findings[0]?.match).toBe("@aws-sdk/lib-dynamodb"); + }); + + it("非 SDK package の import は通すべき", () => { + const code = 'import { Hono } from "hono";\nimport { StatusCodes } from "http-status-codes";'; + const findings = handlerNoDirectSdkImport.check({ + files: ["infrastructure/lib/foo/handlers/bar/index.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(0); + }); + + it("handler//service.ts は対象外にすべき (= 非 index.ts は service / repository 層)", () => { + const code = 'import { CloudFormationClient } from "@aws-sdk/client-cloudformation";'; + const findings = handlerNoDirectSdkImport.check({ + files: ["infrastructure/lib/foo/handlers/bar/service.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(0); + }); + + it("infrastructure/lib/ 外は対象外にすべき", () => { + const code = 'import { CloudFormationClient } from "@aws-sdk/client-cloudformation";'; + const findings = handlerNoDirectSdkImport.check({ + files: ["apps/admin-console/src/handlers/foo/index.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(0); + }); + + it("handlers/ を path に含まない infrastructure/lib/ の index.ts は対象外にすべき", () => { + const code = 'import { CloudFormationClient } from "@aws-sdk/client-cloudformation";'; + const findings = handlerNoDirectSdkImport.check({ + files: ["infrastructure/lib/control-plane-stack.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(0); + }); + + it("複数 SDK import は別 finding として並べるべき", () => { + const code = [ + 'import { CloudFormationClient } from "@aws-sdk/client-cloudformation";', + 'import { SSMClient } from "@aws-sdk/client-ssm";', + 'import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";', + ].join("\n"); + const findings = handlerNoDirectSdkImport.check({ + files: ["infrastructure/lib/foo/handlers/bar/index.ts"], + readFile: () => code, + }); + + expect(findings).toHaveLength(3); + expect(findings.map((f) => f.match)).toEqual([ + "@aws-sdk/client-cloudformation", + "@aws-sdk/client-ssm", + "@aws-sdk/lib-dynamodb", + ]); + }); +}); diff --git a/.claude/harness/src/rules/handler-no-direct-sdk-import.ts b/.claude/harness/src/rules/handler-no-direct-sdk-import.ts new file mode 100644 index 000000000..ea955e731 --- /dev/null +++ b/.claude/harness/src/rules/handler-no-direct-sdk-import.ts @@ -0,0 +1,78 @@ +import type { Finding, Rule, RuleContext } from "../types.ts"; + +/** + * Issue #986 / SOLID enforcement: Lambda handler routing layer + * (= infrastructure/lib/<...>/handlers//index.ts) MUST NOT directly + * import "@aws-sdk/client-*" / "@aws-sdk/lib-*". + * + * Rationale (Dependency Inversion Principle / Layered architecture): + * index.ts should be HTTP routing only. + * Business rule + SDK calls go to a separate service / repository module. + * + * Expected shape: + * handlers//index.ts — Hono routes (parse, validate, dispatch) + * -> handlers//.ts — business rule (existing deploy.ts, list.ts, ...) + * -> handlers/shared/.ts — SDK adapter (cfn-status.ts, external-id-store.ts) + * + * Direct SDK import from index.ts couples HTTP context with AWS API context, + * forces tests to mock both Hono and SDK, and duplicates DDB Put / etc logic + * across multiple handler index.ts files (DRY violation). + * + * Existing violations are tolerated via the baseline file. Only newly added + * SDK imports in handler index.ts are blocked, to ratchet quality upward. + * + * Scope: infrastructure/lib/**\/handlers/**\/index.ts (Hono routing entry). + * + * Exceptions: + * - shared.ts and other non-index files (service / repository layer may call SDK) + * - participant-handler/sso.ts and similar non-index files + */ + +const SDK_IMPORT_RE = /from\s+["']@aws-sdk\/(client-|lib-)/; + +function shouldInspect(path: string): boolean { + if (!path.startsWith("infrastructure/lib/")) return false; + if (!path.includes("/handlers/")) return false; + if (!path.endsWith("/index.ts")) return false; + return true; +} + +export const handlerNoDirectSdkImport: Rule = { + id: "handler-no-direct-sdk-import", + severity: "warning", + check(ctx: RuleContext): readonly Finding[] { + const findings: Finding[] = []; + for (const path of ctx.files) { + if (!shouldInspect(path)) continue; + let content: string; + try { + content = ctx.readFile(path); + } catch { + continue; + } + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + if (!SDK_IMPORT_RE.test(line)) continue; + const pkgMatch = line.match(/@aws-sdk\/(client-[a-z-]+|lib-[a-z-]+)/); + const pkg = pkgMatch?.[0] ?? "@aws-sdk/?"; + findings.push({ + ruleId: "handler-no-direct-sdk-import", + severity: "warning", + filePath: path, + line: i + 1, + match: pkg, + message: + "Handler routing layer is importing an AWS SDK client (" + + pkg + + ") directly. This couples HTTP routing with infrastructure concerns.", + recommendation: + "Move SDK calls into a service / repository module. Keep index.ts as routes only. " + + "See Issue #986 Phase B for the layered architecture pattern.", + }); + } + } + return findings; + }, +}; diff --git a/.claude/harness/src/rules/index.ts b/.claude/harness/src/rules/index.ts index 3bf561326..99a2df11b 100644 --- a/.claude/harness/src/rules/index.ts +++ b/.claude/harness/src/rules/index.ts @@ -1,9 +1,14 @@ import { adrMustBeHtml } from "./adr-must-be-html.ts"; import { adrSelfContained } from "./adr-self-contained.ts"; +import { fileTooLarge } from "./file-too-large.ts"; +import { handlerNoDirectSdkImport } from "./handler-no-direct-sdk-import.ts"; import { iamWildcardNeedsJustify } from "./iam-wildcard-needs-justify.ts"; export const architectureRules = [ adrMustBeHtml, adrSelfContained, iamWildcardNeedsJustify, + // Issue #986 / SOLID 規律強制 + fileTooLarge, + handlerNoDirectSdkImport, ] as const; diff --git a/biome.json b/biome.json index 0ec64b538..15c22b8e7 100644 --- a/biome.json +++ b/biome.json @@ -32,7 +32,13 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "warn", + "options": { "maxAllowedComplexity": 15 } + } + } } }, "javascript": {