@terpjs/spec 0.26.1 → 0.29.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.
Files changed (82) hide show
  1. package/VERSION +1 -1
  2. package/catalog/backend/declared_read_controls_are_forwarded.json +22 -0
  3. package/catalog/backend/frozen_values_hold_no_mutable_collection.json +22 -0
  4. package/catalog/backend/operations_reference_catalog.json +19 -0
  5. package/catalog/backend/routes_declare_operation.json +19 -0
  6. package/catalog/frontend/locale-catalogs-complete.json +26 -0
  7. package/catalog/frontend/no-untranslated-ui.json +21 -0
  8. package/corpus/RESIDUALS.json +8 -0
  9. package/corpus/backend/declared_read_controls_are_forwarded/compliant-01/modules/notes/router.py +8 -0
  10. package/corpus/backend/declared_read_controls_are_forwarded/compliant-01/modules/notes/service.py +7 -0
  11. package/corpus/backend/declared_read_controls_are_forwarded/compliant-02/modules/notes/router.py +6 -0
  12. package/corpus/backend/declared_read_controls_are_forwarded/compliant-02/modules/notes/service.py +7 -0
  13. package/corpus/backend/declared_read_controls_are_forwarded/violation-01/modules/notes/router.py +6 -0
  14. package/corpus/backend/declared_read_controls_are_forwarded/violation-01/modules/notes/service.py +9 -0
  15. package/corpus/backend/declared_read_controls_are_forwarded/violation-02/modules/notes/router.py +6 -0
  16. package/corpus/backend/declared_read_controls_are_forwarded/violation-02/modules/notes/service.py +6 -0
  17. package/corpus/backend/frozen_values_hold_no_mutable_collection/compliant-01/modules/notes/values.py +12 -0
  18. package/corpus/backend/frozen_values_hold_no_mutable_collection/compliant-02/modules/notes/values.py +9 -0
  19. package/corpus/backend/frozen_values_hold_no_mutable_collection/violation-01/modules/notes/values.py +15 -0
  20. package/corpus/backend/frozen_values_hold_no_mutable_collection/violation-02/modules/notes/values.py +13 -0
  21. package/corpus/backend/operations_reference_catalog/compliant-01/modules/notes/router.py +16 -0
  22. package/corpus/backend/operations_reference_catalog/compliant-02/modules/notes/router.py +10 -0
  23. package/corpus/backend/operations_reference_catalog/violation-01/modules/notes/router.py +13 -0
  24. package/corpus/backend/operations_reference_catalog/violation-02/modules/notes/router.py +9 -0
  25. package/corpus/backend/routes_declare_operation/compliant-01/control_plane/operations.py +7 -0
  26. package/corpus/backend/routes_declare_operation/compliant-01/modules/notes/router.py +8 -0
  27. package/corpus/backend/routes_declare_operation/compliant-02/modules/notes/router.py +5 -0
  28. package/corpus/backend/routes_declare_operation/compliant-03/control_plane/operations.py +12 -0
  29. package/corpus/backend/routes_declare_operation/compliant-03/modules/notes/router.py +20 -0
  30. package/corpus/backend/routes_declare_operation/violation-01/control_plane/operations.py +3 -0
  31. package/corpus/backend/routes_declare_operation/violation-01/modules/notes/router.py +3 -0
  32. package/corpus/backend/routes_declare_operation/violation-02/control_plane/operations.py +3 -0
  33. package/corpus/backend/routes_declare_operation/violation-02/modules/notes/router.py +8 -0
  34. package/corpus/frontend/escape-hatch/compliant-01/i18n.json +1 -0
  35. package/corpus/frontend/escape-hatch/compliant-01/src/modules/widgets/Widget.tsx +3 -1
  36. package/corpus/frontend/generated-client-only/compliant-04/i18n.json +1 -0
  37. package/corpus/frontend/generated-client-only/compliant-04/src/modules/widgets/Widget.tsx +6 -2
  38. package/corpus/frontend/layout-contract/compliant-01/i18n.json +1 -0
  39. package/corpus/frontend/layout-contract/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  40. package/corpus/frontend/locale-catalogs-complete/compliant-01/i18n.json +11 -0
  41. package/corpus/frontend/locale-catalogs-complete/compliant-01/src/modules/widgets/Widget.tsx +5 -0
  42. package/corpus/frontend/locale-catalogs-complete/violation-01/i18n.json +7 -0
  43. package/corpus/frontend/locale-catalogs-complete/violation-01/src/modules/widgets/Widget.tsx +5 -0
  44. package/corpus/frontend/locale-catalogs-complete/violation-02/src/modules/widgets/Widget.tsx +5 -0
  45. package/corpus/frontend/locale-catalogs-complete/violation-03/i18n.json +1 -0
  46. package/corpus/frontend/locale-catalogs-complete/violation-03/src/modules/widgets/Widget.tsx +1 -0
  47. package/corpus/frontend/locale-catalogs-complete/violation-04/i18n.json +1 -0
  48. package/corpus/frontend/locale-catalogs-complete/violation-04/src/modules/widgets/Widget.tsx +5 -0
  49. package/corpus/frontend/no-cross-module-imports/compliant-01/i18n.json +1 -0
  50. package/corpus/frontend/no-cross-module-imports/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  51. package/corpus/frontend/no-deep-imports/compliant-01/i18n.json +1 -0
  52. package/corpus/frontend/no-deep-imports/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  53. package/corpus/frontend/no-dom-html-injection/compliant-01/i18n.json +1 -0
  54. package/corpus/frontend/no-dom-html-injection/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  55. package/corpus/frontend/no-eval/compliant-01/i18n.json +1 -0
  56. package/corpus/frontend/no-eval/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  57. package/corpus/frontend/no-inline-styling/compliant-01/i18n.json +1 -0
  58. package/corpus/frontend/no-inline-styling/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  59. package/corpus/frontend/no-style-imports/compliant-01/i18n.json +1 -0
  60. package/corpus/frontend/no-style-imports/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  61. package/corpus/frontend/no-unsafe-href/compliant-01/i18n.json +1 -0
  62. package/corpus/frontend/no-unsafe-href/compliant-01/src/modules/widgets/Widget.tsx +3 -1
  63. package/corpus/frontend/no-unsafe-target-blank/compliant-01/i18n.json +1 -0
  64. package/corpus/frontend/no-unsafe-target-blank/compliant-01/src/modules/widgets/Widget.tsx +3 -1
  65. package/corpus/frontend/no-untranslated-ui/compliant-01/i18n.json +1 -0
  66. package/corpus/frontend/no-untranslated-ui/compliant-01/src/modules/widgets/Widget.tsx +11 -0
  67. package/corpus/frontend/no-untranslated-ui/violation-01/src/modules/widgets/Widget.tsx +9 -0
  68. package/corpus/frontend/no-untranslated-ui/violation-02/i18n.json +1 -0
  69. package/corpus/frontend/no-untranslated-ui/violation-02/src/main.tsx +1 -0
  70. package/corpus/frontend/no-untranslated-ui/violation-03/i18n.json +1 -0
  71. package/corpus/frontend/no-untranslated-ui/violation-03/src/modules/widgets/Widget.tsx +5 -0
  72. package/corpus/frontend/no-untranslated-ui/violation-04/i18n.json +1 -0
  73. package/corpus/frontend/no-untranslated-ui/violation-04/src/main.tsx +10 -0
  74. package/corpus/frontend/no-untranslated-ui/violation-05/i18n.json +9 -0
  75. package/corpus/frontend/no-untranslated-ui/violation-05/src/main.tsx +7 -0
  76. package/corpus/frontend/router-links/compliant-01/i18n.json +1 -0
  77. package/corpus/frontend/router-links/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  78. package/corpus/frontend/token-styled-elements/compliant-01/i18n.json +1 -0
  79. package/corpus/frontend/token-styled-elements/compliant-01/src/modules/widgets/Widget.tsx +2 -2
  80. package/corpus/frontend/token-styled-elements/compliant-02/i18n.json +1 -0
  81. package/corpus/frontend/token-styled-elements/compliant-02/src/modules/widgets/Widget.tsx +3 -1
  82. package/package.json +1 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.1
1
+ 0.29.1
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "backend/declared_read_controls_are_forwarded",
3
+ "surface": "backend",
4
+ "title": "A declared filter or sort must be reachable from an endpoint that forwards it",
5
+ "intent": "A read layer declares which fields a caller may narrow or order by, and an endpoint reaches those declarations by forwarding the caller's request into the read. A declaration no endpoint forwards is inert: the capability is described in the source, absent from the API, and silent about the difference. The failure is worse than an unimplemented feature because it presents as an implemented one — a client generated from the contract offers no way to sort, a screen built against it ships with every column's sorting disabled, and nothing in the read layer is wrong. Each declared filter and sort must therefore be reachable: a module that declares one and forwards none is rejected on the source, rather than discovered by a user who cannot order a list the code says is orderable.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "terp.arch",
11
+ "ref": "check_declared_read_controls_are_forwarded"
12
+ }
13
+ ],
14
+ "reference": "Judged per module and per control kind by PRESENCE, never by name: a module that declares a filter is required to forward a filters mapping somewhere within itself, and a module that declares a sort is required to forward a sort. The keyword is visible in the source even when the value handed to it is computed, which is what makes presence decidable where a per-name comparison is not — a mapping built elsewhere hides its names but not its existence, so requiring a specific declared name to appear at a call site would reject correct code. The pairing of one declaration to one endpoint is not statically knowable either, which is why the unit is the module that owns both.",
15
+ "opt_out": "# arch-allow-declared-read-controls-are-forwarded: <reason>",
16
+ "runtime": {
17
+ "applicability": "not-applicable",
18
+ "rationale": "There is nothing for a runtime check to observe. The defect is the ABSENCE of a call: a request that would have carried a filter or a sort never arrives, because the endpoint exposes no parameter for it, so no code path runs and no fail-closed control can fire. This is a build-time-only invariant by nature, not by omission — the counterpart rule that judges a forwarded name against the declarations is enforced at runtime precisely because there a request does reach the read layer."
19
+ },
20
+ "guide_topic": "service",
21
+ "corpus": true
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "backend/frozen_values_hold_no_mutable_collection",
3
+ "surface": "backend",
4
+ "title": "A frozen value object must not hold a mutable collection",
5
+ "intent": "Freezing a value object announces that it cannot change after construction, and callers rely on that: they share it between requests, cache it, use it as a registry key, and skip defensive copies because the type says none are needed. Freezing binds the ATTRIBUTES, not what they point at — a frozen object holding a list or a dict or a set is immutable only one level deep, so anyone holding it can append to its contents while every guarantee the type advertises still appears to hold. The mutation is invisible at the call site that performs it and arrives somewhere else entirely, in another request or another worker, as state that changed without an assignment. A frozen field must therefore be typed as an immutable sequence or mapping, so the promise the type makes is the promise it keeps.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "terp.arch",
11
+ "ref": "check_frozen_values_hold_no_mutable_collection"
12
+ }
13
+ ],
14
+ "reference": "Flagged on the field of a frozen value object whose annotation is a mutable built-in collection — list, dict or set, including their typing spellings and parameterised forms — with the immutable counterpart named in the message: a tuple for a sequence, a Mapping or frozen mapping for a dict, a frozenset for a set. A frozen object is recognised by its declaration, not by convention: a dataclass frozen at the decorator, a NamedTuple, or a model configured immutable. An annotation that is not statically a mutable collection is not judged, because the shape of an aliased or computed type is not knowable here and guessing would reject correct code.",
15
+ "opt_out": "# arch-allow-frozen-values-hold-no-mutable-collection: <reason>",
16
+ "runtime": {
17
+ "applicability": "not-applicable",
18
+ "rationale": "A runtime control would have to intercept mutation of an object the frozen value merely references, which is the thing the language does not offer: the list belongs to whoever else holds it, its mutating methods are not the frozen object's to override, and a deep-freeze on construction would change the value's semantics rather than check them. This is the shape of invariant only a build-time reading of the declaration can hold — which is exactly why declaring a frozen field as immutable is the control, rather than watching what happens to it later."
19
+ },
20
+ "guide_topic": "module",
21
+ "corpus": true
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "backend/operations_reference_catalog",
3
+ "surface": "backend",
4
+ "title": "A route's declared operation is a typed catalog constant, never a bare value",
5
+ "intent": "A route can say what it does for the person calling it, but only by citing a typed definition from one shared catalog -- never a string literal or a value built inline at the call site. Without this, a route's stated purpose could drift from what the catalog documents, or two routes could describe the same action in two different words with no way to tell they were ever meant to agree.",
6
+ "layer": "static-bespoke",
7
+ "enforcement": [
8
+ { "kind": "build-time", "tool": "terp.arch", "ref": "check_operations_reference_catalog" },
9
+ { "kind": "runtime", "tool": "terp.core", "ref": "_validate_declared_operations" }
10
+ ],
11
+ "reference": "OperationDefinition constants from the control-plane operations catalog, cited via the operation(...) route-level marker and the *_operation= keywords a canonical CRUD factory accepts.",
12
+ "opt_out": "# arch-allow-operations-reference-catalog: <reason>",
13
+ "runtime": {
14
+ "applicability": "required",
15
+ "rationale": "Boot resolves every declared operation against the registered catalog by value (id and wording together), so an unknown id or a same-id entry with different wording fails closed before the app ever serves a request."
16
+ },
17
+ "guide_topic": "operations",
18
+ "corpus": true
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "backend/routes_declare_operation",
3
+ "surface": "backend",
4
+ "title": "Every route declares the operation it performs",
5
+ "intent": "A route's method and path say how to call it; nothing about them says what it does for the person calling it. Under this app's chosen coverage level, a route that declares no operation leaves that question permanently unanswered for anyone reading the permission surface rather than the source.",
6
+ "layer": "static-bespoke",
7
+ "enforcement": [
8
+ { "kind": "build-time", "tool": "terp.arch", "ref": "check_routes_declare_operation" },
9
+ { "kind": "runtime", "tool": "terp.core", "ref": "_validate_declared_operations" }
10
+ ],
11
+ "reference": "the operation(...) route-level marker, or for a canonical CRUD factory its *_operation= keywords, checked once the app's operations catalog opts into strict coverage.",
12
+ "opt_out": "# arch-allow-routes-declare-operation: <reason>",
13
+ "runtime": {
14
+ "applicability": "required",
15
+ "rationale": "Boot refuses a mounted route with no declared operation once the app's operations catalog opts into strict coverage; a lighter warn mode records the same gap without refusing the boot, and the coverage choice itself is optional, so an app that has not adopted it boots unchanged."
16
+ },
17
+ "guide_topic": "operations",
18
+ "corpus": true
19
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "id": "frontend/locale-catalogs-complete",
3
+ "surface": "frontend",
4
+ "title": "Every declared target locale translates every authored message",
5
+ "intent": "Every stable user-interface message identifier must have a non-empty translation in every declared target locale. A missing or malformed catalog, empty entries, and undocumented source-language copies fail the gate instead of silently rendering source-language text after a user switches locale.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "@terpjs/eslint-boundaries",
11
+ "ref": "terp/locale-catalogs-complete"
12
+ },
13
+ {
14
+ "kind": "runtime",
15
+ "tool": "@terpjs/react-core",
16
+ "ref": "LocaleProvider"
17
+ }
18
+ ],
19
+ "reference": "Declare sourceLocale and locale message maps in frontend/i18n.json; the boundary rule and defineAppLocales validate that declaration, while LocaleProvider refuses missing target entries at render time.",
20
+ "opt_out": "// terp-allow-locale-catalogs-complete: <reason>",
21
+ "runtime": {
22
+ "applicability": "required",
23
+ "rationale": "The locale provider owns descriptor resolution and can distinguish source fallback from a missing target entry, including dynamically assembled identifiers that a static check cannot see."
24
+ },
25
+ "corpus": true
26
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "frontend/no-untranslated-ui",
3
+ "surface": "frontend",
4
+ "title": "Static user-facing text participates in localization",
5
+ "intent": "Static user-facing text anywhere in app-authored frontend source must use the translation seam. Bare visible copy, accessibility labels, component text properties, labels stored in data, and standard toast feedback are otherwise absent from every translation catalog and silently remain in the source language.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "@terpjs/eslint-boundaries",
11
+ "ref": "terp/no-untranslated-ui"
12
+ }
13
+ ],
14
+ "reference": "Use UiText descriptors and Trans from @terpjs/react-core throughout app-authored frontend source.",
15
+ "opt_out": "// terp-allow-no-untranslated-ui: <reason>",
16
+ "runtime": {
17
+ "applicability": "not-applicable",
18
+ "rationale": "The rendered value cannot prove whether it originated from a descriptor or a bare literal, while dynamic business data legitimately remains untranslated."
19
+ },
20
+ "corpus": true
21
+ }
@@ -4,6 +4,14 @@
4
4
  "backend/no_dynamic_sql": [
5
5
  "an alias-renamed symbol import (`from sqlalchemy import text as sql_text`) is not required to be resolved to `text`"
6
6
  ],
7
+ "backend/no_manual_lease_columns": [
8
+ "a holder/heartbeat pair under other column names (`worker_id` + `last_seen_at`) is not required to be recognised as a hand-rolled lease: the check matches a fixed set of column spellings on a table model, not the custody shape"
9
+ ],
10
+ "backend/no_manual_ownership_checks": [
11
+ "a jobs declaration bound to a name (`ModuleSpec(jobs=MODULE_JOBS)`) is not required to be seen as declaring background work: the build-time background-work clause matches a list or tuple LITERAL",
12
+ "`OwnedMixin` reached indirectly — through an intermediate base, or on a model declared in another package — is not required to be recognised as ownership by the build-time background-work clause, which matches a direct base name within the same module directory",
13
+ "a ModuleSpec that omits `services=` is not required to be caught by the composition-time twin, which iterates the declared services"
14
+ ],
7
15
  "backend/no_raw_outbound_http": [
8
16
  "dynamic import (`importlib.import_module(\"httpx\")`) is not required to be seen as an import"
9
17
  ],
@@ -0,0 +1,8 @@
1
+ # Both declarations are reachable: the endpoint exposes a parameter for each and
2
+ # forwards it, so what the read layer says is narrowable and orderable is.
3
+ def list_notes(session, author_id=None, sort=None):
4
+ return service.list(
5
+ session,
6
+ filters={"author_id": author_id},
7
+ sort=sort,
8
+ )
@@ -0,0 +1,7 @@
1
+ from terp.core import BaseService, FilterField, SortField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (FilterField("author_id", Note.author_id),)
7
+ sortable = (SortField("created_at", Note.created_at),)
@@ -0,0 +1,6 @@
1
+ # The mapping is built rather than written out, and that is still compliant: the
2
+ # rule judges whether the module forwards a filters mapping at all, never which
3
+ # names the mapping happens to contain. A computed mapping hides its names from a
4
+ # reader of the source; it does not hide its existence.
5
+ def list_notes(session, **query):
6
+ return service.list(session, filters=_requested_filters(query), sort=query.get("sort"))
@@ -0,0 +1,7 @@
1
+ from terp.core import BaseService, FilterField, SortField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (FilterField("author_id", Note.author_id),)
7
+ sortable = (SortField("created_at", Note.created_at),)
@@ -0,0 +1,6 @@
1
+ # Two sorts are declared and no endpoint forwards one, so the read is never
2
+ # ordered by anything a caller asked for. Nothing here is wrong and nothing
3
+ # fails: a client generated from this contract simply has no way to sort, and a
4
+ # screen built on it ships with every column's sorting disabled.
5
+ def list_notes(session, author_id=None):
6
+ return service.list(session, filters={"author_id": author_id})
@@ -0,0 +1,9 @@
1
+ from terp.core import BaseService, SortField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ sortable = (
7
+ SortField("name", Note.name),
8
+ SortField("created_at", Note.created_at),
9
+ )
@@ -0,0 +1,6 @@
1
+ # The same absence on the filter side: a declared filter that no endpoint
2
+ # forwards describes a narrowing the API cannot perform. The list is always
3
+ # unnarrowed, which is also the shape a reader is most likely to mistake for a
4
+ # permission problem rather than a missing parameter.
5
+ def list_notes(session, sort=None):
6
+ return service.list(session, sort=sort)
@@ -0,0 +1,6 @@
1
+ from terp.core import BaseService, FilterField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (FilterField("author_id", Note.author_id),)
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+ from collections.abc import Mapping
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class ImportPlan:
7
+ """Frozen all the way down: the promise the type makes is the one it keeps."""
8
+
9
+ name: str
10
+ columns: tuple[str, ...]
11
+ defaults: Mapping[str, str]
12
+ tags: frozenset[str]
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ # Not frozen, so nothing is promised and a list is an honest field. The rule is
5
+ # about a claim that does not hold, never about mutability itself.
6
+ @dataclass
7
+ class ImportDraft:
8
+ name: str
9
+ columns: list[str]
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class ImportPlan:
6
+ """Immutable one level deep, which is the level nobody checks.
7
+
8
+ `plan.columns.append("x")` succeeds on a frozen object. Callers share this
9
+ between requests and skip defensive copies because the type says they can,
10
+ so the mutation arrives somewhere else entirely as state that changed with
11
+ no assignment anywhere near it.
12
+ """
13
+
14
+ name: str
15
+ columns: list[str]
@@ -0,0 +1,13 @@
1
+ from typing import NamedTuple
2
+
3
+
4
+ class RouteSpec(NamedTuple):
5
+ """A NamedTuple is frozen by construction, and holds a mutable dict anyway.
6
+
7
+ Worse than the dataclass case in practice: a tuple is the shape people reach
8
+ for precisely BECAUSE it is safe to use as a key or share freely, so the
9
+ false guarantee travels further.
10
+ """
11
+
12
+ path: str
13
+ handlers: dict[str, str]
@@ -0,0 +1,16 @@
1
+ from control_plane.operations import NOTES_DELETE
2
+ from terp.core import operation
3
+
4
+
5
+ @router.delete('/{note_id}', status_code=204)
6
+ @operation(NOTES_DELETE)
7
+ def delete_note(note_id, session) -> None:
8
+ _service.delete(session, note_id)
9
+
10
+
11
+ # A route with no declared operation at all is not this rule's concern -- an
12
+ # absent declaration is coverage's question (backend/routes_declare_operation),
13
+ # not a drifted one.
14
+ @router.get('/{note_id}')
15
+ def get_note(note_id, session):
16
+ return _service.get(session, note_id)
@@ -0,0 +1,10 @@
1
+ from control_plane.operations import NOTES_LIST
2
+ from terp.core import build_crud_router
3
+
4
+ router = build_crud_router(
5
+ NoteService(),
6
+ read_schema=NoteRead,
7
+ create_schema=NoteCreate,
8
+ update_schema=NoteUpdate,
9
+ list_operation=NOTES_LIST,
10
+ )
@@ -0,0 +1,13 @@
1
+ from terp.core import OperationDefinition, operation
2
+
3
+
4
+ @router.delete('/{note_id}', status_code=204)
5
+ @operation("notes.delete")
6
+ def delete_note(note_id, session) -> None:
7
+ _service.delete(session, note_id)
8
+
9
+
10
+ @router.get('/{note_id}')
11
+ @operation(OperationDefinition(id="notes.get", label="View a note"))
12
+ def get_note(note_id, session):
13
+ return _service.get(session, note_id)
@@ -0,0 +1,9 @@
1
+ from terp.core import build_crud_router
2
+
3
+ router = build_crud_router(
4
+ NoteService(),
5
+ read_schema=NoteRead,
6
+ create_schema=NoteCreate,
7
+ update_schema=NoteUpdate,
8
+ list_operation="notes.list",
9
+ )
@@ -0,0 +1,7 @@
1
+ from terp.core import OperationCatalog, OperationCoverage, OperationDefinition
2
+
3
+ NOTES_LIST = OperationDefinition(id="notes.list", label="List every note")
4
+
5
+ operation_catalog = OperationCatalog(
6
+ operations=(NOTES_LIST,), coverage=OperationCoverage.STRICT
7
+ )
@@ -0,0 +1,8 @@
1
+ from control_plane.operations import NOTES_LIST
2
+ from terp.core import operation
3
+
4
+
5
+ @router.get('/')
6
+ @operation(NOTES_LIST)
7
+ def list_notes():
8
+ return notes
@@ -0,0 +1,5 @@
1
+ # No control_plane/operations.py at all: this app has not opted into operation
2
+ # coverage (the default), so an undeclared route is not this rule's concern.
3
+ @router.get('/')
4
+ def list_notes():
5
+ return notes
@@ -0,0 +1,12 @@
1
+ from terp.core import OperationCatalog, OperationCoverage, OperationDefinition
2
+
3
+ NOTES_LIST = OperationDefinition(id="notes.list", label="List every note")
4
+ NOTES_CREATE = OperationDefinition(id="notes.create", label="Write a new note")
5
+ NOTES_GET = OperationDefinition(id="notes.get", label="View a note")
6
+ NOTES_UPDATE = OperationDefinition(id="notes.update", label="Edit a note")
7
+ NOTES_DELETE = OperationDefinition(id="notes.delete", label="Delete a note")
8
+
9
+ operation_catalog = OperationCatalog(
10
+ operations=(NOTES_LIST, NOTES_CREATE, NOTES_GET, NOTES_UPDATE, NOTES_DELETE),
11
+ coverage=OperationCoverage.STRICT,
12
+ )
@@ -0,0 +1,20 @@
1
+ from control_plane.operations import (
2
+ NOTES_CREATE,
3
+ NOTES_DELETE,
4
+ NOTES_GET,
5
+ NOTES_LIST,
6
+ NOTES_UPDATE,
7
+ )
8
+ from terp.core import build_crud_router
9
+
10
+ router = build_crud_router(
11
+ NoteService(),
12
+ read_schema=NoteRead,
13
+ create_schema=NoteCreate,
14
+ update_schema=NoteUpdate,
15
+ list_operation=NOTES_LIST,
16
+ create_operation=NOTES_CREATE,
17
+ get_operation=NOTES_GET,
18
+ update_operation=NOTES_UPDATE,
19
+ delete_operation=NOTES_DELETE,
20
+ )
@@ -0,0 +1,3 @@
1
+ from terp.core import OperationCatalog, OperationCoverage
2
+
3
+ operation_catalog = OperationCatalog(coverage=OperationCoverage.STRICT)
@@ -0,0 +1,3 @@
1
+ @router.get('/')
2
+ def list_notes():
3
+ return notes
@@ -0,0 +1,3 @@
1
+ from terp.core import OperationCatalog, OperationCoverage
2
+
3
+ operation_catalog = OperationCatalog(coverage=OperationCoverage.STRICT)
@@ -0,0 +1,8 @@
1
+ from terp.core import build_crud_router
2
+
3
+ router = build_crud_router(
4
+ NoteService(),
5
+ read_schema=NoteRead,
6
+ create_schema=NoteCreate,
7
+ update_schema=NoteUpdate,
8
+ )
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.save":"Opslaan"}}}}
@@ -1,4 +1,6 @@
1
+ import { Trans } from "@terpjs/react-core";
2
+
1
3
  export function Widget() {
2
4
  // terp-allow-token-styled-elements: native button needed for a browser extension host
3
- return <button>save</button>;
5
+ return <button><Trans id="widget.save" message="Save" /></button>;
4
6
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"realtime.waiting":"Wachten"}}}}
@@ -1,4 +1,4 @@
1
- import { useRealtimeChannel } from "@terpjs/react-core";
1
+ import { Trans, useRealtimeChannel } from "@terpjs/react-core";
2
2
 
3
3
  type Notice = { sequence: number; text: string };
4
4
 
@@ -13,5 +13,9 @@ export function Widget() {
13
13
  channel: "system.notices",
14
14
  validate: isNotice,
15
15
  });
16
- return <p>{notices.lastMessage?.text ?? "Waiting"}</p>;
16
+ return (
17
+ <p>
18
+ {notices.lastMessage?.text ?? <Trans id="realtime.waiting" message="Waiting" />}
19
+ </p>
20
+ );
17
21
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"nl","locales":{"nl":{},"en":{"messages":{"notes.title":"Notes","notes.all":"All notes"}}}}
@@ -1,8 +1,8 @@
1
1
  import { HubCard, HubPage } from "@terpjs/react-core";
2
2
  export function Widget() {
3
3
  return (
4
- <HubPage title="Notes">
5
- <HubCard title="All notes" to="/notes" />
4
+ <HubPage title={{ id: "notes.title", message: "Notities" }}>
5
+ <HubCard title={{ id: "notes.all", message: "Alle notities" }} to="/notes" />
6
6
  </HubPage>
7
7
  );
8
8
  }
@@ -0,0 +1,11 @@
1
+ {
2
+ "sourceLocale": "en",
3
+ "locales": {
4
+ "en": { "label": "English" },
5
+ "nl": {
6
+ "label": "Nederlands",
7
+ "messages": { "widgets.title": "Widgets" },
8
+ "allowIdentical": ["widgets.title"]
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,5 @@
1
+ import { Page } from "@terpjs/react-core";
2
+
3
+ export function Widget() {
4
+ return <Page title={{ id: "widgets.title", message: "Widgets" }} />;
5
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "sourceLocale": "en",
3
+ "locales": {
4
+ "en": { "label": "English" },
5
+ "nl": { "label": "Nederlands", "messages": {} }
6
+ }
7
+ }
@@ -0,0 +1,5 @@
1
+ import { Page } from "@terpjs/react-core";
2
+
3
+ export function Widget() {
4
+ return <Page title={{ id: "widgets.title", message: "Widgets" }} />;
5
+ }
@@ -0,0 +1,5 @@
1
+ import { Page } from "@terpjs/react-core";
2
+
3
+ export function Widget() {
4
+ return <Page title={{ id: "widgets.title", message: "Widgets" }} />;
5
+ }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widgets.title":""}}}}
@@ -0,0 +1 @@
1
+ export const widget = { id: "widget-1" };
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widgets.title":"Onderdelen"}}}}
@@ -0,0 +1,5 @@
1
+ import { Page } from "@terpjs/react-core";
2
+
3
+ export function Widget({ messageId }: { messageId: string }) {
4
+ return <Page title={{ id: messageId, message: "Widgets" }} />;
5
+ }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.docs":"Documentatie"}}}}
@@ -1,3 +1,5 @@
1
+ import { Trans } from "@terpjs/react-core";
2
+
1
3
  export function Widget() {
2
- return <a href="https://example.com" rel="noopener">docs</a>;
4
+ return <a href="https://example.com" rel="noopener"><Trans id="widget.docs" message="Docs" /></a>;
3
5
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.docs":"Documentatie"}}}}
@@ -1,3 +1,5 @@
1
+ import { Trans } from "@terpjs/react-core";
2
+
1
3
  export function Widget() {
2
- return <a href="https://example.com" target="_blank" rel="noopener">docs</a>;
4
+ return <a href="https://example.com" target="_blank" rel="noopener"><Trans id="widget.docs" message="Docs" /></a>;
3
5
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widgets.title":"Onderdelen","widgets.empty":"Nog niets te zien."}}}}
@@ -0,0 +1,11 @@
1
+ import { Page, Trans } from "@terpjs/react-core";
2
+
3
+ const title = { id: "widgets.title", message: "Widgets" };
4
+
5
+ export function Widget() {
6
+ return (
7
+ <Page title={title}>
8
+ <Trans id="widgets.empty" message="Nothing here yet." />
9
+ </Page>
10
+ );
11
+ }
@@ -0,0 +1,9 @@
1
+ import { Page, Text } from "@terpjs/react-core";
2
+
3
+ export function Widget() {
4
+ return (
5
+ <Page title="Widgets">
6
+ <Text>Nothing here yet.</Text>
7
+ </Page>
8
+ );
9
+ }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{}}}
@@ -0,0 +1 @@
1
+ export const ssoProviders = [{ name: "sso", label: "Single sign-on" }];
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{}}}
@@ -0,0 +1,5 @@
1
+ import { ModuleNav } from "@terpjs/react-core";
2
+
3
+ export function Widget() {
4
+ return <ModuleNav ariaLabel="Widget sections" items={[]} />;
5
+ }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{}}}
@@ -0,0 +1,10 @@
1
+ export function Status({ ready }: { ready: boolean }) {
2
+ return (
3
+ <>
4
+ {ready ? "Ready" : "Waiting"}
5
+ {ready && "Start now"}
6
+ {["First item", "Second item"]}
7
+ {{ primary: "Save changes", fallback: "Cancel changes" }}
8
+ </>
9
+ );
10
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "sourceLocale": "en",
3
+ "locales": {
4
+ "en": {},
5
+ "nl": {
6
+ "messages": {}
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,7 @@
1
+ import { useToast } from "@terpjs/react-core";
2
+
3
+ export function SaveAction() {
4
+ const toast = useToast();
5
+ toast.success("Changes saved");
6
+ return null;
7
+ }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.notes":"Notities"}}}}
@@ -1,4 +1,4 @@
1
- import { Link } from "@terpjs/react-core";
1
+ import { Link, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Link to="/notes">notes</Link>;
3
+ return <Link to="/notes"><Trans id="widget.notes" message="Notes" /></Link>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.ok":"OK"},"allowIdentical":["widget.ok"]}}}
@@ -1,4 +1,4 @@
1
- import { Button } from "@terpjs/react-core";
1
+ import { Button, Trans } from "@terpjs/react-core";
2
2
  export function Widget() {
3
- return <Button>ok</Button>;
3
+ return <Button><Trans id="widget.ok" message="OK" /></Button>;
4
4
  }
@@ -0,0 +1 @@
1
+ {"sourceLocale":"en","locales":{"en":{},"nl":{"messages":{"widget.save":"Opslaan"}}}}
@@ -1,4 +1,6 @@
1
+ import { Trans } from "@terpjs/react-core";
2
+
1
3
  export function Widget() {
2
4
  // terp-allow-token-styled-elements: native button needed for a browser extension host
3
- return <button>save</button>;
5
+ return <button><Trans id="widget.save" message="Save" /></button>;
4
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/spec",
3
- "version": "0.26.1",
3
+ "version": "0.29.1",
4
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",