parse-sdk 0.1.0__py3-none-any.whl

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.
parse_sdk/checks.py ADDED
@@ -0,0 +1,601 @@
1
+ """Staged-artifact promotion gates.
2
+
3
+ `parse sync` builds the next `parse_apis` tree in a staging directory and must
4
+ prove three invariants before it overwrites the user's live tree, because a bad
5
+ promotion is hard to undo and ships straight into the user's repo:
6
+
7
+ 1. ``scan_secrets`` — no vendor key / JWT / high-entropy blob leaked into a
8
+ generated file (the agent writes free-text docstrings + examples, so a
9
+ leaked credential lands in prose, not a quoted literal).
10
+ 2. ``check_generated_client_imports`` — generated code imports ONLY the
11
+ runtime (``parse_sdk._runtime`` / bare ``from parse_sdk import ...``), never
12
+ ``parse_sdk.config`` (resolution is internal to ``_runtime.BaseAPI``) and
13
+ never a generated client from ``parse_sdk.<slug>`` (the deleted v1
14
+ dynamic-model vestige, C-1).
15
+ 3. ``check_docs_import_model`` — generated prose teaches the installed-package
16
+ model (``from parse_apis.<slug> import`` + "installed editable package"),
17
+ not the stale flat on-disk model (C-4).
18
+
19
+ Each gate returns a flat ``list[tuple[Path, str]]`` of findings; an empty list
20
+ means the tree passed. The caller (sync promotion) treats any non-empty list as
21
+ a hard block. Functions are pure: they read the tree and return findings, they
22
+ never mutate it.
23
+
24
+ The secret detectors here are the SINGLE SOURCE of the pattern families —
25
+ ``tests/_secret_patterns.py`` re-exports them (the shipped package must not
26
+ depend on its own test tree, so tests → package is the only legal direction;
27
+ the prior hand-mirrored copy drifted once and the name-only parity test
28
+ could not see regex-body drift). The families are DERIVED from
29
+ ``parse_core.security.code_sanitizer._SUSPICIOUS_PATTERNS`` and reshaped for
30
+ generated-output scanning; family coverage against that backend upstream is
31
+ pinned by ``test_no_secrets_codegen.py::test_covers_known_secret_families``.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+ import re
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import List, Optional, Tuple
40
+
41
+ # --- secret-shape detectors ------------------------------------------------
42
+ #
43
+ # SINGLE SOURCE (tests/_secret_patterns.py re-exports these). PREFIXED are
44
+ # re.search'd over the whole file text — de-anchored so a token in docstring
45
+ # PROSE still matches; ENTROPY are re.fullmatch'd against string-literal
46
+ # VALUES only so CamelCase forward-refs like 'PostCommentsCollection' on
47
+ # every generated module do not false-positive.
48
+
49
+ _PREFIXED_SIGNATURES: List[Tuple[str, "re.Pattern[str]"]] = [
50
+ ("jwt", re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")),
51
+ ("bearer_token", re.compile(r"Bearer\s+[A-Za-z0-9._\-+/=]{20,}")),
52
+ ("vendor_key", re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}")),
53
+ ("github_pat", re.compile(r"ghp_[A-Za-z0-9]{30,}")),
54
+ ("github_oauth", re.compile(r"gho_[A-Za-z0-9]{30,}")),
55
+ ("aws_access_key", re.compile(r"AKIA[A-Z0-9]{12,}")),
56
+ ("slack_token", re.compile(r"xox[bpsa]-[A-Za-z0-9\-]{10,}")),
57
+ ("credential_assignment", re.compile(r'(?:password|passwd|secret|token|api_key)\s*=\s*["\']([^"\']{6,})["\']', re.IGNORECASE)),
58
+ ("session_cookie", re.compile(r'(?:cookie|session_id|csrf|xsrf)\s*[:=]\s*["\']([^"\']{10,})["\']', re.IGNORECASE)),
59
+ ]
60
+
61
+ # Placeholder shapes a credential-assignment VALUE may take BY CONSTRUCTION:
62
+ # an angle-bracket template (`<paste-token-here>`) or an UPPERCASE_UNDERSCORE
63
+ # name (`YOUR_API_KEY`). Letters + underscores only, at least one underscore —
64
+ # NO digit class: admitting digits would exempt real key alphabets (AWS access
65
+ # keys, base32 TOTP seeds, uppercase-alnum vendor keys), and on `.md` surfaces
66
+ # this family is the only net (the entropy scan is `.py`-gated).
67
+ _PLACEHOLDER_VALUE_RE = re.compile(r"^(?:<[^<>]{1,40}>|[A-Z][A-Z_]*_[A-Z_]{1,38})$")
68
+
69
+
70
+ def is_placeholder_value(value: str) -> bool:
71
+ """Whether a ``credential_assignment`` VALUE is placeholder-shaped —
72
+ ``api_key="YOUR_API_KEY"`` teaches, it doesn't leak. Applies ONLY to that
73
+ family's value position; every other family scans unconditionally."""
74
+ return _PLACEHOLDER_VALUE_RE.fullmatch(value) is not None
75
+
76
+
77
+ _ENTROPY_PATTERNS: List[Tuple[str, "re.Pattern[str]"]] = [
78
+ ("long_base64", re.compile(r"[A-Za-z0-9+/=]{30,}")),
79
+ ("long_hex", re.compile(r"[a-fA-F0-9]{32,}")),
80
+ ("short_base64", re.compile(r"[A-Za-z0-9+/=]{20,29}")),
81
+ ]
82
+
83
+
84
+ def _iter_text_files(tree: Path, *, recursive: bool = True):
85
+ """Yield ``(path, text)`` for every readable UTF-8 file under ``tree``.
86
+
87
+ Binary / non-UTF-8 files (a stray ``.png``, a corrupt artifact) are SKIPPED
88
+ rather than crashing the gate: a promotion check must survive whatever the
89
+ codegen wrote, and a non-text file cannot carry a text-shaped secret or a
90
+ Python/Markdown import we care about. Directories and unreadable entries are
91
+ skipped for the same reason.
92
+
93
+ ``recursive=False`` scans only the entries directly in ``tree`` (not its
94
+ subdirectories) — used by the sync engine's whole-tree pre-promotion pass,
95
+ which gates only the top-level artifacts because each slug dir was already
96
+ gated individually (see ``_sync.stage_check_swap`` step 4).
97
+ """
98
+ entries = tree.rglob("*") if recursive else tree.iterdir()
99
+ for path in sorted(entries):
100
+ if not path.is_file():
101
+ continue
102
+ try:
103
+ yield path, path.read_text(encoding="utf-8")
104
+ except (UnicodeDecodeError, OSError):
105
+ continue
106
+
107
+
108
+ # Structural codegen literal sinks, split by HOW MUCH of the node is
109
+ # name-position by construction. An alphanumeric-only schema name of 20+ chars
110
+ # full-matches the entropy band (the same false-positive class that once
111
+ # blocked every module's sync on the re-exported 'PaginationLimitError', and
112
+ # again on long camelCase `_FIELD_ALIASES` values) — but only NAME positions
113
+ # may be exempted: a request dict's VALUES are agent/user data (a pin literal,
114
+ # `_params = {"api_key": "<blob>"}` in an agent-authored example) and must
115
+ # stay scanned.
116
+ _STRUCTURAL_SUBSCRIPT_TARGETS = frozenset({
117
+ "_params", "_p", # request dicts: only the SUBSCRIPT KEY is a wire name
118
+ })
119
+ _STRUCTURAL_MAP_TARGETS = frozenset({
120
+ "_CARRY_PARAMS", # error carry map: {attr ident: wire param}
121
+ "_FIELD_ALIASES", # resource alias map: {field ident: wire field}
122
+ "_KEY_FIELD", # resource key field ident (plain string)
123
+ "_ERROR_CLASSES", # {wire kind: class}
124
+ "_RESOURCE_CLASSES", # {label: class}
125
+ })
126
+ # Receiver roots of the codegen-emitted `.get(...)` / `in` shapes whose first
127
+ # arg / left operand is a response-path or field NAME (`_resp.get(...)`,
128
+ # `items[-1].get(...)`, `_last._FIELD_ALIASES.get(...)`, `'<items_path>' not
129
+ # in _resp`). Constrained to these roots so `anything.get('<blob>')` in an
130
+ # agent-authored example stays scanned; the all-name-slots invariant test
131
+ # proves the set covers every emission shape.
132
+ _STRUCTURAL_RECEIVER_ROOTS = frozenset({"_resp", "items", "_last", "_coerced"})
133
+
134
+
135
+ def _is_emitted_str_enum(node: "ast.ClassDef") -> bool:
136
+ """Whether a class statement is codegen's exact enum emission shape —
137
+ ``class X(str, enum.Enum)``. Both bases are required: the closed shape
138
+ keeps a hand-written ``class X(Enum)`` in an agent-authored example
139
+ outside the exemption."""
140
+ has_str = any(isinstance(b, ast.Name) and b.id == "str" for b in node.bases)
141
+ has_enum = any(
142
+ isinstance(b, ast.Attribute) and b.attr == "Enum"
143
+ and isinstance(b.value, ast.Name) and b.value.id == "enum"
144
+ for b in node.bases
145
+ )
146
+ return has_str and has_enum
147
+
148
+
149
+ def _expr_root_name(expr: ast.expr) -> Optional[str]:
150
+ """The root ``Name`` of an attribute/subscript/call chain (``items[-1]`` →
151
+ ``items``; ``_last._FIELD_ALIASES`` → ``_last``), or None."""
152
+ node = expr
153
+ while isinstance(node, (ast.Attribute, ast.Subscript, ast.Call)):
154
+ node = node.func if isinstance(node, ast.Call) else node.value
155
+ return node.id if isinstance(node, ast.Name) else None
156
+
157
+
158
+ def _is_structural_receiver(expr: ast.expr) -> bool:
159
+ """The two receiver shapes codegen emits as the 1st positional arg of an
160
+ items-extraction call: a bare structural receiver (``_resp``), or the
161
+ ``_walk(_resp, '<parent>')`` wrapper of a DOTTED ``items_path`` (whose own
162
+ receiver is structural). The wrapper case lets the entropy exemption reach
163
+ the LEAF arg of ``_extract_items(_walk(_resp, '<parent>'), '<leaf>')``.
164
+ A CLOSED set — codegen never nests ``_walk`` deeper, so this does NOT
165
+ recurse: a genuinely new shape stays scanned (fails closed) rather than
166
+ being silently exempt."""
167
+ if isinstance(expr, ast.Name):
168
+ return expr.id in _STRUCTURAL_RECEIVER_ROOTS
169
+ return (
170
+ isinstance(expr, ast.Call)
171
+ and isinstance(expr.func, ast.Name)
172
+ and expr.func.id == "_walk"
173
+ and len(expr.args) >= 1
174
+ and isinstance(expr.args[0], ast.Name)
175
+ and expr.args[0].id in _STRUCTURAL_RECEIVER_ROOTS
176
+ )
177
+
178
+
179
+ @dataclass
180
+ class _ModuleFacts:
181
+ """Everything the secret/import gates need from a parsed module, collected
182
+ in ONE ``ast.walk``. ``_emitted_names`` / ``_structural_literal_values`` /
183
+ ``_string_literal_values`` and the import gate were four independent walks
184
+ over the same tree (the dominant cost of a fleet sync's gate pass); this
185
+ fuses them. Each field carries exactly what the corresponding old walk
186
+ produced — moved verbatim, not "simplified" — so the gate verdicts are
187
+ byte-identical (proven on the corpus). ``literals`` is UNFILTERED and in
188
+ ``ast.walk`` order (first-entropy-hit-per-family depends on that order);
189
+ ``import_modules`` is in walk order so import findings keep their sequence.
190
+ """
191
+ names: set[str] # identifiers BOUND in the module
192
+ structural: set[str] # schema-derived codegen-emission literals
193
+ literals: List[Tuple[str, int]] # (value, lineno) per str Constant, unfiltered
194
+ import_modules: List[str] # absolute dotted import names (level==0)
195
+
196
+
197
+ def _module_facts(module: ast.AST) -> _ModuleFacts:
198
+ """Single-pass collector — see :class:`_ModuleFacts`. One ``ast.walk``
199
+ produces four fact sets used by the staged-promotion gates: bound
200
+ identifiers (``names``), schema-derived structural literals (``structural``),
201
+ every string literal in walk order (``literals``), and absolute import
202
+ module names (``import_modules``).
203
+
204
+ ``names`` and ``structural`` are exempted from the ENTROPY secret families
205
+ ONLY (prefixed signatures still search raw text), and only NAME positions are
206
+ exempt. ``names`` covers a generated module's bound identifiers — class/func
207
+ names AND re-exported runtime errors (``PaginationLimitError``'s 20-char
208
+ spelling would otherwise full-match the entropy band and block every sync).
209
+ ``structural`` covers the schema NAMES quoted in codegen's closed emission
210
+ shapes: request subscript keys, ``_STRUCTURAL_MAP_TARGETS`` / ``_DISPLAYS_*``
211
+ map entries, ``str``-enum member values, ``Literal[...]`` codes, and the
212
+ ``_request`` / ``.get`` / membership-test name slots. A pin's literal VALUE,
213
+ request-dict values, and param defaults all stay scanned. The closed
214
+ shape-set is pinned from both sides in ``tests/test_staging_checks.py``
215
+ (all-name-slots invariant + recall fixtures); ``test_no_secrets_codegen.py``
216
+ drives the same path via ``_string_literal_values`` so the gate is
217
+ single-source.
218
+
219
+ The nested ``ast.walk`` calls in the structural block walk a SUBTREE (an
220
+ assigned value / a ``Literal[...]`` slice), not the module, so they are not
221
+ extra module passes."""
222
+ names: set[str] = set()
223
+ structural: set[str] = set()
224
+ literals: List[Tuple[str, int]] = []
225
+ import_modules: List[str] = []
226
+ for node in ast.walk(module):
227
+ # 1) bound identifiers: class/func names, assign targets, imports
228
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
229
+ names.add(node.name)
230
+ elif isinstance(node, ast.Assign):
231
+ for target in node.targets:
232
+ if isinstance(target, ast.Name):
233
+ names.add(target.id)
234
+ elif isinstance(node, ast.AnnAssign):
235
+ if isinstance(node.target, ast.Name):
236
+ names.add(node.target.id)
237
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
238
+ for alias in node.names:
239
+ names.add(alias.asname or alias.name)
240
+
241
+ # 2) schema-derived structural literals (closed codegen emission shapes)
242
+ targets: List[ast.expr] = []
243
+ if isinstance(node, ast.Assign):
244
+ targets = list(node.targets)
245
+ elif isinstance(node, ast.AnnAssign):
246
+ targets = [node.target]
247
+ for target in targets:
248
+ if (
249
+ isinstance(target, ast.Subscript)
250
+ and isinstance(target.value, ast.Name)
251
+ and target.value.id in _STRUCTURAL_SUBSCRIPT_TARGETS
252
+ and isinstance(target.slice, ast.Constant)
253
+ and isinstance(target.slice.value, str)
254
+ ):
255
+ structural.add(target.slice.value)
256
+ elif (
257
+ isinstance(target, ast.Name)
258
+ and (target.id in _STRUCTURAL_MAP_TARGETS
259
+ or target.id.startswith("_DISPLAYS_"))
260
+ and getattr(node, "value", None) is not None
261
+ ):
262
+ for sub in ast.walk(node.value): # type: ignore[union-attr]
263
+ if isinstance(sub, ast.Constant) and isinstance(sub.value, str):
264
+ structural.add(sub.value)
265
+ if isinstance(node, ast.ClassDef) and _is_emitted_str_enum(node):
266
+ for stmt in node.body:
267
+ if (
268
+ isinstance(stmt, ast.Assign)
269
+ and isinstance(stmt.value, ast.Constant)
270
+ and isinstance(stmt.value.value, str)
271
+ ):
272
+ structural.add(stmt.value.value)
273
+ if (
274
+ isinstance(node, ast.Subscript)
275
+ and isinstance(node.value, ast.Name)
276
+ and node.value.id == "Literal"
277
+ ):
278
+ for sub in ast.walk(node.slice):
279
+ if isinstance(sub, ast.Constant) and isinstance(sub.value, str):
280
+ structural.add(sub.value)
281
+ # The items-extraction helper ``_extract_items(_resp, '<items_path>')``
282
+ # and the dotted-path resolver ``_walk(_resp, '<a.b>')`` — bare-Name calls
283
+ # (not attribute calls). The 2nd positional arg is a declared wire path /
284
+ # NAME by construction (the same wire name the legacy
285
+ # ``_resp.get('<items_path>')`` / ``'<items_path>' not in _resp`` shapes
286
+ # already exempt). The 1st arg is a structural receiver: ``_resp``, OR —
287
+ # for a DOTTED items_path — the ``_walk(_resp, '<parent>')`` wrapper of
288
+ # ``_extract_items(_walk(_resp, '<parent>'), '<leaf>')``, so the LEAF wire
289
+ # name is exempt too. Exempt the wire-name arg from the entropy families.
290
+ if (
291
+ isinstance(node, ast.Call)
292
+ and isinstance(node.func, ast.Name)
293
+ and node.func.id in ("_extract_items", "_walk")
294
+ and len(node.args) >= 2
295
+ and _is_structural_receiver(node.args[0])
296
+ ):
297
+ second = node.args[1]
298
+ if isinstance(second, ast.Constant) and isinstance(second.value, str):
299
+ structural.add(second.value)
300
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
301
+ if node.func.attr == "_request":
302
+ for arg in node.args[:2]:
303
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
304
+ structural.add(arg.value)
305
+ elif (
306
+ node.func.attr == "get"
307
+ and node.args
308
+ and _expr_root_name(node.func.value) in _STRUCTURAL_RECEIVER_ROOTS
309
+ ):
310
+ first = node.args[0]
311
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
312
+ structural.add(first.value)
313
+ if isinstance(node, ast.Compare):
314
+ if (
315
+ isinstance(node.left, ast.Constant)
316
+ and isinstance(node.left.value, str)
317
+ and any(isinstance(op, (ast.In, ast.NotIn)) for op in node.ops)
318
+ and len(node.comparators) == 1
319
+ and _expr_root_name(node.comparators[0]) in _STRUCTURAL_RECEIVER_ROOTS
320
+ ):
321
+ structural.add(node.left.value)
322
+
323
+ # 3) every string literal, unfiltered + in walk order (was the final
324
+ # loop in _string_literal_values; the caller filters by excluded set)
325
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
326
+ literals.append((node.value, node.lineno))
327
+
328
+ # 4) absolute import module names (was check_generated_client_imports'
329
+ # walk); relative imports (level>0) and module-less forms are skipped,
330
+ # exactly as the gate does, so the caller's allow-check is unchanged.
331
+ if isinstance(node, ast.Import):
332
+ for alias in node.names:
333
+ import_modules.append(alias.name)
334
+ elif isinstance(node, ast.ImportFrom):
335
+ if not node.level and node.module is not None:
336
+ import_modules.append(node.module)
337
+
338
+ return _ModuleFacts(names, structural, literals, import_modules)
339
+
340
+
341
+ def _string_literal_values(
342
+ text: str, *, structural_exempt: bool = True
343
+ ) -> List[Tuple[str, int]]:
344
+ """Extract entropy-eligible ``(value, lineno)`` string-literal pairs from
345
+ Python source.
346
+
347
+ Entropy families would false-positive on long identifiers (class names,
348
+ dotted module paths) if matched over raw text, so we restrict them to actual
349
+ string-literal values via ``ast`` AND drop any value that equals an
350
+ identifier defined in this module (a forward-ref annotation / ``__all__``
351
+ entry — the documented CamelCase false-positive class) or — when
352
+ ``structural_exempt`` is on — a schema-derived structural literal from the
353
+ known codegen sinks (the ``structural`` set computed by :func:`_module_facts`).
354
+
355
+ ``structural_exempt`` is the provenance gate: the structural shapes are
356
+ things CODEGEN emits, so only the file class codegen emits (the generated
357
+ client module) gets their exemption — an agent-authored ``example.py``
358
+ re-creating one of those shapes (a ``Literal['<blob>']`` annotation, a
359
+ ``_DISPLAYS_*`` map) stays fully scanned. Exclusion is by value equality
360
+ over the file: inside an emitted module a literal equal to an emitted
361
+ name/code is that name by construction (e.g. a closed param's default is
362
+ one of its codes).
363
+
364
+ Non-parseable text yields no literals — the prefixed group still scans the
365
+ raw text, so prefixed secrets in a syntax-broken file are not missed. The
366
+ lineno rides along so a finding can point at the offending line.
367
+
368
+ Parses once and reads :func:`_module_facts` (one walk) for the names,
369
+ structural literals, and unfiltered literal list; the filter (drop any value
370
+ equal to a bound name, plus structural literals when ``structural_exempt``)
371
+ is applied here. ``scan_tree`` does not route through this function — it
372
+ holds the parsed module already and reads ``_module_facts`` directly — so a
373
+ staged ``.py`` file is parsed exactly once per scan. This standalone form
374
+ exists for direct test consumers (``tests/test_no_secrets_codegen.py``).
375
+ """
376
+ try:
377
+ module = ast.parse(text)
378
+ except (SyntaxError, ValueError):
379
+ return []
380
+ facts = _module_facts(module)
381
+ excluded = facts.names | (facts.structural if structural_exempt else set())
382
+ return [(v, ln) for v, ln in facts.literals if v not in excluded]
383
+
384
+
385
+ def _mask(value: str) -> str:
386
+ """A finding's value hint: first 4 chars + length — never more of the
387
+ value (the gate's whole point is that it might be a real secret)."""
388
+ return f"'{value[:4]}…', {len(value)} chars"
389
+
390
+
391
+ @dataclass
392
+ class TreeFindings:
393
+ """All gate findings from ONE pass over a staged tree — read each file once,
394
+ parse each ``.py`` once, run every check off the cached text/AST. The three
395
+ public gates are thin projections of this; ``_sync``'s promotion gate reads
396
+ all four lists. ``syntax_errors`` is engine-internal (the public
397
+ ``scan_secrets`` never compiled) — only the sync gate consumes it, replacing
398
+ its old separate ``rglob('__init__.py')`` compile loop."""
399
+ secrets: List[Tuple[Path, str, int, str]]
400
+ forbidden_imports: List[Tuple[Path, str]]
401
+ stale_docs: List[Tuple[Path, str]]
402
+ syntax_errors: List[Tuple[Path, str]]
403
+
404
+
405
+ def scan_tree(tree: Path, *, recursive: bool = True) -> TreeFindings:
406
+ """Single-pass promotion scanner: read each file once and run every gate.
407
+
408
+ Per file: the prefixed secret signatures over raw text; for ``.py`` files
409
+ one ``ast.parse`` feeding :func:`_module_facts` (entropy families + the
410
+ generated-client import allow-check); a ``compile`` syntax gate on each
411
+ ``__init__.py``; the stale-doc needle scan on doc files. Findings are
412
+ appended in ``_iter_text_files`` (sorted-path) order, so each list matches
413
+ exactly what the old per-gate functions produced — verdicts are unchanged
414
+ (pinned by the equivalence test in ``tests/test_staging_checks.py``). This
415
+ replaces three independent ``_iter_text_files`` walks + up to three parses
416
+ per ``.py`` with one read and one parse per file."""
417
+ secrets: List[Tuple[Path, str, int, str]] = []
418
+ forbidden_imports: List[Tuple[Path, str]] = []
419
+ stale_docs: List[Tuple[Path, str]] = []
420
+ syntax_errors: List[Tuple[Path, str]] = []
421
+ for path, text in _iter_text_files(tree, recursive=recursive):
422
+ seen: set[str] = set()
423
+ # Prefixed signatures — raw text (catches tokens in docstring prose).
424
+ for family, pattern in _PREFIXED_SIGNATURES:
425
+ if family in seen:
426
+ continue
427
+ if family == "credential_assignment":
428
+ # Placeholder carve-out: flag only when some matched VALUE is
429
+ # not placeholder-shaped (group 1 is the quoted value).
430
+ m = next((m for m in pattern.finditer(text)
431
+ if not is_placeholder_value(m.group(1))), None)
432
+ else:
433
+ m = pattern.search(text)
434
+ if m is not None:
435
+ lineno = text.count("\n", 0, m.start()) + 1
436
+ # Mask the captured VALUE when the family has one
437
+ # (credential_assignment/session_cookie); else the whole match.
438
+ masked_src = m.group(1) if m.groups() else m.group(0)
439
+ secrets.append((path, family, lineno, _mask(masked_src)))
440
+ seen.add(family)
441
+ if path.suffix == ".py":
442
+ try:
443
+ module: Optional[ast.AST] = ast.parse(text)
444
+ except (SyntaxError, ValueError):
445
+ module = None
446
+ if module is not None:
447
+ facts = _module_facts(module)
448
+ # Entropy families — string-literal values only. The structural
449
+ # (emission-shape) exemption applies ONLY to the generated
450
+ # client module (codegen emits exactly ``__init__.py``); an
451
+ # agent-authored example re-creating an emission shape stays
452
+ # scanned.
453
+ excluded = facts.names | (
454
+ facts.structural if path.name == "__init__.py" else set())
455
+ literals = [(v, ln) for v, ln in facts.literals if v not in excluded]
456
+ for family, pattern in _ENTROPY_PATTERNS:
457
+ if family in seen:
458
+ continue
459
+ hit = next(((v, ln) for v, ln in literals
460
+ if pattern.fullmatch(v)), None)
461
+ if hit is not None:
462
+ value, lineno = hit
463
+ secrets.append((path, family, lineno, _mask(value)))
464
+ seen.add(family)
465
+ # Generated-client import allow-check (every .py, incl.
466
+ # example.py — a forbidden import there must not slip through).
467
+ for mod in facts.import_modules:
468
+ offending = _is_offending_parse_sdk_module(mod)
469
+ if offending is not None:
470
+ forbidden_imports.append((path, offending))
471
+ # Syntax gate: compile() catches post-AST errors ast.parse accepts
472
+ # (return/yield outside a function, duplicate args); run on every
473
+ # __init__.py even when ast.parse above failed.
474
+ if path.name == "__init__.py":
475
+ try:
476
+ compile(module if module is not None else text, str(path), "exec")
477
+ except SyntaxError as e:
478
+ syntax_errors.append((path, str(e)))
479
+ if _is_doc_file(path):
480
+ for needle in _STALE_DOC_NEEDLES:
481
+ if needle in text:
482
+ stale_docs.append((path, needle))
483
+ return TreeFindings(secrets, forbidden_imports, stale_docs, syntax_errors)
484
+
485
+
486
+ def scan_secrets(tree: Path, *, recursive: bool = True) -> List[Tuple[Path, str, int, str]]:
487
+ """Flag secret-shaped content in every text file under ``tree``.
488
+
489
+ Returns ``(path, family, lineno, masked)`` per finding — ``masked`` shows
490
+ the first 4 chars and the length, never more of the value, so a finding is
491
+ actionable (which literal, on which line) without re-leaking what the gate
492
+ just caught. Prefixed signatures are searched over the whole file text
493
+ (catching tokens that land in docstring prose, where they are not their
494
+ own quoted literal). Entropy families are full-matched against Python
495
+ string-literal values only, so generated CamelCase forward-refs
496
+ (``PostCommentsCollection``) never trip them.
497
+
498
+ A file with multiple distinct families flagged yields one finding per
499
+ family; the same family is reported once per file, pointing at its first
500
+ occurrence (the caller blocks on any finding, so per-occurrence noise adds
501
+ nothing). A thin projection of :func:`scan_tree`."""
502
+ return scan_tree(tree, recursive=recursive).secrets
503
+
504
+
505
+ # --- generated-client import gate (C-1) ------------------------------------
506
+
507
+ # A generated module may import the runtime surface and re-export from the
508
+ # package root, nothing else under ``parse_sdk``. The allow-list is exactly
509
+ # ``parse_sdk._runtime``: credential resolution is an INTERNAL detail
510
+ # of ``_runtime.BaseAPI`` (which delegates to ``parse_sdk.config``), so generated
511
+ # code never imports ``parse_sdk.config`` directly and the gate does not whitelist
512
+ # it — keeping ``config`` (which exposes ``save_credentials``) off generated
513
+ # code's import surface. A bare ``from parse_sdk import X`` pulls package-level
514
+ # names (``__version__``, errors) and is allowed. Anything else —
515
+ # ``parse_sdk.config``, ``parse_sdk.<slug>``, ``parse_sdk.codegen_v2``, etc. — is
516
+ # flagged.
517
+ _ALLOWED_PARSE_SDK_SUBMODULES = frozenset({"_runtime"})
518
+
519
+
520
+ def _is_offending_parse_sdk_module(module: str) -> str | None:
521
+ """Return the offending dotted module if ``module`` is a banned parse_sdk import.
522
+
523
+ ``module`` is a fully-qualified module name (``parse_sdk``,
524
+ ``parse_sdk._runtime``, ``parse_sdk.reddit``, …). Bare ``parse_sdk`` and the
525
+ runtime submodules are allowed; any other ``parse_sdk.<x>`` is offending and
526
+ returned verbatim so the caller can surface exactly what to fix.
527
+
528
+ A slug literally named like a runtime module (e.g. a ``parse_sdk._runtime``
529
+ import) is allowed by membership — the allow-set is checked on the FIRST
530
+ path segment after ``parse_sdk``, so deeper paths like
531
+ ``parse_sdk._runtime.foo`` are still allowed (they resolve into the runtime).
532
+ """
533
+ if module == "parse_sdk":
534
+ return None
535
+ if not module.startswith("parse_sdk."):
536
+ return None
537
+ first_segment = module[len("parse_sdk."):].split(".", 1)[0]
538
+ if first_segment in _ALLOWED_PARSE_SDK_SUBMODULES:
539
+ return None
540
+ return module
541
+
542
+
543
+ def check_generated_client_imports(tree: Path, *, recursive: bool = True) -> List[Tuple[Path, str]]:
544
+ """Flag generated ``.py`` files that import a non-runtime ``parse_sdk`` module.
545
+
546
+ Uses ``ast`` (not regex) so aliased (``import parse_sdk.reddit as r``),
547
+ parenthesized, multiline, and whitespace-padded imports are all normalized
548
+ to their dotted module name before the allow-check. Relative imports
549
+ (``from . import x``) are ignored — they are package-internal and cannot
550
+ name ``parse_sdk.<slug>``. Returns ``(path, offending_module)``.
551
+
552
+ A file that fails to parse is skipped (a broken generated module is a codegen
553
+ bug surfaced elsewhere; this gate concerns only well-formed import shapes).
554
+
555
+ A thin projection of :func:`scan_tree` (the import allow-check runs off the
556
+ same single per-file parse as the secret scan)."""
557
+ return scan_tree(tree, recursive=recursive).forbidden_imports
558
+
559
+
560
+ # --- docs import-model gate (C-4) ------------------------------------------
561
+
562
+ # Stale import-model prose to flag in generated docs (.md / .py). Each needle is
563
+ # a removed/old teaching: the deleted dynamic-model client import
564
+ # (``parse_sdk.<slug>``, ``parse.api(``) or the flat on-disk model
565
+ # (run-from-root + "no import magic" + project-local-package prose). The
566
+ # current, correct teaching is the installed editable package, which contains
567
+ # none of these needles, so it passes cleanly.
568
+ _STALE_DOC_NEEDLES: Tuple[str, ...] = (
569
+ "parse_sdk.", # generated-client import from parse_sdk.<slug>
570
+ "parse.api(", # deleted dynamic-model call spelling
571
+ "no import magic", # flat on-disk model prose
572
+ "from the project root", # "Run scripts from the project root" prose
573
+ "project-local package", # flat on-disk path prose
574
+ )
575
+
576
+
577
+ def _is_doc_file(path: Path) -> bool:
578
+ """A human/agent-facing DOC: any ``.md`` (README + AGENTS/CLAUDE index) or the
579
+ generated ``example.py``. The generated client module (``__init__.py``)
580
+ is NOT a doc — it legitimately imports ``parse_sdk._runtime`` (substring
581
+ ``parse_sdk.``), so scanning it here would false-positive on every module;
582
+ its imports are gated by :func:`check_generated_client_imports` instead.
583
+ """
584
+ return path.suffix == ".md" or path.name == "example.py"
585
+
586
+
587
+ def check_docs_import_model(tree: Path, *, recursive: bool = True) -> List[Tuple[Path, str]]:
588
+ """Flag generated docs that teach a removed/stale SDK import model.
589
+
590
+ Scans doc files (``.md`` + ``example.py``) for any stale-needle substring and
591
+ returns ``(path, needle)`` per hit. The installed-package model
592
+ (``from parse_apis.<slug> import`` + "installed editable package") contains
593
+ none of the needles, so a correct doc returns no findings.
594
+
595
+ Substring matching is intentional — prose can wrap or reflow, but the stale
596
+ teachings ("no import magic", "from the project root") are stable phrases,
597
+ and ``parse_sdk.`` / ``parse.api(`` are exact code spellings that must never
598
+ reappear in shipped docs.
599
+
600
+ A thin projection of :func:`scan_tree`."""
601
+ return scan_tree(tree, recursive=recursive).stale_docs