@biffo/cli 0.249.6 → 0.249.8

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.
@@ -205,7 +205,32 @@ for lock in $ALL_LOCKS; do
205
205
 
206
206
  # `--no-emit-project` drops the tree's own package (nothing published to
207
207
  # audit); `--no-dev` keeps the audit to what actually ships.
208
- if (cd "$dir" && uv export --frozen --no-dev --no-emit-project) >"$reqs" 2>/dev/null && [ -s "$reqs" ]; then
208
+ #
209
+ # `--no-emit-local` drops LOCAL PATH dependencies, and without it this gate
210
+ # cannot scan a plugin tree at all (#1340). `biffo-plugin-sdk` is pulled in
211
+ # as `-e ./packages/python-sdk`; pip-audit tries to resolve it on PyPI, fails
212
+ # with "Dependency not found on PyPI and could not be audited", and produces
213
+ # no parseable output -- so the whole tree came back INCONCLUSIVE and blocked.
214
+ # Every vendored Python plugin tree in every instance has that dependency, so
215
+ # this was not a niche shape: it blocked all of them.
216
+ #
217
+ # Skipping it costs nothing real. A local path dependency is first-party
218
+ # source, audited in its own repo, and never a published artefact an advisory
219
+ # could name. Its TRANSITIVE dependencies are still exported and still
220
+ # audited -- verified on `services/ideation`, where the export keeps 25
221
+ # packages including three carried `via biffo-plugin-sdk`. So the answer this
222
+ # gate gives is unchanged for every package an advisory can actually be about.
223
+ if (cd "$dir" && uv export --frozen --no-dev --no-emit-project --no-emit-local) >"$reqs" 2>/dev/null && [ -s "$reqs" ]; then
224
+ # Report what was skipped rather than skipping it quietly: "audited
225
+ # everything except the bits we could not" must never read the same as
226
+ # "audited everything", which is the failure this whole file exists to
227
+ # fight. Counted from the same lockfile, so the number is the tree's, not
228
+ # a guess.
229
+ local_deps=$( (cd "$dir" && uv export --frozen --no-dev --no-emit-project) 2>/dev/null \
230
+ | grep -cE '^-e |@ file://' || true )
231
+ if [ "${local_deps:-0}" -gt 0 ]; then
232
+ echo " (${rel}: ${local_deps} local path dependenc$([ "$local_deps" -eq 1 ] && echo y || echo ies) excluded -- first-party, audited in their own repo; their transitive dependencies are still scanned)"
233
+ fi
209
234
  audit_deps "pip-audit (${rel})" "-r $reqs" || failed=1
210
235
  else
211
236
  # An export failure is "couldn't run", not "clean" — same discipline as a
@@ -205,7 +205,32 @@ for lock in $ALL_LOCKS; do
205
205
 
206
206
  # `--no-emit-project` drops the tree's own package (nothing published to
207
207
  # audit); `--no-dev` keeps the audit to what actually ships.
208
- if (cd "$dir" && uv export --frozen --no-dev --no-emit-project) >"$reqs" 2>/dev/null && [ -s "$reqs" ]; then
208
+ #
209
+ # `--no-emit-local` drops LOCAL PATH dependencies, and without it this gate
210
+ # cannot scan a plugin tree at all (#1340). `biffo-plugin-sdk` is pulled in
211
+ # as `-e ./packages/python-sdk`; pip-audit tries to resolve it on PyPI, fails
212
+ # with "Dependency not found on PyPI and could not be audited", and produces
213
+ # no parseable output -- so the whole tree came back INCONCLUSIVE and blocked.
214
+ # Every vendored Python plugin tree in every instance has that dependency, so
215
+ # this was not a niche shape: it blocked all of them.
216
+ #
217
+ # Skipping it costs nothing real. A local path dependency is first-party
218
+ # source, audited in its own repo, and never a published artefact an advisory
219
+ # could name. Its TRANSITIVE dependencies are still exported and still
220
+ # audited -- verified on `services/ideation`, where the export keeps 25
221
+ # packages including three carried `via biffo-plugin-sdk`. So the answer this
222
+ # gate gives is unchanged for every package an advisory can actually be about.
223
+ if (cd "$dir" && uv export --frozen --no-dev --no-emit-project --no-emit-local) >"$reqs" 2>/dev/null && [ -s "$reqs" ]; then
224
+ # Report what was skipped rather than skipping it quietly: "audited
225
+ # everything except the bits we could not" must never read the same as
226
+ # "audited everything", which is the failure this whole file exists to
227
+ # fight. Counted from the same lockfile, so the number is the tree's, not
228
+ # a guess.
229
+ local_deps=$( (cd "$dir" && uv export --frozen --no-dev --no-emit-project) 2>/dev/null \
230
+ | grep -cE '^-e |@ file://' || true )
231
+ if [ "${local_deps:-0}" -gt 0 ]; then
232
+ echo " (${rel}: ${local_deps} local path dependenc$([ "$local_deps" -eq 1 ] && echo y || echo ies) excluded -- first-party, audited in their own repo; their transitive dependencies are still scanned)"
233
+ fi
209
234
  audit_deps "pip-audit (${rel})" "-r $reqs" || failed=1
210
235
  else
211
236
  # An export failure is "couldn't run", not "clean" — same discipline as a
@@ -0,0 +1,924 @@
1
+ """Every literal `/api/v1/...` path the frontend calls must exist on this BFF.
2
+
3
+ biffo-template#1330 / tabsii-crm#232. Two occurrences in tabsii-crm, ten days
4
+ apart, both found only by clicking a deployed page, never by a test:
5
+
6
+ - tabsii-crm#218 — the simulation menu called a BFF route that did not exist.
7
+ - tabsii-crm#231 — the entire Finance feature, 13 paths, none served, three
8
+ Brand HQ sections rendering `Not Found` after thirteen merged milestones.
9
+
10
+ Neither existing suite could have caught either one. The frontend's own tests
11
+ mock the API client (`createApiClient`), so the path string handed to `.get`/
12
+ `.post`/etc is never actually sent anywhere — a typo or a route that was
13
+ renamed on the BFF side is invisible to them by construction. The BFF's own
14
+ tests exercise routes it registers, and never look at what the frontend calls.
15
+ Both suites are green, CI is green, the deploy is green, and the page is dead
16
+ the moment a person opens it.
17
+
18
+ This test closes that seam from the BFF side, which is the only place that can
19
+ see both halves: it reads the frontend's source for every `/api/v1/...`
20
+ literal, and asserts each one matches a route this app actually registers —
21
+ read from the running `FastAPI` instance (see `registered_route_paths` below
22
+ for exactly how, and why not the `{r.path for r in app.routes}` this issue's
23
+ own text proposes), never a hand-maintained second list. A second list is
24
+ exactly the `_extract_detail` mistake (biffo-template#1107/#1108): written
25
+ once, drifting the moment either side changes without the other.
26
+
27
+ ## What counts as a call site
28
+
29
+ Every `.ts`/`.tsx` file under `apps/frontend/src/**`, EXCLUDING `*.test.ts(x)`
30
+ and `*.spec.ts(x)`. Test files are deliberately excluded: they mock the API
31
+ client and routinely reference fictional paths (`/api/v1/courses`,
32
+ `/api/v1/modules`, ...) as illustrative fixtures for `api-client.test.ts`
33
+ itself, not as real call sites — including those would make this test fail
34
+ permanently on paths nobody's browser ever requests, which is not the class
35
+ this guards against and would just get the whole file learned-to-ignore.
36
+
37
+ ## Unresolvable paths FAIL, they do not skip
38
+
39
+ A check that drops an input it cannot evaluate shrinks its own denominator and
40
+ reports the remainder as the whole — `scripts/protection-audit.sh` reporting
41
+ 27 clean branches when the real number was 34 is the standing example
42
+ (AGENTS.md §2). Three shapes are extracted but deliberately treated as
43
+ unresolvable rather than matched or silently ignored, and all three FAIL the
44
+ test rather than being dropped:
45
+
46
+ 1. **A computed prefix** — `` `${apiBase}/api/v1/x` `` — where the part
47
+ *before* `/api/v1/` is an interpolation rather than a literal. This test
48
+ has no way to know what `apiBase` evaluates to, so it cannot rule out that
49
+ the real request never reaches `/api/v1/` at all.
50
+ 2. **String concatenation** — `'/api/v1/x/' + id` (built with `+`, not a
51
+ template-literal interpolation) — where the literal fragment this test
52
+ would extract is incomplete on its own (`/api/v1/x/`) and comparing that
53
+ fragment against a route template would either produce a false failure (the
54
+ fragment alone matches nothing) or, worse, a false pass if some unrelated
55
+ route happens to share the prefix.
56
+ 3. **A template literal nested inside another's interpolation** — real shape,
57
+ tabsii-crm's `ops-history-api.ts`:
58
+ `` `/api/v1/ops/units/${id}/history${cond ? `?limit=${limit}` : ''}` ``.
59
+ The extractor's regex is not nesting-aware, so it stops at the FIRST
60
+ closing backtick it finds — the nested literal's opening one — truncating
61
+ `raw` mid-expression. Detected in `_is_nested_template_literal_artifact`
62
+ (a dangling `${` or a literal newline surviving into what should be a URL
63
+ path is the tell) and routed to `unresolved_reason` rather than silently
64
+ producing a corrupted, garbage `normalized` value that would then fail to
65
+ match for a misleading reason — or, worse, coincidentally happen to match
66
+ something and mask a real defect.
67
+
68
+ `${...}` interpolations *inside* an otherwise-literal template — the ordinary,
69
+ expected case, e.g. `` `/api/v1/courses/${id}` `` — are not in either category:
70
+ they are normalised to `{param}` and compared structurally (see below). There
71
+ are no concatenated or computed-prefix call sites in this skeleton today; this
72
+ is future-proofing for whatever a sibling adds, backed by
73
+ `TestExtractApiPaths` below so the behaviour is proven rather than assumed.
74
+
75
+ A third shape is common in practice and deliberately NOT unresolvable: a
76
+ literal query string appended after the path, e.g.
77
+ `` `/api/v1/board?brand_id=${id}` `` (real call sites in tabsii-crm's
78
+ `PipelineBoard.tsx` and several other components — found while checking that
79
+ repo's frontend against its BFF ahead of ever distributing this guard there).
80
+ The query string is stripped before matching, on the same basis FastAPI itself
81
+ uses: a route's path template never includes one, and the query string cannot
82
+ change which route handles the request. Treating the query string as part of
83
+ the path to match would make this guard fail a repo for something FastAPI's
84
+ own router does not care about — a false positive that would have misfired on
85
+ a real sibling the very first time it ran, so the case is proven in
86
+ `TestExtractApiPaths` rather than left to be discovered by the next repo that
87
+ tries this.
88
+
89
+ A fourth shape is the same problem one step further: a query string built as
90
+ its OWN variable and interpolated whole, with no literal `?` anywhere in the
91
+ template — tabsii-crm's `AnalyticsPanel.tsx` calls
92
+ `` `/api/v1/analytics/pipeline/funnel${query}` `` where `query` is
93
+ `` `?${params.toString()}` ``. There is no literal `?` to split on, so this is
94
+ genuinely ambiguous from the call site alone: a trailing `${...}` glued
95
+ directly onto the end of a path (nothing after it, no `/` before it) reads
96
+ exactly as plausibly as a path parameter (`.../funnel{param}`) as it does a
97
+ whole query string (`.../funnel`). Rather than guess, `alt_normalized` records
98
+ the second reading, and a match against *either* reading counts — see
99
+ `_maybe_trailing_dynamic_suffix` and `ExtractedPath.alt_normalized`. This is
100
+ still not a skip: if neither reading matches a registered route, the test
101
+ fails and reports both readings it tried (see the `UNMATCHED` message below).
102
+
103
+ ## Comments are stripped first, and why that is not optional
104
+
105
+ Found while checking tabsii-crm and tabsii-lms (ahead of ever distributing
106
+ this guard, per the sequencing note in #1330): `access-scope.ts` and
107
+ `display-name.ts` each carry a `//` comment that names a historical path in a
108
+ backtick code-span for a human reader — `` // ... `/api/v1/data/roles` already
109
+ returns the whole catalogue ... `` — which is not a call site at all, but a
110
+ naive backtick-literal regex cannot tell the difference. Left unhandled, this
111
+ guard would fail real, correct repos on their own documentation, which is
112
+ exactly the kind of noise that trains people to stop reading a check
113
+ (AGENTS.md's `mustBeUniform`/orphan-ratchet posture makes the same point about
114
+ residue that blocks on arrival rather than on a regression).
115
+
116
+ `/* ... */` block comments are removed outright, and `//` line comments are
117
+ removed from the first occurrence preceded by whitespace or start-of-line
118
+ onward — deliberately NOT from the first bare `//` in the line, which would
119
+ also truncate a perfectly normal string literal like `'https://…'` (no
120
+ whitespace precedes that `//`; Prettier — this repo's own formatter — always
121
+ puts either a space or nothing-but-line-start before a real comment marker).
122
+ This is a heuristic, not a tokenizer, and a string containing a real `//`
123
+ preceded by whitespace inside its own literal could defeat it in principle —
124
+ no instance of that shape exists anywhere in this skeleton or in the two
125
+ sibling repos this was checked against, so the trade is accepted rather than
126
+ built out into a full parser for a case that has not occurred.
127
+
128
+ ## Matching
129
+
130
+ Every `${...}` interpolation is normalised to the literal placeholder
131
+ `{param}` first, so `` `/api/v1/courses/${id}` `` on the frontend and
132
+ `/api/v1/courses/{course_id}` on the BFF are never expected to agree on a
133
+ parameter's *name*, only on the path's *shape* — but "shape" is decided by
134
+ asking the app's OWN router to match a concrete instance of that shape
135
+ (`path_is_registered`, below), not by comparing normalised strings against a
136
+ set built from `app.openapi()["paths"]`.
137
+
138
+ That is a deliberate departure from the more obvious design, forced by a real
139
+ route this was checked against: tabsii-crm's
140
+ `@router.get("/analytics/{report:path}")` uses FastAPI's `:path` converter,
141
+ which matches *any number of remaining segments* — `/api/v1/analytics/sources`
142
+ and `/api/v1/analytics/pipeline/funnel` both dispatch to it, at one and three
143
+ segments respectively. `app.openapi()["paths"]` reports that route as
144
+ `/api/v1/analytics/{report}`, structurally indistinguishable in the JSON from
145
+ an ordinary single-segment param — the `:path` converter is compiled into the
146
+ route's matching *regex*, not preserved in its path string, so nothing about
147
+ the OpenAPI schema alone can tell a one-segment placeholder from a
148
+ many-segment catch-all. A flat string/segment comparison would have failed
149
+ every one of `AnalyticsPanel.tsx`'s five real, working calls against this
150
+ guard the moment tabsii-crm adopted it. `registered_route_paths()` is kept
151
+ only for the human-readable route list in a failure message — it is no longer
152
+ what a match is decided against.
153
+ """
154
+
155
+ from __future__ import annotations
156
+
157
+ import re
158
+ from dataclasses import dataclass
159
+ from pathlib import Path
160
+
161
+ from fastapi import APIRouter, FastAPI
162
+ from starlette.routing import Match
163
+
164
+ from api.main import app
165
+
166
+ REPO_ROOT = Path(__file__).resolve().parents[3]
167
+ FRONTEND_SRC = REPO_ROOT / "apps" / "frontend" / "src"
168
+
169
+ _TEST_FILE_SUFFIXES = (".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")
170
+
171
+ _TEMPLATE_LITERAL = re.compile(r"`([^`]*)`")
172
+ _STRING_LITERAL = re.compile(r"'([^'\n]*)'|\"([^\"\n]*)\"")
173
+ _INTERPOLATION = re.compile(r"\$\{[^}]*\}")
174
+
175
+
176
+ #: Interpolated base-URL identifiers that mean "this call goes somewhere other
177
+ #: than this app's own BFF". A frontend may legitimately call the core API
178
+ #: directly for PUBLIC, unauthenticated endpoints — `${CORE_API_URL}/api/v1/
179
+ #: public/...` is real in tabsii-intake (7 call sites) and tabsii-marketplace
180
+ #: (1), found by running this guard across every sibling before distributing
181
+ #: it. Those paths are not this BFF's contract, so asserting them against its
182
+ #: route table would fail five working call sites for a fact about the guard
183
+ #: rather than about the code.
184
+ #:
185
+ #: It is an ALLOWLIST, not a wildcard, and that is the whole safety property:
186
+ #: an interpolated prefix this list does not name is still unresolvable and
187
+ #: still FAILS, so nobody can hide a real BFF path behind a computed base by
188
+ #: accident. Adding a name here is a deliberate statement that the identifier
189
+ #: denotes an external origin.
190
+ EXTERNAL_BASE_IDENTIFIERS = frozenset({"CORE_API_URL"})
191
+
192
+ _LEADING_INTERPOLATION = re.compile(r"^\$\{\s*([A-Za-z_$][\w$]*)\s*\}")
193
+
194
+
195
+ def _external_base_name(raw: str) -> str | None:
196
+ """The identifier a path is prefixed with, when that identifier is declared
197
+ external — otherwise None, and the caller treats it as unresolvable."""
198
+ match = _LEADING_INTERPOLATION.match(raw)
199
+ if match is None:
200
+ return None
201
+ name = match.group(1)
202
+ return name if name in EXTERNAL_BASE_IDENTIFIERS else None
203
+
204
+
205
+ def _template_literals(text: str):
206
+ """Every template literal in `text`, as `(content, start_index)` — scanned
207
+ with brace and backtick depth rather than matched with a regex.
208
+
209
+ A regex cannot do this. `` `a${ cond ? `b` : '' }c` `` contains a NESTED
210
+ template literal inside its own interpolation, and `` `([^`]*)` `` stops at
211
+ that inner backtick, capturing a fragment that ends mid-expression. That
212
+ fragment is not the path — it is a truncation, and treating it as one is
213
+ how a guard either fails for a misleading reason or, worse, coincidentally
214
+ passes and masks the defect it exists to catch.
215
+
216
+ So: on `${` the scanner enters an interpolation and tracks `{`/`}` depth,
217
+ skipping any backticks inside it, and only a backtick at depth zero closes
218
+ the literal. `/api/v1/ops/units/${id}/history${c ? `?limit=${n}` : ''}`
219
+ (real, tabsii-crm) then yields its whole content, which normalises to a
220
+ real path plus a trailing interpolation — a shape
221
+ `_maybe_trailing_dynamic_suffix` already reads both ways.
222
+ """
223
+ i, n = 0, len(text)
224
+ while i < n:
225
+ if text[i] != "`":
226
+ i += 1
227
+ continue
228
+ start = i + 1
229
+ j = start
230
+ depth = 0
231
+ while j < n:
232
+ ch = text[j]
233
+ if ch == "\\":
234
+ j += 2
235
+ continue
236
+ if depth == 0 and ch == "`":
237
+ yield text[start:j], start
238
+ break
239
+ if ch == "$" and j + 1 < n and text[j + 1] == "{":
240
+ depth += 1
241
+ j += 2
242
+ continue
243
+ if depth:
244
+ if ch == "{":
245
+ depth += 1
246
+ elif ch == "}":
247
+ depth -= 1
248
+ j += 1
249
+ else:
250
+ # Unterminated literal: not a path, and not something to guess at.
251
+ return
252
+ i = j + 1
253
+
254
+
255
+ _PARAM_SEGMENT = re.compile(r"\{[^}]*\}")
256
+
257
+ _BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
258
+ # From the first `//` preceded by whitespace or start-of-line, to end of line.
259
+ # NOT from the first bare `//`, which would also truncate a `'https://…'`
260
+ # string literal — see the module docstring's "Comments are stripped first"
261
+ # section for the real false positives (tabsii-crm, tabsii-lms) this exists
262
+ # to stop.
263
+ _LINE_COMMENT = re.compile(r"(?:^|(?<=\s))//.*$", re.MULTILINE)
264
+
265
+ API_PREFIX = "/api/v1/"
266
+
267
+
268
+ def _strip_comments(text: str) -> str:
269
+ """Remove `/* ... */` and `// ...` so a comment merely mentioning a path
270
+ — in prose, as documentation — is never mistaken for a call site.
271
+
272
+ A block comment is replaced by the same number of newlines it contained,
273
+ not deleted outright — this codebase leans heavily on multi-line `/** */`
274
+ JSDoc, and dropping those newlines would shift every subsequent line
275
+ number, misdirecting a failure message at the wrong line for anything
276
+ below one.
277
+ """
278
+ text = _BLOCK_COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), text)
279
+ return _LINE_COMMENT.sub("", text)
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class ExtractedPath:
284
+ """One `/api/v1/...` literal found in the frontend source.
285
+
286
+ `normalized` is the comparable path with every dynamic segment collapsed
287
+ to `{param}`, or `None` when the path cannot be resolved at all — see
288
+ `unresolved_reason` for why, which is always set exactly when `normalized`
289
+ is not.
290
+
291
+ `alt_normalized` covers one specific, real ambiguity (see
292
+ `_maybe_trailing_dynamic_suffix` below): a trailing `${...}` glued
293
+ directly onto the end of the last path segment, with nothing after it and
294
+ no `/` before it, reads identically whether the variable holds a path
295
+ parameter or a query string built elsewhere and interpolated whole
296
+ (tabsii-crm's `` `/api/v1/analytics/pipeline/funnel${query}` `` is the
297
+ real example this was found against). Either `normalized` or
298
+ `alt_normalized` matching a registered route counts as a match; this is
299
+ still a FAIL when neither does, never a skip.
300
+ """
301
+
302
+ file: Path
303
+ line: int
304
+ raw: str
305
+ normalized: str | None
306
+ unresolved_reason: str | None
307
+ alt_normalized: str | None = None
308
+ #: Set when the path is prefixed with a DECLARED external base
309
+ #: (`EXTERNAL_BASE_IDENTIFIERS`), meaning it never reaches this BFF. Such a
310
+ #: path is neither matched nor unresolved: it is out of this guard's scope
311
+ #: by a stated rule, and is reported in the denominator as its own count so
312
+ #: "all matched" can never quietly mean "we stopped looking at five".
313
+ external_base: str | None = None
314
+
315
+
316
+ def _line_number(text: str, index: int) -> int:
317
+ return text.count("\n", 0, index) + 1
318
+
319
+
320
+ def _touches_concatenation(text: str, start: int, end: int) -> bool:
321
+ """True when a `+` sits immediately beside the literal (outside quotes).
322
+
323
+ Checked in a small window on both sides rather than adjacent characters
324
+ only, so whitespace around the operator (`'/api/v1/x/' + id`, or
325
+ `'/api/v1/x/'+id`) does not let a concatenated fragment slip through as if
326
+ it were a complete, standalone path.
327
+ """
328
+ window = 40
329
+ after = text[end : end + window].lstrip()
330
+ before = text[max(0, start - window) : start].rstrip()
331
+ return after.startswith("+") or before.endswith("+")
332
+
333
+
334
+ def _relative(path: Path) -> str:
335
+ return str(path.relative_to(REPO_ROOT))
336
+
337
+
338
+ def _resolve_interpolations(raw: str) -> str:
339
+ """Replace every BALANCED `${...}` with `{param}`, tracking brace depth.
340
+
341
+ `_INTERPOLATION`'s `[^}]*` stops at the first `}` it meets, which is the
342
+ wrong one whenever an interpolation contains a nested template literal
343
+ with its own interpolation inside it — `${c ? `?limit=${n}` : ''}` gets
344
+ cut after `${n}`, leaving `: ''}` behind as if it were path text. Depth
345
+ tracking is the only thing that reads that correctly.
346
+
347
+ An interpolation that never closes is left exactly as it is, so
348
+ `_is_nested_template_literal_artifact` can still see the `${` and fail
349
+ loudly rather than guess.
350
+ """
351
+ out: list[str] = []
352
+ i, n = 0, len(raw)
353
+ while i < n:
354
+ if raw[i] == "$" and i + 1 < n and raw[i + 1] == "{":
355
+ depth, j = 1, i + 2
356
+ while j < n and depth:
357
+ if raw[j] == "{":
358
+ depth += 1
359
+ elif raw[j] == "}":
360
+ depth -= 1
361
+ j += 1
362
+ if depth:
363
+ out.append(raw[i:])
364
+ break
365
+ out.append("{param}")
366
+ i = j
367
+ continue
368
+ out.append(raw[i])
369
+ i += 1
370
+ return "".join(out)
371
+
372
+
373
+ def _is_nested_template_literal_artifact(raw: str) -> bool:
374
+ """True when `raw` is almost certainly not the true content of the
375
+ template literal it was extracted from — a truncation, not a real path.
376
+
377
+ `_TEMPLATE_LITERAL` is a plain, non-nesting regex: it matches from an
378
+ opening backtick to the very next backtick, full stop. A JS/TS template
379
+ literal containing a NESTED one inside its own interpolation — tabsii-crm's
380
+ `` `/api/v1/ops/units/${id}/history${ cond ? `?limit=${limit}` : '' }` ``
381
+ (real, found while checking that repo ahead of ever distributing this
382
+ guard there) — has an inner backtick before its real closing one, so the
383
+ regex stops there instead, capturing `raw` as everything up to that INNER
384
+ opening backtick: a fragment ending mid-expression, with a dangling `${`
385
+ and a literal newline in what should be a URL path.
386
+
387
+ Detected two ways, either sufficient on its own: a literal newline (no
388
+ real API path contains one), or a `${` that survives resolution
389
+ unresolved (every WELL-FORMED interpolation closes with a matching `}`
390
+ and gets replaced; one that does not close within the captured text is
391
+ exactly what a truncation looks like). This is a heuristic, not a full
392
+ tokenizer — see the module docstring's "Comments are stripped first"
393
+ section for the same trade made elsewhere in this file — and rather than
394
+ risk silently producing a plausible-looking but WRONG normalised path
395
+ (which would fail the guard for the wrong, misleading reason, or worse,
396
+ coincidentally NOT fail and mask a real defect), a detected truncation is
397
+ routed to `unresolved_reason` and FAILS loudly, per the module docstring's
398
+ "Unresolvable paths FAIL, they do not skip" rule.
399
+ """
400
+ resolved = _resolve_interpolations(raw)
401
+ if "${" in resolved:
402
+ # An interpolation that never closes: the capture really is truncated.
403
+ return True
404
+ # A newline in the PATH text is impossible; one inside an interpolation is
405
+ # just Prettier wrapping a long ternary, and `_template_literals` now
406
+ # captures those whole rather than stopping at the inner backtick. So the
407
+ # newline test has to run AFTER interpolations are resolved away, or every
408
+ # multi-line-formatted call site reads as a truncation it is not.
409
+ return "\n" in resolved
410
+
411
+
412
+ def _strip_query_string(raw: str) -> str:
413
+ """Drop a literal `?...` suffix — FastAPI route templates never include
414
+ one, and it cannot change which route handles the request, so keeping it
415
+ in the comparable path would fail a real, working call site (see the
416
+ module docstring's third shape)."""
417
+ return raw.split("?", 1)[0]
418
+
419
+
420
+ # Matches the RESOLVED form: interpolations are turned into `{param}` before
421
+ # this runs (see the ordering note in `extract_api_paths`), so a trailing
422
+ # dynamic suffix looks like `{param}` at the end, not `${...}`.
423
+ _TRAILING_INTERPOLATION = re.compile(r"\{param\}$")
424
+
425
+
426
+ def _maybe_trailing_dynamic_suffix(path_only: str) -> str | None:
427
+ """The alternate reading for a trailing `${...}` with no `/` before it.
428
+
429
+ `/api/v1/analytics/pipeline/funnel${query}` (tabsii-crm, real) is exactly
430
+ as plausible read as "a path parameter glued onto the segment with no
431
+ separator" (`.../funnel{param}`, `normalized`'s reading) as it is "a query
432
+ string built elsewhere and interpolated whole" (`.../funnel`, dropping it
433
+ entirely) — the two are genuinely indistinguishable from the call site
434
+ alone, and `query` there is in fact built as `` `?${params.toString()}` ``.
435
+
436
+ Returns `None` when there is no trailing interpolation glued directly
437
+ onto the end (nothing to disambiguate), so callers can tell "not
438
+ applicable" from "applies, and resolves to the empty suffix".
439
+ """
440
+ match = _TRAILING_INTERPOLATION.search(path_only)
441
+ if match is None:
442
+ return None
443
+ before = path_only[: match.start()]
444
+ if before.endswith("/"):
445
+ return None # a genuine path segment, not a glued-on suffix
446
+ return before
447
+
448
+
449
+ def extract_api_paths(text: str, file: Path) -> list[ExtractedPath]:
450
+ """Every `/api/v1/...` literal or template in `text`, resolved or not.
451
+
452
+ Comments are stripped first (see `_strip_comments`) — a path merely
453
+ mentioned in a `//` or `/* */` comment is not a call site.
454
+ """
455
+ text = _strip_comments(text)
456
+ found: list[ExtractedPath] = []
457
+
458
+ for _content, _start in _template_literals(text):
459
+ raw = _content
460
+ _end = _start + len(_content)
461
+ if raw.startswith(API_PREFIX):
462
+ if _touches_concatenation(text, _start - 1, _end + 1):
463
+ found.append(
464
+ ExtractedPath(
465
+ file,
466
+ _line_number(text, _start),
467
+ raw,
468
+ None,
469
+ "built with string concatenation (+) — the extracted "
470
+ "literal is a fragment, not the whole path",
471
+ )
472
+ )
473
+ elif _is_nested_template_literal_artifact(raw):
474
+ found.append(
475
+ ExtractedPath(
476
+ file,
477
+ _line_number(text, _start),
478
+ raw,
479
+ None,
480
+ "contains a template literal nested inside its own "
481
+ "interpolation (e.g. a conditional query string built "
482
+ "with a second backtick-quoted expression) — this "
483
+ "extractor's regex is not nesting-aware and would "
484
+ "otherwise truncate at the inner backtick, producing "
485
+ "a corrupted path rather than a real mismatch",
486
+ )
487
+ )
488
+ else:
489
+ # Resolve interpolations BEFORE splitting off the query
490
+ # string. A ternary inside an interpolation carries its own
491
+ # `?` (`${c ? `?limit=${n}` : ""}`), and splitting first
492
+ # truncates the path at that operator — which reads as a
493
+ # missing route rather than as the parsing bug it is.
494
+ path_only = _strip_query_string(_resolve_interpolations(raw))
495
+ normalized = path_only
496
+ alt_base = _maybe_trailing_dynamic_suffix(path_only)
497
+ alt_normalized = alt_base
498
+ found.append(
499
+ ExtractedPath(
500
+ file,
501
+ _line_number(text, _start),
502
+ raw,
503
+ normalized,
504
+ None,
505
+ alt_normalized,
506
+ )
507
+ )
508
+ elif (_base := _external_base_name(raw)) is not None and API_PREFIX in raw:
509
+ # Prefixed with a DECLARED external origin, so it never reaches
510
+ # this BFF at all — counted and reported, never silently dropped.
511
+ found.append(
512
+ ExtractedPath(
513
+ file,
514
+ _line_number(text, _start),
515
+ raw,
516
+ None,
517
+ None,
518
+ None,
519
+ _base,
520
+ )
521
+ )
522
+ elif API_PREFIX in raw:
523
+ # Contains it, but does not start with it: the segment before
524
+ # `/api/v1/` is itself an interpolation (`${apiBase}/api/v1/x`),
525
+ # so this test cannot know what actually precedes the request.
526
+ found.append(
527
+ ExtractedPath(
528
+ file,
529
+ _line_number(text, _start),
530
+ raw,
531
+ None,
532
+ "computed path prefix — an interpolation precedes "
533
+ f"{API_PREFIX!r}, so the real request target is unknown",
534
+ )
535
+ )
536
+
537
+ for match in _STRING_LITERAL.finditer(text):
538
+ raw = match.group(1) if match.group(1) is not None else match.group(2)
539
+ if not raw.startswith(API_PREFIX):
540
+ continue
541
+ if _touches_concatenation(text, match.start(), match.end()):
542
+ found.append(
543
+ ExtractedPath(
544
+ file,
545
+ _line_number(text, match.start()),
546
+ raw,
547
+ None,
548
+ "built with string concatenation (+) — the extracted "
549
+ "literal is a fragment, not the whole path",
550
+ )
551
+ )
552
+ else:
553
+ normalized = _strip_query_string(raw)
554
+ found.append(
555
+ ExtractedPath(file, _line_number(text, match.start()), raw, normalized, None)
556
+ )
557
+
558
+ return found
559
+
560
+
561
+ def _frontend_source_files() -> list[Path]:
562
+ """`.ts`/`.tsx` call sites under `apps/frontend/src/**`, tests excluded.
563
+
564
+ Test files are excluded deliberately, not by omission — see the module
565
+ docstring. They mock the API client, so a path string inside one is a
566
+ fixture value, never something a browser actually requests.
567
+ """
568
+ candidates = [*FRONTEND_SRC.rglob("*.ts"), *FRONTEND_SRC.rglob("*.tsx")]
569
+ return sorted(p for p in candidates if not p.name.endswith(_TEST_FILE_SUFFIXES))
570
+
571
+
572
+ def registered_route_paths() -> set[str]:
573
+ """This BFF's own registered route templates, normalised for display.
574
+
575
+ Read from the live `FastAPI` app — never a hand-maintained second list,
576
+ which is precisely how #1107/#1108 (`_extract_detail`) and #218/#231 (this
577
+ issue) happened: two copies of the same fact, kept in step by nothing.
578
+
579
+ Deliberately `app.openapi()["paths"]`, not `{r.path for r in app.routes}`
580
+ as biffo-template#1330 proposes: on the FastAPI version this skeleton
581
+ actually pins (0.139.0), `app.include_router(...)` defers expansion behind
582
+ an internal `_IncludedRouter` wrapper with no `.path` of its own, so
583
+ walking `app.routes` and skipping anything without `.path` silently
584
+ returns only the three auto-generated meta-routes (`/openapi.json`,
585
+ `/api/docs`, `/docs/oauth2-redirect`) and NONE of the app's real routes —
586
+ confirmed by running exactly that snippet against this app before writing
587
+ this function. That is the same failure shape this whole test exists to
588
+ prevent, one level in: a check that quietly evaluates against less than it
589
+ thinks it does. `app.openapi()` is the documented, version-stable way to
590
+ ask "what does this app actually serve": it walks whatever internal
591
+ representation the installed FastAPI uses and returns the fully-resolved
592
+ path templates (prefixes already applied, params already in `{name}`
593
+ form), so it does not need to know about `_IncludedRouter` or whatever
594
+ FastAPI's next refactor replaces it with.
595
+
596
+ Used only to build the human-readable route list in a failure message —
597
+ see `path_is_registered` for what a match is actually decided against, and
598
+ why this set alone is not enough (the `:path`-converter case).
599
+ """
600
+ schema = app.openapi()
601
+ paths: set[str] = set(schema.get("paths", {}).keys())
602
+ return {_PARAM_SEGMENT.sub("{param}", path) for path in paths}
603
+
604
+
605
+ _PLACEHOLDER_SEGMENT = "x"
606
+
607
+
608
+ def _concrete_candidate(normalized: str) -> str:
609
+ """A `{param}`-templated path turned into one concrete instance — an
610
+ ordinary, inert path segment with no special meaning to any router."""
611
+ return normalized.replace("{param}", _PLACEHOLDER_SEGMENT)
612
+
613
+
614
+ def path_is_registered(fastapi_app: FastAPI, normalized: str) -> bool:
615
+ """Does a concrete instance of this template dispatch to ANY route this
616
+ app registers?
617
+
618
+ Asks the app's OWN router — `route.matches(scope)`, the exact mechanism a
619
+ real request goes through — rather than reconstructing FastAPI's path
620
+ semantics as a second, parallel implementation. That distinction is not
621
+ tidiness: a flat string/segment comparison cannot represent a `:path`
622
+ converter at all. `@router.get("/analytics/{report:path}")` (a real route
623
+ in tabsii-crm) matches any number of trailing segments, so
624
+ `/api/v1/analytics/{param}` (one segment) and
625
+ `/api/v1/analytics/pipeline/funnel` (three) are never equal as strings,
626
+ yet the same real request dispatches to that one route either way — see
627
+ the module docstring's "Matching" section for how this was found. Only
628
+ Starlette's own compiled route regex — reached through `.matches()`, never
629
+ re-derived from `app.openapi()`'s already-flattened path strings, which do
630
+ not preserve which converter a segment used — knows that.
631
+
632
+ `Match.NONE` means no match at all; `Match.PARTIAL` means the path matched
633
+ but the HTTP method did not. Either non-`NONE` result means a route
634
+ exists for this path shape, which is the only question this guard asks —
635
+ it does not care which verb the frontend used.
636
+ """
637
+ candidate = _concrete_candidate(normalized)
638
+ scope = {"type": "http", "path": candidate, "method": "GET", "path_params": {}}
639
+ for route in fastapi_app.routes:
640
+ match, _ = route.matches(scope)
641
+ if match != Match.NONE:
642
+ return True
643
+ return False
644
+
645
+
646
+ def test_every_frontend_api_v1_path_is_registered_on_the_bff() -> None:
647
+ extracted: list[ExtractedPath] = []
648
+ for file in _frontend_source_files():
649
+ extracted.extend(extract_api_paths(file.read_text(encoding="utf-8"), file))
650
+
651
+ external = [p for p in extracted if p.external_base is not None]
652
+ in_scope = [p for p in extracted if p.external_base is None]
653
+ resolved = [p for p in in_scope if p.normalized is not None]
654
+ unresolved = [p for p in in_scope if p.normalized is None]
655
+
656
+ def _is_matched(p: ExtractedPath) -> bool:
657
+ if p.normalized is not None and path_is_registered(app, p.normalized):
658
+ return True
659
+ return p.alt_normalized is not None and path_is_registered(app, p.alt_normalized)
660
+
661
+ unmatched = [p for p in resolved if not _is_matched(p)]
662
+ matched_count = len(resolved) - len(unmatched)
663
+
664
+ # The denominator, always reported — never just "N failures" with no sense
665
+ # of how many paths were even in scope. A share with an invisible
666
+ # denominator is how #1330's own motivating class ("27 clean branches"
667
+ # when the real count was 34) recurs one level up.
668
+ summary = (
669
+ f"{len(extracted)} /api/v1/... path template(s) found under "
670
+ f"apps/frontend/src/** ({_relative(FRONTEND_SRC)}); "
671
+ f"{matched_count} matched a route this BFF registers, "
672
+ f"{len(unmatched)} did not, {len(unresolved)} could not be resolved "
673
+ f"at all, {len(external)} target a declared external base and are out "
674
+ "of this BFF's scope."
675
+ )
676
+
677
+ lines = [summary]
678
+ for p in unmatched:
679
+ readings = (
680
+ repr(p.normalized)
681
+ if p.alt_normalized is None
682
+ else (f"{p.normalized!r} or {p.alt_normalized!r}")
683
+ )
684
+ lines.append(
685
+ f" UNMATCHED {_relative(p.file)}:{p.line} {p.raw!r} "
686
+ f"-> normalised {readings}, no BFF route matches either"
687
+ )
688
+ for p in unresolved:
689
+ lines.append(
690
+ f" UNRESOLVED {_relative(p.file)}:{p.line} {p.raw!r} -> {p.unresolved_reason}"
691
+ )
692
+ for p in external:
693
+ # Reported even though they pass, so the excluded set stays visible
694
+ # rather than becoming a silent hole in the denominator.
695
+ lines.append(
696
+ f" EXTERNAL {_relative(p.file)}:{p.line} {p.raw!r} "
697
+ f"-> prefixed with ${{{p.external_base}}}, declared external"
698
+ )
699
+ if unmatched or unresolved:
700
+ # Display only -- `path_is_registered` above is what actually decided
701
+ # pass/fail; see its docstring for why this flattened set cannot be
702
+ # used for the decision itself.
703
+ lines.append(f"\nRoutes this BFF registers: {sorted(registered_route_paths())}")
704
+
705
+ assert not unmatched and not unresolved, "\n".join(lines)
706
+
707
+
708
+ class TestExtractApiPaths:
709
+ """`extract_api_paths` in isolation, so its handling of each shape is
710
+ proven rather than only exercised indirectly through whatever this
711
+ skeleton's frontend happens to contain today (currently: one literal
712
+ call, `/api/v1/whoami`, and no concatenated or computed-prefix call sites
713
+ at all)."""
714
+
715
+ def test_plain_literal_resolves_unchanged(self) -> None:
716
+ found = extract_api_paths("api.get('/api/v1/whoami')", Path("x.ts"))
717
+ assert len(found) == 1
718
+ assert found[0].normalized == "/api/v1/whoami"
719
+ assert found[0].unresolved_reason is None
720
+
721
+ def test_template_interpolation_normalises_to_param(self) -> None:
722
+ found = extract_api_paths("api.get(`/api/v1/courses/${id}`)", Path("x.ts"))
723
+ assert len(found) == 1
724
+ assert found[0].normalized == "/api/v1/courses/{param}"
725
+
726
+ def test_multiple_interpolations_all_normalise(self) -> None:
727
+ found = extract_api_paths(
728
+ "api.get(`/api/v1/courses/${courseId}/modules/${moduleId}`)", Path("x.ts")
729
+ )
730
+ assert len(found) == 1
731
+ assert found[0].normalized == "/api/v1/courses/{param}/modules/{param}"
732
+
733
+ def test_string_concatenation_is_unresolved_not_skipped(self) -> None:
734
+ found = extract_api_paths("api.get('/api/v1/courses/' + id)", Path("x.ts"))
735
+ assert len(found) == 1
736
+ assert found[0].normalized is None
737
+ assert found[0].unresolved_reason is not None
738
+ assert "concatenation" in found[0].unresolved_reason
739
+
740
+ def test_concatenation_before_the_literal_is_also_unresolved(self) -> None:
741
+ found = extract_api_paths("api.get(prefix + '/api/v1/courses')", Path("x.ts"))
742
+ assert len(found) == 1
743
+ assert found[0].normalized is None
744
+
745
+ def test_query_string_is_stripped_not_treated_as_part_of_the_path(self) -> None:
746
+ # Real shape, not hypothetical: tabsii-crm's PipelineBoard.tsx calls
747
+ # `/api/v1/board?brand_id=${encodeURIComponent(brandId)}` against a
748
+ # route registered as plain `/api/v1/board` — found while checking
749
+ # that repo's frontend against its BFF ahead of distributing this
750
+ # guard there. Without stripping the query string this would fail a
751
+ # real, working call site: FastAPI's own router does not consider the
752
+ # query string part of the path template it dispatches on.
753
+ found = extract_api_paths(
754
+ "api.get(`/api/v1/board?brand_id=${encodeURIComponent(brandId)}`)", Path("x.ts")
755
+ )
756
+ assert len(found) == 1
757
+ assert found[0].normalized == "/api/v1/board"
758
+ assert found[0].unresolved_reason is None
759
+
760
+ def test_query_string_on_a_plain_string_literal_is_also_stripped(self) -> None:
761
+ found = extract_api_paths("api.get('/api/v1/units?active=true')", Path("x.ts"))
762
+ assert len(found) == 1
763
+ assert found[0].normalized == "/api/v1/units"
764
+
765
+ def test_trailing_interpolation_with_no_slash_gets_an_alt_reading(self) -> None:
766
+ # Real shape, not hypothetical: tabsii-crm's AnalyticsPanel.tsx calls
767
+ # `/api/v1/analytics/pipeline/funnel${query}` where `query` is built
768
+ # separately as `` `?${params.toString()}` `` — no literal `?` in
769
+ # this template for `_strip_query_string` to find. `normalized` reads
770
+ # it as a path param glued on with no separator; `alt_normalized`
771
+ # reads it as the whole trailing query string, i.e. dropped.
772
+ found = extract_api_paths(
773
+ "api.get(`/api/v1/analytics/pipeline/funnel${query}`)", Path("x.ts")
774
+ )
775
+ assert len(found) == 1
776
+ assert found[0].normalized == "/api/v1/analytics/pipeline/funnel{param}"
777
+ assert found[0].alt_normalized == "/api/v1/analytics/pipeline/funnel"
778
+
779
+ def test_interpolation_after_a_slash_is_not_treated_as_ambiguous(self) -> None:
780
+ # `.../courses/${id}` is an ordinary path parameter -- the `/`
781
+ # immediately before `${` is exactly what distinguishes it from the
782
+ # glued-on-suffix shape above, so there is nothing to disambiguate.
783
+ found = extract_api_paths("api.get(`/api/v1/courses/${id}`)", Path("x.ts"))
784
+ assert len(found) == 1
785
+ assert found[0].alt_normalized is None
786
+
787
+ def test_a_path_named_only_in_a_line_comment_is_not_a_call_site(self) -> None:
788
+ # Real shape, not hypothetical: tabsii-crm's access-scope.ts and
789
+ # display-name.ts each have a `//` comment naming a path in a
790
+ # backtick code-span for a human reader
791
+ # (`` /api/v1/data/roles already returns the whole catalogue ``) —
792
+ # found while checking that repo ahead of ever distributing this
793
+ # guard there. Neither is a call site.
794
+ found = extract_api_paths(
795
+ "// see `/api/v1/data/roles` for the full catalogue\napi.get('/api/v1/roles')",
796
+ Path("x.ts"),
797
+ )
798
+ assert len(found) == 1
799
+ assert found[0].normalized == "/api/v1/roles"
800
+
801
+ def test_a_path_named_only_in_a_block_comment_is_not_a_call_site(self) -> None:
802
+ found = extract_api_paths(
803
+ "/** deprecated: used to call `/api/v1/legacy/x` */\napi.get('/api/v1/whoami')",
804
+ Path("x.ts"),
805
+ )
806
+ assert len(found) == 1
807
+ assert found[0].normalized == "/api/v1/whoami"
808
+
809
+ def test_a_url_containing_double_slash_is_not_mistaken_for_a_comment(self) -> None:
810
+ found = extract_api_paths(
811
+ "const base = 'https://example.com'\napi.get('/api/v1/whoami')",
812
+ Path("x.ts"),
813
+ )
814
+ assert len(found) == 1
815
+ assert found[0].normalized == "/api/v1/whoami"
816
+
817
+ def test_block_comment_stripping_preserves_line_numbers(self) -> None:
818
+ found = extract_api_paths(
819
+ "/**\n * a multi-line\n * jsdoc block\n */\napi.get('/api/v1/whoami')",
820
+ Path("x.ts"),
821
+ )
822
+ assert len(found) == 1
823
+ assert found[0].line == 5
824
+
825
+ def test_computed_prefix_is_unresolved_not_skipped(self) -> None:
826
+ found = extract_api_paths("api.get(`${apiBase}/api/v1/courses`)", Path("x.ts"))
827
+ assert len(found) == 1
828
+ assert found[0].normalized is None
829
+ assert found[0].unresolved_reason is not None
830
+ assert "computed path prefix" in found[0].unresolved_reason
831
+
832
+ def test_a_path_with_no_api_v1_prefix_at_all_is_ignored(self) -> None:
833
+ found = extract_api_paths("const url = '/health'; const other = `${x}/y`", Path("x.ts"))
834
+ assert found == []
835
+
836
+ def test_nested_template_literal_resolves_rather_than_being_given_up_on(self) -> None:
837
+ # Real shape, not hypothetical: tabsii-crm's ops-history-api.ts builds
838
+ # a conditional query string as its own nested template literal —
839
+ # found by running this guard against that repo before distributing it.
840
+ #
841
+ # A plain `` `([^`]*)` `` regex truncates at the NESTED backtick, and
842
+ # the first version of this guard therefore declared the path
843
+ # unresolvable and failed loudly. That was the right call over
844
+ # guessing, but it left the guard unable to pass in the one repo whose
845
+ # defect motivated it — so it could never have been distributed.
846
+ # `_template_literals` and `_resolve_interpolations` now track brace
847
+ # and backtick depth, so the path resolves for real. It is the ONLY
848
+ # unresolvable case either repo had.
849
+ found = extract_api_paths(
850
+ "api.get(\n"
851
+ " `/api/v1/ops/units/${encodeURIComponent(unitId)}/history${\n"
852
+ " limit !== undefined ? `?limit=${limit}` : ''\n"
853
+ " }`,\n"
854
+ ")",
855
+ Path("x.ts"),
856
+ )
857
+ assert len(found) == 1
858
+ assert found[0].unresolved_reason is None
859
+ # The ternary's own `?` must not be mistaken for the query separator:
860
+ # resolving interpolations has to happen BEFORE the query string is
861
+ # split off, or the path truncates at `limit !== undefined `.
862
+ assert found[0].normalized == "/api/v1/ops/units/{param}/history{param}"
863
+ # ...and the trailing suffix keeps its alternate reading, which is the
864
+ # one that matches the registered route.
865
+ assert found[0].alt_normalized == "/api/v1/ops/units/{param}/history"
866
+
867
+ def test_is_nested_template_literal_artifact_accepts_well_formed_paths(self) -> None:
868
+ # Sanity check on the detector itself: it must not flag ordinary,
869
+ # correctly-extracted paths as artifacts, or every real call site
870
+ # would spuriously fail alongside the truncated ones.
871
+ assert _is_nested_template_literal_artifact("/api/v1/whoami") is False
872
+ assert _is_nested_template_literal_artifact("/api/v1/courses/${id}") is False
873
+ assert (
874
+ _is_nested_template_literal_artifact("/api/v1/courses/${courseId}/modules/${moduleId}")
875
+ is False
876
+ )
877
+
878
+ def test_registered_route_paths_normalises_fastapi_params(self) -> None:
879
+ registered = registered_route_paths()
880
+ assert "/api/v1/whoami" in registered
881
+ assert "/api/v1/health" in registered
882
+ assert all("{" not in r or r.count("{param}") == r.count("{") for r in registered)
883
+
884
+
885
+ class TestPathIsRegistered:
886
+ """`path_is_registered` in isolation, against both this skeleton's real
887
+ app and a throwaway one built to carry the shape that broke a flat
888
+ string/segment comparison: a `:path` converter."""
889
+
890
+ def test_matches_a_plain_registered_route(self) -> None:
891
+ assert path_is_registered(app, "/api/v1/whoami") is True
892
+
893
+ def test_matches_a_registered_route_through_its_param(self) -> None:
894
+ # sanity: the matcher is not simply "true for everything"
895
+ assert path_is_registered(app, "/api/v1/whoami/{param}") is False
896
+
897
+ def test_rejects_a_genuinely_unregistered_path(self) -> None:
898
+ # The tabsii-crm#231 shape, reproduced directly: a path with no route
899
+ # behind it at all.
900
+ assert path_is_registered(app, "/api/v1/finance/reports") is False
901
+
902
+ def test_a_path_converter_catch_all_matches_any_number_of_segments(self) -> None:
903
+ # Real shape, not hypothetical: tabsii-crm's analytics.py registers
904
+ # `@router.get("/analytics/{report:path}")`, and AnalyticsPanel.tsx
905
+ # legitimately calls it at both one segment
906
+ # (`/api/v1/analytics/sources`) and effectively three
907
+ # (`/api/v1/analytics/pipeline/funnel`) — found while checking that
908
+ # repo's frontend against its BFF ahead of ever distributing this
909
+ # guard there. A flat string/segment comparison could only ever
910
+ # accept ONE of those shapes; `path_is_registered` accepts both,
911
+ # because Starlette's own compiled route regex does.
912
+ catchall_app = FastAPI()
913
+ catchall_router = APIRouter()
914
+
915
+ @catchall_router.get("/analytics/{report:path}")
916
+ async def _report(report: str) -> dict[str, str]:
917
+ return {"report": report}
918
+
919
+ catchall_app.include_router(catchall_router, prefix="/api/v1")
920
+
921
+ assert path_is_registered(catchall_app, "/api/v1/analytics/{param}") is True
922
+ assert path_is_registered(catchall_app, "/api/v1/analytics/pipeline/funnel") is True
923
+ assert path_is_registered(catchall_app, "/api/v1/analytics/pipeline/funnel/{param}") is True
924
+ assert path_is_registered(catchall_app, "/api/v1/other/path") is False
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.249.6",
3
+ "version": "0.249.8",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",