enola-guides 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +59 -8
- data/examples/ember-app/enola/constraints/ember.yaml +10 -0
- data/examples/ember-app/enola/recipes/ember-conventions.yaml +83 -0
- data/examples/laws/README.md +32 -0
- data/examples/laws/clean-rings/README.md +10 -0
- data/examples/laws/clean-rings/enola/constraints/clean.yaml +8 -0
- data/examples/laws/cluster-laws/README.md +9 -0
- data/examples/laws/cluster-laws/enola/constraints/cluster.rb +24 -0
- data/examples/laws/cqrs-queries-never-write/README.md +11 -0
- data/examples/laws/cqrs-queries-never-write/enola/constraints/cqrs.yaml +7 -0
- data/examples/laws/foreign-keys/README.md +10 -0
- data/examples/laws/foreign-keys/enola/constraints/foreign-keys.yaml +12 -0
- data/examples/laws/governed-code/README.md +11 -0
- data/examples/laws/governed-code/enola/constraints/governance.rb +8 -0
- data/examples/laws/instantiation/README.md +14 -0
- data/examples/laws/instantiation/enola/constraints/ceremony.yaml +11 -0
- data/examples/laws/maintenance-tasks/README.md +9 -0
- data/examples/laws/maintenance-tasks/enola/constraints/maintenance-tasks.rb +15 -0
- data/examples/laws/mixins-stay-independent/README.md +11 -0
- data/examples/laws/mixins-stay-independent/enola/constraints/concerns.yaml +9 -0
- data/examples/laws/naming-conventions/README.md +10 -0
- data/examples/laws/naming-conventions/enola/constraints/naming.yaml +5 -0
- data/examples/laws/one-door/README.md +8 -0
- data/examples/laws/one-door/enola/constraints/one-door.yaml +18 -0
- data/examples/laws/one-owner-per-table/README.md +12 -0
- data/examples/laws/one-owner-per-table/enola/constraints/tables.rb +8 -0
- data/examples/laws/paired-names/README.md +9 -0
- data/examples/laws/paired-names/enola/constraints/pairs.yaml +11 -0
- data/examples/laws/parts-never-cycle/README.md +12 -0
- data/examples/laws/parts-never-cycle/enola/constraints/cycles.yaml +12 -0
- data/examples/laws/protocol-any-of/README.md +10 -0
- data/examples/laws/protocol-any-of/enola/constraints/entry.yaml +8 -0
- data/examples/laws/public-surface/README.md +11 -0
- data/examples/laws/public-surface/enola/constraints/surface.yaml +10 -0
- data/examples/laws/public-tool-prefix/README.md +8 -0
- data/examples/laws/public-tool-prefix/enola/constraints/public-tools.rb +8 -0
- data/examples/laws/rails-forbidden-names/README.md +14 -0
- data/examples/laws/rails-forbidden-names/enola/constraints/naming.yaml +13 -0
- data/examples/laws/rails-mutations-are-authorized/README.md +11 -0
- data/examples/laws/rails-mutations-are-authorized/enola/constraints/authorization.rb +9 -0
- data/examples/laws/rails-request-boundaries/README.md +12 -0
- data/examples/laws/rails-request-boundaries/enola/constraints/rails.yaml +13 -0
- data/examples/laws/rails-strict-arrangement/README.md +10 -0
- data/examples/laws/rails-strict-arrangement/enola/constraints/strict.yaml +12 -0
- data/examples/laws/readers-read-the-store-only/README.md +7 -0
- data/examples/laws/readers-read-the-store-only/enola/constraints/readers.yaml +13 -0
- data/examples/laws/receiverless-request-api/README.md +10 -0
- data/examples/laws/receiverless-request-api/enola/constraints/bare.yaml +10 -0
- data/examples/laws/resolved-ancestry/README.md +15 -0
- data/examples/laws/resolved-ancestry/enola/constraints/records.yaml +11 -0
- data/examples/laws/routes-have-consumers/README.md +12 -0
- data/examples/laws/routes-have-consumers/enola/constraints/consumers.rb +8 -0
- data/examples/laws/storage-stays-home/README.md +12 -0
- data/examples/laws/storage-stays-home/enola/constraints/ownership.rb +9 -0
- data/examples/laws/vanilla-rails-stays-empty/README.md +10 -0
- data/examples/laws/vanilla-rails-stays-empty/enola/constraints/vanilla.yaml +11 -0
- data/examples/rails-shop/enola/constraints/laws.rb +93 -0
- data/examples/rails-shop/enola.yaml +9 -0
- data/guides/01-start-here.md +136 -0
- data/guides/02-rails.md +168 -0
- data/guides/03-ember.md +131 -0
- data/guides/04-writing-laws.md +108 -0
- data/guides/05-constraints-in-ci.md +67 -0
- data/guides/06-hooks-and-agents.md +74 -0
- data/guides/07-providers.md +90 -0
- data/guides/08-beyond-one-repo.md +99 -0
- data/lib/enola-guides.rb +1 -1
- data/recipes/README.md +52 -0
- data/recipes/ember-conventions.yaml +83 -0
- data/recipes/house-recipe-template.yaml +27 -0
- data/templates/constraints-starter.rb +30 -0
- data/templates/enola-check.sh +152 -0
- data/templates/enola-cluster.yaml +25 -0
- data/templates/enola-findings.rb +83 -0
- data/templates/skills/enola/SKILL.md +39 -0
- data/templates/skills/enola-grade-before-push/SKILL.md +21 -0
- data/templates/skills/enola-intent-first/SKILL.md +25 -0
- data/templates/skills/enola-laws/SKILL.md +35 -0
- metadata +80 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 103217d2be760b0b582027a9119e6acc6fae874ffc1171f8e59df35ac5ffb09b
|
|
4
|
+
data.tar.gz: 65f13f8cd1c54cafec16aca3f50a80710dcc77df8f6e66077c54fb346afeaf3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cac7aed45a1041aeaeb66c46040f165dfa4156b05d5c2c088890dd18912930b6924059b5f0b1bcc00c4379b7574e524ab8794a43c936a5e5915a4afccf7572c
|
|
7
|
+
data.tar.gz: fe230a7e9daac6c8c88662bdcce76f1be1ff8019992c48f57a95afb469fc4bad58bd02e6171d62603e796b80cd31938826d59a5c8010adada9fa116d5a16e13d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 (2026-08-22)
|
|
4
|
+
|
|
5
|
+
The laws catalogue: `examples/laws/`, twenty-six complete declarations one
|
|
6
|
+
directory each, nineteen proven by enola's benchmark suite and seven
|
|
7
|
+
generalised from daily use (foreign keys, maintenance tasks, a public tool
|
|
8
|
+
prefix, one door to a schema, readers that read the store only, cluster
|
|
9
|
+
laws), each with a README and verified with `constraints lint`.
|
|
10
|
+
|
|
11
|
+
## 0.1.0 (2026-08-22)
|
|
12
|
+
|
|
13
|
+
First content. Eight guides (start here, a Rails application, an Ember
|
|
14
|
+
application, writing laws, constraints in CI, hooks and agents,
|
|
15
|
+
providers, beyond one repository), two worked examples (a Rails shop in
|
|
16
|
+
the Ruby DSL with Prism and Rubydex as providers; an Ember app bound to
|
|
17
|
+
the conventions recipe), four agent skills (enola, enola-laws,
|
|
18
|
+
enola-grade-before-push, enola-intent-first), the CI check template with
|
|
19
|
+
its renderer, a cluster configuration, a constraints starter, and two
|
|
20
|
+
recipes. Written against the release of enola that carries the
|
|
21
|
+
constraints vocabulary (enola-labs/enola#247).
|
|
22
|
+
|
|
23
|
+
## 0.0.0
|
|
24
|
+
|
|
25
|
+
Placeholder to claim the name.
|
data/README.md
CHANGED
|
@@ -1,12 +1,63 @@
|
|
|
1
1
|
# enola-guides
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[enola](https://github.com/enola-labs/enola)
|
|
5
|
-
|
|
3
|
+
How to use what is landing in [enola](https://github.com/enola-labs/enola)
|
|
4
|
+
([enola-labs/enola#247](https://github.com/enola-labs/enola/pull/247)): a
|
|
5
|
+
Ruby DSL for writing architecture constraints, Prism and Rubydex as
|
|
6
|
+
providers that run together with enola's own extractors, recipes a team
|
|
7
|
+
reviews, laws that read the whole graph, and an MCP your agent uses during
|
|
8
|
+
a session. Guides, worked examples, skills and templates, packaged as a
|
|
9
|
+
gem so a team can pin the guidance it adopted. Rubyists first; consumed by
|
|
10
|
+
[enola-rb](https://github.com/misabegovic/enola-rb), the gem that runs
|
|
11
|
+
enola. Expanding as the practice does.
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
```bash
|
|
14
|
+
gem install enola-guides
|
|
15
|
+
gem contents enola-guides
|
|
16
|
+
```
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
## Start with an example
|
|
19
|
+
|
|
20
|
+
[`examples/rails-shop`](examples/rails-shop): a Rails application declaring
|
|
21
|
+
thirteen laws in the Ruby DSL, from "jobs never invoke controller code" to
|
|
22
|
+
"billing keeps to its own tables" and "a mutating action is authorized",
|
|
23
|
+
with Prism and Rubydex configured as providers beside the extractors.
|
|
24
|
+
[`examples/ember-app`](examples/ember-app): an Ember application bound to
|
|
25
|
+
the conventions recipe in one block. [`examples/laws`](examples/laws/README.md):
|
|
26
|
+
twenty-six laws, one directory each, from foreign keys on a tenant column
|
|
27
|
+
to parts that may not cycle, each a complete declaration you copy and run.
|
|
28
|
+
|
|
29
|
+
## Guides
|
|
30
|
+
|
|
31
|
+
1. [Start here](guides/01-start-here.md): install, snapshot, baseline, a
|
|
32
|
+
graded change, the hooks, a first law. Ten minutes.
|
|
33
|
+
2. [A Rails application](guides/02-rails.md): what enola measures, the
|
|
34
|
+
Prism and Rubydex providers, the recipe, the laws a Rails team writes.
|
|
35
|
+
3. [An Ember application](guides/03-ember.md): the Ember facts, the
|
|
36
|
+
conventions as a recipe, an app inside a larger repository.
|
|
37
|
+
4. [Writing laws](guides/04-writing-laws.md): parts, the twenty-one
|
|
38
|
+
sentences, modes, dates, exemptions, what a refusal means.
|
|
39
|
+
5. [Constraints in CI](guides/05-constraints-in-ci.md): the check step.
|
|
40
|
+
6. [Hooks and agents](guides/06-hooks-and-agents.md): the session hooks,
|
|
41
|
+
the MCP tools, the skills.
|
|
42
|
+
7. [Providers](guides/07-providers.md): the seam, the Ruby pair, the
|
|
43
|
+
census, the runtime capture.
|
|
44
|
+
8. [Beyond one repository](guides/08-beyond-one-repo.md): the cluster, the
|
|
45
|
+
seams, the laws across them, the history.
|
|
46
|
+
|
|
47
|
+
## Skills
|
|
48
|
+
|
|
49
|
+
The skills under [`templates/skills/`](templates/skills) are the ones used
|
|
50
|
+
day to day with enola's MCP in an agent session: `enola` (ask the graph
|
|
51
|
+
before grepping, grade after changing), `enola-laws` (write a law as a
|
|
52
|
+
sentence with a reason, lint it, read what it verdicts),
|
|
53
|
+
`enola-grade-before-push` (the delta is the deliverable, a declined grade
|
|
54
|
+
is not a pass), `enola-intent-first` (read the governing decision before
|
|
55
|
+
the file moves). Copy a directory into your agent's skills folder.
|
|
56
|
+
|
|
57
|
+
## Templates and recipes
|
|
58
|
+
|
|
59
|
+
[`templates/`](templates): the CI check script and its comment renderer,
|
|
60
|
+
a cluster configuration, a constraints starter. [`recipes/`](recipes/README.md):
|
|
61
|
+
recipes a repository copies into its own `enola/recipes/` and binds in one
|
|
62
|
+
line, starting with the Ember conventions and a house-recipe template;
|
|
63
|
+
starting points the team reviews, never defaults the tool applies.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The Ember conventions bound in one block. The recipe is copied from
|
|
2
|
+
# enola-guides' recipes/ into this repository's enola/recipes/, where the
|
|
3
|
+
# team reviews it; the two optional roles are bound to this app's helpers.
|
|
4
|
+
use_recipe:
|
|
5
|
+
- recipe: ember-conventions
|
|
6
|
+
as: app
|
|
7
|
+
mode: ratchet
|
|
8
|
+
bind:
|
|
9
|
+
task-factories: { match: ["app/utils/**"], name_pattern: "*.trackedFunction" }
|
|
10
|
+
fixture-setup: { match: ["tests/helpers/**"] }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
recipe: ember-conventions
|
|
2
|
+
roles:
|
|
3
|
+
- name: components
|
|
4
|
+
match: ["app/components/**"]
|
|
5
|
+
- name: integration-tests
|
|
6
|
+
kind: test_ref
|
|
7
|
+
match: ["tests/integration/**"]
|
|
8
|
+
- name: actions
|
|
9
|
+
kind: symbol
|
|
10
|
+
where: { decorators: action }
|
|
11
|
+
- name: framework-controllers
|
|
12
|
+
kind: symbol
|
|
13
|
+
where: { superclass_module: "@ember/controller" }
|
|
14
|
+
- name: constructors
|
|
15
|
+
match: ["app/**"]
|
|
16
|
+
name_pattern: "*.constructor"
|
|
17
|
+
- name: fetchers
|
|
18
|
+
match: ["app/services/**"]
|
|
19
|
+
- name: legacy-accessor
|
|
20
|
+
match: ["app/utils/**"]
|
|
21
|
+
name_pattern: "*.get"
|
|
22
|
+
- name: argument-mutators
|
|
23
|
+
kind: symbol
|
|
24
|
+
where: { fields_written: args }
|
|
25
|
+
- name: element-modifiers
|
|
26
|
+
kind: symbol
|
|
27
|
+
match: ["app/components/**", "app/modifiers/**"]
|
|
28
|
+
- name: state-writing-members
|
|
29
|
+
kind: symbol
|
|
30
|
+
where: { fields_written: "<any>" }
|
|
31
|
+
- name: task-factories
|
|
32
|
+
optional: true
|
|
33
|
+
- name: fixture-setup
|
|
34
|
+
optional: true
|
|
35
|
+
rules:
|
|
36
|
+
- id: no-action-decorator
|
|
37
|
+
forbid_fact: actions
|
|
38
|
+
because: "An arrow function binds lexically and needs no decorator; the decorator predates it and the convention says to stop writing it."
|
|
39
|
+
|
|
40
|
+
- id: no-framework-controllers
|
|
41
|
+
forbid_fact: framework-controllers
|
|
42
|
+
because: "A route component replaces the controller and its template. Controllers are quirky and carry surprising singleton state, and the convention is to stop adding them."
|
|
43
|
+
|
|
44
|
+
- id: components-import-no-render-modifiers
|
|
45
|
+
forbid: components
|
|
46
|
+
to_name: ["@ember/render-modifiers*", "ember-render-helpers*"]
|
|
47
|
+
via: imports
|
|
48
|
+
because: "The render-helper form fires on a lifecycle rather than attaching to the element it acts on, and it has been discouraged for years. A named modifier attaches to the element and says what it acts on."
|
|
49
|
+
|
|
50
|
+
- id: constructors-do-not-fetch
|
|
51
|
+
forbid: constructors
|
|
52
|
+
to: fetchers
|
|
53
|
+
via: calls
|
|
54
|
+
because: "Rendering pulls data in as it is needed. A constructor that fetches is eager, runs before anything asks, and cannot be re-run when the arguments change."
|
|
55
|
+
|
|
56
|
+
- id: no-legacy-property-accessor
|
|
57
|
+
forbid: components
|
|
58
|
+
to: legacy-accessor
|
|
59
|
+
via: calls
|
|
60
|
+
because: "The proxy accessor reads through a layer that no longer exists on a resolved value, and it defeats type checking. Reach for the property."
|
|
61
|
+
|
|
62
|
+
- id: arguments-are-not-mutated
|
|
63
|
+
forbid_fact: argument-mutators
|
|
64
|
+
because: "Data flows down and actions flow up. A component that writes through its own arguments changes state it does not own, and the owner never learns of it. Pass a callback and let the owner make the change."
|
|
65
|
+
|
|
66
|
+
- id: a-modifier-takes-its-element
|
|
67
|
+
require: element-modifiers
|
|
68
|
+
when_edge_to: ["*.modifier"]
|
|
69
|
+
via: calls
|
|
70
|
+
must_prop_contain: { prop: takes_parameters, value: "yes" }
|
|
71
|
+
because: "A modifier is handed the element it is attached to. One that declares no parameter is not modifying anything; it is a side effect fired by render, and a getter or a tracked task says that honestly."
|
|
72
|
+
|
|
73
|
+
- id: derivations-do-not-write
|
|
74
|
+
forbid: state-writing-members
|
|
75
|
+
to: task-factories
|
|
76
|
+
via: calls
|
|
77
|
+
because: "A task is derived state and runs inside the render cycle. Writing the component's own state from one schedules another render off the render it is running in. Return the data and read it through the task's value instead."
|
|
78
|
+
|
|
79
|
+
- id: component-tests-carry-no-fixtures
|
|
80
|
+
forbid: integration-tests
|
|
81
|
+
to: fixture-setup
|
|
82
|
+
via: calls
|
|
83
|
+
because: "A fixture library sets up back-end state and has no place in a component test: if the component makes no request, build a front-end record, and if it does, let the acceptance layer answer it."
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Laws, one directory each
|
|
2
|
+
|
|
3
|
+
Every directory holds a complete declaration you can copy into a repository's
|
|
4
|
+
`enola/` and run. Most are proven by enola's own benchmark suite; the rest are
|
|
5
|
+
generalised from laws in daily use on a multi-repository estate.
|
|
6
|
+
|
|
7
|
+
- [`governed-code`](governed-code/README.md): The knowledge pages compile into the graph as anchors, and a law demands that every file of a part has one.
|
|
8
|
+
- [`rails-forbidden-names`](rails-forbidden-names/README.md): A naming convention phrased as a prohibition.
|
|
9
|
+
- [`rails-mutations-are-authorized`](rails-mutations-are-authorized/README.md): The code behind a mutating route is selected by the route's method through the handled_by edge the Rails binder draws, and must reach a policy.
|
|
10
|
+
- [`rails-request-boundaries`](rails-request-boundaries/README.md): The Rails laws a team actually writes.
|
|
11
|
+
- [`rails-strict-arrangement`](rails-strict-arrangement/README.md): The strict Rails arrangement: the Rails laws, no circle among the parts, and concerns that stay independent of their includers.
|
|
12
|
+
- [`clean-rings`](clean-rings/README.md): Four rings.
|
|
13
|
+
- [`cqrs-queries-never-write`](cqrs-queries-never-write/README.md): A command-query split.
|
|
14
|
+
- [`instantiation`](instantiation/README.md): What Ruby code constructs, as a fact.
|
|
15
|
+
- [`mixins-stay-independent`](mixins-stay-independent/README.md): A module never reaches the classes that include it.
|
|
16
|
+
- [`naming-conventions`](naming-conventions/README.md): Ruby naming conventions: no get_, set_ or is_ prefixes; a predicate ends in a question mark and has_ is fine.
|
|
17
|
+
- [`one-owner-per-table`](one-owner-per-table/README.md): unique_across compares the members of different repositories; with the storage facts of one repository there is nobody to share a table with, so the rule refuses by name rather than reporting every table as unique.
|
|
18
|
+
- [`paired-names`](paired-names/README.md): A naming pair: every with_ method has a without_ twin.
|
|
19
|
+
- [`parts-never-cycle`](parts-never-cycle/README.md): Parts that may not depend on each other in a circle.
|
|
20
|
+
- [`protocol-any-of`](protocol-any-of/README.md): A protocol satisfied by one of several methods.
|
|
21
|
+
- [`public-surface`](public-surface/README.md): A path-defined public surface.
|
|
22
|
+
- [`receiverless-request-api`](receiverless-request-api/README.md): A literal far end narrowed to receiver-less calls.
|
|
23
|
+
- [`resolved-ancestry`](resolved-ancestry/README.md): Ancestry a provider resolved, and a component selected by it.
|
|
24
|
+
- [`routes-have-consumers`](routes-have-consumers/README.md): A law across repositories refuses by name inside one.
|
|
25
|
+
- [`storage-stays-home`](storage-stays-home/README.md): A part keeps to the tables it owns.
|
|
26
|
+
- [`vanilla-rails-stays-empty`](vanilla-rails-stays-empty/README.md): Plain Rails: the extra directories must stay empty, each with a stated reason.
|
|
27
|
+
- [`foreign-keys`](foreign-keys/README.md): A foreign-key law reads the storage facts the schema yields: every table carrying a tenant column must declare the foreign key to the tenant table.
|
|
28
|
+
- [`maintenance-tasks`](maintenance-tasks/README.md): Two laws for a maintenance-tasks directory: a naming convention the runner depends on, and a boundary against controller code that has no request to read.
|
|
29
|
+
- [`public-tool-prefix`](public-tool-prefix/README.md): A naming law on an externally exposed surface: every class in the public tools directory carries the prefix, so the surface stays greppable and a public class can never shadow an internal one.
|
|
30
|
+
- [`one-door`](one-door/README.md): A boundary with one sanctioned crossing: controllers may not reach the GraphQL schema, except the single execute action, named as an exemption with its owner and reason so the carve-out is a decision and not a hole.
|
|
31
|
+
- [`readers-read-the-store-only`](readers-read-the-store-only/README.md): The allow form: a part's calls may land only in the named parts.
|
|
32
|
+
- [`cluster-laws`](cluster-laws/README.md): Laws that only a cluster snapshot can answer: a table owned by two repositories, mutating actions selected through the routes they handle, and routes no loaded client calls.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# clean rings
|
|
2
|
+
|
|
3
|
+
Four rings. An entity that calls a use case and a use case that calls an
|
|
4
|
+
adapter both point outward; the controller calling inward is the direction the
|
|
5
|
+
arrangement allows. The shipped clean recipe names the two outward reaches.
|
|
6
|
+
|
|
7
|
+
Declaration: `enola/constraints/clean.yaml`.
|
|
8
|
+
|
|
9
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
10
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# cluster laws
|
|
2
|
+
|
|
3
|
+
Laws that only a cluster snapshot can answer: a table owned by two
|
|
4
|
+
repositories, mutating actions selected through the routes they handle, and
|
|
5
|
+
routes no loaded client calls. Each refuses by name in a single-repository
|
|
6
|
+
snapshot rather than reporting silence as compliance.
|
|
7
|
+
|
|
8
|
+
Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
|
|
9
|
+
then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Enola.architecture "platform" do
|
|
2
|
+
part :tables, files: "app/models/**", kind: :storage
|
|
3
|
+
part :mutating_actions, service: :backend, files: "app/controllers/**", handles: [:post, :put, :patch, :delete]
|
|
4
|
+
part :policies, service: :backend, files: "app/policies/**"
|
|
5
|
+
part :api, service: :backend, files: "config/**", kind: :route
|
|
6
|
+
|
|
7
|
+
law "one owner per table" do
|
|
8
|
+
tables.must_be_unique_across by: :table
|
|
9
|
+
why "a table two repositories both model is written by two codebases that never see each other's validations or callbacks"
|
|
10
|
+
mode :advisory
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
law "a mutating action is authorized" do
|
|
14
|
+
mutating_actions.must_reach :policies
|
|
15
|
+
why "an action behind a POST, PUT, PATCH or DELETE route changes state, and the policy call is the one place the change is authorized"
|
|
16
|
+
mode :advisory
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
law "every api route has a consumer" do
|
|
20
|
+
api.must_have_consumer
|
|
21
|
+
why "a route nobody calls is a surface nobody maintains"
|
|
22
|
+
mode :advisory
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# cqrs queries never write
|
|
2
|
+
|
|
3
|
+
A command-query split. A query that calls update_all writes; a read model that
|
|
4
|
+
issues a command is a feedback loop; a query that only counts is clean. The
|
|
5
|
+
shipped cqrs recipe names the two breaches by the mutating method and the
|
|
6
|
+
command reached.
|
|
7
|
+
|
|
8
|
+
Declaration: `enola/constraints/cqrs.yaml`.
|
|
9
|
+
|
|
10
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
11
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# foreign keys
|
|
2
|
+
|
|
3
|
+
A foreign-key law reads the storage facts the schema yields: every table
|
|
4
|
+
carrying a tenant column must declare the foreign key to the tenant table. The
|
|
5
|
+
require form verdicts what a member fact carries, with a when antecedent
|
|
6
|
+
narrowing to the tables the law is about, so a table without the column is
|
|
7
|
+
never asked.
|
|
8
|
+
|
|
9
|
+
Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
|
|
10
|
+
then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
components:
|
|
2
|
+
- name: tables
|
|
3
|
+
match: ["db/structure.sql", "db/schema.rb", "app/models/**"]
|
|
4
|
+
kind: storage
|
|
5
|
+
|
|
6
|
+
rules:
|
|
7
|
+
- id: account-fk
|
|
8
|
+
require: tables
|
|
9
|
+
when_prop_contains: { prop: columns, value: account_id }
|
|
10
|
+
must_prop_contain: { prop: fk_constraints, value: "account_id->accounts" }
|
|
11
|
+
mode: advisory
|
|
12
|
+
because: "A table carrying account_id references an account, so the database should say so with a foreign key. Advisory while a backfill path that ships the column before the constraint is reconciled with the rule; the verdict names every table that carries the column without the key."
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# governed code
|
|
2
|
+
|
|
3
|
+
The knowledge pages compile into the graph as anchors, and a law demands that
|
|
4
|
+
every file of a part has one. The charge service is anchored by the decision
|
|
5
|
+
page; the refund service is not, and is named. Without any compiled page the
|
|
6
|
+
rule would refuse rather than call everything ungoverned.
|
|
7
|
+
|
|
8
|
+
Declaration: `enola/constraints/governance.rb`.
|
|
9
|
+
|
|
10
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
11
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# instantiation
|
|
2
|
+
|
|
3
|
+
What Ruby code constructs, as a fact. `new` on a literal constant is an
|
|
4
|
+
instantiates relation from the calling member to the class, which is the one
|
|
5
|
+
call whose result has a knowable type; `new` on a variable receiver names no
|
|
6
|
+
class and records nothing. A construction whose result is immediately sent a
|
|
7
|
+
message is the one-shot ceremony, named on the calling member as Class.method,
|
|
8
|
+
so a component can select the members that perform it and a rule can forbid
|
|
9
|
+
it. A construction bound to a local first is not the ceremony.
|
|
10
|
+
|
|
11
|
+
Declaration: `enola/constraints/ceremony.yaml`.
|
|
12
|
+
|
|
13
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
14
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
components:
|
|
2
|
+
- name: one-shot-builders
|
|
3
|
+
kind: symbol
|
|
4
|
+
match: ["app/services/**"]
|
|
5
|
+
where:
|
|
6
|
+
one_shot_call: "Payments::Charge.call"
|
|
7
|
+
|
|
8
|
+
rules:
|
|
9
|
+
- id: no-one-shot-charge
|
|
10
|
+
forbid_fact: one-shot-builders
|
|
11
|
+
because: "a charge is built and called in one breath, so nobody can hold it to test it"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# maintenance tasks
|
|
2
|
+
|
|
3
|
+
Two laws for a maintenance-tasks directory: a naming convention the runner
|
|
4
|
+
depends on, and a boundary against controller code that has no request to
|
|
5
|
+
read. Both were mined from a codebase where they already held, which is the
|
|
6
|
+
right moment to declare a law.
|
|
7
|
+
|
|
8
|
+
Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
|
|
9
|
+
then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Enola.architecture "app" do
|
|
2
|
+
part :maintenance_tasks, files: "app/tasks/**", kind: :symbol, where: { symbol_kind: "class" }
|
|
3
|
+
part :tasks, files: "app/tasks/**"
|
|
4
|
+
part :controllers, files: "app/controllers/**"
|
|
5
|
+
|
|
6
|
+
law "every maintenance task lives in the Maintenance namespace" do
|
|
7
|
+
maintenance_tasks.names_must_match "Maintenance::*"
|
|
8
|
+
why "the task runner resolves task constants from that namespace, so a task declared outside it never appears in the runner and cannot be run by anyone"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
law "a maintenance task never invokes controller code" do
|
|
12
|
+
tasks.must_not_call controllers
|
|
13
|
+
why "a task runs from the runner or the console with no request around it, so controller code reached from one has nothing to read"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# mixins stay independent
|
|
2
|
+
|
|
3
|
+
A module never reaches the classes that include it. Taggable calls Post, which
|
|
4
|
+
includes it; Auditable calls Ledger, which does not. Who includes whom comes
|
|
5
|
+
from the resolved ancestry the rubydex provider emits, so the rule refuses
|
|
6
|
+
with a named cause on a snapshot without it and names Taggable on one with it.
|
|
7
|
+
|
|
8
|
+
Declaration: `enola/constraints/concerns.yaml`.
|
|
9
|
+
|
|
10
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
11
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# naming conventions
|
|
2
|
+
|
|
3
|
+
Ruby naming conventions: no get_, set_ or is_ prefixes; a predicate ends in a
|
|
4
|
+
question mark and has_ is fine. Three offenders and two clean names in one
|
|
5
|
+
class.
|
|
6
|
+
|
|
7
|
+
Declaration: `enola/constraints/naming.yaml`.
|
|
8
|
+
|
|
9
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
10
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# one door
|
|
2
|
+
|
|
3
|
+
A boundary with one sanctioned crossing: controllers may not reach the GraphQL
|
|
4
|
+
schema, except the single execute action, named as an exemption with its owner
|
|
5
|
+
and reason so the carve-out is a decision and not a hole.
|
|
6
|
+
|
|
7
|
+
Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
|
|
8
|
+
then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
components:
|
|
2
|
+
- name: controllers
|
|
3
|
+
match: ["app/controllers/**"]
|
|
4
|
+
- name: schema
|
|
5
|
+
match: ["app/graphql/**"]
|
|
6
|
+
|
|
7
|
+
rules:
|
|
8
|
+
- id: controllers-reach-the-schema-through-one-door
|
|
9
|
+
forbid: controllers
|
|
10
|
+
to: schema
|
|
11
|
+
via: calls
|
|
12
|
+
mode: advisory
|
|
13
|
+
exempt:
|
|
14
|
+
- witness: "Api::GraphqlController#execute -> AppSchema.execute via calls"
|
|
15
|
+
owner: platform
|
|
16
|
+
since: "2026-08-22"
|
|
17
|
+
because: "the one sanctioned entry point"
|
|
18
|
+
because: "Controllers reach the schema through exactly one door, the execute action; every other controller stays out of it. A second door is a second place to forget authentication."
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# one owner per table
|
|
2
|
+
|
|
3
|
+
unique_across compares the members of different repositories; with the storage
|
|
4
|
+
facts of one repository there is nobody to share a table with, so the rule
|
|
5
|
+
refuses by name rather than reporting every table as unique. The verdict that
|
|
6
|
+
names two owners needs the cluster, which the harness measures per repository
|
|
7
|
+
by design.
|
|
8
|
+
|
|
9
|
+
Declaration: `enola/constraints/tables.rb`.
|
|
10
|
+
|
|
11
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
12
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# paired names
|
|
2
|
+
|
|
3
|
+
A naming pair: every with_ method has a without_ twin. with_history has its
|
|
4
|
+
twin, with_guests does not, archive is outside the pattern and never asked.
|
|
5
|
+
|
|
6
|
+
Declaration: `enola/constraints/pairs.yaml`.
|
|
7
|
+
|
|
8
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
9
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
components:
|
|
2
|
+
- name: chat
|
|
3
|
+
match: ["app/chat/**"]
|
|
4
|
+
kind: symbol
|
|
5
|
+
where: { symbol_kind: method }
|
|
6
|
+
rules:
|
|
7
|
+
- id: paired-scopes
|
|
8
|
+
require_name: chat
|
|
9
|
+
pattern: "with_*"
|
|
10
|
+
requires: "without_*"
|
|
11
|
+
because: "a reader who finds with_ expects without_ beside it"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# parts never cycle
|
|
2
|
+
|
|
3
|
+
Parts that may not depend on each other in a circle. Jobs reach models and
|
|
4
|
+
models reach jobs, so the two form a cycle at the part level; mailers sit
|
|
5
|
+
downstream of models only. The rule contracts the module graph to one node per
|
|
6
|
+
declared part, admits the symbol-rollup edges Ruby has, and names the circle;
|
|
7
|
+
the downstream part is not in it.
|
|
8
|
+
|
|
9
|
+
Declaration: `enola/constraints/cycles.yaml`.
|
|
10
|
+
|
|
11
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
12
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
components:
|
|
2
|
+
- name: jobs
|
|
3
|
+
match: ["app/jobs/**"]
|
|
4
|
+
- name: models
|
|
5
|
+
match: ["app/models/**"]
|
|
6
|
+
- name: mailers
|
|
7
|
+
match: ["app/mailers/**"]
|
|
8
|
+
rules:
|
|
9
|
+
- id: parts-never-cycle
|
|
10
|
+
forbid_cycles: jobs
|
|
11
|
+
among: [models, mailers]
|
|
12
|
+
because: "parts that reach each other in a circle cannot be taken apart"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# protocol any of
|
|
2
|
+
|
|
3
|
+
A protocol satisfied by one of several methods. Charge defines call, Refund
|
|
4
|
+
defines run, Reconcile defines neither; any_of accepts either door and names
|
|
5
|
+
the class with none, listing what would have satisfied it.
|
|
6
|
+
|
|
7
|
+
Declaration: `enola/constraints/entry.yaml`.
|
|
8
|
+
|
|
9
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
10
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# public surface
|
|
2
|
+
|
|
3
|
+
A path-defined public surface. Ruby has no visibility keyword the extractor
|
|
4
|
+
can read at the file level, so a component names the files that are its public
|
|
5
|
+
surface; reaching a public file from outside is fine and reaching an internal
|
|
6
|
+
one is the breach.
|
|
7
|
+
|
|
8
|
+
Declaration: `enola/constraints/surface.yaml`.
|
|
9
|
+
|
|
10
|
+
Verify it on your own tree: copy `enola/` into the repository, run
|
|
11
|
+
`enola constraints lint .`, then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
components:
|
|
2
|
+
- name: billing
|
|
3
|
+
match: ["app/billing/**"]
|
|
4
|
+
public: ["app/billing/public/**"]
|
|
5
|
+
- name: orders
|
|
6
|
+
match: ["app/orders/**"]
|
|
7
|
+
rules:
|
|
8
|
+
- id: billing-surface
|
|
9
|
+
private: billing
|
|
10
|
+
because: "billing is reached through its public files; the rest is implementation"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# public tool prefix
|
|
2
|
+
|
|
3
|
+
A naming law on an externally exposed surface: every class in the public tools
|
|
4
|
+
directory carries the prefix, so the surface stays greppable and a public
|
|
5
|
+
class can never shadow an internal one.
|
|
6
|
+
|
|
7
|
+
Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
|
|
8
|
+
then `enola --generate . && enola check .`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Enola.architecture "app" do
|
|
2
|
+
part :public_tools, files: "app/public_tools/**", kind: :symbol, where: { symbol_kind: "class" }
|
|
3
|
+
|
|
4
|
+
law "public tools carry the Public prefix" do
|
|
5
|
+
public_tools.names_must_match "Public*"
|
|
6
|
+
why "an externally exposed tool class is greppable by its prefix and cannot shadow an internal one"
|
|
7
|
+
end
|
|
8
|
+
end
|