graphitect 0.2.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.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- graphitect-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Bash extractor. Moved verbatim from graphify/extract.py."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The
|
|
13
|
+
# canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes
|
|
14
|
+
# such a variable resolve to the script's own directory, so the literal suffix that
|
|
15
|
+
# follows (`lib/x.sh`) can be resolved against the sourcing file's own dir (#2079).
|
|
16
|
+
_BASH_LEADING_EXPANSION = re.compile(
|
|
17
|
+
r"^(?:(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/?)+"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bash_source_suffix(raw: str, allow_dotdot: bool = False) -> str | None:
|
|
22
|
+
"""Return the literal path suffix of a variable-built `source` argument, or
|
|
23
|
+
None when the remainder is empty or still holds an expansion.
|
|
24
|
+
|
|
25
|
+
``"${DIR}/lib/x.sh"`` -> ``"lib/x.sh"``.
|
|
26
|
+
|
|
27
|
+
When *allow_dotdot* is False (the default — the base is a script-dir guess),
|
|
28
|
+
``..`` segments are rejected to prevent traversal outside the project tree.
|
|
29
|
+
When True (the base is a tracked var_bases entry — a known directory),
|
|
30
|
+
``..`` is permitted and the caller normalises with ``os.path.normpath``.
|
|
31
|
+
"""
|
|
32
|
+
suffix = _BASH_LEADING_EXPANSION.sub("", raw, count=1).lstrip("/")
|
|
33
|
+
if not suffix or "$" in suffix:
|
|
34
|
+
return None
|
|
35
|
+
if not allow_dotdot and ".." in suffix.split("/"):
|
|
36
|
+
return None
|
|
37
|
+
return suffix
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _within_tree(ceiling: Path, target: Path) -> bool:
|
|
41
|
+
"""True if *target* is *ceiling* or lives beneath it, compared lexically
|
|
42
|
+
(normpath, no filesystem access).
|
|
43
|
+
|
|
44
|
+
A ``source`` path built with ``..`` (the ``$VAR/../lib`` idiom, #2596 form 4,
|
|
45
|
+
or a ``$(dirname …)`` prefix) must not be allowed to walk up to an arbitrary
|
|
46
|
+
host path: a corpus is attacker-controllable, and both the ``is_file()``
|
|
47
|
+
existence probe and the recorded absolute ``target_file`` on the emitted edge
|
|
48
|
+
are a corpus-side information leak (``source "$VAR/../../../../etc/passwd"``).
|
|
49
|
+
Callers gate the probe/emit on this so a target that escapes the allowed tree
|
|
50
|
+
is dropped before it is ever stat-ed. Defense in depth only:
|
|
51
|
+
``resolve_bash_source_edges`` independently keeps a *resolved* edge only when
|
|
52
|
+
the target is itself a scanned corpus file."""
|
|
53
|
+
c = os.path.normpath(str(ceiling))
|
|
54
|
+
t = os.path.normpath(str(target))
|
|
55
|
+
return t == c or t.startswith(c + os.sep)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Recognise ``$(dirname "$VAR")`` (or ``$(dirname "${VAR}")``) at the start of
|
|
59
|
+
# a ``source`` argument, capturing the variable name so the source resolver
|
|
60
|
+
# can treat the whole construct as ``var_bases[VAR].parent`` (#2596 form 3).
|
|
61
|
+
_BASH_SOURCE_DIRNAME = re.compile(
|
|
62
|
+
r'\$\(dirname\s+"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"\)'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Name of the leading variable of a `source` argument: `${ROOT}/lib/x.sh` -> ROOT.
|
|
67
|
+
_BASH_LEADING_VAR = re.compile(
|
|
68
|
+
r"^\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}|^\$([A-Za-z_][A-Za-z0-9_]*)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# The `$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)` idiom, capturing the
|
|
72
|
+
# trailing `/..` hops applied to the dirname result.
|
|
73
|
+
_BASH_DIRNAME_IDIOM = re.compile(r"dirname[^)]*\)((?:/\.\.)*)")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bash_assignment_base(value: str, script_dir: Path) -> Path | None:
|
|
77
|
+
"""Resolve a top-level assignment's value to a directory, or None if untracked.
|
|
78
|
+
|
|
79
|
+
Covers the two forms that make a ``source "${VAR}/lib/x.sh"`` target knowable
|
|
80
|
+
statically (#2172):
|
|
81
|
+
|
|
82
|
+
* the script-dir idiom ``"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"``,
|
|
83
|
+
including any number of trailing ``/..`` hops (and the ``$0`` spelling)
|
|
84
|
+
* a literal path, absolute or relative to the script's own directory
|
|
85
|
+
|
|
86
|
+
Anything else -- a value built from other variables, or command substitution
|
|
87
|
+
we do not model -- is left untracked so the caller keeps its previous
|
|
88
|
+
script-dir guess rather than binding somewhere invented.
|
|
89
|
+
"""
|
|
90
|
+
val = value.strip().strip("'\"")
|
|
91
|
+
if not val:
|
|
92
|
+
return None
|
|
93
|
+
if "dirname" in val and ("BASH_SOURCE" in val or "$0" in val):
|
|
94
|
+
m = _BASH_DIRNAME_IDIOM.search(val)
|
|
95
|
+
base = script_dir
|
|
96
|
+
for _ in range((m.group(1).count("..") if m else 0)):
|
|
97
|
+
base = base.parent
|
|
98
|
+
return base
|
|
99
|
+
if "$" in val or "`" in val:
|
|
100
|
+
return None
|
|
101
|
+
candidate = Path(val)
|
|
102
|
+
return candidate if candidate.is_absolute() else script_dir / candidate
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_bash(path: Path) -> dict:
|
|
106
|
+
"""Extract functions, source imports, and cross-function calls from a .sh file."""
|
|
107
|
+
try:
|
|
108
|
+
import tree_sitter_bash as tsbash
|
|
109
|
+
from tree_sitter import Language, Parser
|
|
110
|
+
except ImportError:
|
|
111
|
+
return {"nodes": [], "edges": [], "error": "tree-sitter-bash not installed"}
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
language = Language(tsbash.language())
|
|
115
|
+
parser = Parser(language)
|
|
116
|
+
source = path.read_bytes()
|
|
117
|
+
tree = parser.parse(source)
|
|
118
|
+
root = tree.root_node
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return {"nodes": [], "edges": [], "error": str(e)}
|
|
121
|
+
|
|
122
|
+
stem = _file_stem(path)
|
|
123
|
+
str_path = str(path)
|
|
124
|
+
nodes: list[dict] = []
|
|
125
|
+
edges: list[dict] = []
|
|
126
|
+
# Cross-file resolution scaffolding consumed by resolve_bash_source_edges in
|
|
127
|
+
# the extract pipeline: `bash_sources` records which files this one `source`s,
|
|
128
|
+
# `raw_calls` records calls whose callee isn't defined in this file (candidate
|
|
129
|
+
# calls into a sourced library). The extractor sees one file at a time and so
|
|
130
|
+
# can't resolve these itself (#2141).
|
|
131
|
+
raw_calls: list[dict] = []
|
|
132
|
+
bash_sources: list[dict] = []
|
|
133
|
+
raw_seen: set[tuple[str, str]] = set()
|
|
134
|
+
seen_ids: set[str] = set()
|
|
135
|
+
function_bodies: list[tuple[str, Any]] = []
|
|
136
|
+
defined_functions: set[str] = set()
|
|
137
|
+
|
|
138
|
+
from graphify.security import sanitize_metadata # module-level cached import
|
|
139
|
+
|
|
140
|
+
def add_node(nid: str, label: str, line: int, kind: str = "code") -> None:
|
|
141
|
+
if nid and nid not in seen_ids:
|
|
142
|
+
seen_ids.add(nid)
|
|
143
|
+
nodes.append({"id": nid, "label": label, "file_type": "code",
|
|
144
|
+
"source_file": str_path, "source_location": f"L{line}",
|
|
145
|
+
"metadata": sanitize_metadata({"language": "bash", "kind": kind})}) # noqa: E501
|
|
146
|
+
|
|
147
|
+
def add_edge(src: str, tgt: str, relation: str, line: int,
|
|
148
|
+
confidence: str = "EXTRACTED", weight: float = 1.0,
|
|
149
|
+
context: str | None = None,
|
|
150
|
+
target_file: str | None = None) -> None:
|
|
151
|
+
if not src or not tgt or src == tgt:
|
|
152
|
+
return
|
|
153
|
+
edge = {"source": src, "target": tgt, "relation": relation,
|
|
154
|
+
"confidence": confidence, "source_file": str_path,
|
|
155
|
+
"source_location": f"L{line}", "weight": weight}
|
|
156
|
+
if context:
|
|
157
|
+
edge["context"] = context
|
|
158
|
+
# Transient resolved-target hint (#1814/#2169), mirroring the Python/
|
|
159
|
+
# markdown extractors: lets the extract() id-remap pass canonicalize a
|
|
160
|
+
# target minted from an absolute path even when the target file is not
|
|
161
|
+
# in the batch (incremental run) or lives out of root (#2243). Popped
|
|
162
|
+
# before anything persists, never shipped into graph.json.
|
|
163
|
+
if target_file is not None:
|
|
164
|
+
edge["target_file"] = target_file
|
|
165
|
+
edges.append(edge)
|
|
166
|
+
|
|
167
|
+
file_nid = _make_id(str(path))
|
|
168
|
+
# file_nid is fully path-derived and never produced by _make_id(stem, func_name),
|
|
169
|
+
# so appending "__entry" guarantees a distinct ID from any function node.
|
|
170
|
+
entry_nid = file_nid + "__entry"
|
|
171
|
+
add_node(file_nid, path.name, 1, kind="file")
|
|
172
|
+
add_node(entry_nid, f"{path.name} script", 1, kind="bash_entrypoint")
|
|
173
|
+
add_edge(file_nid, entry_nid, "contains", 1)
|
|
174
|
+
|
|
175
|
+
_BASH_SOURCE_COMMANDS = frozenset({"source", "."})
|
|
176
|
+
_BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"})
|
|
177
|
+
# Parent node types that mean a contained command is part of a substitution
|
|
178
|
+
# or expansion, not a real function call. Token-level filtering misses
|
|
179
|
+
# these because `$(build)` exposes `build` as a child command whose name
|
|
180
|
+
# token has no metacharacters — only the parent does.
|
|
181
|
+
_BASH_EXPANSION_PARENTS = frozenset({
|
|
182
|
+
"command_substitution",
|
|
183
|
+
"process_substitution",
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
def text(node) -> str:
|
|
187
|
+
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
188
|
+
|
|
189
|
+
def is_inside_expansion(node) -> bool:
|
|
190
|
+
parent = node.parent
|
|
191
|
+
while parent is not None:
|
|
192
|
+
if parent.type in _BASH_EXPANSION_PARENTS:
|
|
193
|
+
return True
|
|
194
|
+
parent = parent.parent
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def _bash_call_in_command_allowed(cmd_node) -> bool:
|
|
198
|
+
"""Whether a command node should emit call edges.
|
|
199
|
+
|
|
200
|
+
Bare ``$(fn)`` at script level and process substitutions stay suppressed
|
|
201
|
+
(#2141). Value capture via ``x=$(fn)`` is a real invocation (#2978).
|
|
202
|
+
"""
|
|
203
|
+
parent = cmd_node.parent
|
|
204
|
+
saw_command_substitution = False
|
|
205
|
+
while parent is not None:
|
|
206
|
+
if parent.type == "process_substitution":
|
|
207
|
+
return False
|
|
208
|
+
if parent.type == "command_substitution":
|
|
209
|
+
saw_command_substitution = True
|
|
210
|
+
if parent.type == "variable_assignment" and saw_command_substitution:
|
|
211
|
+
return True
|
|
212
|
+
parent = parent.parent
|
|
213
|
+
return not saw_command_substitution
|
|
214
|
+
|
|
215
|
+
def literal(node) -> str | None:
|
|
216
|
+
# Token-level filter: rejects names containing shell metacharacters.
|
|
217
|
+
# Combined with `is_inside_expansion` for parent-context rejection.
|
|
218
|
+
raw = text(node).strip()
|
|
219
|
+
if not raw:
|
|
220
|
+
return None
|
|
221
|
+
if raw[0:1] in {"'", '"'} and raw[-1:] == raw[0]:
|
|
222
|
+
raw = raw[1:-1]
|
|
223
|
+
if any(token in raw for token in ("$", "`", "$(", "<(", ">", "|", ";", "&")):
|
|
224
|
+
return None
|
|
225
|
+
return raw
|
|
226
|
+
|
|
227
|
+
def _bash_func_name(node) -> str | None:
|
|
228
|
+
"""Get the name from a function_definition node."""
|
|
229
|
+
# bash grammar: function_definition has a word child (the name)
|
|
230
|
+
for child in node.children:
|
|
231
|
+
if child.type == "word":
|
|
232
|
+
return literal(child)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def walk_calls(body_node, func_nid: str, seen_calls: set) -> None:
|
|
236
|
+
if body_node is None:
|
|
237
|
+
return
|
|
238
|
+
for child in body_node.children:
|
|
239
|
+
if child.type == "function_definition":
|
|
240
|
+
# Skip nested function definitions — their bodies are walked
|
|
241
|
+
# separately, so we don't attribute their calls to the
|
|
242
|
+
# enclosing scope.
|
|
243
|
+
continue
|
|
244
|
+
if child.type == "command" and _bash_call_in_command_allowed(child):
|
|
245
|
+
cmd_name_node = child.child_by_field_name("name")
|
|
246
|
+
if cmd_name_node is None and child.children:
|
|
247
|
+
cmd_name_node = child.children[0]
|
|
248
|
+
if cmd_name_node:
|
|
249
|
+
name = literal(cmd_name_node)
|
|
250
|
+
# Defined-functions wins. Skip-lists for external commands
|
|
251
|
+
# would create false negatives when a user defines a
|
|
252
|
+
# function shadowing an external (`install`, `find`, etc.).
|
|
253
|
+
if name and name in defined_functions:
|
|
254
|
+
tgt = _make_id(stem, name)
|
|
255
|
+
key = (func_nid, tgt)
|
|
256
|
+
if tgt and key not in seen_calls:
|
|
257
|
+
seen_calls.add(key)
|
|
258
|
+
add_edge(func_nid, tgt, "calls",
|
|
259
|
+
child.start_point[0] + 1,
|
|
260
|
+
confidence="EXTRACTED", context="call")
|
|
261
|
+
elif (name and name not in _BASH_SOURCE_COMMANDS
|
|
262
|
+
and name not in _BASH_SCRIPT_RUNNERS):
|
|
263
|
+
# Callee isn't defined in this file — it may be a function
|
|
264
|
+
# from a sourced library. Record an unresolved raw_call for
|
|
265
|
+
# resolve_bash_source_edges to bind against the files this
|
|
266
|
+
# script `source`s. A callee that is not a sourced function
|
|
267
|
+
# (a genuine external command) matches nothing there and
|
|
268
|
+
# yields no edge, so this can't over-connect the graph (#2141).
|
|
269
|
+
raw_key = (func_nid, name)
|
|
270
|
+
if raw_key not in raw_seen:
|
|
271
|
+
raw_seen.add(raw_key)
|
|
272
|
+
raw_calls.append({
|
|
273
|
+
"language": "bash",
|
|
274
|
+
"callee": name,
|
|
275
|
+
"caller_nid": func_nid,
|
|
276
|
+
"source_file": str_path,
|
|
277
|
+
"source_location": f"L{child.start_point[0] + 1}",
|
|
278
|
+
})
|
|
279
|
+
walk_calls(child, func_nid, seen_calls)
|
|
280
|
+
|
|
281
|
+
def walk(node, parent_nid: str) -> None:
|
|
282
|
+
t = node.type
|
|
283
|
+
if t == "function_definition":
|
|
284
|
+
name = _bash_func_name(node)
|
|
285
|
+
if name:
|
|
286
|
+
fn_nid = _make_id(stem, name)
|
|
287
|
+
line = node.start_point[0] + 1
|
|
288
|
+
add_node(fn_nid, f"{name}()", line, kind="bash_function")
|
|
289
|
+
add_edge(parent_nid, fn_nid, "defines", line)
|
|
290
|
+
defined_functions.add(name)
|
|
291
|
+
# find the compound_statement body
|
|
292
|
+
body = None
|
|
293
|
+
for child in node.children:
|
|
294
|
+
if child.type == "compound_statement":
|
|
295
|
+
body = child
|
|
296
|
+
break
|
|
297
|
+
function_bodies.append((fn_nid, body))
|
|
298
|
+
# Recurse into the body so nested function definitions are discovered
|
|
299
|
+
# and added to function_bodies for the second-pass walk_calls.
|
|
300
|
+
if body is not None:
|
|
301
|
+
walk(body, fn_nid)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if t == "command":
|
|
305
|
+
if is_inside_expansion(node):
|
|
306
|
+
return
|
|
307
|
+
cmd_name_node = node.child_by_field_name("name")
|
|
308
|
+
if cmd_name_node is None and node.children:
|
|
309
|
+
cmd_name_node = node.children[0]
|
|
310
|
+
if cmd_name_node:
|
|
311
|
+
cmd = literal(cmd_name_node)
|
|
312
|
+
args = [c for c in node.children
|
|
313
|
+
if c.type in ("word", "string", "concatenation")
|
|
314
|
+
and c != cmd_name_node]
|
|
315
|
+
if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions:
|
|
316
|
+
# find the path argument (first word after command name)
|
|
317
|
+
if args:
|
|
318
|
+
raw = _read_text(args[0], source).strip().strip("'\"")
|
|
319
|
+
line = node.start_point[0] + 1
|
|
320
|
+
if raw.startswith((".", "/")):
|
|
321
|
+
resolved = (path.parent / raw).resolve()
|
|
322
|
+
# Only emit the edge if the target actually exists on
|
|
323
|
+
# disk — prevents graph pollution from crafted paths
|
|
324
|
+
# like `source ../../etc/passwd` that traverse outside
|
|
325
|
+
# the project tree (B-1).
|
|
326
|
+
if resolved.exists():
|
|
327
|
+
tgt_nid = _make_id(str(resolved))
|
|
328
|
+
add_edge(file_nid, tgt_nid, "imports_from", line,
|
|
329
|
+
context="import",
|
|
330
|
+
target_file=str(resolved))
|
|
331
|
+
# Record the sourced file so resolve_bash_source_edges
|
|
332
|
+
# can bind calls into its functions (#2141). Gated on
|
|
333
|
+
# existence like the edge above, so crafted traversal
|
|
334
|
+
# paths never enter the resolver's data.
|
|
335
|
+
bash_sources.append({
|
|
336
|
+
"target_path": raw,
|
|
337
|
+
"source_file": str_path,
|
|
338
|
+
"source_location": f"L{line}",
|
|
339
|
+
})
|
|
340
|
+
elif "$" in raw:
|
|
341
|
+
# Variable-built path, e.g. the ubiquitous
|
|
342
|
+
# `source "${BENCH_DIR}/lib/x.sh"` idiom. The raw text
|
|
343
|
+
# bakes the unexpanded `${VAR}` into the id, which
|
|
344
|
+
# matches no node and is dropped as a dangling edge
|
|
345
|
+
# (#2079). Strip the leading expansion(s) and resolve
|
|
346
|
+
# the literal suffix against the script's own dir;
|
|
347
|
+
# emit INFERRED (the expansion can't be proven
|
|
348
|
+
# statically) only when it resolves to a real file,
|
|
349
|
+
# never a dead id.
|
|
350
|
+
|
|
351
|
+
# Form 3 (#2596): `source "$(dirname "$VAR")/lib/x.sh"`
|
|
352
|
+
# — command substitution in the source argument.
|
|
353
|
+
# Recognise the dirname idiom on the source line the
|
|
354
|
+
# same way _bash_assignment_base does on assignment
|
|
355
|
+
# lines: treat `$(dirname "$VAR")` as
|
|
356
|
+
# `var_bases[VAR].parent` (falling back to
|
|
357
|
+
# `script_dir.parent` when VAR is untracked), then
|
|
358
|
+
# resolve the literal suffix against it.
|
|
359
|
+
dirname_match = _BASH_SOURCE_DIRNAME.match(raw)
|
|
360
|
+
if dirname_match:
|
|
361
|
+
var_name = dirname_match.group(1)
|
|
362
|
+
if var_name in var_bases:
|
|
363
|
+
base = var_bases[var_name].parent
|
|
364
|
+
else:
|
|
365
|
+
# Untracked var: fall back to the script's
|
|
366
|
+
# own directory, so dirname resolves to its
|
|
367
|
+
# parent — matching the existing untracked-var
|
|
368
|
+
# fallback semantics.
|
|
369
|
+
base = path.parent.parent
|
|
370
|
+
# Strip the $(dirname ...) prefix and any leading
|
|
371
|
+
# slash to get the literal suffix.
|
|
372
|
+
suffix = raw[dirname_match.end():].lstrip("/")
|
|
373
|
+
# The base is a guessed script dir, so reject a
|
|
374
|
+
# `..` suffix outright — same policy as
|
|
375
|
+
# _bash_source_suffix(allow_dotdot=False); without
|
|
376
|
+
# it, `$(dirname "$VAR")/../../../etc/passwd`
|
|
377
|
+
# resolves and gets probed/recorded (#2596).
|
|
378
|
+
if (suffix and "$" not in suffix
|
|
379
|
+
and ".." not in suffix.split("/")):
|
|
380
|
+
resolved = (base / suffix).resolve()
|
|
381
|
+
if resolved.is_file():
|
|
382
|
+
add_edge(file_nid, _make_id(str(resolved)),
|
|
383
|
+
"imports_from", line,
|
|
384
|
+
confidence="INFERRED", context="import",
|
|
385
|
+
target_file=str(resolved))
|
|
386
|
+
bash_sources.append({
|
|
387
|
+
"target_path": str(resolved),
|
|
388
|
+
"source_file": str_path,
|
|
389
|
+
"source_location": f"L{line}",
|
|
390
|
+
})
|
|
391
|
+
else:
|
|
392
|
+
# Check if the leading variable is tracked
|
|
393
|
+
# before deciding whether to allow `..` in
|
|
394
|
+
# the suffix (Form 4, #2596). When the base
|
|
395
|
+
# is a known var_bases entry, `..` is safe.
|
|
396
|
+
var_match = _BASH_LEADING_VAR.match(raw)
|
|
397
|
+
allow_dotdot = False
|
|
398
|
+
if var_match:
|
|
399
|
+
_vn = var_match.group(1) or var_match.group(2)
|
|
400
|
+
if _vn in var_bases:
|
|
401
|
+
allow_dotdot = True
|
|
402
|
+
suffix = _bash_source_suffix(raw, allow_dotdot=allow_dotdot)
|
|
403
|
+
if suffix:
|
|
404
|
+
# Resolve against the variable's tracked base
|
|
405
|
+
# when we know it, else fall back to the
|
|
406
|
+
# script's own directory as before (#2172).
|
|
407
|
+
base = path.parent
|
|
408
|
+
if var_match:
|
|
409
|
+
var_name = var_match.group(1) or var_match.group(2)
|
|
410
|
+
if var_name in var_bases:
|
|
411
|
+
base = var_bases[var_name]
|
|
412
|
+
if var_match and var_name in var_bases:
|
|
413
|
+
resolved = Path(os.path.normpath(base / suffix))
|
|
414
|
+
# A tracked base may reach a sibling via
|
|
415
|
+
# `$VAR/../lib`, so the ceiling is one
|
|
416
|
+
# level up — but `..` must not walk past
|
|
417
|
+
# it to an arbitrary host path (#2596).
|
|
418
|
+
ceiling = base.parent
|
|
419
|
+
else:
|
|
420
|
+
resolved = (base / suffix).resolve()
|
|
421
|
+
# Untracked base: `..` was already rejected
|
|
422
|
+
# by _bash_source_suffix, so the target is
|
|
423
|
+
# under base; the gate is belt-and-braces.
|
|
424
|
+
ceiling = base
|
|
425
|
+
if _within_tree(ceiling, resolved) and resolved.is_file():
|
|
426
|
+
add_edge(file_nid, _make_id(str(resolved)),
|
|
427
|
+
"imports_from", line,
|
|
428
|
+
confidence="INFERRED", context="import",
|
|
429
|
+
target_file=str(resolved))
|
|
430
|
+
# Integration (#2141 + #2079): record
|
|
431
|
+
# the resolved sourced file so calls
|
|
432
|
+
# into its functions resolve too, not
|
|
433
|
+
# just the source edge. target_path is
|
|
434
|
+
# absolute here; resolve_bash_source_edges
|
|
435
|
+
# takes it as-is.
|
|
436
|
+
bash_sources.append({
|
|
437
|
+
"target_path": str(resolved),
|
|
438
|
+
"source_file": str_path,
|
|
439
|
+
"source_location": f"L{line}",
|
|
440
|
+
})
|
|
441
|
+
else:
|
|
442
|
+
# Bare `source lib.sh` (no leading ./ or /). Bash
|
|
443
|
+
# itself resolves such a name via $PATH at runtime,
|
|
444
|
+
# but in practice the file sits next to the script,
|
|
445
|
+
# so bind it when a sibling of that name exists
|
|
446
|
+
# (#2171). Same existence gate as the ./-prefixed
|
|
447
|
+
# branch above: a name that resolves to nothing keeps
|
|
448
|
+
# the old opaque `imports` edge and records no
|
|
449
|
+
# bash_sources entry, so nothing is fabricated.
|
|
450
|
+
sibling: Path | None = None
|
|
451
|
+
if raw:
|
|
452
|
+
try:
|
|
453
|
+
candidate = path.parent / raw
|
|
454
|
+
if candidate.is_file():
|
|
455
|
+
sibling = candidate.resolve()
|
|
456
|
+
except OSError:
|
|
457
|
+
sibling = None
|
|
458
|
+
if sibling is not None:
|
|
459
|
+
# A bare `source lib.sh` resolves via $PATH at
|
|
460
|
+
# runtime; binding it to the sibling of that name
|
|
461
|
+
# is a heuristic, so mark it INFERRED (#2171).
|
|
462
|
+
add_edge(file_nid, _make_id(str(sibling)),
|
|
463
|
+
"imports_from", line,
|
|
464
|
+
confidence="INFERRED", context="import",
|
|
465
|
+
target_file=str(sibling))
|
|
466
|
+
bash_sources.append({
|
|
467
|
+
"target_path": raw,
|
|
468
|
+
"source_file": str_path,
|
|
469
|
+
"source_location": f"L{line}",
|
|
470
|
+
})
|
|
471
|
+
else:
|
|
472
|
+
tgt_nid = _make_id(raw)
|
|
473
|
+
if tgt_nid:
|
|
474
|
+
add_edge(file_nid, tgt_nid, "imports", line,
|
|
475
|
+
context="import")
|
|
476
|
+
elif cmd not in defined_functions:
|
|
477
|
+
raw = cmd if (cmd and cmd.endswith(".sh")) else None
|
|
478
|
+
if cmd and cmd in _BASH_SCRIPT_RUNNERS and args:
|
|
479
|
+
raw = literal(args[0])
|
|
480
|
+
if raw is None:
|
|
481
|
+
# The command name is itself an expansion, e.g.
|
|
482
|
+
# `"$script_dir/x.sh" --flag`. `literal()` rejects any
|
|
483
|
+
# candidate containing "$", so `cmd` is None and neither
|
|
484
|
+
# of the two branches above can see the path. Read the
|
|
485
|
+
# node's raw text the way the `source` branch does and
|
|
486
|
+
# strip the leading ${VAR}/ segments -- the same
|
|
487
|
+
# script-dir assumption already used for `source
|
|
488
|
+
# "$DIR/lib/x.sh"` (#2079). `_bash_source_suffix`
|
|
489
|
+
# rejects a remainder that still holds an expansion or
|
|
490
|
+
# a `..` segment, and the `resolved.is_file()` gate
|
|
491
|
+
# below means a wrong script-dir guess emits nothing.
|
|
492
|
+
cand = _read_text(cmd_name_node, source).strip().strip("'\"")
|
|
493
|
+
if cand.endswith(".sh") and "$" in cand:
|
|
494
|
+
raw = _bash_source_suffix(cand) or None
|
|
495
|
+
if raw and raw.endswith(".sh"):
|
|
496
|
+
resolved = (path.parent / raw).resolve()
|
|
497
|
+
if resolved.is_file():
|
|
498
|
+
target_path = resolved
|
|
499
|
+
if not path.is_absolute():
|
|
500
|
+
try:
|
|
501
|
+
target_path = resolved.relative_to(Path.cwd().resolve())
|
|
502
|
+
except ValueError:
|
|
503
|
+
pass
|
|
504
|
+
caller_nid = entry_nid if parent_nid == file_nid else parent_nid
|
|
505
|
+
# target_file lets the extract() remap loop learn
|
|
506
|
+
# this script's canonical id even when it is not in
|
|
507
|
+
# the batch; the "__entry"-suffixed endpoint is
|
|
508
|
+
# rewritten there via the suffix-aware entry
|
|
509
|
+
# registration (#2243).
|
|
510
|
+
add_edge(caller_nid, _make_id(str(target_path)) + "__entry",
|
|
511
|
+
"calls", node.start_point[0] + 1,
|
|
512
|
+
context="script_invocation",
|
|
513
|
+
target_file=str(target_path))
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
if t == "declaration_command":
|
|
517
|
+
# export/declare/readonly VAR=value at program level
|
|
518
|
+
if node.parent and node.parent.type == "program":
|
|
519
|
+
for child in node.children:
|
|
520
|
+
if child.type == "variable_assignment":
|
|
521
|
+
var_node = child.child_by_field_name("name")
|
|
522
|
+
if var_node:
|
|
523
|
+
var = _read_text(var_node, source).strip()
|
|
524
|
+
if var:
|
|
525
|
+
var_nid = _make_id(stem, var)
|
|
526
|
+
line = child.start_point[0] + 1
|
|
527
|
+
add_node(var_nid, var, line)
|
|
528
|
+
add_edge(file_nid, var_nid, "defines", line)
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
for child in node.children:
|
|
532
|
+
walk(child, parent_nid)
|
|
533
|
+
|
|
534
|
+
# Pre-pass: collect all defined function names so the source-command handler
|
|
535
|
+
# in walk() can detect user-defined functions that shadow 'source' / '.'
|
|
536
|
+
# regardless of definition order in the file.
|
|
537
|
+
def _prescan_functions(node) -> None:
|
|
538
|
+
if node.type == "function_definition":
|
|
539
|
+
name = _bash_func_name(node)
|
|
540
|
+
if name:
|
|
541
|
+
defined_functions.add(name)
|
|
542
|
+
for child in node.children:
|
|
543
|
+
_prescan_functions(child)
|
|
544
|
+
else:
|
|
545
|
+
for child in node.children:
|
|
546
|
+
_prescan_functions(child)
|
|
547
|
+
|
|
548
|
+
_prescan_functions(root)
|
|
549
|
+
# Bases for `source "${VAR}/lib/x.sh"` resolution (#2172). #2079 always resolved
|
|
550
|
+
# the literal suffix against the script's own directory, which is right for the
|
|
551
|
+
# `DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom but wrong whenever
|
|
552
|
+
# the variable points elsewhere -- e.g. ROOT=".../scripts/.." with a same-named
|
|
553
|
+
# decoy under the script dir bound to the decoy. Track top-level assignments so
|
|
554
|
+
# the real base is used; untracked variables keep the script-dir guess.
|
|
555
|
+
var_bases: dict[str, Path] = {}
|
|
556
|
+
for _assign in root.children:
|
|
557
|
+
if _assign.type != "variable_assignment":
|
|
558
|
+
continue
|
|
559
|
+
_name_node = _assign.child_by_field_name("name")
|
|
560
|
+
_value_node = _assign.child_by_field_name("value")
|
|
561
|
+
if _name_node is None or _value_node is None:
|
|
562
|
+
continue
|
|
563
|
+
_name = _read_text(_name_node, source).strip()
|
|
564
|
+
if not _name:
|
|
565
|
+
continue
|
|
566
|
+
_base = _bash_assignment_base(_read_text(_value_node, source), path.parent)
|
|
567
|
+
if _base is not None:
|
|
568
|
+
var_bases[_name] = _base
|
|
569
|
+
|
|
570
|
+
walk(root, file_nid)
|
|
571
|
+
|
|
572
|
+
# Second pass: cross-function calls
|
|
573
|
+
top_seen: set = set()
|
|
574
|
+
walk_calls(root, entry_nid, top_seen) # top-level calls attributed to the entrypoint
|
|
575
|
+
for fn_nid, body in function_bodies:
|
|
576
|
+
walk_calls(body, fn_nid, set())
|
|
577
|
+
|
|
578
|
+
return {"nodes": nodes, "edges": edges,
|
|
579
|
+
"raw_calls": raw_calls, "bash_sources": bash_sources}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Laravel Blade template extractor. Moved verbatim from graphify/extract.py."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from graphify.extractors.base import _make_id
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_blade(path: Path) -> dict:
|
|
10
|
+
"""Extract @include, <livewire:> components, and wire:click bindings from Blade templates."""
|
|
11
|
+
import re
|
|
12
|
+
try:
|
|
13
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
14
|
+
except OSError:
|
|
15
|
+
return {"error": f"cannot read {path}"}
|
|
16
|
+
|
|
17
|
+
file_nid = _make_id(str(path))
|
|
18
|
+
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
|
|
19
|
+
"source_file": str(path), "source_location": None}]
|
|
20
|
+
edges = []
|
|
21
|
+
|
|
22
|
+
# @include('path.to.partial') or @include("path.to.partial")
|
|
23
|
+
for m in re.finditer(r"@include\(['\"]([^'\"]+)['\"]", src):
|
|
24
|
+
tgt = m.group(1).replace(".", "/")
|
|
25
|
+
tgt_nid = _make_id(tgt)
|
|
26
|
+
if tgt_nid not in {n["id"] for n in nodes}:
|
|
27
|
+
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
|
|
28
|
+
"source_file": str(path), "source_location": None})
|
|
29
|
+
edges.append({"source": file_nid, "target": tgt_nid, "relation": "includes",
|
|
30
|
+
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
|
31
|
+
"source_file": str(path), "source_location": None, "weight": 1.0})
|
|
32
|
+
|
|
33
|
+
# <livewire:component.name /> or <livewire:component.name>
|
|
34
|
+
for m in re.finditer(r"<livewire:([\w.\-]+)", src):
|
|
35
|
+
tgt_nid = _make_id(m.group(1))
|
|
36
|
+
if tgt_nid not in {n["id"] for n in nodes}:
|
|
37
|
+
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
|
|
38
|
+
"source_file": str(path), "source_location": None})
|
|
39
|
+
edges.append({"source": file_nid, "target": tgt_nid, "relation": "uses_component",
|
|
40
|
+
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
|
41
|
+
"source_file": str(path), "source_location": None, "weight": 1.0})
|
|
42
|
+
|
|
43
|
+
# wire:click="methodName"
|
|
44
|
+
for m in re.finditer(r'wire:click=["\']([^"\']+)["\']', src):
|
|
45
|
+
tgt_nid = _make_id(m.group(1))
|
|
46
|
+
if tgt_nid not in {n["id"] for n in nodes}:
|
|
47
|
+
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
|
|
48
|
+
"source_file": str(path), "source_location": None})
|
|
49
|
+
edges.append({"source": file_nid, "target": tgt_nid, "relation": "binds_method",
|
|
50
|
+
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
|
51
|
+
"source_file": str(path), "source_location": None, "weight": 1.0})
|
|
52
|
+
|
|
53
|
+
return {"nodes": nodes, "edges": edges}
|