Skip to content

Repeated Markdown headings collapse into one Section and misattribute references #2586

Description

@bline

Concerns the doc-links feature from #2551; no existing issue found.

Section qualified names omit the heading hierarchy, so two ## Implementation headings under different parents become one node (the last one). References written under them are then attributed to the File node, with no unresolved row. Repeated subsection names are common in technical documentation.

# Service A

## Implementation

Uses `src/ledger.py::balance`.

# Service B

## Implementation

Uses `src/ledger.py::post_entry`.
Implementation sections: [['<project>.docs.repeated.Implementation', '9']]
MENTIONS sources: [['File', 'balance'], ['File', 'post_entry']]

Expected: two Implementation sections, each the source of its own reference.

Environment: built from main at ec4fbd3a (2026-10-10), Linux x86-64, CLI mode, fresh cache. Upstream main at 76a24859 adds no changes that appear to touch these code paths.

Reproduction

CBM="codebase-memory-mcp cli" ./repro.sh 2 (needs git and python3; each case uses a fresh throwaway repository). Each check prints REPRODUCED or NOT REPRODUCED. Exit status is 0 when all reproduce, 1 when any does not, and 2 on a command failure.

repro.sh
#!/bin/bash
# Standalone reproductions for codebase-memory-mcp issue reports.
# Usage: CBM="codebase-memory-mcp cli" ./repro.sh [1a|1b|1c|1d|2|3|4|5 ...]
# Needs: git, python3. Each case uses a fresh throwaway repository.
#
# Every case asserts the defective outcome it reports:
#   REPRODUCED      the defect is present (expected on the reported build)
#   NOT REPRODUCED  the observed value differs (fixed, or environment differs)
# Exit status: 0 all reproduced, 1 any not reproduced, 2 a command failed.
set -eEuo pipefail
shopt -s inherit_errexit
CBM=${CBM:-"codebase-memory-mcp cli"}
WORK=${WORK:-$(mktemp -d)}
REPO="" PROJ="" MISSED=0

# A failure anywhere, including inside $(...), leaves a marker so the case
# reports a command error instead of a misleading NOT REPRODUCED.
die() { echo "ERROR: $*" >&2; : > "$WORK/failed"; exit 2; }
trap 'die "command failed at line $LINENO: $BASH_COMMAND"' ERR

# Run one CLI tool; a nonzero exit or non-JSON output stops the script.
# index_repository always answers in JSON and takes no --format flag.
cbm() {
  local out format=(--format json)
  [ "$1" = index_repository ] && format=()
  out=$($CBM "$@" "${format[@]}" 2>"$WORK/stderr") || { cat "$WORK/stderr" >&2; die "$CBM $1 exited nonzero"; }
  python3 -c 'import json,sys; json.loads(sys.argv[1])' "$out" 2>/dev/null || die "$CBM $1 returned non-JSON: $out"
  printf '%s' "$out"
}
py() { python3 -c "import json,sys; d=json.loads(sys.argv[1]); print($2)" "$1"; }
redact() { sed -e "s|$REPO|<repo>|g" -e "s|$PROJ|<project>|g"; }

expect() {  # expect <claim> <actual> <value-that-shows-the-defect>
  if [ "$2" = "$3" ]; then echo "REPRODUCED: $1"
  else echo "NOT REPRODUCED: $1"; echo "  expected: $3"; echo "  actual:   $2"; MISSED=1; fi
}

new_repo() {
  REPO=$(mktemp -d "$WORK/repo.XXXX")
  cd "$REPO"
  git init -q && git config user.email t@t && git config user.name t
  mkdir -p src/util docs
  printf 'def post_entry(ledger, amount):\n    ledger.append(amount)\n    return balance(ledger)\n\n\ndef balance(ledger):\n    return sum(ledger)\n' > src/ledger.py
  printf 'def clamp(x, lo, hi):\n    return max(lo, min(hi, x))\n' > src/util/rounding.py
}
commit() { git add -A && git commit -qm "$1"; }
index() { local out; out=$(cbm index_repository --repo-path "$REPO"); PROJ=$(py "$out" 'd["project"]'); }
coverage() { cbm check_index_coverage --project "$PROJ" --paths "[\"$1\"]"; }
fresh() { local out; out=$(coverage "$1"); py "$out" 'd["paths"][0]["freshness"]'; }
generation() { local out; out=$(coverage src/ledger.py); py "$out" 'd["metadata"]["generation"]'; }
cypher() { local out; out=$(cbm query_graph --project "$PROJ" --query "$1"); py "$out" 'd["rows"]' | redact; }
snippet() { local out; out=$(cbm get_code_snippet --project "$PROJ" --qualified-name "$1" --source-mode full)
            py "$out" 'repr((d["start_line"], d["end_line"], d["source"]))'; }
doc_links() { local out; out=$(cbm index_status --project "$PROJ" --diagnostics full); py "$out" 'repr((d["doc_links"]["mentions"], d["doc_links"]["unresolved"]))'; }

case_1a() {  # exposed generation does not change on incremental re-index
  new_repo; commit init; index
  local first; first=$(generation); echo "generation after full index: $first"
  for i in 1 2; do
    sleep 1.1; echo "# edit $i" >> src/ledger.py; index
    local now; now=$(generation); echo "generation after edit $i + index: $now"
    expect "generation unchanged after incremental re-index $i" "$now" "$first"
  done
}
case_1b() {  # snippet mixes indexed line range with current disk bytes
  new_repo; commit init; index
  local before after; before=$(snippet balance); echo "indexed snippet of balance: $before"
  printf '# a\n# b\n# c\n' | cat - src/ledger.py > "$WORK/shifted" && mv "$WORK/shifted" src/ledger.py
  local state; state=$(fresh src/ledger.py); echo "after inserting 3 lines at top, no re-index; freshness=$state"
  after=$(snippet balance); echo "snippet of balance now: $after"
  expect "snippet for balance returns another function's lines" "$after" "(6, 7, '    return balance(ledger)\\n\\n')"
}
case_1c() {  # same-size edit with restored mtime passes freshness
  new_repo; commit init; index
  touch -r src/ledger.py "$WORK/mtime-ref"
  sed -i 's/return sum(ledger)/return max(ledger)/' src/ledger.py
  touch -r "$WORK/mtime-ref" src/ledger.py
  echo "content changed: $(git diff --numstat -- src/ledger.py) (same size, original mtime)"
  expect "freshness reports a content change as current" "$(fresh src/ledger.py)" "metadata_match"
}
case_1d() {  # identical bytes with new mtime stay metadata_changed after re-index
  new_repo; commit init; index
  echo "# tmp" >> src/ledger.py; git checkout -q -- src/ledger.py   # identical bytes, new mtime
  echo "git status after restore: '$(git status --short)'"
  index; expect "freshness stale after re-index of identical bytes" "$(fresh src/ledger.py)" "metadata_changed"
  index; expect "freshness stale after second re-index" "$(fresh src/ledger.py)" "metadata_changed"
}
case_2() {  # repeated headings collapse; references misattributed
  new_repo
  printf '# Service A\n\n## Implementation\n\nUses `src/ledger.py::balance`.\n\n# Service B\n\n## Implementation\n\nUses `src/ledger.py::post_entry`.\n' > docs/repeated.md
  commit init; index
  local sections sources
  sections=$(cypher 'MATCH (s:Section) WHERE s.file_path = "docs/repeated.md" AND s.name = "Implementation" RETURN s.qualified_name, s.start_line')
  sources=$(cypher 'MATCH (s)-[:MENTIONS]->(c) WHERE s.file_path = "docs/repeated.md" RETURN s.label, c.name ORDER BY c.name')
  echo "Implementation sections: $sections"; echo "MENTIONS sources: $sources"
  expect "two Implementation headings yield one Section" "$sections" "[['<project>.docs.repeated.Implementation', '9']]"
  expect "references under them are attributed to the File" "$sources" "[['File', 'balance'], ['File', 'post_entry']]"
}
case_3() {  # missing member silently becomes a file edge
  new_repo
  printf '# Notes\n\nSee `src/util/rounding.py::no_such_function`.\n' > docs/notes.md
  commit init; index
  local edges; edges=$(cypher 'MATCH (s)-[r:MENTIONS]->(c) WHERE s.file_path = "docs/notes.md" RETURN c.label, c.name, r.tier')
  echo "MENTIONS: $edges"; echo "doc_links (mentions, unresolved): $(doc_links)"
  expect "missing member resolves as an exact File edge" "$edges" "[['File', 'rounding.py', 'exact']]"
  expect "no unresolved row records the missing member" "$(doc_links)" "(1, {})"
}
case_4() {  # references into a moved directory vanish without unresolved rows
  new_repo
  printf '# Notes\n\nSee `src/util/rounding.py::clamp`.\n' > docs/notes.md
  commit init; index
  echo "before move: $(cypher 'MATCH (s)-[:MENTIONS]->(c) RETURN c.name')"
  git mv src/util src/helpers; index
  local edges; edges=$(cypher 'MATCH (s)-[:MENTIONS]->(c) RETURN c.name')
  echo "after git mv src/util src/helpers and re-index: MENTIONS=$edges doc_links=$(doc_links)"
  expect "reference into moved directory disappears" "$edges" "[]"
  expect "no unresolved row records it" "$(doc_links)" "(0, {})"
}
case_5() {  # enhancement context: a doc-only edit yields no detect_changes seeds
  new_repo
  printf '# Notes\n\n## Posting\n\nPosting uses `src/ledger.py::post_entry`.\n' > docs/notes.md
  commit init; index
  echo "MENTIONS from the section: $(cypher 'MATCH (s:Section)-[:MENTIONS]->(c) RETURN s.name, c.name')"
  sed -i 's/Posting uses/Posting always uses/' docs/notes.md; index
  local out; out=$(cbm detect_changes --project "$PROJ" --edge-types '["CALLS","MENTIONS"]')
  echo "doc-only edit: $(py "$out" 'repr((d["changed_files"], d["seed_symbols"]))')"
  expect "doc-only edit produces zero seeds" "$(py "$out" 'd["seed_symbols"]')" "0"
}

trap - ERR
for c in "${@:-1a 1b 1c 1d 2 3 4 5}"; do for one in $c; do
  echo "=== case $one"
  rm -f "$WORK/failed"
  set +e
  ( set -e; trap 'die "command failed at line $LINENO: $BASH_COMMAND"' ERR; "case_$one"; exit "$MISSED" )
  status=$?
  set -e
  if [ -e "$WORK/failed" ] || [ "$status" -gt 1 ]; then echo "case $one: COMMAND FAILED" >&2; exit 2; fi
  [ "$status" -eq 1 ] && MISSED=1
done; done
[ "$MISSED" -eq 0 ] && echo "=== all cases reproduced" || echo "=== some cases did not reproduce"
exit "$MISSED"

Activity

  1. added
    cypherCypher query language parser/executor bugs
    parsing/qualityGraph extraction bugs, false positives, missing edges
    on Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    cypherCypher query language parser/executor bugsparsing/qualityGraph extraction bugs, false positives, missing edges

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions