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
graphify/paths.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Single source of truth for the graphify output-directory name.
|
|
2
|
+
|
|
3
|
+
The output directory is ``graphify-out`` by default and overridable with the
|
|
4
|
+
``GRAPHIFY_OUT`` env var (worktrees or shared-output setups, #686). It accepts a
|
|
5
|
+
relative name (``"graphify-out-feature"``) or an absolute path
|
|
6
|
+
(``"/shared/graphify-out"``).
|
|
7
|
+
|
|
8
|
+
This used to be duplicated as an identical ``_GRAPHIFY_OUT`` constant in
|
|
9
|
+
``__main__``, ``cache``, and ``watch``, while ``security`` and ``callflow_html``
|
|
10
|
+
hardcoded the literal ``"graphify-out"`` and silently ignored the override
|
|
11
|
+
(#1423). Centralising it here keeps the name in one place. The value is read
|
|
12
|
+
once at import time, matching the previous per-module constants — set
|
|
13
|
+
``GRAPHIFY_OUT`` before the process starts (the normal worktree/shared-output
|
|
14
|
+
flow) and every reader honours it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
25
|
+
|
|
26
|
+
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _atomic_replace(path: "str | Path", write_fn) -> None:
|
|
30
|
+
"""Atomically replace ``path`` with content written by ``write_fn(f)``.
|
|
31
|
+
|
|
32
|
+
Writes a temp file in the SAME directory, then ``os.replace``s it into place
|
|
33
|
+
(an atomic rename on one filesystem). A process kill (SIGKILL/Ctrl-C), OOM, or
|
|
34
|
+
ENOSPC mid-write leaves the previous file intact — the destination is
|
|
35
|
+
untouched until the rename. This is NOT a power-loss durability guarantee:
|
|
36
|
+
there is no fsync (matching the rest of the codebase), so an OS/hardware crash
|
|
37
|
+
right after the rename can still expose unflushed bytes on some filesystems.
|
|
38
|
+
The temp file is removed if the write fails.
|
|
39
|
+
|
|
40
|
+
A symlinked destination is resolved first so the write goes THROUGH the link
|
|
41
|
+
to its target (rather than replacing the link with a regular file), keeping
|
|
42
|
+
the shared-output/worktree symlink setups this module documents working.
|
|
43
|
+
"""
|
|
44
|
+
# Resolve symlinks so the temp lands on the target's filesystem (same-fs
|
|
45
|
+
# atomic rename) and the replace writes through the link, not over it.
|
|
46
|
+
real = Path(os.path.realpath(str(path)))
|
|
47
|
+
real.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
fd, tmp = tempfile.mkstemp(dir=str(real.parent), prefix=".gfy-", suffix=".tmp")
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
51
|
+
write_fn(f)
|
|
52
|
+
# mkstemp creates the temp file 0600; match the destination's existing
|
|
53
|
+
# mode (or the umask default for a new file) so an atomic replace never
|
|
54
|
+
# silently tightens a previously group/world-readable output to
|
|
55
|
+
# owner-only. Best-effort — a chmod failure must not fail the write.
|
|
56
|
+
try:
|
|
57
|
+
mode = stat.S_IMODE(os.stat(real).st_mode)
|
|
58
|
+
except OSError:
|
|
59
|
+
umask = os.umask(0)
|
|
60
|
+
os.umask(umask)
|
|
61
|
+
mode = 0o666 & ~umask
|
|
62
|
+
try:
|
|
63
|
+
os.chmod(tmp, mode)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
try:
|
|
67
|
+
os.replace(tmp, str(real))
|
|
68
|
+
except PermissionError:
|
|
69
|
+
# Windows: os.replace fails (WinError 5/32) when the destination is
|
|
70
|
+
# briefly locked by another handle (antivirus, an open reader). Fall
|
|
71
|
+
# back to copy-then-delete, matching graphify.cache's atomic writer.
|
|
72
|
+
import shutil
|
|
73
|
+
shutil.copy2(tmp, str(real))
|
|
74
|
+
os.unlink(tmp)
|
|
75
|
+
except BaseException:
|
|
76
|
+
try:
|
|
77
|
+
os.unlink(tmp)
|
|
78
|
+
except OSError:
|
|
79
|
+
# The temp was chmod'd to match the destination above, so when the
|
|
80
|
+
# destination is read-only the temp is too — and Windows refuses to
|
|
81
|
+
# unlink a read-only file. Clear the bit and retry, or every failed
|
|
82
|
+
# write leaks a `.gfy-*.tmp` into the output directory.
|
|
83
|
+
try:
|
|
84
|
+
os.chmod(tmp, stat.S_IWRITE)
|
|
85
|
+
os.unlink(tmp)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_text_atomic(path: "str | Path", text: str) -> None:
|
|
92
|
+
"""Atomically write ``text`` (UTF-8) to ``path``. See :func:`_atomic_replace`."""
|
|
93
|
+
_atomic_replace(path, lambda f: f.write(text))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_json_atomic(path: "str | Path", obj, *, indent: "int | None" = None, ensure_ascii: bool = True) -> None:
|
|
97
|
+
"""Atomically write ``obj`` as JSON to ``path``, streaming the encode into the
|
|
98
|
+
temp file rather than materializing the whole string first (matters for very
|
|
99
|
+
large graphs). ``ensure_ascii`` mirrors ``json.dump`` so callers that emit raw
|
|
100
|
+
UTF-8 (non-ASCII labels/paths) keep byte-for-byte output. See :func:`_atomic_replace`."""
|
|
101
|
+
_atomic_replace(path, lambda f: json.dump(obj, f, indent=indent, ensure_ascii=ensure_ascii))
|
|
102
|
+
|
|
103
|
+
# Directory segments that, when they appear as a whole path component, mark the
|
|
104
|
+
# whole path as a test location. Matched against path *segments* (not raw
|
|
105
|
+
# substrings) so "src/contest.py" / "latest/x.py" / "src/greatest/x.py" do NOT
|
|
106
|
+
# match — only a segment that *equals* one of these names (case-insensitively).
|
|
107
|
+
_TEST_DIR_SEGMENTS = frozenset({"tests", "test", "spec", "specs", "__tests__"})
|
|
108
|
+
|
|
109
|
+
# Filename patterns marking a file as a test, matched against the *filename*
|
|
110
|
+
# only (case-insensitive). These are conventions across ecosystems:
|
|
111
|
+
# test_*.py pytest / unittest
|
|
112
|
+
# *_test.* Go / Python / Rust
|
|
113
|
+
# *.test.* JS/TS (jest, vitest)
|
|
114
|
+
# *.spec.* / *_spec.* Jasmine / RSpec / Karma
|
|
115
|
+
# *.Tests.ps1 PowerShell Pester
|
|
116
|
+
# *Test.java / *Tests.cs (case-sensitive convention, handled below)
|
|
117
|
+
_TEST_FILENAME_PATTERNS = (
|
|
118
|
+
re.compile(r"^test_.*", re.IGNORECASE),
|
|
119
|
+
re.compile(r".*_test\..+$", re.IGNORECASE),
|
|
120
|
+
re.compile(r".*\.test\..+$", re.IGNORECASE),
|
|
121
|
+
re.compile(r".*\.spec\..+$", re.IGNORECASE),
|
|
122
|
+
re.compile(r".*_spec\..+$", re.IGNORECASE),
|
|
123
|
+
re.compile(r".*\.tests\.ps1$", re.IGNORECASE),
|
|
124
|
+
# Java `FooTest.java` / `FooTests.java`, C# `FooTests.cs` style. Require an
|
|
125
|
+
# uppercase-led `Test`/`Tests` immediately before the extension so plain
|
|
126
|
+
# words like "greatest"/"contest.cs" do not match.
|
|
127
|
+
re.compile(r".*Test\.java$"),
|
|
128
|
+
re.compile(r".*Tests\.java$"),
|
|
129
|
+
re.compile(r".*Tests\.cs$"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_test_path(path: str) -> bool:
|
|
134
|
+
"""Classify a source path as a test path (case-insensitive, segment-aware).
|
|
135
|
+
|
|
136
|
+
Shared by extract.py and symbol_resolution.py so cross-file call resolution
|
|
137
|
+
treats test mocks/stubs identically. A path is a test path when:
|
|
138
|
+
* any whole path segment equals a known test dir name
|
|
139
|
+
(``tests``/``test``/``spec``/``specs``/``__tests__``), or
|
|
140
|
+
* the filename matches a known test-file naming convention.
|
|
141
|
+
|
|
142
|
+
Conservative on purpose: matches segments/filenames, never raw substrings,
|
|
143
|
+
so ``latest.py``, ``src/contest.py`` and ``src/greatest/x.py`` are NON-test.
|
|
144
|
+
"""
|
|
145
|
+
if not path:
|
|
146
|
+
return False
|
|
147
|
+
# Accept both POSIX and Windows separators regardless of host OS so the
|
|
148
|
+
# classifier is stable across the mixed paths that flow through extraction.
|
|
149
|
+
norm = str(path).replace("\\", "/")
|
|
150
|
+
pure = PurePosixPath(norm)
|
|
151
|
+
segments = list(pure.parts)
|
|
152
|
+
# Strip a leading drive/anchor segment (e.g. "C:/") that PureWindowsPath
|
|
153
|
+
# would surface; with the manual "\\"->"/" swap above PurePosixPath keeps
|
|
154
|
+
# the path body intact, but guard against a Windows drive embedded as a
|
|
155
|
+
# segment just in case.
|
|
156
|
+
for segment in segments:
|
|
157
|
+
if segment.lower() in _TEST_DIR_SEGMENTS:
|
|
158
|
+
return True
|
|
159
|
+
# A drive-letter colon segment like "c:" is never a test dir.
|
|
160
|
+
filename = pure.name
|
|
161
|
+
if not filename:
|
|
162
|
+
return False
|
|
163
|
+
for pattern in _TEST_FILENAME_PATTERNS:
|
|
164
|
+
if pattern.match(filename):
|
|
165
|
+
return True
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _path_proximity_winner(call_site_file: str, candidate_files: dict[str, str]) -> str | None:
|
|
170
|
+
"""Pick the candidate whose source file is closest to the call site.
|
|
171
|
+
|
|
172
|
+
``candidate_files`` maps candidate id -> its source_file. Returns a single
|
|
173
|
+
winning candidate id, or ``None`` when no proximity tier yields a unique
|
|
174
|
+
winner. Tiers, in order:
|
|
175
|
+
|
|
176
|
+
1. same file as the call site,
|
|
177
|
+
2. same directory,
|
|
178
|
+
3. longest common path-prefix (must be a strict, unique maximum).
|
|
179
|
+
|
|
180
|
+
Used only as a secondary tie-break after the test/non-test filter, so the
|
|
181
|
+
god-node guard still holds when proximity is genuinely ambiguous.
|
|
182
|
+
"""
|
|
183
|
+
if not call_site_file:
|
|
184
|
+
return None
|
|
185
|
+
call_norm = str(call_site_file).replace("\\", "/")
|
|
186
|
+
call_dir = PurePosixPath(call_norm).parent
|
|
187
|
+
|
|
188
|
+
# Tier 1: exact same file.
|
|
189
|
+
same_file = [cid for cid, f in candidate_files.items()
|
|
190
|
+
if str(f).replace("\\", "/") == call_norm]
|
|
191
|
+
if len(same_file) == 1:
|
|
192
|
+
return same_file[0]
|
|
193
|
+
if len(same_file) > 1:
|
|
194
|
+
return None # genuinely ambiguous within one file; bail
|
|
195
|
+
|
|
196
|
+
# Tier 2: same directory.
|
|
197
|
+
same_dir = [cid for cid, f in candidate_files.items()
|
|
198
|
+
if PurePosixPath(str(f).replace("\\", "/")).parent == call_dir]
|
|
199
|
+
if len(same_dir) == 1:
|
|
200
|
+
return same_dir[0]
|
|
201
|
+
if len(same_dir) > 1:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
# Tier 3: longest common path-prefix, computed over path segments. The
|
|
205
|
+
# winner must be a strict unique maximum, else we bail (guard holds).
|
|
206
|
+
call_parts = call_dir.parts
|
|
207
|
+
|
|
208
|
+
def _common_prefix_len(f: str) -> int:
|
|
209
|
+
parts = PurePosixPath(str(f).replace("\\", "/")).parent.parts
|
|
210
|
+
n = 0
|
|
211
|
+
for a, b in zip(call_parts, parts):
|
|
212
|
+
if a != b:
|
|
213
|
+
break
|
|
214
|
+
n += 1
|
|
215
|
+
return n
|
|
216
|
+
|
|
217
|
+
scored = sorted(
|
|
218
|
+
((cid, _common_prefix_len(f)) for cid, f in candidate_files.items()),
|
|
219
|
+
key=lambda kv: kv[1],
|
|
220
|
+
reverse=True,
|
|
221
|
+
)
|
|
222
|
+
if not scored:
|
|
223
|
+
return None
|
|
224
|
+
best = scored[0][1]
|
|
225
|
+
winners = [cid for cid, score in scored if score == best]
|
|
226
|
+
if len(winners) == 1 and best > 0:
|
|
227
|
+
return winners[0]
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def disambiguate_ambiguous_candidates(
|
|
232
|
+
candidates: list[str],
|
|
233
|
+
candidate_files: dict[str, str],
|
|
234
|
+
call_site_file: str,
|
|
235
|
+
) -> str | None:
|
|
236
|
+
"""Resolve an ambiguous bare-name call to one candidate, or ``None``.
|
|
237
|
+
|
|
238
|
+
Shared god-node tie-breaker (#1553) used by both the inline cross-file call
|
|
239
|
+
pass in ``extract.py`` and ``symbol_resolution.resolve_cross_file_raw_calls``
|
|
240
|
+
so the heuristics stay aligned across languages. ``candidates`` is the list
|
|
241
|
+
of node ids sharing the callee's name; ``candidate_files`` maps each id ->
|
|
242
|
+
its source_file. Returns the surviving candidate id only when exactly one
|
|
243
|
+
survives; otherwise ``None`` (caller keeps the god-node guard / ``continue``).
|
|
244
|
+
|
|
245
|
+
Tie-breakers, in order:
|
|
246
|
+
1. NON-TEST preference. Classify the call site and each candidate as
|
|
247
|
+
test/non-test. When the call site is NON-test, drop test candidates.
|
|
248
|
+
When the call site IS a test file, prefer test-local candidates
|
|
249
|
+
(same file first, then any test candidate); fall back to the full set
|
|
250
|
+
only if no test candidate exists.
|
|
251
|
+
2. PATH PROXIMITY over whatever survived step 1.
|
|
252
|
+
"""
|
|
253
|
+
if not candidates:
|
|
254
|
+
return None
|
|
255
|
+
if len(candidates) == 1:
|
|
256
|
+
return candidates[0]
|
|
257
|
+
|
|
258
|
+
call_is_test = _is_test_path(call_site_file)
|
|
259
|
+
test_cands = [c for c in candidates if _is_test_path(candidate_files.get(c, ""))]
|
|
260
|
+
nontest_cands = [c for c in candidates if c not in set(test_cands)]
|
|
261
|
+
|
|
262
|
+
if call_is_test:
|
|
263
|
+
# Prefer a test-local definition (same file) first.
|
|
264
|
+
call_norm = str(call_site_file).replace("\\", "/")
|
|
265
|
+
same_file_test = [
|
|
266
|
+
c for c in test_cands
|
|
267
|
+
if str(candidate_files.get(c, "")).replace("\\", "/") == call_norm
|
|
268
|
+
]
|
|
269
|
+
if len(same_file_test) == 1:
|
|
270
|
+
return same_file_test[0]
|
|
271
|
+
if test_cands:
|
|
272
|
+
survivors = test_cands
|
|
273
|
+
else:
|
|
274
|
+
survivors = nontest_cands or candidates
|
|
275
|
+
else:
|
|
276
|
+
# Non-test call site: drop test mocks/stubs entirely.
|
|
277
|
+
survivors = nontest_cands
|
|
278
|
+
|
|
279
|
+
if len(survivors) == 1:
|
|
280
|
+
return survivors[0]
|
|
281
|
+
if not survivors:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
# Step 2: path proximity over the survivors.
|
|
285
|
+
return _path_proximity_winner(
|
|
286
|
+
call_site_file,
|
|
287
|
+
{c: candidate_files.get(c, "") for c in survivors},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by path
|
|
291
|
+
# guards that walk parents looking for the output directory by name.
|
|
292
|
+
GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def out_path(*parts: str) -> Path:
|
|
296
|
+
"""A path inside the configured output dir, e.g. ``out_path("cache")``.
|
|
297
|
+
|
|
298
|
+
``Path(GRAPHIFY_OUT) / ...`` resolves correctly for both a relative name
|
|
299
|
+
("graphify-out") and an absolute override ("/shared/graphify-out").
|
|
300
|
+
"""
|
|
301
|
+
return Path(GRAPHIFY_OUT, *parts)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def default_graph_json() -> str:
|
|
305
|
+
"""Default ``graph.json`` path under the configured output dir.
|
|
306
|
+
|
|
307
|
+
The package-wide fallback used by serve/build/benchmark/prs and the CLI read
|
|
308
|
+
commands so a ``GRAPHIFY_OUT`` override is honoured everywhere, not just where
|
|
309
|
+
the path is passed explicitly (#1423).
|
|
310
|
+
"""
|
|
311
|
+
return str(out_path("graph.json"))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def is_absolute_any_platform(p: "str | Path | None") -> bool:
|
|
315
|
+
"""Whether *p* is absolute under POSIX **or** Windows rules.
|
|
316
|
+
|
|
317
|
+
``Path.is_absolute()`` and ``os.path.isabs()`` answer for the HOST os only,
|
|
318
|
+
which is the wrong question for a path that was *stored* — a ``source_file``
|
|
319
|
+
in ``graph.json``, a ``prune_sources`` entry, a cache key. Those travel
|
|
320
|
+
between machines (build in Docker/CI, update on a Windows workstation, or
|
|
321
|
+
the reverse), so the host's rules do not describe the string in hand:
|
|
322
|
+
|
|
323
|
+
- On Windows, ``WindowsPath("/home/ci/repo/docs/a.md").is_absolute()`` is
|
|
324
|
+
False — no drive letter — so a Linux-built graph's absolute paths read as
|
|
325
|
+
relative and get baked into node IDs or joined under the scan root (#2618).
|
|
326
|
+
- On POSIX, ``PosixPath("C:/Users/u/a.md").is_absolute()`` is False for the
|
|
327
|
+
mirror-image reason (#2197, #1789).
|
|
328
|
+
|
|
329
|
+
``os.path.isabs`` is additionally not stable across supported interpreters:
|
|
330
|
+
Python 3.13 changed ``ntpath.isabs`` so a path starting with a single slash
|
|
331
|
+
is no longer absolute, where 3.10–3.12 said it was. The project supports
|
|
332
|
+
>=3.10, so a guard written on it silently means different things per version.
|
|
333
|
+
|
|
334
|
+
Answering for both platforms is the conservative choice for stored paths:
|
|
335
|
+
treating a path as absolute at worst declines to relativize it (the string is
|
|
336
|
+
kept as-is), whereas treating an absolute path as relative corrupts identity.
|
|
337
|
+
Covers drive-letter, UNC, and POSIX-root forms with either separator.
|
|
338
|
+
|
|
339
|
+
NOTE: this is for STORED/portable paths. Code resolving a path against the
|
|
340
|
+
real local filesystem (``cli``, ``detect``, ``hooks``) must keep using
|
|
341
|
+
``Path.is_absolute()`` — there the host's rules are exactly right.
|
|
342
|
+
"""
|
|
343
|
+
if not p:
|
|
344
|
+
return False
|
|
345
|
+
s = str(p)
|
|
346
|
+
return PurePosixPath(s).is_absolute() or PureWindowsPath(s).is_absolute()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# Legacy Windows path ceiling. Unless long-path support is enabled *and* every
|
|
350
|
+
# consumer opts in, the ENTIRE path — drive, directories, filename, and the
|
|
351
|
+
# terminating NUL — must fit in MAX_PATH (260) characters, so the usable budget
|
|
352
|
+
# is 259. POSIX has no equivalent whole-path ceiling in practice; its limit is
|
|
353
|
+
# per-component (NAME_MAX, conventionally 255 bytes).
|
|
354
|
+
_WINDOWS_MAX_PATH = 260
|
|
355
|
+
|
|
356
|
+
# Floor for the stem budget below. A directory deep enough to push the budget
|
|
357
|
+
# under this cannot host readable filenames anyway; keep enough room for
|
|
358
|
+
# _cap_filename's "_" + 8-char digest so a truncated stem stays collision-safe
|
|
359
|
+
# and deterministic rather than degenerating into a bare prefix.
|
|
360
|
+
_MIN_STEM_BUDGET = 16
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def stem_filename_budget(output_dir: "str | Path", *, reserve: int = 0, limit: int = 200) -> int:
|
|
364
|
+
"""Largest filename stem an exporter may write directly into ``output_dir``.
|
|
365
|
+
|
|
366
|
+
Exporters cap note/article filenames so they stay under the filesystem's
|
|
367
|
+
per-component limit (conventionally NAME_MAX=255 bytes, hence the 200
|
|
368
|
+
default). That is the right question on POSIX and the wrong one on Windows,
|
|
369
|
+
where the constraint is on the WHOLE path, not the component: a 200-char
|
|
370
|
+
stem under a perfectly ordinary vault directory such as
|
|
371
|
+
``C:\\Users\\me\\projects\\svc\\graphify-out\\obsidian`` exceeds MAX_PATH and
|
|
372
|
+
the write dies with ``FileNotFoundError``, aborting the export mid-vault.
|
|
373
|
+
|
|
374
|
+
Returns ``limit`` unchanged on POSIX, so existing output is byte-for-byte
|
|
375
|
+
stable there. On Windows it returns the smaller of ``limit`` and whatever
|
|
376
|
+
still fits inside MAX_PATH once ``output_dir``, the separator, ``reserve``
|
|
377
|
+
(room for caller-added prefixes/collision suffixes) and the ``.md``
|
|
378
|
+
extension are accounted for.
|
|
379
|
+
|
|
380
|
+
The budget is a CHARACTER count, but callers that cap UTF-8 BYTES may pass
|
|
381
|
+
it straight through: a string's UTF-8 length is never below its character
|
|
382
|
+
length, so a byte-capped stem always satisfies the character ceiling too.
|
|
383
|
+
"""
|
|
384
|
+
if os.name != "nt":
|
|
385
|
+
return limit
|
|
386
|
+
try:
|
|
387
|
+
base = os.path.abspath(str(output_dir))
|
|
388
|
+
except (OSError, ValueError):
|
|
389
|
+
return limit
|
|
390
|
+
# An extended-length path ("\\?\C:\...", "\\?\UNC\...") opts out of MAX_PATH
|
|
391
|
+
# entirely, so nothing needs shrinking.
|
|
392
|
+
if base.startswith("\\\\?\\"):
|
|
393
|
+
return limit
|
|
394
|
+
budget = (_WINDOWS_MAX_PATH - 1) - len(base) - len(os.sep) - reserve - len(".md")
|
|
395
|
+
return max(_MIN_STEM_BUDGET, min(limit, budget))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def nfc(s: str) -> str:
|
|
399
|
+
"""NFC-normalize a path string.
|
|
400
|
+
|
|
401
|
+
macOS (HFS+/APFS) reports filenames in NFD while manifests, graph
|
|
402
|
+
``source_file`` entries and user input are typically NFC. Comparing raw
|
|
403
|
+
strings makes the same file look like two different paths, so any path
|
|
404
|
+
membership test must normalize BOTH sides (#2210, #2221/#2224).
|
|
405
|
+
"""
|
|
406
|
+
import unicodedata
|
|
407
|
+
return unicodedata.normalize("NFC", s)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def load_node_link_graph(path_or_data):
|
|
411
|
+
"""Load a graphify graph.json into a networkx graph, accepting both writers.
|
|
412
|
+
|
|
413
|
+
The clustered writer stores edges under ``links`` (networkx's node-link
|
|
414
|
+
default); the raw ``--no-cluster`` writer stores them under ``edges``.
|
|
415
|
+
Consumers that call ``node_link_graph(data, edges="links")`` directly
|
|
416
|
+
raise ``KeyError: 'links'`` on a raw graph (#2212) — the ``except
|
|
417
|
+
TypeError`` fallback only covers old networkx without the ``edges``
|
|
418
|
+
kwarg, not the missing key. Normalize before parsing, same idiom as
|
|
419
|
+
affected.py/serve.py.
|
|
420
|
+
|
|
421
|
+
Accepts a path (size-cap-checked via the security module, then parsed)
|
|
422
|
+
or an already-parsed dict (no size check — the caller owns any cap).
|
|
423
|
+
"""
|
|
424
|
+
from networkx.readwrite import json_graph
|
|
425
|
+
data = path_or_data
|
|
426
|
+
if not isinstance(data, dict):
|
|
427
|
+
p = Path(data)
|
|
428
|
+
from graphify.security import check_graph_file_size_cap # lazy: security imports paths
|
|
429
|
+
check_graph_file_size_cap(p)
|
|
430
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
431
|
+
if isinstance(data, dict) and "links" not in data and "edges" in data:
|
|
432
|
+
data = dict(data, links=data["edges"])
|
|
433
|
+
try:
|
|
434
|
+
return json_graph.node_link_graph(data, edges="links")
|
|
435
|
+
except TypeError: # networkx too old for the edges kwarg; default is "links"
|
|
436
|
+
return json_graph.node_link_graph(data)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path, PurePosixPath
|
|
3
|
+
from graphify.extract import extract_sql
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _quote_ident(name: str) -> str:
|
|
7
|
+
"""Double-quote a PostgreSQL identifier, escaping embedded double-quotes."""
|
|
8
|
+
return '"' + name.replace('"', '""') + '"'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def introspect_postgres(dsn: str | None = None) -> dict:
|
|
12
|
+
"""Connect to PostgreSQL, reconstruct DDL, and extract via extract_sql()."""
|
|
13
|
+
try:
|
|
14
|
+
import psycopg
|
|
15
|
+
except ModuleNotFoundError:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"psycopg is required for --postgres. "
|
|
18
|
+
"Install with: pip install 'graphifyy[postgres]'"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
conn = psycopg.connect(dsn or "") # empty string = PG* env vars
|
|
23
|
+
except psycopg.OperationalError as exc:
|
|
24
|
+
# Sanitize: strip the DSN/credentials that psycopg may embed in the
|
|
25
|
+
# OperationalError message (e.g. "connection to server … failed: …\nDETAIL: …")
|
|
26
|
+
msg = str(exc).split("\n")[0]
|
|
27
|
+
raise ConnectionError(f"could not connect to PostgreSQL: {msg}") from None
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE")
|
|
31
|
+
|
|
32
|
+
# 1. Query tables
|
|
33
|
+
with conn.cursor() as cur:
|
|
34
|
+
cur.execute("""
|
|
35
|
+
SELECT table_schema, table_name, table_type
|
|
36
|
+
FROM information_schema.tables
|
|
37
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
38
|
+
ORDER BY table_schema, table_name;
|
|
39
|
+
""")
|
|
40
|
+
tables = cur.fetchall()
|
|
41
|
+
|
|
42
|
+
# 2. Query views
|
|
43
|
+
cur.execute("""
|
|
44
|
+
SELECT table_schema, table_name, view_definition
|
|
45
|
+
FROM information_schema.views
|
|
46
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
47
|
+
ORDER BY table_schema, table_name;
|
|
48
|
+
""")
|
|
49
|
+
views = cur.fetchall()
|
|
50
|
+
|
|
51
|
+
# 3. Query routines (functions/procedures), including language
|
|
52
|
+
cur.execute("""
|
|
53
|
+
SELECT routine_schema, routine_name, routine_type,
|
|
54
|
+
routine_definition, external_language
|
|
55
|
+
FROM information_schema.routines
|
|
56
|
+
WHERE routine_schema NOT IN ('pg_catalog', 'information_schema')
|
|
57
|
+
ORDER BY routine_schema, routine_name;
|
|
58
|
+
""")
|
|
59
|
+
routines = cur.fetchall()
|
|
60
|
+
|
|
61
|
+
# 4. Query foreign keys — grouped by constraint to handle composites.
|
|
62
|
+
# Read pg_catalog.pg_constraint, NOT information_schema.referential_
|
|
63
|
+
# constraints: that view only shows constraints where the current
|
|
64
|
+
# user has WRITE access to the referencing table (owner or a
|
|
65
|
+
# privilege other than SELECT), so a read-only introspection role
|
|
66
|
+
# sees zero FK rows while tables/views/routines all appear — the
|
|
67
|
+
# graph then silently loses every 'references' edge (#1746).
|
|
68
|
+
# pg_constraint is not privilege-filtered. It also keys constraints
|
|
69
|
+
# by oid rather than by name (constraint names are only unique per
|
|
70
|
+
# table, so the old name-based key_column_usage joins could
|
|
71
|
+
# cross-match same-named constraints on sibling tables).
|
|
72
|
+
cur.execute("""
|
|
73
|
+
SELECT
|
|
74
|
+
con.conname AS constraint_name,
|
|
75
|
+
ns.nspname AS table_schema,
|
|
76
|
+
rel.relname AS table_name,
|
|
77
|
+
(SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
|
|
78
|
+
FROM UNNEST(con.conkey) WITH ORDINALITY AS k(attnum, ord)
|
|
79
|
+
JOIN pg_catalog.pg_attribute att
|
|
80
|
+
ON att.attrelid = con.conrelid AND att.attnum = k.attnum
|
|
81
|
+
) AS columns,
|
|
82
|
+
fns.nspname AS foreign_table_schema,
|
|
83
|
+
frel.relname AS foreign_table_name,
|
|
84
|
+
(SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
|
|
85
|
+
FROM UNNEST(con.confkey) WITH ORDINALITY AS k(attnum, ord)
|
|
86
|
+
JOIN pg_catalog.pg_attribute att
|
|
87
|
+
ON att.attrelid = con.confrelid AND att.attnum = k.attnum
|
|
88
|
+
) AS foreign_columns
|
|
89
|
+
FROM pg_catalog.pg_constraint con
|
|
90
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
91
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
92
|
+
JOIN pg_catalog.pg_class frel ON frel.oid = con.confrelid
|
|
93
|
+
JOIN pg_catalog.pg_namespace fns ON fns.oid = frel.relnamespace
|
|
94
|
+
WHERE con.contype = 'f'
|
|
95
|
+
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
96
|
+
ORDER BY ns.nspname, rel.relname, con.conname;
|
|
97
|
+
""")
|
|
98
|
+
fks = cur.fetchall()
|
|
99
|
+
finally:
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
ddl = []
|
|
103
|
+
|
|
104
|
+
# Tables — quote identifiers to handle reserved words, hyphens, mixed-case
|
|
105
|
+
for schema, name, ttype in tables:
|
|
106
|
+
if ttype == "BASE TABLE":
|
|
107
|
+
ddl.append(f"CREATE TABLE {_quote_ident(schema)}.{_quote_ident(name)} (id INT);")
|
|
108
|
+
|
|
109
|
+
# Views — real body if available, stub if NULL (permission denied)
|
|
110
|
+
for schema, name, body in views:
|
|
111
|
+
if body:
|
|
112
|
+
ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS {body};")
|
|
113
|
+
else:
|
|
114
|
+
ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS SELECT 1;")
|
|
115
|
+
|
|
116
|
+
# FK edges — one ALTER TABLE per constraint (handles composite FKs correctly).
|
|
117
|
+
# Emitted BEFORE the function DDL: routine bodies the grammar can't parse
|
|
118
|
+
# (notably C-language extension functions, whose "body" is just the C symbol
|
|
119
|
+
# name) put tree-sitter into error recovery that consumes the statements
|
|
120
|
+
# after them — with FKs last, every 'references' edge is silently lost on
|
|
121
|
+
# any DB with a common extension installed (#1854). FK statements only
|
|
122
|
+
# reference tables, which are emitted first, so this order is always safe.
|
|
123
|
+
for constraint_name, t_schema, t_name, cols, r_schema, r_name, r_cols in fks:
|
|
124
|
+
col_list = ", ".join(_quote_ident(c) for c in cols)
|
|
125
|
+
ref_col_list = ", ".join(_quote_ident(c) for c in r_cols)
|
|
126
|
+
ddl.append(
|
|
127
|
+
f"ALTER TABLE {_quote_ident(t_schema)}.{_quote_ident(t_name)} "
|
|
128
|
+
f"ADD CONSTRAINT {_quote_ident(constraint_name)} "
|
|
129
|
+
f"FOREIGN KEY ({col_list}) REFERENCES {_quote_ident(r_schema)}.{_quote_ident(r_name)}({ref_col_list});"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Functions & Procedures — real body if available, stub if NULL
|
|
133
|
+
# Use $gfx$ as the dollar-quote tag to avoid collision with $$ inside bodies.
|
|
134
|
+
# Use external_language from the catalog; fall back to plpgsql if NULL/blank.
|
|
135
|
+
for schema, name, rtype, body, ext_lang in routines:
|
|
136
|
+
lang = (ext_lang or "plpgsql").lower()
|
|
137
|
+
fn_sig = f"{_quote_ident(schema)}.{_quote_ident(name)}()"
|
|
138
|
+
stub_body = "BEGIN SELECT 1; END;"
|
|
139
|
+
if rtype in ("FUNCTION", "PROCEDURE"):
|
|
140
|
+
actual_body = body if body else stub_body
|
|
141
|
+
# Represent PROCEDUREs as FUNCTION so tree-sitter-sql can parse them
|
|
142
|
+
ddl.append(
|
|
143
|
+
f"CREATE FUNCTION {fn_sig} RETURNS void"
|
|
144
|
+
f" AS $gfx$ {actual_body} $gfx$ LANGUAGE {lang};"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
ddl_string = "\n".join(ddl)
|
|
148
|
+
|
|
149
|
+
# Determine host/dbname for virtual path DSN sanitization
|
|
150
|
+
info = psycopg.conninfo.conninfo_to_dict(dsn or "")
|
|
151
|
+
host = info.get("host", "localhost")
|
|
152
|
+
dbname = info.get("dbname", "db")
|
|
153
|
+
virtual_path = PurePosixPath(f"postgresql://{host}/{dbname}")
|
|
154
|
+
|
|
155
|
+
# Pass virtual path and in-memory DDL content to extract_sql
|
|
156
|
+
result = extract_sql(virtual_path, content=ddl_string)
|
|
157
|
+
# extract_sql() reports a missing grammar as an 'error' key with empty
|
|
158
|
+
# nodes/edges; surface it instead of letting the CLI print
|
|
159
|
+
# "PostgreSQL: 0 nodes" as if the database were empty.
|
|
160
|
+
if result.get("error"):
|
|
161
|
+
raise ImportError(
|
|
162
|
+
f"{result['error']} (required by --postgres; "
|
|
163
|
+
"install with: pip install 'graphifyy[postgres]')"
|
|
164
|
+
)
|
|
165
|
+
return result
|