AGENT SKILL

glimpse

GLIMPSE architecture rules for Python projects. Load before creating, moving, or reviewing anything under pacts, specs, mills, links, gates, or inits: services, repositories, DTOs, protocols, entry points, DI wiring. Covers layer import rules, file layout and slicing (noun, verb, page, port, adapter, kind), the __init__.py re-export policy, growth thresholds, required patterns, and drift red flags. Also use when deciding where new code goes or which test type a layer needs.

使用场景测试与质量
License 许可
MIT
最近更新
2026-11-12
Stars
★ 0
Forks
0

01 前言 Front Matter

name
glimpse
description
GLIMPSE architecture rules for Python projects. Load before creating, moving, or reviewing anything under pacts, specs, mills, links, gates, or inits: services, repositories, DTOs, protocols, entry points, DI wiring. Covers layer import rules, file layout and slicing (noun, verb, page, port, adapter, kind), the __init__.py re-export policy, growth thresholds, required patterns, and drift red flags. Also use when deciding where new code goes or which test type a layer needs.

03 SKILL.md 正文

<!-- Generated from SKILL.src.md — edit that, then run: mise run skill -->

GLIMPSE Architecture Reference

This file is the compressed form of the full reference at https://glimpse.fancysnake.dev/, which is authoritative where the two disagree and carries the worked examples and the reasoning behind each rule.

Layers

pacts   Protocols, DTOs, errors, enums, TypedDicts. Depends on nothing.
specs   Business invariants (pure constants, no IO). Only for mills; see below.
mills   Business logic, services. Depends on pacts + specs. No side-effect imports.
links   Repositories, external clients. pacts + ORM / driver / SDK.
gates   Entry points: request handlers, forms, routing, CLI commands. pacts only.
inits   DI container, middleware. The only layer where gates, mills, links meet.
edges   Settings, wsgi, manage.py. Outside GLIMPSE; empty on a CLI, never absent.

All seven exist from the first commit — edges/ as an empty package where the project has nothing for it. The import contracts are taken as a set on day one, and a contract can only name a module that exists.

Import rules enforced by importlinter (pyproject.toml → [tool.importlinter]). No exceptions without explicit approval.

What is fixed, and what is guidance. Absolute: which layer code belongs to, and which layer may import which. Everything below — slicing axes, thresholds, file names, patterns — is recorded practice. Follow it by default; treat an arrangement it does not describe as legitimate when it follows a real need and breaks no import rule. Absence of a rule is not a violation, so do not flag code for failing to match a shape this reference never required.

Composition is per-port. inits composes the object graph everywhere; how it reaches gates differs. Web: the framework dispatches to gates, so inits never imports them — middleware builds Services() per request and attaches it to the request (the framework's thread-safe seam). CLI: nothing dispatches, so inits imports the gate classes, injects mills into their constructors, and is named by dotted string in pyproject.toml ([project.scripts] → myproject.inits.cli:run).

edges is two-way isolated. Nothing imports edges; edges imports nothing first-party — it names project code only by dotted string (DJANGO_SETTINGS_MODULE, MIDDLEWARE, ROOT_URLCONF). The root middleware imports services, so it lives in inits, not edges.

specs or pacts — what it is, not where it is used. A constant more than one layer must enforce is a fact about the shape of the data (a max length lives in the column, the form, and the rule) — that is a contract, so it goes to pacts beside the DTO it constrains. A constant only the rule can observe (seat limit, grace period) is a business invariant and goes to specs, which mills alone may import: a threshold reaching links ends up encoded in the schema. Tell: change a specs value and only mills changes; change a pacts value and every enforcer changes together.

Configuration enters at inits. inits reads the environment and passes each value to the leaf that needs it; mills never reads settings at all. On a framework with a settings singleton, links and gates read it through the framework's accessor (django.conf.settings) — an import of the framework, not of edges. Without such a framework there is no settings layer at all, and edges/ stays empty. A leaf may still probe its own environment when the probe is injectable for tests; what the environment decides, inits decides. User input read at runtime — a config file, a command flag — is not configuration: it comes in through a port, shaped in pacts, validated in a mill.

"Framework-free" means no side effects, not package names. Forbidden in mills: imports that do IO, touch global state, or own control flow (ORM, HTTP machinery, settings access). Pure computation is fine wherever it comes from — django.utils.text.slugify qualifies. Enforcement level (ban package / review-guarded / ban effectful subtrees) is a per-project choice.

The rule guards against destructive operations and output the program depends on, not everything ambient: the clock, a random draw, a UUID, a log line are all fine in a mill, and all mockable. Logging reads better at the edge anyway (mills raise, gates catch and log) — a preference, not a rule. If a generated value is input to what the service does rather than something it produces on the way, take it as an argument; when the making is somebody else's job, that is a link, and a link earns its place even as a one-line wrapper because it names an external capability.

No DDD tactical patterns. GLIMPSE has no aggregates or value objects — data moves as DTOs and write TypedDicts; invariants live in service code. The slicing axes are GLIMPSE's own (see Slicing vocabulary).

File layout

pacts, specs, mills, inits start as single modules; links and gates are packages from day one. The first three are sliced by noun, and on day one you have one mills.py — don't plan further ahead. inits stays thin whatever the project does, so split it however is convenient. links and gates take the port axis immediately: the port is knowable before a line is written — you know you are building a CLI, you know you are talking to a database — and skipping it means renaming every import the day a second adapter appears.

# Day one — axis-free layers stay flat
pacts.py
specs.py
mills.py
inits.py
links/{port}/{adapter}.py                   # e.g. links/db/sqlite.py
gates/{port}/{adapter}.py                   # e.g. gates/cli/argparse.py
edges/__init__.py                           # empty until a framework fills it

# Grown
pacts/{noun}.py                             # or pacts/{noun}/{verb}.py
pacts/{port}.py                             # port machinery (e.g. pacts/db.py)
pacts/services.py                           # ServicesProtocol + service protocols
specs/{noun}.py
mills/{noun}.py                             # or mills/{noun}/{verb}.py
inits/repositories.py                       # a module per registry, plus one that binds
inits/services.py                           # (+ middleware.py on web, cli.py on a CLI)
links/{port}/{adapter}/{kind}.py            # while small (models.py, repositories.py)
links/{port}/{adapter}/{kind}/{module}.py   # when {kind} crosses threshold
links/{port}/{adapter}/__init__.py          # facade — re-exports the public surface
gates/{port}/{adapter}/{page}.py            # or .../{page_group}/{page}.py,
gates/{port}/{adapter}/{page}/{subpage}.py  #    or .../{page}/{subpage}.py

Thresholds for every promotion above: Growing rules.

__init__.py re-export policy. Default: keep __init__.py empty and import each symbol from the module that defines it (from pkg.foo.bar import Bar, not from pkg.foo import Bar). A facade __init__.py that re-exports a public surface is allowed only for: a framework or public-API package whose inner layout is implementation detail (the links adapter facade), relief from line-length pressure, or a pre-existing legacy facade. It is not the default.

Slicing vocabulary

Port : A delivery mechanism, named after the domain concept rather than the technology. : Examples: cli, web, db, payment_api, email : The first axis for links and gates, known before any code exists.

Adapter : The specific technology implementing a port. : Examples: postgres, sqlite, argparse, stripe, sendgrid : One port can have multiple adapters — usually coexisting, not interchangeable. payment_api/stripe and payment_api/paypal are both wired and both live; which one handles a given payment is a business decision made in a mill, through the protocols it holds. Genuine substitution (db/postgres vs db/sqlite) is the rarer case: one adapter wired per deployment, chosen in inits. : One technology can serve multiple ports. A full-stack framework shipping both an ORM and a request layer appears as db/{framework} and web/{framework} — two separate adapters that share nothing but a name.

Kind : The type of thing a links module holds, below the adapter. : Examples: models and repositories for a db adapter; transport, types, signer for an API client. : Kinds are per-adapter. The db shape is not a universal template.

Noun : A fat data cow — the model cluster everything else hangs off. : Examples: invoices, customers, proposals, users : The primary slicing axis for pacts, mills, and specs. : Which nouns exist depends on what the system is for, not on the words in the domain. An invoice tracker slices by invoices; a subscription service slices by billing, and an invoice is a detail inside it. : Plurality is not prescribed; it follows the noun. events are many, a panel is one. : Nouns are GLIMPSE's own axis, not a DDD import. If another axis fits a project better, slice by that instead; the layer rules don't change.

Verb : An activity cut inside a noun. : Examples: issue, refund, enroll, schedule : A verb module holds the records and logic of actions, not first-class data. Verbs nest inside nouns: invoices might cut into issue and refund. : No catch-all verbs. manage, organize, and misc name no activity — if you cannot name a real one, the file is not too big yet.

Page : What the user touches — the slicing axis for gates, below port and adapter. : Gates take whatever grouping the interface already has: a command or command group for a CLI, a page group and page — or a page and its subpages — for the web, a view for a TUI, a tool for MCP. : Gates mirror the shape of the interface; mills mirror the domain. The two need not line up, and forcing them to is how a sitemap ends up in mills.

Entity : A persistence-level concept: the unit that a DTO and a repository wrap. : Narrower than a noun — one noun spans many entities. : Conceptual, not a file-layout axis. links slices by kind, so one models.py holds many entities' models. There is no links/db/{adapter}/{entity}.py.

A noun contains verb cuts. A verb cut depends on entities.

Slicing rules

pacts, mills, specs — by noun, then verb. links — port / adapter / kind. gates — port / adapter / page. inits — however is convenient.

Each pacts module holds all boundary contracts for that noun or verb cut: DTOs, write TypedDicts, repository protocols, errors. Split by domain concern, not by technical kind — no pacts/dtos.py, pacts/protocols.py, or pacts/repos/ directories, and never a pacts/core.py or common bucket. A contract two nouns share gets no bucket either: it stays in the noun that needed it first, and earns its own module named after the thing it is (pacts/money.py) once the sharing makes the case. The ban is on the name, not on the extraction.

pacts placement algorithm — pacts mirrors the whole system; place each contract by three questions, in order: (1) tied to a noun? → pacts/{noun}.py (DTOs, write dicts, domain errors, repo protocols); (2) tied to a port? → pacts/{port}.py (e.g. pacts/db.py for TransactionProtocol — test: would it survive a total change of business domain?); (3) about the wiring? → pacts/services.py for ServicesProtocol and the service protocols it names — that module mirrors inits/services.py, and a service protocol describes a registry leaf, not the noun its methods mention.

Protocols exist where a boundary needs them, not by policy. Repository protocols: essential — mills depend on them. Service protocols: optional — needed for every service on a typed services namespace (web always; a CLI when inits hands the gate the whole registry rather than individual mills) and recommended for service-to-service dependencies. Gate classes: no protocols — nothing outside inits refers to them.

Errors are coarse and shared (NotFoundError, not ProposalNotFound); the gate catching one decides what it means for that screen, at the call-site — no central error-to-status mapping. Adapter code translates store exceptions into pacts errors (IntegrityError → DatabaseConstraintError), so no ORM exception reaches a mill.

Boundary vs core — where does it go? Decide by what the code does. If it crosses a boundary (data shapes moving between layers) it is a contract → pacts (DTOs, protocols). If it enforces business rules (service logic, invariants) it is core → mills. DTOs stay in pacts even though they feel like domain objects: repo protocols in pacts return them, so moving them to mills would make pacts → mills → pacts circular. A DTO is a data contract for a port, not a domain object.

Repo methods follow the needs. Parameters express variation within a use case; a different scope is a different method. Filtering an event's meetings by facilitator or topic: parameters. Switching events or including never-accepted meetings: a second use case → a second method. No generic query objects through the protocol.

A links adapter's kinds are its own — a db adapter is not the universal template. For db, the kinds are typically models (internal) and repositories (public, exposed through the facade and consumed via the protocols in pacts); a payment_api/stripe adapter may stay a single file, or split into transport / types / signer with no internal-vs-public distinction. The public face of any links adapter is whatever its facade re-exports — internal modules stay internal, so external code does from myproject.links.db.postgres import SessionRepository and never reaches models.

pacts and mills share the noun/verb axis, but nothing requires them to promote in lockstep — mills/ may be a package while pacts.py is still one file. Nothing enforces it and nothing depends on it.

Growing rules

Default: start as small as possible. Split only when size or friction makes the case for itself. Premature splitting creates churn, bloats the import graph, and makes the layout look complete before the requirements actually demand it.

Concrete thresholds — none is a hard line, all are "watch for this":

  • A layer becomes a package when it earns it. Promote mills.py → mills/ on any one of: it crosses ~1000 lines, two unrelated concerns in it cause merge friction, or a second noun genuinely exists. Not before. inits is a convenience call either way — it stays thin whatever it holds.
  • ~1000 lines per file — split a file when it crosses this and the two halves are unrelated enough that they cause merge friction. A 1500-line file holding one tightly coupled service is fine; a 600-line file holding three independent services is not.
  • ~12 public symbols per namespace level — applies to repository registries, the services tree, pacts noun modules, and the inits namespaces. At 13+ leaves, introduce a sub-bucket grouped by noun or verb. With ≤12, stay flat.
  • Folder must contain at least 2 files before it exists. Never create inits/services/invoices/issuing/ for a single leaf. Never create pacts/{noun}/{verb}.py while the noun has only one cut. Reverse the speculative scaffold; flatten back when the leaf count drops.
  • Split links by kind first. Default: one file per kind. When a kind crosses ~1000 lines, promote it to a package and split into submodules (kind1/part1.py, kind1/part2.py) — same pattern as growing views.py into views/. Do not create suffixed siblings (kind1_a.py). The baseline is halve, don't shard, and arrange parts to avoid circular imports; the right grouping is adapter-specific (e.g. db models often split by foreign-key dependency hierarchy — the entities that change together — repositories along the same lines; an external-API adapter may not need to split at all). The links/{port}/{adapter}/__init__.py facade keeps the public import path stable across the promotion. Framework technicality: if the ORM discovers model classes by importing the package (Django does), models/__init__.py must re-export them; repositories/__init__.py can stay empty since the facade lives at the parent.

When in doubt, keep it flat. The ~12 rule and the ~1000-line rule are escape hatches, not invitations.

Patterns

  1. Entry points return DTOs, never models. Templates, serializers, and CLI output receive DTOs from pacts. ORM instances never leave links.
  2. Entry points call services, not repos. request.services.<name>.method(...) is the data path out of a view — pacts is the only project import a gate has, so never a repo, a model, or a service class, and never a repository reached directly. Services are exposed as a flat namespace wired in inits/services.py; CLI gates receive theirs at construction.
  3. Services take specific repo protocols + a TransactionProtocol via constructor — not imports of concrete repos, not dependencies passed as method arguments. With an ambient ORM (Django), never a whole Unit of Work; with a session-based ORM (SQLAlchemy) the session already is one, and injecting it is idiomatic. ISP at the service boundary: declare the two-or-three protocols actually used.
  4. Mills have no side-effect imports. Only protocols and DTOs from pacts, constants from specs, pure helpers from anywhere (see Layers).
  5. Writes use TypedDicts. DTOs for reads, TypedDicts for writes — gates → mills as input, mills → links as what repo write methods accept (create(data: CreateProposalDict) -> ProposalDTO; a CreateXDict has no id — the store assigns it). The split is the id: a nullable one on the DTO would push a null check into everything that touches it, and a write shape is short-lived enough that it never makes the trip a DTO makes.
  6. Web requests typed via a gate-local typing-only subclass of the framework request (class RootRequest(HttpRequest): services: ServicesProtocol in the web adapter) — never instantiated; middleware mutates the real request. Only ServicesProtocol comes from pacts. CLI gates have no context.
  7. Multi-repo writes use transaction.atomic() from TransactionProtocol. Entry points never start transactions; that is a service concern. The protocol: atomic() and savepoint(), both returning a context manager; savepoint() rolls back only its block on constraint violation, re-raising as a pacts error with the outer transaction usable. The implementation is inits binding glue when it wraps an ambient ORM with no store behind it, and a links adapter when it holds a connection.
  8. New repo methods need matching Protocol in pacts.
  9. DTOs must be constructible from what the adapter loaded. Pydantic is not required — a dataclass, NamedTuple, or attrs class is a DTO too (write shapes are already TypedDict, so pacts can be pure stdlib); pick one and use it throughout. With Pydantic, attribute rows (an ORM instance) need model_config = ConfigDict(from_attributes=True); mapping rows (sqlite3.Row, a dict cursor) validate from dict(row) with no config; without it, construction is a plain call. A row that does not match the DTO is mapped in the repository, not by a method on the DTO.
  10. New repositories exposed as @cached_property on inits/repositories.py (flat). New services exposed as @cached_property on inits/services.py (flat, zero-arg Services() builds its own dependencies — no DI inside the composition root). Lifetimes: @cached_property = per container (per request); @functools.lru_cache on a module-level inits factory = per process (pooled clients, connections). Closing follows the owner: a process-lifetime connection is wound down in inits (often nothing to write — exit does it); one opened mid-flight is closed by a context manager the link exposes, like TransactionProtocol. See Growing rules for when to bucket.
  11. Protocol implementations declare the protocol as a base class — where a protocol exists — so the intent is explicit and a type checker verifies conformance. This assumes one runs: an explicit Protocol subclass inherits the stub bodies, so an unimplemented method returns None rather than failing. Exception: very generic structural protocols (TransactionProtocol, callbacks) with multiple unrelated duck-typed implementations.
  12. Gates validate format, mills validate meaning. A gate checks input parses (an email, an int); a mill checks it makes sense ("email or username required", seat limits from specs). Parse vs semantics, not single-field vs cross-field. Permissions: trivial checks (is_authenticated) in gates; rule-bearing permission systems in mills.
  13. Django apps are markers, not structure. An AppConfig sits at the lowest directory Django must discover (models, commands, templatetags), with a custom label (names all end in .django — default labels collide). Migrations live in links/db/{framework}/migrations/; admin (model-coupled by design) at links/db/django/admin.py; plain Forms only, never ModelForm. Framework-owned surfaces (request.user, django_login) are named exemptions — contain them in gates; mills see ids and DTOs.

Dependency direction

Cross-noun access is fine. Repos cross nouns freely — data access is not behavior. An entry point on one noun's turf reading another noun's data is normal, not a boundary violation. The smell to watch is duplicated behavior across nouns; the fix is a shared lower-level mill function that both call, not a rule against cross-noun repo reads.

Service-to-service calls are fine when reusing real orchestration. The genuine smells are narrower: layering inversion (a low-level unit depending on a high-level orchestrator), cycles, and anemic delegation (one service calling another for a single trivial read it could do via a repo).

Testing

The layer under test dictates the test type — not convenience, not what is easiest for coverage:

  • Pure-logic core (mills) → unit tests. No IO; the service gets MagicMocks for the repo protocols (and for data when only one field matters); assert how the mocks were called. MagicMock covers TransactionProtocol — it speaks the context-manager protocol.
  • IO-bearing boundary layers (links, gates, adapters, templates) → integration tests against real infrastructure. Mock at the lowest level or not at all; assert side effects. A gate test is a full-request test via the framework's test client, asserting all fields of the response (context data, redirect URL, status), with strict templates that fail on unknown variables.

Tests live in a repo-root tests/ split by type — tests/unit/ (mills, plus pure helpers from any layer; organised by convenience, not mirroring the code) and tests/integration/ (gates by port + page, links by port + adapter).

An uncovered line is covered by the test type that owns its layer — never raise links/gates coverage with a mock-everything unit test of IO-bearing code (views, commands, repositories, importers). Exception: a pure, IO-free helper (no DB, HTTP, request/response, template render, or framework objects) may be unit-tested wherever it lives.

Drift red flags

Layout and slicing

links.py or gates.py as a single file : Both need the {port}/{adapter} axis from day one. The port is knowable before any code is written; deferring it costs an import rewrite the day a second adapter appears.

A layer promoted to a package before it earned it : pacts/, specs/, or mills/ as a directory while there is one noun, well under ~1000 lines, and no merge friction. The tree is anticipating nouns you have not discovered.

Nested folders holding one or two small files : pacts/invoices/issue/create.py when pacts/invoices/issue.py would do, or inits/services/invoices/issuing.py with no sibling. A folder needs at least two leaves to exist.

Port axis inside mills or specs : mills/web/proposals.py or specs/api/.... Mills and specs have no delivery-mechanism axis. If you see a port word inside these layers, the code belongs elsewhere.

A catch-all verb module : manage.py, organize.py, misc.py inside a noun. A verb cut must name a real activity.

A noun axis inside gates : gates/web/django/invoices.py when the interface has no such page. Gates mirror the interface; mills mirror the domain. The two trees are not expected to match.

common, shared, utils, or entities as a module or folder name : Magnets for unrelated code. Each says where a file sits, not what it holds, so anything can be filed there and nothing can ever be found. Shared types go to pacts, under the noun that needed them first; everything else takes a name from the axis it belongs to. The exception is a real concept that happens to carry the word — a DOMEntity in a browser-port adapter earns entities.py; a bag of dataclasses does not.

pacts

pacts split by technical kind instead of noun : pacts/dtos.py, pacts/protocols.py, pacts/repos/. These group by what the type is, not by what domain concern it belongs to. This forces unrelated nouns to share files and makes the package harder to navigate.

pacts/core.py, pacts/common.py, or similar : A common/ bucket wearing a nicer name. Every contract has a principled home under the noun / port / wiring axes.

A DTO that cannot be built from a store row or ORM instance : Repositories cannot return it. Whatever the project uses for DTOs — Pydantic is not required — construction has to work from the row the adapter loaded. A row that does not match the DTO is mapped in the repository, not by a method on the DTO.

A protocol implementation that does not name the protocol as a base class : The conformance check is left to a structural match that can silently drift. The exception is very generic structural protocols — TransactionProtocol, callbacks — with multiple unrelated duck-typed implementations.

specs

specs imported from links, gates, or inits : specs are business invariants, and business rules are enforced in mills alone. A constant needed elsewhere is either a contract (pacts — a max length or an allowed range is a fact about the shape of the data, and belongs beside the contract it constrains) or configuration, which enters at inits or comes from the framework's settings accessor where there is one.

specs reading from os.environ or settings, or performing IO : It is a constants layer. Environment-dependent values enter at inits.

mills

mills importing anything with side effects : An ORM, HTTP machinery, a CLI parser, settings access — absolute violation. Pure computation is fine wherever it comes from. So is the ambient stuff the rule was never about: the clock, a random draw, a UUID, a log line.

A service taking a whole Unit of Work instead of the protocols it uses : Applies to ambient-ORM projects. With a session-based ORM the session already is a unit of work, and injecting it is idiomatic.

A page axis inside mills : Gates mirror the interface, mills mirror the domain. A sitemap in mills is the interface leaking inward.

links

Model and repository in the same links file : This collapses the internal-vs-public boundary. Models are internal to the adapter; repositories are its public surface.

links files named per entity : links/db/postgres/user.py. links slices by kind, not by entity. One models.py holds many entities' models.

Suffix-sibling links files : repositories_invoices.py, models_users.py. Promote the kind to a {kind}/ package with submodules instead. Halve, don't shard.

A links facade that re-exports models, or omits a public repository : links/{port}/{adapter}/__init__.py is the public surface. Whatever it exports is public; everything else is internal.

An ORM model imported from outside links/ : Use the repository protocol from pacts instead.

A repository imported directly in a gate or a mill : Inject it through inits.

gates

A gate importing project code other than pacts : An ORM model, a repository class, a service class from mills — all the same violation. A gate calls services through their protocols. If none exists, create one — a mill in mills, a protocol in pacts, a leaf in inits/services.py — before writing the gate.

A gate returning ORM instances to templates or serializers : Return DTOs from pacts. ORM instances never leave links.

A gate that opens a transaction : Atomicity is a service concern.

Business rules in form validation : Gates validate format — an email, an int, a date. Meaning ("email or username required", seat limits) belongs in mills, which alone may read specs.

A non-port axis at the top level of gates : gates/mills/.... The first axis below gates is always the port.

inits

A gate constructing repository or service instances : That is the wiring inits owns, and doing it in a gate breaks it.

mills importing from inits : The dependency runs the other way. inits knows the concrete classes; mills sees only protocols.

inits containing business logic : It should only wire, never decide.