feat(arch): add contracts skeleton and warn-mode arch-check
Give later waves a measurable import/table/path baseline without changing business behavior or occupying the integration slot. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Wave 0 baseline allowlist. Generated from 2026-09-14 go list, not tightened.
|
||||
# Target owners are recorded for table/path checks; extra mentions are warnings.
|
||||
|
||||
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: 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:
|
||||
workflowdef: projectworkflow,storage,upload,workflow
|
||||
workspace:
|
||||
contracts:
|
||||
|
||||
table_owners:
|
||||
document_records: docgov
|
||||
chat_messages: ctxmgr
|
||||
chat_turns: ctxmgr
|
||||
freeform_documents: freedoc
|
||||
task_runs: taskrun
|
||||
|
||||
path_owners:
|
||||
output.md: squad,workspace
|
||||
data/dev: config,workspace
|
||||
workspaces/: squad,workspace
|
||||
@@ -0,0 +1,46 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContractsHasNoBusinessImports(t *testing.T) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
for _, ent := range ents {
|
||||
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
f, err := parser.ParseFile(fset, filepath.Join(dir, ent.Name()), nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range f.Imports {
|
||||
path := strings.Trim(spec.Path.Value, `"`)
|
||||
if strings.HasPrefix(path, "chat-agent-system/internal/") && path != "chat-agent-system/internal/contracts" {
|
||||
t.Fatalf("%s imports business package %s", ent.Name(), path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVocabularyAndErrorCodesStable(t *testing.T) {
|
||||
if StageV01Triage != "V0.1_triage" || ErrorPlanFrozen != "PLAN_FROZEN" {
|
||||
t.Fatal("Wave 0 vocabulary drifted")
|
||||
}
|
||||
if EventDocumentApproved == "" || EventRunFinalized == "" {
|
||||
t.Fatal("event names required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package contracts
|
||||
|
||||
// Stable API error codes. Handlers should eventually emit only these strings
|
||||
// in the JSON envelope. Wave 0 records the table; it does not change HTTP.
|
||||
const (
|
||||
ErrorDocumentNotFound = "DOCUMENT_NOT_FOUND"
|
||||
ErrorDocumentContentMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING"
|
||||
ErrorDocumentContentEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY"
|
||||
ErrorDocumentContentIntegrity = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH"
|
||||
ErrorDocumentContentDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT"
|
||||
ErrorDocumentHeadConflict = "DOCUMENT_HEAD_CONFLICT"
|
||||
ErrorDocumentEditForbidden = "DOCUMENT_EDIT_FORBIDDEN"
|
||||
ErrorDocumentBusy = "DOCUMENT_BUSY"
|
||||
ErrorPlanFrozen = "PLAN_FROZEN"
|
||||
ErrorWorkflowAccessDenied = "WORKFLOW_ACCESS_DENIED"
|
||||
ErrorTurnNotCompleted = "TURN_NOT_COMPLETED"
|
||||
ErrorMessageNotExportable = "MESSAGE_NOT_EXPORTABLE"
|
||||
ErrorMessageRevisionChanged = "MESSAGE_REVISION_CHANGED"
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package contracts
|
||||
|
||||
import "time"
|
||||
|
||||
// Event is the typed bus payload. Implementations must stay serializable
|
||||
// and must not embed domain structs from other packages.
|
||||
type Event interface {
|
||||
EventName() string
|
||||
OccurredAt() time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
EventDocumentApproved = "document.approved"
|
||||
EventDocumentRejected = "document.rejected"
|
||||
EventDocumentLeaseChanged = "document.lease_changed"
|
||||
EventRunFinalized = "run.finalized"
|
||||
EventStageAdvanced = "stage.advanced"
|
||||
EventStageGateDenied = "stage.gate_denied"
|
||||
EventMessageRecorded = "message.recorded"
|
||||
EventNotificationRequested = "notification.requested"
|
||||
)
|
||||
|
||||
type EventHandler func(ev Event) error
|
||||
|
||||
type EventBus interface {
|
||||
Publish(ev Event) error
|
||||
Subscribe(name string, h EventHandler)
|
||||
}
|
||||
|
||||
// ProgressReporter is the transport-neutral progress port.
|
||||
// Domain modules must not import internal/stream.
|
||||
type ProgressReporter interface {
|
||||
Report(step string, status string, detail map[string]string)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package contracts
|
||||
|
||||
// MaturityStage is the shared business-maturity vocabulary.
|
||||
// Wave 0 only records the values; intent/bizdev still own their local aliases
|
||||
// until Wave 1 deletes the reverse import.
|
||||
type MaturityStage string
|
||||
|
||||
const (
|
||||
StageV01Triage MaturityStage = "V0.1_triage"
|
||||
StageV05Feasibility MaturityStage = "V0.5_feasibility"
|
||||
StageV10Quoting MaturityStage = "V1.0_quoting"
|
||||
)
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bash
|
||||
# Architecture boundary check. Wave 0: warn by default, never rewrite code.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BOUNDARIES="${ROOT}/.doc/system-design/module-boundaries.yaml"
|
||||
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
|
||||
|
||||
violations=0
|
||||
warn() {
|
||||
printf 'WARN %s\n' "$1"
|
||||
violations=$((violations + 1))
|
||||
}
|
||||
|
||||
lookup_imports() {
|
||||
local pkg="$1"
|
||||
awk -v pkg="$pkg" '
|
||||
$1=="imports:" {sec=1; next}
|
||||
sec && $1 ~ /^(table_owners|path_owners):/ { exit }
|
||||
sec && $1 ~ /^[A-Za-z0-9_]+:$/ || sec && $1 ~ /^[A-Za-z0-9_]+:/ {
|
||||
key=$1; sub(":","",key)
|
||||
$1=""
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/,"",$0)
|
||||
gsub(/"/,"",$0)
|
||||
if (key==pkg) { print $0; exit }
|
||||
next
|
||||
}
|
||||
' "${BOUNDARIES}"
|
||||
}
|
||||
|
||||
lookup_owners() {
|
||||
local section="$1" key="$2"
|
||||
awk -v secname="$section" -v key="$key" '
|
||||
$0 ~ "^"secname":" {sec=1; next}
|
||||
sec && $1 ~ /^(imports|table_owners|path_owners):/ && $1 !~ "^"secname":" { exit }
|
||||
sec && $1 ~ /^[A-Za-z0-9_./]+:/ {
|
||||
k=$1; sub(":","",k)
|
||||
$1=""
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/,"",$0)
|
||||
if (k==key) { print $0; exit }
|
||||
next
|
||||
}
|
||||
' "${BOUNDARIES}"
|
||||
}
|
||||
|
||||
allowed_has() {
|
||||
local needle="$1"
|
||||
local list="$2"
|
||||
[[ -z "${list}" ]] && return 1
|
||||
IFS=',' read -r -a parts <<< "${list}"
|
||||
for p in "${parts[@]}"; do
|
||||
p="${p// /}"
|
||||
[[ "${p}" == "${needle}" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
root_pkg() {
|
||||
local rel="${1#chat-agent-system/internal/}"
|
||||
echo "${rel%%/*}"
|
||||
}
|
||||
|
||||
echo "arch-check mode=${MODE}"
|
||||
echo "boundaries=${BOUNDARIES}"
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "${line}" ]] && continue
|
||||
pkg="$(echo "${line}" | awk '{print $1}')"
|
||||
root="$(root_pkg "${pkg}")"
|
||||
[[ "${root}" == "workflow" ]] && continue
|
||||
allow="$(lookup_imports "${root}")"
|
||||
deps="$(echo "${line}" | awk '{for(i=2;i<=NF;i++) print $i}')"
|
||||
for dep in ${deps}; do
|
||||
[[ "${dep}" != chat-agent-system/internal/* ]] && continue
|
||||
droot="$(root_pkg "${dep}")"
|
||||
[[ "${droot}" == "${root}" ]] && continue
|
||||
if ! allowed_has "${droot}" "${allow}"; then
|
||||
warn "R-import ${root} -> ${droot} not in baseline allowlist"
|
||||
fi
|
||||
done
|
||||
if [[ "${root}" == "contracts" ]]; then
|
||||
for dep in ${deps}; do
|
||||
if [[ "${dep}" == chat-agent-system/internal/* && "${dep}" != chat-agent-system/internal/contracts ]]; then
|
||||
warn "R1 contracts imports ${dep}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done < <(go list -f '{{.ImportPath}} {{join .Imports " "}}' ./internal/...)
|
||||
|
||||
declare -A seen_table
|
||||
while IFS= read -r table; do
|
||||
owners="$(lookup_owners table_owners "${table}")"
|
||||
while IFS= read -r file; do
|
||||
[[ -z "${file}" ]] && continue
|
||||
rel="${file#${ROOT}/internal/}"
|
||||
pkg="${rel%%/*}"
|
||||
key="${table}|${pkg}"
|
||||
[[ -n "${seen_table[$key]+x}" ]] && continue
|
||||
seen_table[$key]=1
|
||||
if ! allowed_has "${pkg}" "${owners}"; then
|
||||
warn "R5 table ${table} mentioned in ${pkg} (target owner ${owners})"
|
||||
fi
|
||||
done < <(rg -l --glob '*.go' --glob '!*_test.go' --fixed-strings "${table}" "${ROOT}/internal" || true)
|
||||
done <<'TABLES'
|
||||
document_records
|
||||
chat_messages
|
||||
chat_turns
|
||||
freeform_documents
|
||||
task_runs
|
||||
TABLES
|
||||
|
||||
declare -A seen_path
|
||||
while IFS= read -r lit; do
|
||||
owners="$(lookup_owners path_owners "${lit}")"
|
||||
while IFS= read -r file; do
|
||||
[[ -z "${file}" ]] && continue
|
||||
rel="${file#${ROOT}/internal/}"
|
||||
pkg="${rel%%/*}"
|
||||
key="${lit}|${pkg}"
|
||||
[[ -n "${seen_path[$key]+x}" ]] && continue
|
||||
seen_path[$key]=1
|
||||
if ! allowed_has "${pkg}" "${owners}"; then
|
||||
warn "R6 path ${lit} mentioned in ${pkg} (target owner ${owners})"
|
||||
fi
|
||||
done < <(rg -l --glob '*.go' --glob '!*_test.go' --fixed-strings "${lit}" "${ROOT}/internal" || true)
|
||||
done <<'PATHS'
|
||||
output.md
|
||||
data/dev
|
||||
workspaces/
|
||||
PATHS
|
||||
|
||||
echo "violations=${violations}"
|
||||
if [[ "${MODE}" == "block" && "${violations}" -gt 0 ]]; then
|
||||
echo "arch-check block: failing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "arch-check ${MODE}: recorded ${violations} baseline findings"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user