@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.
- package/.claude/skills/writing-tests/SKILL.md +27 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +7 -7
- package/AGENTS.md +116 -0
- package/CHANGELOG.md +175 -0
- package/CLAUDE.md +5 -0
- package/docs/lesson-promotion-pipeline.md +6 -6
- package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
- package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
- package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
- package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
- package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
- package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
- package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
- package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
- package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
- package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
- package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
- package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
- package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
- package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
- package/package.json +2 -2
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +54 -0
- package/plugins/cartographer/README.md +56 -1
- package/plugins/cartographer/config.json +7 -1
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
- package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
- package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
- package/plugins/cartographer/scripts/run-audit.sh +136 -53
- package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +71 -0
- package/plugins/librarian/config.json +30 -1
- package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
- package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
- package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
- package/plugins/librarian/skills/librarian/SKILL.md +98 -1
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +63 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
- package/plugins/tribunal/config.json +1 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
- package/scripts/lib/prompt-rules.sh +6 -1
- package/test/bats/archivist-inject.bats +1 -1
- package/test/bats/assayer-extract.bats +2 -2
- package/test/bats/bursar-session-start.bats +3 -3
- package/test/bats/cartographer-config.bats +43 -0
- package/test/bats/cartographer-events.bats +267 -0
- package/test/bats/cartographer-filter.bats +169 -0
- package/test/bats/cartographer-lock.bats +3 -3
- package/test/bats/cartographer-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/compass-sanitizer.bats +11 -11
- package/test/bats/compass-transcript.bats +2 -2
- package/test/bats/config.bats +15 -15
- package/test/bats/curator-session-start.bats +10 -3
- package/test/bats/emit-payload-default.bats +52 -0
- package/test/bats/governor-ledger.bats +1 -1
- package/test/bats/historian-prompt-submit.bats +1 -1
- package/test/bats/inspector-post-write-hook.bats +4 -4
- package/test/bats/librarian-author-key.bats +477 -0
- package/test/bats/librarian-cli.bats +16 -16
- package/test/bats/librarian-lesson-judge.bats +884 -0
- package/test/bats/librarian-lesson-promote.bats +552 -0
- package/test/bats/librarian-lesson-review.bats +900 -0
- package/test/bats/librarian-lesson-transform.bats +17 -3
- package/test/bats/librarian-session-end.bats +161 -0
- package/test/bats/librarian-session-start.bats +36 -2
- package/test/bats/lineage-config.bats +1 -1
- package/test/bats/lineage-redact.bats +5 -5
- package/test/bats/session-tracker.bats +4 -4
- package/test/bats/tribunal-aggregate.bats +354 -1
- package/test/bats/tribunal-gate.bats +631 -0
- package/test/bats/tribunal-judge-agents.bats +119 -0
- package/test/bats/tribunal-jury.bats +1 -1
- package/test/bats/turn-tracker.bats +1 -1
- package/test/bats/warden-sanitizer.bats +3 -3
- package/test/bats/worktree-tracker.bats +2 -2
- package/test/node/lesson-validate-agreement.test.mjs +35 -6
|
@@ -14,6 +14,7 @@ Every other Onlooker plugin is reactive. Cartographer is the exception.
|
|
|
14
14
|
| `dead_rule` | A rule fully subsumed by a more specific rule elsewhere |
|
|
15
15
|
| `stale_ref` | A reference to a file path, tool, or command that no longer exists |
|
|
16
16
|
| `scope_collision` | A project rule that duplicates or silently overrides a global `~/.claude/CLAUDE.md` rule |
|
|
17
|
+
| `undocumented_entity` | Something that exists on disk — a plugin, a skill — that no instruction file mentions |
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -42,7 +43,7 @@ Findings appear in the next `/cartographer` invocation or in any event log consu
|
|
|
42
43
|
/cartographer --verbose # show all known findings (no bus events)
|
|
43
44
|
/cartographer --status # running state + last completion time
|
|
44
45
|
/cartographer --force # kill running audit and restart
|
|
45
|
-
/cartographer --
|
|
46
|
+
/cartographer --type=stale_ref # one finding type; skips the other analyzers
|
|
46
47
|
/cartographer --scope=src/ # scoped to a subdirectory
|
|
47
48
|
```
|
|
48
49
|
|
|
@@ -74,6 +75,43 @@ All options are optional. Defaults shown:
|
|
|
74
75
|
|
|
75
76
|
**Note:** Overriding `exclude_paths` replaces the entire list. Repeat the defaults plus your additions if you want to extend rather than replace.
|
|
76
77
|
|
|
78
|
+
### Detecting omissions
|
|
79
|
+
|
|
80
|
+
Every other check reads the instruction files and tests what it finds against
|
|
81
|
+
the filesystem. `undocumented_entity` runs the other way: it enumerates
|
|
82
|
+
entities on disk and flags any whose name appears in no instruction file. That
|
|
83
|
+
is the one kind of drift the other checks structurally cannot see — something
|
|
84
|
+
absent produces no reference to follow. The mention check is case-sensitive,
|
|
85
|
+
so `plugins/foo/` documented only as "Foo" is still flagged as undocumented.
|
|
86
|
+
|
|
87
|
+
This phase only runs as part of a full audit (SessionStart or manual
|
|
88
|
+
`/cartographer`) — it is skipped on the targeted post-write audit that runs
|
|
89
|
+
after you edit an instruction file, so editing `CLAUDE.md` never produces an
|
|
90
|
+
omission finding on its own.
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"cartographer": {
|
|
95
|
+
"undocumented_entity": {
|
|
96
|
+
"enabled": true,
|
|
97
|
+
"globs": ["plugins/*/", "skills/*/"],
|
|
98
|
+
"exclude": [],
|
|
99
|
+
"max_findings": 20
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`globs` are relative to the repository root, and a glob matching nothing is
|
|
106
|
+
simply inert — the defaults do nothing in a repository without those
|
|
107
|
+
directories. The list is deliberately opt-in: most of a repository has no
|
|
108
|
+
business being named in `CLAUDE.md`, so only classes where you expect the
|
|
109
|
+
documentation to be *complete* belong here.
|
|
110
|
+
|
|
111
|
+
**Note:** as with `exclude_paths`, overriding `globs` or `exclude` replaces the
|
|
112
|
+
entire list rather than extending it. Repeat the defaults alongside your
|
|
113
|
+
additions if you mean to extend.
|
|
114
|
+
|
|
77
115
|
## Privacy
|
|
78
116
|
|
|
79
117
|
- All analysis uses `claude -p` via your existing Claude Code session — no separate API key, no new data recipient.
|
|
@@ -92,10 +130,27 @@ All options are optional. Defaults shown:
|
|
|
92
130
|
└── dedup/ # empty sentinel per emitted finding hash
|
|
93
131
|
```
|
|
94
132
|
|
|
133
|
+
## Finding lifecycle
|
|
134
|
+
|
|
135
|
+
A finding is born `resolved: false` and refreshed on every audit that observes it again. A **full** audit that completes without a failed phase then retires anything it did not observe: absence is the evidence that the drift is gone, so those records get `resolved: true` and a `resolved_at` stamp, and `/cartographer` stops rendering them. `/cartographer --verbose` still lists them, tagged `[RESOLVED]`, so you can confirm a fix took.
|
|
136
|
+
|
|
137
|
+
Two runs deliberately retire nothing, because neither looked widely enough for absence to mean anything:
|
|
138
|
+
|
|
139
|
+
- **Targeted post-write audits** evaluate a single file, so nearly every stored finding is absent for reasons unrelated to being fixed.
|
|
140
|
+
- **Partial runs** (any phase timed out or errored) produce no findings for the phase that failed, which is indistinguishable from its findings being gone.
|
|
141
|
+
|
|
142
|
+
Resolution is not terminal. The dedup sentinel outlives it, so drift that is reintroduced returns as a *known* finding — that path reopens the record (`resolved: false`, `resolved_at` cleared) rather than filing a new one, keeping `first_seen_at` intact. Without that, a recurring finding would stay hidden forever.
|
|
143
|
+
|
|
95
144
|
## Event delivery
|
|
96
145
|
|
|
97
146
|
`cartographer.issue.found` events are delivered at-least-once. If the audit process crashes between emitting an event and writing the dedup sentinel, the finding is re-emitted once on the next run. Downstream consumers must deduplicate on `payload.finding_hash`.
|
|
98
147
|
|
|
148
|
+
`cartographer.issue.resolved` carries the same `finding_hash`, so a consumer can close the finding it opened and hold open/closed state from the log alone. It is emitted only by a run that looked everywhere: a targeted audit sees a single file and a run with a failed phase sees an incomplete corpus, so neither can treat a finding's absence as evidence its drift is gone.
|
|
149
|
+
|
|
150
|
+
The payload carries no timestamp of its own. The envelope's required `timestamp` is the resolution time, since the event is emitted from the sweep that flips the record.
|
|
151
|
+
|
|
152
|
+
`cartographer.audit.complete` reports `resolved_finding_count` for the same reason and under the same condition — a run that skipped the sweep omits the field entirely rather than reporting `0`, which would read as "swept, retired nothing".
|
|
153
|
+
|
|
99
154
|
## Non-goals
|
|
100
155
|
|
|
101
156
|
Cartographer will not:
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
"model": "claude-haiku-4-5-20251001",
|
|
16
16
|
"max_output_tokens": 2048
|
|
17
17
|
},
|
|
18
|
-
"exclude_paths": ["node_modules", ".git", "vendor", ".venv", "dist", ".next", ".nuxt", "build", "__pycache__"]
|
|
18
|
+
"exclude_paths": ["node_modules", ".git", "vendor", ".venv", "dist", ".next", ".nuxt", "build", "__pycache__"],
|
|
19
|
+
"undocumented_entity": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"globs": ["plugins/*/", "skills/*/"],
|
|
22
|
+
"exclude": [],
|
|
23
|
+
"max_findings": 20
|
|
24
|
+
}
|
|
19
25
|
}
|
|
20
26
|
}
|
|
@@ -66,18 +66,18 @@ export CARTOGRAPHER_TARGET_FILE="$CANONICAL"
|
|
|
66
66
|
export CARTOGRAPHER_REPO_ROOT="$REPO_ROOT"
|
|
67
67
|
export ONLOOKER_DIR
|
|
68
68
|
|
|
69
|
+
# The launcher deliberately loads no config — see the matching note in
|
|
70
|
+
# cartographer-session-start.sh. It execs run-audit.sh, which loads config once
|
|
71
|
+
# for itself; anything loaded here is discarded by the exec, and could not have
|
|
72
|
+
# resolved in the first place because PLUGIN_ROOT is not exported.
|
|
69
73
|
if command -v setsid &>/dev/null; then
|
|
70
74
|
nohup setsid bash -c "
|
|
71
75
|
trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
|
|
72
|
-
source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
|
|
73
|
-
cartographer_config_load \"$REPO_ROOT\"
|
|
74
76
|
exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
|
|
75
77
|
" >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
|
|
76
78
|
else
|
|
77
79
|
nohup bash -c "
|
|
78
80
|
trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
|
|
79
|
-
source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
|
|
80
|
-
cartographer_config_load \"$REPO_ROOT\"
|
|
81
81
|
exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
|
|
82
82
|
" >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
|
|
83
83
|
fi
|
|
@@ -68,18 +68,21 @@ export CARTOGRAPHER_TRIGGER="$TRIGGER"
|
|
|
68
68
|
export CARTOGRAPHER_REPO_ROOT="$REPO_ROOT"
|
|
69
69
|
export ONLOOKER_DIR
|
|
70
70
|
|
|
71
|
+
# The launcher deliberately loads no config. It execs run-audit.sh, which
|
|
72
|
+
# replaces the process, and the config lives in a plain shell variable that is
|
|
73
|
+
# never exported — so anything loaded here is discarded a line later. It also
|
|
74
|
+
# could not have worked: PLUGIN_ROOT is not exported (only CLAUDE_PLUGIN_ROOT
|
|
75
|
+
# is), so cartographer-config.sh resolved config-loader.sh against an empty
|
|
76
|
+
# prefix in this sub-shell and appended two lines of noise to audit.log on every
|
|
77
|
+
# audit. run-audit.sh loads config once, for itself.
|
|
71
78
|
if command -v setsid &>/dev/null; then
|
|
72
79
|
nohup setsid bash -c "
|
|
73
80
|
trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
|
|
74
|
-
source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
|
|
75
|
-
cartographer_config_load \"$REPO_ROOT\"
|
|
76
81
|
exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
|
|
77
82
|
" >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
|
|
78
83
|
else
|
|
79
84
|
nohup bash -c "
|
|
80
85
|
trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
|
|
81
|
-
source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
|
|
82
|
-
cartographer_config_load \"$REPO_ROOT\"
|
|
83
86
|
exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
|
|
84
87
|
" >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
|
|
85
88
|
fi
|
|
@@ -25,9 +25,16 @@ cartographer_collect_files() {
|
|
|
25
25
|
done < <(printf '%s' "$exclude_json" | jq -r '.[]' 2>/dev/null)
|
|
26
26
|
|
|
27
27
|
# Discover CLAUDE.md and AGENTS.md
|
|
28
|
+
#
|
|
29
|
+
# ${find_excludes[@]+"${find_excludes[@]}"} rather than "${find_excludes[@]}":
|
|
30
|
+
# under bash 3.2 (macOS system bash) with `set -u`, expanding a genuinely
|
|
31
|
+
# empty array with the plain form is an unbound-variable error, not an
|
|
32
|
+
# empty expansion — that's the case whenever exclude_json has zero entries
|
|
33
|
+
# (e.g. it is unset upstream, or a caller deliberately passes "[]"). The
|
|
34
|
+
# `+` form is the standard portable guard for bash <4.4.
|
|
28
35
|
find "$repo_root" -maxdepth "$max_depth" \
|
|
29
36
|
\( -name "CLAUDE.md" -o -name "AGENTS.md" \) \
|
|
30
|
-
"${find_excludes[@]}" \
|
|
37
|
+
${find_excludes[@]+"${find_excludes[@]}"} \
|
|
31
38
|
-type f 2>/dev/null
|
|
32
39
|
|
|
33
40
|
# Discover .claude/rules/*.md at repo level
|
|
@@ -78,3 +78,29 @@ cartographer_config_max_output_tokens_synthesis() {
|
|
|
78
78
|
v=$(cartographer_config_get '.cartographer.synthesis.max_output_tokens')
|
|
79
79
|
printf '%s' "${v:-2048}"
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
# ── undocumented_entity phase ──────────────────────────────────────────────────
|
|
83
|
+
# Disk → doc detection. Unlike the other phases these are read inside the
|
|
84
|
+
# analysis sub-shell rather than by the orchestrator; see run-audit.sh and
|
|
85
|
+
# ecosystem-88v for why.
|
|
86
|
+
|
|
87
|
+
cartographer_config_undocumented_enabled() {
|
|
88
|
+
local v
|
|
89
|
+
v=$(cartographer_config_get '.cartographer.undocumented_entity.enabled')
|
|
90
|
+
printf '%s' "${v:-true}"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cartographer_config_undocumented_globs() {
|
|
94
|
+
cartographer_config_get_json \
|
|
95
|
+
'.cartographer.undocumented_entity.globs // ["plugins/*/","skills/*/"]'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
cartographer_config_undocumented_exclude() {
|
|
99
|
+
cartographer_config_get_json '.cartographer.undocumented_entity.exclude // []'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
cartographer_config_undocumented_max_findings() {
|
|
103
|
+
local v
|
|
104
|
+
v=$(cartographer_config_get '.cartographer.undocumented_entity.max_findings')
|
|
105
|
+
printf '%s' "${v:-20}"
|
|
106
|
+
}
|
|
@@ -44,6 +44,96 @@ _cartographer_session_id() {
|
|
|
44
44
|
printf 'unknown'
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
# Build the cartographer.issue.found payload for one finding record.
|
|
48
|
+
#
|
|
49
|
+
# Lives here rather than inline in run_emit so the test suite drives the same
|
|
50
|
+
# construction production does. Two independent descriptions of one payload is
|
|
51
|
+
# precisely how the published schema came to document events no code had ever
|
|
52
|
+
# emitted; a single builder means a test that passes is evidence about the real
|
|
53
|
+
# emission path, not about a copy of it.
|
|
54
|
+
#
|
|
55
|
+
# Usage: cartographer_issue_found_payload <audit_id> <finding_hash> <finding_json>
|
|
56
|
+
cartographer_issue_found_payload() {
|
|
57
|
+
local audit_id="${1:-}" finding_hash="${2:-}" finding="${3:-}"
|
|
58
|
+
[[ -z "$audit_id" || -z "$finding_hash" || -z "$finding" ]] && return 1
|
|
59
|
+
|
|
60
|
+
local ftype fseverity ffile_a ffile_b fdesc
|
|
61
|
+
# A finding with no usable type is malformed — the analysis phase that
|
|
62
|
+
# produced it has a bug. This used to default to "unknown", a value no
|
|
63
|
+
# schema admits, so the payload was built, rejected at emit, and the
|
|
64
|
+
# rejection swallowed by the caller's emit_safe: the finding reached disk
|
|
65
|
+
# and nothing reached the bus. Failing here puts it in audit.log instead,
|
|
66
|
+
# where an operator can read it (ecosystem-ci0).
|
|
67
|
+
#
|
|
68
|
+
# `// ""` collapses absent, null, and empty into one check. jq's `//`
|
|
69
|
+
# treats "" as present, so an empty type would otherwise slip through and
|
|
70
|
+
# produce a payload just as unvalidatable as "unknown", by another route.
|
|
71
|
+
ftype=$(printf '%s' "$finding" | jq -r '.type // ""')
|
|
72
|
+
if [[ -z "$ftype" ]]; then
|
|
73
|
+
printf 'cartographer-events: finding %s carries no type; refusing to emit issue.found\n' \
|
|
74
|
+
"$finding_hash" >&2
|
|
75
|
+
return 1
|
|
76
|
+
fi
|
|
77
|
+
fseverity=$(printf '%s' "$finding" | jq -r '.severity // "warning"')
|
|
78
|
+
ffile_a=$(printf '%s' "$finding" | jq -r '.file_a // ""')
|
|
79
|
+
ffile_b=$(printf '%s' "$finding" | jq -r '.file_b // null')
|
|
80
|
+
fdesc=$(printf '%s' "$finding" | jq -r '.description // ""')
|
|
81
|
+
|
|
82
|
+
jq -n \
|
|
83
|
+
--arg audit_id "$audit_id" \
|
|
84
|
+
--arg finding_hash "$finding_hash" \
|
|
85
|
+
--arg finding_type "$ftype" \
|
|
86
|
+
--arg severity "$fseverity" \
|
|
87
|
+
--argjson affected_files "$(jq -n --arg a "$ffile_a" --arg b "$ffile_b" \
|
|
88
|
+
'if $b == "null" or $b == "" then [$a] else [$a,$b] end')" \
|
|
89
|
+
--arg summary "$fdesc" \
|
|
90
|
+
'{"audit_id":$audit_id,"finding_hash":$finding_hash,"finding_type":$finding_type,"severity":$severity,"affected_files":$affected_files,"summary":$summary}'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Build the cartographer.issue.resolved payload for a retired finding.
|
|
94
|
+
#
|
|
95
|
+
# Deliberately just the pair: the envelope already carries a required timestamp
|
|
96
|
+
# set at emit time, and this is emitted from the sweep that flips the record, so
|
|
97
|
+
# a payload-level resolved_at would duplicate it while being free to disagree —
|
|
98
|
+
# the record stores epoch seconds, the envelope ISO 8601. The schema rejects the
|
|
99
|
+
# field for that reason (onlooker-community/schema#50).
|
|
100
|
+
#
|
|
101
|
+
# Usage: cartographer_issue_resolved_payload <audit_id> <finding_hash>
|
|
102
|
+
cartographer_issue_resolved_payload() {
|
|
103
|
+
local audit_id="${1:-}" finding_hash="${2:-}"
|
|
104
|
+
[[ -z "$audit_id" || -z "$finding_hash" ]] && return 1
|
|
105
|
+
|
|
106
|
+
jq -n \
|
|
107
|
+
--arg audit_id "$audit_id" \
|
|
108
|
+
--arg finding_hash "$finding_hash" \
|
|
109
|
+
'{"audit_id":$audit_id,"finding_hash":$finding_hash}'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Build the cartographer.audit.complete payload for a finished run.
|
|
113
|
+
#
|
|
114
|
+
# resolved_count is optional and omitted from the payload when empty. That is
|
|
115
|
+
# the difference between "swept and retired nothing" and "never swept": a
|
|
116
|
+
# targeted or partial run cannot treat a finding's absence as evidence, so it
|
|
117
|
+
# must report no count rather than a zero that reads as the former.
|
|
118
|
+
#
|
|
119
|
+
# Usage: cartographer_audit_complete_payload <audit_id> <trigger> <new_count> <total_count> <duration_ms> [resolved_count]
|
|
120
|
+
cartographer_audit_complete_payload() {
|
|
121
|
+
local audit_id="${1:-}" trigger="${2:-}"
|
|
122
|
+
local new_count="${3:-0}" total_count="${4:-0}" duration_ms="${5:-0}"
|
|
123
|
+
local resolved_count="${6:-}"
|
|
124
|
+
[[ -z "$audit_id" ]] && return 1
|
|
125
|
+
|
|
126
|
+
jq -n \
|
|
127
|
+
--arg audit_id "$audit_id" \
|
|
128
|
+
--arg trigger "$trigger" \
|
|
129
|
+
--argjson new_finding_count "$new_count" \
|
|
130
|
+
--argjson total_finding_count "$total_count" \
|
|
131
|
+
--argjson duration_ms "$duration_ms" \
|
|
132
|
+
--arg resolved "$resolved_count" \
|
|
133
|
+
'{"audit_id":$audit_id,"trigger":$trigger,"new_finding_count":$new_finding_count,"total_finding_count":$total_finding_count,"duration_ms":$duration_ms}
|
|
134
|
+
+ (if $resolved == "" then {} else {"resolved_finding_count": ($resolved|tonumber)} end)'
|
|
135
|
+
}
|
|
136
|
+
|
|
47
137
|
cartographer_emit_event() {
|
|
48
138
|
local event_type="${1:-}"
|
|
49
139
|
local payload="${2:-}"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cartographer-filter.sh — narrow an audit to one finding type or one subtree.
|
|
3
|
+
#
|
|
4
|
+
# SKILL.md documented a --phase flag and a --scope flag that run-audit.sh never
|
|
5
|
+
# read, so both silently ran a full audit (ecosystem-9og). The vocabulary was
|
|
6
|
+
# wrong too: --phase listed finding TYPES, while the pipeline's phases are
|
|
7
|
+
# discover/extract/relate/synthesize/emit. The flag is --type here for that
|
|
8
|
+
# reason — it narrows what the audit looks FOR, not which stage runs.
|
|
9
|
+
#
|
|
10
|
+
# The five types do not map one-to-one onto analyzer calls:
|
|
11
|
+
#
|
|
12
|
+
# contradiction \_ one LLM pass emits either type, so neither can be
|
|
13
|
+
# dead_rule / requested without running the other
|
|
14
|
+
# stale_ref — its own call
|
|
15
|
+
# scope_collision — its own call
|
|
16
|
+
# undocumented_entity — its own call
|
|
17
|
+
#
|
|
18
|
+
# So narrowing works in two steps: skip the analyzers that cannot produce the
|
|
19
|
+
# requested type, then drop any findings the surviving analyzers produced that
|
|
20
|
+
# were not asked for. Skipping is where the savings are — each analyzer skipped
|
|
21
|
+
# is an LLM call not made.
|
|
22
|
+
|
|
23
|
+
CARTOGRAPHER_FINDING_TYPES="contradiction dead_rule stale_ref scope_collision undocumented_entity"
|
|
24
|
+
|
|
25
|
+
# True when a type filter is set and valid.
|
|
26
|
+
#
|
|
27
|
+
# An unrecognized value is rejected rather than silently matching nothing: a
|
|
28
|
+
# typo that quietly produced an empty audit would look exactly like a clean
|
|
29
|
+
# repo, which is the failure this whole issue is about.
|
|
30
|
+
#
|
|
31
|
+
# Usage: cartographer_filter_valid_type <type>
|
|
32
|
+
cartographer_filter_valid_type() {
|
|
33
|
+
local want="${1:-}"
|
|
34
|
+
[[ -z "$want" ]] && return 1
|
|
35
|
+
local t
|
|
36
|
+
for t in $CARTOGRAPHER_FINDING_TYPES; do
|
|
37
|
+
[[ "$t" == "$want" ]] && return 0
|
|
38
|
+
done
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# True when the analyzer should run under the active filter.
|
|
43
|
+
#
|
|
44
|
+
# An empty filter means no filter, so everything runs. The contradiction
|
|
45
|
+
# analyzer answers to both of the types it can emit.
|
|
46
|
+
#
|
|
47
|
+
# Usage: cartographer_filter_wants <analyzer> <type_filter>
|
|
48
|
+
# analyzer: contradiction | stale_ref | scope_collision | undocumented_entity
|
|
49
|
+
cartographer_filter_wants() {
|
|
50
|
+
local analyzer="${1:-}" filter="${2:-}"
|
|
51
|
+
[[ -z "$filter" ]] && return 0
|
|
52
|
+
[[ -z "$analyzer" ]] && return 1
|
|
53
|
+
|
|
54
|
+
if [[ "$analyzer" == "contradiction" ]]; then
|
|
55
|
+
[[ "$filter" == "contradiction" || "$filter" == "dead_rule" ]]
|
|
56
|
+
return
|
|
57
|
+
fi
|
|
58
|
+
[[ "$analyzer" == "$filter" ]]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Drop findings whose type was not requested.
|
|
62
|
+
#
|
|
63
|
+
# Only the contradiction analyzer can return a type other than the one asked
|
|
64
|
+
# for, but filtering the whole set is simpler than special-casing it and stays
|
|
65
|
+
# correct if an analyzer gains a second output type later.
|
|
66
|
+
#
|
|
67
|
+
# Usage: cartographer_filter_findings <findings_json> <type_filter>
|
|
68
|
+
cartographer_filter_findings() {
|
|
69
|
+
local findings="${1:-[]}" filter="${2:-}"
|
|
70
|
+
if [[ -z "$filter" ]]; then
|
|
71
|
+
printf '%s' "$findings"
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
printf '%s' "$findings" \
|
|
75
|
+
| jq -c --arg t "$filter" '[.[] | select(.type == $t)]' 2>/dev/null \
|
|
76
|
+
|| printf '[]'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Restrict a discovered-file list to those under a subtree.
|
|
80
|
+
#
|
|
81
|
+
# The scope narrows which instruction files are ANALYZED; it deliberately does
|
|
82
|
+
# not become the repo root, because stale_ref resolves path-like tokens against
|
|
83
|
+
# the real root and would otherwise report every path outside the scope as
|
|
84
|
+
# broken.
|
|
85
|
+
#
|
|
86
|
+
# A scope matching nothing yields an empty list, which is a legitimate answer —
|
|
87
|
+
# the caller decides whether that is worth reporting.
|
|
88
|
+
#
|
|
89
|
+
# Usage: cartographer_filter_scope <files_json> <repo_root> <scope_path>
|
|
90
|
+
cartographer_filter_scope() {
|
|
91
|
+
local files="${1:-[]}" repo_root="${2:-}" scope="${3:-}"
|
|
92
|
+
if [[ -z "$scope" ]]; then
|
|
93
|
+
printf '%s' "$files"
|
|
94
|
+
return 0
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# Accept a repo-relative scope or an absolute one.
|
|
98
|
+
local abs="$scope"
|
|
99
|
+
[[ "$abs" != /* ]] && abs="${repo_root%/}/${scope#./}"
|
|
100
|
+
abs="${abs%/}"
|
|
101
|
+
|
|
102
|
+
printf '%s' "$files" \
|
|
103
|
+
| jq -c --arg p "$abs" '[.[] | select(. == $p or startswith($p + "/"))]' 2>/dev/null \
|
|
104
|
+
|| printf '[]'
|
|
105
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cartographer-omission.sh — disk → doc detection.
|
|
3
|
+
#
|
|
4
|
+
# Every other analysis phase starts from the text of the instruction files and
|
|
5
|
+
# tests what it finds against the filesystem. This one runs the other way: it
|
|
6
|
+
# enumerates entities on disk and checks each is mentioned somewhere in the
|
|
7
|
+
# corpus. An entity nothing names produces no token for stale_ref to extract
|
|
8
|
+
# and no rule for contradiction to compare, which is why omissions were
|
|
9
|
+
# previously invisible to every phase.
|
|
10
|
+
#
|
|
11
|
+
# Detection is a grep — no model call. A model would only help judge whether an
|
|
12
|
+
# omission MATTERS, which is a sharper question than the drift that motivated
|
|
13
|
+
# this. See docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# cartographer_analyze_undocumented_entity <files_json> <repo_root> \
|
|
17
|
+
# <globs_json> <exclude_json> <max_findings>
|
|
18
|
+
#
|
|
19
|
+
# Prints a JSON array of findings on stdout in the same shape the analyzers in
|
|
20
|
+
# cartographer-analyze.sh return, so run_synthesize merges it without special
|
|
21
|
+
# handling. Diagnostics go to stderr, which run-audit.sh appends to audit.log.
|
|
22
|
+
|
|
23
|
+
# Word-boundary mention test.
|
|
24
|
+
#
|
|
25
|
+
# Boundaries are hand-rolled rather than \b because entity names contain
|
|
26
|
+
# hyphens, and \b treats '-' as a non-word character: \blist-prompt-rules\b
|
|
27
|
+
# would also match inside "my-list-prompt-rules-thing". Bounding on
|
|
28
|
+
# [^A-Za-z0-9_-] instead means a hyphenated name matches only when genuinely
|
|
29
|
+
# standalone, while a name inside a path ("plugins/beta/") still counts.
|
|
30
|
+
_cartographer_name_mentioned() {
|
|
31
|
+
local name="$1"
|
|
32
|
+
local files_json="$2"
|
|
33
|
+
|
|
34
|
+
local escaped
|
|
35
|
+
escaped=$(printf '%s' "$name" | sed 's/[][\.*^$(){}?+|\\]/\\&/g')
|
|
36
|
+
|
|
37
|
+
local fpath
|
|
38
|
+
while IFS= read -r fpath; do
|
|
39
|
+
[[ -z "$fpath" || ! -f "$fpath" ]] && continue
|
|
40
|
+
if grep -qE "(^|[^A-Za-z0-9_-])${escaped}([^A-Za-z0-9_-]|\$)" "$fpath" 2>/dev/null; then
|
|
41
|
+
return 0
|
|
42
|
+
fi
|
|
43
|
+
done < <(printf '%s' "$files_json" | jq -r '.[]' 2>/dev/null)
|
|
44
|
+
return 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cartographer_analyze_undocumented_entity() {
|
|
48
|
+
local files_json="${1:-[]}"
|
|
49
|
+
local repo_root="${2:?repo_root required}"
|
|
50
|
+
local globs_json="${3:-[]}"
|
|
51
|
+
local exclude_json="${4:-[]}"
|
|
52
|
+
local max_findings="${5:-20}"
|
|
53
|
+
|
|
54
|
+
# An empty corpus cannot document anything, so every entity would look
|
|
55
|
+
# undocumented. Refuse rather than emit a burst of false findings that the
|
|
56
|
+
# emit phase would dedup-sentinel and never re-evaluate.
|
|
57
|
+
local corpus_count
|
|
58
|
+
corpus_count=$(printf '%s' "$files_json" | jq 'length' 2>/dev/null || printf '0')
|
|
59
|
+
[[ "${corpus_count:-0}" -eq 0 ]] && { printf '[]'; return 0; }
|
|
60
|
+
|
|
61
|
+
local findings="[]"
|
|
62
|
+
local emitted=0 dropped=0
|
|
63
|
+
local root="${repo_root%/}"
|
|
64
|
+
|
|
65
|
+
# nullglob so an unmatched pattern expands to nothing rather than to itself.
|
|
66
|
+
# Restore the prior setting — this library is sourced, not run.
|
|
67
|
+
local had_nullglob=0
|
|
68
|
+
shopt -q nullglob && had_nullglob=1
|
|
69
|
+
shopt -s nullglob
|
|
70
|
+
|
|
71
|
+
local glob match
|
|
72
|
+
while IFS= read -r glob; do
|
|
73
|
+
[[ -z "$glob" ]] && continue
|
|
74
|
+
# shellcheck disable=SC2086 # $glob unquoted on purpose: this is the glob
|
|
75
|
+
# expansion. ${root} IS quoted — a repo path containing a space must not
|
|
76
|
+
# word-split before the glob expands, or the match silently finds nothing.
|
|
77
|
+
for match in "${root}"/$glob; do
|
|
78
|
+
[[ -e "$match" ]] || continue
|
|
79
|
+
|
|
80
|
+
local trimmed="${match%/}"
|
|
81
|
+
local name relpath
|
|
82
|
+
name=$(basename "$trimmed")
|
|
83
|
+
relpath="${trimmed#"${root}"/}"
|
|
84
|
+
|
|
85
|
+
local excluded=0 excl
|
|
86
|
+
while IFS= read -r excl; do
|
|
87
|
+
[[ -z "$excl" ]] && continue
|
|
88
|
+
[[ "$relpath" == *"$excl"* ]] && { excluded=1; break; }
|
|
89
|
+
done < <(printf '%s' "$exclude_json" | jq -r '.[]' 2>/dev/null)
|
|
90
|
+
[[ "$excluded" -eq 1 ]] && continue
|
|
91
|
+
|
|
92
|
+
_cartographer_name_mentioned "$name" "$files_json" && continue
|
|
93
|
+
|
|
94
|
+
if [[ "$emitted" -ge "$max_findings" ]]; then
|
|
95
|
+
dropped=$(( dropped + 1 ))
|
|
96
|
+
continue
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
local finding
|
|
100
|
+
finding=$(jq -n \
|
|
101
|
+
--arg fa "$trimmed" \
|
|
102
|
+
--arg n "$name" \
|
|
103
|
+
--arg rp "$relpath" \
|
|
104
|
+
'{
|
|
105
|
+
type: "undocumented_entity",
|
|
106
|
+
severity: "warning",
|
|
107
|
+
file_a: $fa,
|
|
108
|
+
excerpt_a: $n,
|
|
109
|
+
file_b: null,
|
|
110
|
+
excerpt_b: null,
|
|
111
|
+
description: ($n + " exists at " + $rp
|
|
112
|
+
+ " but is not mentioned in any instruction file."),
|
|
113
|
+
suggested_fix: ("Document " + $n
|
|
114
|
+
+ " in CLAUDE.md, or exclude its path from cartographer.undocumented_entity.")
|
|
115
|
+
}')
|
|
116
|
+
findings=$(printf '%s' "$findings" | jq --argjson f "$finding" '. + [$f]')
|
|
117
|
+
emitted=$(( emitted + 1 ))
|
|
118
|
+
done
|
|
119
|
+
done < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null)
|
|
120
|
+
|
|
121
|
+
[[ "$had_nullglob" -eq 0 ]] && shopt -u nullglob
|
|
122
|
+
|
|
123
|
+
# Say what was dropped. A silent truncation reads as "this is everything".
|
|
124
|
+
if [[ "$dropped" -gt 0 ]]; then
|
|
125
|
+
printf 'undocumented_entity: capped at %s findings, %s candidate(s) dropped\n' \
|
|
126
|
+
"$max_findings" "$dropped" >&2
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
printf '%s' "$findings"
|
|
130
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cartographer-resolve.sh — retire findings whose drift is gone.
|
|
3
|
+
#
|
|
4
|
+
# A finding is written once to findings/<hash>.json and re-observed on every
|
|
5
|
+
# later audit, which refreshes last_seen_at. Nothing ever marked one absent, so
|
|
6
|
+
# fixing the drift a finding reported did not retire the finding — it rendered
|
|
7
|
+
# forever. The record already carried resolved:false from birth; the field
|
|
8
|
+
# existed, the loop that flips it did not.
|
|
9
|
+
#
|
|
10
|
+
# Absence is the evidence: the audit knows every finding it observed this run,
|
|
11
|
+
# so anything in the store whose last_seen_at predates this run's start was not
|
|
12
|
+
# observed and its drift is gone.
|
|
13
|
+
#
|
|
14
|
+
# That inference is only sound when the run actually looked everywhere, which is
|
|
15
|
+
# what the two guards below protect.
|
|
16
|
+
|
|
17
|
+
# Refresh a finding that was observed again this run.
|
|
18
|
+
#
|
|
19
|
+
# Usage: cartographer_refresh_finding <finding_file> <now>
|
|
20
|
+
#
|
|
21
|
+
# Reopening matters as much as the timestamp. The dedup sentinel outlives
|
|
22
|
+
# resolution, so drift that is fixed and then reintroduced arrives here rather
|
|
23
|
+
# than down the new-finding path. Leaving resolved:true would keep a live
|
|
24
|
+
# finding hidden from the renderer forever — a silent false negative, and a
|
|
25
|
+
# worse failure than the stale findings this module exists to retire.
|
|
26
|
+
cartographer_refresh_finding() {
|
|
27
|
+
local finding_file="${1:-}" now="${2:-}"
|
|
28
|
+
[[ -z "$finding_file" || -z "$now" ]] && return 1
|
|
29
|
+
[[ -f "$finding_file" ]] || return 1
|
|
30
|
+
|
|
31
|
+
local updated
|
|
32
|
+
updated=$(jq --argjson ts "$now" \
|
|
33
|
+
'.last_seen_at = $ts | .resolved = false | del(.resolved_at)' \
|
|
34
|
+
"$finding_file" 2>/dev/null) || return 1
|
|
35
|
+
[[ -z "$updated" ]] && return 1
|
|
36
|
+
|
|
37
|
+
printf '%s\n' "$updated" >"${finding_file}.tmp" || return 1
|
|
38
|
+
mv -f "${finding_file}.tmp" "$finding_file"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Whether this run looked at enough for absence to mean anything.
|
|
42
|
+
#
|
|
43
|
+
# Two ways it does not. A targeted post-write audit evaluates a single file, so
|
|
44
|
+
# almost every stored finding is "unobserved" for reasons that have nothing to
|
|
45
|
+
# do with being fixed — letting it resolve would wipe the store on every edit.
|
|
46
|
+
# And a phase that timed out or errored contributes no findings, which is
|
|
47
|
+
# indistinguishable from its findings being gone.
|
|
48
|
+
#
|
|
49
|
+
# Named rather than inlined because run_emit needs the same answer to decide
|
|
50
|
+
# whether cartographer.audit.complete may report a resolved count at all. Two
|
|
51
|
+
# copies of this condition is precisely the kind of divergence that has bitten
|
|
52
|
+
# this plugin before.
|
|
53
|
+
#
|
|
54
|
+
# Usage: cartographer_resolution_is_sound <target_file> <phases_failed>
|
|
55
|
+
cartographer_resolution_is_sound() {
|
|
56
|
+
local target_file="${1:-}" phases_failed="${2:-0}"
|
|
57
|
+
[[ -z "$target_file" && "$phases_failed" -eq 0 ]]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Mark findings not observed by this audit as resolved.
|
|
61
|
+
#
|
|
62
|
+
# Usage:
|
|
63
|
+
# cartographer_resolve_absent_findings <findings_dir> <cutoff_ts> \
|
|
64
|
+
# <target_file> <phases_failed_count> \
|
|
65
|
+
# [now] [on_resolved]
|
|
66
|
+
#
|
|
67
|
+
# on_resolved, when given, is the name of a function called once per retired
|
|
68
|
+
# finding with its hash. Its stdout is discarded; see the call site below.
|
|
69
|
+
#
|
|
70
|
+
# cutoff_ts is the audit's start time: a finding observed this run had its
|
|
71
|
+
# last_seen_at refreshed to at-or-after it, so a record older than it was not
|
|
72
|
+
# observed. The comparison is strict (<), which errs toward leaving a finding
|
|
73
|
+
# open when the timestamps collide — a stale finding is visible and correctable,
|
|
74
|
+
# an incorrectly retired one is silent.
|
|
75
|
+
#
|
|
76
|
+
# Prints the number of findings newly resolved.
|
|
77
|
+
cartographer_resolve_absent_findings() {
|
|
78
|
+
local findings_dir="${1:-}" cutoff_ts="${2:-}"
|
|
79
|
+
local target_file="${3:-}" phases_failed="${4:-0}"
|
|
80
|
+
local now="${5:-}"
|
|
81
|
+
local on_resolved="${6:-}"
|
|
82
|
+
[[ -z "$now" ]] && now=$(date +%s)
|
|
83
|
+
|
|
84
|
+
[[ -z "$findings_dir" || -z "$cutoff_ts" ]] && return 1
|
|
85
|
+
if [[ ! -d "$findings_dir" ]]; then
|
|
86
|
+
printf '0'
|
|
87
|
+
return 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Both guards live in the predicate. A run that fails either one retires
|
|
91
|
+
# nothing and announces nothing — announcing a resolution the run did not
|
|
92
|
+
# establish tells every consumer to close a finding that is still live.
|
|
93
|
+
if ! cartographer_resolution_is_sound "$target_file" "$phases_failed"; then
|
|
94
|
+
printf '0'
|
|
95
|
+
return 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
local resolved=0 f updated
|
|
99
|
+
for f in "$findings_dir"/*.json; do
|
|
100
|
+
[[ -f "$f" ]] || continue
|
|
101
|
+
# Emits nothing when the record is already resolved or was seen this
|
|
102
|
+
# run, so an unchanged record is never rewritten and never counted.
|
|
103
|
+
updated=$(jq --argjson cutoff "$cutoff_ts" --argjson now "$now" '
|
|
104
|
+
if .resolved == true then empty
|
|
105
|
+
elif ((.last_seen_at // 0) < $cutoff) then . + {resolved: true, resolved_at: $now}
|
|
106
|
+
else empty end
|
|
107
|
+
' "$f" 2>/dev/null) || continue
|
|
108
|
+
[[ -z "$updated" ]] && continue
|
|
109
|
+
printf '%s\n' "$updated" >"${f}.tmp" || continue
|
|
110
|
+
mv -f "${f}.tmp" "$f" || continue
|
|
111
|
+
resolved=$(( resolved + 1 ))
|
|
112
|
+
|
|
113
|
+
# Announce inside the guarded loop, so a run that must not resolve also
|
|
114
|
+
# cannot announce. The emitter is injected rather than sourced: this
|
|
115
|
+
# module stays pure data, and a test can pass a stub without dragging
|
|
116
|
+
# the event bus in behind it.
|
|
117
|
+
#
|
|
118
|
+
# stdout is the count channel — the caller reads it through a command
|
|
119
|
+
# substitution — so the emitter is redirected away from it. That is also
|
|
120
|
+
# why announcing works at all here despite the subshell: appending to
|
|
121
|
+
# the event log is a filesystem effect, and unlike a variable it
|
|
122
|
+
# survives.
|
|
123
|
+
if [[ -n "$on_resolved" ]]; then
|
|
124
|
+
local fhash
|
|
125
|
+
fhash=$(printf '%s' "$updated" | jq -r '.finding_hash // ""')
|
|
126
|
+
[[ -z "$fhash" ]] && fhash=$(basename "$f" .json)
|
|
127
|
+
"$on_resolved" "$fhash" >/dev/null || true
|
|
128
|
+
fi
|
|
129
|
+
done
|
|
130
|
+
|
|
131
|
+
printf '%d' "$resolved"
|
|
132
|
+
}
|