diff --git a/planfile/analysis/parsers/text_parser.py b/planfile/analysis/parsers/text_parser.py index c6c07f7..99a7a1b 100644 --- a/planfile/analysis/parsers/text_parser.py +++ b/planfile/analysis/parsers/text_parser.py @@ -35,7 +35,6 @@ def analyze_text(file_path: Path) -> tuple[list[ExtractedIssue], list[ExtractedM try: with open(file_path, encoding='utf-8', errors='ignore') as f: content = f.read() - lines = content.split('\n') # Extract issues from patterns for pattern_name, pattern in ISSUE_PATTERNS.items(): @@ -111,6 +110,8 @@ def analyze_text(file_path: Path) -> tuple[list[ExtractedIssue], list[ExtractedM file_path=str(file_path) )) + except TimeoutError: + raise except Exception: # Skip files that can't be read as text pass diff --git a/planfile/analysis/parsers/yaml_parser.py b/planfile/analysis/parsers/yaml_parser.py index db6fab6..603567f 100644 --- a/planfile/analysis/parsers/yaml_parser.py +++ b/planfile/analysis/parsers/yaml_parser.py @@ -6,6 +6,18 @@ from planfile.analysis.models import ExtractedIssue, ExtractedMetric, ExtractedTask from planfile.analysis.parsers.text_parser import analyze_text from planfile.analysis.parsers.toon_parser import analyze_toon +from planfile.core.fastio import FastLoader + + +def _load_safe_yaml(content: str): + try: + return yaml.load(content, Loader=FastLoader) + except yaml.YAMLError: + if FastLoader is yaml.SafeLoader: + raise + # libyaml formats parser errors differently. Reuse the original safe + # parser on the error path to retain diagnostic text and compatibility. + return yaml.safe_load(content) def _is_issue_content(value: str) -> bool: @@ -93,7 +105,9 @@ def analyze_yaml(file_path: Path) -> tuple[list[ExtractedIssue], list[ExtractedM parsed = False try: - data = yaml.safe_load(content) + # Reuse the safe libyaml loader (or SafeLoader fallback) already + # selected by the storage layer; retain YAML 1.1 semantics. + data = _load_safe_yaml(content) parsed = True except yaml.YAMLError as error: if 'toon' in str(file_path): @@ -135,6 +149,8 @@ def analyze_yaml(file_path: Path) -> tuple[list[ExtractedIssue], list[ExtractedM metrics.extend(text_metrics) tasks.extend(text_tasks) + except TimeoutError: + raise except Exception as e: issues.append(ExtractedIssue( name=f"Failed to parse {file_path.name}", diff --git a/project/ticket-256/README.md b/project/ticket-256/README.md new file mode 100644 index 0000000..757b404 --- /dev/null +++ b/project/ticket-256/README.md @@ -0,0 +1,25 @@ +# Ticket 256: Accelerate bounded safe YAML health analysis + +- **ID**: ticket-256 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-10-28 + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: continue user-requested Heyasa autonomy repairs, tests and independent publication. This is a disjoint CI dependency for native254 / PR314, whose frozen source116dc03 remains preserved. This native ticket256 is distinct from GitHub issue256 about scoped inbound reads. + +OneDev job f15d356d52cb30441ce1e162 failed its Python3.11 coverage run solely at test_health_check_local_project_runs_promptly: HEALTH_TIMEOUT8s,1890other tests passed. Profiling the unchanged analyzer found614files/3.99MB taking7.8s undercProfile, with6.6s in PyYAML pure-Python parsing. Reuse safe accelerated loading, retain pure fallback and YAML semantics, and propagate TimeoutError through parser exception handling. Keep the8second CLI deadline and every required CI gate. + +## Acceptance criteria + +- [x] AC-01: Safe accelerated/pure loaders preserve issue/metric/task semantics for YAML1.1, GitHub workflow expressions, anchors, merges, Unicode, malformed and unsafe-tag inputs. +- [x] AC-02: Injected parser timeouts propagate to HEALTH_TIMEOUT rather than partial healthy output; no external command/tag executes. +- [ ] AC-03: Real repository health results match baseline; current affected checks, governance, protected verification and independent publication pass. + +Native254 exact-owner resource was explicitly handed off after the failed CI receipt (releasedrev7/fence326); its branch and PR remain open/frozen. Never edit254 source or reset its OneDev attempts. After this dependency lands, resume254 against the changed base through fresh admission and verification. + +## Validation + +Owner-local Planfile tracking: PLF-20016 in semcod/planfile, allocated with a stable dedupe key. Two public-CLI timeout regressions failed before correction and now pass. All54affected tests pass with Testless coverage in7.50s; Ruff passes. Full615file/3.996MB analysis has byte-identical serialized issue/metric/task results with SafeLoader and CSafeLoader (digestb538eb9ca4cb641d2b4b16e10656428bc0bac7aa0febac8603393688390a767f). Latest unprofiled observations:1.964s pure,0.852s accelerated; timings are host observations, not a deadline guarantee. Exact module paths are asserted against this worktree before candidate profiling. Malformed YAML falls back to the original safe parser for identical error descriptions. The8second deadline, pure fallback and required checks remain. Protected verification/publication remain pending. diff --git a/project/ticket-256/intent.json b/project/ticket-256/intent.json new file mode 100644 index 0000000..3985f0b --- /dev/null +++ b/project/ticket-256/intent.json @@ -0,0 +1,84 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-256", + "summary": "Accelerate bounded YAML health analysis with safe loader parity", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "project/ticket-256/**", + "TODO.md", + "project/TICKETS.md", + "planfile/analysis/parsers/text_parser.py", + "planfile/analysis/parsers/yaml_parser.py", + "tests/test_health_check.py", + "tests/test_yaml_analysis_loader.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "outcome": "Keep bounded health analysis responsive using safe accelerated YAML parsing with equivalent diagnostics; preserve deadlines and propagate timeouts instead of reporting partial success.", + "targetBranch": "main", + "acceptedBaseSha": "959224a9cc95fe0a73a0d26b285c6b17b7c55561", + "complexity": "S", + "estimatedMinutes": 35, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "nonGoals": [ + "No change to8second health deadline, required CI, protected profiles or foreign scope.", + "No runtime deployment, remote ticket selection edits or test quarantine." + ], + "architecture": { + "status": "accepted", + "decision": "Use existing safe libyaml loader with pure-Python fallback; qualify output parity and let TimeoutError propagate from both YAML and text parsing.", + "responsibilityChanges": false, + "components": [ + { + "name": "analysis-parsers", + "paths": [ + "planfile/analysis/parsers/yaml_parser.py", + "planfile/analysis/parsers/text_parser.py" + ] + }, + { + "name": "health-contract", + "paths": [ + "tests/test_health_check.py", + "tests/test_yaml_analysis_loader.py" + ] + } + ], + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Protected corrective/revert source change; preserve deadlines, authoritative tickets and CI history." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest -q tests/test_health_check.py tests/test_health_analysis.py tests/test_yaml_analysis_loader.py", + "./project/governance-check.sh" + ], + "evidence": "Compare safe loader outputs on realistic YAML fixtures and whole-repository health summary; prove TimeoutError cannot become partial success; retain exact current-head/base protected verification." + } + ] + } +} diff --git a/tests/test_health_check.py b/tests/test_health_check.py index 3404c6b..5dafe87 100644 --- a/tests/test_health_check.py +++ b/tests/test_health_check.py @@ -198,3 +198,45 @@ def test_file_analyzer_prioritizes_single_file_bounding_over_repo_byte_budget(tm assert result["budget"]["truncated"] is False assert str(oversized.resolve()) not in result["analyzed_files"] assert str(normal.resolve()) in result["analyzed_files"] + + +def test_yaml_parser_deadline_cannot_be_reported_as_healthy(tmp_path, monkeypatch): + import yaml + + from planfile.cli.groups.health.commands import HealthAnalysisTimeout + + (tmp_path / "sample.yaml").write_text("message: ordinary\n", encoding="utf-8") + + def timed_out(*args, **kwargs): + raise HealthAnalysisTimeout + + monkeypatch.setattr(yaml, "load", timed_out) + result = CliRunner().invoke(app, ["health", "check", str(tmp_path), "--format", "json"]) + payload = json.loads(result.stdout) + assert result.exit_code == 1 + assert payload["status"] == "timeout" + assert payload["diagnostics"][0]["code"] == "HEALTH_TIMEOUT" + + +def test_text_parser_deadline_cannot_be_reported_as_healthy(tmp_path, monkeypatch): + import builtins + + from planfile.analysis import file_analyzer + from planfile.cli.groups.health.commands import HealthAnalysisTimeout + + source = tmp_path / "sample.py" + source.write_text("print('ordinary')\n", encoding="utf-8") + original_open = builtins.open + + def timed_out(file, *args, **kwargs): + if Path(file) == source: + raise HealthAnalysisTimeout + return original_open(file, *args, **kwargs) + + monkeypatch.setattr(file_analyzer, "HAS_RUST_ANALYZER", False) + monkeypatch.setattr(builtins, "open", timed_out) + result = CliRunner().invoke(app, ["health", "check", str(tmp_path), "--format", "json"]) + payload = json.loads(result.stdout) + assert result.exit_code == 1 + assert payload["status"] == "timeout" + assert payload["diagnostics"][0]["code"] == "HEALTH_TIMEOUT" diff --git a/tests/test_yaml_analysis_loader.py b/tests/test_yaml_analysis_loader.py new file mode 100644 index 0000000..874f8f7 --- /dev/null +++ b/tests/test_yaml_analysis_loader.py @@ -0,0 +1,84 @@ +"""Safe accelerated YAML analysis retains the pure loader's observable meaning.""" + +from dataclasses import asdict + +import pytest +import yaml + +from planfile.analysis.parsers import yaml_parser + +VALID_DOCUMENTS = [ + "message: a bug needs fixing\non: failed\nyes: off\n", + 'quoted: "error with ☃ and café"\nnumber: 012\nnull_value: null\n', + "defaults: &defaults\n message: bug in defaults\ncopy:\n <<: *defaults\n other: issue in copy\n", + "values: &values [bug, error]\ncopy: *values\n", + "message: |\n error on first line\n bug on second line\n", + "message: old error\nmessage: replacement bug\n", + "date: 2026-10-28\ntruth: true\nfloat: 1.5\n", + "recursive: &recursive {child: *recursive, message: error in cycle}\n", +] + + +def records(result): + return [[asdict(row) for row in rows] for rows in result] + + +@pytest.mark.parametrize("content", VALID_DOCUMENTS) +def test_accelerated_loader_preserves_safe_yaml_analysis(tmp_path, monkeypatch, content): + source = tmp_path / "sample.yaml" + source.write_text(content, encoding="utf-8") + monkeypatch.setattr(yaml_parser, "FastLoader", yaml.SafeLoader) + expected = records(yaml_parser.analyze_yaml(source)) + monkeypatch.setattr(yaml_parser, "FastLoader", getattr(yaml, "CSafeLoader", yaml.SafeLoader)) + assert records(yaml_parser.analyze_yaml(source)) == expected + + +@pytest.mark.parametrize("loader", [yaml.SafeLoader, getattr(yaml, "CSafeLoader", yaml.SafeLoader)]) +@pytest.mark.parametrize("workflow", [False, True]) +def test_unsafe_yaml_tags_never_execute(tmp_path, monkeypatch, loader, workflow): + folder = tmp_path / ".github/workflows" if workflow else tmp_path + folder.mkdir(parents=True, exist_ok=True) + source = folder / "unsafe.yaml" + marker = tmp_path / "must-not-exist" + source.write_text( + '!!python/object/apply:os.system ["touch ' + str(marker) + '"]\n', encoding="utf-8" + ) + monkeypatch.setattr(yaml_parser, "FastLoader", loader) + issues, _metrics, _tasks = yaml_parser.analyze_yaml(source) + assert not marker.exists() + if not workflow: + assert any("Fix YAML syntax" in issue.name for issue in issues) + + +@pytest.mark.parametrize("loader", [yaml.SafeLoader, getattr(yaml, "CSafeLoader", yaml.SafeLoader)]) +def test_malformed_workflow_retains_one_diagnostic(tmp_path, monkeypatch, loader): + source = tmp_path / ".github/workflows/broken.yaml" + source.parent.mkdir(parents=True) + source.write_text("name: CI\non: [push\n", encoding="utf-8") + monkeypatch.setattr(yaml_parser, "FastLoader", loader) + issues, metrics, tasks = yaml_parser.analyze_yaml(source) + assert len(issues) == 1 and not metrics and not tasks + assert issues[0].tags == ["yaml", "github-actions", "unsupported"] + assert "Unsupported GitHub Actions YAML construct" in issues[0].description + + +def test_large_yaml_analysis_preserves_every_issue(tmp_path, monkeypatch): + source = tmp_path / "large.yaml" + source.write_text("\n".join(f"item_{i}: error number {i}" for i in range(1200)) + "\n") + monkeypatch.setattr(yaml_parser, "FastLoader", yaml.SafeLoader) + expected = records(yaml_parser.analyze_yaml(source)) + monkeypatch.setattr(yaml_parser, "FastLoader", getattr(yaml, "CSafeLoader", yaml.SafeLoader)) + assert records(yaml_parser.analyze_yaml(source)) == expected + assert len(expected[0]) >= 1200 + + +@pytest.mark.parametrize( + "content", ["poor: > 10s\n", "value: [broken\n", "value: !unknown input\n"] +) +def test_parser_error_diagnostics_match_the_pure_loader(tmp_path, monkeypatch, content): + source = tmp_path / "invalid.yaml" + source.write_text(content) + monkeypatch.setattr(yaml_parser, "FastLoader", yaml.SafeLoader) + expected = records(yaml_parser.analyze_yaml(source)) + monkeypatch.setattr(yaml_parser, "FastLoader", getattr(yaml, "CSafeLoader", yaml.SafeLoader)) + assert records(yaml_parser.analyze_yaml(source)) == expected