axis-synome 0.1.0.dev34__tar.gz → 0.1.0.dev35__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 (103) hide show
  1. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/PKG-INFO +1 -1
  2. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/WRITING_SPECS.md +6 -0
  3. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/pyproject.toml +5 -0
  4. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/_version.py +2 -2
  5. axis_synome-0.1.0.dev35/src/axis_synome/spec_validator/check_source_uuids.py +378 -0
  6. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome.egg-info/PKG-INFO +1 -1
  7. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome.egg-info/SOURCES.txt +2 -0
  8. axis_synome-0.1.0.dev35/tests/axis_synome/spec_validator/test_check_source_uuids.py +376 -0
  9. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/.flake8 +0 -0
  10. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/README.md +0 -0
  11. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/setup.cfg +0 -0
  12. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/__init__.py +0 -0
  13. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/__init__.py +0 -0
  14. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/README.md +0 -0
  15. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/__init__.py +0 -0
  16. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/alm_proxies.py +0 -0
  17. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/assets.py +0 -0
  18. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/assets_by_prime.py +0 -0
  19. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/networks.py +0 -0
  20. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/primes.py +0 -0
  21. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/protocol_sets.py +0 -0
  22. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/tokens.py +0 -0
  23. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/entities/types.py +0 -0
  24. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/asc.py +0 -0
  25. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/asc_collateral_ratio.py +0 -0
  26. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/asc_incentive.py +0 -0
  27. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/dab.py +0 -0
  28. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/latent_asc.py +0 -0
  29. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/ratio_latent_asc.py +0 -0
  30. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/asc/formulas/resting_asc.py +0 -0
  31. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/codegen_test/entities/agents.py +0 -0
  32. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/crypto_lending/__init__.py +0 -0
  33. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/crypto_lending/formulas/__init__.py +0 -0
  34. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/crypto_lending/formulas/lif.py +0 -0
  35. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/risk_capital/__init__.py +0 -0
  36. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/risk_capital/financial_rrc/__init__.py +0 -0
  37. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/risk_capital/financial_rrc/entities.py +0 -0
  38. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/risk_capital/financial_rrc/perpetual_positions.py +0 -0
  39. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/risk_capital/formulas/__init__.py +0 -0
  40. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/risk_capital/formulas/required_risk_capital.py +0 -0
  41. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/suraf/README.md +0 -0
  42. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/suraf/entities/assessor_score.py +0 -0
  43. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/suraf/entities/assets.py +0 -0
  44. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/suraf/entities/mappings.py +0 -0
  45. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/suraf/formulas/crr.py +0 -0
  46. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec/suraf/formulas/scoring.py +0 -0
  47. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/__init__.py +0 -0
  48. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/evm_address.py +0 -0
  49. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/pendle_validation.py +0 -0
  50. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/runtime/__init__.py +0 -0
  51. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/runtime/base.py +0 -0
  52. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/runtime/math.py +0 -0
  53. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/runtime/reference.py +0 -0
  54. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/validated_dataclass.py +0 -0
  55. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_support/validated_str.py +0 -0
  56. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_validator/__init__.py +0 -0
  57. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_validator/checker.py +0 -0
  58. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_validator/flake8_plugin.py +0 -0
  59. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome/spec_validator/python_subset.py +0 -0
  60. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome.egg-info/dependency_links.txt +0 -0
  61. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome.egg-info/entry_points.txt +0 -0
  62. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome.egg-info/requires.txt +0 -0
  63. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/src/axis_synome.egg-info/top_level.txt +0 -0
  64. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/__init__.py +0 -0
  65. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/conftest.py +0 -0
  66. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/mocks.py +0 -0
  67. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_alm_proxies.py +0 -0
  68. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_asc.py +0 -0
  69. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_asc_collateral_ratio.py +0 -0
  70. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_asc_incentive.py +0 -0
  71. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_dab.py +0 -0
  72. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_evm_address.py +0 -0
  73. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_latent_asc.py +0 -0
  74. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_prime_agent_data_validation.py +0 -0
  75. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_ratio_latent_asc.py +0 -0
  76. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/asc/test_resting_asc.py +0 -0
  77. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/risk_capital/__init__.py +0 -0
  78. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/risk_capital/financial_rrc/__init__.py +0 -0
  79. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/risk_capital/financial_rrc/test_perpetual_positions.py +0 -0
  80. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/risk_capital/formulas/__init__.py +0 -0
  81. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/risk_capital/formulas/test_loss_given_default.py +0 -0
  82. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/spec_support/__init__.py +0 -0
  83. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/spec_support/runtime/__init__.py +0 -0
  84. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/spec_support/runtime/test_base.py +0 -0
  85. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/spec_support/runtime/test_math.py +0 -0
  86. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/spec_validator/test_checker.py +0 -0
  87. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/spec_validator/test_flake8_plugin.py +0 -0
  88. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/__init__.py +0 -0
  89. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/entities/__init__.py +0 -0
  90. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/entities/test_assessor_score.py +0 -0
  91. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/formulas/__init__.py +0 -0
  92. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/formulas/test_crr.py +0 -0
  93. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/formulas/test_scoring.py +0 -0
  94. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/static/aave_ausdc/v1/crr_mapping.csv +0 -0
  95. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/static/aave_ausdc/v1/penalty.csv +0 -0
  96. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_1_scores.csv +0 -0
  97. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_2_scores.csv +0 -0
  98. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/static/aave_ausdc/v1/scorecards/Assessor_3_scores.csv +0 -0
  99. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/static/aave_ausdc/v1/weights.csv +0 -0
  100. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/suraf_client/__init__.py +0 -0
  101. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/suraf_client/suraf_client.py +0 -0
  102. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/tests/axis_synome/suraf/suraf_client/test_suraf_client.py +0 -0
  103. {axis_synome-0.1.0.dev34 → axis_synome-0.1.0.dev35}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis-synome
3
- Version: 0.1.0.dev34
3
+ Version: 0.1.0.dev35
4
4
  Summary: Axis specification modules (entities, formulas, validators)
5
5
  Author-email: Archon Tech <hello@archontech.ai>
6
6
  License: MIT
@@ -14,6 +14,9 @@ This is to facilitate the following goals, paraphrased from the [Axis Synome Tec
14
14
 
15
15
  ## Core Principles
16
16
 
17
+ > If you change this section, also update the distilled review bullets in
18
+ > `.github/instructions/axis-synome.instructions.md`.
19
+
17
20
  Specs encode Atlas calculation rules directly in typed Python.
18
21
  They are declarative, deterministic, side‑effect‑free computations; no I/O or mutable state.
19
22
 
@@ -48,6 +51,9 @@ Relations are traversable: clients and tooling can evaluate predicates across th
48
51
 
49
52
  ### Documentation and Provenance
50
53
 
54
+ > If you change this section, also update the distilled review bullets in
55
+ > `.github/instructions/axis-synome.instructions.md`.
56
+
51
57
  - Prefer function docstrings for descriptive text. Put the human‑readable description of what a formula does into the function’s docstring. Docstrings are code‑adjacent, IDE/hover friendly, and can be used to generate documentation.
52
58
  - As functions are also math formulas and documentation from docstrs will also be used for the mathematical exposition, avoid Python specific terminology.
53
59
  - Avoid duplicating long‑form descriptions in metadata.
@@ -74,3 +74,8 @@ select = [
74
74
  "PGH004",
75
75
  ]
76
76
  ignore = ["E402", "E501"]
77
+
78
+ # Prefer inline suppressions over a [tool.ruff.lint.per-file-ignores] table:
79
+ # put `# ruff: noqa: <CODE>` (file-level) or `# noqa: <CODE>` (line-level) in
80
+ # the source file itself, with a short reason. Keeps the rationale next to the
81
+ # code it justifies and avoids a central table that drifts as files move.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.0.dev34'
22
- __version_tuple__ = version_tuple = (0, 1, 0, 'dev34')
21
+ __version__ = version = '0.1.0.dev35'
22
+ __version_tuple__ = version_tuple = (0, 1, 0, 'dev35')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,378 @@
1
+ """Verify each ``:source_uuid:`` in spec docstrings resolves to an ``id`` in the
2
+ Atlas ``content/`` tree (one ``document.md`` per Atlas section, with YAML
3
+ frontmatter carrying ``id: <uuid>``).
4
+ """
5
+
6
+ # ruff: noqa: T201 -- CLI entry point legitimately prints to stdout/stderr.
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import ast
12
+ import json
13
+ import re
14
+ import sys
15
+ from collections import defaultdict
16
+ from collections.abc import Iterable, Iterator
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ # Canonical 8-4-4-4-12 hex UUID. Case-insensitive; we lowercase before compare.
21
+ _UUID_RE = re.compile(
22
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
23
+ )
24
+
25
+ # Generic reST field at the start of a docstring line: ``:name: value``.
26
+ # ``name`` is restricted to identifier-shaped tokens so we don't match URLs etc.
27
+ _FIELD_LINE_RE = re.compile(r"^:([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$")
28
+
29
+ # Allowlist of recognised docstring field names. Anything else triggers a
30
+ # warning so typos and unintended drift surface early.
31
+ ALLOWED_DOCSTRING_FIELDS: frozenset[str] = frozenset({"source_uuid"})
32
+
33
+ # YAML frontmatter ``id:`` field. We don't pull in a YAML parser for one line;
34
+ # the field is always emitted as ``id: <uuid>`` by the Atlas decompose tool.
35
+ _FRONTMATTER_ID_RE = re.compile(r"^id:\s+(" + _UUID_RE.pattern + r")\s*$")
36
+
37
+ # Defaults derived from this file's location so the CLI works from any cwd.
38
+ # `parents` indices: [0]=spec_validator, [1]=axis_synome (under src),
39
+ # [2]=src, [3]=axis_synome (package dir), [4]=python, [5]=repo root.
40
+ _HERE = Path(__file__).resolve()
41
+ DEFAULT_SPEC_ROOT: Path = _HERE.parents[1] / "spec"
42
+ DEFAULT_CONTENT_ROOT: Path = _HERE.parents[5] / "content"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class SpecRef:
47
+ """A single ``:source_uuid:`` reference extracted from a spec file."""
48
+
49
+ path: Path
50
+ lineno: int
51
+ symbol: str
52
+ uuid: str # lowercased
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FieldWarning:
57
+ """A non-fatal issue extracted alongside the spec references."""
58
+
59
+ path: Path
60
+ lineno: int
61
+ message: str
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class CheckResult:
66
+ spec_refs: list[SpecRef]
67
+ atlas_uuids: frozenset[str] # lowercased
68
+ missing: dict[str, list[SpecRef]] # uuid -> refs that point at it
69
+ warnings: list[FieldWarning]
70
+
71
+
72
+ # --------------------------------------------------------------------------- #
73
+ # Spec-side extraction
74
+ # --------------------------------------------------------------------------- #
75
+
76
+
77
+ def _iter_spec_files(spec_root: Path) -> Iterator[Path]:
78
+ yield from sorted(p for p in spec_root.rglob("*.py") if "__pycache__" not in p.parts)
79
+
80
+
81
+ def _process_docstring(
82
+ docstring: str,
83
+ *,
84
+ path: Path,
85
+ base_lineno: int,
86
+ symbol: str,
87
+ ) -> tuple[list[SpecRef], list[FieldWarning]]:
88
+ """Extract every reST ``:name: value`` field from a docstring.
89
+
90
+ For ``:source_uuid:`` we yield a :class:`SpecRef` (with a malformed-UUID
91
+ warning if the value isn't canonical 8-4-4-4-12 hex). For any other field
92
+ name we yield an "unknown field" warning so the allowlist in
93
+ :data:`ALLOWED_DOCSTRING_FIELDS` stays the single point of truth.
94
+ """
95
+ refs: list[SpecRef] = []
96
+ warnings: list[FieldWarning] = []
97
+ for offset, raw in enumerate(docstring.splitlines()):
98
+ line = raw.strip()
99
+ match = _FIELD_LINE_RE.match(line)
100
+ if not match:
101
+ continue
102
+ name = match.group(1)
103
+ value = match.group(2).strip()
104
+ lineno = base_lineno + offset
105
+
106
+ if name == "source_uuid":
107
+ uuid_match = _UUID_RE.fullmatch(value)
108
+ if uuid_match is None:
109
+ warnings.append(
110
+ FieldWarning(
111
+ path=path,
112
+ lineno=lineno,
113
+ message=(
114
+ f"':source_uuid:' value is not a canonical 8-4-4-4-12 "
115
+ f"hex UUID: {value!r}"
116
+ ),
117
+ )
118
+ )
119
+ continue
120
+ refs.append(
121
+ SpecRef(
122
+ path=path,
123
+ lineno=lineno,
124
+ symbol=symbol,
125
+ uuid=value.lower(),
126
+ )
127
+ )
128
+ continue
129
+
130
+ if name not in ALLOWED_DOCSTRING_FIELDS:
131
+ warnings.append(
132
+ FieldWarning(
133
+ path=path,
134
+ lineno=lineno,
135
+ message=(
136
+ f"unknown docstring field ':{name}:' — "
137
+ f"allowed fields: {sorted(ALLOWED_DOCSTRING_FIELDS)}"
138
+ ),
139
+ )
140
+ )
141
+
142
+ return refs, warnings
143
+
144
+
145
+ def _docstring_node(body: list[ast.stmt]) -> tuple[str, int] | None:
146
+ """Return ``(docstring, lineno)`` if first stmt is a bare string-literal Expr."""
147
+ if not body:
148
+ return None
149
+ first = body[0]
150
+ if (
151
+ isinstance(first, ast.Expr)
152
+ and isinstance(first.value, ast.Constant)
153
+ and isinstance(first.value.value, str)
154
+ ):
155
+ return first.value.value, first.value.lineno
156
+ return None
157
+
158
+
159
+ def _trailing_const_docstrings(body: list[ast.stmt]) -> Iterator[tuple[str, str, int]]:
160
+ """Yield ``(target_name, docstring, lineno)`` for ``X: Final[...] = v`` followed by ``\"\"\"...\"\"\"``.
161
+
162
+ Mirrors :class:`SpecChecker._validate_constant_docstrings` but only needs
163
+ the ``(name, docstring)`` pairs — we don't enforce structure here.
164
+ """
165
+ for prev, curr in zip(body, body[1:], strict=False):
166
+ if not (
167
+ isinstance(curr, ast.Expr)
168
+ and isinstance(curr.value, ast.Constant)
169
+ and isinstance(curr.value.value, str)
170
+ ):
171
+ continue
172
+ target_name: str | None = None
173
+ if isinstance(prev, ast.AnnAssign) and isinstance(prev.target, ast.Name):
174
+ target_name = prev.target.id
175
+ elif (
176
+ isinstance(prev, ast.Assign)
177
+ and len(prev.targets) == 1
178
+ and isinstance(prev.targets[0], ast.Name)
179
+ ):
180
+ target_name = prev.targets[0].id
181
+ if target_name is not None:
182
+ yield target_name, curr.value.value, curr.value.lineno
183
+
184
+
185
+ def extract_spec_refs(path: Path) -> tuple[list[SpecRef], list[FieldWarning]]:
186
+ """Extract every ``:source_uuid:`` reference (and field warnings) from one spec file."""
187
+ source = path.read_text(encoding="utf-8")
188
+ try:
189
+ tree = ast.parse(source, filename=str(path))
190
+ except SyntaxError as exc:
191
+ return [], [
192
+ FieldWarning(path=path, lineno=exc.lineno or 0, message=f"syntax error: {exc.msg}")
193
+ ]
194
+
195
+ refs: list[SpecRef] = []
196
+ warnings: list[FieldWarning] = []
197
+
198
+ module_doc = _docstring_node(tree.body)
199
+ if module_doc is not None:
200
+ text, lineno = module_doc
201
+ r, w = _process_docstring(text, path=path, base_lineno=lineno, symbol="<module>")
202
+ refs.extend(r)
203
+ warnings.extend(w)
204
+
205
+ for node in ast.walk(tree):
206
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
207
+ doc = _docstring_node(node.body)
208
+ if doc is not None:
209
+ text, lineno = doc
210
+ r, w = _process_docstring(text, path=path, base_lineno=lineno, symbol=node.name)
211
+ refs.extend(r)
212
+ warnings.extend(w)
213
+ if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
214
+ for name, text, lineno in _trailing_const_docstrings(list(node.body)):
215
+ r, w = _process_docstring(text, path=path, base_lineno=lineno, symbol=name)
216
+ refs.extend(r)
217
+ warnings.extend(w)
218
+
219
+ return refs, warnings
220
+
221
+
222
+ # --------------------------------------------------------------------------- #
223
+ # Atlas-side extraction
224
+ # --------------------------------------------------------------------------- #
225
+
226
+
227
+ def extract_atlas_uuids(content_root: Path) -> frozenset[str]:
228
+ """Extract ``id`` UUIDs from YAML frontmatter of every ``document.md`` under
229
+ ``content_root``. ``_index.md`` files (navigation only) are skipped.
230
+ """
231
+ uuids: set[str] = set()
232
+ for path in sorted(content_root.rglob("document.md")):
233
+ with path.open(encoding="utf-8") as fh:
234
+ if fh.readline().rstrip("\r\n") != "---":
235
+ continue
236
+ for raw in fh:
237
+ line = raw.rstrip("\r\n")
238
+ if line == "---":
239
+ break
240
+ match = _FRONTMATTER_ID_RE.match(line)
241
+ if match:
242
+ uuids.add(match.group(1).lower())
243
+ break
244
+ return frozenset(uuids)
245
+
246
+
247
+ # --------------------------------------------------------------------------- #
248
+ # Orchestration
249
+ # --------------------------------------------------------------------------- #
250
+
251
+
252
+ def check(
253
+ spec_root: Path,
254
+ content_root: Path | None,
255
+ ) -> CheckResult:
256
+ """Run extraction on both sides and compute the missing / warning sets.
257
+
258
+ If ``content_root`` is ``None`` the Atlas side is treated as empty and no
259
+ "missing" entries are produced — callers decide whether that's an error.
260
+ """
261
+ spec_refs: list[SpecRef] = []
262
+ warnings: list[FieldWarning] = []
263
+ for path in _iter_spec_files(spec_root):
264
+ r, w = extract_spec_refs(path)
265
+ spec_refs.extend(r)
266
+ warnings.extend(w)
267
+
268
+ by_uuid: dict[str, list[SpecRef]] = defaultdict(list)
269
+ for ref in spec_refs:
270
+ by_uuid[ref.uuid].append(ref)
271
+
272
+ # Duplicate-UUID across distinct *files* → warning. Multiple symbols in
273
+ # the same module pointing at the same Atlas section is the natural pattern
274
+ # (e.g. an arithmetic formula and its boolean predicate variant); only
275
+ # cross-file reuse is suspicious enough to surface.
276
+ for uuid, refs in by_uuid.items():
277
+ files = {r.path for r in refs}
278
+ if len(files) > 1:
279
+ sample = refs[0]
280
+ others = [r for r in refs if r.path != sample.path]
281
+ other_locations = ", ".join(f"{r.path}:{r.lineno}" for r in others)
282
+ warnings.append(
283
+ FieldWarning(
284
+ path=sample.path,
285
+ lineno=sample.lineno,
286
+ message=(
287
+ f"UUID {uuid} reused across distinct files (also at {other_locations})"
288
+ ),
289
+ )
290
+ )
291
+
292
+ if content_root is None:
293
+ atlas_uuids: frozenset[str] = frozenset()
294
+ missing: dict[str, list[SpecRef]] = {}
295
+ else:
296
+ atlas_uuids = extract_atlas_uuids(content_root)
297
+ missing = {uuid: refs for uuid, refs in by_uuid.items() if uuid not in atlas_uuids}
298
+
299
+ return CheckResult(
300
+ spec_refs=spec_refs,
301
+ atlas_uuids=atlas_uuids,
302
+ missing=missing,
303
+ warnings=warnings,
304
+ )
305
+
306
+
307
+ # --------------------------------------------------------------------------- #
308
+ # CLI
309
+ # --------------------------------------------------------------------------- #
310
+
311
+
312
+ def _format_missing(missing: dict[str, list[SpecRef]]) -> str:
313
+ lines = ["ERROR: spec :source_uuid: values not found in the Atlas content tree:"]
314
+ for uuid in sorted(missing):
315
+ refs = missing[uuid]
316
+ lines.append(f" {uuid}")
317
+ for ref in refs:
318
+ lines.append(f" {ref.path}:{ref.lineno} ({ref.symbol})")
319
+ return "\n".join(lines)
320
+
321
+
322
+ def _emit_map(result: CheckResult, path: Path) -> None:
323
+ payload = {
324
+ "spec_to_atlas": [
325
+ {
326
+ "uuid": ref.uuid,
327
+ "spec_path": str(ref.path),
328
+ "lineno": ref.lineno,
329
+ "symbol": ref.symbol,
330
+ }
331
+ for ref in sorted(result.spec_refs, key=lambda r: (str(r.path), r.lineno))
332
+ ],
333
+ }
334
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
335
+
336
+
337
+ def main(argv: Iterable[str] | None = None) -> int:
338
+ parser = argparse.ArgumentParser(
339
+ prog="check-source-uuids",
340
+ description="Verify spec :source_uuid: references resolve to UUIDs in the Atlas content tree.",
341
+ )
342
+ parser.add_argument(
343
+ "--emit-map",
344
+ type=Path,
345
+ default=None,
346
+ help="Optional path to write a JSON spec↔atlas map.",
347
+ )
348
+ args = parser.parse_args(list(argv) if argv is not None else None)
349
+
350
+ if not DEFAULT_SPEC_ROOT.is_dir():
351
+ print(f"ERROR: spec root not found: {DEFAULT_SPEC_ROOT}", file=sys.stderr)
352
+ return 2
353
+ if not DEFAULT_CONTENT_ROOT.is_dir():
354
+ print(f"ERROR: Atlas content tree not found: {DEFAULT_CONTENT_ROOT}", file=sys.stderr)
355
+ return 2
356
+
357
+ result = check(DEFAULT_SPEC_ROOT, DEFAULT_CONTENT_ROOT)
358
+
359
+ for warn in result.warnings:
360
+ print(f"WARN: {warn.path}:{warn.lineno}: {warn.message}", file=sys.stderr)
361
+
362
+ if args.emit_map is not None:
363
+ _emit_map(result, args.emit_map)
364
+
365
+ if result.missing:
366
+ print(_format_missing(result.missing), file=sys.stderr)
367
+ return 1
368
+
369
+ print(
370
+ f"OK: all {len(result.spec_refs)} spec :source_uuid: values "
371
+ f"({len({r.uuid for r in result.spec_refs})} unique) "
372
+ f"resolve to documents under {DEFAULT_CONTENT_ROOT}."
373
+ )
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis-synome
3
- Version: 0.1.0.dev34
3
+ Version: 0.1.0.dev35
4
4
  Summary: Axis specification modules (entities, formulas, validators)
5
5
  Author-email: Archon Tech <hello@archontech.ai>
6
6
  License: MIT
@@ -55,6 +55,7 @@ src/axis_synome/spec_support/runtime/base.py
55
55
  src/axis_synome/spec_support/runtime/math.py
56
56
  src/axis_synome/spec_support/runtime/reference.py
57
57
  src/axis_synome/spec_validator/__init__.py
58
+ src/axis_synome/spec_validator/check_source_uuids.py
58
59
  src/axis_synome/spec_validator/checker.py
59
60
  src/axis_synome/spec_validator/flake8_plugin.py
60
61
  src/axis_synome/spec_validator/python_subset.py
@@ -80,6 +81,7 @@ tests/axis_synome/spec_support/__init__.py
80
81
  tests/axis_synome/spec_support/runtime/__init__.py
81
82
  tests/axis_synome/spec_support/runtime/test_base.py
82
83
  tests/axis_synome/spec_support/runtime/test_math.py
84
+ tests/axis_synome/spec_validator/test_check_source_uuids.py
83
85
  tests/axis_synome/spec_validator/test_checker.py
84
86
  tests/axis_synome/spec_validator/test_flake8_plugin.py
85
87
  tests/axis_synome/suraf/__init__.py
@@ -0,0 +1,376 @@
1
+ """Tests for :mod:`axis_synome.spec_validator.check_source_uuids`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from axis_synome.spec_validator import check_source_uuids as mod
11
+ from axis_synome.spec_validator.check_source_uuids import (
12
+ ALLOWED_DOCSTRING_FIELDS,
13
+ check,
14
+ extract_atlas_uuids,
15
+ extract_spec_refs,
16
+ main,
17
+ )
18
+
19
+
20
+ def _write(p: Path, text: str) -> Path:
21
+ p.parent.mkdir(parents=True, exist_ok=True)
22
+ p.write_text(text, encoding="utf-8")
23
+ return p
24
+
25
+
26
+ def _write_doc(content_root: Path, doc_no: str, uuid: str) -> Path:
27
+ """Write a minimal Atlas content document with the given frontmatter id."""
28
+ return _write(
29
+ content_root / doc_no / "document.md",
30
+ f"---\nid: {uuid}\ndocNo: {doc_no}\n---\n\n# {doc_no}\n",
31
+ )
32
+
33
+
34
+ def _write_content_tree(content_root: Path, uuids: list[str]) -> Path:
35
+ """Lay out a fake Atlas content/ tree with one document per uuid."""
36
+ for i, uuid in enumerate(uuids):
37
+ _write_doc(content_root, f"X/{i}", uuid)
38
+ return content_root
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # extract_spec_refs
43
+ # --------------------------------------------------------------------------- #
44
+
45
+
46
+ def test_extract_function_docstring_uuid(tmp_path: Path) -> None:
47
+ spec = _write(
48
+ tmp_path / "f.py",
49
+ '''def foo() -> int:
50
+ """Summary.
51
+
52
+ :source_uuid: 11111111-2222-3333-4444-555555555555
53
+ """
54
+ return 1
55
+ ''',
56
+ )
57
+ refs, warnings = extract_spec_refs(spec)
58
+ assert warnings == []
59
+ assert len(refs) == 1
60
+ ref = refs[0]
61
+ assert ref.uuid == "11111111-2222-3333-4444-555555555555"
62
+ assert ref.symbol == "foo"
63
+ assert ref.path == spec
64
+ # Line number is the docstring line containing :source_uuid:
65
+ assert ref.lineno == 4
66
+
67
+
68
+ def test_extract_module_and_class_docstrings(tmp_path: Path) -> None:
69
+ spec = _write(
70
+ tmp_path / "f.py",
71
+ '''"""Module doc.
72
+
73
+ :source_uuid: AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE
74
+ """
75
+
76
+ class C:
77
+ """Class doc.
78
+
79
+ :source_uuid: 11111111-1111-1111-1111-111111111111
80
+ """
81
+ pass
82
+ ''',
83
+ )
84
+ refs, warnings = extract_spec_refs(spec)
85
+ assert warnings == []
86
+ by_symbol = {r.symbol: r for r in refs}
87
+ # Case-insensitive: extracted lowercased
88
+ assert by_symbol["<module>"].uuid == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
89
+ assert by_symbol["C"].uuid == "11111111-1111-1111-1111-111111111111"
90
+
91
+
92
+ def test_extract_final_parameter_docstring(tmp_path: Path) -> None:
93
+ spec = _write(
94
+ tmp_path / "f.py",
95
+ '''from typing import Final
96
+
97
+ BETA: Final[float] = 0.3
98
+ """:source_uuid: 22222222-3333-4444-5555-666666666666"""
99
+ ''',
100
+ )
101
+ refs, warnings = extract_spec_refs(spec)
102
+ assert warnings == []
103
+ assert len(refs) == 1
104
+ assert refs[0].symbol == "BETA"
105
+ assert refs[0].uuid == "22222222-3333-4444-5555-666666666666"
106
+
107
+
108
+ def test_unknown_field_warns(tmp_path: Path) -> None:
109
+ spec = _write(
110
+ tmp_path / "f.py",
111
+ '''def foo() -> int:
112
+ """Summary.
113
+
114
+ :source_uid: 11111111-2222-3333-4444-555555555555
115
+ :returns: nothing important
116
+ """
117
+ return 1
118
+ ''',
119
+ )
120
+ refs, warnings = extract_spec_refs(spec)
121
+ assert refs == []
122
+ field_names = sorted(
123
+ w.message.split("'")[1] for w in warnings if "unknown docstring field" in w.message
124
+ )
125
+ assert field_names == [":returns:", ":source_uid:"]
126
+ # Allowlist sanity: source_uuid is the only currently-permitted field.
127
+ assert frozenset({"source_uuid"}) == ALLOWED_DOCSTRING_FIELDS
128
+
129
+
130
+ def test_malformed_uuid_warns(tmp_path: Path) -> None:
131
+ spec = _write(
132
+ tmp_path / "f.py",
133
+ '''def foo() -> int:
134
+ """:source_uuid: not-a-uuid-but-36-chars-long-aaaaaaa"""
135
+ return 1
136
+ ''',
137
+ )
138
+ refs, warnings = extract_spec_refs(spec)
139
+ assert refs == []
140
+ assert len(warnings) == 1
141
+ assert "not a canonical" in warnings[0].message
142
+
143
+
144
+ def test_uuid_outside_docstring_ignored(tmp_path: Path) -> None:
145
+ spec = _write(
146
+ tmp_path / "f.py",
147
+ """# :source_uuid: 11111111-2222-3333-4444-555555555555
148
+ X = 1 # :source_uuid: 11111111-2222-3333-4444-555555555555
149
+ """,
150
+ )
151
+ refs, warnings = extract_spec_refs(spec)
152
+ assert refs == []
153
+ assert warnings == []
154
+
155
+
156
+ def test_pycache_skipped(tmp_path: Path) -> None:
157
+ _write(
158
+ tmp_path / "__pycache__" / "x.py",
159
+ '''def f():\n """:source_uuid: 11111111-2222-3333-4444-555555555555"""\n pass\n''',
160
+ )
161
+ result = check(tmp_path, content_root=None)
162
+ assert result.spec_refs == []
163
+
164
+
165
+ # --------------------------------------------------------------------------- #
166
+ # extract_atlas_uuids
167
+ # --------------------------------------------------------------------------- #
168
+
169
+
170
+ def test_atlas_extracts_id_from_document_frontmatter(tmp_path: Path) -> None:
171
+ content_root = tmp_path / "content"
172
+ _write_doc(content_root, "A/1", "11111111-2222-3333-4444-555555555555")
173
+ _write_doc(content_root, "NR/2", "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")
174
+ # _index.md files lack `id:` and should be ignored even if walked.
175
+ _write(content_root / "A" / "_index.md", "---\ntype: index\n---\n")
176
+ # Body-text mention of `id: <uuid>` outside frontmatter must not match.
177
+ _write_doc(content_root, "A/2", "22222222-2222-3333-4444-555555555555")
178
+ body_with_fake_id = (
179
+ "---\nid: 33333333-3333-3333-4444-555555555555\n---\n\n"
180
+ "Some prose mentioning id: 99999999-9999-9999-9999-999999999999 in body.\n"
181
+ )
182
+ _write(content_root / "A" / "3" / "document.md", body_with_fake_id)
183
+
184
+ uuids = extract_atlas_uuids(content_root)
185
+ assert uuids == frozenset({
186
+ "11111111-2222-3333-4444-555555555555",
187
+ "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
188
+ "22222222-2222-3333-4444-555555555555",
189
+ "33333333-3333-3333-4444-555555555555",
190
+ })
191
+
192
+
193
+ # --------------------------------------------------------------------------- #
194
+ # check() — end-to-end behavior
195
+ # --------------------------------------------------------------------------- #
196
+
197
+
198
+ def _fixture_pair(
199
+ tmp_path: Path, *, atlas_uuids: list[str], spec_uuids: list[str]
200
+ ) -> tuple[Path, Path]:
201
+ spec_root = tmp_path / "spec"
202
+ for i, uuid in enumerate(spec_uuids):
203
+ _write(
204
+ spec_root / f"f_{i}.py",
205
+ f'''def f_{i}() -> int:
206
+ """:source_uuid: {uuid}"""
207
+ return {i}
208
+ ''',
209
+ )
210
+ content_root = _write_content_tree(tmp_path / "content", atlas_uuids)
211
+ return spec_root, content_root
212
+
213
+
214
+ def test_check_happy_path(tmp_path: Path) -> None:
215
+ spec_root, content_root = _fixture_pair(
216
+ tmp_path,
217
+ atlas_uuids=["11111111-2222-3333-4444-555555555555"],
218
+ spec_uuids=["11111111-2222-3333-4444-555555555555"],
219
+ )
220
+ result = check(spec_root, content_root)
221
+ assert result.missing == {}
222
+ assert result.warnings == []
223
+
224
+
225
+ def test_check_missing_uuid_reports(tmp_path: Path) -> None:
226
+ spec_root, content_root = _fixture_pair(
227
+ tmp_path,
228
+ atlas_uuids=["11111111-2222-3333-4444-555555555555"],
229
+ spec_uuids=["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"],
230
+ )
231
+ result = check(spec_root, content_root)
232
+ assert list(result.missing) == ["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]
233
+ refs = result.missing["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]
234
+ assert len(refs) == 1
235
+ assert refs[0].symbol == "f_0"
236
+
237
+
238
+ def test_check_case_insensitive(tmp_path: Path) -> None:
239
+ spec_root, content_root = _fixture_pair(
240
+ tmp_path,
241
+ atlas_uuids=["AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"],
242
+ spec_uuids=["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"],
243
+ )
244
+ result = check(spec_root, content_root)
245
+ assert result.missing == {}
246
+
247
+
248
+ def test_check_duplicate_uuid_warns_only_across_distinct_files(tmp_path: Path) -> None:
249
+ spec_root = tmp_path / "spec"
250
+ # Two functions in different files share a UUID → cross-file → warn.
251
+ _write(
252
+ spec_root / "a.py",
253
+ '''def fa() -> int:
254
+ """:source_uuid: 11111111-2222-3333-4444-555555555555"""
255
+ return 1
256
+ ''',
257
+ )
258
+ _write(
259
+ spec_root / "b.py",
260
+ '''def fb() -> int:
261
+ """:source_uuid: 11111111-2222-3333-4444-555555555555"""
262
+ return 2
263
+ ''',
264
+ )
265
+ content_root = _write_content_tree(
266
+ tmp_path / "content", ["11111111-2222-3333-4444-555555555555"]
267
+ )
268
+ result = check(spec_root, content_root)
269
+ assert result.missing == {}
270
+ dup = [w for w in result.warnings if "reused across distinct files" in w.message]
271
+ assert len(dup) == 1
272
+
273
+
274
+ def test_check_same_file_repeating_uuid_no_warning(tmp_path: Path) -> None:
275
+ # Same Atlas UUID across multiple functions in the same module is the
276
+ # natural pattern (e.g. an arithmetic formula and its boolean predicate
277
+ # variant); should not warn.
278
+ spec_root = tmp_path / "spec"
279
+ _write(
280
+ spec_root / "a.py",
281
+ '''def f1() -> int:
282
+ """:source_uuid: 11111111-2222-3333-4444-555555555555"""
283
+ return 1
284
+
285
+
286
+ def f2() -> int:
287
+ """:source_uuid: 11111111-2222-3333-4444-555555555555"""
288
+ return 2
289
+ ''',
290
+ )
291
+ content_root = _write_content_tree(
292
+ tmp_path / "content", ["11111111-2222-3333-4444-555555555555"]
293
+ )
294
+ result = check(spec_root, content_root)
295
+ dup = [w for w in result.warnings if "reused across distinct" in w.message]
296
+ assert dup == []
297
+
298
+
299
+ # --------------------------------------------------------------------------- #
300
+ # CLI / main()
301
+ # --------------------------------------------------------------------------- #
302
+
303
+
304
+ def test_main_ok(
305
+ tmp_path: Path,
306
+ capsys: pytest.CaptureFixture[str],
307
+ monkeypatch: pytest.MonkeyPatch,
308
+ ) -> None:
309
+ spec_root, content_root = _fixture_pair(
310
+ tmp_path,
311
+ atlas_uuids=["11111111-2222-3333-4444-555555555555"],
312
+ spec_uuids=["11111111-2222-3333-4444-555555555555"],
313
+ )
314
+ monkeypatch.setattr(mod, "DEFAULT_SPEC_ROOT", spec_root)
315
+ monkeypatch.setattr(mod, "DEFAULT_CONTENT_ROOT", content_root)
316
+ rc = main([])
317
+ assert rc == 0
318
+ assert "OK:" in capsys.readouterr().out
319
+
320
+
321
+ def test_main_missing_returns_1(
322
+ tmp_path: Path,
323
+ capsys: pytest.CaptureFixture[str],
324
+ monkeypatch: pytest.MonkeyPatch,
325
+ ) -> None:
326
+ spec_root, content_root = _fixture_pair(
327
+ tmp_path,
328
+ atlas_uuids=[],
329
+ spec_uuids=["11111111-2222-3333-4444-555555555555"],
330
+ )
331
+ # Empty content/ wouldn't exist on disk after _write_content_tree([]); make
332
+ # sure the directory exists so the existence check passes.
333
+ content_root.mkdir(exist_ok=True)
334
+ monkeypatch.setattr(mod, "DEFAULT_SPEC_ROOT", spec_root)
335
+ monkeypatch.setattr(mod, "DEFAULT_CONTENT_ROOT", content_root)
336
+ rc = main([])
337
+ assert rc == 1
338
+ err = capsys.readouterr().err
339
+ assert "11111111-2222-3333-4444-555555555555" in err
340
+ assert "f_0" in err # symbol is reported
341
+
342
+
343
+ def test_main_missing_content_root_returns_2(
344
+ tmp_path: Path,
345
+ capsys: pytest.CaptureFixture[str],
346
+ monkeypatch: pytest.MonkeyPatch,
347
+ ) -> None:
348
+ spec_root = tmp_path / "spec"
349
+ spec_root.mkdir()
350
+ monkeypatch.setattr(mod, "DEFAULT_SPEC_ROOT", spec_root)
351
+ monkeypatch.setattr(mod, "DEFAULT_CONTENT_ROOT", tmp_path / "missing-content")
352
+ rc = main([])
353
+ assert rc == 2
354
+ assert "not found" in capsys.readouterr().err
355
+
356
+
357
+ def test_main_emit_map(
358
+ tmp_path: Path,
359
+ monkeypatch: pytest.MonkeyPatch,
360
+ ) -> None:
361
+ spec_root, content_root = _fixture_pair(
362
+ tmp_path,
363
+ atlas_uuids=["11111111-2222-3333-4444-555555555555"],
364
+ spec_uuids=["11111111-2222-3333-4444-555555555555"],
365
+ )
366
+ monkeypatch.setattr(mod, "DEFAULT_SPEC_ROOT", spec_root)
367
+ monkeypatch.setattr(mod, "DEFAULT_CONTENT_ROOT", content_root)
368
+ out = tmp_path / "map.json"
369
+ rc = main(["--emit-map", str(out)])
370
+ assert rc == 0
371
+ payload = json.loads(out.read_text(encoding="utf-8"))
372
+ assert payload.keys() == {"spec_to_atlas"}
373
+ entries = payload["spec_to_atlas"]
374
+ assert len(entries) == 1
375
+ assert entries[0]["uuid"] == "11111111-2222-3333-4444-555555555555"
376
+ assert entries[0]["symbol"] == "f_0"