nthlayer-common 2.1.0__tar.gz → 2.1.2__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 (122) hide show
  1. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/PKG-INFO +1 -1
  2. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/pyproject.toml +1 -1
  3. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/__init__.py +3 -3
  4. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/scan.py +56 -7
  5. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common.egg-info/PKG-INFO +1 -1
  6. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_manifest_scan.py +145 -4
  7. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/README.md +0 -0
  8. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/setup.cfg +0 -0
  9. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/__init__.py +0 -0
  10. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/api_client.py +0 -0
  11. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/clients/__init__.py +0 -0
  12. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/clients/base.py +0 -0
  13. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/clients/cortex.py +0 -0
  14. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/clients/mimir.py +0 -0
  15. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/clients/pagerduty.py +0 -0
  16. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/clients/slack.py +0 -0
  17. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/cloudevents.py +0 -0
  18. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/config.py +0 -0
  19. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/dependency_models.py +0 -0
  20. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/domain_models.py +0 -0
  21. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/errors.py +0 -0
  22. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/explanation.py +0 -0
  23. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/gate_models.py +0 -0
  24. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/governance_bridge/__init__.py +0 -0
  25. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/governance_bridge/emitter.py +0 -0
  26. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/governance_bridge/models.py +0 -0
  27. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/__init__.py +0 -0
  28. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/models.py +0 -0
  29. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/normalizer.py +0 -0
  30. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/ownership.py +0 -0
  31. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/ownership_providers/__init__.py +0 -0
  32. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/ownership_providers/backstage.py +0 -0
  33. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/ownership_providers/base.py +0 -0
  34. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/ownership_providers/kubernetes.py +0 -0
  35. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/ownership_providers/pagerduty.py +0 -0
  36. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/identity/resolver.py +0 -0
  37. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/llm.py +0 -0
  38. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/llm_structured.py +0 -0
  39. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/llm_stub.py +0 -0
  40. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/models.py +0 -0
  41. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/openslo/__init__.py +0 -0
  42. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/openslo/parser.py +0 -0
  43. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/parser/__init__.py +0 -0
  44. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/parser/_shared.py +0 -0
  45. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/parser/loader.py +0 -0
  46. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/parser/v1.py +0 -0
  47. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/parser/v2.py +0 -0
  48. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/target_validation.py +0 -0
  49. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/manifest/v1_compat.py +0 -0
  50. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/metrics.py +0 -0
  51. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/outcomes.py +0 -0
  52. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/overrides/__init__.py +0 -0
  53. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/overrides/ingestion.py +0 -0
  54. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/overrides/models.py +0 -0
  55. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/parsing.py +0 -0
  56. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/prompts.py +0 -0
  57. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/__init__.py +0 -0
  58. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/base.py +0 -0
  59. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/grafana.py +0 -0
  60. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/lock.py +0 -0
  61. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/mimir.py +0 -0
  62. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/pagerduty.py +0 -0
  63. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/prometheus.py +0 -0
  64. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/providers/registry.py +0 -0
  65. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/py.typed +0 -0
  66. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/__init__.py +0 -0
  67. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/errors.py +0 -0
  68. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/hashing.py +0 -0
  69. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/models.py +0 -0
  70. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/sqlite_store.py +0 -0
  71. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/store.py +0 -0
  72. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/verdict_bridge.py +0 -0
  73. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/records/verification.py +0 -0
  74. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/slack.py +0 -0
  75. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/slack_web.py +0 -0
  76. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/slo_models.py +0 -0
  77. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/telemetry.py +0 -0
  78. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/tiers.py +0 -0
  79. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/verdicts/__init__.py +0 -0
  80. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/verdicts/core.py +0 -0
  81. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/verdicts/models.py +0 -0
  82. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/verdicts/serialise.py +0 -0
  83. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/verdicts/sqlite_store.py +0 -0
  84. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common/verdicts/store.py +0 -0
  85. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common.egg-info/SOURCES.txt +0 -0
  86. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common.egg-info/dependency_links.txt +0 -0
  87. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common.egg-info/requires.txt +0 -0
  88. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/src/nthlayer_common.egg-info/top_level.txt +0 -0
  89. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_api_client.py +0 -0
  90. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_cloudevents.py +0 -0
  91. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_config.py +0 -0
  92. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_errors.py +0 -0
  93. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_explanation.py +0 -0
  94. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_governance_bridge.py +0 -0
  95. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_identity.py +0 -0
  96. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_llm.py +0 -0
  97. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_llm_structured.py +0 -0
  98. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_llm_stub.py +0 -0
  99. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_manifest_models.py +0 -0
  100. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_manifest_parser.py +0 -0
  101. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_manifest_real_specs.py +0 -0
  102. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_manifest_service_type.py +0 -0
  103. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_manifest_v2_archetypes.py +0 -0
  104. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_metrics.py +0 -0
  105. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_models.py +0 -0
  106. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_outcomes.py +0 -0
  107. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_overrides.py +0 -0
  108. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_prompts.py +0 -0
  109. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_providers.py +0 -0
  110. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_slack.py +0 -0
  111. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_slack_web.py +0 -0
  112. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_target_validation.py +0 -0
  113. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_telemetry.py +0 -0
  114. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_tier_errors.py +0 -0
  115. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_tiers.py +0 -0
  116. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_v1_to_v2_migration.py +0 -0
  117. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_verdicts_core.py +0 -0
  118. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_verdicts_models.py +0 -0
  119. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_verdicts_serialise.py +0 -0
  120. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_verdicts_sqlite_concurrency.py +0 -0
  121. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_verdicts_store.py +0 -0
  122. {nthlayer_common-2.1.0 → nthlayer_common-2.1.2}/tests/test_verdicts_v15_fields.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nthlayer-common
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Shared utilities for the NthLayer ecosystem
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nthlayer-common"
7
- version = "2.1.0"
7
+ version = "2.1.2"
8
8
  description = "Shared utilities for the NthLayer ecosystem"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -98,12 +98,12 @@ __all__ = [
98
98
  "VALID_TIERS",
99
99
  # Service-type helpers
100
100
  "is_valid_service_type",
101
- # Directory scanning (opensrm-3470)
101
+ "resolve_service_type",
102
+ "valid_service_types_phrase",
103
+ # Directory scanning
102
104
  "MANIFEST_SUFFIXES",
103
105
  "foreign_yaml_reason",
104
106
  "iter_manifest_files",
105
- "resolve_service_type",
106
- "valid_service_types_phrase",
107
107
  # Enums
108
108
  "DependencyCriticality",
109
109
  "SourceFormat",
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
 
27
27
  import yaml
28
28
 
29
+ from nthlayer_common.manifest.models import is_valid_service_type
29
30
  from nthlayer_common.manifest.parser.v1 import is_srm_v1_format
30
31
  from nthlayer_common.manifest.parser.v2 import is_opensrm_v2_format
31
32
 
@@ -38,8 +39,10 @@ MANIFEST_SUFFIXES = (".yaml", ".yml")
38
39
  def iter_manifest_files(specs_dir: str | Path) -> list[Path]:
39
40
  """Every ``.yaml``/``.yml`` file directly under ``specs_dir``, sorted.
40
41
 
41
- Sorted because callers dedupe on a first-wins basis: unsorted, which
42
- manifest wins for a service with two files would vary by filesystem.
42
+ Sorted so the order is stable across machines. That matters to any
43
+ caller that dedupes first-wins — learn/retrospective does; observe's
44
+ spec_loader does not, and whether it should is opensrm-3470's follow-up
45
+ rather than a property this function can assume.
43
46
 
44
47
  Returns empty for a path that is not a directory rather than raising,
45
48
  so a caller that wants its own error message for that case keeps the
@@ -48,7 +51,21 @@ def iter_manifest_files(specs_dir: str | Path) -> list[Path]:
48
51
  path = Path(specs_dir)
49
52
  if not path.is_dir():
50
53
  return []
51
- return sorted(p for p in path.iterdir() if p.suffix in MANIFEST_SUFFIXES)
54
+ # `not p.is_dir()` rather than `p.is_file()`: excluding DIRECTORIES was
55
+ # the point — a directory named `foo.yaml` would be counted as a broken
56
+ # manifest by every caller. is_file() also excludes a dangling symlink,
57
+ # which silently drops a stale overlay link that previously reached
58
+ # load_manifest and was counted.
59
+ #
60
+ # Suffix compared case-folded: `.YAML` was excluded outright — never
61
+ # loaded, never counted, never logged — which is the silent-subset-by-
62
+ # extension failure MANIFEST_SUFFIXES exists to prevent, one case-fold
63
+ # away.
64
+ return sorted(
65
+ p
66
+ for p in path.iterdir()
67
+ if not p.is_dir() and p.suffix.lower() in MANIFEST_SUFFIXES
68
+ )
52
69
 
53
70
 
54
71
  def foreign_yaml_reason(spec_file: str | Path) -> str | None:
@@ -77,9 +94,10 @@ def foreign_yaml_reason(spec_file: str | Path) -> str | None:
77
94
  its body is indistinguishable in principle from any other headerless
78
95
  YAML mapping — both are dicts with no apiVersion, no kind, no
79
96
  ``spec.service``. No content heuristic separates those, and adding more
80
- markers would not change it. Such a file is dropped; the caller should
81
- record it at debug so an investigator chasing a number that looks wrong
82
- can pick the trail back up.
97
+ markers would not change it. For such a file this returns a reason, so
98
+ the caller can record it rather than drop it without trace — which is
99
+ what lets an investigator chasing a number that looks wrong pick the
100
+ trail back up.
83
101
  """
84
102
  path = Path(spec_file)
85
103
  try:
@@ -117,11 +135,42 @@ def foreign_yaml_reason(spec_file: str | Path) -> str | None:
117
135
  if near_miss:
118
136
  return None
119
137
 
138
+ # Body recovery: a bad merge or a mis-indent can strip the header while
139
+ # leaving the body intact, and that is the common corruption.
140
+ #
141
+ # The key names differ by format, and getting them wrong makes this whole
142
+ # branch inert — which it was until opensrm-3470's edge-cases pass:
143
+ # v1 spec.slos a MAPPING of name -> config (parser/v1 does
144
+ # slos_data.items()), not a list
145
+ # v2 spec.slo a list of OpenSLO documents
146
+ # spec.judgment_slo a list
147
+ # spec.service a MAPPING; OpenSLO writes the same key as a
148
+ # plain string, which is what keeps OpenSLO out
149
+ # spec.outcomes a mapping
150
+ # v1 spec.type REQUIRED by parse_srm_v1 (v1.py:91), and
151
+ # checked against is_valid_service_type rather
152
+ # than "is a string": a Backstage catalog-info
153
+ # carries spec.type too (`service`, `library`),
154
+ # and counting those makes every Backstage
155
+ # Component in the directory a broken manifest
156
+ # v2 spec.owner REQUIRED by parse_opensrm_v2 (v2.py:104)
157
+ # spec.slos is accepted as either shape: v1 writes a mapping, and a
158
+ # partially-converted file is exactly the kind of thing that lands here.
159
+ #
160
+ # The required keys matter most: a file carrying the one key its format
161
+ # cannot be parsed without makes a stronger claim to being a manifest
162
+ # than any of the optional ones above. Checking only the optional keys
163
+ # was the same mistake as testing spec.slos for the wrong type —
164
+ # reasoning about shapes without asking what the parsers demand.
120
165
  spec = data.get("spec")
121
166
  if isinstance(spec, dict) and (
122
167
  isinstance(spec.get("service"), dict)
123
- or isinstance(spec.get("slos"), list)
168
+ or isinstance(spec.get("slos"), (dict, list))
169
+ or isinstance(spec.get("slo"), list)
170
+ or isinstance(spec.get("judgment_slo"), list)
124
171
  or isinstance(spec.get("outcomes"), dict)
172
+ or is_valid_service_type(spec.get("type"))
173
+ or isinstance(spec.get("owner"), dict)
125
174
  ):
126
175
  return None
127
176
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nthlayer-common
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Shared utilities for the NthLayer ecosystem
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -16,9 +16,8 @@ have now happened in nthlayer-workers:
16
16
 
17
17
  ``foreign_yaml_reason`` is that question, promoted here from
18
18
  ``nthlayer_workers.learn.retrospective`` so the three call sites that need
19
- it stop reaching into each other (opensrm-3470). ``iter_manifest_files``
20
- comes with it because ``.yml`` blindness is the same silent-subset bug
21
- reached by file extension rather than parse error.
19
+ it stop reaching into each other (opensrm-3470). ``iter_manifest_files`` comes with it — see MANIFEST_SUFFIXES for why both
20
+ suffixes, always.
22
21
 
23
22
  These tests are ported from the workers suite deliberately: the promotion
24
23
  must be behaviour-preserving, and porting the contract is how that is
@@ -54,7 +53,10 @@ _AIMING = {
54
53
  # Header stripped by a bad merge or a mis-indent, body intact. This is the
55
54
  # common corruption, and the case the gate's last extension exists for.
56
55
  "headerless, spec.service mapping": "spec:\n service:\n name: s\n type: api\n",
57
- "headerless, spec.slos": "spec:\n slos:\n - name: availability\n",
56
+ # v1 writes spec.slos as a MAPPING of name -> config. Encoding it as a
57
+ # list here is what let the inert-guard bug hide: the fixture agreed
58
+ # with the broken predicate instead of with the parser.
59
+ "headerless, spec.slos mapping": "spec:\n slos:\n availability:\n target: 99.9\n",
58
60
  "headerless, spec.outcomes": "spec:\n outcomes:\n decision_value: {}\n",
59
61
  # A typo'd kind or a drifted API group is an ordinary way a real manifest
60
62
  # breaks: aiming at us and missing still counts.
@@ -158,3 +160,142 @@ def test_iter_manifest_files_on_a_missing_directory_is_empty(tmp_path):
158
160
  """Callers check is_dir() for their own error messages; this returning
159
161
  empty rather than raising keeps that their decision."""
160
162
  assert iter_manifest_files(tmp_path / "nope") == []
163
+
164
+
165
+ def test_iter_manifest_files_skips_directories(tmp_path):
166
+ """A DIRECTORY named ``foo.yaml`` is not a manifest file — every caller
167
+ would otherwise count it as a manifest that failed to load."""
168
+ (tmp_path / "subdir.yaml").mkdir()
169
+ (tmp_path / "real.yaml").write_text("spec: {}\n")
170
+
171
+ assert [p.name for p in iter_manifest_files(tmp_path)] == ["real.yaml"]
172
+
173
+
174
+ # =============================================================================
175
+ # The body-recovery step must fire for REAL manifest shapes
176
+ # =============================================================================
177
+ #
178
+ # opensrm-oh27's fifth and final gate iteration added body recovery: a bad
179
+ # merge or a mis-indent can strip the header while leaving the body intact,
180
+ # and that is the common corruption. It was landed as "the last extension"
181
+ # on the grounds that the remaining leak was unclosable in principle.
182
+ #
183
+ # It was inert. `spec.slos` was tested with isinstance(..., list), but v1
184
+ # writes it as a MAPPING (parser/v1.py does slos_data.items()), and v2 does
185
+ # not use `spec.slos` at all — its keys are `spec.slo` and
186
+ # `spec.judgment_slo`. So the branch never fired for any real manifest, in
187
+ # either format, and a header-stripped manifest was dropped as foreign with
188
+ # parse_failures == 0: the exact silent subset this bead family exists to
189
+ # remove, inside the guard meant to prevent it.
190
+ #
191
+ # Found by opensrm-3470's edge-cases pass. Not caught earlier because the
192
+ # promotion was verified against the ORIGINAL, which had the same defect —
193
+ # faithful copying proves faithfulness, not correctness.
194
+
195
+
196
+ @pytest.mark.parametrize(
197
+ ("label", "body"),
198
+ [
199
+ # v1: spec.slos is a mapping of name -> config
200
+ ("v1 slos mapping", "spec:\n type: api\n slos:\n availability:\n target: 99.9\n"),
201
+ # v2: a list of OpenSLO documents
202
+ ("v2 slo list", "spec:\n slo:\n - apiVersion: openslo/v1\n kind: SLO\n"),
203
+ # v2: judgment SLOs
204
+ ("v2 judgment_slo", "spec:\n judgment_slo:\n - metadata: {name: j}\n"),
205
+ # already covered, kept so a regression in either direction shows here
206
+ ("v2 service mapping", "spec:\n service:\n name: s\n type: api\n"),
207
+ ("v2 outcomes", "spec:\n outcomes:\n decision_value: {}\n"),
208
+ ],
209
+ )
210
+ def test_headerless_manifest_bodies_are_recovered(tmp_path, label, body):
211
+ """A header-stripped manifest must COUNT, not be dropped as foreign."""
212
+ path = tmp_path / "x.yaml"
213
+ path.write_text(body)
214
+
215
+ assert foreign_yaml_reason(path) is None, (
216
+ f"{label}: a headerless manifest body was classified as foreign YAML, "
217
+ f"so it would be dropped with parse_failures == 0"
218
+ )
219
+
220
+
221
+ def test_openslo_still_excluded_after_widening_the_body_check(tmp_path):
222
+ """Widening body recovery must not start swallowing OpenSLO documents.
223
+
224
+ OpenSLO writes spec.service as a STRING and has spec.objectives; it has
225
+ no spec.slos/slo/judgment_slo. If widening broke this, every OpenSLO file
226
+ in a specs directory would start counting as a broken manifest.
227
+ """
228
+ path = tmp_path / "x.yaml"
229
+ path.write_text("spec:\n service: my-svc\n objectives:\n - target: 0.99\n")
230
+
231
+ assert foreign_yaml_reason(path) is not None
232
+
233
+
234
+ @pytest.mark.parametrize("name", ["a.YAML", "b.Yml", "c.YmL"])
235
+ def test_iter_manifest_files_is_case_insensitive_on_suffix(tmp_path, name):
236
+ """`.YAML` was excluded — never loaded, never counted, never logged.
237
+
238
+ The same silent-subset-by-extension failure MANIFEST_SUFFIXES exists to
239
+ prevent, one case-fold away."""
240
+ (tmp_path / name).write_text("spec: {}\n")
241
+
242
+ assert [p.name for p in iter_manifest_files(tmp_path)] == [name]
243
+
244
+
245
+ def test_a_dangling_symlink_is_still_listed(tmp_path):
246
+ """is_file() follows symlinks, so a broken one vanished from the listing.
247
+
248
+ Before the is_file() guard it reached load_manifest and raised, so it was
249
+ counted. Silently dropping a stale overlay symlink is a regression the
250
+ guard introduced; excluding directories was the point, not excluding
251
+ everything that is not a readable file.
252
+ """
253
+ (tmp_path / "broken.yaml").symlink_to(tmp_path / "does-not-exist.yaml")
254
+
255
+ assert [p.name for p in iter_manifest_files(tmp_path)] == ["broken.yaml"]
256
+
257
+
258
+ @pytest.mark.parametrize(
259
+ ("label", "body"),
260
+ [
261
+ # parser/v1.py:91 — "spec.type is required"
262
+ ("v1 required type", "spec:\n type: api\n dependencies:\n - name: x\n"),
263
+ # parser/v2.py:104 — "spec.owner is required in v2 manifests"
264
+ ("v2 required owner", "spec:\n owner:\n group: group:default/t\n contracts: []\n"),
265
+ ],
266
+ )
267
+ def test_body_recovery_covers_the_keys_the_parsers_require(tmp_path, label, body):
268
+ """Body recovery checked OPTIONAL keys and omitted the required ones.
269
+
270
+ A file carrying the one key its format cannot be parsed without is about
271
+ as strong a claim to being a manifest as a body can make — stronger than
272
+ the optional keys already accepted. Same class as the inert-list bug: a
273
+ predicate reasoning about shapes without checking what the parsers
274
+ actually demand.
275
+ """
276
+ path = tmp_path / "x.yaml"
277
+ path.write_text(body)
278
+
279
+ assert foreign_yaml_reason(path) is None, f"{label} was dropped as foreign"
280
+
281
+
282
+ def test_backstage_component_is_not_a_manifest(tmp_path):
283
+ """A Backstage catalog-info.yaml carries `spec.type` and `spec.owner` too.
284
+
285
+ Accepting any string `spec.type` as a manifest marker counts every
286
+ Backstage Component in a specs directory as a broken manifest — the
287
+ noisy direction, which is how a coverage caveat stops being read.
288
+
289
+ `spec.type` only counts when it is a value OpenSRM would accept:
290
+ `service` is not one, `api` is. Backstage's `spec.owner` is a string
291
+ where v2's is a mapping, so that marker already discriminates.
292
+ """
293
+ path = tmp_path / "catalog-info.yaml"
294
+ path.write_text(
295
+ "apiVersion: backstage.io/v1alpha1\n"
296
+ "kind: Component\n"
297
+ "metadata: {name: svc}\n"
298
+ "spec: {type: service, lifecycle: production, owner: team-a}\n"
299
+ )
300
+
301
+ assert foreign_yaml_reason(path) is not None