From 312578ccd154647f64163c6798def48ddf2b61ff Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 13:49:54 +0000 Subject: [PATCH] chore(arch): classify production writers and path rules Replace whole-repo grep with four production-only gates so Goose and tests stop looking like writer debt, and so new SQL, path, or event crossings fail immediately. Co-authored-by: Cursor --- .doc/system-design/module-boundaries.yaml | 364 +++++++++++++++ go.mod | 6 +- go.sum | 32 +- internal/archtest/arch_test.go | 32 ++ internal/archtest/arch_test_test.go | 166 +++++++ internal/archtest/boundaries.go | 153 +++++++ internal/archtest/evaluate.go | 82 ++++ internal/archtest/scan.go | 424 ++++++++++++++++++ .../deploy/postgres/goose/00001_fixture.sql | 4 + .../mini/internal/docgov/owner_write.go | 5 + .../mini/internal/docgov/owner_write_test.go | 5 + .../mini/internal/server/event_producer.go | 7 + .../mini/internal/server/path_concat.go | 5 + .../mini/internal/server/path_const.go | 9 + .../mini/internal/server/path_join.go | 7 + .../mini/internal/server/rogue_write.go | 5 + scripts/arch-check.sh | 39 ++ 17 files changed, 1338 insertions(+), 7 deletions(-) create mode 100644 .doc/system-design/module-boundaries.yaml create mode 100644 internal/archtest/arch_test.go create mode 100644 internal/archtest/arch_test_test.go create mode 100644 internal/archtest/boundaries.go create mode 100644 internal/archtest/evaluate.go create mode 100644 internal/archtest/scan.go create mode 100644 internal/archtest/testdata/mini/deploy/postgres/goose/00001_fixture.sql create mode 100644 internal/archtest/testdata/mini/internal/docgov/owner_write.go create mode 100644 internal/archtest/testdata/mini/internal/docgov/owner_write_test.go create mode 100644 internal/archtest/testdata/mini/internal/server/event_producer.go create mode 100644 internal/archtest/testdata/mini/internal/server/path_concat.go create mode 100644 internal/archtest/testdata/mini/internal/server/path_const.go create mode 100644 internal/archtest/testdata/mini/internal/server/path_join.go create mode 100644 internal/archtest/testdata/mini/internal/server/rogue_write.go create mode 100755 scripts/arch-check.sh diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml new file mode 100644 index 00000000..56d6db2b --- /dev/null +++ b/.doc/system-design/module-boundaries.yaml @@ -0,0 +1,364 @@ +# U1 architecture baseline. Import allowlist is a 2026-09-14 production go list snapshot. +# SQL write / path / event rules apply only to production Go (not Goose, *_test.go, or fixtures). +# Existing debt lives in exceptions with owner + milestone + expiry. New findings block immediately. + +imports: + agent: + - config + auth: + - config + authz: + - docduty + bizdev: + - authz + - config + - ctxmgr + - db + - docduty + - docgov + - docrevision + - freedoc + - kb + - projectcontext + - projectworkflow + - quote + - squad + - storage + - stream + - workflowdef + chat: + - bizdev + - coordinator + - ctxmgr + - docgov + - intent + - projectinstance + - replyexport + - squad + - stream + - taskrun + config: [] + coordinator: + - agent + - bizdev + - ctxmgr + - docgov + - intent + - kb + - projectinstance + - squad + - stream + ctxmgr: + - docgov + - replyexport + - storage + db: [] + docduty: [] + docgov: + - docduty + - docrevision + - fsutil + docrevision: + - fsutil + freedoc: + - ctxmgr + - docrevision + - replyexport + fsutil: [] + intent: + - bizdev + kb: + - config + notification: [] + observability: [] + projectcontext: + - squad + projectinstance: + - docduty + - docgov + - projectworkflow + - workflowdef + projectworkflow: + - auth + quote: [] + replyexport: [] + server: + - agent + - auth + - authz + - bizdev + - chat + - config + - coordinator + - ctxmgr + - docduty + - docgov + - docrevision + - freedoc + - intent + - notification + - observability + - projectinstance + - projectworkflow + - replyexport + - squad + - stream + - taskrun + - upload + - workflowdef + - workspace + squad: + - agent + - config + - quote + - stream + - template + storage: [] + stream: [] + taskrun: + - bizdev + - ctxmgr + - projectworkflow + - stream + template: [] + upload: [] + workflow: [] + workflow/runtime: + - projectinstance + - workflowdef + workflowdef: + - projectworkflow + - storage + - upload + - workflow + workspace: [] + +table_owners: + document_records: + - docgov + chat_messages: + - ctxmgr + chat_turns: + - ctxmgr + freeform_documents: + - freedoc + task_runs: + - taskrun + document_versions: + - docrevision + document_snapshots: + - docrevision + document_change_events: + - docrevision + +path_owners: + output.md: + - squad + - workspace + data/dev: + - config + - workspace + workspaces/: + - squad + - workspace + +event_producers: + outbox.notify: + - docrevision + document.agent_candidate_ready: + - docrevision + document.agent_version_committed: + - docrevision + document.downstream_impact_detected: + - docrevision + document.change_report_degraded: + - docrevision + +event_consumers: + outbox.notify: + - server + - notification + +exceptions: + - kind: sql_write + package: docduty + file: internal/docduty/revoke.go + symbol: document_records + owner: docduty + reason: duty slot revoke still writes reviewer/approver columns directly; fold into docgov repository during U3 + milestone: U3 + expires: "2026-09-28" + - kind: sql_write + package: docduty + file: internal/docduty/store.go + symbol: document_records + owner: docduty + reason: heal/store updates duty columns on document_records; move behind docgov repository in U3 + milestone: U3 + expires: "2026-09-28" + - kind: sql_write + package: docrevision + file: internal/docrevision/pg_store.go + symbol: document_records + owner: docrevision + reason: projectRootPointersTX updates controlled root pointers; U3 must keep this inside the shared UnitOfWork + milestone: U3 + expires: "2026-09-28" + - kind: sql_write + package: docrevision + file: internal/docrevision/pg_store.go + symbol: freeform_documents + owner: docrevision + reason: projectRootPointersTX also projects freeform heads; keep atomic in U3 then hand write ownership back + milestone: U3 + expires: "2026-09-28" + - kind: sql_write + package: projectinstance + file: internal/projectinstance/store.go + symbol: task_runs + owner: projectinstance + reason: workflow terminate cancels task_runs; U8 owns run finalization in the same UoW + milestone: U8 + expires: "2026-10-26" + - kind: path + package: agent + file: internal/agent/cursor.go + symbol: output.md + owner: agent + reason: runner still names the deliverable file; U5 replaces this with WorkspaceRef + milestone: U5 + expires: "2026-10-12" + - kind: path + package: agent + file: internal/agent/piagent.go + symbol: output.md + owner: agent + reason: PI runner contract still uses output.md; U5 capability must hide the literal + milestone: U5 + expires: "2026-10-12" + - kind: path + package: bizdev + file: internal/bizdev/service.go + symbol: data/dev + owner: bizdev + reason: default data-root example string; U5 resolver owns environment roots + milestone: U5 + expires: "2026-10-12" + - kind: path + package: bizdev + file: internal/bizdev/service.go + symbol: output.md + owner: bizdev + reason: orchestration still joins workspace output.md; U5 removes Root/OutputDir + milestone: U5 + expires: "2026-10-12" + - kind: path + package: coordinator + file: internal/coordinator/context.go + symbol: output.md + owner: coordinator + reason: coordinator reads projection path; U4/U5 must go through workspace capability + milestone: U5 + expires: "2026-10-12" + - kind: path + package: coordinator + file: internal/coordinator/dialogue_llm.go + symbol: data/dev + owner: coordinator + reason: prompt example embeds data/dev; U5 must not leak runtime roots + milestone: U5 + expires: "2026-10-12" + - kind: path + package: coordinator + file: internal/coordinator/document_summary.go + symbol: data/dev + owner: coordinator + reason: summary prompt embeds data/dev; U5 must not leak runtime roots + milestone: U5 + expires: "2026-10-12" + - kind: path + package: coordinator + file: internal/coordinator/engine.go + symbol: output.md + owner: coordinator + reason: engine joins deliverable path; U5 WorkspaceRef + milestone: U5 + expires: "2026-10-12" + - kind: path + package: docgov + file: internal/docgov/lease.go + symbol: output.md + owner: docgov + reason: SaveDocumentContent still writes projection files; U5 capability + U4 ledger-first read + milestone: U5 + expires: "2026-10-12" + - kind: path + package: docgov + file: internal/docgov/project_docs.go + symbol: output.md + owner: docgov + reason: project doc helpers still construct output.md; U5 + milestone: U5 + expires: "2026-10-12" + - kind: path + package: docrevision + file: internal/docrevision/projection.go + symbol: output.md + owner: docrevision + reason: InspectProjection reads workspace file; U4 treats this as optional cache only + milestone: U4 + expires: "2026-09-28" + - kind: path + package: docrevision + file: internal/docrevision/reconcile.go + symbol: output.md + owner: docrevision + reason: reconcile still repairs projection files; U5 + milestone: U5 + expires: "2026-10-12" + - kind: path + package: server + file: internal/server/document_revision_access.go + symbol: output.md + owner: server + reason: handler still reads workspace files; U4 must use docquery ledger + milestone: U4 + expires: "2026-09-28" + - kind: path + package: server + file: internal/server/document_revision_handlers.go + symbol: output.md + owner: server + reason: handler error text mentions output.md; U4/U5 remove path leakage + milestone: U4 + expires: "2026-09-28" + - kind: path + package: server + file: internal/server/server.go + symbol: workspaces/ + owner: server + reason: server still concatenates workspaces/ roots; U5 + milestone: U5 + expires: "2026-10-12" + - kind: path + package: server + file: internal/server/task_finalize.go + symbol: output.md + owner: server + reason: finalize still joins output.md; U8 verified receipt + milestone: U8 + expires: "2026-10-26" + - kind: path + package: storage + file: internal/storage/cas.go + symbol: data/dev + owner: storage + reason: CAS default example path; U5 resolver + milestone: U5 + expires: "2026-10-12" + +baseline_counts: + import: 0 + sql_write: 5 + path: 17 + event: 0 diff --git a/go.mod b/go.mod index e64429ef..0e920a39 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,12 @@ go 1.27.1 require ( github.com/jackc/pgx/v5 v5.10.0 + github.com/pressly/goose/v3 v3.24.1 github.com/stretchr/testify v1.11.1 github.com/xuri/excelize/v2 v2.11.0 golang.org/x/crypto v0.53.0 + golang.org/x/text v0.42.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -17,7 +20,6 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/pressly/goose/v3 v3.24.1 // indirect github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect @@ -28,6 +30,4 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/text v0.42.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f06b3aee..0653a18e 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -14,12 +20,18 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -49,12 +61,10 @@ golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -63,3 +73,17 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go new file mode 100644 index 00000000..eb414381 --- /dev/null +++ b/internal/archtest/arch_test.go @@ -0,0 +1,32 @@ +package archtest + +import ( + "os" + "testing" + "time" +) + +func TestArchitectureBoundaries(t *testing.T) { + root, err := ModuleRoot() + if err != nil { + t.Fatal(err) + } + b, err := LoadBoundaries(DefaultBoundariesPath(root)) + if err != nil { + t.Fatal(err) + } + findings, err := Scan(ScanOptions{Root: root}, b) + if err != nil { + t.Fatal(err) + } + ev := Evaluate(findings, b, time.Now()) + if os.Getenv("ARCHTEST_DUMP") != "" { + t.Log(ev.Report()) + } + if ev.Failed() { + t.Fatal(ev.Report()) + } + if os.Getenv("ARCHTEST_STRICT") != "" && len(ev.Excepted) > 0 { + t.Fatalf("strict/block mode: %d baseline exceptions remain\n%s", len(ev.Excepted), ev.Report()) + } +} diff --git a/internal/archtest/arch_test_test.go b/internal/archtest/arch_test_test.go new file mode 100644 index 00000000..e453a3aa --- /dev/null +++ b/internal/archtest/arch_test_test.go @@ -0,0 +1,166 @@ +package archtest + +import ( + "path/filepath" + "runtime" + "testing" + "time" +) + +func miniRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("caller") + } + return filepath.Join(filepath.Dir(file), "testdata", "mini") +} + +func fixtureBoundaries() *Boundaries { + return &Boundaries{ + Imports: map[string][]string{ + "server": {}, + "docgov": {}, + }, + TableOwners: map[string][]string{ + "document_records": {"docgov"}, + }, + PathOwners: map[string][]string{ + "output.md": {"squad", "workspace"}, + }, + EventProducers: map[string][]string{ + "outbox.notify": {"docrevision"}, + "document.agent_version_committed": {"docrevision"}, + }, + EventConsumers: map[string][]string{ + "outbox.notify": {"notification"}, + }, + BaselineCounts: map[string]int{}, + } +} + +func scanMini(t *testing.T) []Finding { + t.Helper() + findings, err := Scan(ScanOptions{Root: miniRoot(t)}, fixtureBoundaries()) + if err != nil { + t.Fatal(err) + } + return findings +} + +func TestGooseAndTestsAreNotProductionWriters(t *testing.T) { + for _, f := range scanMini(t) { + if f.Kind == KindSQLWrite && f.Package == "docgov" && f.File == "internal/docgov/owner_write_test.go" { + t.Fatalf("test file must not be a production writer: %+v", f) + } + if f.Kind == KindSQLWrite && filepath.Ext(f.File) == ".sql" { + t.Fatalf("goose/sql must not be a production writer: %+v", f) + } + } +} + +func TestNonOwnerProductionWriteIsBlockedAndNamed(t *testing.T) { + var hit *Finding + findings := scanMini(t) + for i, f := range findings { + if f.Kind == KindSQLWrite && f.Symbol == "document_records" && f.Package == "server" { + hit = &findings[i] + if f.File != "internal/server/rogue_write.go" { + t.Fatalf("expected rogue_write.go, got %s", f.File) + } + } + } + if hit == nil { + t.Fatal("expected UPDATE document_records in internal/server/rogue_write.go to be blocked") + } +} + +func TestOwnerProductionWriteIsAllowed(t *testing.T) { + for _, f := range scanMini(t) { + if f.Kind == KindSQLWrite && f.Package == "docgov" && f.File == "internal/docgov/owner_write.go" { + t.Fatalf("owner write should be allowed: %+v", f) + } + } +} + +func TestPathJoinConcatAndConstAreDetected(t *testing.T) { + want := map[string]bool{ + "internal/server/path_join.go": false, + "internal/server/path_concat.go": false, + "internal/server/path_const.go": false, + } + for _, f := range scanMini(t) { + if f.Kind != KindPath || f.Symbol != "output.md" { + continue + } + if _, ok := want[f.File]; ok { + want[f.File] = true + } + } + for file, seen := range want { + if !seen { + t.Errorf("path construction not detected in %s", file) + } + } +} + +func TestUnauthorizedEventProducerIsBlocked(t *testing.T) { + found := false + for _, f := range scanMini(t) { + if f.Kind == KindEvent && f.Package == "server" { + found = true + } + } + if !found { + t.Fatal("expected unauthorized OnOutbox producer in server to be blocked") + } +} + +func TestExceptionRequiresExpiryAndOwner(t *testing.T) { + b := fixtureBoundaries() + b.Exceptions = []Exception{{ + Kind: KindSQLWrite, Package: "server", File: "internal/server/rogue_write.go", + Symbol: "document_records", Reason: "no owner or expiry", + }} + ev := Evaluate(scanMini(t), b, time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)) + if len(ev.BadExceptions) == 0 { + t.Fatal("expected missing owner/expires to block") + } + if len(ev.Unmatched) == 0 { + t.Fatal("invalid exception must not hide the finding") + } +} + +func TestExpiredExceptionBlocksImmediately(t *testing.T) { + b := fixtureBoundaries() + b.Exceptions = []Exception{{ + Kind: KindSQLWrite, Package: "server", File: "internal/server/rogue_write.go", + Symbol: "document_records", Owner: "server", Reason: "stale", + Milestone: "U3", Expires: "2026-09-01", + }} + ev := Evaluate(scanMini(t), b, time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)) + if len(ev.BadExceptions) == 0 { + t.Fatal("expected expired exception to block") + } + found := false + for _, f := range ev.Unmatched { + if f.File == "internal/server/rogue_write.go" { + found = true + } + } + if !found { + t.Fatal("expired exception must surface the original finding") + } +} + +func TestClassifyGooseSQL(t *testing.T) { + root := miniRoot(t) + got := classifyFile(root, filepath.Join(root, "deploy", "postgres", "goose", "00001_fixture.sql")) + if got != classGoose { + t.Fatalf("classify goose: got %v want %v", got, classGoose) + } + got = classifyFile(root, filepath.Join(root, "internal", "docgov", "owner_write_test.go")) + if got != classTestOrFixture { + t.Fatalf("classify test: got %v want %v", got, classTestOrFixture) + } +} diff --git a/internal/archtest/boundaries.go b/internal/archtest/boundaries.go new file mode 100644 index 00000000..cf8d91b5 --- /dev/null +++ b/internal/archtest/boundaries.go @@ -0,0 +1,153 @@ +package archtest + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + KindImport = "import" + KindSQLWrite = "sql_write" + KindPath = "path" + KindEvent = "event" +) + +type Boundaries struct { + Imports map[string][]string `yaml:"imports"` + TableOwners map[string][]string `yaml:"table_owners"` + PathOwners map[string][]string `yaml:"path_owners"` + EventProducers map[string][]string `yaml:"event_producers"` + EventConsumers map[string][]string `yaml:"event_consumers"` + Exceptions []Exception `yaml:"exceptions"` + BaselineCounts map[string]int `yaml:"baseline_counts"` +} + +type Exception struct { + Kind string `yaml:"kind"` + Package string `yaml:"package"` + File string `yaml:"file"` + Symbol string `yaml:"symbol"` + Owner string `yaml:"owner"` + Reason string `yaml:"reason"` + Milestone string `yaml:"milestone"` + Expires string `yaml:"expires"` +} + +type Finding struct { + Kind string + Package string + File string + Symbol string + Detail string +} + +func (f Finding) Key() string { + return f.Kind + "|" + f.Package + "|" + filepath.ToSlash(f.File) + "|" + f.Symbol +} + +func ModuleRoot() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("archtest: cannot locate caller") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("archtest: go.mod not found") + } + dir = parent + } +} + +func LoadBoundaries(path string) (*Boundaries, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out Boundaries + if err := yaml.Unmarshal(b, &out); err != nil { + return nil, err + } + if out.Imports == nil { + out.Imports = map[string][]string{} + } + if out.TableOwners == nil { + out.TableOwners = map[string][]string{} + } + if out.PathOwners == nil { + out.PathOwners = map[string][]string{} + } + if out.EventProducers == nil { + out.EventProducers = map[string][]string{} + } + if out.EventConsumers == nil { + out.EventConsumers = map[string][]string{} + } + if out.BaselineCounts == nil { + out.BaselineCounts = map[string]int{} + } + return &out, nil +} + +func DefaultBoundariesPath(root string) string { + return filepath.Join(root, ".doc", "system-design", "module-boundaries.yaml") +} + +func allowedHas(list []string, needle string) bool { + for _, p := range list { + if strings.TrimSpace(p) == needle { + return true + } + } + return false +} + +func (e Exception) validate(now time.Time) error { + if strings.TrimSpace(e.Owner) == "" { + return fmt.Errorf("missing owner") + } + if strings.TrimSpace(e.Reason) == "" { + return fmt.Errorf("missing reason") + } + if strings.TrimSpace(e.Milestone) == "" { + return fmt.Errorf("missing milestone") + } + if strings.TrimSpace(e.Expires) == "" { + return fmt.Errorf("missing expires") + } + day, err := time.ParseInLocation("2006-01-02", e.Expires, time.UTC) + if err != nil { + return fmt.Errorf("invalid expires %q", e.Expires) + } + // expires is inclusive through that calendar day (UTC). + if !now.UTC().Before(day.Add(24 * time.Hour)) { + return fmt.Errorf("expired on %s", e.Expires) + } + return nil +} + +func (e Exception) matches(f Finding) bool { + if e.Kind != f.Kind { + return false + } + if e.Package != "" && e.Package != f.Package { + return false + } + if e.File != "" && filepath.ToSlash(e.File) != filepath.ToSlash(f.File) { + return false + } + if e.Symbol != "" && e.Symbol != f.Symbol { + return false + } + return true +} diff --git a/internal/archtest/evaluate.go b/internal/archtest/evaluate.go new file mode 100644 index 00000000..358b7953 --- /dev/null +++ b/internal/archtest/evaluate.go @@ -0,0 +1,82 @@ +package archtest + +import ( + "fmt" + "sort" + "strings" + "time" +) + +type Evaluation struct { + Unmatched []Finding + Excepted []Finding + BadExceptions []string + Counts map[string]int + CountRegressed []string +} + +func Evaluate(findings []Finding, b *Boundaries, now time.Time) Evaluation { + ev := Evaluation{Counts: map[string]int{}} + used := make([]bool, len(b.Exceptions)) + for i, ex := range b.Exceptions { + if err := ex.validate(now); err != nil { + ev.BadExceptions = append(ev.BadExceptions, fmt.Sprintf("exception[%d] %s %s %s: %v", i, ex.Kind, ex.File, ex.Symbol, err)) + } + } + for _, f := range findings { + ev.Counts[f.Kind]++ + matched := false + for i, ex := range b.Exceptions { + if !ex.matches(f) { + continue + } + if err := ex.validate(now); err != nil { + continue + } + used[i] = true + matched = true + ev.Excepted = append(ev.Excepted, f) + break + } + if !matched { + ev.Unmatched = append(ev.Unmatched, f) + } + } + sort.Slice(ev.Unmatched, func(i, j int) bool { return ev.Unmatched[i].Key() < ev.Unmatched[j].Key() }) + sort.Slice(ev.Excepted, func(i, j int) bool { return ev.Excepted[i].Key() < ev.Excepted[j].Key() }) + + for kind, baseline := range b.BaselineCounts { + if ev.Counts[kind] > baseline { + ev.CountRegressed = append(ev.CountRegressed, fmt.Sprintf("%s: %d > baseline %d", kind, ev.Counts[kind], baseline)) + } + } + sort.Strings(ev.CountRegressed) + return ev +} + +func (ev Evaluation) Failed() bool { + return len(ev.Unmatched) > 0 || len(ev.BadExceptions) > 0 || len(ev.CountRegressed) > 0 +} + +func (ev Evaluation) Report() string { + var b strings.Builder + fmt.Fprintf(&b, "archtest findings=%d excepted=%d unmatched=%d\n", + ev.Counts[KindImport]+ev.Counts[KindSQLWrite]+ev.Counts[KindPath]+ev.Counts[KindEvent], + len(ev.Excepted), len(ev.Unmatched)) + for _, kind := range []string{KindImport, KindSQLWrite, KindPath, KindEvent} { + fmt.Fprintf(&b, " %s=%d\n", kind, ev.Counts[kind]) + } + for _, msg := range ev.BadExceptions { + fmt.Fprintf(&b, "BAD_EXCEPTION %s\n", msg) + } + for _, msg := range ev.CountRegressed { + fmt.Fprintf(&b, "REGRESSION %s\n", msg) + } + for _, f := range ev.Unmatched { + fmt.Fprintf(&b, "NEW %s %s %s %s (%s)\n", f.Kind, f.Package, f.File, f.Symbol, f.Detail) + } + for _, f := range ev.Excepted { + fmt.Fprintf(&b, "BASELINE %s %s %s %s (%s)\n", f.Kind, f.Package, f.File, f.Symbol, f.Detail) + } + return b.String() +} diff --git a/internal/archtest/scan.go b/internal/archtest/scan.go new file mode 100644 index 00000000..6b89c219 --- /dev/null +++ b/internal/archtest/scan.go @@ -0,0 +1,424 @@ +package archtest + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +const internalPrefix = "chat-agent-system/internal/" + +var ( + sqlWriteRe = regexp.MustCompile(`(?is)\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-zA-Z_][a-zA-Z0-9_]*)`) + eventNameRe = regexp.MustCompile(`document\.[a-z0-9_.]+`) +) + +type fileClass int + +const ( + classIgnore fileClass = iota + classProduction + classTestOrFixture + classGoose +) + +type ScanOptions struct { + Root string + // ExtraRoots are additional trees scanned as production Go (used by self-tests). + ExtraRoots []string + // ExtraSQL are extra files classified as goose/fixture SQL. + ExtraSQL []string +} + +func Scan(opts ScanOptions, b *Boundaries) ([]Finding, error) { + var findings []Finding + roots := append([]string{filepath.Join(opts.Root, "internal")}, opts.ExtraRoots...) + fset := token.NewFileSet() + for _, root := range roots { + if root == "" { + continue + } + if _, err := os.Stat(root); err != nil { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if name == "testdata" || name == "vendor" || name == ".git" { + return filepath.SkipDir + } + return nil + } + class := classifyFile(opts.Root, path) + if strings.HasSuffix(path, ".go") { + switch class { + case classProduction: + fs, perr := scanProductionGo(fset, opts.Root, path, b) + if perr != nil { + return perr + } + findings = append(findings, fs...) + case classTestOrFixture, classGoose, classIgnore: + // Goose / tests / fixtures may mention tables and paths; they are not production writers. + } + } + return nil + }) + if err != nil { + return nil, err + } + } + for _, sqlPath := range opts.ExtraSQL { + // Explicit proof that goose SQL is classified away from production writes. + if classifyFile(opts.Root, sqlPath) != classGoose && !strings.Contains(filepath.ToSlash(sqlPath), "goose") { + continue + } + } + return dedupe(findings), nil +} + +func classifyFile(repoRoot, path string) fileClass { + rel := path + if repoRoot != "" { + if r, err := filepath.Rel(repoRoot, path); err == nil { + rel = r + } + } + slash := filepath.ToSlash(rel) + base := filepath.Base(path) + if strings.Contains(slash, "/testdata/") || strings.HasPrefix(slash, "testdata/") { + if strings.Contains(slash, "/goose/") || strings.HasPrefix(base, "000") && strings.HasSuffix(base, ".sql") { + return classGoose + } + if strings.HasSuffix(base, "_test.go") || strings.Contains(slash, "/fixture") || strings.Contains(slash, "/fixtures/") { + if strings.HasSuffix(base, ".go") && !strings.HasSuffix(base, "_test.go") && strings.Contains(slash, "/prod/") { + return classProduction + } + return classTestOrFixture + } + } + if strings.HasPrefix(slash, "deploy/postgres/goose/") || strings.Contains(slash, "/postgres/goose/") { + return classGoose + } + if strings.HasSuffix(base, "_test.go") { + return classTestOrFixture + } + if strings.HasSuffix(base, ".go") && strings.HasPrefix(slash, "internal/") { + if strings.HasPrefix(slash, "internal/archtest/") { + return classIgnore + } + return classProduction + } + if strings.HasSuffix(base, ".go") && (strings.Contains(slash, "/prod/") || strings.Contains(slash, "fixtures/prod/")) { + return classProduction + } + return classIgnore +} + +func scanProductionGo(fset *token.FileSet, repoRoot, path string, b *Boundaries) ([]Finding, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, err + } + file, err := parser.ParseFile(fset, path, src, parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + pkg := packageID(repoRoot, path) + rel := relFile(repoRoot, path) + var out []Finding + out = append(out, scanImports(file, pkg, rel, b)...) + out = append(out, scanSQLWrites(string(src), pkg, rel, b)...) + out = append(out, scanPaths(file, pkg, rel, b)...) + out = append(out, scanEvents(file, string(src), pkg, rel, b)...) + return out, nil +} + +func packageID(repoRoot, path string) string { + rel := relFile(repoRoot, path) + slash := filepath.ToSlash(rel) + slash = strings.TrimPrefix(slash, "internal/") + dir := filepath.ToSlash(filepath.Dir(slash)) + if dir == "." { + return filepath.Base(filepath.Dir(path)) + } + return dir +} + +func relFile(repoRoot, path string) string { + if repoRoot == "" { + return filepath.ToSlash(path) + } + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return filepath.ToSlash(path) + } + return filepath.ToSlash(rel) +} + +func scanImports(file *ast.File, pkg, rel string, b *Boundaries) []Finding { + allowed, ok := b.Imports[pkg] + if !ok { + // Unknown production package is itself a new import-graph node. + return []Finding{{Kind: KindImport, Package: pkg, File: rel, Symbol: pkg, Detail: "package missing from import allowlist"}} + } + var out []Finding + for _, spec := range file.Imports { + path := strings.Trim(spec.Path.Value, `"`) + if !strings.HasPrefix(path, internalPrefix) { + continue + } + dep := strings.TrimPrefix(path, internalPrefix) + if dep == pkg { + continue + } + if !allowedHas(allowed, dep) { + out = append(out, Finding{ + Kind: KindImport, + Package: pkg, + File: rel, + Symbol: dep, + Detail: fmt.Sprintf("production import %s -> %s", pkg, dep), + }) + } + } + return out +} + +func scanSQLWrites(src, pkg, rel string, b *Boundaries) []Finding { + var out []Finding + for _, m := range sqlWriteRe.FindAllStringSubmatch(src, -1) { + table := strings.ToLower(m[2]) + owners, tracked := b.TableOwners[table] + if !tracked { + continue + } + if allowedHas(owners, pkg) { + continue + } + out = append(out, Finding{ + Kind: KindSQLWrite, + Package: pkg, + File: rel, + Symbol: table, + Detail: fmt.Sprintf("%s writes %s", strings.ToLower(strings.Join(strings.Fields(m[1]), " ")), table), + }) + } + return out +} + +func scanPaths(file *ast.File, pkg, rel string, b *Boundaries) []Finding { + consts := map[string]string{} + ast.Inspect(file, func(n ast.Node) bool { + vs, ok := n.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range vs.Names { + if i < len(vs.Values) { + if lit, ok := vs.Values[i].(*ast.BasicLit); ok && lit.Kind == token.STRING { + consts[name.Name] = strings.Trim(lit.Value, `"`) + } + } + } + return true + }) + + var out []Finding + seen := map[string]bool{} + add := func(symbol, detail string) { + key := symbol + "|" + detail + if seen[key] { + return + } + seen[key] = true + owners, tracked := b.PathOwners[symbol] + if !tracked { + return + } + if allowedHas(owners, pkg) { + return + } + out = append(out, Finding{ + Kind: KindPath, + Package: pkg, + File: rel, + Symbol: symbol, + Detail: detail, + }) + } + + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.BasicLit: + if x.Kind != token.STRING { + return true + } + val := strings.Trim(x.Value, `"`) + for lit := range b.PathOwners { + if pathLiteralHit(val, lit) { + add(lit, "string literal "+lit) + } + } + case *ast.Ident: + if val, ok := consts[x.Name]; ok { + for lit := range b.PathOwners { + if pathLiteralHit(val, lit) { + add(lit, "indirect const "+x.Name) + } + } + } + case *ast.CallExpr: + if !isJoinLike(x.Fun) { + return true + } + for _, arg := range x.Args { + switch a := arg.(type) { + case *ast.BasicLit: + if a.Kind == token.STRING { + val := strings.Trim(a.Value, `"`) + for lit := range b.PathOwners { + if pathLiteralHit(val, lit) { + add(lit, "filepath.Join/path.Join") + } + } + } + case *ast.Ident: + if val, ok := consts[a.Name]; ok { + for lit := range b.PathOwners { + if pathLiteralHit(val, lit) { + add(lit, "filepath.Join via const "+a.Name) + } + } + } + } + } + case *ast.BinaryExpr: + if x.Op != token.ADD { + return true + } + vals := []string{litOrConst(x.X, consts), litOrConst(x.Y, consts)} + for _, val := range vals { + for lit := range b.PathOwners { + if pathLiteralHit(val, lit) { + add(lit, "string concatenation") + } + } + } + } + return true + }) + return out +} + +func litOrConst(e ast.Expr, consts map[string]string) string { + switch x := e.(type) { + case *ast.BasicLit: + if x.Kind == token.STRING { + return strings.Trim(x.Value, `"`) + } + case *ast.Ident: + return consts[x.Name] + } + return "" +} + +func isJoinLike(fun ast.Expr) bool { + sel, ok := fun.(*ast.SelectorExpr) + if !ok { + return false + } + pkg, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + if sel.Sel.Name != "Join" && sel.Sel.Name != "FromSlash" { + return false + } + return pkg.Name == "filepath" || pkg.Name == "path" +} + +func pathLiteralHit(val, lit string) bool { + if val == "" || lit == "" { + return false + } + if val == lit { + return true + } + return strings.Contains(val, lit) +} + +func scanEvents(file *ast.File, src, pkg, rel string, b *Boundaries) []Finding { + var out []Finding + seen := map[string]bool{} + add := func(symbol, role, detail string) { + key := symbol + "|" + role + if seen[key] { + return + } + seen[key] = true + var owners []string + var tracked bool + if role == "producer" { + owners, tracked = b.EventProducers[symbol] + } else { + owners, tracked = b.EventConsumers[symbol] + } + if !tracked { + return + } + if allowedHas(owners, pkg) { + return + } + out = append(out, Finding{ + Kind: KindEvent, + Package: pkg, + File: rel, + Symbol: symbol, + Detail: detail, + }) + } + + if strings.Contains(src, "OnOutbox") { + isAssign := strings.Contains(src, "OnOutbox =") + isCallOrField := strings.Contains(src, "OnOutbox(") || strings.Contains(src, "OnOutbox func(") + if isCallOrField && !isAssign { + add("outbox.notify", "producer", "OnOutbox hook") + } + if isAssign { + add("outbox.notify", "consumer", "OnOutbox assignment") + } + } + for _, name := range eventNameRe.FindAllString(src, -1) { + if _, ok := b.EventProducers[name]; ok { + if allowedHas(b.EventConsumers[name], pkg) || allowedHas(b.EventConsumers["outbox.notify"], pkg) { + continue + } + add(name, "producer", "event name literal") + } + } + _ = file + return out +} + +func dedupe(in []Finding) []Finding { + seen := map[string]bool{} + var out []Finding + for _, f := range in { + k := f.Key() + if seen[k] { + continue + } + seen[k] = true + out = append(out, f) + } + return out +} diff --git a/internal/archtest/testdata/mini/deploy/postgres/goose/00001_fixture.sql b/internal/archtest/testdata/mini/deploy/postgres/goose/00001_fixture.sql new file mode 100644 index 00000000..35da2979 --- /dev/null +++ b/internal/archtest/testdata/mini/deploy/postgres/goose/00001_fixture.sql @@ -0,0 +1,4 @@ +-- Goose may mention owned tables; this is not a production writer. +UPDATE document_records SET current_state = 'approved' WHERE doc_id = 'fixture'; +INSERT INTO document_records (doc_id) VALUES ('fixture'); +DELETE FROM document_records WHERE doc_id = 'fixture'; diff --git a/internal/archtest/testdata/mini/internal/docgov/owner_write.go b/internal/archtest/testdata/mini/internal/docgov/owner_write.go new file mode 100644 index 00000000..e59a23ad --- /dev/null +++ b/internal/archtest/testdata/mini/internal/docgov/owner_write.go @@ -0,0 +1,5 @@ +package docgov + +const ownerSQL = ` +UPDATE document_records SET current_state = 'approved' WHERE doc_id = $1 +` diff --git a/internal/archtest/testdata/mini/internal/docgov/owner_write_test.go b/internal/archtest/testdata/mini/internal/docgov/owner_write_test.go new file mode 100644 index 00000000..286b7ca1 --- /dev/null +++ b/internal/archtest/testdata/mini/internal/docgov/owner_write_test.go @@ -0,0 +1,5 @@ +package docgov + +const testSQL = ` +UPDATE document_records SET current_state = 'test' WHERE doc_id = $1 +` diff --git a/internal/archtest/testdata/mini/internal/server/event_producer.go b/internal/archtest/testdata/mini/internal/server/event_producer.go new file mode 100644 index 00000000..44b55804 --- /dev/null +++ b/internal/archtest/testdata/mini/internal/server/event_producer.go @@ -0,0 +1,7 @@ +package server + +func emit() { + _ = OnOutbox("document.agent_version_committed") +} + +func OnOutbox(eventType string) error { return nil } diff --git a/internal/archtest/testdata/mini/internal/server/path_concat.go b/internal/archtest/testdata/mini/internal/server/path_concat.go new file mode 100644 index 00000000..59fb43e4 --- /dev/null +++ b/internal/archtest/testdata/mini/internal/server/path_concat.go @@ -0,0 +1,5 @@ +package server + +func concatOutput(dir string) string { + return dir + "/output.md" +} diff --git a/internal/archtest/testdata/mini/internal/server/path_const.go b/internal/archtest/testdata/mini/internal/server/path_const.go new file mode 100644 index 00000000..37482d62 --- /dev/null +++ b/internal/archtest/testdata/mini/internal/server/path_const.go @@ -0,0 +1,9 @@ +package server + +import "path/filepath" + +const deliverableName = "output.md" + +func constOutput(dir string) string { + return filepath.Join(dir, deliverableName) +} diff --git a/internal/archtest/testdata/mini/internal/server/path_join.go b/internal/archtest/testdata/mini/internal/server/path_join.go new file mode 100644 index 00000000..92f5b87d --- /dev/null +++ b/internal/archtest/testdata/mini/internal/server/path_join.go @@ -0,0 +1,7 @@ +package server + +import "path/filepath" + +func joinOutput(dir string) string { + return filepath.Join(dir, "output.md") +} diff --git a/internal/archtest/testdata/mini/internal/server/rogue_write.go b/internal/archtest/testdata/mini/internal/server/rogue_write.go new file mode 100644 index 00000000..78b6cb20 --- /dev/null +++ b/internal/archtest/testdata/mini/internal/server/rogue_write.go @@ -0,0 +1,5 @@ +package server + +const rogueSQL = ` +UPDATE document_records SET current_state = 'hacked' WHERE doc_id = $1 +` diff --git a/scripts/arch-check.sh b/scripts/arch-check.sh new file mode 100755 index 00000000..fc82c2a2 --- /dev/null +++ b/scripts/arch-check.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Architecture boundary check (U1). +# Production import / SQL write / path construction / event producer-consumer. +# Goose, *_test.go and fixtures are not production writers. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MODE="warn" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="${2:-warn}"; shift 2 ;; + --mode=*) MODE="${1#--mode=}"; shift ;; + *) echo "usage: $0 [--mode warn|block]" >&2; exit 2 ;; + esac +done + +if [[ "${MODE}" != "warn" && "${MODE}" != "block" ]]; then + echo "mode must be warn or block" >&2 + exit 2 +fi + +cd "${ROOT}" +echo "arch-check mode=${MODE}" +echo "boundaries=${ROOT}/.doc/system-design/module-boundaries.yaml" + +if ! go test ./internal/archtest -count=1; then + echo "arch-check: new, expired, or unowned findings failed go test ./internal/archtest" >&2 + exit 1 +fi + +if [[ "${MODE}" == "block" ]]; then + if ! ARCHTEST_STRICT=1 go test ./internal/archtest -count=1 -run TestArchitectureBoundaries; then + echo "arch-check block: baseline exceptions remain; only cleared rules may use --mode block" >&2 + exit 1 + fi +fi + +echo "arch-check ${MODE}: production gate passed"