dab3f35079
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/ directory. Content moved out of the LyraResearch archive and depersonalised: addresses stay in the archive, recipes ship. - plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the six required fields; catalog.json as the harness-neutral source of truth and .claude-plugin/ as one adapter over it. - _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a declared file cannot silently miss the line rules. - ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata). - LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as the material the licence decision grew from. Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their fixtures with a clean baseline and 2 root files reaching the line rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.7 KiB
Python
192 lines
7.7 KiB
Python
"""Poisoned-fixture corpus for the delivery gate.
|
|
|
|
python _gate/test_gate.py
|
|
|
|
Every rule in gate.RULES must have exactly one poison here, and that poison must
|
|
make its rule fire. A rule that never reddens on a fixture does not exist -- the
|
|
previous validator in this project ran "successfully" for months while checking
|
|
a literal that could not occur. This file is the answer to that.
|
|
|
|
The clean baseline lives in _gate/fixtures/clean and must be green. Each variant
|
|
is that tree copied to a temp directory plus exactly one mutation.
|
|
|
|
Output is ASCII only; the Cyrillic poison is written as escapes and never
|
|
printed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import gate # noqa: E402
|
|
|
|
CLEAN = Path(__file__).resolve().parent / 'fixtures' / 'clean'
|
|
SKILL = 'skills/sample-skill/SKILL.md'
|
|
FM = 'skills/sample-skill/references/failure-modes.md'
|
|
|
|
# rule id -> (operation, path, payload, replacement)
|
|
POISONS: dict[str, tuple] = {
|
|
'P01': ('append', SKILL,
|
|
'\nDonor tree lives at D:\\Work\\NG\\overmind on the build box.\n'),
|
|
'P02': ('append', SKILL,
|
|
'\nSee RangedWeaponInstance.cpp:194 for the original.\n'),
|
|
'P03': ('append', SKILL,
|
|
'\nThe donor project Lyra shipped this pattern unchanged.\n'),
|
|
'P04': ('append', SKILL,
|
|
'\n\u041f\u0440\u0438\u043c\u0435\u0447\u0430\u043d\u0438\u0435.\n'),
|
|
'P05': ('append', SKILL, '\nRelated: [[ue-modular-gameplay]]\n'),
|
|
'P06': ('replace', SKILL, 'name: sample-skill', 'name: renamed-skill'),
|
|
'P07': ('replace', SKILL, 'description: >-', 'summary: >-'),
|
|
'P08': ('delete', FM),
|
|
'P09': ('replace', FM, '**Guardrail.**', 'Guardrail:'),
|
|
'P10': ('append', SKILL,
|
|
'\nSee [the archive](../../../notes/00-index.md).\n'),
|
|
'P11': ('mkfile', 'notes/leak.md', '# a note from the archive\n'),
|
|
'P12': ('mkfile', 'skills/sample-skill/references/stale.pyc', 'junk\n'),
|
|
'P13': ('append', SKILL,
|
|
'\nRun this through Claude Code with ${CLAUDE_PLUGIN_ROOT} set.\n'),
|
|
'P14': ('delete', 'catalog.json'),
|
|
# The single clean entry is SS-01 and sits first. Renaming it to SS-02
|
|
# leaves position 1 holding number 02, which is exactly the gap that
|
|
# reordering sections during authoring produces.
|
|
'P15': ('replace', FM, '### SS-01', '### SS-02'),
|
|
# A second skill reusing the SS- prefix. This is the defect that let nine
|
|
# shipped entries vanish from the archive resolver without any count
|
|
# disagreeing loudly enough to notice.
|
|
'P16': ('copyskill', 'skills/sample-skill', 'sample-twin'),
|
|
}
|
|
|
|
|
|
# Surface coverage is a separate question from rule coverage. POISONS proves
|
|
# each rule can fire; this proves each file the gate CLAIMS to scan actually
|
|
# reaches the line-level rules. Both were needed: every rule above had a
|
|
# working fixture while a LICENSE carrying all five text violations passed
|
|
# green, because the fixtures all poison .md and LICENSE has no extension.
|
|
# Declaring a file on the surface and filtering it out one line later is the
|
|
# same defect class as a rule with no fixture -- coverage asserted, not had.
|
|
SURFACE_POISON = (
|
|
'Donor tree at D:\\Work\\NG\\overmind\n'
|
|
'See RangedWeaponInstance.cpp:194\n'
|
|
'The donor project Lyra shipped this\n'
|
|
'\u041f\u0440\u0438\u043c\u0435\u0447\u0430\u043d\u0438\u0435\n'
|
|
'Related: [[ue-modular-gameplay]]\n')
|
|
SURFACE_EXPECT = {'P01', 'P02', 'P03', 'P04', 'P05'}
|
|
|
|
|
|
def check_surface(failures: list[str]) -> None:
|
|
"""Every name in gate.SCAN_ROOT_FILES must reach the line-level rules."""
|
|
for name in gate.SCAN_ROOT_FILES:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = materialize(Path(td) / 'surface')
|
|
(root / name).write_text(SURFACE_POISON, encoding='utf-8')
|
|
fired = {v.rule for v in gate.run(root) if v.path == name}
|
|
missing = SURFACE_EXPECT - fired
|
|
print(f'surface {name:<12} '
|
|
f'{"scanned" if not missing else "NOT SCANNED"}')
|
|
if missing:
|
|
failures.append(
|
|
f'{name} is on the scan surface but line rules '
|
|
f'{sorted(missing)} never saw it')
|
|
|
|
|
|
def materialize(tmp: Path) -> Path:
|
|
root = tmp / 'plugin'
|
|
shutil.copytree(CLEAN, root)
|
|
return root
|
|
|
|
|
|
def poison(root: Path, spec: tuple) -> None:
|
|
op = spec[0]
|
|
target = root / spec[1]
|
|
if op == 'append':
|
|
target.write_text(
|
|
target.read_text(encoding='utf-8') + spec[2], encoding='utf-8')
|
|
elif op == 'replace':
|
|
text = target.read_text(encoding='utf-8')
|
|
if spec[2] not in text:
|
|
raise AssertionError(f'poison anchor absent: {spec[2]!r}')
|
|
target.write_text(text.replace(spec[2], spec[3], 1), encoding='utf-8')
|
|
elif op == 'delete':
|
|
target.unlink()
|
|
elif op == 'mkfile':
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(spec[2], encoding='utf-8')
|
|
elif op == 'copyskill':
|
|
# Duplicate a skill under a new folder name, keeping its entry-id
|
|
# prefix. The frontmatter name and the catalog entry are fixed up so
|
|
# that P06 and P14 stay green and only the prefix collision fires.
|
|
src, dst = root / spec[1], root / spec[1].rsplit('/', 1)[0] / spec[2]
|
|
shutil.copytree(src, dst)
|
|
md = dst / 'SKILL.md'
|
|
md.write_text(
|
|
md.read_text(encoding='utf-8').replace(
|
|
f'name: {src.name}', f'name: {spec[2]}'), encoding='utf-8')
|
|
cat = root / 'catalog.json'
|
|
data = json.loads(cat.read_text(encoding='utf-8'))
|
|
twin = dict(data['skills'][0])
|
|
twin['id'] = spec[2]
|
|
twin['path'] = f'skills/{spec[2]}'
|
|
twin['entry'] = f'skills/{spec[2]}/SKILL.md'
|
|
twin['references'] = [r.replace(src.name, spec[2])
|
|
for r in twin.get('references', [])]
|
|
data['skills'].append(twin)
|
|
cat.write_text(json.dumps(data, indent=2) + '\n', encoding='utf-8')
|
|
else:
|
|
raise AssertionError(f'unknown poison op: {op}')
|
|
|
|
|
|
def main() -> int:
|
|
failures: list[str] = []
|
|
rule_ids = {r.id for r in gate.RULES}
|
|
|
|
missing = rule_ids - set(POISONS)
|
|
extra = set(POISONS) - rule_ids
|
|
if missing:
|
|
failures.append(f'rules with no fixture: {sorted(missing)}')
|
|
if extra:
|
|
failures.append(f'fixtures for unknown rules: {sorted(extra)}')
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = materialize(Path(td) / 'clean')
|
|
found = gate.run(root)
|
|
if found:
|
|
failures.append('clean baseline is not green:')
|
|
for v in found:
|
|
failures.append(f' {v.rule} {v.path}:{v.line} {v.text}')
|
|
else:
|
|
print('clean baseline green')
|
|
|
|
for rule in sorted(POISONS):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = materialize(Path(td) / rule)
|
|
poison(root, POISONS[rule])
|
|
fired = sorted({v.rule for v in gate.run(root)})
|
|
ok = rule in fired
|
|
others = [f for f in fired if f != rule]
|
|
note = f' (also {" ".join(others)})' if others else ''
|
|
print(f'{rule} poison '
|
|
f'{"fires" if ok else "SILENT"}{note}')
|
|
if not ok:
|
|
failures.append(
|
|
f'{rule}: poison did not fire the rule; fired={fired}')
|
|
|
|
check_surface(failures)
|
|
|
|
print()
|
|
if failures:
|
|
for f in failures:
|
|
print('FAIL: ' + f)
|
|
return 1
|
|
print(f'{len(POISONS)} rules, each reddens on its own fixture, '
|
|
f'{len(gate.SCAN_ROOT_FILES)} root files reach the line rules, '
|
|
'baseline clean')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|