@terpjs/spec 0.18.0 → 0.20.0
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.
- package/VERSION +1 -1
- package/catalog/backend/cross_module_imports_use_public_surface.json +21 -0
- package/catalog/backend/emitted_events_are_declared.json +22 -0
- package/catalog/backend/module_dependency_graph_is_acyclic.json +26 -0
- package/catalog/backend/no_cross_module_imports.json +4 -4
- package/catalog/backend/table_ownership_is_not_split.json +27 -0
- package/corpus/backend/cross_module_imports_use_public_surface/compliant-01/modules/a/module.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/compliant-01/modules/a/service.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/compliant-01/modules/b/module.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/violation-01/modules/a/module.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/violation-01/modules/a/service.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/violation-01/modules/b/module.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/violation-02/modules/a/module.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/violation-02/modules/a/service.py +1 -0
- package/corpus/backend/cross_module_imports_use_public_surface/violation-02/modules/b/module.py +1 -0
- package/corpus/backend/emitted_events_are_declared/compliant-01/modules/notes/module.py +13 -0
- package/corpus/backend/emitted_events_are_declared/violation-01/modules/notes/module.py +14 -0
- package/corpus/backend/module_dependency_graph_is_acyclic/compliant-01/modules/a/module.py +1 -0
- package/corpus/backend/module_dependency_graph_is_acyclic/compliant-01/modules/b/module.py +1 -0
- package/corpus/backend/module_dependency_graph_is_acyclic/violation-01/modules/a/module.py +1 -0
- package/corpus/backend/module_dependency_graph_is_acyclic/violation-01/modules/b/module.py +1 -0
- package/corpus/backend/no_cross_module_imports/compliant-02/modules/a/module.py +1 -0
- package/corpus/backend/no_cross_module_imports/compliant-02/modules/a/service.py +1 -0
- package/corpus/backend/no_cross_module_imports/compliant-02/modules/b/module.py +1 -0
- package/corpus/backend/table_ownership_is_not_split/compliant-01/modules/notes/migrations/versions/0a1b2c3d4e5f_create_notes_tables.py +22 -0
- package/corpus/backend/table_ownership_is_not_split/compliant-01/modules/notes/models.py +8 -0
- package/corpus/backend/table_ownership_is_not_split/violation-01/expected-findings.json +7 -0
- package/corpus/backend/table_ownership_is_not_split/violation-01/modules/notes/migrations/versions/0a1b2c3d4e5f_create_notes_tables.py +22 -0
- package/corpus/backend/table_ownership_is_not_split/violation-01/modules/tasks/models.py +8 -0
- package/package.json +2 -2
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.20.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "backend/cross_module_imports_use_public_surface",
|
|
3
|
+
"surface": "backend",
|
|
4
|
+
"title": "A declared module edge grants models / schemas / service / events only",
|
|
5
|
+
"intent": "A declared dependency buys the other module's domain vocabulary, not its delivery surface. Importing another module's router couples the two through HTTP shapes and lets an in-process call walk past the authorization policy that guards those routes; importing an underscore-prefixed submodule takes a dependency on something its owner never published; importing the bare package depends on whatever that package happens to re-export. The edge is therefore bounded to the published surface, so the dependency stays a contract rather than a keyhole into the other module's implementation.",
|
|
6
|
+
"layer": "static-portable",
|
|
7
|
+
"enforcement": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "build-time",
|
|
10
|
+
"tool": "terp.arch",
|
|
11
|
+
"ref": "check_cross_module_imports_use_public_surface"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"opt_out": "# arch-allow-cross-module-imports-use-public-surface: <reason>",
|
|
15
|
+
"runtime": {
|
|
16
|
+
"applicability": "not-applicable",
|
|
17
|
+
"rationale": "Which submodule an import names is a source-form property. Once composed, every module's objects are ordinary in-process references and the distinction between a published surface and an internal one no longer exists."
|
|
18
|
+
},
|
|
19
|
+
"guide_topic": "dependencies",
|
|
20
|
+
"corpus": true
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "backend/emitted_events_are_declared",
|
|
3
|
+
"surface": "backend",
|
|
4
|
+
"title": "A module emits only the events its manifest declares",
|
|
5
|
+
"intent": "The manifest's emits list is the module's published contract: it is what the control plane validates, what an operator reads to know what a module produces, and what another team subscribes against. An emit the manifest never declared makes that contract quietly untrue — the event really does go out, so nothing fails, while the document everyone reasons from says it cannot happen. The rule compares every event constant an emit call or lifecycle event map names inside a module package against that module's declared emits.",
|
|
6
|
+
"layer": "static-bespoke",
|
|
7
|
+
"enforcement": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "build-time",
|
|
10
|
+
"tool": "terp.arch",
|
|
11
|
+
"ref": "check_emitted_events_are_declared"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"reference": "ModuleSpec(emits=[...]) in modules/<name>/module.py, compared against emit(event=...) and LifecycleEventMap(created=/updated=/deleted=...) references in the same module package.",
|
|
15
|
+
"opt_out": "# arch-allow-emitted-events-are-declared: <reason>",
|
|
16
|
+
"runtime": {
|
|
17
|
+
"applicability": "not-applicable",
|
|
18
|
+
"rationale": "emit() carries no module identity at the call site, so the running system cannot attribute an emit to the manifest that should have declared it; the association exists only in the source layout (which module package the call lives in). Adding a module handle to emit() to make it runtime-checkable would put the answer in the caller's hands, which is exactly what the rule is verifying."
|
|
19
|
+
},
|
|
20
|
+
"guide_topic": "events",
|
|
21
|
+
"corpus": true
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "backend/module_dependency_graph_is_acyclic",
|
|
3
|
+
"surface": "backend",
|
|
4
|
+
"title": "Declared module dependency edges form a DAG",
|
|
5
|
+
"intent": "A cycle is the point at which two 'independent' modules have quietly become one: neither can be read, tested, deployed or removed without the other, and the direction that would say which owns the shared concept no longer exists. Refusing the cycle rather than the coupling keeps the available fixes the real ones — extract the shared concept into a module both depend on, or invert the weaker direction into an event subscription.",
|
|
6
|
+
"layer": "static-portable",
|
|
7
|
+
"enforcement": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "build-time",
|
|
10
|
+
"tool": "terp.arch",
|
|
11
|
+
"ref": "check_module_dependency_graph_is_acyclic"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"kind": "runtime",
|
|
15
|
+
"tool": "terp.core",
|
|
16
|
+
"ref": "_validate_requires"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"opt_out": "# arch-allow-module-dependency-graph-is-acyclic: <reason>",
|
|
20
|
+
"runtime": {
|
|
21
|
+
"applicability": "required",
|
|
22
|
+
"rationale": "The composition root receives every ModuleSpec, so the declared graph is fully observable at boot. create_app validates it and raises BootError on a cycle, fail closed — the build-time check is the earlier, better-located half of the same control, not its only one."
|
|
23
|
+
},
|
|
24
|
+
"guide_topic": "dependencies",
|
|
25
|
+
"corpus": true
|
|
26
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "backend/no_cross_module_imports",
|
|
3
3
|
"surface": "backend",
|
|
4
|
-
"title": "A module
|
|
5
|
-
"intent": "Modules
|
|
4
|
+
"title": "A module imports a sibling only across an edge it declared",
|
|
5
|
+
"intent": "Modules are independent by default; a real dependency is DECLARED, never implicit. The depending module names the sibling as a dependency edge in its own manifest, which puts the coupling where a reader already looks; an undeclared sibling import is refused. Both absolute and relative sibling imports are caught: a relative import is resolved to its absolute module first, so renaming the import style does not re-couple two leaf modules. The declaration is read from source, so one a static reader cannot resolve grants nothing (fail closed).",
|
|
6
6
|
"layer": "static-portable",
|
|
7
7
|
"enforcement": [
|
|
8
8
|
{
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"opt_out": "# arch-allow-no-cross-module-imports: <reason>",
|
|
15
15
|
"runtime": {
|
|
16
16
|
"applicability": "not-applicable",
|
|
17
|
-
"rationale": "
|
|
17
|
+
"rationale": "Whether an edge was declared is an import-graph property of the source tree. At runtime the composed app legitimately holds every module in one process, so a cross-module reference is indistinguishable from composition itself."
|
|
18
18
|
},
|
|
19
|
-
"guide_topic": "
|
|
19
|
+
"guide_topic": "dependencies",
|
|
20
20
|
"corpus": true
|
|
21
21
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "backend/table_ownership_is_not_split",
|
|
3
|
+
"surface": "backend",
|
|
4
|
+
"title": "The package whose models own a table is the package whose history creates it",
|
|
5
|
+
"intent": "Moving a model between packages splits table ownership from history ownership, and nothing visible happens: the losing package no longer owns the table so its scoped autogenerate cannot propose a drop, and the gaining package diffs against a database where the table already exists so it proposes no create. No DDL is emitted, every existing database keeps upgrading, and the build stays green. The damage lands later: the next ordinary schema change to that model is authored into the gaining package's independent history, which — absent a foreign key between the two packages — a fresh install may run before the history that creates the table, failing with 'no such table'. Only fresh installs break (a new environment, a new developer, a restore, a disaster-recovery rebuild), months after the causing commit, and the blame falls on an innocent add_column. A table is therefore created by exactly the package whose models declare it; moving a table between packages uses expand/contract (new table, copy the rows, retire the old one in a later release under a reviewed destructive-migration marker).",
|
|
6
|
+
"layer": "static-portable",
|
|
7
|
+
"enforcement": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "build-time",
|
|
10
|
+
"tool": "terp.arch",
|
|
11
|
+
"ref": "check_table_ownership_is_not_split"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"kind": "build-time",
|
|
15
|
+
"tool": "terp.migrations",
|
|
16
|
+
"ref": "assert_no_split_table_ownership"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"reference": "For each migration tree, the tables its models own (the mapped classes under its import path) are compared against the tables that other trees' revision files literally create with create_table('<name>') inside upgrade(). A table owned by one package and created by another is a violation. A table owned but created by no package is not (that is the normal state before `terp migrate make` authors the revision), and neither is a table created under one name and later renamed (no literal create under the current name), so the check only fires on an exact, statically provable foreign creator. Enforced at authoring time (terp migrate make) and in the build-time drift guard (assert_migrations_match_models). The opt-out lives in the owning package's models module, for a move that has already shipped and whose expand/contract is planned for a later release.",
|
|
20
|
+
"opt_out": "# arch-allow-table-ownership-is-not-split: <reason>",
|
|
21
|
+
"runtime": {
|
|
22
|
+
"applicability": "not-applicable",
|
|
23
|
+
"rationale": "The invariant relates the source tree's model ownership to its authored revision files; both inputs exist only in source. At apply time the split is unobservable — an already-provisioned database upgrades cleanly, which is precisely why the failure escapes to fresh installs. The complementary runtime controls (assert_migrations_current, assert_no_orphaned_revisions) enforce different invariants about a live database."
|
|
24
|
+
},
|
|
25
|
+
"guide_topic": "migrations",
|
|
26
|
+
"corpus": true
|
|
27
|
+
}
|
package/corpus/backend/cross_module_imports_use_public_surface/compliant-01/modules/a/module.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="a", router=router, policy=Policy.default(), requires=("b",))
|
package/corpus/backend/cross_module_imports_use_public_surface/compliant-01/modules/a/service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from app.modules.b.service import ConnectionProfileService
|
package/corpus/backend/cross_module_imports_use_public_surface/compliant-01/modules/b/module.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="b", router=router, policy=Policy.default())
|
package/corpus/backend/cross_module_imports_use_public_surface/violation-01/modules/a/module.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="a", router=router, policy=Policy.default(), requires=("b",))
|
package/corpus/backend/cross_module_imports_use_public_surface/violation-01/modules/a/service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from app.modules.b.router import router as b_router
|
package/corpus/backend/cross_module_imports_use_public_surface/violation-01/modules/b/module.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="b", router=router, policy=Policy.default())
|
package/corpus/backend/cross_module_imports_use_public_surface/violation-02/modules/a/module.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="a", router=router, policy=Policy.default(), requires=("b",))
|
package/corpus/backend/cross_module_imports_use_public_surface/violation-02/modules/a/service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from app.modules.b._internal import secret_helper
|
package/corpus/backend/cross_module_imports_use_public_surface/violation-02/modules/b/module.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="b", router=router, policy=Policy.default())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from control_plane.events import NOTE_CREATED, NOTE_DELETED
|
|
2
|
+
from terp.core import ModuleSpec, Policy, Roles, emit
|
|
3
|
+
|
|
4
|
+
spec = ModuleSpec(
|
|
5
|
+
name="notes",
|
|
6
|
+
router=router,
|
|
7
|
+
policy=Policy(read=Roles.VIEWER, write=Roles.EDITOR),
|
|
8
|
+
emits=[NOTE_CREATED, NOTE_DELETED],
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def archive(session, note):
|
|
13
|
+
emit(session, event=NOTE_DELETED, payload={"id": str(note.id)})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from control_plane.events import NOTE_CREATED, NOTE_DELETED
|
|
2
|
+
from terp.core import ModuleSpec, Policy, Roles, emit
|
|
3
|
+
|
|
4
|
+
spec = ModuleSpec(
|
|
5
|
+
name="notes",
|
|
6
|
+
router=router,
|
|
7
|
+
policy=Policy(read=Roles.VIEWER, write=Roles.EDITOR),
|
|
8
|
+
emits=[NOTE_CREATED],
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def archive(session, note):
|
|
13
|
+
# Declared nowhere: the manifest above says this module emits only NOTE_CREATED.
|
|
14
|
+
emit(session, event=NOTE_DELETED, payload={"id": str(note.id)})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="a", router=router, policy=Policy.default(), requires=("b",))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="b", router=router, policy=Policy.default())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="a", router=router, policy=Policy.default(), requires=("b",))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="b", router=router, policy=Policy.default(), requires=("a",))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="a", router=router, policy=Policy.default(), requires=("b",))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from app.modules.b.service import ConnectionProfileService
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module = ModuleSpec(name="b", router=router, policy=Policy.default())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""create the notes table
|
|
2
|
+
|
|
3
|
+
Revision ID: 0a1b2c3d4e5f
|
|
4
|
+
Revises:
|
|
5
|
+
"""
|
|
6
|
+
from alembic import op
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
|
|
9
|
+
revision = "0a1b2c3d4e5f"
|
|
10
|
+
down_revision = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def upgrade() -> None:
|
|
14
|
+
op.create_table(
|
|
15
|
+
"notes_note",
|
|
16
|
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
|
17
|
+
sa.Column("title", sa.String(length=200), nullable=False),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downgrade() -> None:
|
|
22
|
+
op.drop_table("notes_note")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""create the notes table
|
|
2
|
+
|
|
3
|
+
Revision ID: 0a1b2c3d4e5f
|
|
4
|
+
Revises:
|
|
5
|
+
"""
|
|
6
|
+
from alembic import op
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
|
|
9
|
+
revision = "0a1b2c3d4e5f"
|
|
10
|
+
down_revision = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def upgrade() -> None:
|
|
14
|
+
op.create_table(
|
|
15
|
+
"notes_note",
|
|
16
|
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
|
17
|
+
sa.Column("title", sa.String(length=200), nullable=False),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downgrade() -> None:
|
|
22
|
+
op.drop_table("notes_note")
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/spec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The Terp Standard
|
|
3
|
+
"version": "0.20.0",
|
|
4
|
+
"description": "The Terp Standard — stack-neutral rule catalog, violation corpus, finding format, and refused-surface declaration (ADRs 0080/0081; packaged per ADR 0082, published per ADR 0086). Data only: consumers resolve the spec root via require.resolve('@terpjs/spec/package.json').",
|
|
5
5
|
"files": [
|
|
6
6
|
"VERSION",
|
|
7
7
|
"findings.schema.json",
|