Make the import-surface scan cover symlinked external plugins
Path.rglob does not descend symlinks, so a symlinked external plugin (the ADR-003 dev loop) silently escaped the contract-purity scan. The scanner now resolves plugin dirs before walking, a regression test plants a symlinked plugin with a real violation and asserts it is flagged, and the known-limitation notes in the external-repo docs are lifted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -209,8 +209,17 @@ def _plugin_source_files():
|
||||
# Skip migrations/ - Alembic env.py is framework glue that legitimately
|
||||
# calls the shared runner in shopdb.plugins.alembic_template (see ADR-008),
|
||||
# not plugin domain code. Mirrors the style check excluding versions/.
|
||||
return [p for p in root.rglob('*.py')
|
||||
if '__pycache__' not in p.parts and 'migrations' not in p.parts]
|
||||
# Resolve each plugin dir before rglob: rglob does not descend symlinked
|
||||
# directories, and external plugins arrive as symlinks per ADR-003.
|
||||
files = [p for p in root.glob('*.py')]
|
||||
for plugindir in sorted(root.iterdir()):
|
||||
if not plugindir.is_dir():
|
||||
continue
|
||||
for path in plugindir.resolve().rglob('*.py'):
|
||||
if '__pycache__' in path.parts or 'migrations' in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def test_plugins_only_import_contract_surface():
|
||||
@@ -231,3 +240,31 @@ def test_plugins_only_import_contract_surface():
|
||||
'Plugins must import core only via shopdb.api or shopdb.plugins.base. '
|
||||
'Violations:\n' + '\n'.join(violations)
|
||||
)
|
||||
|
||||
|
||||
def test_import_scan_covers_symlinked_plugins(tmp_path):
|
||||
"""External plugins symlinked into plugins/ ARE scanned for violations.
|
||||
|
||||
Path.rglob does not descend symlinked directories, so a naive scan would
|
||||
silently skip external plugins installed per ADR-003 (clone or symlink
|
||||
into plugins/<name>/). The scanner resolves each plugin dir first; this
|
||||
test plants a symlinked plugin with a violating import and asserts the
|
||||
scan both sees the file and flags the violation.
|
||||
"""
|
||||
root = Path(__file__).resolve().parent.parent / 'plugins'
|
||||
external = tmp_path / 'symlinkdemo'
|
||||
external.mkdir()
|
||||
# violating import: internal core path, not the contract surface
|
||||
(external / 'plugin.py').write_text('from shopdb.extensions import db\n')
|
||||
|
||||
link = root / 'zzsymlinkdemo'
|
||||
link.symlink_to(external, target_is_directory=True)
|
||||
try:
|
||||
scanned = {str(p) for p in _plugin_source_files()}
|
||||
assert any('symlinkdemo' in s for s in scanned), (
|
||||
'symlinked plugin sources were not scanned'
|
||||
)
|
||||
with pytest.raises(AssertionError, match='shopdb.extensions'):
|
||||
test_plugins_only_import_contract_surface()
|
||||
finally:
|
||||
link.unlink()
|
||||
|
||||
Reference in New Issue
Block a user