@onlooker-community/ecosystem 0.34.0 → 0.43.0

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 (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Validates every emitted cartographer.* event against @onlooker-community/schema.
4
+ #
5
+ # Both cartographer payloads were off-contract from the day the plugin shipped:
6
+ # the published schema described a design that was never built, so every event
7
+ # failed validation and emit_safe swallowed the failure with `|| true`. Findings
8
+ # still landed on disk, so /cartographer looked healthy and nothing surfaced the
9
+ # loss. Nothing here drove a real payload through validation, which is why the
10
+ # two sides could drift that far apart (ecosystem-q4d).
11
+ #
12
+ # So these tests build their payloads with the same
13
+ # cartographer_issue_found_payload / cartographer_audit_complete_payload the
14
+ # audit calls — not a copy of them. A copy would be free to drift in exactly the
15
+ # way the schema did.
16
+
17
+ setup() {
18
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
19
+ setup_test_env
20
+
21
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/cartographer"
22
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
23
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
24
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
25
+
26
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
27
+ export CLAUDE_SESSION_ID="bats-session-$$"
28
+
29
+ # shellcheck disable=SC1091
30
+ source "${PLUGIN_ROOT}/scripts/lib/cartographer-events.sh"
31
+ }
32
+
33
+ # Skip when the installed schema predates the corrected cartographer payloads.
34
+ # The old definitions required issue_type/file_path; asserting against them
35
+ # would be asserting the bug.
36
+ _require_cartographer_schema() {
37
+ if ! grep -q "finding_hash" \
38
+ "${REPO_ROOT}/node_modules/@onlooker-community/schema/schemas/payload/plugins-memory.json" 2>/dev/null; then
39
+ skip "installed @onlooker-community/schema predates the corrected cartographer payloads"
40
+ fi
41
+ }
42
+
43
+ # Skip when the installed schema predates cartographer.issue.resolved (2.15.0).
44
+ _require_resolution_schema() {
45
+ if ! grep -q "cartographer.issue.resolved" \
46
+ "${REPO_ROOT}/node_modules/@onlooker-community/schema/schemas/payload/plugins-memory.json" 2>/dev/null; then
47
+ skip "installed @onlooker-community/schema predates cartographer.issue.resolved"
48
+ fi
49
+ }
50
+
51
+ _validate_latest_event() {
52
+ local last
53
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
54
+ [ -n "$last" ] || return 1
55
+ printf '%s' "$last" | ONLOOKER_DIR="$ONLOOKER_DIR" \
56
+ node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
57
+ }
58
+
59
+ # Valid 26-char Crockford Base32 ULID (no I, L, O, or U).
60
+ AUDIT_ID="01J0000000000000000000AB34"
61
+
62
+ # A finding record in the shape the analysis phases produce, which is what
63
+ # run_emit reads out of ALL_FINDINGS.
64
+ _finding() {
65
+ local type="${1:-undocumented_entity}" severity="${2:-warning}"
66
+ local file_a="${3:-CLAUDE.md}" file_b="${4:-null}"
67
+ jq -cn --arg t "$type" --arg s "$severity" --arg a "$file_a" --argjson b "$file_b" \
68
+ '{type: $t, severity: $s, file_a: $a, file_b: $b,
69
+ description: "fixture finding", suggested_fix: "document it"}'
70
+ }
71
+
72
+ @test "cartographer.issue.found validates for a single-file finding" {
73
+ _require_cartographer_schema
74
+ cartographer_emit_event "cartographer.issue.found" \
75
+ "$(cartographer_issue_found_payload "$AUDIT_ID" "abc123" "$(_finding)")"
76
+ run _validate_latest_event
77
+ [ "$status" -eq 0 ]
78
+ }
79
+
80
+ @test "cartographer.issue.found validates for a two-file finding" {
81
+ _require_cartographer_schema
82
+ cartographer_emit_event "cartographer.issue.found" \
83
+ "$(cartographer_issue_found_payload "$AUDIT_ID" "abc123" \
84
+ "$(_finding contradiction error CLAUDE.md '"AGENTS.md"')")"
85
+ run _validate_latest_event
86
+ [ "$status" -eq 0 ]
87
+ }
88
+
89
+ @test "every finding type the analysis phases produce validates" {
90
+ _require_cartographer_schema
91
+ local t
92
+ for t in contradiction stale_ref dead_rule scope_collision undocumented_entity; do
93
+ cartographer_emit_event "cartographer.issue.found" \
94
+ "$(cartographer_issue_found_payload "$AUDIT_ID" "hash-${t}" "$(_finding "$t")")" \
95
+ || return 1
96
+ _validate_latest_event || return 1
97
+ done
98
+ }
99
+
100
+ @test "cartographer.audit.complete validates" {
101
+ _require_cartographer_schema
102
+ cartographer_emit_event "cartographer.audit.complete" \
103
+ "$(cartographer_audit_complete_payload "$AUDIT_ID" "session_start_first_run" 1 3 8420)"
104
+ run _validate_latest_event
105
+ [ "$status" -eq 0 ]
106
+ }
107
+
108
+ @test "every trigger the hooks set validates" {
109
+ _require_cartographer_schema
110
+ local t
111
+ for t in session_start_first_run session_start_interval post_tool_use manual; do
112
+ cartographer_emit_event "cartographer.audit.complete" \
113
+ "$(cartographer_audit_complete_payload "$AUDIT_ID" "$t" 0 0 5)" || return 1
114
+ _validate_latest_event || return 1
115
+ done
116
+ }
117
+
118
+ # The emitted payload is the contract downstream reads, so assert on its
119
+ # contents and not merely that it validated. finding_hash in particular is what
120
+ # makes at-least-once delivery deduplicable (the plugin's ADR-003).
121
+ @test "the emitted payload carries the fields consumers read" {
122
+ _require_cartographer_schema
123
+ cartographer_emit_event "cartographer.issue.found" \
124
+ "$(cartographer_issue_found_payload "$AUDIT_ID" "abc123" \
125
+ "$(_finding contradiction error CLAUDE.md '"AGENTS.md"')")"
126
+
127
+ grep '"event_type":"cartographer.issue.found"' "$ONLOOKER_EVENTS_LOG" \
128
+ | jq -e --arg a "$AUDIT_ID" '
129
+ .payload.audit_id == $a
130
+ and .payload.finding_hash == "abc123"
131
+ and .payload.finding_type == "contradiction"
132
+ and .payload.severity == "error"
133
+ and .payload.affected_files == ["CLAUDE.md", "AGENTS.md"]
134
+ ' >/dev/null
135
+ }
136
+
137
+ @test "a single-file finding carries exactly one affected file" {
138
+ _require_cartographer_schema
139
+ cartographer_emit_event "cartographer.issue.found" \
140
+ "$(cartographer_issue_found_payload "$AUDIT_ID" "abc123" "$(_finding)")"
141
+
142
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
143
+ | jq -e '.payload.affected_files == ["CLAUDE.md"]' >/dev/null
144
+ }
145
+
146
+ @test "the retired pre-implementation vocabulary no longer validates" {
147
+ _require_cartographer_schema
148
+ # Guards the direction of the fix: if someone "restores" the old schema,
149
+ # this is the test that objects.
150
+ run cartographer_emit_event "cartographer.issue.found" \
151
+ '{"issue_type":"orphaned_plugin","file_path":"CLAUDE.md","severity":"warning"}'
152
+ [ "$status" -ne 0 ]
153
+ }
154
+
155
+ # A finding that reaches the emit phase without a usable .type is a bug in
156
+ # whichever analysis phase produced it. The builder used to paper over that with
157
+ # finding_type "unknown" — a value no schema admits — so the payload was
158
+ # rejected, emit_safe swallowed the rejection with `|| true`, and the finding
159
+ # landed on disk with nothing on the bus. Failing here instead puts the problem
160
+ # in audit.log where an operator can read it (ecosystem-ci0).
161
+ #
162
+ # Empty string is covered alongside null and absent because jq's `//` treats ""
163
+ # as present, so it slipped past the old fallback and produced a payload just as
164
+ # unvalidatable as "unknown", by a different route.
165
+ @test "a finding with no usable type is rejected rather than defaulted" {
166
+ local shape
167
+ for shape in '{}' '{"type":null}' '{"type":""}'; do
168
+ run cartographer_issue_found_payload "$AUDIT_ID" "abc123" \
169
+ "$(jq -cn --argjson s "$shape" \
170
+ '$s + {severity:"warning", file_a:"CLAUDE.md", description:"d"}')"
171
+ [ "$status" -ne 0 ] || return 1
172
+ done
173
+ }
174
+
175
+ # End-to-end statement of the symptom. Note this one does NOT discriminate the
176
+ # builder fix — before it, the schema rejected "unknown" downstream and the bus
177
+ # stayed empty for that reason instead. What it guards is the schema side: it
178
+ # fails if anyone ever admits "unknown" into the finding_type enum, which was
179
+ # the tempting cheap fix ci0 rejected.
180
+ @test "a typeless finding puts nothing on the bus" {
181
+ _require_cartographer_schema
182
+ run cartographer_emit_event "cartographer.issue.found" \
183
+ "$(cartographer_issue_found_payload "$AUDIT_ID" "abc123" \
184
+ '{"severity":"warning","file_a":"CLAUDE.md","description":"d"}')"
185
+ [ "$status" -ne 0 ] || return 1
186
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
187
+ }
188
+
189
+ @test "the builder names the rejected finding on stderr" {
190
+ # Returning non-zero is not enough on its own — emit_safe appends this
191
+ # stream to audit.log, so the message is what an operator actually reads.
192
+ run cartographer_issue_found_payload "$AUDIT_ID" "abc123" \
193
+ '{"severity":"warning","file_a":"CLAUDE.md","description":"d"}'
194
+ [ "$status" -ne 0 ] || return 1
195
+ [[ "$output" == *"abc123"* ]] || return 1
196
+ [[ "$output" == *"carries no type"* ]]
197
+ }
198
+
199
+ # A full audit retires findings it stopped observing, and that never reached the
200
+ # bus: a consumer reading only the log saw every finding ever opened and none
201
+ # ever closed (ecosystem-w2i).
202
+ @test "cartographer.issue.resolved validates" {
203
+ _require_resolution_schema
204
+ cartographer_emit_event "cartographer.issue.resolved" \
205
+ "$(cartographer_issue_resolved_payload "$AUDIT_ID" "abc123")"
206
+ run _validate_latest_event
207
+ [ "$status" -eq 0 ]
208
+ }
209
+
210
+ # Symmetry with issue.found is the whole point: the same hash opens and closes a
211
+ # finding, so a consumer can hold open/closed state from the log alone.
212
+ @test "the resolved payload carries the hash a consumer closes on" {
213
+ _require_resolution_schema
214
+ cartographer_emit_event "cartographer.issue.resolved" \
215
+ "$(cartographer_issue_resolved_payload "$AUDIT_ID" "abc123")"
216
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
217
+ | jq -e --arg a "$AUDIT_ID" \
218
+ '.payload.audit_id == $a and .payload.finding_hash == "abc123"' >/dev/null
219
+ }
220
+
221
+ @test "the resolved payload builder rejects missing arguments" {
222
+ run cartographer_issue_resolved_payload "" "abc123"
223
+ [ "$status" -ne 0 ] || return 1
224
+ run cartographer_issue_resolved_payload "$AUDIT_ID" ""
225
+ [ "$status" -ne 0 ]
226
+ }
227
+
228
+ @test "audit.complete carries resolved_finding_count when the sweep ran" {
229
+ _require_resolution_schema
230
+ cartographer_emit_event "cartographer.audit.complete" \
231
+ "$(cartographer_audit_complete_payload "$AUDIT_ID" "manual" 0 2 500 3)"
232
+ run _validate_latest_event
233
+ [ "$status" -eq 0 ] || return 1
234
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
235
+ | jq -e '.payload.resolved_finding_count == 3' >/dev/null
236
+ }
237
+
238
+ # A targeted or partial run skips the sweep entirely, so it must report no count
239
+ # rather than a zero that reads as "swept, found nothing to retire".
240
+ @test "audit.complete omits resolved_finding_count when the sweep was skipped" {
241
+ _require_resolution_schema
242
+ cartographer_emit_event "cartographer.audit.complete" \
243
+ "$(cartographer_audit_complete_payload "$AUDIT_ID" "post_tool_use" 1 1 20)"
244
+ run _validate_latest_event
245
+ [ "$status" -eq 0 ] || return 1
246
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
247
+ | jq -e '.payload | has("resolved_finding_count") | not' >/dev/null
248
+ }
249
+
250
+ @test "payload builders reject missing arguments" {
251
+ run cartographer_issue_found_payload "" "abc123" "$(_finding)"
252
+ [ "$status" -ne 0 ] || return 1
253
+ run cartographer_issue_found_payload "$AUDIT_ID" "" "$(_finding)"
254
+ [ "$status" -ne 0 ] || return 1
255
+ run cartographer_audit_complete_payload ""
256
+ [ "$status" -ne 0 ]
257
+ }
258
+
259
+ @test "emission fails on unknown event type" {
260
+ run cartographer_emit_event "cartographer.no.such.event" '{"audit_id":"x"}'
261
+ [ "$status" -ne 0 ]
262
+ }
263
+
264
+ @test "cartographer_emit_event returns 1 when payload is empty" {
265
+ run cartographer_emit_event "cartographer.issue.found" ""
266
+ [ "$status" -ne 0 ]
267
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Unit coverage for the --type and --scope narrowing.
4
+ #
5
+ # Both flags were documented in SKILL.md while run-audit.sh read neither, so a
6
+ # user who passed them got a silent full audit (ecosystem-9og). The end-to-end
7
+ # proof that they now change what runs lives in cartographer-run-audit.bats;
8
+ # this file pins the decision logic those tests depend on.
9
+
10
+ setup() {
11
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
12
+ setup_test_env
13
+
14
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/cartographer"
15
+ # shellcheck disable=SC1091
16
+ source "${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh"
17
+ }
18
+
19
+ # ── Type validity ────────────────────────────────────────────────────────────
20
+
21
+ @test "every documented finding type is accepted" {
22
+ local t
23
+ for t in contradiction dead_rule stale_ref scope_collision undocumented_entity; do
24
+ cartographer_filter_valid_type "$t" || return 1
25
+ done
26
+ }
27
+
28
+ # The published schema's finding_type enum and this list are the same five
29
+ # values; a type valid here that the schema rejects would emit nothing.
30
+ @test "the accepted types match the schema enum exactly" {
31
+ local schema="${REPO_ROOT}/node_modules/@onlooker-community/schema/schemas/payload/plugins-memory.json"
32
+ [ -f "$schema" ] || skip "schema package not installed"
33
+ local from_schema from_lib
34
+ from_schema=$(jq -r '.["$defs"]["cartographer.issue.found"].properties.finding_type.enum //
35
+ .definitions["cartographer.issue.found"].properties.finding_type.enum
36
+ | sort | join(" ")' "$schema")
37
+ from_lib=$(printf '%s\n' $CARTOGRAPHER_FINDING_TYPES | sort | tr '\n' ' ' | sed 's/ $//')
38
+ [ "$from_schema" = "$from_lib" ]
39
+ }
40
+
41
+ @test "an unknown type is rejected" {
42
+ run cartographer_filter_valid_type "not_a_type"
43
+ [ "$status" -ne 0 ]
44
+ }
45
+
46
+ @test "an empty type is rejected, so callers must check before filtering" {
47
+ run cartographer_filter_valid_type ""
48
+ [ "$status" -ne 0 ]
49
+ }
50
+
51
+ # ── Which analyzers run ──────────────────────────────────────────────────────
52
+
53
+ @test "no filter runs every analyzer" {
54
+ local a
55
+ for a in contradiction stale_ref scope_collision undocumented_entity; do
56
+ cartographer_filter_wants "$a" "" || return 1
57
+ done
58
+ }
59
+
60
+ @test "a type filter runs only its own analyzer" {
61
+ cartographer_filter_wants "stale_ref" "stale_ref" || return 1
62
+ run cartographer_filter_wants "scope_collision" "stale_ref"
63
+ [ "$status" -ne 0 ] || return 1
64
+ run cartographer_filter_wants "undocumented_entity" "stale_ref"
65
+ [ "$status" -ne 0 ] || return 1
66
+ run cartographer_filter_wants "contradiction" "stale_ref"
67
+ [ "$status" -ne 0 ]
68
+ }
69
+
70
+ # contradiction and dead_rule come out of one LLM pass, so either request must
71
+ # run that analyzer. Skipping it for dead_rule would silently produce nothing.
72
+ @test "dead_rule runs the contradiction analyzer" {
73
+ cartographer_filter_wants "contradiction" "dead_rule"
74
+ }
75
+
76
+ @test "asking for dead_rule still skips the unrelated analyzers" {
77
+ run cartographer_filter_wants "stale_ref" "dead_rule"
78
+ [ "$status" -ne 0 ] || return 1
79
+ run cartographer_filter_wants "undocumented_entity" "dead_rule"
80
+ [ "$status" -ne 0 ]
81
+ }
82
+
83
+ # ── Filtering the results ────────────────────────────────────────────────────
84
+
85
+ FINDINGS='[{"type":"contradiction","file_a":"a"},{"type":"dead_rule","file_a":"b"}]'
86
+
87
+ @test "no filter passes findings through untouched" {
88
+ run cartographer_filter_findings "$FINDINGS" ""
89
+ [ "$output" = "$FINDINGS" ]
90
+ }
91
+
92
+ # The shared LLM pass returns both types, so the unrequested one is dropped here.
93
+ @test "the co-emitted type is dropped from the results" {
94
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
95
+ cartographer_filter_findings '$FINDINGS' 'dead_rule' | jq -c '[.[].type]'"
96
+ [ "$output" = '["dead_rule"]' ]
97
+ }
98
+
99
+ @test "filtering to the other co-emitted type keeps only it" {
100
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
101
+ cartographer_filter_findings '$FINDINGS' 'contradiction' | jq -c '[.[].type]'"
102
+ [ "$output" = '["contradiction"]' ]
103
+ }
104
+
105
+ @test "a type present in no finding yields an empty array, not an error" {
106
+ run cartographer_filter_findings "$FINDINGS" "stale_ref"
107
+ [ "$status" -eq 0 ] || return 1
108
+ [ "$output" = "[]" ]
109
+ }
110
+
111
+ @test "malformed findings json degrades to empty rather than aborting" {
112
+ run cartographer_filter_findings "not json" "stale_ref"
113
+ [ "$status" -eq 0 ] || return 1
114
+ [ "$output" = "[]" ]
115
+ }
116
+
117
+ # ── Scope ────────────────────────────────────────────────────────────────────
118
+
119
+ FILES='["/repo/CLAUDE.md","/repo/plugins/tribunal/CLAUDE.md","/repo/plugins/echo/CLAUDE.md"]'
120
+
121
+ @test "no scope passes the file list through untouched" {
122
+ run cartographer_filter_scope "$FILES" "/repo" ""
123
+ [ "$output" = "$FILES" ]
124
+ }
125
+
126
+ @test "a repo-relative scope keeps only files beneath it" {
127
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
128
+ cartographer_filter_scope '$FILES' '/repo' 'plugins/tribunal' | jq -c ."
129
+ [ "$output" = '["/repo/plugins/tribunal/CLAUDE.md"]' ]
130
+ }
131
+
132
+ @test "an absolute scope works too" {
133
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
134
+ cartographer_filter_scope '$FILES' '/repo' '/repo/plugins/echo' | jq -c ."
135
+ [ "$output" = '["/repo/plugins/echo/CLAUDE.md"]' ]
136
+ }
137
+
138
+ @test "a trailing slash on the scope does not change the match" {
139
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
140
+ cartographer_filter_scope '$FILES' '/repo' 'plugins/tribunal/' | jq -c 'length'"
141
+ [ "$output" = "1" ]
142
+ }
143
+
144
+ @test "a leading ./ on the scope does not change the match" {
145
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
146
+ cartographer_filter_scope '$FILES' '/repo' './plugins/tribunal' | jq -c 'length'"
147
+ [ "$output" = "1" ]
148
+ }
149
+
150
+ # A sibling whose name merely starts with the scope string is not inside it.
151
+ # Prefix matching without the separator would pull plugins/echo-legacy into a
152
+ # scope of plugins/echo.
153
+ @test "a sibling sharing a name prefix is not swept in" {
154
+ local files='["/repo/plugins/echo/CLAUDE.md","/repo/plugins/echo-legacy/CLAUDE.md"]'
155
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
156
+ cartographer_filter_scope '$files' '/repo' 'plugins/echo' | jq -c ."
157
+ [ "$output" = '["/repo/plugins/echo/CLAUDE.md"]' ]
158
+ }
159
+
160
+ @test "scoping to an exact file path keeps that file" {
161
+ run bash -c "source '${PLUGIN_ROOT}/scripts/lib/cartographer-filter.sh'
162
+ cartographer_filter_scope '$FILES' '/repo' 'CLAUDE.md' | jq -c ."
163
+ [ "$output" = '["/repo/CLAUDE.md"]' ]
164
+ }
165
+
166
+ @test "a scope matching nothing yields an empty list" {
167
+ run cartographer_filter_scope "$FILES" "/repo" "does/not/exist"
168
+ [ "$output" = "[]" ]
169
+ }
@@ -89,8 +89,8 @@ teardown() {
89
89
  cartographer_lock_release '${BATS_TEST_TMPDIR}/x.lock' && echo RELEASE_OK
90
90
  "
91
91
  [ "$status" -eq 0 ]
92
- [[ "$output" == *"SOURCED_OK"* ]]
93
- [[ "$output" == *"ACQUIRE_FAILED"* ]]
94
- [[ "$output" == *"RELEASE_OK"* ]]
92
+ [[ "$output" == *"SOURCED_OK"* ]] || return 1
93
+ [[ "$output" == *"ACQUIRE_FAILED"* ]] || return 1
94
+ [[ "$output" == *"RELEASE_OK"* ]] || return 1
95
95
  [[ "$output" == *"locking disabled"* ]]
96
96
  }
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/cartographer"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/cartographer-omission.sh"
11
+
12
+ FIXTURE_REPO="${BATS_TEST_TMPDIR}/repo"
13
+ mkdir -p "${FIXTURE_REPO}/plugins/alpha" \
14
+ "${FIXTURE_REPO}/plugins/beta" \
15
+ "${FIXTURE_REPO}/skills/solo"
16
+ DOC="${FIXTURE_REPO}/CLAUDE.md"
17
+ printf '# Doc\nThe alpha plugin does things.\n' > "$DOC"
18
+ CORPUS=$(jq -n --arg f "$DOC" '[$f]')
19
+ }
20
+
21
+ @test "flags an entity whose name appears nowhere in the corpus" {
22
+ local out
23
+ out=$(cartographer_analyze_undocumented_entity \
24
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
25
+ [[ "$(printf '%s' "$out" | jq -r 'length')" == "1" ]] || return 1
26
+ [[ "$(printf '%s' "$out" | jq -r '.[0].excerpt_a')" == "beta" ]] || return 1
27
+ [ "$(printf '%s' "$out" | jq -r '.[0].type')" = "undocumented_entity" ]
28
+ }
29
+
30
+ @test "does not flag an entity the corpus mentions" {
31
+ local out
32
+ out=$(cartographer_analyze_undocumented_entity \
33
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
34
+ [ "$(printf '%s' "$out" | jq -r '[.[] | select(.excerpt_a == "alpha")] | length')" = "0" ]
35
+ }
36
+
37
+ @test "finding carries the entity as file_a and a null file_b" {
38
+ local out
39
+ out=$(cartographer_analyze_undocumented_entity \
40
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
41
+ [[ "$(printf '%s' "$out" | jq -r '.[0].file_a')" == "${FIXTURE_REPO}/plugins/beta" ]] || return 1
42
+ [[ "$(printf '%s' "$out" | jq -r '.[0].file_b')" == "null" ]] || return 1
43
+ [ "$(printf '%s' "$out" | jq -r '.[0].severity')" = "warning" ]
44
+ }
45
+
46
+ @test "word boundary: a longer word containing the name does not count as a mention" {
47
+ printf '# Doc\nWe do a lot of counseling here.\n' > "$DOC"
48
+ mkdir -p "${FIXTURE_REPO}/plugins/counsel"
49
+ local out
50
+ out=$(cartographer_analyze_undocumented_entity \
51
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
52
+ [ "$(printf '%s' "$out" | jq -r '[.[] | select(.excerpt_a == "counsel")] | length')" = "1" ]
53
+ }
54
+
55
+ @test "word boundary: a hyphenated name is not matched inside a longer hyphenated token" {
56
+ printf '# Doc\nSee my-list-prompt-rules-thing for details.\n' > "$DOC"
57
+ mkdir -p "${FIXTURE_REPO}/skills/list-prompt-rules"
58
+ local out
59
+ out=$(cartographer_analyze_undocumented_entity \
60
+ "$CORPUS" "$FIXTURE_REPO" '["skills/*/"]' '[]' 20)
61
+ [ "$(printf '%s' "$out" | jq -r '[.[] | select(.excerpt_a == "list-prompt-rules")] | length')" = "1" ]
62
+ }
63
+
64
+ @test "word boundary: a name bounded by slashes counts as a mention" {
65
+ printf '# Doc\nSee plugins/beta/ for details.\n' > "$DOC"
66
+ local out
67
+ out=$(cartographer_analyze_undocumented_entity \
68
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
69
+ [ "$(printf '%s' "$out" | jq -r '[.[] | select(.excerpt_a == "beta")] | length')" = "0" ]
70
+ }
71
+
72
+ @test "exclude filters a matched path by substring" {
73
+ local out
74
+ out=$(cartographer_analyze_undocumented_entity \
75
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/","skills/*/"]' '["skills/"]' 20)
76
+ [[ "$(printf '%s' "$out" | jq -r 'length')" == "1" ]] || return 1
77
+ [ "$(printf '%s' "$out" | jq -r '.[0].excerpt_a')" = "beta" ]
78
+ }
79
+
80
+ @test "max_findings caps the result and reports the drop count on stderr" {
81
+ local out err
82
+ err="${BATS_TEST_TMPDIR}/err.txt"
83
+ out=$(cartographer_analyze_undocumented_entity \
84
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/","skills/*/"]' '[]' 1 2>"$err")
85
+ [[ "$(printf '%s' "$out" | jq -r 'length')" == "1" ]] || return 1
86
+ grep -q "1 candidate" "$err"
87
+ }
88
+
89
+ @test "a glob matching nothing yields an empty array" {
90
+ local out
91
+ out=$(cartographer_analyze_undocumented_entity \
92
+ "$CORPUS" "$FIXTURE_REPO" '["nonexistent/*/"]' '[]' 20)
93
+ [ "$out" = "[]" ]
94
+ }
95
+
96
+ @test "an empty corpus yields an empty array rather than flagging everything" {
97
+ local out
98
+ out=$(cartographer_analyze_undocumented_entity \
99
+ '[]' "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
100
+ [ "$out" = "[]" ]
101
+ }
102
+
103
+ @test "the same entity produces an identical finding hash across two runs" {
104
+ # shellcheck disable=SC1091
105
+ source "${PLUGIN_ROOT}/scripts/lib/cartographer-analyze.sh"
106
+ local a b h1 h2
107
+ a=$(cartographer_analyze_undocumented_entity \
108
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
109
+ b=$(cartographer_analyze_undocumented_entity \
110
+ "$CORPUS" "$FIXTURE_REPO" '["plugins/*/"]' '[]' 20)
111
+ h1=$(cartographer_finding_hash "undocumented_entity" \
112
+ "$(printf '%s' "$a" | jq -r '.[0].file_a')" \
113
+ "$(printf '%s' "$a" | jq -r '.[0].excerpt_a')" "" "")
114
+ h2=$(cartographer_finding_hash "undocumented_entity" \
115
+ "$(printf '%s' "$b" | jq -r '.[0].file_a')" \
116
+ "$(printf '%s' "$b" | jq -r '.[0].excerpt_a')" "" "")
117
+ [[ -n "$h1" ]] || return 1
118
+ [ "$h1" = "$h2" ]
119
+ }
120
+
121
+ @test "flags an entity under a repo root whose path contains a space" {
122
+ # BATS_TEST_TMPDIR itself never contains a space, which is exactly why this
123
+ # was missed — build a fixture one level under it that does.
124
+ local spaced_repo="${BATS_TEST_TMPDIR}/my repo"
125
+ mkdir -p "${spaced_repo}/plugins/alpha" "${spaced_repo}/plugins/beta"
126
+ local doc="${spaced_repo}/CLAUDE.md"
127
+ printf '# Doc\nThe alpha plugin does things.\n' > "$doc"
128
+ local corpus
129
+ corpus=$(jq -n --arg f "$doc" '[$f]')
130
+
131
+ local out
132
+ out=$(cartographer_analyze_undocumented_entity \
133
+ "$corpus" "$spaced_repo" '["plugins/*/"]' '[]' 20)
134
+ [ "$(printf '%s' "$out" | jq -r '[.[] | select(.excerpt_a == "beta")] | length')" = "1" ]
135
+ }
136
+
137
+ # Stubs `claude` so the three LLM phases make no real call, then runs a full
138
+ # audit. Any extra env the caller needs is exported before calling this.
139
+ _run_audit() {
140
+ local stub="${BATS_TEST_TMPDIR}/bin"
141
+ mkdir -p "$stub"
142
+ printf '#!/usr/bin/env bash\ncat >/dev/null\nprintf "[]"\n' > "${stub}/claude"
143
+ chmod +x "${stub}/claude"
144
+
145
+ PATH="${stub}:${PATH}" \
146
+ CARTOGRAPHER_DIR="${BATS_TEST_TMPDIR}/state" \
147
+ CARTOGRAPHER_REPO_ROOT="$FIXTURE_REPO" \
148
+ CARTOGRAPHER_TRIGGER="manual" \
149
+ CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" \
150
+ bash "${PLUGIN_ROOT}/scripts/run-audit.sh"
151
+ }
152
+
153
+ # run_emit writes findings with a bare `jq`, which pretty-prints — the file
154
+ # contains `"type": "undocumented_entity"` with a space. Parse rather than grep.
155
+ _findings_of_type() {
156
+ local dir="${BATS_TEST_TMPDIR}/state/findings"
157
+ local count=0 f
158
+ [[ -d "$dir" ]] || { printf '0'; return 0; }
159
+ for f in "$dir"/*.json; do
160
+ [[ -e "$f" ]] || continue
161
+ if [[ "$(jq -r '.type // ""' "$f" 2>/dev/null)" == "undocumented_entity" ]]; then
162
+ count=$(( count + 1 ))
163
+ fi
164
+ done
165
+ printf '%s' "$count"
166
+ }
167
+
168
+ # Names of every undocumented_entity finding on disk, sorted, space-separated.
169
+ _findings_named() {
170
+ local dir="${BATS_TEST_TMPDIR}/state/findings"
171
+ local names=() f
172
+ [[ -d "$dir" ]] || return 0
173
+ for f in "$dir"/*.json; do
174
+ [[ -e "$f" ]] || continue
175
+ if [[ "$(jq -r '.type // ""' "$f" 2>/dev/null)" == "undocumented_entity" ]]; then
176
+ names+=("$(jq -r '.excerpt_a' "$f")")
177
+ fi
178
+ done
179
+ printf '%s' "$(printf '%s\n' "${names[@]+"${names[@]}"}" | sort | tr '\n' ' ' | sed 's/ $//')"
180
+ }
181
+
182
+ @test "integration: a full audit records the undocumented entities on disk" {
183
+ # alpha is documented; beta and solo are not, and the default globs cover
184
+ # both plugins/ and skills/. Assert identity, not just count — a bare count
185
+ # passes just as happily on two findings for the same entity.
186
+ _run_audit
187
+ [ "$(_findings_named)" = "beta solo" ]
188
+ }
189
+
190
+ @test "integration: a targeted post-write audit records no undocumented entity" {
191
+ export CARTOGRAPHER_TARGET_FILE="$DOC"
192
+ _run_audit
193
+ [ "$(_findings_of_type)" = "0" ]
194
+ }
195
+
196
+ @test "integration: enabled=false suppresses the phase" {
197
+ mkdir -p "${FIXTURE_REPO}/.claude"
198
+ printf '%s\n' '{"cartographer":{"undocumented_entity":{"enabled":false}}}' \
199
+ > "${FIXTURE_REPO}/.claude/settings.json"
200
+ _run_audit
201
+ [ "$(_findings_of_type)" = "0" ]
202
+ }