mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
e0f0c7b54a
The three agents/ files were relative symlinks into deep-research/agents/ (v3.7.0 Phase 2.1); on Windows checkouts without core.symlinks and in zip-download installs they materialise as one-line text files, silently breaking the three plugin agents (#413, external audit). Materialized as real byte-identical copies. New CI lint scripts/check_agents_mirror_sync.py takes over the single-source guarantee (hard-pinned mirror roster: set equality + regular-file-never-symlink + byte-equality; symlink check runs before byte-equality). The two symlink-dependent lints adapt: version-consistency invariant 8 excludes the mirror dir from the agent count; write-scope I5 maps direct children of root agents/ by name to their deep-research sources (codex P2: nested agents/sub/agents/ files do NOT remap — fail-open guard, pinned by a negative test). skills/ directory symlinks deliberately unchanged. 10 new mirror-sync tests, 3 mutations killed, 6 tests adapted/added across the two existing suites; lint + pytest companion wired into CI. Dual-track review: codex (1 P2, adopted) + security review (0 findings). Closes #413 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
116 lines
4.5 KiB
Python
116 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Lint: plugin-root agents/ mirrors stay byte-identical REAL files (#413).
|
|
|
|
agents/ held relative symlinks into deep-research/agents/ from v3.7.0 Phase
|
|
2.1 until #413: on Windows checkouts without developer mode / `core.symlinks`,
|
|
and in zip-download installs, a relative symlink materialises as a one-line
|
|
text file containing the link path — silently breaking the three plugin
|
|
agents. The symlinks are now real byte-identical copies, and this lint takes
|
|
over the guarantee the symlinks used to provide structurally: the v3.7.0
|
|
notes chose symlinks-not-copies precisely so the plugin surface could never
|
|
drift from the v3.6.7-hardened sources (the Pattern C3 surface that the §6
|
|
inversion sweep + INV-1/2/3 lint closed). A copy CAN drift; CI byte-equality
|
|
closes that again.
|
|
|
|
Invariants:
|
|
1. agents/*.md is exactly the MIRRORS roster (set equality). A deleted
|
|
mirror silently un-ships a plugin agent; an unrostered addition has no
|
|
declared source to stay in sync with. Editing the roster is a deliberate
|
|
plugin-surface change, made here in lockstep with the tree.
|
|
2. Every mirror is a regular file, never a symlink — the #413 regression.
|
|
Checked before byte-equality (a symlink byte-matches its own target).
|
|
3. Every mirror is byte-identical to its canonical source. To change an
|
|
agent: edit the SOURCE under deep-research/agents/, then re-copy
|
|
(`cp <source> <mirror>`). Never edit the mirror.
|
|
|
|
Related: check_version_consistency.py invariant 8 excludes agents/ from the
|
|
unique-agent count, and check_v3_10_134_write_scope.py I5 maps agents/ files
|
|
back to their deep-research sources — both rely on THIS lint pinning the
|
|
mirror set to byte-identical aliases.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
# mirror (plugin surface) -> canonical source. Keys must be the full contents
|
|
# of agents/*.md (invariant 1).
|
|
MIRRORS = {
|
|
"agents/report_compiler_agent.md":
|
|
"deep-research/agents/report_compiler_agent.md",
|
|
"agents/research_architect_agent.md":
|
|
"deep-research/agents/research_architect_agent.md",
|
|
"agents/synthesis_agent.md":
|
|
"deep-research/agents/synthesis_agent.md",
|
|
}
|
|
|
|
|
|
def check(root: Path) -> list[str]:
|
|
errors: list[str] = []
|
|
|
|
roster = set(MIRRORS)
|
|
agents_dir = root / "agents"
|
|
on_disk = (
|
|
{p.relative_to(root).as_posix() for p in agents_dir.glob("*.md")}
|
|
if agents_dir.is_dir() else set()
|
|
)
|
|
for mirror in sorted(roster - on_disk):
|
|
errors.append(
|
|
f"{mirror}: rostered mirror is missing — the plugin would "
|
|
f"silently un-ship this agent (restore with `cp "
|
|
f"{MIRRORS[mirror]} {mirror}`)"
|
|
)
|
|
for extra in sorted(on_disk - roster):
|
|
errors.append(
|
|
f"{extra}: not in the MIRRORS roster — an unrostered agents/ "
|
|
"file has no declared source to stay in sync with. Add it to "
|
|
"the roster in check_agents_mirror_sync.py (a deliberate "
|
|
"plugin-surface change) or remove it."
|
|
)
|
|
|
|
for mirror, source in sorted(MIRRORS.items()):
|
|
mp = root / mirror
|
|
if not mp.exists() and not mp.is_symlink():
|
|
# Truly absent — already reported missing above. A BROKEN symlink
|
|
# has exists()==False but is_symlink()==True and must fall through
|
|
# to the symlink branch; collapsing this to `not mp.exists()`
|
|
# would silently skip broken links — the #413 regression itself.
|
|
continue
|
|
if mp.is_symlink():
|
|
errors.append(
|
|
f"{mirror}: is a symlink — #413 regression (breaks Windows "
|
|
f"checkouts and zip installs). Replace with a real copy: "
|
|
f"`rm {mirror} && cp {source} {mirror}`"
|
|
)
|
|
continue
|
|
sp = root / source
|
|
if not sp.is_file():
|
|
errors.append(
|
|
f"{source}: canonical source for {mirror} is missing"
|
|
)
|
|
continue
|
|
if mp.read_bytes() != sp.read_bytes():
|
|
errors.append(
|
|
f"{mirror}: byte-drift from {source} — edit the source, "
|
|
f"then re-copy: `cp {source} {mirror}`"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
errors = check(REPO_ROOT)
|
|
if errors:
|
|
print("agents/ mirror sync check failed (#413):")
|
|
for err in errors:
|
|
print(f"- {err}")
|
|
return 1
|
|
print("agents/ mirror sync check passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|