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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,5 @@
|
||||
package docgov
|
||||
|
||||
const ownerSQL = `
|
||||
UPDATE document_records SET current_state = 'approved' WHERE doc_id = $1
|
||||
`
|
||||
@@ -0,0 +1,5 @@
|
||||
package docgov
|
||||
|
||||
const testSQL = `
|
||||
UPDATE document_records SET current_state = 'test' WHERE doc_id = $1
|
||||
`
|
||||
@@ -0,0 +1,7 @@
|
||||
package server
|
||||
|
||||
func emit() {
|
||||
_ = OnOutbox("document.agent_version_committed")
|
||||
}
|
||||
|
||||
func OnOutbox(eventType string) error { return nil }
|
||||
@@ -0,0 +1,5 @@
|
||||
package server
|
||||
|
||||
func concatOutput(dir string) string {
|
||||
return dir + "/output.md"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package server
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
const deliverableName = "output.md"
|
||||
|
||||
func constOutput(dir string) string {
|
||||
return filepath.Join(dir, deliverableName)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package server
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
func joinOutput(dir string) string {
|
||||
return filepath.Join(dir, "output.md")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package server
|
||||
|
||||
const rogueSQL = `
|
||||
UPDATE document_records SET current_state = 'hacked' WHERE doc_id = $1
|
||||
`
|
||||
Executable
+39
@@ -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"
|
||||
Reference in New Issue
Block a user