From 5739b39856097e26f5c2202f22bb4ca8d75e33b4 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sat, 19 Sep 2026 16:45:05 +0200 Subject: [PATCH 1/3] feat(report): scan Wellmanifest standard adoptions (ticket-076) --- project/TICKETS.md | 4 ++ project/ticket-076/README.md | 33 +++++++++++ project/ticket-076/intent.json | 65 +++++++++++++++++++++ src/monag/governance.py | 100 +++++++++++++++++++++++++++++++++ src/monag/report.py | 35 +++++++++++- tests/test_report.py | 29 ++++++++++ 6 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 project/ticket-076/README.md create mode 100644 project/ticket-076/intent.json create mode 100644 src/monag/governance.py diff --git a/project/TICKETS.md b/project/TICKETS.md index 00d5513..2872a91 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -60,4 +60,8 @@ | **ticket-067** | [`README.md`](./ticket-067/README.md) | - | - | - | - | - | | **ticket-068** | [`README.md`](./ticket-068/README.md) | - | - | - | - | - | | **ticket-069** | [`README.md`](./ticket-069/README.md) | - | - | - | - | - | +| **ticket-070** | [`README.md`](./ticket-070/README.md) | - | - | - | - | - | +| **ticket-071** | [`README.md`](./ticket-071/README.md) | - | - | - | - | - | +| **ticket-072** | [`README.md`](./ticket-072/README.md) | - | - | - | - | - | +| **ticket-076** | [`README.md`](./ticket-076/README.md) | - | - | - | - | - | diff --git a/project/ticket-076/README.md b/project/ticket-076/README.md new file mode 100644 index 0000000..563d3cd --- /dev/null +++ b/project/ticket-076/README.md @@ -0,0 +1,33 @@ +# Ticket 076: Scan Wellmanifest standard adoptions in workspace reports + +- **ID**: ticket-076 +- **Owner**: codex:ticket-076-standards +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-09-19 + +## Goal and scope + +Implement the report-layer slice of GitHub issue #91: scan local Wellmanifest +adoption and lock manifests, report declared packs and enforcement levels, and +surface workspace-local pin disagreement without claiming network freshness. + +SESSION_EXECUTION_AUTHORIZATION: the user explicitly instructed the agent to +continue implementation on 2026-09-19. + +## Acceptance criteria + +- [x] AC-01: Repositories with valid, absent and malformed adoption manifests + produce truthful structured observations. +- [x] AC-02: `monag report --sections standards` renders an adoption summary + and reports workspace-local revision disagreement. +- [x] AC-03: Focused report/governance tests and the managed governance gate + pass. + +Validation: `PYTHONPATH=src python -m pytest -q tests/test_report.py` — 33 passed; +governance plugin reported `GOV-PASS`. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-076/intent.json b/project/ticket-076/intent.json new file mode 100644 index 0000000..3ec6e94 --- /dev/null +++ b/project/ticket-076/intent.json @@ -0,0 +1,65 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-076", + "summary": "Scan Wellmanifest standard adoptions in workspace reports", + "workstream": "application", + "classification": { + "kind": "FEATURE", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-076/**", + "TODO.md", + "project/TICKETS.md", + "src/monag/governance.py", + "src/monag/report.py", + "tests/test_governance.py", + "tests/test_report.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "08215f3bbf3c29b2642c9f9acb9acf90fbec42af", + "targetBranch": "main", + "outcome": "Provide a read-only workspace standards scanner and report section.", + "nonGoals": [ + "Do not claim release freshness without an authoritative source.", + "Do not modify status or export integration owned by a later slice." + ], + "complexity": "S", + "estimatedMinutes": 60, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Keep filesystem observation in a dedicated read-only module and reuse report section collection and presentation.", + "components": [ + {"name": "governance scanner", "paths": ["src/monag/governance.py"]}, + {"name": "report", "paths": ["src/monag/report.py", "tests/test_report.py"]} + ], + "responsibilityChanges": false, + "interfaceChanges": ["report section: standards"], + "dataChanges": [], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Revert the protected delivery commit." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-03", + "commands": ["PYTHONPATH=src python -m pytest -q tests/test_report.py", "./project/governance-check.sh"], + "evidence": "Focused report tests and governance gate pass." + } + ] + } +} diff --git a/src/monag/governance.py b/src/monag/governance.py new file mode 100644 index 0000000..33d2fd0 --- /dev/null +++ b/src/monag/governance.py @@ -0,0 +1,100 @@ +"""Read-only workspace observations for Wellmanifest adoption metadata.""" +from __future__ import annotations + +import json +from pathlib import Path + + +_SKIP_DIRS = {'.git', '.worktrees', '.venv', 'node_modules', '__pycache__'} + + +def _read_object(path: Path): + try: + value = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + return None, f'{path}: {type(exc).__name__}: {exc}' + if not isinstance(value, dict): + return None, f'{path}: expected a JSON object' + return value, None + + +def _repositories(root: Path, depth: int): + """Find checkout roots, excluding linked-worktree implementation copies.""" + root = Path(root) + found = [] + for candidate in [root, *root.rglob('*')]: + try: + relative_depth = len(candidate.relative_to(root).parts) + except ValueError: + continue + if relative_depth > depth or not candidate.is_dir(): + continue + if candidate.name in _SKIP_DIRS or any(part in _SKIP_DIRS for part in candidate.relative_to(root).parts): + continue + if (candidate / '.git').exists(): + found.append(candidate) + return found + + +def _standards(adoption, lock): + standards = [] + for item in adoption.get('adoptions', []) if isinstance(adoption, dict) else []: + if not isinstance(item, dict) or not isinstance(item.get('id'), str): + continue + standards.append({key: item.get(key) for key in ('id', 'version', 'revision', 'level', 'model')}) + standard = lock.get('standard') if isinstance(lock, dict) else None + if isinstance(standard, dict) and isinstance(standard.get('id'), str): + lock_pack = { + 'id': standard['id'], 'version': standard.get('version'), + 'revision': standard.get('sourceRevision'), 'level': None, 'model': None, + } + if not any(pack['id'] == lock_pack['id'] for pack in standards): + standards.append(lock_pack) + return sorted(standards, key=lambda item: item['id']) + + +def scan(root, depth=2): + """Collect local adoption declarations and compare only local immutable pins.""" + root = Path(root) + repositories, errors, pins = [], [], {} + for repo_root in _repositories(root, depth): + adoption_path = repo_root / '.governance' / 'standard-adoption.json' + lock_path = repo_root / '.governance' / 'manifest.lock.json' + adoption = lock = None + if adoption_path.is_file(): + adoption, error = _read_object(adoption_path) + if error: + errors.append(error) + if lock_path.is_file(): + lock, error = _read_object(lock_path) + if error: + errors.append(error) + standards = _standards(adoption, lock) + for pack in standards: + revision = pack.get('revision') + if isinstance(revision, str) and revision: + pins.setdefault(pack['id'], set()).add(revision) + try: + name = repo_root.relative_to(root).as_posix() or repo_root.name + except ValueError: + name = repo_root.name + repositories.append({ + 'name': name, 'path': str(repo_root), + 'mode': adoption.get('mode', 'missing') if adoption else 'missing', + 'profile': adoption.get('profile', '—') if adoption else '—', + 'repository_role': adoption.get('repositoryRole') if adoption else None, + 'has_adoption_manifest': adoption is not None, + 'has_lock_manifest': lock is not None, + 'standards': standards, + }) + drift = [ + {'id': pack, 'revisions': sorted(revisions)} + for pack, revisions in sorted(pins.items()) if len(revisions) > 1 + ] + return { + 'schema': 'monag.governance-observation/v1', 'root': str(root), + 'repository_count': len(repositories), + 'adoption_manifest_count': sum(repo['has_adoption_manifest'] for repo in repositories), + 'repositories': repositories, 'drift': drift, 'errors': errors, + 'freshness': 'unobserved: local scan compares only declared workspace pins', + } diff --git a/src/monag/report.py b/src/monag/report.py index dd8bd10..d530761 100644 --- a/src/monag/report.py +++ b/src/monag/report.py @@ -28,7 +28,7 @@ SCHEMA = 'monag.report/v1' SECTION_REGISTRY = ( - 'status', 'prs', 'audit', 'resume', 'export', 'advise', + 'status', 'prs', 'audit', 'resume', 'export', 'advise', 'standards', ) @@ -184,6 +184,9 @@ def collect(root, depth=2, hours=24, sections=None, github=True, kwargs['repos_with_worktrees'] = audit_sec.get('repos_with_worktrees') data = advise.advise(root, depth=depth, issue_limit=issue_limit, **kwargs) result['sections']['advise'] = data + elif section == 'standards': + from . import governance + result['sections']['standards'] = governance.scan(root, depth=depth) except Exception as exc: result['errors'].append(f'{section}: {type(exc).__name__}: {exc}') result['duration_seconds'] = round(time.monotonic() - started, 2) @@ -293,6 +296,33 @@ def format_section_advise(data): return '\n'.join(lines) +def format_section_standards(data): + """Render local Wellmanifest adoption observations without freshness claims.""" + repos = data.get('repositories', []) + lines = [f"Repositories scanned: **{data.get('repository_count', len(repos))}** · " + f"Adoption manifests: **{data.get('adoption_manifest_count', 0)}** · " + f"Workspace pin disagreements: **{len(data.get('drift', []))}**"] + rows = [] + for repo in repos: + packs = repo.get('standards', []) + declared = ', '.join( + f"{pack.get('id', '?')} ({pack.get('level', '?')})" for pack in packs + ) or '—' + rows.append([ + repo.get('name', repo.get('path', '')), repo.get('mode', 'missing'), + repo.get('profile', '—'), declared, + ]) + if rows: + lines.extend(['', presentation.table(['Repository', 'Mode', 'Profile', 'Declared packs'], rows)]) + if data.get('drift'): + lines.extend(['', '**Workspace-local pin disagreement (not a release-freshness claim):**']) + for item in data['drift']: + lines.append(f"- `{item['id']}`: {', '.join(item['revisions'])}") + for error in data.get('errors', []): + lines.append(f"- Observation error: {error}") + return '\n'.join(lines) + + _SECTION_FORMATTERS = { 'status': format_section_status, 'prs': format_section_prs, @@ -300,6 +330,7 @@ def format_section_advise(data): 'resume': format_section_resume, 'export': format_section_export, 'advise': format_section_advise, + 'standards': format_section_standards, } _SECTION_TITLES = { @@ -309,6 +340,7 @@ def format_section_advise(data): 'resume': 'Planfile backlog and worktrees', 'export': 'Candidate work items', 'advise': 'Architectural Advisory & Task Guidance', + 'standards': 'Wellmanifest standards adoption', } @@ -575,4 +607,3 @@ def run_daemon(root, emails=None, interval=3600, depth=2, hours=24, break stop_event.wait(min(current_interval, 5)) return results - diff --git a/tests/test_report.py b/tests/test_report.py index a93a1b2..120b0a2 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -81,6 +81,35 @@ def test_collect_captures_errors_gracefully(tmp_path): assert any('RuntimeError' in e for e in data['errors']) +def test_collect_standards_and_formats_workspace_drift(tmp_path): + for name, revision in [('alpha', 'a' * 40), ('beta', 'b' * 40)]: + governance = tmp_path / name / '.governance' + governance.mkdir(parents=True) + (tmp_path / name / '.git').mkdir() + (governance / 'standard-adoption.json').write_text(json.dumps({ + 'schema': 'wellmanifest.standard-adoption/v1', 'mode': 'enforce', + 'profile': 'baseline', 'repositoryRole': 'service', 'adoptions': [{ + 'id': 'wellmanifest/new-project', 'version': '1.0', 'revision': revision, + 'model': 'protected-conformance', 'level': 'S4', 'artifacts': [], 'evidence': [], + }], + })) + (tmp_path / 'missing' / '.git').mkdir(parents=True) + malformed = tmp_path / 'malformed' / '.governance' + malformed.mkdir(parents=True) + (tmp_path / 'malformed' / '.git').mkdir() + (malformed / 'standard-adoption.json').write_text('{not json') + data = report.collect(tmp_path, depth=2, sections=['standards']) + standards = data['sections']['standards'] + assert standards['repository_count'] == 4 + assert standards['adoption_manifest_count'] == 2 + assert any('JSONDecodeError' in error for error in standards['errors']) + assert any(repo['mode'] == 'missing' for repo in standards['repositories']) + assert standards['drift'] == [{'id': 'wellmanifest/new-project', 'revisions': ['a' * 40, 'b' * 40]}] + rendered = report.format_section_standards(standards) + assert 'Workspace-local pin disagreement' in rendered + assert 'enforce' in rendered + + # -- markdown ----------------------------------------------------------- def test_markdown_output_has_sections(): From c0613b44a200c77b3172259b76105219b6b32873 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sat, 19 Sep 2026 19:10:21 +0200 Subject: [PATCH 2/3] fix(report): harden standards scanning and pin observations (ticket-076) --- project/ticket-076/README.md | 18 +++++- project/ticket-076/intent.json | 4 +- src/monag/governance.py | 79 ++++++++++++++++-------- src/monag/report.py | 9 ++- tests/test_governance.py | 109 +++++++++++++++++++++++++++++++++ tests/test_report.py | 17 +++++ 6 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 tests/test_governance.py diff --git a/project/ticket-076/README.md b/project/ticket-076/README.md index 563d3cd..ac7cf07 100644 --- a/project/ticket-076/README.md +++ b/project/ticket-076/README.md @@ -3,7 +3,7 @@ - **ID**: ticket-076 - **Owner**: codex:ticket-076-standards - **Status**: IN_PROGRESS -- **Workflow state**: EDIT +- **Workflow state**: VALIDATION - **Created**: 2026-09-19 ## Goal and scope @@ -15,6 +15,13 @@ surface workspace-local pin disagreement without claiming network freshness. SESSION_EXECUTION_AUTHORIZATION: the user explicitly instructed the agent to continue implementation on 2026-09-19. +Continuation 2026-09-19: the user requested investigation and repair, then +explicitly instructed the agent to handle lease setup autonomously. Reused +the existing Subactor repository-change-leases store and acquired the initial +ticket-076 lease through its controller API (fencing token 36). Repair remains +inside the accepted scanner/report scope: malformed metadata, bounded traversal, +and conflicting adoption/lock pins, with regression coverage. + ## Acceptance criteria - [x] AC-01: Repositories with valid, absent and malformed adoption manifests @@ -24,8 +31,13 @@ continue implementation on 2026-09-19. - [x] AC-03: Focused report/governance tests and the managed governance gate pass. -Validation: `PYTHONPATH=src python -m pytest -q tests/test_report.py` — 33 passed; -governance plugin reported `GOV-PASS`. +Validation after repair: `PYTHONPATH=src python3 -m pytest -q` — 345 passed, +4 skipped. `./project/governance-check.sh` — `GOV-PASS` (0 errors, 0 warnings). +Added 19 regression cases covering malformed metadata, bounded traversal, +symlink avoidance, conflicting pins, invalid UTF-8 and escaped report output. +Live read-only scan of the Semcod workspace at depth 1 observed 68 +repositories, no observation errors, in 0.027 seconds. These observations do +not establish release freshness or deployment. ## Tracking boundary diff --git a/project/ticket-076/intent.json b/project/ticket-076/intent.json index 3ec6e94..deab849 100644 --- a/project/ticket-076/intent.json +++ b/project/ticket-076/intent.json @@ -44,7 +44,7 @@ "status": "accepted", "decision": "Keep filesystem observation in a dedicated read-only module and reuse report section collection and presentation.", "components": [ - {"name": "governance scanner", "paths": ["src/monag/governance.py"]}, + {"name": "governance scanner", "paths": ["src/monag/governance.py", "tests/test_governance.py"]}, {"name": "report", "paths": ["src/monag/report.py", "tests/test_report.py"]} ], "responsibilityChanges": false, @@ -57,7 +57,7 @@ "validation": [ { "criterion": "AC-03", - "commands": ["PYTHONPATH=src python -m pytest -q tests/test_report.py", "./project/governance-check.sh"], + "commands": ["PYTHONPATH=src python -m pytest -q tests/test_governance.py tests/test_report.py", "./project/governance-check.sh"], "evidence": "Focused report tests and governance gate pass." } ] diff --git a/src/monag/governance.py b/src/monag/governance.py index 33d2fd0..de9d5d9 100644 --- a/src/monag/governance.py +++ b/src/monag/governance.py @@ -11,44 +11,73 @@ def _read_object(path: Path): try: value = json.loads(path.read_text(encoding='utf-8')) - except (OSError, json.JSONDecodeError) as exc: + except (OSError, UnicodeError, json.JSONDecodeError) as exc: return None, f'{path}: {type(exc).__name__}: {exc}' if not isinstance(value, dict): return None, f'{path}: expected a JSON object' return value, None -def _repositories(root: Path, depth: int): +def _repositories(root: Path, depth: int, errors=None): """Find checkout roots, excluding linked-worktree implementation copies.""" root = Path(root) - found = [] - for candidate in [root, *root.rglob('*')]: + if isinstance(depth, bool) or not isinstance(depth, int) or depth < 0: + raise ValueError('depth must be a non-negative integer') + found, pending = [], [(root, 0)] + while pending: + candidate, level = pending.pop() try: - relative_depth = len(candidate.relative_to(root).parts) - except ValueError: - continue - if relative_depth > depth or not candidate.is_dir(): - continue - if candidate.name in _SKIP_DIRS or any(part in _SKIP_DIRS for part in candidate.relative_to(root).parts): - continue - if (candidate / '.git').exists(): - found.append(candidate) - return found + if candidate.is_symlink(): + continue + if (candidate / '.git').exists(): + found.append(candidate) + if level < depth: + children = sorted(candidate.iterdir()) + pending.extend((child, level + 1) for child in children + if child.name not in _SKIP_DIRS + and not child.is_symlink() and child.is_dir()) + except OSError as exc: + if errors is not None: + errors.append(f'{candidate}: {type(exc).__name__}: {exc}') + return sorted(found) -def _standards(adoption, lock): +def _standards(adoption, lock, errors=None, source='manifest'): + errors = errors if errors is not None else [] standards = [] - for item in adoption.get('adoptions', []) if isinstance(adoption, dict) else []: - if not isinstance(item, dict) or not isinstance(item.get('id'), str): + items = adoption.get('adoptions', []) if isinstance(adoption, dict) else [] + if not isinstance(items, list): + errors.append(f'{source}: adoptions must be an array') + items = [] + for index, item in enumerate(items): + if not isinstance(item, dict) or not isinstance(item.get('id'), str) or not item['id'].strip(): + errors.append(f'{source}: adoptions[{index}] requires a non-empty string id') + continue + if any(item.get(key) is not None and not isinstance(item[key], str) + for key in ('version', 'revision', 'level', 'model')): + errors.append(f'{source}: adoptions[{index}] metadata must be strings or null') continue - standards.append({key: item.get(key) for key in ('id', 'version', 'revision', 'level', 'model')}) + standards.append({**{key: item.get(key) for key in ('id', 'version', 'revision', 'level', 'model')}, + 'source': 'adoption'}) standard = lock.get('standard') if isinstance(lock, dict) else None - if isinstance(standard, dict) and isinstance(standard.get('id'), str): + if isinstance(lock, dict) and (not isinstance(standard, dict) + or not isinstance(standard.get('id'), str) or not standard['id'].strip() + or any(standard.get(key) is not None and not isinstance(standard[key], str) + for key in ('version', 'sourceRevision'))): + errors.append(f'{source}: lock standard requires a string id and string metadata') + elif isinstance(standard, dict): lock_pack = { 'id': standard['id'], 'version': standard.get('version'), 'revision': standard.get('sourceRevision'), 'level': None, 'model': None, + 'source': 'lock', } - if not any(pack['id'] == lock_pack['id'] for pack in standards): + matching = [pack for pack in standards if pack['id'] == lock_pack['id']] + if matching and any(pack[key] is not None and lock_pack[key] is not None + and pack[key] != lock_pack[key] + for pack in matching for key in ('version', 'revision')): + errors.append(f"{source}: adoption/lock disagreement for {lock_pack['id']}") + if not any(all(pack[key] == lock_pack[key] for key in ('id', 'version', 'revision')) + for pack in standards): standards.append(lock_pack) return sorted(standards, key=lambda item: item['id']) @@ -57,7 +86,7 @@ def scan(root, depth=2): """Collect local adoption declarations and compare only local immutable pins.""" root = Path(root) repositories, errors, pins = [], [], {} - for repo_root in _repositories(root, depth): + for repo_root in _repositories(root, depth, errors): adoption_path = repo_root / '.governance' / 'standard-adoption.json' lock_path = repo_root / '.governance' / 'manifest.lock.json' adoption = lock = None @@ -69,18 +98,20 @@ def scan(root, depth=2): lock, error = _read_object(lock_path) if error: errors.append(error) - standards = _standards(adoption, lock) + standards = _standards(adoption, lock, errors, str(repo_root)) for pack in standards: revision = pack.get('revision') if isinstance(revision, str) and revision: pins.setdefault(pack['id'], set()).add(revision) try: - name = repo_root.relative_to(root).as_posix() or repo_root.name + relative = repo_root.relative_to(root) + name = relative.as_posix() if relative.parts else repo_root.name except ValueError: name = repo_root.name repositories.append({ 'name': name, 'path': str(repo_root), - 'mode': adoption.get('mode', 'missing') if adoption else 'missing', + 'mode': adoption.get('mode', 'unspecified') if adoption is not None + else ('invalid' if adoption_path.exists() else 'missing'), 'profile': adoption.get('profile', '—') if adoption else '—', 'repository_role': adoption.get('repositoryRole') if adoption else None, 'has_adoption_manifest': adoption is not None, diff --git a/src/monag/report.py b/src/monag/report.py index d530761..86f13b0 100644 --- a/src/monag/report.py +++ b/src/monag/report.py @@ -306,7 +306,9 @@ def format_section_standards(data): for repo in repos: packs = repo.get('standards', []) declared = ', '.join( - f"{pack.get('id', '?')} ({pack.get('level', '?')})" for pack in packs + f"{pack.get('id', '?')} ({pack.get('level') or '—'}; " + f"version={pack.get('version') or '—'}; revision={pack.get('revision') or '—'}; " + f"source={pack.get('source') or '—'})" for pack in packs ) or '—' rows.append([ repo.get('name', repo.get('path', '')), repo.get('mode', 'missing'), @@ -317,9 +319,10 @@ def format_section_standards(data): if data.get('drift'): lines.extend(['', '**Workspace-local pin disagreement (not a release-freshness claim):**']) for item in data['drift']: - lines.append(f"- `{item['id']}`: {', '.join(item['revisions'])}") + lines.append(f"- {presentation.cell(item['id'])}: " + f"{presentation.cell(', '.join(item['revisions']))}") for error in data.get('errors', []): - lines.append(f"- Observation error: {error}") + lines.append(f"- Observation error: {presentation.cell(error)}") return '\n'.join(lines) diff --git a/tests/test_governance.py b/tests/test_governance.py new file mode 100644 index 0000000..f6c5ae7 --- /dev/null +++ b/tests/test_governance.py @@ -0,0 +1,109 @@ +"""Regression coverage for untrusted workspace adoption observations.""" +import json +from pathlib import Path + +import pytest + +from monag import governance + + +def repository(root, adoption=None, lock=None): + (root / '.git').mkdir(parents=True) + metadata = root / '.governance' + metadata.mkdir() + for name, value in [('standard-adoption.json', adoption), ('manifest.lock.json', lock)]: + if value is not None: + (metadata / name).write_text(json.dumps(value)) + return root + + +@pytest.mark.parametrize('value', [None, 1, 'invalid', {}]) +def test_malformed_adoptions_are_reported_without_aborting(tmp_path, value): + repository(tmp_path / 'bad', {'adoptions': value}) + repository(tmp_path / 'good', {'adoptions': [{'id': 'wellmanifest/worktrees'}]}) + result = governance.scan(tmp_path) + assert result['repository_count'] == 2 + assert any('adoptions must be an array' in error for error in result['errors']) + assert result['repositories'][1]['standards'][0]['id'] == 'wellmanifest/worktrees' + + +def test_depth_and_exclusions_prune_traversal(tmp_path, monkeypatch): + repository(tmp_path / 'repo') + repository(tmp_path / '.worktrees' / 'hidden') + repository(tmp_path / 'repo' / 'too-deep') + original = Path.iterdir + visited = [] + + def observed(path): + visited.append(path) + return original(path) + + monkeypatch.setattr(Path, 'iterdir', observed) + assert len(governance.scan(tmp_path, depth=1)['repositories']) == 1 + assert visited == [tmp_path] + visited.clear() + governance.scan(tmp_path, depth=0) + assert visited == [] + + +def test_symlink_is_not_followed(tmp_path): + repository(tmp_path / 'real') + (tmp_path / 'alias').symlink_to(tmp_path / 'real', target_is_directory=True) + (tmp_path / 'real' / 'loop').symlink_to(tmp_path, target_is_directory=True) + assert governance.scan(tmp_path, depth=5)['repository_count'] == 1 + + +def test_conflicting_lock_pin_is_preserved(tmp_path): + repository(tmp_path, {'adoptions': [{'id': 'pack', 'revision': 'a' * 40}]}, + {'standard': {'id': 'pack', 'sourceRevision': 'b' * 40}}) + result = governance.scan(tmp_path, depth=0) + assert result['drift'] == [{'id': 'pack', 'revisions': ['a' * 40, 'b' * 40]}] + assert 'adoption/lock disagreement' in result['errors'][0] + assert {p['source'] for p in result['repositories'][0]['standards']} == {'adoption', 'lock'} + assert result['repositories'][0]['name'] == tmp_path.name + + +def test_matching_lock_pin_does_not_duplicate_or_report_drift(tmp_path): + repository(tmp_path, {'adoptions': [{'id': 'pack', 'revision': 'a' * 40}]}, + {'standard': {'id': 'pack', 'sourceRevision': 'a' * 40}}) + result = governance.scan(tmp_path, depth=0) + assert result['errors'] == [] + assert result['drift'] == [] + assert len(result['repositories'][0]['standards']) == 1 + + +def test_invalid_utf8_is_distinct_from_missing_manifest(tmp_path): + repository(tmp_path) + (tmp_path / '.governance' / 'standard-adoption.json').write_bytes(b'\xff') + result = governance.scan(tmp_path, depth=0) + assert result['repositories'][0]['mode'] == 'invalid' + assert 'UnicodeDecodeError' in result['errors'][0] + + +@pytest.mark.parametrize('value', [None, [], {'id': ''}, {'id': 'pack', 'sourceRevision': []}]) +def test_invalid_lock_is_reported(tmp_path, value): + repository(tmp_path, lock={'standard': value}) + result = governance.scan(tmp_path) + assert result['errors'] + assert result['repositories'][0]['standards'] == [] + + +def test_invalid_items_do_not_hide_valid_items(tmp_path): + repository(tmp_path, {'adoptions': [None, {'id': ''}, {'id': 'bad', 'revision': []}, {'id': 'good'}]}) + result = governance.scan(tmp_path) + assert len(result['errors']) == 3 + assert [p['id'] for p in result['repositories'][0]['standards']] == ['good'] + + +def test_unreadable_directory_is_observation_error(tmp_path, monkeypatch): + def denied(path): + raise PermissionError('denied') + monkeypatch.setattr(Path, 'iterdir', denied) + result = governance.scan(tmp_path) + assert 'PermissionError' in result['errors'][0] + + +@pytest.mark.parametrize('depth', [-1, True, 1.5]) +def test_invalid_depth_is_rejected(tmp_path, depth): + with pytest.raises(ValueError, match='non-negative integer'): + governance.scan(tmp_path, depth) diff --git a/tests/test_report.py b/tests/test_report.py index 120b0a2..2e0e36a 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -462,6 +462,23 @@ def test_run_daemon_dynamic_config(tmp_path): assert mock_send.call_args[0][0] == ['dyn@dev.local'] +def test_standards_displays_pins_and_escapes_untrusted_observations(): + text = report.format_section_standards({ + 'repositories': [{'name': 'repo', 'standards': [{ + 'id': 'pack', 'level': 'S3', 'version': '1.2', + 'revision': 'a' * 40, 'source': 'lock', + }]}], + 'drift': [{'id': ''], + }) + assert 'a' * 40 in text + assert 'source=lock' in text + assert 'S3' in text + assert '