loomweave-plugin-python 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. loomweave_plugin_python-1.0.0/.gitignore +48 -0
  2. loomweave_plugin_python-1.0.0/PKG-INFO +73 -0
  3. loomweave_plugin_python-1.0.0/README.md +49 -0
  4. loomweave_plugin_python-1.0.0/plugin.toml +71 -0
  5. loomweave_plugin_python-1.0.0/pyproject.toml +104 -0
  6. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/__init__.py +3 -0
  7. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/__main__.py +15 -0
  8. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/call_resolver.py +65 -0
  9. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/entity_id.py +75 -0
  10. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/extractor.py +1312 -0
  11. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/py.typed +0 -0
  12. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/pyright_session.py +1655 -0
  13. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/qualname.py +48 -0
  14. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/reference_resolver.py +70 -0
  15. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/server.py +310 -0
  16. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/stdout_guard.py +62 -0
  17. loomweave_plugin_python-1.0.0/src/loomweave_plugin_python/wardline_descriptor.py +197 -0
  18. loomweave_plugin_python-1.0.0/tests/__init__.py +0 -0
  19. loomweave_plugin_python-1.0.0/tests/test_entity_id.py +212 -0
  20. loomweave_plugin_python-1.0.0/tests/test_extractor.py +1600 -0
  21. loomweave_plugin_python-1.0.0/tests/test_package.py +56 -0
  22. loomweave_plugin_python-1.0.0/tests/test_pyright_session.py +1093 -0
  23. loomweave_plugin_python-1.0.0/tests/test_qualname.py +138 -0
  24. loomweave_plugin_python-1.0.0/tests/test_round_trip.py +219 -0
  25. loomweave_plugin_python-1.0.0/tests/test_server.py +751 -0
  26. loomweave_plugin_python-1.0.0/tests/test_stdout_guard.py +54 -0
  27. loomweave_plugin_python-1.0.0/tests/test_wardline_descriptor.py +167 -0
  28. loomweave_plugin_python-1.0.0/uv.lock +1077 -0
@@ -0,0 +1,48 @@
1
+ .filigree/
2
+ /target
3
+ **/*.rs.bk
4
+ Cargo.lock.bak
5
+
6
+ # SQLite working files (project-level .loomweave/ is tracked per ADR-005)
7
+ *.db-journal
8
+ *.db-wal
9
+
10
+ # Rust-analyzer / IDE caches
11
+ /.idea
12
+ /.vscode
13
+
14
+ # Python (plugins/python/)
15
+ .venv/
16
+ __pycache__/
17
+ *.egg-info/
18
+ .mypy_cache/
19
+ .pytest_cache/
20
+ .ruff_cache/
21
+ .coverage
22
+ htmlcov/
23
+ .claude/
24
+
25
+ .env
26
+
27
+ # Smoke-test result artifacts (per-run; archived separately at tag-cut)
28
+ tests/e2e/external-operator-smoke-results-*.md
29
+
30
+ # Generated skill fingerprint (rewritten by `loomweave install --skills`).
31
+ .agents/skills/loomweave-workflow/.fingerprint
32
+
33
+ # Loomweave runtime artifacts — the index DB, per-project instance fingerprint,
34
+ # and analyze lock change on every run, so they are not tracked
35
+ # (see .loomweave/.gitignore).
36
+ .loomweave/loomweave.db
37
+ .loomweave/instance_id
38
+ .loomweave/loomweave.lock
39
+
40
+ # Documentation site build output (mkdocs `site_dir`, web/mkdocs.yml).
41
+ /site-build/
42
+
43
+ # Filigree-managed docs — a running filigree process rewrites its managed
44
+ # instruction blocks in these every session; untracked to avoid diff churn
45
+ # (filigree regenerates them on demand).
46
+ AGENTS.md
47
+ CLAUDE.md
48
+ .agents/skills/filigree-workflow/SKILL.md
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: loomweave-plugin-python
3
+ Version: 1.0.0
4
+ Summary: Loomweave Python language plugin — v1.0 release
5
+ Author-email: John Morrissey <qacona@gmail.com>
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: packaging>=24
12
+ Requires-Dist: pyright==1.1.409
13
+ Requires-Dist: pyyaml>=6.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: build>=1.2; extra == 'dev'
16
+ Requires-Dist: mypy>=1.11; extra == 'dev'
17
+ Requires-Dist: pip-audit>=2.9; extra == 'dev'
18
+ Requires-Dist: pre-commit>=3.8; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.6; extra == 'dev'
22
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # loomweave-plugin-python
26
+
27
+ The Python language plugin for [Loomweave](../../README.md). Extracts Python
28
+ entities from source files and serves them to the Loomweave core over the
29
+ JSON-RPC protocol defined in [WP2 L4](../../docs/implementation/sprint-1/wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing).
30
+
31
+ **Status**: Python structural extractor. It emits modules, classes, functions,
32
+ `contains`, `calls`, `references`, `imports`, and versioned entity signatures
33
+ for Stable Entity Identity (SEI) matching. It also reads Wardline's NG-25
34
+ trust-vocabulary descriptor without importing Wardline and emits source-observed
35
+ Wardline decorator metadata/tags on decorated entities when a descriptor is
36
+ available.
37
+
38
+ ## Install (development)
39
+
40
+ ```bash
41
+ python -m venv .venv
42
+ source .venv/bin/activate
43
+ pip install -e '.[dev]'
44
+ ```
45
+
46
+ This places `loomweave-plugin-python` on your `$PATH` and installs the
47
+ dev-time toolchain (`ruff`, `mypy`, `pytest`, `pytest-cov`, `pre-commit`).
48
+
49
+ ## ADR-023 tooling gates
50
+
51
+ Every commit must pass all four:
52
+
53
+ ```bash
54
+ ruff check plugins/python
55
+ ruff format --check plugins/python
56
+ mypy --strict plugins/python
57
+ pytest plugins/python
58
+ ```
59
+
60
+ CI runs the same four gates in the `python-plugin` job.
61
+
62
+ ## Design references
63
+
64
+ - [WP3 plan](../../docs/implementation/sprint-1/wp3-python-plugin.md) — task
65
+ ledger, lock-ins, and UQ resolutions.
66
+ - [ADR-003](../../docs/loomweave/adr/ADR-003-entity-id-scheme.md) — 3-segment
67
+ `EntityId` format this plugin produces.
68
+ - [ADR-018](../../docs/loomweave/adr/ADR-018-identity-reconciliation.md) —
69
+ cross-product identity join with Wardline.
70
+ - [ADR-022](../../docs/loomweave/adr/ADR-022-core-plugin-ontology.md) —
71
+ manifest schema and ontology-boundary enforcement.
72
+ - [ADR-023](../../docs/loomweave/adr/ADR-023-tooling-baseline.md) — the four
73
+ Python gates and the `pre-commit` setup.
@@ -0,0 +1,49 @@
1
+ # loomweave-plugin-python
2
+
3
+ The Python language plugin for [Loomweave](../../README.md). Extracts Python
4
+ entities from source files and serves them to the Loomweave core over the
5
+ JSON-RPC protocol defined in [WP2 L4](../../docs/implementation/sprint-1/wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing).
6
+
7
+ **Status**: Python structural extractor. It emits modules, classes, functions,
8
+ `contains`, `calls`, `references`, `imports`, and versioned entity signatures
9
+ for Stable Entity Identity (SEI) matching. It also reads Wardline's NG-25
10
+ trust-vocabulary descriptor without importing Wardline and emits source-observed
11
+ Wardline decorator metadata/tags on decorated entities when a descriptor is
12
+ available.
13
+
14
+ ## Install (development)
15
+
16
+ ```bash
17
+ python -m venv .venv
18
+ source .venv/bin/activate
19
+ pip install -e '.[dev]'
20
+ ```
21
+
22
+ This places `loomweave-plugin-python` on your `$PATH` and installs the
23
+ dev-time toolchain (`ruff`, `mypy`, `pytest`, `pytest-cov`, `pre-commit`).
24
+
25
+ ## ADR-023 tooling gates
26
+
27
+ Every commit must pass all four:
28
+
29
+ ```bash
30
+ ruff check plugins/python
31
+ ruff format --check plugins/python
32
+ mypy --strict plugins/python
33
+ pytest plugins/python
34
+ ```
35
+
36
+ CI runs the same four gates in the `python-plugin` job.
37
+
38
+ ## Design references
39
+
40
+ - [WP3 plan](../../docs/implementation/sprint-1/wp3-python-plugin.md) — task
41
+ ledger, lock-ins, and UQ resolutions.
42
+ - [ADR-003](../../docs/loomweave/adr/ADR-003-entity-id-scheme.md) — 3-segment
43
+ `EntityId` format this plugin produces.
44
+ - [ADR-018](../../docs/loomweave/adr/ADR-018-identity-reconciliation.md) —
45
+ cross-product identity join with Wardline.
46
+ - [ADR-022](../../docs/loomweave/adr/ADR-022-core-plugin-ontology.md) —
47
+ manifest schema and ontology-boundary enforcement.
48
+ - [ADR-023](../../docs/loomweave/adr/ADR-023-tooling-baseline.md) — the four
49
+ Python gates and the `pre-commit` setup.
@@ -0,0 +1,71 @@
1
+ [plugin]
2
+ name = "loomweave-plugin-python"
3
+ plugin_id = "python"
4
+ version = "1.0.0"
5
+ protocol_version = "1.0"
6
+ # Bare basename per ADR-021 §Layer 1 + WP2 scrub commit eb0a41d — the host
7
+ # refuses manifests whose `executable` carries any path component.
8
+ executable = "loomweave-plugin-python"
9
+ language = "python"
10
+ extensions = ["py"]
11
+
12
+ [capabilities.runtime]
13
+ # Plugin's declared RSS envelope (MiB). Effective prlimit is
14
+ # min(this, core default 2 GiB). Pyright's Node-based language server
15
+ # inherits this limit from the plugin process, so B.4* declares the core
16
+ # ceiling rather than the Sprint-1 CPython-only working set.
17
+ expected_max_rss_mb = 2048
18
+ # Triggers LMWV-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING well before the 500k
19
+ # core cap (warning emission itself is deferred to Tier B — Sprint 1
20
+ # only lands the declaration).
21
+ expected_entities_per_file = 5000
22
+ # Wardline semantic extraction reads the NG-25 vocabulary descriptor without
23
+ # importing Wardline, then emits source-observed decorator metadata on entities.
24
+ wardline_aware = true
25
+ # v0.1 rejects `true` at initialize with LMWV-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY.
26
+ reads_outside_project_root = false
27
+
28
+ [capabilities.runtime.pyright]
29
+ pin = "1.1.409"
30
+
31
+ [ontology]
32
+ # Sprint 2 B.2: classes + modules join the kind set. B.3 (ADR-026) adds
33
+ # the first edge kind, `contains`. B.4* adds scan-time `calls` edges.
34
+ # B.5* adds scan-time `references` edges. Phase 3 Task 3 adds scan-time
35
+ # `imports` candidate edges.
36
+ entity_kinds = ["function", "class", "module"]
37
+ edge_kinds = ["contains", "calls", "references", "imports"]
38
+ # Per ADR-022: uppercase `LMWV-{PLUGIN_ID_UPPER}-`. Reserved at parse
39
+ # against the LMWV-INFRA-* and LMWV-FACT-* namespaces.
40
+ rule_id_prefix = "LMWV-PY-"
41
+ # Bumps per ADR-027 when the entity/edge/rule set shifts. Phase 3 Task 3 is a
42
+ # MINOR bump (additive `imports` edge kind). NOTE: ADR-007's summary-cache key
43
+ # is the 5-tuple (entity_id, content_hash, prompt_template_id, model_tier,
44
+ # guidance_fingerprint) — ontology_version is handshake-validation, NOT a
45
+ # cache-key component. New edge rows miss the cache by component-1 of
46
+ # the 5-tuple organically (no edges live in the 5-tuple yet anyway).
47
+ ontology_version = "0.7.0"
48
+
49
+ [integrations.wardline]
50
+ expected_descriptor_version = "wardline-generic-2"
51
+
52
+ [ontology.roles]
53
+ file_scope = ["module"]
54
+ callable = ["function"]
55
+ syntax_degraded_module = ["module"]
56
+
57
+ # SEI signature declaration (ADR-038 REQ-C-01 / Wave 1). The plugin emits a
58
+ # versioned `signature` object per function/class entity, stored verbatim by the
59
+ # core and compared by string equality as the matcher's move-case input. Modules
60
+ # and packages declare no signature (the move case abstains for them — fail
61
+ # closed). A `schema_version` bump voids cached signature equality.
62
+ [signature]
63
+ schema_version = 1
64
+
65
+ [signature.schemas.function]
66
+ v = 1
67
+ fields = ["params", "return_ann"]
68
+
69
+ [signature.schemas.class]
70
+ v = 1
71
+ fields = ["bases"]
@@ -0,0 +1,104 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "loomweave-plugin-python"
7
+ version = "1.0.0"
8
+ description = "Loomweave Python language plugin — v1.0 release"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "John Morrissey", email = "qacona@gmail.com" }]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ ]
18
+ dependencies = [
19
+ "packaging>=24",
20
+ "pyright==1.1.409",
21
+ "pyyaml>=6.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "pytest-cov>=5.0",
28
+ "ruff>=0.6",
29
+ "mypy>=1.11",
30
+ "pre-commit>=3.8",
31
+ "pip-audit>=2.9",
32
+ "build>=1.2",
33
+ "types-PyYAML>=6.0",
34
+ ]
35
+
36
+ [project.scripts]
37
+ loomweave-plugin-python = "loomweave_plugin_python.__main__:main"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/loomweave_plugin_python"]
41
+
42
+ [tool.hatch.build.targets.wheel.shared-data]
43
+ # Route plugin.toml into <install-prefix>/share/loomweave/plugins/<suffix>/ so
44
+ # WP2's L9 install-prefix fallback finds it. The <suffix> is produced by
45
+ # `discovery.rs::strip_prefix("loomweave-plugin-")` on the binary name, so for
46
+ # loomweave-plugin-python the suffix is "python" — the target directory must
47
+ # match that basename exactly.
48
+ "plugin.toml" = "share/loomweave/plugins/python/plugin.toml"
49
+
50
+ [tool.ruff]
51
+ target-version = "py311"
52
+ line-length = 100
53
+ src = ["src", "tests"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["ALL"]
57
+ ignore = [
58
+ "D", # pydocstyle relaxed per ADR-023
59
+ "COM812", # conflicts with ruff format
60
+ "ISC001", # conflicts with ruff format
61
+ "CPY", # copyright headers are not our convention
62
+ "ANN401", # Any is legitimate for JSON-RPC payload fields (shape is open)
63
+ "TRY003", # short composed exception messages are fine
64
+ ]
65
+
66
+ [tool.ruff.lint.per-file-ignores]
67
+ "tests/**" = [
68
+ "S101", # assert is the whole point of tests
69
+ "PLR2004", # magic numbers are fine in test expectations
70
+ "ANN", # type annotations optional in tests
71
+ "INP001", # tests/ is a namespace package
72
+ "S108", # hard-coded /tmp paths are test fixtures, not real I/O
73
+ "E501", # long assert messages in test failures are fine
74
+ ]
75
+
76
+ [tool.ruff.lint.mccabe]
77
+ # Dispatch loops naturally exceed the default 10; 15 matches our Rust clippy.toml.
78
+ max-complexity = 15
79
+
80
+ [tool.ruff.lint.pylint]
81
+ max-returns = 10
82
+ max-branches = 15
83
+
84
+ [tool.ruff.format]
85
+ # defaults: double quotes, space indent, trailing commas where appropriate
86
+
87
+ [tool.mypy]
88
+ python_version = "3.11"
89
+ strict = true
90
+ warn_unused_configs = true
91
+ files = ["src", "tests"]
92
+
93
+ [[tool.mypy.overrides]]
94
+ module = "yaml"
95
+ ignore_missing_imports = true
96
+
97
+ [tool.pytest.ini_options]
98
+ testpaths = ["tests"]
99
+ addopts = "--strict-markers --cov=loomweave_plugin_python --cov-report=term-missing --cov-fail-under=85"
100
+ pythonpath = ["src"]
101
+ markers = [
102
+ "pyright: requires pyright-langserver on PATH or in the active virtualenv",
103
+ "slow: exercises subprocess or scale behavior that is slower than unit tests",
104
+ ]
@@ -0,0 +1,3 @@
1
+ """loomweave-plugin-python — Python language plugin for Loomweave."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,15 @@
1
+ """Entry point for the ``loomweave-plugin-python`` executable.
2
+
3
+ Installs stdout discipline (``stdout_guard``) and hands control to the
4
+ JSON-RPC server loop. ``sys.exit`` threads the server's exit code out to
5
+ the host process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from loomweave_plugin_python.server import main
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Literal, NotRequired, Protocol, TypedDict
5
+
6
+ if TYPE_CHECKING:
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+
11
+ class CallsEdgeProperties(TypedDict):
12
+ candidates: list[str]
13
+
14
+
15
+ class CallsRawEdge(TypedDict):
16
+ kind: Literal["calls"]
17
+ from_id: str
18
+ to_id: str
19
+ source_byte_start: int
20
+ source_byte_end: int
21
+ confidence: Literal["resolved", "ambiguous"]
22
+ properties: NotRequired[CallsEdgeProperties]
23
+
24
+
25
+ class Finding(TypedDict):
26
+ subcode: str
27
+ severity: Literal["info", "warning", "error"]
28
+ message: str
29
+ metadata: dict[str, object]
30
+
31
+
32
+ class UnresolvedCallSite(TypedDict):
33
+ caller_entity_id: str
34
+ site_ordinal: int
35
+ source_byte_start: int
36
+ source_byte_end: int
37
+ callee_expr: str
38
+
39
+
40
+ @dataclass
41
+ class CallResolutionResult:
42
+ edges: list[CallsRawEdge] = field(default_factory=list)
43
+ unresolved_call_sites_total: int = 0
44
+ unresolved_call_sites: list[UnresolvedCallSite] = field(default_factory=list)
45
+ pyright_query_latency_ms: list[int] = field(default_factory=list)
46
+ pyright_index_parse_latency_ms: list[int] = field(default_factory=list)
47
+ findings: list[Finding] = field(default_factory=list)
48
+
49
+
50
+ class CallResolver(Protocol):
51
+ def resolve_calls(
52
+ self,
53
+ file_path: str | Path,
54
+ function_ids: Sequence[str],
55
+ ) -> CallResolutionResult: ...
56
+
57
+
58
+ class NoOpCallResolver:
59
+ def resolve_calls(
60
+ self,
61
+ file_path: str | Path,
62
+ function_ids: Sequence[str],
63
+ ) -> CallResolutionResult:
64
+ _ = (file_path, function_ids)
65
+ return CallResolutionResult()
@@ -0,0 +1,75 @@
1
+ """L2 3-segment EntityId assembler matching WP1's Rust ``entity_id()`` byte-for-byte.
2
+
3
+ Per ADR-003 + ADR-022, every Loomweave entity has a 3-segment ID of the
4
+ form ``{plugin_id}:{kind}:{canonical_qualified_name}``.
5
+
6
+ Validation (mirrors ``crates/loomweave-core/src/entity_id.rs``):
7
+
8
+ - ``plugin_id`` and ``kind`` must match the identifier grammar
9
+ ``[a-z][a-z0-9_]*`` (ADR-022).
10
+ - No segment may contain a literal ``:`` (reserved separator).
11
+ - No segment may be empty.
12
+
13
+ The shared fixture ``fixtures/entity_id.json`` (Task 5) drives the
14
+ cross-language parity check: both the Rust assembler and this Python
15
+ assembler consume the same fixture rows and must produce identical
16
+ strings byte-for-byte.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ _GRAMMAR = re.compile(r"^[a-z][a-z0-9_]*$")
24
+
25
+
26
+ class EntityIdError(ValueError):
27
+ """Base class for all ``entity_id()`` validation failures."""
28
+
29
+
30
+ class EmptySegmentError(EntityIdError):
31
+ """A segment (``plugin_id``, ``kind``, or ``canonical_qualified_name``) was empty."""
32
+
33
+ def __init__(self, field: str) -> None:
34
+ super().__init__(f"segment {field} empty")
35
+ self.field = field
36
+
37
+
38
+ class GrammarViolationError(EntityIdError):
39
+ """A segment did not match the ADR-022 grammar ``[a-z][a-z0-9_]*``."""
40
+
41
+ def __init__(self, field: str, value: str) -> None:
42
+ super().__init__(f"segment {field} violates ADR-022 grammar [a-z][a-z0-9_]*: {value!r}")
43
+ self.field = field
44
+ self.value = value
45
+
46
+
47
+ class SegmentContainsColonError(EntityIdError):
48
+ """A segment contained the reserved ``:`` separator (UQ-WP1-07)."""
49
+
50
+ def __init__(self, field: str, value: str) -> None:
51
+ super().__init__(f"segment {field} contains reserved ':' separator: {value!r}")
52
+ self.field = field
53
+ self.value = value
54
+
55
+
56
+ def _validate_grammar(field: str, value: str) -> None:
57
+ """Mirror ``validate_grammar`` in the Rust side — empty, colon, then regex."""
58
+ if not value:
59
+ raise EmptySegmentError(field)
60
+ if ":" in value:
61
+ raise SegmentContainsColonError(field, value)
62
+ if not _GRAMMAR.fullmatch(value):
63
+ raise GrammarViolationError(field, value)
64
+
65
+
66
+ def entity_id(plugin_id: str, kind: str, canonical_qualified_name: str) -> str:
67
+ """Assemble the 3-segment EntityId string with full validation."""
68
+ _validate_grammar("plugin_id", plugin_id)
69
+ _validate_grammar("kind", kind)
70
+ qn_field = "canonical_qualified_name"
71
+ if not canonical_qualified_name:
72
+ raise EmptySegmentError(qn_field)
73
+ if ":" in canonical_qualified_name:
74
+ raise SegmentContainsColonError(qn_field, canonical_qualified_name)
75
+ return f"{plugin_id}:{kind}:{canonical_qualified_name}"