terp-cli 0.32.0__tar.gz → 0.34.0__tar.gz

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 (39) hide show
  1. {terp_cli-0.32.0 → terp_cli-0.34.0}/PKG-INFO +8 -8
  2. {terp_cli-0.32.0 → terp_cli-0.34.0}/pyproject.toml +8 -8
  3. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/__init__.py +69 -10
  4. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/verify.py +57 -62
  5. {terp_cli-0.32.0 → terp_cli-0.34.0}/.gitignore +0 -0
  6. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/_appref.py +0 -0
  7. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/_engine.py +0 -0
  8. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/_output.py +0 -0
  9. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/_subjects.py +0 -0
  10. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/access.py +0 -0
  11. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/apidocs.py +0 -0
  12. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/authz_surface.py +0 -0
  13. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/capabilities.py +0 -0
  14. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/deploy_safety.py +0 -0
  15. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/dev.py +0 -0
  16. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/docker.py +0 -0
  17. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/docker_masks.py +0 -0
  18. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/envfile.py +0 -0
  19. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/envschema.py +0 -0
  20. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/envseams.py +0 -0
  21. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/fmt.py +0 -0
  22. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/grants.py +0 -0
  23. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/jobs.py +0 -0
  24. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/leases.py +0 -0
  25. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/module_roles.py +0 -0
  26. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/openapi.py +0 -0
  27. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/outbox.py +0 -0
  28. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/ports.py +0 -0
  29. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/profiles.py +0 -0
  30. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/py.typed +0 -0
  31. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/routes.py +0 -0
  32. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/scaffold.py +0 -0
  33. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/schema.py +0 -0
  34. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/seed.py +0 -0
  35. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/service_accounts.py +0 -0
  36. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/smoke.py +0 -0
  37. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/users.py +0 -0
  38. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/version.py +0 -0
  39. {terp_cli-0.32.0 → terp_cli-0.34.0}/src/terp/cli/workbench.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: terp-cli
3
- Version: 0.32.0
3
+ Version: 0.34.0
4
4
  Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
5
  Project-URL: Repository, https://github.com/AITT-NL/terp-framework
6
6
  Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.13
9
9
  Requires-Dist: pyyaml>=6.0
10
- Requires-Dist: terp-arch==0.32.0
11
- Requires-Dist: terp-core==0.32.0
12
- Requires-Dist: terp-migrations==0.32.0
10
+ Requires-Dist: terp-arch==0.34.0
11
+ Requires-Dist: terp-core==0.34.0
12
+ Requires-Dist: terp-migrations==0.34.0
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.32.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.34.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.34.0; extra == 'jobs'
16
16
  Provides-Extra: scheduler
17
- Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.34.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.32.0; extra == 'worker'
19
+ Requires-Dist: terp-cap-outbox==0.34.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.32.0"
7
+ version = "0.34.0"
8
8
  description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
9
9
  requires-python = ">=3.13"
10
10
  license = "Apache-2.0"
11
11
  dependencies = [
12
- "terp-core==0.32.0",
12
+ "terp-core==0.34.0",
13
13
  # `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
14
14
  # off the path for `terp inspect` / `terp guide` while making migrations work.
15
- "terp-migrations==0.32.0",
15
+ "terp-migrations==0.34.0",
16
16
  # `terp guide rules` projects the live rule registry; terp-arch is imported lazily
17
17
  # (only when that topic is rendered), so it stays off the common `terp guide` path.
18
- "terp-arch==0.32.0",
18
+ "terp-arch==0.34.0",
19
19
  # `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
20
20
  # (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
21
21
  # even the `docker` binary. Declared explicitly rather than leant on: every app
@@ -29,11 +29,11 @@ dependencies = [
29
29
  # or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
30
30
  # one process role, while `jobs` is the convenient complete jobs-process bundle.
31
31
  [project.optional-dependencies]
32
- worker = ["terp-cap-outbox==0.32.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.32.0"]
32
+ worker = ["terp-cap-outbox==0.34.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.34.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.32.0",
36
- "terp-cap-scheduler-apscheduler==0.32.0",
35
+ "terp-cap-outbox==0.34.0",
36
+ "terp-cap-scheduler-apscheduler==0.34.0",
37
37
  ]
38
38
 
39
39
  [project.scripts]
@@ -683,6 +683,11 @@ Stored references (Ref + OnDelete)
683
683
  chosen), and a database reports its default action as absent rather than as the
684
684
  words NO ACTION -- which is why emitting the literal would make every
685
685
  model-versus-database comparison report drift on that constraint forever.
686
+ - A reference you WRITE must point at a row you could READ. The foreign key checks that
687
+ the target exists; every flush also checks it against the target's row scope
688
+ (tenancy, soft-delete, an owner read scope), on insert and whenever an update changes
689
+ the reference - however the flush is reached, an autoflush included. Out of scope
690
+ reads exactly like missing: the same 409.
686
691
  - Ref() forwards everything else to Field(), and indexes by default: an unindexed
687
692
  foreign key turns every parent delete and every join into a table scan.
688
693
  owner_id: uuid.UUID | None = Ref("user.id", on_delete=OnDelete.SET_NULL,
@@ -1007,6 +1012,11 @@ Multi-tenant rows (tenancy capability)
1007
1012
  class Doc(BaseTable, TenantScopedMixin, table=True): ...
1008
1013
  class DocService(TenantScopedService[Doc, DocCreate, DocUpdate]): model = Doc
1009
1014
  - Never filter tenant_id by hand — the framework owns the predicate (the gate forbids it).
1015
+ - A reference stays inside its tenant. Every flush checks each Ref it writes against
1016
+ the target model's row scope, so a row cannot point at another
1017
+ tenant's row (nor at a soft-deleted one): the write fails with the same 409 a missing
1018
+ target gets, and says nothing about the other tenant. Nothing to wire - it follows from
1019
+ the predicate the mixin registers.
1010
1020
  - The current tenant comes from the request (TenantMiddleware binds the JWT `tenant`
1011
1021
  claim); in tests use tenant_context(tenant_id).
1012
1022
  - Wire it through the create_app middleware seam — never add_middleware (the gate
@@ -1454,8 +1464,23 @@ Realtime push (realtime capability)
1454
1464
  - Wire the runtime seams once at the composition root:
1455
1465
  configure_realtime(permission_enforcer=..., principal_validator=...,
1456
1466
  message_session_provider=...)
1457
- configure_broker / configure_ticket_store replace the in-memory defaults when you run
1458
- more than one replica (the in-memory ones are per process).
1467
+ - The default broker and ticket store are PER PROCESS: a publish reaches only subscribers
1468
+ connected to the process that published. That breaks with a second replica, and it
1469
+ breaks with ONE replica too once a job handler runs in `terp jobs worker` - the worker
1470
+ serves no transport, so its publish reaches nobody. Share both through Redis
1471
+ (terp-cap-redis[realtime]) and promise it, so a missing wiring fails instead of
1472
+ dropping messages:
1473
+ configure_broker(RedisRealtimeBroker.from_url(settings.REDIS_URL))
1474
+ configure_ticket_store(RedisConnectionTicketStore.from_url(settings.REDIS_URL))
1475
+ configure_realtime(..., require_shared_broker=settings.is_production)
1476
+ The worker runs the same composition root, so it wires the same broker. Pub/sub is
1477
+ server-wide: the database index is in the channel names, but two deployments sharing
1478
+ one Redis server AND database need distinct namespace=... values. Delivery is
1479
+ fire-and-forget either way: a message published while nobody listens is gone, and a
1480
+ publish Redis does not take (connecting and the reply are each bounded at 2s) is
1481
+ dropped with a logged warning - it never fails the write that published it. A
1482
+ transport subscribes before it answers, so an unreachable Redis is an SSE 503 or a
1483
+ WebSocket closed 1013 before accept, and the browser backs off.
1459
1484
  - Transport is TICKET-based, never a token in a URL: the client POSTs
1460
1485
  /api/v1/realtime/tickets, receives a one-use short-lived ticket, then connects to
1461
1486
  /api/v1/realtime/sse/<channel>?ticket=... (or /ws/<channel> in websocket mode). The
@@ -1579,6 +1604,26 @@ Using capabilities
1579
1604
  and egress auditing attach. A sanctioned internal target is a declared
1580
1605
  `allow_private_addresses=True`, visible in the composition root, never a quiet
1581
1606
  exception inside the client.
1607
+ - A vendor SDK builds its own HTTP client, so neither the rule (it reads YOUR imports)
1608
+ nor EgressClient sees its traffic. Hold the whole process to the declaration at the
1609
+ socket, in the composition root the web process and the worker both run:
1610
+ install_egress_guard(EgressGuard.for_policies(
1611
+ rates_policy, # every EgressPolicy you declared
1612
+ hosts=("api.vendor.example",), # what an SDK calls, by exact name
1613
+ infrastructure=("db", "redis", "smtp.relay.internal"), # names, IPs or CIDRs
1614
+ ))
1615
+ It holds what goes through Python's socket module on IPv4/IPv6, whichever library
1616
+ calls it: a lookup of an undeclared name (getaddrinfo, gethostbyname) is refused
1617
+ before it is made, and a connect or datagram into a private / loopback / link-local /
1618
+ metadata range is refused unless it is declared infrastructure; a peer given by name
1619
+ (localhost too) is resolved and every address held. The refusal is EgressRefusedError
1620
+ (502); under an async client it can arrive inside an ExceptionGroup. It cannot see
1621
+ uvloop, so it refuses to install beside it and refuses `import uvloop` after it: run
1622
+ uvicorn with --loop asyncio (uvicorn[standard] picks uvloop otherwise), and a worker
1623
+ or scheduler with asyncio.run. Native drivers that open their own sockets (libpq,
1624
+ gRPC's C core) are invisible to it and a public IP literal passes; the deployment's
1625
+ network policy holds those. Built for Linux: on Windows asyncio's socketpair connects
1626
+ to loopback, so a new event loop is refused unless loopback is declared.
1582
1627
  - Credentials never live in module source: a credential-shaped assignment (password,
1583
1628
  api_key, token, ...) to a string literal — or a recognizable secret-token literal
1584
1629
  anywhere — is refused by the no_hardcoded_credentials rule. Wire secrets through
@@ -1778,28 +1823,36 @@ Frontend module screens (@terpjs/react-core)
1778
1823
  everything composes the token-styled @terpjs/react-core surface. The full catalog (with
1779
1824
  per-export "Use" guidance) is the @terpjs/react-core README; each export also carries
1780
1825
  JSDoc, so your editor shows the same guidance inline.
1781
- - The boundary lint (@terpjs/eslint-boundaries) refuses, fail-closed:
1826
+ - The boundary lint (@terpjs/eslint-boundaries) refuses, fail-closed, in every file under
1827
+ frontend/src/ — a component beside the modules is held exactly like one inside them:
1782
1828
  raw <button>/<input>/<select>/<textarea> -> Button / Input / Select / Textarea
1783
1829
  raw <table> -> DataView (terp guide dataview)
1784
1830
  raw <dialog> -> ConfirmDialog
1785
1831
  raw <form> -> Stack as="form" (terp guide forms)
1786
1832
  raw fetch / XMLHttpRequest -> useTerpClient() + unwrap (typed client)
1787
1833
  WebSocket / EventSource / sendBeacon -> the generated client (one egress path)
1788
- style={} / className / module stylesheets -> layout via Stack/DetailList; design tokens
1834
+ style={} / className / any stylesheet -> layout via Stack/DetailList; design tokens
1789
1835
  <a href="/..."> -> the router's Link (role-aware, no reload)
1790
1836
  deep imports (@terpjs/*/src, @terpjs/*/dist) -> import from the package root only
1791
1837
  data-terp / data-terp-* anywhere in src -> compose the component that renders it
1792
1838
  The data-terp markers are react-core's own: its stylesheet and the runtime layout check
1793
1839
  trust them. A framework screen you replace (renderTerpApp({ login })) is yours, so its
1794
1840
  tests find it by role and accessible name, not by the framework screen's markers.
1841
+ The bootstrap (src/main.tsx) is the one file that imports stylesheets, and only the token
1842
+ pipeline's three: @terpjs/contract/tokens.css, ./house-style.css and ./theme.css. A
1843
+ library's stylesheet is refused there too: it would paint outside the palettes. A
1844
+ stylesheet loaded through import() or import.meta.glob is refused the same way.
1845
+ Modules stay independent: a module never imports a sibling, and code outside the modules
1846
+ (a shared helper) never imports from one, since shared code is what modules depend on.
1847
+ The bootstrap finds the modules with import.meta.glob, which is not an import.
1795
1848
  - Frontend security defaults (each its own lint rule, same error-only footing):
1796
1849
  dangerouslySetInnerHTML and DOM HTML-injection sinks (innerHTML/outerHTML/
1797
1850
  insertAdjacentHTML/document.write) are refused — render text, or Markdown from
1798
1851
  @terpjs/react-core for rich text; eval() / new Function() are refused; javascript:
1799
1852
  URLs in href/src are refused; a static target="_blank" link needs rel="noopener".
1800
1853
  - Every routed view renders a page archetype (Page / OverviewPage / DetailPage / HubPage /
1801
- DashboardPage / FormPage / SettingsPage / SplitPage); buildAppRouter refuses an unframed
1802
- view at runtime, fail closed. An app can ratchet
1854
+ DashboardPage / FormPage / SettingsPage / SplitPage / WorkspacePage); buildAppRouter
1855
+ refuses an unframed view at runtime, fail closed. An app can ratchet
1803
1856
  further with an opt-in slot-typed layout contract (terp guide layouts).
1804
1857
  - Route paths and params are CHECKED, from generated types (ADR 0092). The router is built
1805
1858
  at runtime from the manifests, so nothing type-checks a path or a param name until you
@@ -1873,7 +1926,7 @@ Forms (react-core primitives)
1873
1926
  <Button type="submit" variant="primary">Save</Button>
1874
1927
  </Stack>
1875
1928
  - Field wraps label + control + hint/error for one field; Stack (vertical by default)
1876
- is the layout — never style={} / className / a module stylesheet.
1929
+ is the layout — never style={} / className / a stylesheet.
1877
1930
  - Submit through the typed client: const client = useTerpClient();
1878
1931
  await unwrap(client.POST("/api/v1/invoices/", { body })); a failure throws ApiError
1879
1932
  ({code, status, requestId, fields}) — map codes to copy with useErrorMessage, show
@@ -1938,8 +1991,8 @@ Theming and branding (design tokens, palettes, the brand mark)
1938
1991
 
1939
1992
  - EVERY style is a design token. The react-core primitives paint from CSS custom
1940
1993
  properties shipped by @terpjs/contract (tokens.css), which is why the boundary lint
1941
- refuses `style={}`, `className` and module stylesheets: a module that painted itself
1942
- would not follow the palette. Modules never need theme-specific code.
1994
+ refuses `style={}`, `className` and stylesheets in every src/ file: code that painted
1995
+ itself would not follow the palette. Modules never need theme-specific code.
1943
1996
  - THE SHIPPED PALETTES, plus "system":
1944
1997
  midday twilight evening night contrast
1945
1998
  Named for the time of day they suit: midday is the light set, twilight a dimmed dark,
@@ -2095,6 +2148,10 @@ Layout contracts (slot-typed layouts, ADR 0079)
2095
2148
  BarChart / ProportionBar / StatusHistory / Timeline / Divider / Text +
2096
2149
  the same states and ConfirmDialog: "how is the whole doing", its
2097
2150
  figures in the summary band
2151
+ WorkspacePage -> ONE CanvasHost + the same states and ConfirmDialog: one canvas that
2152
+ fills the screen. The host takes ONE canvas child (an svg, or a
2153
+ canvas library's root) and sizes it to the whole box; what is
2154
+ drawn ON it is yours. The trail is `parents`, as on DashboardPage
2098
2155
  Grid is not an overview-body component, deliberately: an overview body is a data
2099
2156
  collection, a grid of cards into each area is a hub, and sections of figures and charts
2100
2157
  are a dashboard — each has its own archetype. Heading is admitted nowhere: a heading in a governed body must OWN its
@@ -2137,7 +2194,9 @@ Layout contracts (slot-typed layouts, ADR 0079)
2137
2194
  app's areas is a HubPage; one area's collection (how each one is doing) an OverviewPage;
2138
2195
  how the whole is doing a DashboardPage -- a landing that also carries the app's figures is
2139
2196
  one, as the hub preset scaffolds it; one record a DetailPage; entering a record a
2140
- FormPage; settings a SettingsPage; a list beside the record it selects a SplitPage.
2197
+ FormPage; settings a SettingsPage; a list beside the record it selects a SplitPage; work
2198
+ done on a surface (a diagram of nodes and connections, a plan) a WorkspacePage, whose one
2199
+ CanvasHost takes every pixel the shell leaves - a canvas never needs a style to be sized.
2141
2200
  - Alternate framed and unframed blocks. The frame belongs to the data a reader works with:
2142
2201
  a collection, a chart and a lone figure sit on the surface, the collection brightest. A
2143
2202
  boxed Card steps back onto --color-bg-subtle, for a group of controls or one section of
@@ -1090,6 +1090,45 @@ def _node_modules_problem(root: pathlib.Path, workspace: str) -> str | None:
1090
1090
  )
1091
1091
 
1092
1092
 
1093
+ #: The status a shell gives a command it cannot find, and the one a check answers with
1094
+ #: when the tool it runs is not installed.
1095
+ _NOT_FOUND = 127
1096
+
1097
+
1098
+ def _run_argv(
1099
+ argv: list[str], root: pathlib.Path, *, env: dict[str, str] | None = None
1100
+ ) -> subprocess.CompletedProcess[str]:
1101
+ """Run *argv* in *root* as a fixed argv, with no shell; a missing tool is a result.
1102
+
1103
+ Every check that shells out comes through here, so a tool that is not installed
1104
+ answers the way a shell would, exit 127 naming the tool, instead of raising. Only the
1105
+ manifest runner used to catch it. The runners with a body of their own (route types,
1106
+ the API client, the API reference) let ``FileNotFoundError`` escape the profile loop:
1107
+ the run ended in a traceback about ``subprocess``, every check after that one went
1108
+ unrun and unreported, and the one fact worth reading, which tool is missing, was a
1109
+ frame deep in the stack.
1110
+
1111
+ The message is the result's stdout, so each caller's own formatting of a finished
1112
+ command carries it unchanged.
1113
+ """
1114
+ executable = shutil.which(argv[0]) or argv[0]
1115
+ try:
1116
+ return subprocess.run( # noqa: S603 - fixed argv, shell=False
1117
+ [executable, *argv[1:]],
1118
+ cwd=root,
1119
+ capture_output=True,
1120
+ text=True,
1121
+ encoding="utf-8",
1122
+ errors="replace",
1123
+ check=False,
1124
+ env=None if env is None else {**os.environ, **env},
1125
+ )
1126
+ except FileNotFoundError:
1127
+ return subprocess.CompletedProcess(
1128
+ argv, _NOT_FOUND, stdout=f"{argv[0]}: executable not found on PATH", stderr=""
1129
+ )
1130
+
1131
+
1093
1132
  def _run_subprocess(
1094
1133
  check: VerifyCheck, root: pathlib.Path, *, env: dict[str, str] | None = None
1095
1134
  ) -> tuple[int, str]:
@@ -1109,20 +1148,7 @@ def _run_subprocess(
1109
1148
  problem = _node_modules_problem(root, _npm_workspace(argv))
1110
1149
  if problem is not None:
1111
1150
  return 1, problem
1112
- executable = shutil.which(argv[0]) or argv[0]
1113
- try:
1114
- completed = subprocess.run( # noqa: S603 - fixed manifest argv, shell=False
1115
- [executable, *argv[1:]],
1116
- cwd=root,
1117
- capture_output=True,
1118
- text=True,
1119
- encoding="utf-8",
1120
- errors="replace",
1121
- check=False,
1122
- env=None if env is None else {**os.environ, **env},
1123
- )
1124
- except FileNotFoundError:
1125
- return 127, f"{argv[0]}: executable not found on PATH"
1151
+ completed = _run_argv(argv, root, env=env)
1126
1152
  return completed.returncode, completed.stdout + (
1127
1153
  "\n" + completed.stderr if completed.stderr else ""
1128
1154
  )
@@ -1640,17 +1666,7 @@ def _run_routes_drift(root: pathlib.Path) -> tuple[int, str]:
1640
1666
  problem = _node_modules_problem(root, "frontend")
1641
1667
  if problem is not None:
1642
1668
  return 1, problem
1643
- argv = routes_argv(check=True)
1644
- executable = shutil.which(argv[0]) or argv[0]
1645
- completed = subprocess.run( # noqa: S603 - fixed manifest argv, shell=False
1646
- [executable, *argv[1:]],
1647
- cwd=root,
1648
- capture_output=True,
1649
- text=True,
1650
- encoding="utf-8",
1651
- errors="replace",
1652
- check=False,
1653
- )
1669
+ completed = _run_argv(routes_argv(check=True), root)
1654
1670
  return completed.returncode, f"{completed.stdout}{completed.stderr}"
1655
1671
 
1656
1672
 
@@ -1663,21 +1679,6 @@ _API_DOCS_ARTIFACTS = ("platform-api.md", "terp_core.pyi")
1663
1679
  _API_DOCS_ADOPT_HINT = "commit docs/platform-api.md + docs/terp_core.pyi to enable"
1664
1680
 
1665
1681
 
1666
- def _tracked(root: pathlib.Path, relative: str) -> bool:
1667
- """Whether *relative* is a file git is tracking in *root*."""
1668
- git = shutil.which("git") or "git"
1669
- completed = subprocess.run( # noqa: S603 - fixed argv, shell=False
1670
- [git, "ls-files", "--error-unmatch", "--", relative],
1671
- cwd=root,
1672
- capture_output=True,
1673
- text=True,
1674
- encoding="utf-8",
1675
- errors="replace",
1676
- check=False,
1677
- )
1678
- return completed.returncode == 0
1679
-
1680
-
1681
1682
  def _run_api_docs_drift(root: pathlib.Path) -> tuple[int, str]:
1682
1683
  """Regenerate the API reference and fail on drift from the committed copy.
1683
1684
 
@@ -1707,7 +1708,20 @@ def _run_api_docs_drift(root: pathlib.Path) -> tuple[int, str]:
1707
1708
  f"({_API_DOCS_ADOPT_HINT})",
1708
1709
  )
1709
1710
  relatives = [f"docs/{name}" for name in _API_DOCS_ARTIFACTS]
1710
- tracked = [relative for relative in relatives if _tracked(root, relative)]
1711
+ probes = {
1712
+ relative: _run_argv(["git", "ls-files", "--error-unmatch", "--", relative], root)
1713
+ for relative in relatives
1714
+ }
1715
+ if any(probe.returncode == _NOT_FOUND for probe in probes.values()):
1716
+ # Not "untracked, skipped": without git there is no answer to whether the pair is
1717
+ # tracked, and reading that silence as "no" would turn this check green over a
1718
+ # comparison it never made.
1719
+ return (
1720
+ _NOT_FOUND,
1721
+ "git: executable not found on PATH - the drift check asks git whether "
1722
+ f"{' + '.join(relatives)} are tracked, so it cannot run without it",
1723
+ )
1724
+ tracked = [relative for relative, probe in probes.items() if probe.returncode == 0]
1711
1725
  if not tracked:
1712
1726
  return (
1713
1727
  0,
@@ -1729,16 +1743,7 @@ def _run_api_docs_drift(root: pathlib.Path) -> tuple[int, str]:
1729
1743
  written = [str(path) for path in api_docs(str(docs))]
1730
1744
  finally:
1731
1745
  os.chdir(previous)
1732
- git = shutil.which("git") or "git"
1733
- completed = subprocess.run( # noqa: S603 - fixed argv, shell=False
1734
- [git, "diff", "--exit-code", "--", *relatives],
1735
- cwd=root,
1736
- capture_output=True,
1737
- text=True,
1738
- encoding="utf-8",
1739
- errors="replace",
1740
- check=False,
1741
- )
1746
+ completed = _run_argv(["git", "diff", "--exit-code", "--", *relatives], root)
1742
1747
  output = "\n".join(
1743
1748
  ["\n".join(f"wrote {path}" for path in written), completed.stdout]
1744
1749
  )
@@ -1941,17 +1946,7 @@ def _run_api_client(root: pathlib.Path) -> tuple[int, str]:
1941
1946
  return 1, f"could not export the OpenAPI document: {refusal}"
1942
1947
  finally:
1943
1948
  os.chdir(previous)
1944
- argv = ["npm", "--prefix", "frontend", "run", "generate"]
1945
- executable = shutil.which(argv[0]) or argv[0]
1946
- completed = subprocess.run( # noqa: S603 - fixed argv, shell=False
1947
- [executable, *argv[1:]],
1948
- cwd=root,
1949
- capture_output=True,
1950
- text=True,
1951
- encoding="utf-8",
1952
- errors="replace",
1953
- check=False,
1954
- )
1949
+ completed = _run_argv(["npm", "--prefix", "frontend", "run", "generate"], root)
1955
1950
  output = f"wrote {written.name}\n{completed.stdout}{completed.stderr}"
1956
1951
  if completed.returncode != 0:
1957
1952
  output += (
File without changes
File without changes
File without changes