Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ jobs:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-hook-utils.sh --check-bump "origin/$BASE_REF"

parse-concern-value-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Full history so the PR base ref is resolvable for the bump gate.
fetch-depth: 0
- name: Verify plugin copies match the shared parser
run: scripts/sync-parse-concern-value.sh --check
- name: Run shared parser tests
run: bash lib/parse-concern-value.test.sh
- name: Verify consuming plugins bumped when the lib changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-parse-concern-value.sh --check-bump "origin/$BASE_REF"

standards-contract-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand Down Expand Up @@ -590,6 +610,7 @@ jobs:
needs:
- hygiene
- hook-utils-sync
- parse-concern-value-sync
- standards-contract-sync
- cross-plugin-source-drift
- skill-leaf-name-gate
Expand Down
116 changes: 116 additions & 0 deletions lib/parse-concern-value.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Resolve a single scalar value from a topic-docs concern file the way every
# consuming plugin must: quote-aware, comment-safe, whitespace-trimmed,
# trailing-slash-normalized — with a caller-supplied fallback for the case the
# key is absent.
#
# Why this exists: the topic-docs seam (`.claude/topic-docs.yaml` `memory_dir`
# and siblings) was parsed inline at each consumer with a naive
# `val="${val%%#*}"` FIRST — which truncates a legitimately-quoted value that
# contains `#` (`"a#b"` -> `"a`) and strips comments before quotes are resolved.
# Centralizing the parse fixes it once for every consumer instead of re-forking
# the same bug per site.
#
# SINGLE SOURCE OF TRUTH: lib/parse-concern-value.sh at the marketplace repo
# root. The copies materialized into consuming plugins exist because installed
# plugins are cache-isolated and must be self-contained — never edit a copy.
# Edit the source and run scripts/sync-parse-concern-value.sh; CI rejects
# drifted copies.
#
# Usage:
# parse-concern-value.sh <concern-file> <key> [fallback]
#
# <concern-file> path to the concern file (e.g. .claude/topic-docs.yaml)
# <key> scalar key to read (e.g. memory_dir)
# [fallback] value to emit when the key is absent/empty — the caller's
# already-resolved rung-2 location (a save-point convention
# declared in CLAUDE.md / .claude/rules). Prose is an
# inference source, not a runtime authority, so the caller
# infers it and passes it in; this script never reads prose.
#
# Output: the resolved value on stdout, or empty when nothing resolves (the
# caller applies the documented default, e.g. `.work`). Always exits 0 for a
# well-formed invocation.
#
# Resolution order (mirrors the topic-docs contract's non-interactive degrade):
# 1. key present and non-empty in the concern file -> its parsed value
# 2. else the caller-supplied fallback (if non-empty)
# 3. else empty (interactive/inferred-layout rungs are the caller's job)
set -uo pipefail

if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat <<'EOF'
parse-concern-value.sh — resolve a scalar value from a topic-docs concern file.

Usage:
parse-concern-value.sh <concern-file> <key> [fallback]

Reads <key> from <concern-file>, quote-aware and comment-safe: a `#` inside a
quoted value is preserved; a trailing ` # comment` on an unquoted value is
stripped; surrounding quotes are peeled, surrounding whitespace trimmed, and a
trailing slash normalized. Emits [fallback] when the key is absent/empty, or
nothing when neither resolves. Always exits 0 for a well-formed invocation.
EOF
exit 0
fi

concern_file="${1:-}"
key="${2:-}"
fallback="${3:-}"

if [[ -z "$concern_file" || -z "$key" ]]; then
echo "parse-concern-value: usage: parse-concern-value.sh <concern-file> <key> [fallback]" >&2
exit 2
fi

# Peel a matching surrounding quote pair, strip a comment (quote-aware), trim
# surrounding whitespace, normalize a trailing slash — in THAT order. Quote
# resolution comes first so a `#` inside quotes is never mistaken for a comment.
strip_value() {
local raw="$1" val
raw="${raw//$'\r'/}" # CRLF guard: Git Bash leaves a trailing \r on read lines
# Trim leading whitespace (sed's [[:space:]]* already ate the post-colon run,
# but a re-used helper must not assume its caller's extraction).
raw="${raw#"${raw%%[![:space:]]*}"}"

case "$raw" in
'"'*)
# Double-quoted: value is the span up to the closing quote; anything after
# it (e.g. a trailing comment) is discarded. `#` inside is literal.
val="${raw#\"}"
val="${val%%\"*}"
;;
"'"*)
val="${raw#\'}"
val="${val%%\'*}"
;;
*)
# Unquoted: strip a comment at a `#` that starts the value (a comment-only
# value like `# use default` — YAML-null, must resolve to empty so the
# fallback fires) or is preceded by whitespace (` #…`); a `#` adjacent to a
# non-space char (`a#b`, `.work/#t`) is part of the scalar. Then trim
# trailing whitespace.
val="$raw"
val="$(printf '%s' "$val" | sed -e 's/^#.*$//' -e 's/[[:space:]]#.*$//')"
val="${val%"${val##*[![:space:]]}"}"
;;
esac

val="${val%/}" # normalize a single trailing slash
printf '%s' "$val"
}

resolved=""
if [[ -f "$concern_file" ]]; then
# sed anchors on the literal key; keys are `[a-z_]+` identifiers, no metachars.
raw_line=$(sed -n "s/^${key}:[[:space:]]*//p" "$concern_file" | head -1)
if [[ -n "$raw_line" ]]; then
resolved=$(strip_value "$raw_line")
fi
fi

if [[ -z "$resolved" ]]; then
resolved="$fallback"
fi

printf '%s\n' "$resolved"
103 changes: 103 additions & 0 deletions lib/parse-concern-value.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Regression tests for lib/parse-concern-value.sh — the shared topic-docs
# concern-value parser. Run directly: bash lib/parse-concern-value.test.sh
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$SCRIPT_DIR/parse-concern-value.sh"

TEST_TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TEST_TMPDIR"' EXIT

FAILED=0
CASE_NUM=0

pass() {
CASE_NUM=$((CASE_NUM + 1))
printf 'PASS: %s\n' "$1"
}
fail() {
CASE_NUM=$((CASE_NUM + 1))
FAILED=$((FAILED + 1))
printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2
}
assert_eq() {
if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: [$2], actual: [$3]"; fi
}
assert_exit() {
if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi
}
assert_contains() {
case "$2" in
*"$3"*) pass "$1" ;;
*) fail "$1" "expected to contain: $3" ;;
esac
}

# Write a concern file holding a single memory_dir line, then resolve it.
resolve() {
local content="$1" key="${2:-memory_dir}" fallback="${3:-}"
local f="$TEST_TMPDIR/topic-docs.yaml"
printf '%s\n' "$content" >"$f"
bash "$SCRIPT" "$f" "$key" "$fallback"
}

# --- Case: --help exits 0 with usage ---
rc=0
OUT=$(bash "$SCRIPT" --help) || rc=$?
assert_exit "--help exits 0" 0 "$rc"
assert_contains "--help prints usage" "$OUT" "Usage:"

# --- Regression 1: `#` inside a quoted value is preserved (the reported bug) ---
assert_eq 'double-quoted a#b keeps the #' "a#b" "$(resolve 'memory_dir: "a#b"')"
assert_eq 'single-quoted a#b keeps the #' "a#b" "$(resolve "memory_dir: 'a#b'")"
assert_eq 'unquoted .work/#topic keeps the #' ".work/#topic" "$(resolve 'memory_dir: .work/#topic')"
assert_eq 'quoted value with trailing comment drops comment, keeps inner #' \
"a#b" "$(resolve 'memory_dir: "a#b" # inline comment')"

# --- Held behavior: unquoted trailing ` # comment` still stripped ---
assert_eq 'unquoted trailing comment stripped' ".scratch" "$(resolve 'memory_dir: .scratch # the tier')"

# --- Held behavior: surrounding whitespace trimmed ---
assert_eq 'leading/trailing whitespace trimmed (unquoted)' ".scratch" "$(resolve 'memory_dir: .scratch ')"

# --- Held behavior: interior whitespace in a quoted value preserved ---
assert_eq 'quoted interior space preserved' ".scratch dir" "$(resolve 'memory_dir: ".scratch dir"')"

# --- Held behavior: trailing slash normalized ---
assert_eq 'trailing slash normalized (unquoted)' ".work" "$(resolve 'memory_dir: .work/')"
assert_eq 'trailing slash normalized (quoted)' "foo/bar" "$(resolve 'memory_dir: "foo/bar/"')"

# --- Regression 2: absent/empty key falls back to the caller-supplied location ---
assert_eq 'absent key returns fallback (declared save-point)' \
".notes" "$(resolve 'other_key: x' memory_dir '.notes')"
assert_eq 'empty value returns fallback' \
".notes" "$(resolve 'memory_dir:' memory_dir '.notes')"
assert_eq 'comment-only value falls back (memory_dir: # use default)' \
".notes" "$(resolve 'memory_dir: # use default' memory_dir '.notes')"
assert_eq 'comment-only value, no fallback => empty' \
"" "$(resolve 'memory_dir: # use default')"
assert_eq 'fallback is trailing-slash-agnostic passthrough' \
".notes/deep" "$(resolve 'other_key: x' memory_dir '.notes/deep')"

# --- Absent concern FILE returns fallback (empty when none) ---
assert_eq 'missing file, no fallback => empty' "" "$(bash "$SCRIPT" "$TEST_TMPDIR/nope.yaml" memory_dir)"
assert_eq 'missing file, with fallback => fallback' ".x" "$(bash "$SCRIPT" "$TEST_TMPDIR/nope.yaml" memory_dir '.x')"

# --- Present key wins over fallback ---
assert_eq 'present key overrides fallback' ".real" "$(resolve 'memory_dir: .real' memory_dir '.fallback')"

# --- Nothing resolves => empty (caller applies documented default) ---
assert_eq 'no key, no fallback => empty' "" "$(resolve 'other_key: x')"

# --- Missing args exit 2 ---
rc=0
bash "$SCRIPT" >/dev/null 2>&1 || rc=$?
assert_exit "no args exits 2" 2 "$rc"

if [[ "$FAILED" -eq 0 ]]; then
printf '\nAll %d checks passed.\n' "$CASE_NUM"
exit 0
fi
printf '\n%d/%d checks failed.\n' "$FAILED" "$CASE_NUM" >&2
exit 1
2 changes: 1 addition & 1 deletion plugins/claude-memory/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-memory",
"version": "0.2.2",
"version": "0.2.3",
"description": "Audits the Claude Code instruction/memory layer — CLAUDE.md, CLAUDE.local.md, .claude/rules/, and auto-memory — against a checklist derived from official Claude Code documentation. A deterministic script-backed spine (MEMORY.md index integrity, orphan always-loaded rules) yields identical findings on identical repo state; judgment-tier checks apply fixed criteria with model reading. Actions: audit (default), fix (per-item approval), update (refresh criteria from current docs), report.",
"author": {
"name": "Melodic Software",
Expand Down
22 changes: 22 additions & 0 deletions plugins/claude-memory/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
All notable changes to the `claude-memory` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.2.3]

### Fixed

- **`orphan-rule-check` no longer truncates a quoted `memory_dir` at an interior `#`.**
Seam resolution now routes through the shared `parse-concern-value.sh` helper
(materialized from `lib/parse-concern-value.sh`), which resolves surrounding quotes
*before* stripping comments: `memory_dir: ".scratch#dir"` keeps its `#` and the correct
tier is excluded from the reference search, rather than collapsing to `.scratch` and
masking an orphan rule. The naive `${seam%%#*}`-first strip is gone; an unquoted
whitespace-preceded trailing `# comment`, surrounding whitespace, and trailing-slash
handling are unchanged. As a
non-interactive detector it still degrades to the documented `.work` default when the
seam is unset — the contract's inferred/interactive rungs stay the calling skill's job.
- **A comment-only `memory_dir` now resolves to the fallback, not a literal directory.**
`memory_dir: # use default` is YAML-null; the parser previously kept `# use default`
as the value (its comment strip only fired on a whitespace-*preceded* `#`), so the
detector searched `# use default/` and stopped excluding the default `.work/` tier —
letting a `.work` reference mask an orphan. A `#` that starts the unquoted value is now
treated as a comment, so resolution falls through to the caller's fallback / documented
default.

## [0.2.2]

### Changed
Expand Down
24 changes: 12 additions & 12 deletions plugins/claude-memory/skills/audit/scripts/orphan-rule-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@

set -uo pipefail

# Absolute path to this script's dir, resolved before any `cd` so the sibling
# shared parser stays locatable regardless of how we were invoked.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat <<'EOF'
orphan-rule-check.sh — flag always-loaded .claude/rules/*.md referenced by no tracked file.
Expand Down Expand Up @@ -55,18 +59,14 @@ fi
cd "$repo_root" || exit 1

# Resolve the in-repo memory tier from the topic-docs seam (`.claude/topic-docs.yaml`
# `memory_dir`) — the same sed-extract shape as docs-hygiene's noise-shapes.sh — and
# exclude THAT path from the reference search, falling back to the convention default
# `.work` only when the seam is unset. NOTE: this is the tracked in-repo tier, distinct
# from the auto-memory dir the sibling resolve-memory-dir.sh derives.
memory_dir=".work"
topic_docs="${repo_root}/.claude/topic-docs.yaml"
if [[ -f "$topic_docs" ]]; then
seam=$(sed -n 's/^memory_dir:[[:space:]]*//p' "$topic_docs" | head -1)
seam="${seam%%#*}"; seam="${seam%"${seam##*[![:space:]]}"}"; seam="${seam%/}"
seam="${seam#\"}"; seam="${seam%\"}"; seam="${seam#\'}"; seam="${seam%\'}"
[[ -n "$seam" ]] && memory_dir="$seam"
fi
# `memory_dir`) via the shared parser (quote-aware, comment-safe) and exclude THAT
# path from the reference search. NOTE: this is the tracked in-repo tier, distinct
# from the auto-memory dir the sibling resolve-memory-dir.sh derives. As a
# non-interactive detector it degrades straight to the documented `.work` default
# when the seam is unset; the contract's inferred/interactive rungs (a save-point
# convention declared in CLAUDE.md / .claude/rules) are the calling skill's job.
seam=$("$SCRIPT_DIR/parse-concern-value.sh" "${repo_root}/.claude/topic-docs.yaml" memory_dir)
memory_dir="${seam:-.work}"

# A rule is always-loaded unless its frontmatter declares `paths:`. Frontmatter is the
# leading `---` ... `---` block.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@ printf 'see d.md\n' >"$WS/.scratch dir/refD.md"
OUT=$(cd "$WS" && bash "$SCRIPT")
assert_contains "whitespace: ref in quoted '.scratch dir' tier is excluded => d.md orphan" "$OUT" "d.md"

# --- Case 9: `#` inside a quoted memory_dir is preserved, not truncated ---------------
# A naive `${seam%%#*}` truncates `.scratch#dir` to `.scratch`, so the REAL tier is not
# excluded and its ref counts — masking the orphan. Quote-aware parsing keeps the `#`:
# the full tier IS excluded and the rule stays an orphan.

HASH="$TEST_TMPDIR/hash"
make_repo "$HASH"
mkdir -p "$HASH/.claude/rules" "$HASH/.scratch#dir"
printf 'memory_dir: ".scratch#dir"\n' >"$HASH/.claude/topic-docs.yaml"
# rule E referenced ONLY from the #-containing memory tier => still ORPHAN
printf '# Rule E\n\nbody\n' >"$HASH/.claude/rules/e.md"
printf 'see e.md\n' >"$HASH/.scratch#dir/refE.md"
(cd "$HASH" && git add -A && git commit -q -m "hash fixture")

OUT=$(cd "$HASH" && bash "$SCRIPT")
assert_contains "hash: ref in quoted '.scratch#dir' tier is excluded => e.md orphan" "$OUT" "e.md"

if [[ "$FAILED" -eq 0 ]]; then
printf '\nAll %d checks passed.\n' "$CASE_NUM"
exit 0
Expand Down
Loading
Loading