embeddings-cli 0.9.0__tar.gz → 0.9.1__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 (157) hide show
  1. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.github/workflows/tests.yml +15 -0
  2. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/CHANGELOG.md +10 -0
  3. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/PKG-INFO +1 -1
  4. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/export.py +1 -1
  5. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/gaps.py +21 -11
  6. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/map.py +7 -3
  7. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/regions.py +7 -3
  8. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/pyproject.toml +1 -1
  9. embeddings_cli-0.9.1/scripts/sonar_open_issues.py +147 -0
  10. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_backend_client.py +6 -3
  11. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_cli_contract.py +2 -1
  12. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_corpus.py +2 -1
  13. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_directions.py +8 -4
  14. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_export.py +16 -8
  15. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_gaps.py +2 -1
  16. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_neighbors.py +2 -1
  17. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_numeric.py +2 -1
  18. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_regions.py +2 -1
  19. embeddings_cli-0.9.1/tests/test_sonar_gate.py +152 -0
  20. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_stability.py +2 -1
  21. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/uv.lock +1 -1
  22. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/agent-config/SKILL.md +0 -0
  23. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  24. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/agent-config/scripts/show.sh +0 -0
  25. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/ask-colleague/SKILL.md +0 -0
  26. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
  27. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/ask-colleague/prompts/review.md +0 -0
  28. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/ask-colleague/prompts/write.md +0 -0
  29. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
  30. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  31. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  32. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/challenge/SKILL.md +0 -0
  33. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/cicd/SKILL.md +0 -0
  34. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  35. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  36. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  37. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  38. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  39. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/SKILL.md +0 -0
  40. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  41. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  42. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  43. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  44. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
  45. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  46. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/deviate/SKILL.md +0 -0
  47. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  48. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  49. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  50. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  51. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/recall/SKILL.md +0 -0
  52. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/recall/scripts/recall.sh +0 -0
  53. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/remember/SKILL.md +0 -0
  54. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/remember/scripts/remember.sh +0 -0
  55. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/run-tests/SKILL.md +0 -0
  56. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/run-tests/scripts/test.sh +0 -0
  57. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/scope/SKILL.md +0 -0
  58. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/sonarclaude/SKILL.md +0 -0
  59. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  60. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  61. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  62. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/summarize-delivery/SKILL.md +0 -0
  63. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/think/SKILL.md +0 -0
  64. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/think/scripts/think.sh +0 -0
  65. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/version-bump/SKILL.md +0 -0
  66. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills/version-bump/scripts/bump.py +0 -0
  67. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.claude/skills.local.yaml.example +0 -0
  68. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.devague/current +0 -0
  69. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.devague/current_plan +0 -0
  70. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.devague/frames/embeddings-survey-surface.json +0 -0
  71. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.devague/plans/embeddings-survey-surface.json +0 -0
  72. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.flake8 +0 -0
  73. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.github/workflows/publish.yml +0 -0
  74. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.gitignore +0 -0
  75. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/.markdownlint-cli2.yaml +0 -0
  76. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/AGENTS.colleague.md +0 -0
  77. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/CLAUDE.md +0 -0
  78. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/LICENSE +0 -0
  79. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/README.md +0 -0
  80. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/culture.yaml +0 -0
  81. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/design-notes/semantic-measurement.md +0 -0
  82. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/evidence/2026-07-20-live-end-to-end.md +0 -0
  83. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/evidence/build_corpus.py +0 -0
  84. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/evidence/corpus.jsonl +0 -0
  85. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/evidence/map.html +0 -0
  86. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/evidence/pairs-legal-to-technical.txt +0 -0
  87. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/evidence/probes-legal.txt +0 -0
  88. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/plans/2026-07-20-embeddings-survey-surface.md +0 -0
  89. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/skill-sources.md +0 -0
  90. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/specs/2026-07-20-embeddings-survey-surface.md +0 -0
  91. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/2026-07-20-eidetic-embedder.md +0 -0
  92. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/atlas-instruct/atlas.json +0 -0
  93. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/atlas-instruct/records.jsonl +0 -0
  94. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/atlas-long/atlas.json +0 -0
  95. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/atlas-long/records.jsonl +0 -0
  96. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/atlas-plain/atlas.json +0 -0
  97. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/atlas-plain/records.jsonl +0 -0
  98. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/build_probe_corpus.py +0 -0
  99. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/measure_pairs.py +0 -0
  100. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/pairs-contra-high.txt +0 -0
  101. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/pairs-contra-low.txt +0 -0
  102. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/pairs-negation-contra.txt +0 -0
  103. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/pairs-para-high.txt +0 -0
  104. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/pairs-value-contra.txt +0 -0
  105. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/probe-corpus-instruct.jsonl +0 -0
  106. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/probe-corpus-long.jsonl +0 -0
  107. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/probe-corpus.jsonl +0 -0
  108. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/probes-heldout.txt +0 -0
  109. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/docs/studies/probes.py +0 -0
  110. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/__init__.py +0 -0
  111. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/__main__.py +0 -0
  112. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/_atomic.py +0 -0
  113. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/_numeric.py +0 -0
  114. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/atlas/__init__.py +0 -0
  115. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/atlas/io.py +0 -0
  116. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/atlas/schema.py +0 -0
  117. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/backends/__init__.py +0 -0
  118. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/backends/cache.py +0 -0
  119. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/backends/client.py +0 -0
  120. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/__init__.py +0 -0
  121. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/__init__.py +0 -0
  122. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/_atlas_arg.py +0 -0
  123. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/cli.py +0 -0
  124. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/compare.py +0 -0
  125. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/directions.py +0 -0
  126. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/doctor.py +0 -0
  127. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/explain.py +0 -0
  128. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/learn.py +0 -0
  129. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/neighbors.py +0 -0
  130. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/overview.py +0 -0
  131. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/survey.py +0 -0
  132. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_commands/whoami.py +0 -0
  133. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_errors.py +0 -0
  134. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_output.py +0 -0
  135. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/cli/_provenance.py +0 -0
  136. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/corpus.py +0 -0
  137. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/explain/__init__.py +0 -0
  138. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/explain/catalog.py +0 -0
  139. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/measure/__init__.py +0 -0
  140. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/embeddings/measure/stability.py +0 -0
  141. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/sonar-project.properties +0 -0
  142. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/__init__.py +0 -0
  143. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/_fakes.py +0 -0
  144. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/_viewer_contract.py +0 -0
  145. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_atlas_arg.py +0 -0
  146. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_atlas_io.py +0 -0
  147. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_atlas_schema.py +0 -0
  148. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_atomic.py +0 -0
  149. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_backend_cache.py +0 -0
  150. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_cli.py +0 -0
  151. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_cli_introspection.py +0 -0
  152. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_compare.py +0 -0
  153. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_fakes.py +0 -0
  154. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_map.py +0 -0
  155. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_provenance.py +0 -0
  156. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_roadmap_lockstep.py +0 -0
  157. {embeddings_cli-0.9.0 → embeddings_cli-0.9.1}/tests/test_survey.py +0 -0
@@ -37,6 +37,21 @@ jobs:
37
37
  env:
38
38
  SONAR_HOST_URL: https://sonarcloud.io
39
39
 
40
+ # Stricter than the quality gate: any issue left Open or Confirmed fails the run.
41
+ # An issue marked Accepted on SonarCloud does not. The scan step above waits for
42
+ # the analysis (sonar.qualitygate.wait), so the issue list is complete here.
43
+ - name: No open SonarCloud issues
44
+ if: env.SONAR_TOKEN != ''
45
+ env:
46
+ PR_NUMBER: ${{ github.event.pull_request.number }}
47
+ BRANCH_NAME: ${{ github.ref_name }}
48
+ run: |
49
+ if [ -n "$PR_NUMBER" ]; then
50
+ python3 scripts/sonar_open_issues.py --pull-request "$PR_NUMBER"
51
+ else
52
+ python3 scripts/sonar_open_issues.py --branch "$BRANCH_NAME"
53
+ fi
54
+
40
55
  lint:
41
56
  runs-on: ubuntu-latest
42
57
  permissions:
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/). This project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.1] - 2026-11-14
9
+
10
+ ### Added
11
+
12
+ - CI fails when SonarCloud holds an issue left Open or Confirmed for the pull request or, on main, for the branch (`scripts/sonar_open_issues.py`, run after the scan). An issue marked Accepted does not fail it.
13
+
14
+ ### Fixed
15
+
16
+ - The 28 SonarCloud issues open on main: composite assertions split in the tests, runs of `append` replaced by `extend` in `gaps`, `map` and `regions`, and the redacted parameters of `export` built as a dict.
17
+
8
18
  ## [0.9.0] - 2026-11-13
9
19
 
10
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embeddings-cli
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Maps the geometry of meaning produced by embedding models — survey a corpus into a semantic landscape, then inspect its regions, neighborhoods, directions, and gaps, and compare models. Backs a Surveyor agent that runs measured experiments against the space and records an evidence-backed Atlas.
5
5
  Project-URL: Homepage, https://github.com/agentculture/embeddings-cli
6
6
  Project-URL: Issues, https://github.com/agentculture/embeddings-cli/issues
@@ -359,7 +359,7 @@ def _provenance(atlas: Atlas, endpoint_name: str | None) -> dict:
359
359
  }
360
360
  if endpoint_name is not None:
361
361
  provenance["endpoint"] = endpoint_name
362
- params = _redact(dict(manifest.params), needles)
362
+ params = {key: _redact(item, needles) for key, item in manifest.params.items()}
363
363
  corpus = params.get("corpus")
364
364
  if isinstance(corpus, str) and corpus:
365
365
  # The file's own name only (either separator), never the folders above it.
@@ -1003,18 +1003,24 @@ def _render(payload: dict) -> str:
1003
1003
  "",
1004
1004
  ]
1005
1005
  for finding in payload["corpus_coverage_findings"]:
1006
- lines.append(f"- [{finding['type']}] {finding['shows']}")
1007
- lines.append(f" does not show: {finding['does_not_show']}")
1008
- lines.append(f" asserted: {finding['asserted']} ({finding['stability_verdict']})")
1009
- lines.append("")
1006
+ lines.extend(
1007
+ [
1008
+ f"- [{finding['type']}] {finding['shows']}",
1009
+ f" does not show: {finding['does_not_show']}",
1010
+ f" asserted: {finding['asserted']} ({finding['stability_verdict']})",
1011
+ "",
1012
+ ]
1013
+ )
1010
1014
  if not payload["corpus_coverage_findings"]:
1011
1015
  lines += ["(no area of this atlas met the sparsity or void thresholds above)", ""]
1012
1016
 
1013
- lines.append(
1014
- f"## model representation — {payload['counts']['model_representation']} finding(s)"
1017
+ lines.extend(
1018
+ [
1019
+ f"## model representation — {payload['counts']['model_representation']} finding(s)",
1020
+ " what probing actually showed. Only ever produced by a live probe step.",
1021
+ "",
1022
+ ]
1015
1023
  )
1016
- lines.append(" what probing actually showed. Only ever produced by a live probe step.")
1017
- lines.append("")
1018
1024
  if not step["requested"]:
1019
1025
  lines += [
1020
1026
  "no probe step ran (probing is off by default), so no claim about the model is "
@@ -1031,9 +1037,13 @@ def _render(payload: dict) -> str:
1031
1037
  ]
1032
1038
  else:
1033
1039
  for finding in payload["model_representation_findings"]:
1034
- lines.append(f"- [{finding['verdict']}] {finding['shows']}")
1035
- lines.append(f" does not show: {finding['does_not_show']}")
1036
- lines.append("")
1040
+ lines.extend(
1041
+ [
1042
+ f"- [{finding['verdict']}] {finding['shows']}",
1043
+ f" does not show: {finding['does_not_show']}",
1044
+ "",
1045
+ ]
1046
+ )
1037
1047
  if not payload["model_representation_findings"]:
1038
1048
  lines += ["(the probe step ran but produced no finding; see inconclusive below)", ""]
1039
1049
 
@@ -614,9 +614,13 @@ def _render_text(payload: dict) -> str:
614
614
  lines.append("re-run with --apply to write the artifact. Nothing was written.")
615
615
  lines.append("")
616
616
 
617
- lines.append(payload["caveat"])
618
- lines.append("")
619
- lines.append("uncertain (belongs in the atlas manifest's uncertain[]):")
617
+ lines.extend(
618
+ [
619
+ payload["caveat"],
620
+ "",
621
+ "uncertain (belongs in the atlas manifest's uncertain[]):",
622
+ ]
623
+ )
620
624
  lines += [f"- {entry}" for entry in payload["uncertain"]]
621
625
  lines.append("")
622
626
  lines.append(reproduce_line(payload[REPRODUCED_BY_KEY]))
@@ -489,9 +489,13 @@ def _render(payload: dict) -> str:
489
489
  ]
490
490
  for region in payload["regions"]:
491
491
  names = ", ".join(item["id"] for item in region["representatives"])
492
- lines.append(f"region {region['id']} — {region['size']} items")
493
- lines.append(f" representatives: {names}")
494
- lines.append(f" stability: {region['stability']['verdict']}")
492
+ lines.extend(
493
+ [
494
+ f"region {region['id']} — {region['size']} items",
495
+ f" representatives: {names}",
496
+ f" stability: {region['stability']['verdict']}",
497
+ ]
498
+ )
495
499
  if region["finding"]:
496
500
  lines.append(f" finding: {region['finding']}")
497
501
  lines.append("")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "embeddings-cli"
3
- version = "0.9.0"
3
+ version = "0.9.1"
4
4
  description = "Maps the geometry of meaning produced by embedding models — survey a corpus into a semantic landscape, then inspect its regions, neighborhoods, directions, and gaps, and compare models. Backs a Surveyor agent that runs measured experiments against the space and records an evidence-backed Atlas."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Fail when SonarCloud holds open issues for this pull request or branch.
3
+
4
+ python3 scripts/sonar_open_issues.py --pull-request 12
5
+ python3 scripts/sonar_open_issues.py --branch main
6
+
7
+ The quality gate only looks at new code against thresholds, so a pull request can pass it
8
+ while carrying open issues. This check is stricter: any issue whose status is Open or
9
+ Confirmed fails the run. An issue marked Accepted (or False positive, or Fixed) does not:
10
+ accepting an issue on SonarCloud is the way to keep it on purpose.
11
+
12
+ It reads the project key from ``sonar-project.properties`` and asks SonarCloud's public
13
+ web API for the issues of one analysis scope: a pull request (the issues its analysis
14
+ found in the changed code) or a branch (every issue on it). CI runs it straight after
15
+ the scan step, which waits for the analysis to finish (``sonar.qualitygate.wait``).
16
+
17
+ The token, if ``SONAR_TOKEN`` is set, is sent only to SonarCloud as a bearer header and
18
+ is never printed. Standard library only.
19
+
20
+ Exit codes: 0 no open issue, 1 open issues (each one is listed), 2 the check could not
21
+ be made (no project key, the request failed, or the answer was not understood).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ import urllib.error
31
+ import urllib.parse
32
+ import urllib.request
33
+ from pathlib import Path
34
+
35
+ HOST = "https://sonarcloud.io"
36
+ OPEN_STATUSES = frozenset({"OPEN", "CONFIRMED"})
37
+ PAGE_SIZE = 500
38
+ #: SonarCloud serves at most 10,000 results per search; stop there and say so.
39
+ MAX_RESULTS = 10_000
40
+
41
+
42
+ class CheckError(Exception):
43
+ """The check could not be made (distinct from "open issues were found")."""
44
+
45
+
46
+ def project_key(properties: Path) -> str:
47
+ """``sonar.projectKey`` from a ``sonar-project.properties`` file."""
48
+ try:
49
+ lines = properties.read_text(encoding="utf-8").splitlines()
50
+ except OSError as err:
51
+ raise CheckError(f"cannot read {properties}: {err}") from err
52
+ for line in lines:
53
+ name, sep, value = line.partition("=")
54
+ if sep and name.strip() == "sonar.projectKey" and value.strip():
55
+ return value.strip()
56
+ raise CheckError(f"{properties} has no sonar.projectKey")
57
+
58
+
59
+ def fetch_json(url: str, token: str | None) -> dict:
60
+ """GET one page of the web API. Only ever called with a SonarCloud https URL."""
61
+ if not url.startswith(HOST + "/"):
62
+ raise CheckError("refusing to request an address outside SonarCloud")
63
+ request = urllib.request.Request(url, headers={"Accept": "application/json"})
64
+ if token:
65
+ request.add_header("Authorization", f"Bearer {token}")
66
+ try:
67
+ with urllib.request.urlopen(request, timeout=60) as response: # nosec B310
68
+ return json.loads(response.read().decode("utf-8"))
69
+ except urllib.error.HTTPError as err:
70
+ raise CheckError(f"SonarCloud answered HTTP {err.code} for the issue search") from err
71
+ except (urllib.error.URLError, TimeoutError, ValueError) as err:
72
+ raise CheckError(f"could not read the issue search from SonarCloud: {err}") from err
73
+
74
+
75
+ def is_open(issue: dict) -> bool:
76
+ """Open or Confirmed. Accepted, False positive and Fixed are not open."""
77
+ status = issue.get("issueStatus") or issue.get("status") or ""
78
+ return str(status).upper() in OPEN_STATUSES
79
+
80
+
81
+ def open_issues(key: str, scope: dict[str, str], token: str | None, fetch=fetch_json) -> list[dict]:
82
+ """Every open issue of the project in ``scope`` (``pullRequest`` or ``branch``)."""
83
+ found: list[dict] = []
84
+ page = 1
85
+ while True:
86
+ query = {"componentKeys": key, "resolved": "false", "ps": str(PAGE_SIZE), "p": str(page)}
87
+ query.update(scope)
88
+ data = fetch(f"{HOST}/api/issues/search?{urllib.parse.urlencode(query)}", token)
89
+ if not isinstance(data, dict) or "errors" in data or "issues" not in data:
90
+ detail = data.get("errors") if isinstance(data, dict) else data
91
+ raise CheckError(f"SonarCloud did not return an issue list: {detail}")
92
+ issues = data["issues"]
93
+ found.extend(issue for issue in issues if is_open(issue))
94
+ total = int(data.get("total", data.get("paging", {}).get("total", 0)))
95
+ if not issues or page * PAGE_SIZE >= min(total, MAX_RESULTS):
96
+ if total > MAX_RESULTS:
97
+ raise CheckError(f"more than {MAX_RESULTS} issues; the list is incomplete")
98
+ return found
99
+ page += 1
100
+
101
+
102
+ def describe(issue: dict) -> str:
103
+ component = str(issue.get("component", ""))
104
+ path = component.split(":", 1)[1] if ":" in component else component
105
+ line = issue.get("line")
106
+ where = f"{path}:{line}" if line else path
107
+ return (
108
+ f"{where} {issue.get('rule', '?')} "
109
+ f"{str(issue.get('severity', '')).lower()} {issue.get('message', '')}"
110
+ )
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
115
+ scope = parser.add_mutually_exclusive_group(required=True)
116
+ scope.add_argument("--pull-request", metavar="<number>", help="check this pull request")
117
+ scope.add_argument("--branch", metavar="<name>", help="check this branch")
118
+ parser.add_argument(
119
+ "--properties",
120
+ type=Path,
121
+ default=Path(__file__).resolve().parents[1] / "sonar-project.properties",
122
+ help="the sonar-project.properties to read the project key from",
123
+ )
124
+ args = parser.parse_args(argv)
125
+ where = {"pullRequest": args.pull_request} if args.pull_request else {"branch": args.branch}
126
+ label = f"pull request {args.pull_request}" if args.pull_request else f"branch {args.branch}"
127
+ try:
128
+ key = project_key(args.properties)
129
+ issues = open_issues(key, where, os.environ.get("SONAR_TOKEN") or None)
130
+ except CheckError as err:
131
+ print(f"sonar-open-issues: could not check {label}: {err}", file=sys.stderr)
132
+ return 2
133
+ if not issues:
134
+ print(f"sonar-open-issues: no open issue on {label} of {key}")
135
+ return 0
136
+ print(f"sonar-open-issues: {len(issues)} open issue(s) on {label} of {key}:", file=sys.stderr)
137
+ for issue in issues:
138
+ print(" " + describe(issue), file=sys.stderr)
139
+ print(
140
+ "fix each one, or mark it Accepted on SonarCloud if it is kept on purpose",
141
+ file=sys.stderr,
142
+ )
143
+ return 1
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
@@ -247,7 +247,8 @@ def test_short_data_list_raises_cli_error_code_2() -> None:
247
247
  client.embed(["hello", "world"])
248
248
 
249
249
  assert exc_info.value.code == EXIT_ENV_ERROR
250
- assert "1" in exc_info.value.message and "2" in exc_info.value.message
250
+ assert "1" in exc_info.value.message
251
+ assert "2" in exc_info.value.message
251
252
  assert "one embedding per input" in exc_info.value.remediation
252
253
 
253
254
 
@@ -461,10 +462,12 @@ def test_no_socket_opened_when_transport_is_injected(monkeypatch: pytest.MonkeyP
461
462
 
462
463
  def test_endpoint_help_text_states_off_machine_implication() -> None:
463
464
  lowered = ENDPOINT_HELP.lower()
464
- assert "off" in lowered and "machine" in lowered
465
+ assert "off" in lowered
466
+ assert "machine" in lowered
465
467
  assert DEFAULT_BASE_URL in ENDPOINT_HELP
466
468
  assert ENV_BASE_URL in ENDPOINT_HELP
467
- assert isinstance(ENDPOINT_FLAG, str) and ENDPOINT_FLAG.startswith("--")
469
+ assert isinstance(ENDPOINT_FLAG, str)
470
+ assert ENDPOINT_FLAG.startswith("--")
468
471
 
469
472
 
470
473
  # --------------------------------------------------------------------------
@@ -213,7 +213,8 @@ def test_every_subcommand_invalid_flag_is_a_structured_error_json_mode(
213
213
  ), f"'embeddings {' '.join(path)} --json {_BOGUS_FLAG}' leaked a result onto stdout"
214
214
  payload = json.loads(captured.err)
215
215
  assert payload["code"] == 1
216
- assert isinstance(payload["message"], str) and payload["message"]
216
+ assert isinstance(payload["message"], str)
217
+ assert payload["message"]
217
218
  assert "remediation" in payload
218
219
 
219
220
 
@@ -237,7 +237,8 @@ def test_duplicate_ids_are_refused_naming_both_lines(tmp_path) -> None:
237
237
  load_corpus(path)
238
238
  assert exc.value.code == 1
239
239
  # Both the offending line and the first occurrence, so the fix is obvious.
240
- assert "3" in exc.value.message and "1" in exc.value.message
240
+ assert "3" in exc.value.message
241
+ assert "1" in exc.value.message
241
242
  assert "shared" in exc.value.message
242
243
  assert "unique" in exc.value.remediation
243
244
 
@@ -143,10 +143,12 @@ def test_directions_overview_json_returns_the_sections_payload(
143
143
  ) -> None:
144
144
  payload = _run_json(capsys, ["directions", "overview", "--json"])
145
145
  assert payload["subject"] == "embeddings-cli directions"
146
- assert isinstance(payload["sections"], list) and payload["sections"]
146
+ assert isinstance(payload["sections"], list)
147
+ assert payload["sections"]
147
148
  for section in payload["sections"]:
148
149
  assert section["title"]
149
- assert isinstance(section["items"], list) and section["items"]
150
+ assert isinstance(section["items"], list)
151
+ assert section["items"]
150
152
 
151
153
 
152
154
  def test_bare_directions_json_matches_overview_json(capsys: pytest.CaptureFixture[str]) -> None:
@@ -364,7 +366,8 @@ def test_holdout_pairs_are_disjoint_from_the_pairs_the_direction_was_derived_fro
364
366
 
365
367
  train = {(entry["a"], entry["b"]) for entry in candidate["train"]["pairs_used"]}
366
368
  holdout = {(entry["a"], entry["b"]) for entry in candidate["holdout"]["pairs_used"]}
367
- assert train and holdout
369
+ assert train
370
+ assert holdout
368
371
  assert not (train & holdout)
369
372
  assert len(train) + len(holdout) == PAIR_COUNT
370
373
  assert payload["pairs"]["train"] == len(train)
@@ -432,7 +435,8 @@ def test_a_planted_direction_is_stable_across_seed_reruns(
432
435
  payload = _run_json(capsys, _discover(planted_atlas, pairs, "--top", "1", "--runs", "4"))
433
436
  stability = payload["candidates"][0]["stability"]
434
437
  assert stability["verified"] is True
435
- assert stability["score"] is not None and stability["score"] > 0.5
438
+ assert stability["score"] is not None
439
+ assert stability["score"] > 0.5
436
440
 
437
441
 
438
442
  # --------------------------------------------------------------------------
@@ -383,7 +383,8 @@ def test_a_nan_in_metadata_is_refused_not_written_as_broken_json(tmp_path, capsy
383
383
  rc, err = run(capsys, "--atlas", str(target), "--out", str(out), "--apply")
384
384
  assert rc == 1
385
385
  assert "JSON" in err["message"]
386
- assert not out.exists() and not (tmp_path / "v.f32").exists()
386
+ assert not out.exists()
387
+ assert not (tmp_path / "v.f32").exists()
387
388
 
388
389
 
389
390
  def test_a_seed_beyond_the_viewers_integer_range_is_refused(tmp_path, capsys) -> None:
@@ -408,8 +409,10 @@ def test_a_value_that_overflows_float32_is_refused(tmp_path, capsys) -> None:
408
409
  out = tmp_path / "v.json"
409
410
  rc, err = run(capsys, "--atlas", str(target), "--out", str(out), "--apply")
410
411
  assert rc == 2
411
- assert "doc-2" in err["message"] and "float32" in err["message"]
412
- assert not out.exists() and not (tmp_path / "v.f32").exists()
412
+ assert "doc-2" in err["message"]
413
+ assert "float32" in err["message"]
414
+ assert not out.exists()
415
+ assert not (tmp_path / "v.f32").exists()
413
416
 
414
417
 
415
418
  def test_atlas_without_vectors_is_refused_with_a_survey_hint(tmp_path: Path, capsys) -> None:
@@ -799,7 +802,8 @@ def test_no_group_is_written_without_group_by(atlas: Path, tmp_path: Path, capsy
799
802
  out = tmp_path / "v.json"
800
803
  rc, payload = run(capsys, "--atlas", str(atlas), "--out", str(out), "--apply")
801
804
  assert rc == 0
802
- assert payload["group_by"] is None and payload["groups"] == 0
805
+ assert payload["group_by"] is None
806
+ assert payload["groups"] == 0
803
807
  data, _ = load_pair(out)
804
808
  assert all("group" not in point for point in data["points"])
805
809
 
@@ -813,7 +817,8 @@ def test_group_by_writes_each_points_group_from_metadata(
813
817
  )
814
818
  assert rc == 0
815
819
  assert payload["group_by"] == "topic"
816
- assert payload["groups"] == 3 and payload["grouped_points"] == 12
820
+ assert payload["groups"] == 3
821
+ assert payload["grouped_points"] == 12
817
822
  assert check_pair(out) == []
818
823
  data, _ = load_pair(out)
819
824
  assert [point["group"] for point in data["points"]] == [f"topic-{i % 3}" for i in range(12)]
@@ -837,7 +842,8 @@ def test_group_by_leaves_records_without_a_usable_value_ungrouped(tmp_path: Path
837
842
  capsys, "--atlas", str(target), "--out", str(out), "--apply", "--group-by", "kind"
838
843
  )
839
844
  assert rc == 0, payload
840
- assert payload["grouped_points"] == 2 and payload["groups"] == 2
845
+ assert payload["grouped_points"] == 2
846
+ assert payload["groups"] == 2
841
847
  data, _ = load_pair(out)
842
848
  assert [point.get("group") for point in data["points"]] == ["alpha", "7"] + [None] * 4
843
849
  assert check_pair(out) == []
@@ -850,7 +856,8 @@ def test_group_by_a_key_no_record_carries_is_refused(atlas: Path, tmp_path: Path
850
856
  )
851
857
  assert rc == 1
852
858
  assert "topik" in payload["message"]
853
- assert not out.exists() and not (tmp_path / "v.f32").exists()
859
+ assert not out.exists()
860
+ assert not (tmp_path / "v.f32").exists()
854
861
 
855
862
 
856
863
  def test_group_by_with_labels_only_is_refused(atlas: Path, tmp_path: Path, capsys) -> None:
@@ -875,6 +882,7 @@ def test_the_corpus_path_is_reduced_to_its_file_name(tmp_path: Path, capsys) ->
875
882
  rc, _ = run(capsys, "--atlas", str(target), "--out", str(out), "--apply")
876
883
  assert rc == 0
877
884
  raw = out.read_bytes()
878
- assert b"someone" not in raw and b"private-project" not in raw
885
+ assert b"someone" not in raw
886
+ assert b"private-project" not in raw
879
887
  data, _ = load_pair(out)
880
888
  assert data["provenance"]["params"]["corpus"] == "corpus.jsonl"
@@ -580,7 +580,8 @@ def test_probe_flag_without_a_source_is_a_user_error(
580
580
 
581
581
  assert code == 1
582
582
  err = capsys.readouterr().err
583
- assert "--probes" in err and "--holdout" in err
583
+ assert "--probes" in err
584
+ assert "--holdout" in err
584
585
 
585
586
 
586
587
  def test_missing_probes_file_is_a_user_error(
@@ -473,7 +473,8 @@ def test_boundary_reports_the_largest_gap_between_b_c_and_c_d(
473
473
  assert boundary["gaps"] == pytest.approx(expected_gaps, abs=1e-6)
474
474
  assert boundary["largest_gap_index"] == expected_gaps.index(max(expected_gaps))
475
475
  assert boundary["largest_gap"] == pytest.approx(max(expected_gaps), abs=1e-6)
476
- assert isinstance(boundary["note"], str) and boundary["note"]
476
+ assert isinstance(boundary["note"], str)
477
+ assert boundary["note"]
477
478
 
478
479
 
479
480
  def test_boundary_with_fewer_than_two_neighbors_reports_no_gap(
@@ -55,7 +55,8 @@ def test_distances_are_correct_and_bounded() -> None:
55
55
  assert distances[0] == pytest.approx(0.0)
56
56
  assert distances[1] == pytest.approx(1.0)
57
57
  assert distances[2] == pytest.approx(2.0)
58
- assert np.all(distances >= 0.0) and np.all(distances <= 2.0)
58
+ assert np.all(distances >= 0.0)
59
+ assert np.all(distances <= 2.0)
59
60
 
60
61
 
61
62
  def test_an_identical_vector_never_yields_a_negative_distance() -> None:
@@ -332,7 +332,8 @@ def test_single_run_regions_are_unverified_findings_about_the_parameters(
332
332
  assert region["stability"]["verdict"] == VERDICT_UNVERIFIED
333
333
  assert region["stability"]["score"] is None
334
334
  finding = region["finding"]
335
- assert isinstance(finding, str) and finding
335
+ assert isinstance(finding, str)
336
+ assert finding
336
337
  assert "parameters" in finding
337
338
  assert "k=3" in finding
338
339
  assert "not" in finding
@@ -0,0 +1,152 @@
1
+ """The CI check that fails on open SonarCloud issues (scripts/sonar_open_issues.py).
2
+
3
+ No request leaves the machine: the fetch function is replaced by a fake.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib.util
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ REPO_ROOT = Path(__file__).resolve().parents[1]
14
+ SCRIPT = REPO_ROOT / "scripts" / "sonar_open_issues.py"
15
+
16
+ spec = importlib.util.spec_from_file_location("sonar_open_issues", SCRIPT)
17
+ gate = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(gate)
19
+
20
+
21
+ def issue(status: str, n: int = 1, legacy: bool = False) -> dict:
22
+ record = {
23
+ "rule": "python:S9073",
24
+ "severity": "MAJOR",
25
+ "component": "agentculture_demo:tests/test_x.py",
26
+ "line": n,
27
+ "message": f"message {n}",
28
+ }
29
+ record["status" if legacy else "issueStatus"] = status
30
+ return record
31
+
32
+
33
+ def fake(pages: list[dict]):
34
+ calls: list[tuple[str, str | None]] = []
35
+
36
+ def fetch(url: str, token: str | None) -> dict:
37
+ calls.append((url, token))
38
+ return pages[min(len(calls), len(pages)) - 1]
39
+
40
+ return fetch, calls
41
+
42
+
43
+ def test_project_key_is_read_from_this_repos_properties() -> None:
44
+ assert gate.project_key(REPO_ROOT / "sonar-project.properties") == "agentculture_embeddings-cli"
45
+
46
+
47
+ def test_project_key_missing_is_a_check_error(tmp_path: Path) -> None:
48
+ (tmp_path / "p").write_text("sonar.organization=x\n", encoding="utf-8")
49
+ with pytest.raises(gate.CheckError):
50
+ gate.project_key(tmp_path / "p")
51
+ with pytest.raises(gate.CheckError):
52
+ gate.project_key(tmp_path / "absent")
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "status,is_open",
57
+ [
58
+ ("OPEN", True),
59
+ ("CONFIRMED", True),
60
+ ("ACCEPTED", False),
61
+ ("FALSE_POSITIVE", False),
62
+ ("FIXED", False),
63
+ ],
64
+ )
65
+ def test_only_open_and_confirmed_count(status: str, is_open: bool) -> None:
66
+ assert gate.is_open(issue(status)) is is_open
67
+ assert gate.is_open(issue(status, legacy=True)) is is_open
68
+
69
+
70
+ def test_scope_and_project_are_sent_and_accepted_issues_are_ignored() -> None:
71
+ fetch, calls = fake([{"total": 3, "issues": [issue("OPEN", 1), issue("ACCEPTED", 2)]}])
72
+ found = gate.open_issues("agentculture_demo", {"pullRequest": "12"}, "secret", fetch)
73
+ assert [i["line"] for i in found] == [1]
74
+ url, token = calls[0]
75
+ assert url.startswith("https://sonarcloud.io/api/issues/search?")
76
+ assert "componentKeys=agentculture_demo" in url
77
+ assert "pullRequest=12" in url
78
+ assert token == "secret"
79
+ assert "secret" not in url
80
+
81
+
82
+ def test_every_page_is_read() -> None:
83
+ first = {
84
+ "total": gate.PAGE_SIZE + 2,
85
+ "issues": [issue("OPEN", n) for n in range(gate.PAGE_SIZE)],
86
+ }
87
+ second = {"total": gate.PAGE_SIZE + 2, "issues": [issue("OPEN", 900), issue("CONFIRMED", 901)]}
88
+ fetch, calls = fake([first, second])
89
+ found = gate.open_issues("k", {"branch": "main"}, None, fetch)
90
+ assert len(found) == gate.PAGE_SIZE + 2
91
+ assert len(calls) == 2
92
+ assert "p=2" in calls[1][0]
93
+
94
+
95
+ def test_an_error_answer_is_a_check_error_not_a_pass() -> None:
96
+ fetch, _ = fake([{"errors": [{"msg": "Component key not found"}]}])
97
+ with pytest.raises(gate.CheckError):
98
+ gate.open_issues("k", {"branch": "main"}, None, fetch)
99
+
100
+
101
+ def test_requests_outside_sonarcloud_are_refused() -> None:
102
+ with pytest.raises(gate.CheckError):
103
+ gate.fetch_json("http://127.0.0.1:9/api/issues/search", None)
104
+
105
+
106
+ def run(monkeypatch, capsys, pages, argv, token="tok-do-not-print"):
107
+ fetch, _ = fake(pages)
108
+ monkeypatch.setattr(gate, "fetch_json", fetch)
109
+ if token:
110
+ monkeypatch.setenv("SONAR_TOKEN", token)
111
+ real = gate.open_issues
112
+ monkeypatch.setattr(gate, "open_issues", lambda k, s, t: real(k, s, t, fetch))
113
+ code = gate.main(argv)
114
+ out = capsys.readouterr()
115
+ assert token not in out.out + out.err
116
+ return code, out
117
+
118
+
119
+ def test_exit_0_when_nothing_is_open(monkeypatch, capsys) -> None:
120
+ code, out = run(monkeypatch, capsys, [{"total": 1, "issues": [issue("ACCEPTED")]}],
121
+ ["--pull-request", "7"]) # fmt: skip
122
+ assert code == 0
123
+ assert "no open issue on pull request 7" in out.out
124
+
125
+
126
+ def test_exit_1_lists_each_open_issue(monkeypatch, capsys) -> None:
127
+ pages = [{"total": 2, "issues": [issue("OPEN", 11), issue("CONFIRMED", 22)]}]
128
+ code, out = run(monkeypatch, capsys, pages, ["--branch", "main"])
129
+ assert code == 1
130
+ assert "2 open issue(s) on branch main" in out.err
131
+ assert "tests/test_x.py:11" in out.err
132
+ assert "tests/test_x.py:22" in out.err
133
+ assert "Accepted" in out.err
134
+
135
+
136
+ def test_exit_2_when_the_check_cannot_be_made(monkeypatch, capsys) -> None:
137
+ code, out = run(monkeypatch, capsys, [{"errors": [{"msg": "nope"}]}], ["--branch", "main"])
138
+ assert code == 2
139
+ assert "could not check branch main" in out.err
140
+
141
+
142
+ def test_the_workflow_runs_the_check_after_the_scan() -> None:
143
+ text = (REPO_ROOT / ".github" / "workflows" / "tests.yml").read_text(encoding="utf-8")
144
+ scan = text.index("SonarCloud Scan")
145
+ check = text.index("scripts/sonar_open_issues.py")
146
+ assert scan < check
147
+ # Guarded like the scan itself: no token, no scan, no check.
148
+ step = text[text.rindex("- name:", 0, check) : check]
149
+ assert "env.SONAR_TOKEN != ''" in step
150
+ assert "sonar.qualitygate.wait=true" in (REPO_ROOT / "sonar-project.properties").read_text(
151
+ encoding="utf-8"
152
+ )