Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion planfile/analysis/parsers/text_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion planfile/analysis/parsers/yaml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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}",
Expand Down
25 changes: 25 additions & 0 deletions project/ticket-256/README.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 84 additions & 0 deletions project/ticket-256/intent.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
}
42 changes: 42 additions & 0 deletions tests/test_health_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
84 changes: 84 additions & 0 deletions tests/test_yaml_analysis_loader.py
Original file line number Diff line number Diff line change
@@ -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
Loading