CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
Step 3 of the Scribe -> Grimoire rename. - Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*. - hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR / SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`. - skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes arrive as grimoire-proc-<slug>; moment_actions names them the same way. - Migration 0124 rewrites stored skill names (rule_moments, rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing. - Settings' install commands and MCP-only defaults, the docs and the tests follow. - scripts/check_plugin.py drops the smoke event for a report-check hook that has no file in plugin/hooks. Kept on purpose: the secret-sealing label, and the names that let backups written as Scribe restore and be counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
9.4 KiB
Python
222 lines
9.4 KiB
Python
"""The by-name duplicate arm confirms its grep hits against the real extractor.
|
|
|
|
The extractors that decide what a payload DEFINES blank comment and string
|
|
spans before any line matcher runs. This is the other end — `grimoire_local_dups`,
|
|
which decides which OTHER files already define that name — and a plain
|
|
`git grep` cannot do that.
|
|
|
|
A grep sees lines, not spans. The line
|
|
|
|
class with only modifier rules is a deletion that went half-way.
|
|
|
|
is prose from a module docstring in this repo, and it matches the arm's pattern
|
|
for `name=with`. So the arm would tell a writer their genuine symbol was
|
|
already defined, and point at a docstring.
|
|
|
|
WHAT CONFIRMATION COSTS, measured rather than guessed: it drops string
|
|
literals, comments, import statements and Vue's `const emit = defineEmits()`
|
|
boilerplate, and no real definition — where a name has both a real definition
|
|
and a phantom, the phantom goes and the definition stays. The arm is
|
|
dominated by its `git grep` calls, so confirming adds about 15% (a mean of
|
|
~30ms) rather than the second a per-(name, file) shape would cost.
|
|
|
|
These cases are written against real git repositories rather than mocks,
|
|
because what is being pinned is the interaction between `git grep`, `head` and
|
|
the extractor — which is also where a `pipefail` + `head` interaction lives.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh"
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(tmp_path):
|
|
"""A committed git repo, plus a runner for the arm against it."""
|
|
for tool in ("git", "bash", "awk"):
|
|
if shutil.which(tool) is None:
|
|
pytest.skip(f"{tool!r} not installed")
|
|
env = {"PATH": os.environ["PATH"], "HOME": str(tmp_path),
|
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
subprocess.run(["git", "init", "-q"], cwd=root, check=True, env=env)
|
|
|
|
def build(files: dict[str, str]):
|
|
for rel, text in files.items():
|
|
path = root / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
subprocess.run(["git", "add", "-A"], cwd=root, check=True, env=env,
|
|
capture_output=True)
|
|
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=root,
|
|
check=True, env=env, capture_output=True)
|
|
|
|
def run(names: str, writing: str = "new.py") -> str:
|
|
script = (f'set -uo pipefail\n. "{DEFS}"\n'
|
|
f'printf %s "$NAMES" | grimoire_local_dups "{root}" {writing}\n')
|
|
out = subprocess.run(["bash", "-c", script], capture_output=True,
|
|
text=True, env={**env, "NAMES": names}, timeout=120)
|
|
assert out.returncode == 0, out.stderr
|
|
return out.stdout
|
|
|
|
return build, run
|
|
|
|
|
|
# ── the case the task names ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_a_sentence_about_a_definition_is_not_a_definition(repo):
|
|
"""One real `def change`, one docstring that
|
|
wraps onto a line beginning `function change`. The writer is told about
|
|
the first file and not the second."""
|
|
build, run = repo
|
|
build({
|
|
"real.py": "def change(a, b):\n return a\n",
|
|
"prose.py": (
|
|
'"""Some module.\n\n'
|
|
'When a caller moves, the enclosing\n'
|
|
'function change may have moved with it, which is the case\n'
|
|
'this paragraph exists to describe.\n'
|
|
'"""\n\n'
|
|
'X = 1\n'
|
|
),
|
|
})
|
|
out = run("sym\tchange\n")
|
|
assert "real.py" in out
|
|
assert "prose.py" not in out, (
|
|
"a wrapped docstring line still reads as a definition — the "
|
|
"confirmation pass did not run, or did not see the span"
|
|
)
|
|
assert "in 1 other file(s)" in out
|
|
|
|
|
|
def test_the_repos_own_prose_does_not_define_with():
|
|
"""Not a fixture — this repo's actual corpus, the realistic case.
|
|
|
|
Without the confirmation pass the arm names files for `with` on prose in
|
|
docstrings and comments — `…record type with no vector…`, `A class with
|
|
no rules anywhere…`. With it the arm names none, and none is the correct
|
|
answer: nothing here defines a symbol called
|
|
`with`, and in several of the languages in this tree it could not, because
|
|
it is a keyword.
|
|
|
|
Asserting on emptiness is only honest if the assertion could see a hit —
|
|
so it checks the arm ran, via a name that IS defined here.
|
|
"""
|
|
if shutil.which("bash") is None:
|
|
pytest.skip("bash not installed")
|
|
|
|
def arm(name: str) -> str:
|
|
script = (f'set -uo pipefail\n. "{DEFS}"\n'
|
|
f'printf "sym\\t{name}\\n" | grimoire_local_dups "{ROOT}" nothing.py\n')
|
|
out = subprocess.run(["bash", "-c", script], capture_output=True,
|
|
text=True, timeout=180)
|
|
assert out.returncode == 0, out.stderr
|
|
return out.stdout
|
|
|
|
assert arm("extract_definitions").strip(), (
|
|
"the arm found nothing for a name this repo certainly defines, so a "
|
|
"silent result below would prove nothing"
|
|
)
|
|
assert arm("with").strip() == "", (
|
|
"`with` is prose in every file that matches the grep — a hit here is "
|
|
"a sentence being read as a definition"
|
|
)
|
|
|
|
|
|
def test_a_name_that_is_both_real_and_phantom_keeps_the_real_file(repo):
|
|
"""The narrowing case, and the one that would betray an over-eager filter:
|
|
the phantom file drops out and the genuine definition stays."""
|
|
build, run = repo
|
|
build({
|
|
"lib.py": "def create_note(user_id):\n return user_id\n",
|
|
"test_form.py": 'canon = shape_form("async def create_note(user_id: int, ...):", "sym")\n',
|
|
})
|
|
out = run("sym\tcreate_note\n")
|
|
assert "lib.py" in out
|
|
assert "test_form.py" not in out
|
|
assert "in 1 other file(s)" in out
|
|
|
|
|
|
def test_an_import_is_not_a_definition(repo):
|
|
"""TypeScript's `import { type Foo }` matched the `type\\s+NAME` arm, so a
|
|
new type was reported as a duplicate of the files importing it."""
|
|
build, run = repo
|
|
build({
|
|
"card.vue": 'import {\n type Choices, type Decision,\n} from "@/api/inception";\n',
|
|
"real.ts": "export type Choices = { a: number };\n",
|
|
})
|
|
out = run("sym\tChoices\n")
|
|
assert "card.vue" not in out, "an import site is not a definition site"
|
|
|
|
|
|
# ── the caps ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_candidate_cap_is_raised_above_the_display_cap():
|
|
"""Confirmation REMOVES hits, so a cap applied before it runs lets phantom
|
|
matches crowd real definitions out of the window — hits dropped before
|
|
anyone looked at them. Asserted on structure because the ordering is what
|
|
matters, not the numbers."""
|
|
src = DEFS.read_text()
|
|
for const in ("_GRIMOIRE_DUP_CANDIDATES=", "_GRIMOIRE_DUP_SHOWN="):
|
|
assert const in src, f"{const.rstrip(chr(61))} is gone — the two caps were folded back together"
|
|
cands = int(src.split("_GRIMOIRE_DUP_CANDIDATES=")[1].split("\n")[0])
|
|
shown = int(src.split("_GRIMOIRE_DUP_SHOWN=")[1].split("\n")[0])
|
|
assert cands > shown, (
|
|
f"candidates ({cands}) must exceed the display cap ({shown}), or "
|
|
"confirmation can only ever shrink an already-truncated window"
|
|
)
|
|
|
|
|
|
def test_phantoms_ahead_of_the_display_cap_do_not_hide_a_real_definition(repo):
|
|
"""Four phantom files sort before the one real definition. With the old
|
|
`head -4` on the grep, the real file never entered the window at all."""
|
|
build, run = repo
|
|
files = {f"a_phantom_{i}.py": f'BLURB = """\nclass Widget is described here.\n"""\n'
|
|
for i in range(4)}
|
|
files["z_real.py"] = "class Widget:\n pass\n"
|
|
build(files)
|
|
out = run("sym\tWidget\n")
|
|
assert "z_real.py" in out, "the real definition sorted behind four phantoms"
|
|
assert "a_phantom_0.py" not in out
|
|
|
|
|
|
def test_the_early_exiting_head_still_keeps_its_own_output(repo):
|
|
"""Under `pipefail` a `head` that
|
|
exits while git grep is still writing must not void the hits it printed.
|
|
Every file here holds a REAL definition, so confirmation keeps them all
|
|
and only the display cap applies."""
|
|
build, run = repo
|
|
build({f"module_with_a_fairly_long_descriptive_name_{i}.py": "def slug(t):\n return t\n"
|
|
for i in range(3000)})
|
|
out = run("sym\tslug\n")
|
|
assert "`slug` is already defined in 4 other file(s)" in out
|
|
|
|
|
|
# ── css ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_a_selector_named_inside_a_css_comment_is_not_a_rule(repo):
|
|
"""A selector inside a CSS comment, on the lookup side rather than the
|
|
extraction side."""
|
|
build, run = repo
|
|
build({
|
|
"real.css": ".badge { color: red; }\n",
|
|
# The selector must START the line, or the old anchored pattern
|
|
# never matched it and this case would pass without the fix.
|
|
"notes.css": "/*\n.badge, .chip and friends were retired here\n*/\na { color: blue; }\n",
|
|
})
|
|
out = run("css\tbadge\n")
|
|
assert "real.css" in out
|
|
assert "notes.css" not in out
|