loomweave-plugin-python 1.3.1__tar.gz → 1.4.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 (31) hide show
  1. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/.gitignore +1 -0
  2. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/PKG-INFO +1 -1
  3. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/plugin.toml +5 -3
  4. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/pyproject.toml +1 -1
  5. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/__init__.py +1 -1
  6. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/extractor.py +74 -19
  7. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/server.py +1 -1
  8. loomweave_plugin_python-1.4.0/tests/fixtures/wardline-vocabulary-descriptor.golden.yaml +14 -0
  9. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_extractor.py +219 -0
  10. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_package.py +3 -3
  11. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_server.py +2 -2
  12. loomweave_plugin_python-1.4.0/tests/test_wardline_vocabulary_descriptor_conformance.py +309 -0
  13. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/uv.lock +1 -1
  14. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/README.md +0 -0
  15. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/__main__.py +0 -0
  16. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/call_resolver.py +0 -0
  17. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/entity_id.py +0 -0
  18. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/py.typed +0 -0
  19. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/pyright_session.py +0 -0
  20. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/qualname.py +0 -0
  21. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/reference_resolver.py +0 -0
  22. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/stdout_guard.py +0 -0
  23. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/src/loomweave_plugin_python/wardline_descriptor.py +0 -0
  24. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/__init__.py +0 -0
  25. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_dogfood_uniqueness.py +0 -0
  26. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_entity_id.py +0 -0
  27. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_pyright_session.py +0 -0
  28. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_qualname.py +0 -0
  29. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_round_trip.py +0 -0
  30. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_stdout_guard.py +0 -0
  31. {loomweave_plugin_python-1.3.1 → loomweave_plugin_python-1.4.0}/tests/test_wardline_descriptor.py +0 -0
@@ -68,3 +68,4 @@ CLAUDE.md
68
68
  # Local tool/federation config — not part of the solution
69
69
  .mcp.json
70
70
  loomweave.yaml
71
+ .wardline
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loomweave-plugin-python
3
- Version: 1.3.1
3
+ Version: 1.4.0
4
4
  Summary: Loomweave Python language plugin — v1.0 release
5
5
  Author-email: John Morrissey <qacona@gmail.com>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -1,7 +1,7 @@
1
1
  [plugin]
2
2
  name = "loomweave-plugin-python"
3
3
  plugin_id = "python"
4
- version = "1.3.1"
4
+ version = "1.4.0"
5
5
  protocol_version = "1.0"
6
6
  # Bare basename per ADR-021 §Layer 1 + WP2 scrub commit eb0a41d — the host
7
7
  # refuses manifests whose `executable` carries any path component.
@@ -47,13 +47,15 @@ edge_kinds = ["contains", "calls", "references", "imports", "inherits_from", "de
47
47
  rule_id_prefix = "LMWV-PY-"
48
48
  # Bumps per ADR-027 when the entity/edge/rule set shifts. Phase 3 Task 3 is a
49
49
  # MINOR bump (additive `imports` edge kind); 0.8.0 is the additive
50
- # `inherits_from` + `decorates` MINOR bump (clarion-43416be550). NOTE:
50
+ # `inherits_from` + `decorates` MINOR bump (clarion-43416be550); 0.9.0 is the
51
+ # additive `public-surface` reachability-root tag (clarion-4ec50f3d92) — a
52
+ # no-`__all__` library's public defs/classes are now tagged as roots. NOTE:
51
53
  # ADR-007's summary-cache key is the 5-tuple (entity_id, content_hash,
52
54
  # prompt_template_id, model_tier, guidance_fingerprint) — ontology_version is
53
55
  # handshake-validation, NOT a cache-key component. New edge rows miss the
54
56
  # cache by component-1 of the 5-tuple organically (no edges live in the
55
57
  # 5-tuple yet anyway).
56
- ontology_version = "0.8.0"
58
+ ontology_version = "0.9.0"
57
59
 
58
60
  [integrations.wardline]
59
61
  expected_descriptor_version = "wardline-generic-2"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "loomweave-plugin-python"
7
- version = "1.3.1"
7
+ version = "1.4.0"
8
8
  description = "Loomweave Python language plugin — v1.0 release"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """loomweave-plugin-python — Python language plugin for Loomweave."""
2
2
 
3
- __version__ = "1.3.1"
3
+ __version__ = "1.4.0"
@@ -1058,7 +1058,10 @@ class _WalkState:
1058
1058
  seen_ids: set[str]
1059
1059
  file_path: str
1060
1060
  wardline_vocabulary: WardlineVocabulary | None = None
1061
- exported_names: set[str] = field(default_factory=set)
1061
+ # ``None`` means no ``__all__`` was declared (triggers the public-surface
1062
+ # heuristic); a set (possibly empty) means ``__all__`` was declared
1063
+ # explicitly (clarion-4ec50f3d92).
1064
+ exported_names: set[str] | None = None
1062
1065
  duplicate_entities_dropped: int = 0
1063
1066
 
1064
1067
 
@@ -1274,16 +1277,39 @@ def _attach_wardline_entity_metadata(
1274
1277
  }
1275
1278
 
1276
1279
 
1277
- def _module_export_names(tree: ast.Module) -> set[str]:
1278
- exported: set[str] = set()
1280
+ def _module_export_names(tree: ast.Module) -> set[str] | None:
1281
+ """Names declared in a module-level ``__all__``, or ``None`` when no
1282
+ ``__all__`` is declared at all (clarion-4ec50f3d92).
1283
+
1284
+ The ``None`` vs empty-set distinction is load-bearing for the public-surface
1285
+ fallback: an explicit ``__all__ = []`` declares an *empty* public surface
1286
+ (no roots), whereas the *absence* of ``__all__`` triggers the PEP 8
1287
+ public-surface heuristic in :func:`_function_tags` / :func:`_class_tags`.
1288
+ Both plain (``__all__ = [...]``) and annotated (``__all__: list[str] = [...]``)
1289
+ assignments count as a declaration so an annotated ``__all__`` is never
1290
+ mistaken for an absent one. A non-literal value (``__all__ = a + b``, an
1291
+ alias, a comprehension) or an annotation-only stub (``__all__: list[str]``
1292
+ with no value) counts as a declaration but contributes no names — i.e. it is
1293
+ treated as an explicit *empty* surface, which suppresses ``public-surface``;
1294
+ this matches the prior behaviour for non-literal ``__all__`` (no names were
1295
+ ever extracted from it). Bare ``__all__ += [...]`` (``ast.AugAssign`` without a
1296
+ prior ``=`` assignment) is intentionally not handled: it is a runtime
1297
+ ``NameError`` and so unreachable in valid Python."""
1298
+ exported: set[str] | None = None
1279
1299
  for statement in tree.body:
1280
- if not isinstance(statement, ast.Assign):
1300
+ if isinstance(statement, ast.Assign):
1301
+ targets: list[ast.expr] = statement.targets
1302
+ value: ast.expr | None = statement.value
1303
+ elif isinstance(statement, ast.AnnAssign):
1304
+ targets = [statement.target]
1305
+ value = statement.value
1306
+ else:
1281
1307
  continue
1282
- if not any(
1283
- isinstance(target, ast.Name) and target.id == "__all__" for target in statement.targets
1284
- ):
1308
+ if not any(isinstance(target, ast.Name) and target.id == "__all__" for target in targets):
1285
1309
  continue
1286
- match statement.value:
1310
+ if exported is None:
1311
+ exported = set()
1312
+ match value:
1287
1313
  case ast.List(elts=elts) | ast.Tuple(elts=elts) | ast.Set(elts=elts):
1288
1314
  for elt in elts:
1289
1315
  if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
@@ -1321,16 +1347,18 @@ def _is_module_level(parents: list[ast.AST]) -> bool:
1321
1347
  def _function_tags(
1322
1348
  node: ast.FunctionDef | ast.AsyncFunctionDef,
1323
1349
  parents: list[ast.AST],
1324
- exported_names: set[str],
1350
+ exported_names: set[str] | None,
1325
1351
  ) -> set[str]:
1326
1352
  tags: set[str] = set()
1327
- if _is_module_level(parents) and node.name == "main":
1328
- tags.add("entry-point")
1329
- if _is_module_level(parents) and node.name in exported_names:
1330
- tags.add("exported-api")
1331
- if node.name.startswith("test_") or any(
1353
+ module_level = _is_module_level(parents)
1354
+ is_test = node.name.startswith("test_") or any(
1332
1355
  isinstance(parent, ast.ClassDef) and parent.name.startswith("Test") for parent in parents
1333
- ):
1356
+ )
1357
+ if module_level and node.name == "main":
1358
+ tags.add("entry-point")
1359
+ if module_level:
1360
+ tags.update(_module_surface_tag(node.name, exported_names, is_test=is_test))
1361
+ if is_test:
1334
1362
  tags.add("test")
1335
1363
  decorator_names = _decorator_names(node)
1336
1364
  if any(_last_name(name) in _HTTP_ROUTE_DECORATOR_NAMES for name in decorator_names):
@@ -1340,11 +1368,38 @@ def _function_tags(
1340
1368
  return tags
1341
1369
 
1342
1370
 
1343
- def _class_tags(node: ast.ClassDef, parents: list[ast.AST], exported_names: set[str]) -> set[str]:
1371
+ def _module_surface_tag(name: str, exported_names: set[str] | None, *, is_test: bool) -> set[str]:
1372
+ """Root tag for a module-level public entity (clarion-4ec50f3d92).
1373
+
1374
+ With a declared ``__all__`` (``exported_names is not None``) only listed
1375
+ names are ``exported-api`` — the declaration is **authoritative**: a name the
1376
+ author explicitly exported is an exported root regardless of its spelling
1377
+ (so a ``Test``/``test_``-named or underscore-prefixed name listed in
1378
+ ``__all__`` still gets ``exported-api``). With no ``__all__`` at all
1379
+ (``None``) every non-underscore, non-test module-level def/class is the
1380
+ module's public surface by PEP 8 convention, tagged ``public-surface``: a
1381
+ reachability root of lower confidence than a declared export. It is a
1382
+ fail-toward-live posture for public code invoked from outside the static call
1383
+ graph (a library's consumers, or an app's framework-dispatched / DI / CLI
1384
+ handlers), so a codebase that does not exhaustively declare ``__all__`` is not
1385
+ read as mostly dead. ``is_test`` gates only this inferred fallback — test
1386
+ entities are already roots via the ``test`` tag and are not a module's public
1387
+ API; it never suppresses a declared export."""
1388
+ if exported_names is None:
1389
+ if is_test or name.startswith("_"):
1390
+ return set()
1391
+ return {"public-surface"}
1392
+ return {"exported-api"} if name in exported_names else set()
1393
+
1394
+
1395
+ def _class_tags(
1396
+ node: ast.ClassDef, parents: list[ast.AST], exported_names: set[str] | None
1397
+ ) -> set[str]:
1344
1398
  tags: set[str] = set()
1345
- if _is_module_level(parents) and node.name in exported_names:
1346
- tags.add("exported-api")
1347
- if node.name.startswith("Test"):
1399
+ is_test = node.name.startswith("Test")
1400
+ if _is_module_level(parents):
1401
+ tags.update(_module_surface_tag(node.name, exported_names, is_test=is_test))
1402
+ if is_test:
1348
1403
  tags.add("test")
1349
1404
  decorator_names = _decorator_names(node)
1350
1405
  base_names = [_expr_qualified_name(base) for base in node.bases]
@@ -34,7 +34,7 @@ from loomweave_plugin_python.pyright_session import PyrightRunState, PyrightSess
34
34
  from loomweave_plugin_python.stdout_guard import install_stdio
35
35
  from loomweave_plugin_python.wardline_descriptor import WardlineVocabulary, load_wardline_descriptor
36
36
 
37
- ONTOLOGY_VERSION = "0.8.0"
37
+ ONTOLOGY_VERSION = "0.9.0"
38
38
 
39
39
  # Plugin-side Content-Length sanity cap. Matches the host's ADR-021 §2b
40
40
  # default (8 MiB) so the plugin never emits a frame the host would kill us
@@ -0,0 +1,14 @@
1
+ schema: wardline.vocabulary/v1
2
+ version: wardline-generic-2
3
+ entries:
4
+ - canonical_name: external_boundary
5
+ group: 1
6
+ attrs: {}
7
+ - canonical_name: trust_boundary
8
+ group: 1
9
+ attrs:
10
+ _wardline_to_level: TaintState
11
+ - canonical_name: trusted
12
+ group: 1
13
+ attrs:
14
+ _wardline_level: TaintState
@@ -1262,6 +1262,225 @@ class Config:
1262
1262
  assert "data-model" in config["tags"]
1263
1263
 
1264
1264
 
1265
+ def test_no_dunder_all_tags_public_module_surface_as_public_surface() -> None:
1266
+ """clarion-4ec50f3d92: a module with no ``__all__`` falls back to the PEP 8
1267
+ public-surface heuristic — non-underscore module-level defs/classes become
1268
+ ``public-surface`` reachability roots (a lower-confidence root than a
1269
+ declared ``exported-api``), while private, nested, and test entities do not."""
1270
+
1271
+ source = """\
1272
+ def public_fn():
1273
+ pass
1274
+
1275
+
1276
+ def _private_fn():
1277
+ pass
1278
+
1279
+
1280
+ def main():
1281
+ pass
1282
+
1283
+
1284
+ def test_thing():
1285
+ assert True
1286
+
1287
+
1288
+ class PublicClass:
1289
+ def method(self):
1290
+ pass
1291
+
1292
+
1293
+ class _PrivateClass:
1294
+ pass
1295
+
1296
+
1297
+ class TestThing:
1298
+ pass
1299
+ """
1300
+ entities, _ = extract(source, "lib.py")
1301
+ by_id = {e["id"]: e for e in entities}
1302
+
1303
+ def tags(entity_id: str) -> list[str]:
1304
+ return by_id[entity_id].get("tags", [])
1305
+
1306
+ # Public module-level def/class → public-surface, never exported-api
1307
+ # (nothing was *declared*).
1308
+ assert "public-surface" in tags("python:function:lib.public_fn")
1309
+ assert "exported-api" not in tags("python:function:lib.public_fn")
1310
+ assert "public-surface" in tags("python:class:lib.PublicClass")
1311
+
1312
+ # A no-__all__ module-level `main` is both an entry-point and public surface;
1313
+ # the two roots coexist (pinned so a future reader does not assume exclusivity).
1314
+ assert "entry-point" in tags("python:function:lib.main")
1315
+ assert "public-surface" in tags("python:function:lib.main")
1316
+
1317
+ # Underscore-prefixed names are non-public by convention → no root.
1318
+ assert "public-surface" not in tags("python:function:lib._private_fn")
1319
+ assert "public-surface" not in tags("python:class:lib._PrivateClass")
1320
+
1321
+ # Nested defs are not module-level public surface.
1322
+ assert "public-surface" not in tags("python:function:lib.PublicClass.method")
1323
+
1324
+ # Test entities are already roots via the ``test`` tag; they are not the
1325
+ # library's public API and must not be double-tagged public-surface.
1326
+ assert "test" in tags("python:function:lib.test_thing")
1327
+ assert "public-surface" not in tags("python:function:lib.test_thing")
1328
+ assert "test" in tags("python:class:lib.TestThing")
1329
+ assert "public-surface" not in tags("python:class:lib.TestThing")
1330
+
1331
+
1332
+ def test_dunder_all_present_suppresses_public_surface_heuristic() -> None:
1333
+ """clarion-4ec50f3d92: when ``__all__`` is declared it is authoritative —
1334
+ only listed names are ``exported-api`` and the public-surface heuristic does
1335
+ not fire for unlisted public names."""
1336
+
1337
+ source = """\
1338
+ __all__ = ["exported_fn", "ExportedClass"]
1339
+
1340
+
1341
+ def exported_fn():
1342
+ pass
1343
+
1344
+
1345
+ def public_unlisted_fn():
1346
+ pass
1347
+
1348
+
1349
+ class ExportedClass:
1350
+ pass
1351
+
1352
+
1353
+ class UnlistedClass:
1354
+ pass
1355
+ """
1356
+ entities, _ = extract(source, "lib.py")
1357
+ by_id = {e["id"]: e for e in entities}
1358
+
1359
+ def tags(entity_id: str) -> list[str]:
1360
+ return by_id[entity_id].get("tags", [])
1361
+
1362
+ assert "exported-api" in tags("python:function:lib.exported_fn")
1363
+ assert "public-surface" not in tags("python:function:lib.exported_fn")
1364
+ assert "exported-api" in tags("python:class:lib.ExportedClass")
1365
+ # exported-api and public-surface are mutually exclusive for a listed name.
1366
+ assert "public-surface" not in tags("python:function:lib.exported_fn")
1367
+ assert "public-surface" not in tags("python:class:lib.ExportedClass")
1368
+
1369
+ # A declared __all__ means unlisted public names are deliberately not API.
1370
+ assert "exported-api" not in tags("python:function:lib.public_unlisted_fn")
1371
+ assert "public-surface" not in tags("python:function:lib.public_unlisted_fn")
1372
+ assert "exported-api" not in tags("python:class:lib.UnlistedClass")
1373
+ assert "public-surface" not in tags("python:class:lib.UnlistedClass")
1374
+
1375
+
1376
+ def test_annotated_dunder_all_is_a_declaration() -> None:
1377
+ """clarion-4ec50f3d92 (review follow-up): an annotated ``__all__: list[str] =
1378
+ [...]`` is a declaration like a plain assignment — listed names get
1379
+ ``exported-api`` and the public-surface heuristic does not fire for unlisted
1380
+ public names. Pins the ``ast.AnnAssign`` branch of ``_module_export_names``."""
1381
+
1382
+ source = """\
1383
+ __all__: list[str] = ["exported_fn"]
1384
+
1385
+
1386
+ def exported_fn():
1387
+ pass
1388
+
1389
+
1390
+ def public_unlisted_fn():
1391
+ pass
1392
+ """
1393
+ entities, _ = extract(source, "lib.py")
1394
+ by_id = {e["id"]: e for e in entities}
1395
+
1396
+ def tags(entity_id: str) -> list[str]:
1397
+ return by_id[entity_id].get("tags", [])
1398
+
1399
+ assert "exported-api" in tags("python:function:lib.exported_fn")
1400
+ assert "public-surface" not in tags("python:function:lib.exported_fn")
1401
+ # __all__ is declared (just annotated) → heuristic suppressed for unlisted.
1402
+ assert "exported-api" not in tags("python:function:lib.public_unlisted_fn")
1403
+ assert "public-surface" not in tags("python:function:lib.public_unlisted_fn")
1404
+
1405
+
1406
+ def test_annotated_empty_dunder_all_declares_empty_surface() -> None:
1407
+ """clarion-4ec50f3d92 (review follow-up): annotated ``__all__: list[str] = []``
1408
+ is an explicit empty surface, identical to the plain empty case — no
1409
+ public-surface."""
1410
+
1411
+ source = """\
1412
+ __all__: list[str] = []
1413
+
1414
+
1415
+ def public_fn():
1416
+ pass
1417
+ """
1418
+ entities, _ = extract(source, "lib.py")
1419
+ by_id = {e["id"]: e for e in entities}
1420
+ fn_tags = by_id["python:function:lib.public_fn"].get("tags", [])
1421
+ assert "public-surface" not in fn_tags
1422
+ assert "exported-api" not in fn_tags
1423
+
1424
+
1425
+ def test_empty_dunder_all_declares_empty_public_surface() -> None:
1426
+ """clarion-4ec50f3d92: ``__all__ = []`` is an *explicit* empty public
1427
+ surface — distinct from the absence of ``__all__`` — so the public-surface
1428
+ heuristic must not fire."""
1429
+
1430
+ source = """\
1431
+ __all__ = []
1432
+
1433
+
1434
+ def public_fn():
1435
+ pass
1436
+ """
1437
+ entities, _ = extract(source, "lib.py")
1438
+ by_id = {e["id"]: e for e in entities}
1439
+ fn_tags = by_id["python:function:lib.public_fn"].get("tags", [])
1440
+
1441
+ assert "public-surface" not in fn_tags
1442
+ assert "exported-api" not in fn_tags
1443
+
1444
+
1445
+ def test_dunder_all_membership_is_authoritative_over_test_name() -> None:
1446
+ """clarion-4ec50f3d92 (review follow-up): a declared ``__all__`` is
1447
+ authoritative. A ``Test``/``test_``-named name explicitly listed in
1448
+ ``__all__`` keeps ``exported-api`` — the test-name heuristic gates only the
1449
+ *inferred* ``public-surface`` fallback, never a declared export (mirrors
1450
+ ADR-053's 'the declaration is authoritative' principle)."""
1451
+
1452
+ source = """\
1453
+ __all__ = ["TestableThing", "test_vector_exported"]
1454
+
1455
+
1456
+ class TestableThing:
1457
+ pass
1458
+
1459
+
1460
+ def test_vector_exported():
1461
+ pass
1462
+
1463
+
1464
+ class TestUnlisted:
1465
+ pass
1466
+ """
1467
+ entities, _ = extract(source, "lib.py")
1468
+ by_id = {e["id"]: e for e in entities}
1469
+
1470
+ def tags(entity_id: str) -> list[str]:
1471
+ return by_id[entity_id].get("tags", [])
1472
+
1473
+ # Declared in __all__ → exported-api, even though test-named.
1474
+ assert "exported-api" in tags("python:class:lib.TestableThing")
1475
+ assert "exported-api" in tags("python:function:lib.test_vector_exported")
1476
+ # Still also classified as tests (both are reachability roots, no conflict).
1477
+ assert "test" in tags("python:class:lib.TestableThing")
1478
+ assert "test" in tags("python:function:lib.test_vector_exported")
1479
+ # A test-named class NOT in __all__ gets neither exported-api nor public-surface.
1480
+ assert "exported-api" not in tags("python:class:lib.TestUnlisted")
1481
+ assert "public-surface" not in tags("python:class:lib.TestUnlisted")
1482
+
1483
+
1265
1484
  def _wardline_vocabulary(
1266
1485
  *,
1267
1486
  confidence_basis: Literal["descriptor", "descriptor_version_skew"] = "descriptor",
@@ -17,7 +17,7 @@ def _read_toml(path: Path) -> dict[str, Any]:
17
17
 
18
18
 
19
19
  def test_package_version_matches_pyproject() -> None:
20
- assert loomweave_plugin_python.__version__ == "1.3.1"
20
+ assert loomweave_plugin_python.__version__ == "1.4.0"
21
21
 
22
22
 
23
23
  def test_plugin_version_lockstep_across_pyproject_manifest_and_module() -> None:
@@ -42,12 +42,12 @@ def test_plugin_version_lockstep_across_pyproject_manifest_and_module() -> None:
42
42
  def test_manifest_declares_current_v1_ontology_only() -> None:
43
43
  manifest = _read_toml(_PLUGIN_ROOT / "plugin.toml")
44
44
 
45
- assert manifest["plugin"]["version"] == "1.3.1"
45
+ assert manifest["plugin"]["version"] == "1.4.0"
46
46
  assert manifest["capabilities"]["runtime"]["wardline_aware"] is True
47
47
  assert manifest["integrations"]["wardline"]["expected_descriptor_version"] == (
48
48
  EXPECTED_DESCRIPTOR_VERSION
49
49
  )
50
- assert manifest["ontology"]["ontology_version"] == "0.8.0"
50
+ assert manifest["ontology"]["ontology_version"] == "0.9.0"
51
51
  assert manifest["ontology"]["entity_kinds"] == ["function", "class", "module"]
52
52
  assert manifest["ontology"]["edge_kinds"] == [
53
53
  "contains",
@@ -86,8 +86,8 @@ def test_initialize_roundtrip() -> None:
86
86
  assert response["id"] == 1
87
87
  result = response["result"]
88
88
  assert result["name"] == "loomweave-plugin-python"
89
- assert result["version"] == "1.3.1"
90
- assert result["ontology_version"] == "0.8.0"
89
+ assert result["version"] == "1.4.0"
90
+ assert result["ontology_version"] == "0.9.0"
91
91
  assert set(result["capabilities"]) == {"wardline"}
92
92
  assert result["capabilities"]["wardline"]["status"] in {
93
93
  "absent",
@@ -0,0 +1,309 @@
1
+ """Wardline → Loomweave trust-vocabulary descriptor wire conformance oracle.
2
+
3
+ The CONSUMER side of the cross-repo "Vocabulary descriptor (trust-vocab)" seam.
4
+ Wardline AUTHORS the NG-25 trust-vocabulary descriptor — it OWNS the vocabulary
5
+ via ``wardline.core.registry.REGISTRY`` and serialises it through
6
+ ``wardline.core.descriptor.build_vocabulary_descriptor`` / ``descriptor_to_yaml``
7
+ to ``.weft/wardline/vocabulary.yaml`` (``{schema: wardline.vocabulary/v1,
8
+ version, entries:[{canonical_name, group, attrs}]}``). Loomweave's Python plugin
9
+ is the real CONSUMER: ``loomweave_plugin_python.wardline_descriptor
10
+ .load_wardline_descriptor`` byte-reads that file (it NEVER imports wardline),
11
+ version-gates on ``EXPECTED_DESCRIPTOR_VERSION == "wardline-generic-2"``, and
12
+ parses ``entries`` into a ``WardlineVocabulary`` that the extractor threads into
13
+ ``wardline:external_boundary`` / ``wardline:trusted`` entity tags.
14
+
15
+ This oracle pins that the bytes Wardline produces are accepted + correctly
16
+ interpreted by Loomweave's REAL consumer code path. It mirrors the layering of
17
+ the taint-fact storage oracle
18
+ (``crates/loomweave-storage/tests/wardline_taint_fact_conformance_oracle.rs``):
19
+
20
+ * Layer 1 — a byte-pin (``test_vendored_golden_matches_byte_pin``): the
21
+ git-blob SHA-1 of the vendored golden, asserted against a const. If the
22
+ vendored fixture drifts by a single byte the pin reds. The SHA mirrors
23
+ wardline's own ``UPSTREAM_BLOB_SHA`` byte-pin idiom
24
+ (``wardline/tests/conformance/test_vocabulary_descriptor_wire_golden.py``)
25
+ so the two repos pin the SAME 40 hex chars. On its OWN this is circular
26
+ (it pins the vendored bytes against themselves); the non-circular breaks
27
+ are the consumer oracle below + the Layer-2 drift recheck.
28
+
29
+ * A NON-CIRCULAR consumer oracle: the vendored golden bytes are written to a
30
+ project descriptor location and fed through Loomweave's REAL
31
+ ``load_wardline_descriptor`` (resolve → read → ``yaml.safe_load`` → parse →
32
+ version-gate), asserting Loomweave ACCEPTS them (version accepted →
33
+ ``enabled``; entries → ``WardlineVocabulary`` with the expected
34
+ canonical_names / groups; the ``external_boundary`` / ``trusted`` decorator
35
+ attrs derive correctly). It then drives the REAL extractor
36
+ (``extractor.extract``) with that parsed-from-golden vocabulary to prove the
37
+ ``wardline:external_boundary`` / ``wardline:trusted`` TAGS derive correctly
38
+ end to end — not just that the lookup table parses. A version-skew copy
39
+ (only the ``version`` string bumped, every other byte the golden's) is fed
40
+ through the SAME real loader to prove the version gate is real, not
41
+ cosmetic: ``enabled`` flips to ``version_skew``. All assertions are driven
42
+ off the consumer's returned state, NOT off the golden restated against
43
+ itself.
44
+
45
+ * Layer 2 — a drift recheck (``test_vendored_golden_matches_wardline_authority``):
46
+ the vendored fixture bytes are compared against the authority golden in the
47
+ Wardline repo
48
+ (``$WARDLINE_REPO/tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml``,
49
+ ``WARDLINE_REPO`` defaulting to ``/home/john/wardline``). Skip-clean when the
50
+ sibling repo is absent (CI / detached checkout); fail-closed on any
51
+ divergence.
52
+
53
+ ── Scope / honesty caveat ──
54
+ This drives the consumer parse + gate (``load_wardline_descriptor``) and the
55
+ consumer tag-derivation (``extractor.extract`` with the parsed vocabulary) — the
56
+ same two code paths the plugin server runs: ``server.handle_initialize`` calls
57
+ ``load_wardline_descriptor`` and stashes ``.vocabulary`` on its state, and
58
+ ``server.handle_analyze_file`` threads that vocabulary into ``extract`` as
59
+ ``wardline_vocabulary=``. It does NOT spin up the JSON-RPC server loop itself;
60
+ the server's own tests cover that wiring. The non-circular guarantee comes from
61
+ driving the REAL parse/gate/derive on the producer-authored bytes, never the
62
+ golden against itself.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import hashlib
68
+ import os
69
+ from pathlib import Path
70
+
71
+ import pytest
72
+
73
+ from loomweave_plugin_python.extractor import extract
74
+ from loomweave_plugin_python.wardline_descriptor import (
75
+ EXPECTED_DESCRIPTOR_VERSION,
76
+ WardlineVocabulary,
77
+ load_wardline_descriptor,
78
+ )
79
+
80
+ # The vendored copy of wardline's authority golden, BYTE-IDENTICAL to
81
+ # wardline/tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml
82
+ # (confirmed via `cmp`). Read as bytes so the byte-pin sees the exact on-disk
83
+ # bytes wardline ships, not a yaml.safe_load round-trip.
84
+ GOLDEN_PATH = Path(__file__).parent / "fixtures" / "wardline-vocabulary-descriptor.golden.yaml"
85
+
86
+ # Layer-1 byte-pin: the git-blob SHA-1 of the vendored golden. This is the SAME
87
+ # 40 hex chars wardline pins as UPSTREAM_BLOB_SHA on the producer side
88
+ # (wardline/tests/conformance/test_vocabulary_descriptor_wire_golden.py) — the
89
+ # two repos pin identical bytes, so a one-sided re-vendor reds both suites.
90
+ # Recomputed below as sha1(b"blob %d\0" % len(data) + data). Any edit to the
91
+ # vendored golden without a matching re-pin reds this test.
92
+ UPSTREAM_BLOB_SHA = "f5ad8d2346ffb6ea75aa469e423c6c7cfd16d40a"
93
+
94
+ # The canonical decorator names the producer authors (group 1) and the attrs the
95
+ # consumer must surface for the trust-tier markers. Asserting on these proves the
96
+ # consumer parses the real authored format — including the trust-tier `attrs`
97
+ # that the existing inline-string unit tests cover but which here come straight
98
+ # off the producer's own bytes.
99
+ EXPECTED_CANONICAL_NAMES = ("external_boundary", "trust_boundary", "trusted")
100
+ EXPECTED_ATTRS = {
101
+ "external_boundary": {},
102
+ "trust_boundary": {"_wardline_to_level": "TaintState"},
103
+ "trusted": {"_wardline_level": "TaintState"},
104
+ }
105
+
106
+
107
+ def _write_project_descriptor(project_root: Path, text: str) -> None:
108
+ """Place descriptor bytes at the real .weft/wardline/ project location the
109
+ consumer reads (ADR-046)."""
110
+ descriptor = project_root / ".weft" / "wardline" / "vocabulary.yaml"
111
+ descriptor.parent.mkdir(parents=True, exist_ok=True)
112
+ descriptor.write_text(text, encoding="utf-8")
113
+
114
+
115
+ # ── Layer 1 — byte-pin ───────────────────────────────────────────────────────
116
+
117
+
118
+ def test_vendored_golden_matches_byte_pin() -> None:
119
+ """Layer-1: the vendored wardline-authored descriptor golden byte-pins to its
120
+ git-blob SHA-1. ANY edit to the vendored fixture without a matching re-pin
121
+ reds here. On its OWN this is circular (vendored bytes pinned against
122
+ themselves); the non-circular protection is the consumer oracle + the Layer-2
123
+ drift recheck below.
124
+
125
+ Tamper proof (verified out-of-band): a one-byte-tampered copy of the fixture
126
+ hashes to a DIFFERENT git-blob SHA-1, so this assert reds — the pin is
127
+ load-bearing, not decorative.
128
+ """
129
+ assert len(UPSTREAM_BLOB_SHA) == 40, (
130
+ f"UPSTREAM_BLOB_SHA must be a 40-char git blob SHA-1: {UPSTREAM_BLOB_SHA!r}"
131
+ )
132
+ assert set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), (
133
+ f"UPSTREAM_BLOB_SHA must be lowercase hex (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}"
134
+ )
135
+ data = GOLDEN_PATH.read_bytes()
136
+ actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() # noqa: S324 - git blob id, not a security hash
137
+ assert actual == UPSTREAM_BLOB_SHA, (
138
+ f"the vendored vocabulary-descriptor golden changed (git blob {actual}, "
139
+ f"pinned {UPSTREAM_BLOB_SHA}) — if this was a deliberate re-vendor, re-copy "
140
+ "BYTE-IDENTICAL from wardline "
141
+ "(tests/conformance/fixtures/wardline-vocabulary-descriptor.golden.yaml), "
142
+ "confirm with `cmp`, and update UPSTREAM_BLOB_SHA in the SAME commit; if not, "
143
+ "revert the edit."
144
+ )
145
+
146
+
147
+ # ── NON-CIRCULAR consumer oracle ─────────────────────────────────────────────
148
+
149
+
150
+ def test_consumer_accepts_golden_and_parses_vocabulary(tmp_path: Path) -> None:
151
+ """The vendored golden bytes (schema line included) fed through the REAL
152
+ ``load_wardline_descriptor`` are ACCEPTED: version-gated to ``enabled`` and
153
+ parsed into a ``WardlineVocabulary`` whose canonical_names / groups / attrs
154
+ match the producer-authored format. Driven off the consumer's returned state,
155
+ never the golden against itself."""
156
+ golden_text = GOLDEN_PATH.read_text("utf-8")
157
+ _write_project_descriptor(tmp_path, golden_text)
158
+
159
+ state = load_wardline_descriptor(tmp_path)
160
+
161
+ # Version accepted — the gate passed, so the descriptor is enabled.
162
+ assert state.status == "enabled", (
163
+ f"consumer rejected the producer-authored golden: status={state.status!r} "
164
+ f"reason={state.reason!r}"
165
+ )
166
+ assert state.source == "project"
167
+ assert state.descriptor_version == EXPECTED_DESCRIPTOR_VERSION
168
+
169
+ vocab = state.vocabulary
170
+ assert isinstance(vocab, WardlineVocabulary)
171
+ assert vocab.version == EXPECTED_DESCRIPTOR_VERSION
172
+ assert vocab.confidence_basis == "descriptor"
173
+
174
+ # entries → WardlineVocabulary with the expected canonical_names.
175
+ assert tuple(sorted(vocab.entries_by_name)) == tuple(sorted(EXPECTED_CANONICAL_NAMES))
176
+
177
+ # Each entry's group + attrs survive parse exactly. The trust-tier attrs
178
+ # (_wardline_to_level / _wardline_level) are the real cross-tool delta the
179
+ # inline-string unit tests duplicate but which here come off producer bytes.
180
+ for name in EXPECTED_CANONICAL_NAMES:
181
+ entry = vocab.entries_by_name[name]
182
+ assert entry.canonical_name == name
183
+ assert entry.group == 1, f"{name} must be a group-1 marker, got {entry.group}"
184
+ assert entry.attrs == EXPECTED_ATTRS[name], (
185
+ f"{name} attrs drifted: {entry.attrs!r} != {EXPECTED_ATTRS[name]!r}"
186
+ )
187
+
188
+ # entry_for_decorator (the real lookup the extractor calls) resolves the
189
+ # last dotted segment to the right entry — the external_boundary / trusted
190
+ # markers derive from the parsed table, not a restated f-string.
191
+ eb = vocab.entry_for_decorator("weft_markers.external_boundary")
192
+ assert eb is not None
193
+ assert eb.canonical_name == "external_boundary"
194
+ tr = vocab.entry_for_decorator("trusted")
195
+ assert tr is not None
196
+ assert tr.canonical_name == "trusted"
197
+
198
+
199
+ def test_consumer_derives_external_boundary_and_trusted_tags_through_extractor(
200
+ tmp_path: Path,
201
+ ) -> None:
202
+ """End-to-end consumer derivation: parse the golden through the REAL
203
+ ``load_wardline_descriptor``, then thread the parsed vocabulary into the REAL
204
+ ``extractor.extract``. This proves the ``wardline:external_boundary`` /
205
+ ``wardline:trusted`` TAGS derive correctly through the full consumer path —
206
+ not merely that the lookup table parsed."""
207
+ golden_text = GOLDEN_PATH.read_text("utf-8")
208
+ _write_project_descriptor(tmp_path, golden_text)
209
+
210
+ state = load_wardline_descriptor(tmp_path)
211
+ assert state.status == "enabled"
212
+ vocabulary = state.vocabulary
213
+ assert vocabulary is not None
214
+
215
+ source = """\
216
+ from weft_markers import external_boundary, trust_boundary, trusted
217
+
218
+ @external_boundary
219
+ def read_body():
220
+ return ""
221
+
222
+ @weft_markers.trust_boundary(to_level="ASSURED")
223
+ @trusted(level="INTEGRAL")
224
+ class Sanitizer:
225
+ pass
226
+ """
227
+
228
+ entities, _edges = extract(source, "service.py", wardline_vocabulary=vocabulary)
229
+
230
+ read_body = next(e for e in entities if e["id"] == "python:function:service.read_body")
231
+ sanitizer = next(e for e in entities if e["id"] == "python:class:service.Sanitizer")
232
+
233
+ # external_boundary tag derives from the golden-parsed vocabulary.
234
+ assert "wardline" in read_body["tags"]
235
+ assert "wardline:external_boundary" in read_body["tags"]
236
+ assert read_body["wardline"]["descriptor_version"] == EXPECTED_DESCRIPTOR_VERSION
237
+ assert read_body["wardline"]["confidence_basis"] == "descriptor"
238
+ eb_decorators = read_body["wardline"]["decorators"]
239
+ assert [d["canonical_name"] for d in eb_decorators] == ["external_boundary"]
240
+ assert eb_decorators[0]["attrs"] == {}
241
+
242
+ # trust_boundary + trusted tags derive, carrying the trust-tier attrs.
243
+ assert "wardline:trust_boundary" in sanitizer["tags"]
244
+ assert "wardline:trusted" in sanitizer["tags"]
245
+ san_attrs = {d["canonical_name"]: d["attrs"] for d in sanitizer["wardline"]["decorators"]}
246
+ assert san_attrs["trust_boundary"] == {"_wardline_to_level": "TaintState"}
247
+ assert san_attrs["trusted"] == {"_wardline_level": "TaintState"}
248
+
249
+
250
+ def test_consumer_version_gate_rejects_skew_copy(tmp_path: Path) -> None:
251
+ """The version gate is REAL, not cosmetic: the SAME golden bytes with ONLY
252
+ the ``version`` string bumped flip the consumer from ``enabled`` to
253
+ ``version_skew`` through the REAL ``load_wardline_descriptor``. The contrast
254
+ with ``test_consumer_accepts_golden_and_parses_vocabulary`` (identical bytes
255
+ bar the version) is the proof that the gate fires on version alone."""
256
+ golden_text = GOLDEN_PATH.read_text("utf-8")
257
+ assert EXPECTED_DESCRIPTOR_VERSION in golden_text, (
258
+ "the golden must carry the expected version for the skew derivation to be a "
259
+ "single-field perturbation"
260
+ )
261
+ skewed = golden_text.replace(EXPECTED_DESCRIPTOR_VERSION, "wardline-generic-3")
262
+ assert skewed != golden_text, "version-skew copy must differ from the golden"
263
+ _write_project_descriptor(tmp_path, skewed)
264
+
265
+ state = load_wardline_descriptor(tmp_path)
266
+
267
+ # The gate fired: a one-field version bump is REJECTED as skew.
268
+ assert state.status == "version_skew", (
269
+ f"version gate failed to fire on a skewed descriptor: status={state.status!r}"
270
+ )
271
+ assert state.descriptor_version == "wardline-generic-3"
272
+ assert state.expected_version == EXPECTED_DESCRIPTOR_VERSION
273
+ # The vocabulary still parses (entries are valid) but is flagged degraded —
274
+ # proving the gate keys on version, not on a parse failure.
275
+ assert state.vocabulary is not None
276
+ assert state.vocabulary.confidence_basis == "descriptor_version_skew"
277
+ assert tuple(sorted(state.vocabulary.entries_by_name)) == tuple(
278
+ sorted(EXPECTED_CANONICAL_NAMES)
279
+ )
280
+
281
+
282
+ # ── Layer 2 — drift recheck vs the Wardline source of truth ──────────────────
283
+
284
+
285
+ def test_vendored_golden_matches_wardline_authority() -> None:
286
+ """Layer-2: the vendored fixture bytes must equal the authority golden in the
287
+ Wardline repo. Skip-clean when the sibling repo is absent (CI / detached
288
+ checkout) — the vendored copy + Layer-1 pin still hold; fail-closed on any
289
+ divergence."""
290
+ repo = os.environ.get("WARDLINE_REPO", "/home/john/wardline")
291
+ authority = (
292
+ Path(repo)
293
+ / "tests"
294
+ / "conformance"
295
+ / "fixtures"
296
+ / "wardline-vocabulary-descriptor.golden.yaml"
297
+ )
298
+ if not authority.exists():
299
+ pytest.skip(
300
+ f"wardline authority golden not found at {authority} — skipping Layer-2 drift "
301
+ "recheck (set WARDLINE_REPO to enable)"
302
+ )
303
+
304
+ authority_bytes = authority.read_bytes()
305
+ vendored_bytes = GOLDEN_PATH.read_bytes()
306
+ assert authority_bytes == vendored_bytes, (
307
+ f"the vendored golden has DRIFTED from the Wardline authority at {authority}; "
308
+ "re-vendor BYTE-IDENTICAL (cmp must show no difference) and re-pin UPSTREAM_BLOB_SHA"
309
+ )
@@ -464,7 +464,7 @@ wheels = [
464
464
 
465
465
  [[package]]
466
466
  name = "loomweave-plugin-python"
467
- version = "1.3.1"
467
+ version = "1.4.0"
468
468
  source = { editable = "." }
469
469
  dependencies = [
470
470
  { name = "packaging" },