cobraphp 0.1.0.dev1__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.
@@ -0,0 +1,402 @@
1
+ """Intra-procedural def-use chain and inter-procedural edge construction.
2
+
3
+ Phase 2: AST-based def-use chains create cross-statement edges within functions.
4
+ Phase 3: CallGraph creates cross-function edges (argument->parameter, return->callsite).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass
11
+ from typing import cast
12
+
13
+ from php_parser_py import AST, Node
14
+
15
+ from ._ast_types import load_php_ast_types
16
+ from ._call_graph import CallGraph
17
+ from ._taint_graph import DataFlowGraph
18
+ from ._taint_rules import CALLABLE_TYPES
19
+
20
+ # Accessor nodes whose taint is inherited from the base Expr_Variable.
21
+ _ACCESSOR_NODE_TYPES = frozenset({"Expr_ArrayDimFetch", "Expr_PropertyFetch"})
22
+
23
+ # Compound assignments (`+=`, `.=`, ...): the target keeps its old value and
24
+ # folds in the RHS, so the target variable node itself becomes the definition.
25
+ _COMPOUND_ASSIGN_TYPES = load_php_ast_types()[2] - {"Expr_Assign"}
26
+
27
+ # Callables with their own variable scope. An arrow function is absent: PHP
28
+ # gives it read access to the enclosing scope's variables.
29
+ _NESTED_SCOPE_TYPES = frozenset({"Stmt_Function", "Stmt_ClassMethod", "Expr_Closure"})
30
+
31
+
32
+ def build_intraproc_edges(ast: AST, dataflow_graph: DataFlowGraph) -> None:
33
+ """Build variable def-use chain edges for all functions and file-level code."""
34
+ for func_node in ast.nodes(lambda n: n.node_type in CALLABLE_TYPES):
35
+ param_defs = _extract_param_defs(ast, func_node)
36
+ body_stmts = _get_body_statements(ast, func_node)
37
+ _build_defuse_for_stmts(ast, body_stmts, dataflow_graph, initial_defs=param_defs)
38
+
39
+ for file_node in ast.file_nodes():
40
+ top_stmts = [c for c in ast.succ(file_node) if c.node_type.startswith("Stmt_")]
41
+ _build_defuse_for_stmts(ast, top_stmts, dataflow_graph)
42
+
43
+ _build_accessor_chain_edges(ast, dataflow_graph)
44
+
45
+
46
+ def build_interproc_edges(
47
+ ast: AST,
48
+ call_graph: CallGraph,
49
+ dataflow_graph: DataFlowGraph,
50
+ ) -> None:
51
+ """Build argument->parameter and return->callsite edges via CallGraph."""
52
+ for cg_node_id in call_graph.storage.get_nodes():
53
+ node_type = call_graph.storage.get_node_prop(cg_node_id, "node_type")
54
+ if node_type != "call_site":
55
+ continue
56
+
57
+ site_node = call_graph.get_site_node(cg_node_id)
58
+ callees = call_graph.get_callees(cg_node_id)
59
+
60
+ if not callees:
61
+ _build_overapprox_edges(ast, site_node.argument_ids, cg_node_id, dataflow_graph)
62
+ continue
63
+
64
+ for callee in callees:
65
+ _build_arg_to_param_edges(
66
+ ast,
67
+ site_node.argument_ids,
68
+ callee.parameter_ids,
69
+ dataflow_graph,
70
+ )
71
+ _build_return_to_callsite_edges(
72
+ ast,
73
+ callee.node_id,
74
+ cg_node_id,
75
+ dataflow_graph,
76
+ )
77
+
78
+
79
+ def _extract_param_defs(ast: AST, func_node: Node) -> dict[str, str]:
80
+ """Extract parameter variable definitions: var_name -> param_var_node_id."""
81
+ param_defs: dict[str, str] = {}
82
+ for child in ast.succ(func_node):
83
+ if child.node_type != "Param":
84
+ continue
85
+ for param_child in ast.succ(child):
86
+ if param_child.node_type != "Expr_Variable":
87
+ continue
88
+ var_name = param_child.all_properties.get("name", "")
89
+ if var_name:
90
+ param_defs[cast(str, var_name)] = param_child.id
91
+ return param_defs
92
+
93
+
94
+ def _get_body_statements(ast: AST, func_node: Node) -> list[Node]:
95
+ """Extract ordered statement children from a callable node."""
96
+ return [child for child in ast.succ(func_node) if child.node_type.startswith("Stmt_")]
97
+
98
+
99
+ def _build_defuse_for_stmts(
100
+ ast: AST,
101
+ stmts: list[Node],
102
+ dataflow_graph: DataFlowGraph,
103
+ initial_defs: dict[str, str] | None = None,
104
+ ) -> None:
105
+ """Build reaching-definition def-use chains across statements."""
106
+ var_defs: dict[str, str] = dict(initial_defs) if initial_defs else {}
107
+
108
+ for stmt in stmts:
109
+ _connect_uses_to_defs(ast, stmt, var_defs, dataflow_graph)
110
+ for var_def in _find_variable_defs(ast, stmt):
111
+ _link_compound_rhs(var_def, dataflow_graph)
112
+ var_defs[var_def.name] = var_def.node_id
113
+
114
+
115
+ def _connect_uses_to_defs(
116
+ ast: AST,
117
+ stmt: Node,
118
+ var_defs: dict[str, str],
119
+ dataflow_graph: DataFlowGraph,
120
+ ) -> None:
121
+ """Connect variable uses in a statement to their definitions."""
122
+ uses = _find_variable_uses(ast, stmt)
123
+ for use_node_id, var_name in uses:
124
+ if var_name not in var_defs:
125
+ continue
126
+ def_node_id = var_defs[var_name]
127
+ if def_node_id == use_node_id:
128
+ continue
129
+ _ensure_node(dataflow_graph, def_node_id)
130
+ _ensure_node(dataflow_graph, use_node_id)
131
+ dataflow_graph.add_structural_edge(def_node_id, use_node_id)
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class _VarDef:
136
+ """One variable definition inside a statement.
137
+
138
+ ``node_id`` carries the assigned value. ``rhs_id`` is set for a compound
139
+ assignment, whose RHS feeds the target variable node.
140
+ """
141
+
142
+ name: str
143
+ node_id: str
144
+ rhs_id: str | None = None
145
+
146
+
147
+ def _link_compound_rhs(var_def: _VarDef, dataflow_graph: DataFlowGraph) -> None:
148
+ # A compound assignment's RHS flows into the target it updates.
149
+ if var_def.rhs_id is None:
150
+ return
151
+ _ensure_node(dataflow_graph, var_def.rhs_id)
152
+ _ensure_node(dataflow_graph, var_def.node_id)
153
+ dataflow_graph.add_structural_edge(var_def.rhs_id, var_def.node_id)
154
+
155
+
156
+ def _find_variable_defs(ast: AST, stmt: Node) -> list[_VarDef]:
157
+ """Find variable definitions in a statement, outside nested scopes."""
158
+ # Post-order over an explicit stack of child iterators: children before
159
+ # their parent, which is evaluation order. A nested scope is skipped with
160
+ # its subtree. No recursion, so a deeply nested expression fits.
161
+ result: list[_VarDef] = []
162
+ if stmt.node_type in _NESTED_SCOPE_TYPES:
163
+ return result
164
+ pending: list[tuple[Node, Iterator[Node]]] = [(stmt, iter(ast.succ(stmt)))]
165
+ while pending:
166
+ node, children = pending[-1]
167
+ child = next(children, None)
168
+ if child is None:
169
+ pending.pop()
170
+ var_def = _assignment_def(ast, node)
171
+ if var_def is not None:
172
+ result.append(var_def)
173
+ continue
174
+ if child.node_type not in _NESTED_SCOPE_TYPES:
175
+ pending.append((child, iter(ast.succ(child))))
176
+ return result
177
+
178
+
179
+ def _assignment_def(ast: AST, node: Node) -> _VarDef | None:
180
+ # The definition an assignment to a plain variable makes, else None.
181
+ target = _assignment_target(ast, node)
182
+ if target is None:
183
+ return None
184
+ var_name, lhs, rhs = target
185
+ if node.node_type == "Expr_Assign":
186
+ return _VarDef(var_name, _assigned_value_id(ast, rhs))
187
+ return _VarDef(var_name, lhs.id, rhs_id=rhs.id)
188
+
189
+
190
+ def _assignment_target(ast: AST, node: Node) -> tuple[str, Node, Node] | None:
191
+ # (variable name, LHS, RHS) of an assignment to a plain variable, else None.
192
+ if node.node_type != "Expr_Assign" and node.node_type not in _COMPOUND_ASSIGN_TYPES:
193
+ return None
194
+ children = list(ast.succ(node))
195
+ if len(children) < 2 or children[0].node_type != "Expr_Variable":
196
+ return None
197
+ lhs, rhs = children[0], children[1]
198
+ var_name = cast(str, lhs.all_properties.get("name", ""))
199
+ if not var_name:
200
+ return None
201
+ return var_name, lhs, rhs
202
+
203
+
204
+ def _assigned_value_id(ast: AST, expr: Node) -> str:
205
+ # The node holding an expression's value: a nested assignment yields the
206
+ # definition node it makes, so `$a = $b = x` gives both the same value.
207
+ # A plain assignment passes its RHS's value on; a loop follows the chain.
208
+ while True:
209
+ target = _assignment_target(ast, expr)
210
+ if target is None:
211
+ return expr.id
212
+ _, lhs, rhs = target
213
+ if expr.node_type != "Expr_Assign":
214
+ return lhs.id
215
+ expr = rhs
216
+
217
+
218
+ def _find_variable_uses(ast: AST, stmt: Node) -> list[tuple[str, str]]:
219
+ """Find variable uses excluding assignment LHS: list of (node_id, var_name)."""
220
+ # Pre-order over an explicit stack: children are pushed reversed so they
221
+ # pop in source order. No recursion, so a deeply nested expression fits.
222
+ result: list[tuple[str, str]] = []
223
+ lhs_ids: set[str] = set()
224
+ pending: list[Node] = [stmt]
225
+ while pending:
226
+ node = pending.pop()
227
+ pending.extend(reversed(_use_scan_children(ast, node, result, lhs_ids)))
228
+ return result
229
+
230
+
231
+ def _use_scan_children(
232
+ ast: AST,
233
+ node: Node,
234
+ result: list[tuple[str, str]],
235
+ lhs_ids: set[str],
236
+ ) -> list[Node]:
237
+ """Record node if it is a variable use; return the children to scan next, in order."""
238
+ if node.node_type == "Expr_Assign":
239
+ # Only the RHS is read; the LHS is a definition.
240
+ children = list(ast.succ(node))
241
+ if len(children) < 2:
242
+ return []
243
+ lhs_ids.add(children[0].id)
244
+ return [children[1]]
245
+ if node.node_type in _NESTED_SCOPE_TYPES:
246
+ # Only a closure's `use (...)` list reads the enclosing scope.
247
+ return _closure_use_children(ast, node)
248
+
249
+ if node.node_type == "Expr_Variable" and node.id not in lhs_ids:
250
+ var_name = node.all_properties.get("name", "")
251
+ # Skip superglobals — they are sources, not def-use targets
252
+ if var_name and not str(var_name).startswith("_"):
253
+ result.append((node.id, cast(str, var_name)))
254
+
255
+ return list(ast.succ(node))
256
+
257
+
258
+ def _closure_use_children(ast: AST, node: Node) -> list[Node]:
259
+ # A closure's captured-variable list; empty for any other callable.
260
+ return [child for child in ast.succ(node) if child.node_type == "ClosureUse"]
261
+
262
+
263
+ def _build_accessor_chain_edges(ast: AST, dataflow_graph: DataFlowGraph) -> None:
264
+ """Create edges from Expr_Variable to its enclosing accessor chain.
265
+
266
+ A tainted object/array variable must propagate taint to its element and
267
+ property reads. Semgrep only flags these intraprocedurally; when the
268
+ variable is tainted via a call-graph return edge, the enclosing
269
+ ArrayDimFetch/PropertyFetch nodes have no incoming edge and stay untainted.
270
+ """
271
+ for node in ast.nodes():
272
+ if node.node_type != "Expr_Variable":
273
+ continue
274
+ if not dataflow_graph.storage.contains_node(node.id):
275
+ continue
276
+ _walk_accessor_parents(ast, node, dataflow_graph)
277
+
278
+
279
+ def _walk_accessor_parents(
280
+ ast: AST,
281
+ node: Node,
282
+ dataflow_graph: DataFlowGraph,
283
+ ) -> None:
284
+ """Walk up from variable through accessor parents, adding edges."""
285
+ current = node
286
+ while True:
287
+ parents = list(ast.prev(current))
288
+ parent = next(
289
+ (p for p in parents if p.node_type in _ACCESSOR_NODE_TYPES),
290
+ None,
291
+ )
292
+ if parent is None:
293
+ break
294
+ _ensure_node(dataflow_graph, parent.id)
295
+ dataflow_graph.add_structural_edge(current.id, parent.id)
296
+ current = parent
297
+
298
+
299
+ def _build_arg_to_param_edges(
300
+ ast: AST,
301
+ argument_ids: list[str],
302
+ parameter_ids: list[str],
303
+ dataflow_graph: DataFlowGraph,
304
+ ) -> None:
305
+ """Create edges from call site arguments to callee parameters by position."""
306
+ min_len = min(len(argument_ids), len(parameter_ids))
307
+ for i in range(min_len):
308
+ arg_expr_id = _get_arg_expression_id(ast, argument_ids[i])
309
+ if not arg_expr_id:
310
+ continue
311
+ param_var_id = _get_param_variable_id(ast, parameter_ids[i])
312
+ if not param_var_id:
313
+ continue
314
+ _ensure_node(dataflow_graph, arg_expr_id)
315
+ _ensure_node(dataflow_graph, param_var_id)
316
+ dataflow_graph.add_structural_edge(arg_expr_id, param_var_id)
317
+
318
+
319
+ def _build_overapprox_edges(
320
+ ast: AST,
321
+ argument_ids: list[str],
322
+ callsite_id: str,
323
+ dataflow_graph: DataFlowGraph,
324
+ ) -> None:
325
+ """Over-approximate taint through an unresolved call.
326
+
327
+ For a call site with no resolved callee (library, builtin, or dynamic
328
+ dispatch), connect each argument expression to the call-site node so a
329
+ tainted argument taints the call's result. Semgrep OSS provides no
330
+ interprocedural taint to fall back on.
331
+ """
332
+ for arg_node_id in argument_ids:
333
+ arg_expr_id = _get_arg_expression_id(ast, arg_node_id)
334
+ if not arg_expr_id:
335
+ continue
336
+ _ensure_node(dataflow_graph, arg_expr_id)
337
+ _ensure_node(dataflow_graph, callsite_id)
338
+ dataflow_graph.add_structural_edge(arg_expr_id, callsite_id)
339
+
340
+
341
+ def _build_return_to_callsite_edges(
342
+ ast: AST,
343
+ callee_func_id: str,
344
+ callsite_id: str,
345
+ dataflow_graph: DataFlowGraph,
346
+ ) -> None:
347
+ """Create edges from callee return expressions to call site node."""
348
+ func_node = ast.node(callee_func_id)
349
+ if func_node is None:
350
+ return
351
+
352
+ for return_stmt in _own_return_statements(ast, func_node):
353
+ return_children = list(ast.succ(return_stmt))
354
+ if not return_children:
355
+ continue
356
+ return_expr = return_children[0]
357
+ _ensure_node(dataflow_graph, return_expr.id)
358
+ _ensure_node(dataflow_graph, callsite_id)
359
+ dataflow_graph.add_structural_edge(return_expr.id, callsite_id)
360
+
361
+
362
+ def _own_return_statements(ast: AST, func_node: Node) -> list[Node]:
363
+ # Stmt_Return nodes at any depth in a callable's body, excluding those of
364
+ # callables nested inside it.
365
+ found: list[Node] = []
366
+ pending = list(ast.succ(func_node))
367
+ while pending:
368
+ node = pending.pop()
369
+ if node.node_type in CALLABLE_TYPES:
370
+ continue
371
+ if node.node_type == "Stmt_Return":
372
+ found.append(node)
373
+ pending.extend(ast.succ(node))
374
+ return found
375
+
376
+
377
+ def _get_arg_expression_id(ast: AST, arg_node_id: str) -> str | None:
378
+ """Get the expression node ID inside an Arg wrapper node."""
379
+ arg_node = ast.node(arg_node_id)
380
+ if arg_node is None:
381
+ return None
382
+ children = list(ast.succ(arg_node))
383
+ if not children:
384
+ return None
385
+ return cast(str, children[0].id)
386
+
387
+
388
+ def _get_param_variable_id(ast: AST, param_node_id: str) -> str | None:
389
+ """Get the Expr_Variable node ID inside a Param wrapper node."""
390
+ param_node = ast.node(param_node_id)
391
+ if param_node is None:
392
+ return None
393
+ for child in ast.succ(param_node):
394
+ if child.node_type == "Expr_Variable":
395
+ return cast(str, child.id)
396
+ return None
397
+
398
+
399
+ def _ensure_node(dataflow_graph: DataFlowGraph, node_id: str) -> None:
400
+ """Ensure node exists in graph."""
401
+ if not dataflow_graph.storage.contains_node(node_id):
402
+ dataflow_graph.add_dataflow_node(node_id)
@@ -0,0 +1,17 @@
1
+ """Errors of the static analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import CobraPhpError
6
+
7
+
8
+ class StaticAnalysisError(CobraPhpError):
9
+ """Semgrep failed, its output was malformed, or the AST is malformed."""
10
+
11
+
12
+ class AnalysisTimeoutError(StaticAnalysisError):
13
+ """A Semgrep run exceeded the time limit; carries `timeout_seconds`."""
14
+
15
+ def __init__(self, timeout_seconds: int):
16
+ super().__init__(f"Analysis timed out after {timeout_seconds} seconds")
17
+ self.timeout_seconds = timeout_seconds
@@ -0,0 +1,172 @@
1
+ """Shared file filter for parse_project calls.
2
+
3
+ Excludes third-party and generated code from PHP AST parsing via three layers:
4
+ convention directories (vendor/, node_modules/), named bundle files
5
+ (yiilite.php), and framework code detected by an identity-marker file — either
6
+ the whole marker subtree or only named subpaths under it (for monorepo
7
+ frameworks that also hold application code).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import tomllib
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from importlib import resources
17
+ from pathlib import Path
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _EXCLUDED_DIRS = frozenset(
22
+ {
23
+ "vendor",
24
+ "vendors",
25
+ "node_modules",
26
+ "generated",
27
+ ".git",
28
+ "patches",
29
+ ".porifera_backup",
30
+ }
31
+ )
32
+
33
+ # yiilite.php is the whole framework concatenated into one file: not business
34
+ # logic, it duplicates class definitions from the separate framework files, and
35
+ # its size makes format-preserving instrumentation exhaust the printer's memory.
36
+ _EXCLUDED_FILES = frozenset({"yiilite.php"})
37
+
38
+ _REGISTRY_PATH = resources.files("cobraphp.analysis") / "frameworks.toml"
39
+ # Bytes of a marker file head scanned for its signature substring; a bootstrap
40
+ # class declaration always appears near the top.
41
+ _MARKER_SCAN_BYTES = 8192
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class _FrameworkSignature:
46
+ """Identity marker locating one vendored framework's subtree root.
47
+
48
+ Attributes:
49
+ name: Framework identifier, logged on exclusion.
50
+ marker: POSIX relative-path suffix of the identity bootstrap file.
51
+ contains: Substring required in the marker file head.
52
+ exclude_subpaths: Subpaths under the detected root to exclude. Empty
53
+ excludes the whole subtree; non-empty keeps the rest as business
54
+ logic (e.g. a monorepo framework that also holds application code).
55
+ """
56
+
57
+ name: str
58
+ marker: str
59
+ contains: str
60
+ exclude_subpaths: tuple[str, ...] = ()
61
+
62
+
63
+ def _load_signatures() -> tuple[_FrameworkSignature, ...]:
64
+ """Load framework identity markers from the TOML registry."""
65
+ raw = tomllib.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
66
+ return tuple(
67
+ _FrameworkSignature(
68
+ name=e["name"],
69
+ marker=e["marker"],
70
+ contains=e["contains"],
71
+ exclude_subpaths=tuple(e.get("exclude_subpaths", ())),
72
+ )
73
+ for e in raw["framework"]
74
+ )
75
+
76
+
77
+ _SIGNATURES = _load_signatures()
78
+
79
+
80
+ def app_file_filter(path: Path) -> bool:
81
+ """Return True for application PHP files, excluding vendor/generated dirs."""
82
+ if path.suffix != ".php":
83
+ return False
84
+ if path.name in _EXCLUDED_FILES:
85
+ return False
86
+ return not any(part in _EXCLUDED_DIRS for part in path.parts)
87
+
88
+
89
+ def _marker_root(candidate: Path, signature: _FrameworkSignature) -> Path | None:
90
+ """Return the subtree root if candidate is signature's identity marker.
91
+
92
+ An unreadable candidate is not a marker: it is skipped with a warning.
93
+ """
94
+ marker_parts = Path(signature.marker).parts
95
+ if candidate.parts[-len(marker_parts) :] != marker_parts:
96
+ return None
97
+ try:
98
+ head = candidate.read_bytes()[:_MARKER_SCAN_BYTES].decode("latin-1")
99
+ except OSError as exc:
100
+ logger.warning("skipping unreadable framework marker: name=%s path=%s error=%s", signature.name, candidate, exc)
101
+ return None
102
+ if signature.contains not in head:
103
+ return None
104
+ depth = len(marker_parts)
105
+ return candidate.parents[depth - 1]
106
+
107
+
108
+ def _excluded_dirs_for(root: Path, signature: _FrameworkSignature) -> list[Path]:
109
+ """Absolute dirs to exclude for one detected framework root.
110
+
111
+ Empty ``exclude_subpaths`` excludes the whole subtree; otherwise only the
112
+ named subpaths under the root, keeping the rest as application code.
113
+ """
114
+ if not signature.exclude_subpaths:
115
+ return [root]
116
+ return [root / sub for sub in signature.exclude_subpaths]
117
+
118
+
119
+ def framework_excluded_dirs(project_root: Path) -> frozenset[Path]:
120
+ """Resolve registry signatures to excluded framework directories.
121
+
122
+ Detects every vendored-framework subtree under project_root and returns
123
+ the directories to exclude from PHP source processing. Shared by AST
124
+ parsing (make_app_file_filter) and source normalization so both apply one
125
+ framework-exclusion set and keep their line numbers consistent.
126
+
127
+ Args:
128
+ project_root: Project directory to scan for framework markers. Returned
129
+ paths are anchored to it; the caller resolves it when its consumers
130
+ supply resolved paths.
131
+
132
+ Returns:
133
+ Directories under project_root to exclude as framework code.
134
+ """
135
+ excluded: set[Path] = set()
136
+ for signature in _SIGNATURES:
137
+ basename = Path(signature.marker).name
138
+ for candidate in project_root.rglob(basename):
139
+ root = _marker_root(candidate, signature)
140
+ if root is None:
141
+ continue
142
+ dirs = _excluded_dirs_for(root, signature)
143
+ excluded.update(dirs)
144
+ logger.info(
145
+ "excluding framework dirs: name=%s root=%s dirs=%s",
146
+ signature.name,
147
+ root,
148
+ dirs,
149
+ )
150
+ return frozenset(excluded)
151
+
152
+
153
+ def make_app_file_filter(project_root: Path) -> Callable[[Path], bool]:
154
+ """Build a filter excluding vendor/generated dirs and framework subtrees.
155
+
156
+ Detects framework subtree roots under project_root once, then returns a
157
+ predicate over the absolute paths parse_project supplies.
158
+
159
+ Args:
160
+ project_root: Project directory passed to parse_project.
161
+
162
+ Returns:
163
+ Predicate returning True for application PHP files.
164
+ """
165
+ excluded_dirs = framework_excluded_dirs(project_root.resolve())
166
+
167
+ def _filter(path: Path) -> bool:
168
+ if not app_file_filter(path):
169
+ return False
170
+ return not any(path.is_relative_to(excluded) for excluded in excluded_dirs)
171
+
172
+ return _filter
@@ -0,0 +1,29 @@
1
+ """Project root path of a parsed project AST.
2
+
3
+ The Semgrep runner reads its scan root here: the project node's
4
+ ``absolutePath`` is required, with no fallback to file nodes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from php_parser_py import AST
10
+
11
+
12
+ def project_root_text(project_ast: AST) -> str:
13
+ """Return the project node's ``absolutePath`` unchanged.
14
+
15
+ A directory name may begin or end with a space, so the path is never
16
+ stripped; only a value that is blank is rejected.
17
+
18
+ Raises:
19
+ ValueError: If the AST has no project node, or the project node has no
20
+ path, an empty path, or a whitespace-only path.
21
+ """
22
+ try:
23
+ project_node = project_ast.project_node()
24
+ except KeyError as exc:
25
+ raise ValueError("Cannot resolve project root from AST: no project node") from exc
26
+ path_val = project_node.get("absolutePath")
27
+ if not isinstance(path_val, str) or not path_val.strip():
28
+ raise ValueError("Cannot resolve project root from AST: project node has no absolutePath")
29
+ return path_val