-
Notifications
You must be signed in to change notification settings - Fork 0
triage: deterministic dependency-manifest resolver (dep_manifest + Lane A) #322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
f9d9ec9
feat(triage): DepManifest schema + DepGraph scope propagation (SP1 ta…
galanko 698995f
feat(triage): dep scope taxonomy + first-party import scan (SP1 tasks…
galanko 38fc618
feat(triage): resolve_by_dep_manifest pure resolver (SP1 task 8)
galanko 739faf6
feat(triage): one-pass collect_import_sites for the manifest assembler
galanko 26cd5b5
feat(triage): npm + pypi lockfile parsers + dep_manifest assembler (S…
galanko d980b0d
feat(triage): wire dep_manifest artifact + resolver into Cliff (SP1 t…
galanko 47cd20c
feat(triage): scope Python dev/docs/test extras by name (recall)
galanko ed348df
fix(triage): don't exclude scripts/tools from the import scan
galanko 3c51f1e
fix(triage): don't count build-config-file imports as shipping imports
galanko 02c464f
fix(triage): harden dep_manifest safety gates (code review)
galanko 4d95581
fix(triage): address CodeRabbit review + ruff CI
galanko 3b44a63
fix(triage): wrap E501 in triage_runner + knowledge (ruff CI)
galanko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| """Deterministic dependency-manifest resolver (SP1 / ADR-0053 dep_manifest). | ||
|
|
||
| Clears a **dependency** finding (``location`` shape ``pkg@version``) as | ||
| ``false_positive`` BEFORE the LLM Deep dive when the package provably does not | ||
| ship: every root reaching its ``(name, version)`` node is dev/test/docs/build | ||
| (no prod, no optional) AND it is not imported by any first-party shipping source. | ||
|
|
||
| Pure — no LLM, no network, no filesystem — keyless and CI-testable. This is a | ||
| *structural* clear like ``code_map`` (facts from the lockfile graph, not a model | ||
| hunch), so it is tier-independent under ADR-0054. | ||
|
|
||
| Safety (never clear a shipping dependency): the clear requires TWO independent | ||
| gates to both hold — non-prod scope AND zero shipping import sites — plus a | ||
| resolved import name. Transitive reachability of a *vulnerable API* is NOT | ||
| decided here (that is Lane B / the Deep dive); this resolver only clears the | ||
| "doesn't ship at all" case. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| from typing import Any | ||
|
|
||
| from cliff.agents.schemas import TriageCheck, TriageOutput, TriageProvenance | ||
|
|
||
| _CONF_DEP_CLEAR = 0.9 | ||
|
|
||
| #: The ONLY scopes that are safe to clear (allowlist, not denylist). A node may be | ||
| #: cleared only if *every* scope reaching it is one of these — any other value | ||
| #: ("prod", "optional", or an unrecognized/future scope) blocks the clear, so a new | ||
| #: shipping scope can never silently pass the gate. | ||
| _CLEARABLE_SCOPES = frozenset({"dev", "test", "docs", "build"}) | ||
|
|
||
|
|
||
| def _parse_location(loc: str) -> tuple[str, str] | None: | ||
| """``"pkg@version"`` -> ``(name, version)``; scoped npm ``@scope/name@ver`` handled. | ||
|
|
||
| Splits on the LAST ``@`` so the leading ``@`` of a scoped name is preserved. | ||
| """ | ||
| s = loc.strip() | ||
| if "@" not in s: | ||
| return None | ||
| name, _, version = s.rpartition("@") | ||
| if not name or not version: | ||
| return None | ||
| return name, version | ||
|
|
||
|
|
||
| def _pep503(name: str) -> str: | ||
| """PEP 503 canonical form (lowercase; runs of -_. → single -).""" | ||
| return re.sub(r"[-_.]+", "-", name).lower() | ||
|
|
||
|
|
||
| def _name_matches(node: dict[str, Any], raw_name: str) -> bool: | ||
| """The finding's scanner-reported name vs a node's stored name. | ||
|
|
||
| pypi nodes are stored PEP-503-normalized (``pyyaml``) but scanners report raw | ||
| names (``PyYAML``, ``ruamel.yaml``) — normalize before comparing. npm names | ||
| are compared verbatim (they are not PEP-503-normalized; ``lodash.merge`` ≠ | ||
| ``lodash-merge``). | ||
| """ | ||
| node_name = node.get("name") | ||
| if node.get("ecosystem") == "pypi": | ||
| return isinstance(node_name, str) and _pep503(raw_name) == node_name | ||
| return raw_name == node_name | ||
|
|
||
|
|
||
| def _clear(*, detail: str) -> TriageOutput: | ||
| return TriageOutput( | ||
| verdict="false_positive", | ||
| confidence=_CONF_DEP_CLEAR, | ||
| checks=[ | ||
| TriageCheck( | ||
| eyebrow="Dependency does not ship", | ||
| result="dev/test-only dependency", | ||
| kind="pass", | ||
| detail=detail, | ||
| ) | ||
| ], | ||
| provenance=TriageProvenance( | ||
| steps_run=["dep_manifest_resolver"], | ||
| exit_stage="dep_manifest_resolver", | ||
| escalated=False, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def resolve_by_dep_manifest( | ||
| finding: dict[str, Any], dep_manifest: dict[str, Any] | None | ||
| ) -> TriageOutput | None: | ||
| """Clear *finding* if its ``pkg@version`` is a dev/test-only, unimported | ||
| dependency; else ``None`` (fall through to Lane B / the Deep dive).""" | ||
| if not dep_manifest: | ||
| return None | ||
| loc = finding.get("location") | ||
| if not isinstance(loc, str): | ||
| return None | ||
| parsed = _parse_location(loc) | ||
| if parsed is None: | ||
| return None | ||
| name, version = parsed | ||
|
|
||
| nodes = dep_manifest.get("nodes") | ||
| if not isinstance(nodes, list): | ||
| return None | ||
| matches = [ | ||
| n | ||
| for n in nodes | ||
| if isinstance(n, dict) and _name_matches(n, name) and n.get("version") == version | ||
| ] | ||
| if len(matches) != 1: | ||
| # not found, or a cross-ecosystem (name, version) collision → conservative | ||
| return None | ||
| node = matches[0] | ||
|
|
||
| scopes = node.get("scopes") | ||
| if not isinstance(scopes, list) or not scopes: | ||
| return None # unknown reachability → cannot prove non-prod → don't clear | ||
| scope_set = {s for s in scopes if isinstance(s, str)} | ||
| if not scope_set <= _CLEARABLE_SCOPES: | ||
| return None # a prod/optional/unknown scope reaches it → don't clear (allowlist) | ||
|
|
||
| # Import-site gate — fail CLOSED: only a present, empty list proves "not imported". | ||
| # A missing / non-list / non-empty import_sites blocks the clear (pitfall 1). | ||
| import_sites = node.get("import_sites") | ||
| if not isinstance(import_sites, list) or import_sites: | ||
| return None # imported in first-party shipping code, or untrustworthy → don't clear | ||
|
|
||
| if not node.get("import_name"): | ||
| # import name unresolved → couldn't run the import gate → don't clear (pitfall 3) | ||
| return None | ||
|
|
||
| return _clear( | ||
| detail=( | ||
| f"{name}@{version} ({node.get('ecosystem', '?')}) reaches only " | ||
| f"{sorted(scope_set)} roots and is imported by no shipping first-party " | ||
| f"source (declared_in={node.get('declared_in') or 'transitive'})" | ||
| ) | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| """Deterministic dependency-manifest builder (ADR-0053 §3, dep_manifest artifact). | ||
|
|
||
| Parses lockfiles/manifests across all workspaces into a graph keyed by | ||
| ``(name, version)``, propagates scopes (prod dominates), finds first-party import | ||
| sites, and emits a ``DepManifest``. Pure — no LLM, no network. | ||
|
|
||
| The public entry point ``build_dep_manifest`` is assembled in ``build.py`` and | ||
| re-exported here (SP1 Task 7). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from .build import build_dep_manifest | ||
| from .graph import DepGraph, Root | ||
|
|
||
| __all__ = ["DepGraph", "Root", "build_dep_manifest"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| """Assemble the ``dep_manifest`` artifact (SP1 Task 7). | ||
|
|
||
| Discover manifests → parse npm + pypi graphs (each ecosystem independently) → | ||
| propagate scopes → resolve import names → attach first-party import sites (one | ||
| walk per ecosystem) → emit a ``DepManifest`` dict. | ||
|
|
||
| Robustness: this builder runs in-process at scan time and parses *untrusted* | ||
| lockfiles. It MUST NOT raise — an uncaught parser error would fail the whole | ||
| profile build (``profile_status='error'``), which silently disables the code_map | ||
| gate, the dep_manifest gate, AND the Deep dive for that repo. Any per-ecosystem | ||
| failure is caught and recorded in ``unresolved``; the artifact is still emitted. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from pathlib import Path | ||
|
|
||
| from ..schemas import DepManifest, DepNode | ||
| from .imports import collect_import_sites | ||
| from .manifests import discover_manifests | ||
| from .npm import parse_npm | ||
| from .pypi import dist_to_import, parse_pypi | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _import_name(name: str, ecosystem: str) -> str | None: | ||
| if ecosystem == "npm": | ||
| return name # npm import name == package name (scoped kept) | ||
| if ecosystem == "pypi": | ||
| return dist_to_import(name) | ||
| return None | ||
|
|
||
|
|
||
| def _lookup_key(iname: str, ecosystem: str) -> str: | ||
| # pypi import sites are bucketed by TOP-LEVEL module; a dotted import name | ||
| # (``google.protobuf``) must be looked up by its first component (``google``). | ||
| return iname.split(".")[0] if ecosystem == "pypi" else iname | ||
|
|
||
|
|
||
| def _ecosystem_nodes(root: Path, ms: list, parser, eco: str) -> tuple[list[dict], list[str]]: | ||
| """Parse one ecosystem into DepNode dicts + its unresolved manifests. Never raises.""" | ||
| try: | ||
| graph, unresolved = parser(root, ms) | ||
| node_map = graph.nodes | ||
| if not node_map: | ||
| return [], list(unresolved) | ||
| scopes = graph.propagate_scopes() | ||
| direct = graph.direct_nodes() | ||
| declared = graph.declared_in_map() | ||
| imports = collect_import_sites(root, eco) # one walk per ecosystem | ||
| nodes: list[dict] = [] | ||
| for (name, version), node_eco in node_map.items(): | ||
| iname = _import_name(name, node_eco) | ||
| sites = imports.get(_lookup_key(iname, node_eco), []) if iname else [] | ||
| nodes.append( | ||
| DepNode( | ||
| name=name, | ||
| version=version, | ||
| ecosystem=node_eco, | ||
| scopes=sorted(scopes.get((name, version), set())), | ||
| direct=(name, version) in direct, | ||
| declared_in=declared.get((name, version), []), | ||
| import_name=iname, | ||
| import_sites=sites, | ||
| ).model_dump() | ||
| ) | ||
| return nodes, list(unresolved) | ||
| except Exception: # noqa: BLE001 - a bad lockfile must not fail the whole profile | ||
| logger.warning( | ||
| "dep_manifest: %s parse failed — recording as unresolved", eco, exc_info=True | ||
| ) | ||
| return [], [f"<{eco}-parse-error>"] | ||
|
|
||
|
|
||
| def build_dep_manifest(root) -> dict: # noqa: ANN001 - Path | str | ||
| root = Path(root) | ||
| try: | ||
| manifests = discover_manifests(root) | ||
| except Exception: # noqa: BLE001 - never let discovery crash the profile build | ||
| logger.warning("dep_manifest: manifest discovery failed", exc_info=True) | ||
| return DepManifest(unresolved=["<discovery-error>"]).model_dump() | ||
|
|
||
| npm_ms = [m for m in manifests if m.ecosystem == "npm"] | ||
| py_ms = [m for m in manifests if m.ecosystem == "pypi"] | ||
| nodes: list[dict] = [] | ||
| unresolved: list[str] = [] | ||
| ecosystems: list[str] = [] | ||
| for ms, parser, eco in ((npm_ms, parse_npm, "npm"), (py_ms, parse_pypi, "pypi")): | ||
| if not ms: | ||
| continue | ||
| eco_nodes, eco_unresolved = _ecosystem_nodes(root, ms, parser, eco) | ||
| unresolved.extend(eco_unresolved) | ||
| if eco_nodes: | ||
| ecosystems.append(eco) | ||
| nodes.extend(eco_nodes) | ||
|
|
||
| return DepManifest( | ||
| ecosystems=ecosystems, | ||
| workspaces=[m.path for m in manifests], | ||
| nodes=nodes, | ||
| unresolved=unresolved, | ||
| ).model_dump() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Deterministic dep_manifest gate correctly gated and wired.
Confirmed against the gate test that monkeypatches
_load_dep_manifestto a dev-only, unimported node and asserts the run_triage verdict is false_positive while maybe_deep_dive is not called, and the symmetric prod-dependency fall-through test. Logic is sound.Minor observation (optional): this block repeats the same "load → resolve → persist+return" shape as the
code_mapgate above it (Lines 266-270). Not urgent given each has a distinct precondition, but if a third gate is added later, consider extracting a small_try_gate(resolver, ctx, loader)helper.🤖 Prompt for AI Agents