PTSIP 0.3.1__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.
Files changed (66) hide show
  1. ptsip/__init__.py +3 -0
  2. ptsip/__main__.py +4 -0
  3. ptsip/app/__init__.py +5 -0
  4. ptsip/app/client.py +53 -0
  5. ptsip/app/github_client.py +200 -0
  6. ptsip/app/server.py +130 -0
  7. ptsip/app/service.py +285 -0
  8. ptsip/app/store.py +233 -0
  9. ptsip/artifact_evidence.py +150 -0
  10. ptsip/build_resolution.py +298 -0
  11. ptsip/clarification/__init__.py +6 -0
  12. ptsip/clarification/generator.py +120 -0
  13. ptsip/clarification/generator_core.py +98 -0
  14. ptsip/clarification/i18n.py +106 -0
  15. ptsip/clarification/model.py +33 -0
  16. ptsip/clarification/render.py +78 -0
  17. ptsip/clarification/resolution/__init__.py +29 -0
  18. ptsip/clarification/resolution/model.py +33 -0
  19. ptsip/clarification/resolution/parser.py +56 -0
  20. ptsip/clarification/resolution/profile_projection.py +198 -0
  21. ptsip/clarification/resolution/resolver.py +30 -0
  22. ptsip/clarification/transports/__init__.py +1 -0
  23. ptsip/clarification/transports/github_issue.py +162 -0
  24. ptsip/cli.py +402 -0
  25. ptsip/conformance.py +531 -0
  26. ptsip/conformance_audit.py +143 -0
  27. ptsip/conformance_engine.py +396 -0
  28. ptsip/constants.py +9 -0
  29. ptsip/doctor.py +33 -0
  30. ptsip/inspection/__init__.py +1 -0
  31. ptsip/inspection/components.py +74 -0
  32. ptsip/inspection/dependencies.py +496 -0
  33. ptsip/inspection/dependencies_030.py +69 -0
  34. ptsip/inspection/dotnet.py +188 -0
  35. ptsip/inspection/go.py +157 -0
  36. ptsip/inspection/inventory.py +161 -0
  37. ptsip/inspection/javascript.py +376 -0
  38. ptsip/inspection/lexing.py +121 -0
  39. ptsip/inspection/source_adapters.py +60 -0
  40. ptsip/lifecycle_evidence.py +245 -0
  41. ptsip/model.py +105 -0
  42. ptsip/pilot/__init__.py +1 -0
  43. ptsip/pilot/runner.py +170 -0
  44. ptsip/repository/__init__.py +1 -0
  45. ptsip/repository/discover.py +68 -0
  46. ptsip/repository/remote.py +54 -0
  47. ptsip/repository/snapshot.py +173 -0
  48. ptsip/review_evidence.py +406 -0
  49. ptsip/spec_identity.py +22 -0
  50. ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
  51. ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
  52. ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
  53. ptsip/specdata/ptsip-profile.schema.json +310 -0
  54. ptsip/specdata/ptsip-registry.yaml +189 -0
  55. ptsip/storage/__init__.py +1 -0
  56. ptsip/storage/local_state.py +32 -0
  57. ptsip/validation/__init__.py +1 -0
  58. ptsip/validation/components.py +164 -0
  59. ptsip/validation/profile.py +172 -0
  60. ptsip/validation/rules.py +163 -0
  61. ptsip-0.3.1.dist-info/METADATA +338 -0
  62. ptsip-0.3.1.dist-info/RECORD +66 -0
  63. ptsip-0.3.1.dist-info/WHEEL +5 -0
  64. ptsip-0.3.1.dist-info/entry_points.txt +3 -0
  65. ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
  66. ptsip-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ import shlex
6
+ import sys
7
+ import tokenize
8
+ import tomllib
9
+ import xml.etree.ElementTree as ET
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from ..model import (
16
+ DependencyEdge,
17
+ DependencyPhase,
18
+ EdgeType,
19
+ EvidenceNodeScope,
20
+ EvidenceProvenance,
21
+ ResolutionStatus,
22
+ )
23
+ from ..repository.snapshot import repository_files
24
+
25
+ _REQUIREMENT_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)")
26
+ _SCRIPT_SUFFIXES = {".py", ".ps1", ".sh", ".bash", ".bat", ".cmd"}
27
+ _PYTHON_RUNNERS = {"python", "python3", "python.exe", "python3.exe", "py", "py.exe"}
28
+ _POWERSHELL_RUNNERS = {"pwsh", "pwsh.exe", "powershell", "powershell.exe"}
29
+ _SHELL_RUNNERS = {"bash", "sh"}
30
+ _CMD_RUNNERS = {"cmd", "cmd.exe"}
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DependencyScanIssue:
35
+ adapter: str
36
+ path: str
37
+ message: str
38
+
39
+ def as_dict(self) -> dict[str, str]:
40
+ return asdict(self)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DependencyScan:
45
+ edges: tuple[DependencyEdge, ...]
46
+ issues: tuple[DependencyScanIssue, ...]
47
+ adapters: tuple[str, ...]
48
+
49
+ def as_dict(self) -> dict[str, object]:
50
+ return {
51
+ "edges": [edge.as_dict() for edge in self.edges],
52
+ "issues": [issue.as_dict() for issue in self.issues],
53
+ "adapters": list(self.adapters),
54
+ "edge_count": len(self.edges),
55
+ "coverage_complete": not self.issues,
56
+ }
57
+
58
+
59
+ def _normalize_dependency_name(value: str) -> str:
60
+ return re.sub(r"[-.]", "_", value.strip().lower())
61
+
62
+
63
+ def _declared_python_dependencies(root: Path) -> tuple[set[str], list[DependencyScanIssue]]:
64
+ names: set[str] = set()
65
+ issues: list[DependencyScanIssue] = []
66
+ pyproject = root / "pyproject.toml"
67
+ if pyproject.is_file():
68
+ try:
69
+ payload = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
70
+ project = payload.get("project", {}) if isinstance(payload, dict) else {}
71
+ if isinstance(project, dict):
72
+ groups: list[object] = [project.get("dependencies", [])]
73
+ optional = project.get("optional-dependencies", {})
74
+ if isinstance(optional, dict):
75
+ groups.extend(optional.values())
76
+ for group in groups:
77
+ if not isinstance(group, list):
78
+ continue
79
+ for item in group:
80
+ if not isinstance(item, str):
81
+ continue
82
+ match = _REQUIREMENT_NAME_RE.match(item)
83
+ if match:
84
+ names.add(_normalize_dependency_name(match.group(1)))
85
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
86
+ issues.append(DependencyScanIssue("python-declarations", "pyproject.toml", str(exc)))
87
+
88
+ for requirements in sorted(root.glob("requirements*.txt")) + sorted(root.glob("requirements*.in")):
89
+ rel = requirements.relative_to(root).as_posix()
90
+ try:
91
+ lines = requirements.read_text(encoding="utf-8-sig").splitlines()
92
+ except (OSError, UnicodeError) as exc:
93
+ issues.append(DependencyScanIssue("python-declarations", rel, str(exc)))
94
+ continue
95
+ for line in lines:
96
+ stripped = line.strip()
97
+ if not stripped or stripped.startswith(("#", "-r", "--requirement", "-c", "--constraint", "-e", "--editable")):
98
+ continue
99
+ match = _REQUIREMENT_NAME_RE.match(stripped)
100
+ if match:
101
+ names.add(_normalize_dependency_name(match.group(1)))
102
+ return names, issues
103
+
104
+
105
+ def _resolve_module(root: Path, module: str) -> str | None:
106
+ if not module:
107
+ return None
108
+ rel = Path(*module.split("."))
109
+ candidates = [
110
+ root / rel.with_suffix(".py"),
111
+ root / rel / "__init__.py",
112
+ root / "src" / rel.with_suffix(".py"),
113
+ root / "src" / rel / "__init__.py",
114
+ ]
115
+ for candidate in candidates:
116
+ if candidate.is_file():
117
+ return candidate.relative_to(root).as_posix()
118
+ return None
119
+
120
+
121
+ def _source_package_parts(root: Path, rel: str) -> list[str]:
122
+ path = root / rel
123
+ parent = path.parent
124
+ parts: list[str] = []
125
+ while parent != root and (parent / "__init__.py").is_file():
126
+ parts.insert(0, parent.name)
127
+ parent = parent.parent
128
+ return parts
129
+
130
+
131
+ def _resolve_relative_import(root: Path, rel: str, node: ast.ImportFrom) -> tuple[str, str | None]:
132
+ package = _source_package_parts(root, rel)
133
+ if not package or node.level <= 0 or node.level > len(package):
134
+ target = ("." * node.level) + (node.module or "")
135
+ return target or "<relative>", None
136
+
137
+ keep = len(package) - node.level + 1
138
+ base_parts = package[:keep]
139
+ if node.module:
140
+ base_parts.extend(node.module.split("."))
141
+ target = ".".join(base_parts)
142
+ return target, _resolve_module(root, target)
143
+
144
+ for alias in node.names:
145
+ if alias.name == "*":
146
+ continue
147
+ target = ".".join([*base_parts, *alias.name.split(".")])
148
+ resolved = _resolve_module(root, target)
149
+ if resolved:
150
+ return target, resolved
151
+
152
+ target = ".".join(base_parts)
153
+ return target, _resolve_module(root, target)
154
+
155
+
156
+ def _python_target_state(
157
+ module: str,
158
+ resolved: str | None,
159
+ declared_dependencies: set[str],
160
+ ) -> tuple[ResolutionStatus, EvidenceNodeScope, str | None]:
161
+ if resolved:
162
+ return ResolutionStatus.RESOLVED, EvidenceNodeScope.PROJECT_COMPONENT, None
163
+ root_name = module.lstrip(".").split(".", 1)[0]
164
+ if root_name and root_name in sys.stdlib_module_names:
165
+ return ResolutionStatus.EXTERNAL, EvidenceNodeScope.PLATFORM, "Python standard-library target"
166
+ if root_name and _normalize_dependency_name(root_name) in declared_dependencies:
167
+ return ResolutionStatus.EXTERNAL, EvidenceNodeScope.EXTERNAL_DEPENDENCY, "Target matches a declared Python dependency"
168
+ return ResolutionStatus.UNRESOLVED, EvidenceNodeScope.UNRESOLVED_TARGET, None
169
+
170
+
171
+ def _python_edges(
172
+ root: Path,
173
+ rel: str,
174
+ declared_dependencies: set[str],
175
+ ) -> tuple[list[DependencyEdge], list[DependencyScanIssue]]:
176
+ path = root / rel
177
+ try:
178
+ with tokenize.open(path) as handle:
179
+ source = handle.read()
180
+ tree = ast.parse(source, filename=rel)
181
+ except (OSError, UnicodeError, SyntaxError) as exc:
182
+ return [], [DependencyScanIssue("python", rel, str(exc))]
183
+
184
+ edges: list[DependencyEdge] = []
185
+ for node in ast.walk(tree):
186
+ if isinstance(node, ast.Import):
187
+ for alias in node.names:
188
+ resolved = _resolve_module(root, alias.name)
189
+ resolution, scope, note = _python_target_state(alias.name, resolved, declared_dependencies)
190
+ edges.append(
191
+ DependencyEdge(
192
+ evidence_id=f"python:{rel}:{node.lineno}:{alias.name}",
193
+ source=rel,
194
+ target=alias.name,
195
+ edge_type=EdgeType.IMPORTS,
196
+ phase=DependencyPhase.UNKNOWN,
197
+ resolution=resolution,
198
+ target_scope=scope,
199
+ provenance=EvidenceProvenance.OBSERVED,
200
+ line=node.lineno,
201
+ resolved_path=resolved,
202
+ adapter="python",
203
+ note=note,
204
+ )
205
+ )
206
+ elif isinstance(node, ast.ImportFrom):
207
+ if node.level:
208
+ target, resolved = _resolve_relative_import(root, rel, node)
209
+ else:
210
+ target = node.module or "<unknown-import>"
211
+ resolved = _resolve_module(root, node.module or "")
212
+ resolution, scope, note = _python_target_state(target, resolved, declared_dependencies)
213
+ if node.level and not resolved and note is None:
214
+ note = "Relative import target could not be resolved from repository package evidence"
215
+ edges.append(
216
+ DependencyEdge(
217
+ evidence_id=f"python:{rel}:{node.lineno}:{target}",
218
+ source=rel,
219
+ target=target,
220
+ edge_type=EdgeType.IMPORTS,
221
+ phase=DependencyPhase.UNKNOWN,
222
+ resolution=resolution,
223
+ target_scope=scope,
224
+ provenance=EvidenceProvenance.OBSERVED,
225
+ line=node.lineno,
226
+ resolved_path=resolved,
227
+ adapter="python",
228
+ note=note,
229
+ )
230
+ )
231
+ elif isinstance(node, ast.Call):
232
+ is_importlib = (
233
+ isinstance(node.func, ast.Attribute)
234
+ and isinstance(node.func.value, ast.Name)
235
+ and node.func.value.id == "importlib"
236
+ and node.func.attr == "import_module"
237
+ )
238
+ is_builtin = isinstance(node.func, ast.Name) and node.func.id == "__import__"
239
+ if not (is_importlib or is_builtin):
240
+ continue
241
+ module_value = node.args[0] if node.args else None
242
+ if isinstance(module_value, ast.Constant) and isinstance(module_value.value, str):
243
+ target = module_value.value
244
+ resolved = _resolve_module(root, target)
245
+ resolution, scope, note = _python_target_state(target, resolved, declared_dependencies)
246
+ else:
247
+ resolved = None
248
+ resolution = ResolutionStatus.DYNAMIC
249
+ scope = EvidenceNodeScope.UNRESOLVED_TARGET
250
+ target = "<dynamic-import>"
251
+ note = "Dynamic import target is not statically known"
252
+ edges.append(
253
+ DependencyEdge(
254
+ evidence_id=f"python-dynamic:{rel}:{node.lineno}",
255
+ source=rel,
256
+ target=target,
257
+ edge_type=EdgeType.LOADS,
258
+ phase=DependencyPhase.UNKNOWN,
259
+ resolution=resolution,
260
+ target_scope=scope,
261
+ provenance=EvidenceProvenance.OBSERVED,
262
+ line=node.lineno,
263
+ resolved_path=resolved,
264
+ adapter="python",
265
+ note=note,
266
+ )
267
+ )
268
+ return edges, []
269
+
270
+
271
+ def _csproj_edges(root: Path, rel: str) -> tuple[list[DependencyEdge], list[DependencyScanIssue]]:
272
+ path = root / rel
273
+ try:
274
+ tree = ET.parse(path)
275
+ except (OSError, ET.ParseError) as exc:
276
+ return [], [DependencyScanIssue("dotnet-csproj", rel, str(exc))]
277
+ edges: list[DependencyEdge] = []
278
+ for element in tree.iter():
279
+ if not element.tag.endswith("ProjectReference"):
280
+ continue
281
+ include = element.attrib.get("Include")
282
+ if not include:
283
+ continue
284
+ candidate = (path.parent / include).resolve()
285
+ try:
286
+ resolved = candidate.relative_to(root).as_posix() if candidate.is_file() and candidate.is_relative_to(root) else None
287
+ except (OSError, ValueError):
288
+ resolved = None
289
+ edges.append(
290
+ DependencyEdge(
291
+ evidence_id=f"dotnet-project-reference:{rel}:{include}",
292
+ source=rel,
293
+ target=include.replace("\\", "/"),
294
+ edge_type=EdgeType.LINKS,
295
+ phase=DependencyPhase.UNKNOWN,
296
+ resolution=ResolutionStatus.RESOLVED if resolved else ResolutionStatus.UNRESOLVED,
297
+ target_scope=EvidenceNodeScope.PROJECT_COMPONENT if resolved else EvidenceNodeScope.UNRESOLVED_TARGET,
298
+ provenance=EvidenceProvenance.DECLARED,
299
+ resolved_path=resolved,
300
+ adapter="dotnet-csproj",
301
+ )
302
+ )
303
+ return edges, []
304
+
305
+
306
+ def _workflow_phase(rel: str, job_name: str) -> DependencyPhase:
307
+ text = f"{rel} {job_name}".lower()
308
+ if "release" in text or "publish" in text or "deploy" in text:
309
+ return DependencyPhase.RELEASE
310
+ if "test" in text:
311
+ return DependencyPhase.TEST
312
+ return DependencyPhase.BUILD
313
+
314
+
315
+ def _working_directory(payload: dict[str, object], job: dict[str, object], step: dict[str, object]) -> str:
316
+ step_value = step.get("working-directory")
317
+ if isinstance(step_value, str) and step_value.strip():
318
+ return step_value.strip()
319
+ for owner in (job, payload):
320
+ defaults = owner.get("defaults")
321
+ if not isinstance(defaults, dict):
322
+ continue
323
+ run_defaults = defaults.get("run")
324
+ if not isinstance(run_defaults, dict):
325
+ continue
326
+ value = run_defaults.get("working-directory")
327
+ if isinstance(value, str) and value.strip():
328
+ return value.strip()
329
+ return "."
330
+
331
+
332
+ def _clean_shell_token(value: str) -> str:
333
+ return value.strip().strip("'\"")
334
+
335
+
336
+ def _is_script_token(value: str) -> bool:
337
+ return Path(_clean_shell_token(value)).suffix.lower() in _SCRIPT_SUFFIXES
338
+
339
+
340
+ def _script_targets_from_command(command: str) -> list[str]:
341
+ targets: list[str] = []
342
+ for line in command.splitlines():
343
+ stripped = line.strip()
344
+ if not stripped or stripped.startswith("#"):
345
+ continue
346
+ for segment in re.split(r"\s*(?:&&|\|\||;)\s*", stripped):
347
+ if not segment:
348
+ continue
349
+ try:
350
+ tokens = [_clean_shell_token(item) for item in shlex.split(segment, posix=False)]
351
+ except ValueError:
352
+ tokens = [_clean_shell_token(item) for item in segment.split()]
353
+ tokens = [item for item in tokens if item]
354
+ while tokens and "=" in tokens[0] and not _is_script_token(tokens[0]):
355
+ tokens.pop(0)
356
+ if not tokens:
357
+ continue
358
+
359
+ first = tokens[0]
360
+ executable = Path(first).name.lower()
361
+ candidate: str | None = None
362
+
363
+ if _is_script_token(first):
364
+ candidate = first
365
+ elif executable in _PYTHON_RUNNERS:
366
+ for token in tokens[1:]:
367
+ if token in {"-c", "-m"}:
368
+ break
369
+ if token.startswith("-"):
370
+ continue
371
+ if Path(token).suffix.lower() == ".py":
372
+ candidate = token
373
+ break
374
+ elif executable in _POWERSHELL_RUNNERS:
375
+ for index, token in enumerate(tokens[1:], start=1):
376
+ if token.lower() in {"-file", "/file"} and index + 1 < len(tokens):
377
+ value = tokens[index + 1]
378
+ if Path(value).suffix.lower() == ".ps1":
379
+ candidate = value
380
+ break
381
+ if Path(token).suffix.lower() == ".ps1":
382
+ candidate = token
383
+ break
384
+ elif executable in _SHELL_RUNNERS:
385
+ for token in tokens[1:]:
386
+ if token.startswith("-"):
387
+ continue
388
+ if Path(token).suffix.lower() in {".sh", ".bash"}:
389
+ candidate = token
390
+ break
391
+ elif executable in _CMD_RUNNERS:
392
+ for token in tokens[1:]:
393
+ if token.lower() in {"/c", "/k"}:
394
+ continue
395
+ if Path(token).suffix.lower() in {".bat", ".cmd"}:
396
+ candidate = token
397
+ break
398
+
399
+ if candidate:
400
+ normalized = candidate.replace("\\", "/")
401
+ if normalized not in targets:
402
+ targets.append(normalized)
403
+ return targets
404
+
405
+
406
+ def _resolve_workflow_script(root: Path, working_directory: str, raw_target: str) -> str | None:
407
+ target_path = Path(raw_target)
408
+ if target_path.is_absolute():
409
+ return None
410
+ cwd = (root / working_directory).resolve()
411
+ try:
412
+ if not cwd.is_relative_to(root):
413
+ return None
414
+ except (OSError, ValueError):
415
+ return None
416
+ candidate = (cwd / target_path).resolve()
417
+ try:
418
+ return candidate.relative_to(root).as_posix() if candidate.is_file() and candidate.is_relative_to(root) else None
419
+ except (OSError, ValueError):
420
+ return None
421
+
422
+
423
+ def _workflow_edges(root: Path, rel: str) -> tuple[list[DependencyEdge], list[DependencyScanIssue]]:
424
+ path = root / rel
425
+ try:
426
+ payload = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
427
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
428
+ return [], [DependencyScanIssue("github-actions", rel, str(exc))]
429
+ if not isinstance(payload, dict):
430
+ return [], [DependencyScanIssue("github-actions", rel, "Workflow root is not a mapping")]
431
+ jobs = payload.get("jobs", {})
432
+ if not isinstance(jobs, dict):
433
+ return [], [DependencyScanIssue("github-actions", rel, "Workflow jobs is not a mapping")]
434
+ edges: list[DependencyEdge] = []
435
+ for job_name, job in jobs.items():
436
+ if not isinstance(job, dict):
437
+ continue
438
+ steps = job.get("steps", [])
439
+ if not isinstance(steps, list):
440
+ continue
441
+ for index, step in enumerate(steps):
442
+ if not isinstance(step, dict) or not isinstance(step.get("run"), str):
443
+ continue
444
+ command = step["run"]
445
+ working_directory = _working_directory(payload, job, step)
446
+ for raw_target in _script_targets_from_command(command):
447
+ if raw_target.startswith("/"):
448
+ continue
449
+ resolved = _resolve_workflow_script(root, working_directory, raw_target)
450
+ looks_explicitly_local = raw_target.startswith(("./", "../")) or "/" in raw_target
451
+ if not resolved and not looks_explicitly_local:
452
+ continue
453
+ edges.append(
454
+ DependencyEdge(
455
+ evidence_id=f"github-actions:{rel}:{job_name}:{index}:{raw_target}",
456
+ source=rel,
457
+ target=raw_target,
458
+ edge_type=EdgeType.INVOKES,
459
+ phase=_workflow_phase(rel, str(job_name)),
460
+ resolution=ResolutionStatus.RESOLVED if resolved else ResolutionStatus.UNRESOLVED,
461
+ target_scope=EvidenceNodeScope.PROJECT_COMPONENT if resolved else EvidenceNodeScope.UNRESOLVED_TARGET,
462
+ provenance=EvidenceProvenance.DECLARED,
463
+ resolved_path=resolved,
464
+ adapter="github-actions",
465
+ working_directory=working_directory,
466
+ note=None if resolved else "Explicit local-script invocation could not be resolved from its effective working-directory",
467
+ )
468
+ )
469
+ return edges, []
470
+
471
+
472
+ def scan_dependency_edges(root: str | Path) -> DependencyScan:
473
+ root = Path(root).resolve()
474
+ _mode, paths, discovery_errors = repository_files(root)
475
+ edges: list[DependencyEdge] = []
476
+ issues = [DependencyScanIssue("repository", "<repository>", item) for item in discovery_errors]
477
+ adapters: set[str] = set()
478
+ declared_python_dependencies, declaration_issues = _declared_python_dependencies(root)
479
+ issues.extend(declaration_issues)
480
+ for rel in paths:
481
+ suffix = Path(rel).suffix.lower()
482
+ if suffix == ".py":
483
+ found, found_issues = _python_edges(root, rel, declared_python_dependencies)
484
+ adapters.add("python")
485
+ elif suffix == ".csproj":
486
+ found, found_issues = _csproj_edges(root, rel)
487
+ adapters.add("dotnet-csproj")
488
+ elif rel.startswith(".github/workflows/") and suffix in {".yaml", ".yml"}:
489
+ found, found_issues = _workflow_edges(root, rel)
490
+ adapters.add("github-actions")
491
+ else:
492
+ continue
493
+ edges.extend(found)
494
+ issues.extend(found_issues)
495
+ edges.sort(key=lambda item: (item.source, item.line or 0, item.target, item.evidence_id))
496
+ return DependencyScan(tuple(edges), tuple(issues), tuple(sorted(adapters)))
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ..repository.snapshot import repository_files
6
+ from .dependencies import DependencyScan, DependencyScanIssue, scan_dependency_edges as scan_legacy_dependency_edges
7
+ from .dotnet import discover_dotnet_projects, source_edges as dotnet_source_edges
8
+ from .go import discover_go_modules, source_edges as go_source_edges
9
+ from .javascript import discover_npm_packages, manifest_edges, source_edges
10
+ from .source_adapters import JAVASCRIPT_TYPESCRIPT_SOURCE_SUFFIXES
11
+
12
+
13
+ def scan_dependency_edges(root: str | Path) -> DependencyScan:
14
+ root = Path(root).resolve()
15
+ legacy = scan_legacy_dependency_edges(root)
16
+ _mode, paths, discovery_errors = repository_files(root)
17
+
18
+ edges = list(legacy.edges)
19
+ issues = list(legacy.issues)
20
+ adapters = set(legacy.adapters)
21
+ for error in discovery_errors:
22
+ issue = DependencyScanIssue("repository", "<repository>", error)
23
+ if issue not in issues:
24
+ issues.append(issue)
25
+
26
+ by_path, by_name, npm_issues = discover_npm_packages(root, paths)
27
+ if by_path or npm_issues:
28
+ adapters.add("npm-manifest")
29
+ for rel, message in npm_issues:
30
+ issues.append(DependencyScanIssue("npm-manifest", rel, message))
31
+ for package in by_path.values():
32
+ edges.extend(manifest_edges(package, by_name))
33
+
34
+ go_modules, go_issues = discover_go_modules(root, paths)
35
+ if go_modules or go_issues or any(Path(rel).suffix.lower() == ".go" for rel in paths):
36
+ adapters.add("go-source")
37
+ for rel, message in go_issues:
38
+ issues.append(DependencyScanIssue("go-source", rel, message))
39
+
40
+ dotnet_projects, dotnet_issues = discover_dotnet_projects(root, paths)
41
+ if dotnet_projects or dotnet_issues or any(Path(rel).suffix.lower() == ".cs" for rel in paths):
42
+ adapters.add("dotnet-source")
43
+ for rel, message in dotnet_issues:
44
+ issues.append(DependencyScanIssue("dotnet-source", rel, message))
45
+
46
+ for rel in paths:
47
+ suffix = Path(rel).suffix.lower()
48
+ if suffix in JAVASCRIPT_TYPESCRIPT_SOURCE_SUFFIXES:
49
+ adapters.add("javascript-typescript")
50
+ found, found_issues = source_edges(root, rel, by_path, by_name)
51
+ edges.extend(found)
52
+ issues.extend(DependencyScanIssue("javascript-typescript", rel, message) for message in found_issues)
53
+ elif suffix == ".go":
54
+ found, found_issues = go_source_edges(root, rel, go_modules)
55
+ edges.extend(found)
56
+ issues.extend(DependencyScanIssue("go-source", rel, message) for message in found_issues)
57
+ elif suffix == ".cs":
58
+ found, found_issues = dotnet_source_edges(root, rel, dotnet_projects)
59
+ edges.extend(found)
60
+ issues.extend(DependencyScanIssue("dotnet-source", rel, message) for message in found_issues)
61
+
62
+ unique_edges = {item.evidence_id: item for item in edges}
63
+ ordered_edges = sorted(
64
+ unique_edges.values(),
65
+ key=lambda item: (item.source, item.line or 0, item.target, item.evidence_id),
66
+ )
67
+ unique_issues = {(item.adapter, item.path, item.message): item for item in issues}
68
+ ordered_issues = [unique_issues[key] for key in sorted(unique_issues)]
69
+ return DependencyScan(tuple(ordered_edges), tuple(ordered_issues), tuple(sorted(adapters)))