From d76e3c49ba5f53ba7b73cf0d6a73b85579dbcddd Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:45:14 +0200 Subject: [PATCH 1/5] chore(api): add tach and a truthful import-boundary baseline --- api/pyproject.toml | 1 + api/tach.toml | 167 +++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 121 ++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 api/tach.toml diff --git a/api/pyproject.toml b/api/pyproject.toml index 78aa0a6b..cd929f88 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -54,6 +54,7 @@ test = [ lint = [ "pyright>=1.1.406", "ruff>=0.7", + "tach>=0.29", "ty>=0.0.42", ] docs = [ diff --git a/api/tach.toml b/api/tach.toml new file mode 100644 index 00000000..f9a4f4a8 --- /dev/null +++ b/api/tach.toml @@ -0,0 +1,167 @@ +# Import-boundary baseline (ADR-000). +# +# This encodes the *current* dependency graph, derived from real imports via +# `tach sync`, not the target architecture in docs/architecture.md. Edges that +# the architecture forbids are marked DEBT(ADR-xxx) with a one-line reason; +# un-annotated edges are legitimate under the current rules (composition-root +# imports, core/infrastructure imports, or the one explicit auth->proposals +# exception). Don't add new DEBT edges; fixing an existing one means deleting +# its line here in the same commit. + +source_roots = ["src"] + +[[modules]] +path = "damnit_api" +# legitimate: the package __init__ configures logging on import. +depends_on = ["damnit_api._logging"] + +[[modules]] +path = "damnit_api.auth" +depends_on = [ + "damnit_api", + "damnit_api.shared", + "damnit_api._db", + "damnit_api._mymdc", + # legitimate: ADR-000's explicit "auth -> proposals" exception (metadata is + # proposals under its current name); membership needs proposal metadata, + # never the reverse. + "damnit_api.metadata", + # DEBT(ADR-000): auth/routers.py's noauth_userinfo() imports + # runs.dependencies.Repositories for local-mode proposal lookup. Slice -> + # slice import with no declared exception. + "damnit_api.runs", +] + +[[modules]] +path = "damnit_api.contextfile" +# legitimate: contextfile resolves proposal metadata/path to serve context +# files, the future proposals/locator.py role (ADR-004). +depends_on = ["damnit_api.metadata"] + +[[modules]] +path = "damnit_api.metadata" +depends_on = [ + "damnit_api", + "damnit_api.shared", + "damnit_api._db", + "damnit_api._mymdc", + # DEBT(ADR-000): metadata/services.py's _local_proposal_number() imports + # runs.repository / runs.sqlite.repository (function-body) for local-mode + # lookup. Slice -> slice import with no declared exception. + "damnit_api.runs", +] + +[[modules]] +path = "damnit_api.runs" +# legitimate: core (shared) plus the to-be-removed utils junk drawer. +depends_on = ["damnit_api", "damnit_api.shared", "damnit_api.utils"] + +[[modules]] +path = "damnit_api.graphql" +depends_on = [ + "damnit_api", + "damnit_api.shared", + # DEBT(ADR-007): graphql is transport-only, but graphql/queries.py, + # subscriptions.py, directives.py and publisher.py import runs.types / + # runs.repository directly. Resolves once resolvers move to runs/gql.py. + "damnit_api.runs", + # DEBT(ADR-007): graphql/queries.py imports metadata.services' + # _get_proposal_meta / _update_proposal_meta - cross-slice private names + # (also ADR-000). Resolves once resolvers move to a slice gql module and + # metadata gets a public interface. + "damnit_api.metadata", +] + +[[modules]] +path = "damnit_api.shared" +depends_on = [ + "damnit_api", + # DEBT(ADR-007): shared/gql.py assembles the whole schema and Context - + # composition-root-shaped, importing from nearly every package - but lives + # under shared/ instead of graphql/schema.py. Most edges below trace to it; + # once it moves to graphql/schema.py they become legitimate composition-root + # imports and this block shrinks to the two exceptions noted below. + "damnit_api._db", + "damnit_api.runs", + "damnit_api.graphql", + "damnit_api.metadata", + # DEBT(ADR-007) + shared/permissions.py imports auth.policy's + # require_proposal_member, the ADR-011 transport adapter over the membership + # policy - a core-shaped module importing a slice. + "damnit_api.auth", + # DEBT(ADR-007) + shared/settings.py imports _mymdc.settings models as part + # of settings assembly. + "damnit_api._mymdc", +] + +[[modules]] +path = "damnit_api._db" +# clean: infrastructure imports nothing app-specific. +depends_on = [] + +[[modules]] +path = "damnit_api._mymdc" +# legitimate: infrastructure -> core. +depends_on = ["damnit_api", "damnit_api.shared"] + +[[modules]] +path = "damnit_api._logging" +depends_on = ["damnit_api.shared"] + +[[modules]] +path = "damnit_api.utils" +depends_on = ["damnit_api.shared"] + +[[modules]] +path = "damnit_api.state" +# composition root: may import everything (architecture.md rule 2). +depends_on = ["damnit_api.runs", "damnit_api._mymdc"] + +[[modules]] +path = "damnit_api.main" +# composition root: may import everything (architecture.md rule 2). +depends_on = [ + "damnit_api", + "damnit_api.shared", + "damnit_api.runs", + "damnit_api.graphql", + "damnit_api.metadata", + "damnit_api.contextfile", + "damnit_api.auth", + "damnit_api._mymdc", + "damnit_api._logging", + "damnit_api.state", +] + +# Public-interface enforcement: modules with no known cross-slice `_private` +# imports get an interface restricting them to non-underscore names, so a new +# private cross-slice import fails loudly. `metadata` is deliberately left off - +# its genuine `_get_proposal_meta` / `_update_proposal_meta` / +# `_get_proposal_meta_many` / `_local_proposal_meta` / `_local_proposal_number` +# cross-slice imports (see the DEBT comments above) would fail this check; +# bending the tool to pass would hide real debt, so it stays an uncovered gap +# (ADR-000). + +[[interfaces]] +expose = ["^[^_][^.]*(\\.[^_][^.]*)*$"] +from = ["damnit_api"] + +[[interfaces]] +expose = ["^[^_][^.]*(\\.[^_][^.]*)*$"] +from = ["damnit_api.auth"] + +[[interfaces]] +expose = ["^[^_][^.]*(\\.[^_][^.]*)*$"] +from = ["damnit_api.contextfile"] + +[[interfaces]] +expose = ["^[^_][^.]*(\\.[^_][^.]*)*$"] +from = ["damnit_api.shared"] + +[[interfaces]] +expose = ["^[^_][^.]*(\\.[^_][^.]*)*$"] +from = ["damnit_api._db"] + +[[interfaces]] +expose = ["^[^_][^.]*(\\.[^_][^.]*)*$"] +from = ["damnit_api._mymdc"] diff --git a/uv.lock b/uv.lock index dba008a3..5890a01b 100644 --- a/uv.lock +++ b/uv.lock @@ -454,6 +454,7 @@ dev = [ { name = "ruff" }, { name = "strawberry-graphql" }, { name = "syrupy" }, + { name = "tach" }, { name = "ty" }, { name = "zensical" }, ] @@ -470,6 +471,7 @@ docs = [ lint = [ { name = "pyright" }, { name = "ruff" }, + { name = "tach" }, { name = "ty" }, ] test = [ @@ -531,6 +533,7 @@ dev = [ { name = "ruff", specifier = ">=0.7" }, { name = "strawberry-graphql", extras = ["debug-server"], specifier = ">=0.283.3" }, { name = "syrupy", specifier = ">=5.5.2" }, + { name = "tach", specifier = ">=0.29" }, { name = "ty", specifier = ">=0.0.42" }, { name = "zensical", specifier = ">=0.0.47" }, ] @@ -547,6 +550,7 @@ docs = [ lint = [ { name = "pyright", specifier = ">=1.1.406" }, { name = "ruff", specifier = ">=0.7" }, + { name = "tach", specifier = ">=0.29" }, { name = "ty", specifier = ">=0.0.42" }, ] test = [ @@ -716,6 +720,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + [[package]] name = "graphql-core" version = "3.2.8" @@ -1272,6 +1300,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952, upload-time = "2026-03-24T15:11:23.801Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -1471,6 +1508,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1582,6 +1631,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1887,6 +1948,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1982,6 +2052,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/e0/2cba9f06de53b5f4ed4b9433694fe0b8e50d91cdce12371f2e9ae03129e3/syrupy-5.5.2-py3-none-any.whl", hash = "sha256:0856d5c1de82a3b47196e100357acab82ece733385d379e8f253478a9d28f76c", size = 54971, upload-time = "2026-07-08T07:45:30.717Z" }, ] +[[package]] +name = "tach" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitpython" }, + { name = "networkx" }, + { name = "prompt-toolkit" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/54/8bae607899ba9fd7abd9895215f2e5dc789a73529f8afeb0a10d2ee71500/tach-0.35.0.tar.gz", hash = "sha256:db8594820685f8ce316ef45d4dfa1a8ed39ce97dd873aa5006800d2668c3b3bb", size = 731143, upload-time = "2026-05-12T12:48:58.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/65/7ec3ac15167d9a2def25ed5cc6699348a940d7d24408d0c0b3e9bf14f1c3/tach-0.35.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:83b43975bcd23ddca2de388891d9f2cce7de3775682ac0a7857981adc4454787", size = 4042721, upload-time = "2026-05-12T12:48:52.149Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b6/e43617eaf57cd2f8a27935c87256eab63b8505e1c7b7a3e1947f2a612f88/tach-0.35.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:f14a3943783fa3285030468958718a0696d908d50a5c5119e2b42dae37e32114", size = 3910668, upload-time = "2026-05-12T12:48:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/936963142c9af18e7a59909960bf6d4645e82190471e162c28ba80dd2475/tach-0.35.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0109591f1be3d0e323769d495b1e303ee41305bf03e9143328000a40eee7059", size = 4262505, upload-time = "2026-05-12T12:48:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/87/0e/575f119447647e2321fc71bacf3909450b743fcbaf5015f11037bd304fee/tach-0.35.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:272234f3ff9f6cef54dffb5175a83313938461e37617c1fe18bbbaa2cc52179c", size = 4156922, upload-time = "2026-05-12T12:48:47.828Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/d235f6ba6be79c8c4490ee1469a73e2325d1f8f380c29954c4103b6aff09/tach-0.35.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:236d2113e4920e83fa60fd814d09a7fc9cbab87fd60a0a79d5afc9dd60a81102", size = 4576596, upload-time = "2026-05-12T12:49:02.238Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e5/254462c90ec22cd1f2f8f5dc27f4b8bef62940eb3775565366d3fb6cd563/tach-0.35.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c65273272f842c1490c2086279296a250a3eeb40b01c02a252b0c8021c52441", size = 5202902, upload-time = "2026-05-12T12:48:53.869Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/aeb321f3752522198a5e8a2f8b0e88cf36f54c108938ec50a845fe19290c/tach-0.35.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afa96c12355acf2a00dc27d3c39ccbca5516fa954640051d9dab9fd47a373d6d", size = 4284724, upload-time = "2026-05-12T12:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/37/07/65d158d670a3106f3b327e53187fdf812a2894a3286407154f802ade6874/tach-0.35.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162677645f6a28b0fd91d5d33334b1e92ac40c8085540f40bf072fc8ab6a2b0c", size = 4530463, upload-time = "2026-05-12T12:49:04.743Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/7fc5ee5e9521ab6d75c0fa9affad4f324bf70f0554c47251cba599a30b5e/tach-0.35.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e9940ec5f07200e349fb68d85939f72b659b96644c28d2f3d4e97d290ec85f95", size = 4437612, upload-time = "2026-05-12T12:48:46.409Z" }, + { url = "https://files.pythonhosted.org/packages/ea/06/5dd9e47839e9d5b202bf1f9d9d526940020f561d461607498938d2c55a5f/tach-0.35.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4162f07b9ca0364eb4f05530b2b6a66b6c6544fae8752e4378458f604346f800", size = 4432234, upload-time = "2026-05-12T12:48:49.178Z" }, + { url = "https://files.pythonhosted.org/packages/81/87/267e1ea74dc4bacce03d301991b39da58211f636325b1fed7bd83b5e9c40/tach-0.35.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:2c2e9fc654a7a250f3698b26872c89de7b42f649736e3c322cb91edcc749cd55", size = 4587446, upload-time = "2026-05-12T12:49:03.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c3/0859a8f4f85b167174a9173df79bc0f15b93908d69537a737f1c44e2d8b9/tach-0.35.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:e73aebad3af4aa99c20f72d54f270e40eac8681b5fd3bb439333936bfba5c892", size = 5330778, upload-time = "2026-05-12T12:48:55.096Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/e1842c063d0ac7be804797fab569ac782ef8a9fa349092c71125bc628e4d/tach-0.35.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e9275b65ff31e7ff9842b3bd7417a393115dbadd1fa8c3d953ae330cff7fbefb", size = 4565068, upload-time = "2026-05-12T12:49:06.024Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/054784261781dbcb8ac663a5d09af6c7996f5bde5230282b4c062dcf1f1f/tach-0.35.0-cp37-abi3-win32.whl", hash = "sha256:72dd6553d9fdd1e72fbd8e5b71892a463e8cd70b3ff6377b4f682aab100937d3", size = 3378975, upload-time = "2026-05-12T12:49:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1d/5dddf88fa9433bf4fcc76afad18e49d8e50e24f1f40c981c286edccd6c2d/tach-0.35.0-cp37-abi3-win_amd64.whl", hash = "sha256:746271a5313c348e00c1b85927260e3e1a75351d86b2da1746e85c5dd77051e6", size = 3671788, upload-time = "2026-05-12T12:48:59.619Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -2000,6 +2103,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "ty" version = "0.0.56" @@ -2197,6 +2309,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, ] +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + [[package]] name = "websockets" version = "16.0" From 5227904bfd7198ab75d342c60b678e3656f1d831 Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:47:04 +0200 Subject: [PATCH 2/5] chore(ci): run tach check in the test-api workflow --- .github/workflows/test-api.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-api.yml b/.github/workflows/test-api.yml index c2f014b1..35d287f4 100644 --- a/.github/workflows/test-api.yml +++ b/.github/workflows/test-api.yml @@ -51,6 +51,9 @@ jobs: - name: Ruff format check run: uv run --no-sync ruff format --check . + - name: Tach check + run: uv run --no-sync tach check + - name: Pytest run: uv run --no-sync pytest env: From 7d2ba18c6849ddcbcbfe0fdcc269af13c282810c Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:47:44 +0200 Subject: [PATCH 3/5] chore(pre-commit): add a tach hook --- .pre-commit-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d5861e9..9aa2c904 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,6 +62,13 @@ repos: # pass_filenames: false # stages: [pre-push] + - id: tach + name: tach + entry: bash -c 'cd api && uv run --group lint tach check' + language: system + files: ^api/ + pass_filenames: false + - id: frontend-eslint name: frontend-eslint entry: bash -c '. scripts/ensure-node.sh && cd frontend && pnpm exec eslint --fix --cache --cache-location node_modules/.cache/eslint/.eslintcache "${@#frontend/}"' -- From f9e7cfe134d723914d90bb015ec07ef94d8fdb15 Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:49:03 +0200 Subject: [PATCH 4/5] docs(api): describe tach enforcement in the import-direction rules --- api/docs/architecture.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/docs/architecture.md b/api/docs/architecture.md index 80f8336e..c372b43c 100644 --- a/api/docs/architecture.md +++ b/api/docs/architecture.md @@ -48,7 +48,7 @@ flowchart LR root --> slices --> infra --> core ``` -The key rules are: +Rules, enforced by [tach](https://github.com/gauge-sh/tach) (`api/tach.toml`, wired into CI and pre-commit): 1. **Downward only:** Slices import `core` and infrastructure - Slices never import the composition root or another slice's internals. @@ -60,10 +60,10 @@ The key rules are: - This means that services should not apply authorisation rules themselves. 5. **No `if settings.is_local:` outside the composition root:** Local mode is selected by composition, not conditionals throughout the codebase (see [ADR-008](adr/008-local-mode-composition.md)). -Note that these are currently only enforced by convention/review. Import linter/archetecture check tool is planned to be added. - !!! warning "Current issues" + The current code still violates some of these rules. Each violation is declared as a `DEBT(ADR-xxx)` edge in `api/tach.toml`, so `tach check` fails on any new one: + - `shared/gql.py`'s import-everything role - - Function-body imports working around circular imports - - Imports 'across' many modules and their files + - `graphql/` resolvers importing `runs` and `metadata` directly + - `auth` and `metadata` importing `runs` for local-mode proposal lookup From 4b0b9a08b4663b99b088985b302058e1d592e826 Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:51:30 +0200 Subject: [PATCH 5/5] docs(api/adr): record tach enforcement of import boundaries --- api/docs/adr/000-vertical-slice-architecture.md | 9 ++++----- api/docs/adr/002-no-global-mutable-state.md | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/api/docs/adr/000-vertical-slice-architecture.md b/api/docs/adr/000-vertical-slice-architecture.md index 95fae513..05f98bbf 100644 --- a/api/docs/adr/000-vertical-slice-architecture.md +++ b/api/docs/adr/000-vertical-slice-architecture.md @@ -24,7 +24,7 @@ Chosen option: a combination of **Vertical Slice Architecture** and **Ports and Slices give change-locality, a feature change touches one directory, and a reviewer can hold a slice in their head. Ports are applied only at the I/O boundaries (the external DAMNIT databases, MyMdC, auth, etc...), where swappability is useful (e.g. local dev vs. production, potential use at other facilities). -The dependency-direction rules that layering-style architectures enforce through folder structure can instead be enforced by a linter (e.g. import linter). +The dependency-direction rules that layering-style architectures enforce through folder structure are instead enforced by an import linter (tach). Full layering, Clean/Onion, and DDD, were rejected as they add a lot of boilerplate/abstraction/overhead to the codebase which is (at least currently) not needed, as the API server is a relatively thin, read-mostly viewer over externally-owned data, so patterns like aggregates, domain events, and use-case classes solve problems this service doesn't have. @@ -32,8 +32,8 @@ Full layering, Clean/Onion, and DDD, were rejected as they add a lot of boilerpl - Good: a feature change touches one directory; PRs map to slices. - Good: new domains get a package with a standard internal shape (`models`, `services`, `routers`/`gql`, `dependencies`), so structure decisions don't recur per feature. -- Good: once import linting lands, contract changes (a new allowed edge) become deliberate, reviewed edits to the linter config rather than drive-by imports. -- Bad: the layout alone guarantees nothing, developers have to ensure that they follow the architecture (although an import linter can be added to enforce the rules). +- Good: contract changes (a new allowed edge) are deliberate, reviewed edits to the tach config rather than drive-by imports. +- Bad: the layout alone guarantees nothing. The rules hold because tach enforces them, not because of the folder structure. ## Details @@ -95,7 +95,7 @@ damnit_api/ ### Naming Rules -- No `_underscore` package names: the prefix tracks no real boundary - a package is internal because nothing outside imports it, which import linting can enforce. (This is why `_db/` and `_mymdc/` become `appdb/` and `mymdc/`.) +- No `_underscore` package names: the prefix tracks no real boundary - a package is internal because nothing outside imports it, which tach enforces. (This is why `_db/` and `_mymdc/` become `appdb/` and `mymdc/`.) - No generic junk-drawer modules (`shared/`, `utils.py`): code either belongs to a feature slice, to `core/` (framework-free, shared), or to infrastructure. ### Dependency Direction @@ -110,5 +110,4 @@ damnit_api/ ### Follow-up / TODOs -- Import linting - `appdb` naming diff --git a/api/docs/adr/002-no-global-mutable-state.md b/api/docs/adr/002-no-global-mutable-state.md index 0b4ed995..e91415c1 100644 --- a/api/docs/adr/002-no-global-mutable-state.md +++ b/api/docs/adr/002-no-global-mutable-state.md @@ -48,7 +48,7 @@ Chosen option: a single frozen `AppState` dataclass built once in the lifespan. 3. There is exactly one composition/setup root: the app entrypoint and its lifespan (target shape: `create_app(settings)`, see the ADR-000 layout). - This is the only place that reads settings to select implementations. - Handlers, resolvers, and services receive dependencies via DI or plain parameters, they must **never** import them. - + - tach enforces this: nothing may import the composition root (`state.py`, `main.py`), so a handler cannot reach `AppState` by import. 4. Caches must be treated as state. - Any cache must be owned by an object that is itself created by a factory and reachable from `AppState`. - Module-level and class-level cache decorators on application code are banned.