diff --git a/CHANGELOG.md b/CHANGELOG.md index c7425ed..7184a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Notable changes to Agent Code Guard are recorded here. ## Unreleased +### Added + +- Explicit source-controlled Markdown document-size ratchets: create accepted + physical-line allowances and update them only downward or by pruning. Accepted + documents pass without repeated REVIEW; growth returns REVIEW, while section + findings remain independent. Normal analysis never writes a baseline. + ## 0.3.1 - 2026-08-29 ### Changed diff --git a/README.md b/README.md index 7059232..1593d6a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Copy this prompt to a coding agent: > skill directory. Do not guess a target or overwrite an existing export; verify > that `.agent-code-guard-version` matches the installed distribution. Follow the > [agent workflow](https://github.com/stef-k/agent-code-guard/blob/main/docs/agent-workflow.md) -> without creating a LOC baseline. After meaningful source or Markdown edits, run +> without creating an adoption baseline. After meaningful source or Markdown edits, run > `code-guard . --changed-only --json --json-mode compact`; outside Git, pass the > exact edited files. Inspect REVIEW and FAIL findings, make only genuine > improvements, rerun the check, and report the result. Never weaken thresholds, @@ -232,11 +232,18 @@ Its default `ratchetAt: "fail"` policy freezes files above `failAt`; choose `ratchetAt: "review"` only when every established file above `warnAt` must be non-increasing while ordinary findings in that range remain REVIEW. +Reviewed oversized Markdown documents have a separate, explicit +[document-size ratchet](https://github.com/stef-k/agent-code-guard/blob/main/docs/usage.md#reviewed-markdown-document-ratchet). +`--create-markdown-baseline` records accepted physical-line counts; +`--update-markdown-baseline` only lowers or prunes them. Documents within their +allowance pass the document-size guard; growth produces REVIEW. Section +findings remain active, and ordinary analysis never writes either baseline. + ## Trust, CI, and platform support CI installs Agent Code Guard and analyzes its own real checkout. REVIEW findings remain visible but non-blocking, while FAIL findings and tool errors block the -workflow; the repository intentionally uses no LOC baseline. +workflow; the repository intentionally uses no adoption baseline. The maintained interpreter range is **CPython 3.10–3.14**. See [Platform support](https://github.com/stef-k/agent-code-guard/blob/main/docs/platform-support.md) for supported binary platforms and diff --git a/docs/README.md b/docs/README.md index 14f4e49..40d27b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,8 +6,8 @@ contract and the evidence behind it. ## User guides - [Human and agent workflow](agent-workflow.md) — installation, repeated changed-work checks, REVIEW judgment, and optional authorized hooks. -- [Usage](usage.md) — installation, file selection, output, result states, and CI. -- [Configuration](configuration.md) — zero-config defaults, guard settings, and exclusions. +- [Usage](usage.md) — installation, file selection, output, adoption ratchets, result states, and CI. +- [Configuration](configuration.md) — zero-config defaults, guard settings, exclusions, and baseline schemas. - [Language support](language-support.md) — applicable syntax languages, extensions, and mixed-content behavior. - [Platform support](platform-support.md) — maintained Python versions and native-wheel boundaries. - [Skill distribution](skill-distribution.md) — version-matched agent skill discovery and export. diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index 2e673c4..c5cbd2f 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -17,7 +17,7 @@ code-guard doctor ``` Ask your coding agent to locate the bundled version-matched skill with -`code-guard --skill-path` and adopt the workflow below without creating a LOC +`code-guard --skill-path` and adopt the workflow below without creating an adoption baseline. Decide whether checks remain manual or use an optional platform hook. Exporting a skill into a persistent directory, installing a hook, or changing persistent user or repository configuration requires your authorization. @@ -26,6 +26,13 @@ Require the agent to report its final result and any REVIEW findings it accepts with justification. Use CI as the final gate, not as a substitute for checks during development. +An explicitly reviewed, cohesive oversized Markdown document can use the +[document-size ratchet](usage.md#reviewed-markdown-document-ratchet) after +acceptance is authorized. Ordinary checks never create or update a baseline. +Unchanged or smaller accepted documents pass the document guard; growth returns +REVIEW, and section findings remain independent. Do not use baseline updates +to hide new growth or treat document acceptance as a section exemption. + ## The shared loop ```text diff --git a/docs/configuration.md b/docs/configuration.md index 6c50f06..ca9bf4e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,6 +147,51 @@ only existing hard failures; choose `review` when established review-level files must also be non-increasing. Never change the policy, thresholds, exclusions, exemptions, or stored allowances merely to silence growth. +## Source-controlled Markdown document ratchet + +Reviewed document allowances live separately from LOC in +`/.agent-tools/code-guard.markdown-baseline.json`: + +```json +{ + "version": 1, + "markdownDocumentSize": { + "files": [ + { + "path": "docs/architecture.md", + "allowedLines": 845 + } + ] + } +} +``` + +The version must be integer `1`. Paths are exact, normalized root-relative `/` +paths to `.md` documents (extension matching is case-insensitive); allowances +are positive integer physical-line counts, including blank and comment lines. +Keys are closed, entries must be sorted by path, and duplicate keys or paths, +non-integer values, unsafe/absolute paths, and symlink traversal fail closed. +Writers use UTF-8, two-space indentation, LF endings, and a final newline. +Creation cannot overwrite a baseline; updates replace it atomically only after +all entries in scope have been checked. A no-op update preserves the file. + +There are no new guard configuration keys. `markdownDocumentSize.reviewAt` +remains 800 by default and `markdownSectionSize.reviewAt` remains 200. +`guards.loc.ratchetAt` has no effect on Markdown. The document-size guard accepts +an exact-path allowance while the document stays at or below that count; +growth above the allowance reviews whenever it also exceeds `reviewAt`. +Documents at or below `reviewAt` pass without needing a baseline. + +`--create-markdown-baseline` records only selected documents above the effective +threshold. `--update-markdown-baseline` only lowers or prunes existing entries +within positional bounds; it never adds or increases allowances. Normal +analysis never mutates this file. Section analysis remains independent, and +section baselines are not supported. These are explicit reviewed-document +acceptances, not default setup or permission to hide growth with exclusions, +threshold changes, or replacement allowances. + +See the [workflow and output contract](usage.md#reviewed-markdown-document-ratchet). + ## Fail-closed validation Malformed JSON, invalid types or thresholds, unknown top-level properties, diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 9a81d53..bec3f7d 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -467,3 +467,21 @@ span. Style facts must use a separate family-specific pass over final discovery, or justify generic artifact/plugin infrastructure. Full corpus, provider, recovery, boundary, and gaming evidence is recorded in `docs/style-guard-evidence.md`. + +## D35 — Reviewed Markdown documents use a separate non-increasing ratchet + +A cohesive oversized Markdown document can retain an explicitly reviewed +physical-line allowance at its exact root-relative path. The separate +`.agent-tools/code-guard.markdown-baseline.json` keeps this acceptance independent +of LOC policy: unchanged or smaller documents pass the document-size guard; +growth above the allowance and ordinary threshold returns REVIEW, never FAIL. +Normal analysis reads without writing. Explicit creation records current +oversized documents; updates only lower or prune existing allowances. + +The baseline changes acceptance, not measurement, global thresholds, or scope. +Section findings remain independent. Sections have headings and line ranges, +but no stable identity across duplicate headings, renames, and edits. Section +ratchets are deferred to avoid adding identity rules to this bounded feature. +Both baselines share filesystem safety routines, while retaining separate +schemas and guard-specific lifecycle rules. The original Markdown admission +and threshold evidence remain historical records. diff --git a/docs/usage.md b/docs/usage.md index 1292ce8..f9943ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -198,12 +198,80 @@ size-regression check. A ratchet entry may not overlap `allowedLargeFiles`. This workflow is only for adopting established legacy repositories. New projects, including Agent Code Guard itself, should meet policy directly and -must not create a ratchet baseline. +must not create a LOC ratchet baseline. Do not switch between `fail` and `review`, raise thresholds, add exclusions or exemptions, or edit allowances merely to silence a growth failure. Those are source-controlled policy changes and require their own substantive justification. +## Reviewed Markdown document ratchet + +After reviewing an oversized, cohesive Markdown document, explicitly record its +accepted physical-line count over a deliberately bounded scope: + +```bash +code-guard docs/architecture.md --create-markdown-baseline +git add .agent-tools/code-guard.markdown-baseline.json +git diff --cached +``` + +This creates `/.agent-tools/code-guard.markdown-baseline.json`. +The analysis root is the enclosing Git top-level or, outside Git, the resolved +invocation directory. Creation requires an enabled `markdownDocumentSize` guard +and records only selected `.md` files strictly above its effective `reviewAt`. +An existing baseline is never overwritten by create. Review and commit the +baseline as the explicit acceptance decision; it is not part of ordinary setup. + +Normal analysis reads the baseline automatically without writing it. With the +default 800-line threshold and an accepted allowance of 845 document lines: + +| Current document | Document-size result | +| --- | --- | +| Same path, 845 lines | PASS, within allowance | +| Same path, 840 lines | PASS, within allowance | +| Same path, 846 lines | REVIEW, allowance exceeded | +| New or renamed path, 845 lines | REVIEW, no allowance | +| Any document at or below 800 lines | PASS, no allowance needed | + +This ratchet applies only to `markdownDocumentSize`. It does not change the +threshold, exclude Markdown from analysis, or suppress `markdownSectionSize`. +An accepted document can still have section REVIEW findings. Growth remains +REVIEW (exit `1`, or `0` with `--ci`), not FAIL. + +After shortening or deleting documents, explicitly lower or prune entries: + +```bash +code-guard docs --update-markdown-baseline +``` + +Update requires an existing baseline and an enabled document guard. It lowers +existing allowances within positional bounds, removes deleted or scope-excluded +paths and entries at or below the effective threshold, and preserves entries +outside those bounds. It never adds an entry or increases an allowance. Growth +in a retained entry aborts the entire update without changing the baseline. +A rename is a deletion plus an unaccepted new path. Manual baseline changes +require source-control review; do not rebaseline merely to hide growth. + +Both write modes accept paths, `--config`, and repeated `--scope-exclude`. +They reject other baseline modes, LOC counting/threshold options, Git selectors, +JSON/CI analysis options, diagnostics, version reporting, and skill management. +Writes do not run normal analysis. Successful writes exit `0`; invalid inputs +or unsafe baselines produce tool errors (exit `3`, stderr, no completed report). +Normal analysis and creation do not grant allowances through an explicitly +supplied file symlink; updates reject symlink bounds. Baseline storage and +entries must not traverse symlinks, and baseline-enabled scopes must stay +inside the analysis root. + +Human output shows the accepted allowance and `within`, `exceeded`, or +`no longer needed`. Full/debug JSON retains document measurements and the +unchanged `thresholds.reviewAt`; entries matched by path add `baselineLines` +and `ratchetStatus` (`within`, `exceeded`, or `notNeeded`). Unmatched findings +retain their original shape. Compact JSON omits accepted PASS findings like +other passes. Accepted documents do not require the document-size policy; +independent section findings still require their own policy. + +See the [persisted schema](configuration.md#source-controlled-markdown-document-ratchet). + ## Results and exit codes - `PASS` means no special action and exits `0`. diff --git a/skills/code-guard/SKILL.md b/skills/code-guard/SKILL.md index 735af62..69f52f0 100644 --- a/skills/code-guard/SKILL.md +++ b/skills/code-guard/SKILL.md @@ -148,8 +148,9 @@ reviews and never fails. Load `references/complexity-policy.md` only when Markdown document and direct-section size count all physical lines. Sections run from a supported heading through the line before the next heading of any -level, or EOF. Exact effective thresholds pass; greater measurements review and -never fail. Load `references/markdown-size-policy.md` when either +level, or EOF. Exact effective thresholds pass; greater measurements normally +review and never fail. A document measurement within an explicitly accepted +Markdown document baseline passes the document-size guard. Load `references/markdown-size-policy.md` when either `markdownDocumentSize` or `markdownSectionSize` appears in `requiredPolicies`. Review navigation and responsibility without mechanically splitting coherent specifications or gaming headings/formatting. @@ -161,3 +162,20 @@ REVIEW requires inspection and justification, not mandatory refactoring. Agent Code Guard is the canonical LOC implementation. Agent LOC Guard is the completed prototype/reference whose mature behavior was migrated from commit `75ab39d261dbc65f78815836fac90add16d265d1`. Project-specific architecture rules, framework-specific checks, arbitrary style preferences, security scanners, and dependency auditing are outside the universal core. + +## Reviewed Markdown document baselines + +After explicit acceptance of a cohesive oversized document, use +`code-guard docs/architecture.md --create-markdown-baseline` to record its current +physical-line count in `.agent-tools/code-guard.markdown-baseline.json` at the +analysis root. Normal analysis reads this file without writing. Unchanged or +smaller accepted documents pass `markdownDocumentSize`; growth above both the +allowance and effective threshold returns REVIEW. `markdownSectionSize` remains +independent and has no baseline support. + +`--update-markdown-baseline` only lowers or prunes existing allowances within +positional bounds; it never adds or increases one. Neither write command is +part of routine checks. Do not create, replace, remove, or increase allowances +merely to silence findings. Review and commit explicit acceptance decisions. +Human/full JSON output retains acceptance context; compact omits accepted PASS +findings. Load the Markdown policy for actionable document or section reviews. diff --git a/skills/code-guard/references/markdown-size-policy.md b/skills/code-guard/references/markdown-size-policy.md index 1b54fd8..50dd0aa 100644 --- a/skills/code-guard/references/markdown-size-policy.md +++ b/skills/code-guard/references/markdown-size-policy.md @@ -14,3 +14,20 @@ mechanically split coherent material, compress formatting or remove useful blank lines, or hide content in fenced code. Agents must not raise thresholds or disable either guard merely to silence a finding; project or user authority is required for configuration changes. + +## Explicit document acceptance + +An explicitly reviewed, cohesive oversized document may use a source-controlled +allowance created with `--create-markdown-baseline`. The separate +`.agent-tools/code-guard.markdown-baseline.json` records exact root-relative paths +and current physical-line counts. Within that allowance the document finding +passes; growth above both the allowance and effective threshold is REVIEW, +never FAIL. At or below the ordinary threshold, an allowance is no longer needed. + +This applies only to `markdownDocumentSize`. Inspect section REVIEW findings +independently; document acceptance does not exempt sections. Normal analysis +never changes a baseline. After reductions or deletions, +`--update-markdown-baseline` can explicitly lower or prune existing entries but +cannot add or increase allowances. Agents must not create or replace baselines, +remove allowances, raise thresholds, or add exclusions merely to silence growth. +Baseline acceptance requires explicit authority and source-control review. diff --git a/src/agent_code_guard/baseline_files.py b/src/agent_code_guard/baseline_files.py new file mode 100644 index 0000000..b697a70 --- /dev/null +++ b/src/agent_code_guard/baseline_files.py @@ -0,0 +1,139 @@ +"""Filesystem safety shared by explicit source-controlled baseline workflows.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from .file_selection import is_within + + +def validate_paths(root: Path, entries: dict[str, int], label: str = "LOC") -> None: + for relative in entries: + candidate = root / Path(relative) + resolved = candidate.resolve(strict=False) + if not is_within(resolved, root): + raise ValueError(f"{label} baseline path escapes analysis root: {relative}") + current = root + for part in Path(relative).parts: + current = current / part + if current.is_symlink(): + raise ValueError(f"{label} baseline path traverses a symlink: {relative}") + + +def validate_explicit_scope( + values: list[str], invocation: Path, root: Path, selected_files: tuple[Path, ...], +) -> set[Path]: + """Validate raw bounds before resolution erases empty directories and file-link identity.""" + linked_targets: set[Path] = set() + directly_reached: set[Path] = set() + for value in values or ["."]: + path = Path(value) if Path(value).is_absolute() else invocation / value + resolved = path.resolve() + if not is_within(resolved, root): + raise ValueError(f"baseline scope is outside analysis root: {value}") + if path.is_symlink() and path.is_file(): + linked_targets.add(resolved) + elif path.is_file(): + directly_reached.add(resolved) + elif path.is_dir(): + directly_reached.update( + selected.resolve() for selected in selected_files if is_within(selected, resolved) + ) + return linked_targets - directly_reached + + +def atomic_replace(target: Path, content: bytes) -> None: + temporary = _write_temporary(target, content) + try: + os.replace(temporary, target) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def atomic_create(target: Path, content: bytes, label: str = "LOC") -> None: + temporary = _write_temporary(target, content) + try: + os.link(temporary, target) + except FileExistsError as exc: + raise ValueError(f"{label} baseline already exists: {target.parent.name}/{target.name}") from exc + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _write_temporary(target: Path, content: bytes) -> Path: + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + return temporary + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def resolve_bounds(values: list[str], invocation: Path, root: Path) -> list[tuple[Path, bool]]: + bounds = [] + for value in values or ["."]: + path = Path(value) if Path(value).is_absolute() else invocation / value + if not path.exists(): + raise FileNotFoundError(f"explicit path does not exist: {value}") + if path.is_symlink(): + raise ValueError(f"baseline bounds may not be symlinks: {value}") + resolved = path.resolve() + if not is_within(resolved, root): + raise ValueError(f"baseline scope is outside analysis root: {value}") + bounds.append((resolved, path.is_dir())) + return bounds + + +def in_bounds(relative: str, bounds: list[tuple[Path, bool]], root: Path) -> bool: + candidate = (root / Path(relative)).resolve(strict=False) + return any( + is_within(candidate, bound) if is_directory else candidate == bound + for bound, is_directory in bounds + ) + + +def require_regular_inside(path: Path, root: Path) -> None: + if path.is_symlink() or not path.is_file() or not is_within(path, root): + raise ValueError(f"baseline scope contains an unsafe or outside-root path: {path}") + + +def canonical_path(value: str) -> bool: + if not value or "\\" in value or value.endswith("/") or "//" in value: + return False + path = Path(value) + if path.is_absolute() or path.drive or value.startswith("//"): + return False + return all(part not in {"", ".", ".."} for part in value.split("/")) + + +def validate_analysis_scope(root: Path, files: tuple[Path, ...]) -> None: + """Recheck physical containment before applying any persisted allowance.""" + error = "baseline analysis scope is outside analysis root" + try: + current_root = root.resolve(strict=True) + except OSError as exc: + raise ValueError(f"{error}: {root}") from exc + for path in files: + try: + current_path = path.resolve(strict=True) + valid = ( + not path.is_symlink() and current_path.is_file() + and current_path.is_relative_to(current_root) + ) + except OSError: + valid = False + if not valid: + raise ValueError(f"{error}: {path}") diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 27de2a4..25c1ed6 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -15,7 +15,7 @@ from .file_selection import ResolvedScope, resolve_invocation, resolve_scope from .guards import callable_size, complexity, loc, markdown_document_size, markdown_section_size, nesting from .human_output import format_completed_analysis -from . import loc_baseline +from . import baseline_files, loc_baseline, markdown_baseline from .result_model import GuardResult, aggregate_state, required_policies from .invocation import AnalysisContext, SelectedFile, load_configuration from .skill_distribution import export_skill, skill_path as installed_skill_path @@ -59,7 +59,15 @@ def parser() -> argparse.ArgumentParser: code-guard [PATH ...] --update-loc-baseline [counting/configuration options] Create or lower/prune the source-controlled LOC ratchet at /.agent-tools/code-guard.loc-baseline.json. These explicit - write modes do not run normal analysis and never increase an allowance.""", + write modes do not run normal analysis and never increase an allowance. + +Reviewed Markdown documents: + code-guard [PATH ...] --create-markdown-baseline [--config FILE] + code-guard [PATH ...] --update-markdown-baseline [--config FILE] + Create or lower/prune .agent-tools/code-guard.markdown-baseline.json at the + analysis root. Accept reviewed document sizes; growth produces REVIEW. + Section findings remain independent. Both modes accept --scope-exclude, + reject LOC counting options and analysis modes, and never add on update.""", ) value.add_argument( "paths", nargs="*", default=[], @@ -97,6 +105,14 @@ def parser() -> argparse.ArgumentParser: "--update-loc-baseline", action="store_true", help="Lower or prune the canonical LOC baseline without running analysis.", ) + value.add_argument( + "--create-markdown-baseline", action="store_true", + help="Record current reviewed Markdown document sizes without running normal analysis.", + ) + value.add_argument( + "--update-markdown-baseline", action="store_true", + help="Lower or prune existing Markdown document allowances; never add or increase one.", + ) value.add_argument( "--skill-path", action="store_true", help="Print the absolute path to this distribution's bundled Code Guard skill.", @@ -202,11 +218,18 @@ def _management_mode(args: argparse.Namespace) -> int | None: return 0 -def _loc_baseline_mode(args: argparse.Namespace) -> int | None: - if not args.create_loc_baseline and not args.update_loc_baseline: +def _baseline_mode(args: argparse.Namespace) -> int | None: + modes = (args.create_loc_baseline, args.update_loc_baseline, + args.create_markdown_baseline, args.update_markdown_baseline) + if not any(modes): return None + markdown_mode = args.create_markdown_baseline or args.update_markdown_baseline incompatible = ( - args.create_loc_baseline and args.update_loc_baseline + sum(modes) != 1 + or (markdown_mode and ( + args.warn is not None or args.fail is not None or args.include or args.exclude + or args.count_blank_lines or args.ignore_comment_lines + )) or (bool(args.paths) and args.paths[0] == "doctor") or args.json or args.json_mode is not None @@ -219,27 +242,28 @@ def _loc_baseline_mode(args: argparse.Namespace) -> int | None: or args.export_skill is not None ) if incompatible: - raise ValueError( - "LOC baseline write modes accept only paths and LOC counting/configuration options" - ) + options = "configuration/scope options" if markdown_mode else "LOC counting/configuration options" + raise ValueError(f"baseline write modes accept only paths and {options}; use one write mode") args.paths = args.paths or ["."] invocation = Path.cwd() validate_configuration(args.config, invocation) scope = resolve_scope(args, invocation) - linked_targets = loc_baseline.validate_explicit_scope( + linked_targets = baseline_files.validate_explicit_scope( args.paths, invocation, scope.root, scope.files, ) - config = loc.load_config(args) - if args.create_loc_baseline: + lifecycle = markdown_baseline if markdown_mode else loc_baseline + label = "Markdown" if markdown_mode else "LOC" + config = markdown_document_size.load_config(args) if markdown_mode else loc.load_config(args) + if args.create_loc_baseline or args.create_markdown_baseline: files = tuple(path for path in scope.files if path.resolve() not in linked_targets) - count = loc_baseline.create(scope.root, files, config) - print(f"Created LOC baseline: {loc_baseline.RELATIVE_PATH} ({count} entries).") + count = lifecycle.create(scope.root, files, config) + print(f"Created {label} baseline: {lifecycle.RELATIVE_PATH} ({count} entries).") else: - lowered, removed, unchanged = loc_baseline.update( + lowered, removed, unchanged = lifecycle.update( scope.root, args.paths, invocation, config, scope.excluded_files, ) print( - f"Updated LOC baseline: {loc_baseline.RELATIVE_PATH} " + f"Updated {label} baseline: {lifecycle.RELATIVE_PATH} " f"({lowered} lowered, {removed} removed, {unchanged} unchanged)." ) return 0 @@ -336,29 +360,19 @@ def run_analysis( context = scope if isinstance(scope, AnalysisContext) else _legacy_context(scope, args) loc_config = loc.load_config(args, context.configuration) baseline = baseline_override if baseline_loaded else loc_baseline.load_if_present(context.root) + document_baseline = markdown_baseline.load_if_present(context.root) if baseline is not None: loc_baseline.validate_paths(context.root, baseline) loc_baseline.validate_overlap(baseline, loc_config) - error = "baseline analysis scope is outside analysis root" - try: - current_root = context.root.resolve(strict=True) - except OSError as exc: - raise ValueError(f"{error}: {context.root}") from exc - for selected in context.selected_files: - try: - current_path = selected.physical_path.resolve(strict=True) - valid = ( - not selected.physical_path.is_symlink() - and current_path.is_file() - and current_path.is_relative_to(current_root) - ) - except OSError: - valid = False - if not valid: - raise ValueError(f"{error}: {selected.physical_path}") - baseline = dict(baseline) - for target in linked_targets or set(): - baseline.pop(target.relative_to(context.root).as_posix(), None) + if baseline is not None or document_baseline is not None: + baseline_files.validate_analysis_scope( + context.root, tuple(selected.physical_path for selected in context.selected_files), + ) + baseline = dict(baseline) if baseline is not None else None + for allowances in (baseline, document_baseline): + if allowances is not None: + for target in linked_targets or set(): + allowances.pop(target.relative_to(context.root).as_posix(), None) callable_size_config = callable_size.load_config(args, context.configuration) nesting_config = nesting.load_config(args, context.configuration) complexity_config = complexity.load_config(args, context.configuration) @@ -388,7 +402,7 @@ def run_analysis( markdown = import_module("agent_code_guard.markdown") markdown_facts = markdown.analyze_files(markdown_files) if markdown_document_config.enabled: - results.append(markdown_document_size.run(context.root, markdown_document_config, markdown_facts)) + results.append(markdown_document_size.run(context.root, markdown_document_config, markdown_facts, document_baseline)) if markdown_section_config.enabled: results.append(markdown_section_size.run(context.root, markdown_section_config, markdown_facts)) else: @@ -473,7 +487,7 @@ def main() -> int: try: if args.json_mode is not None and not args.json: raise ValueError("--json-mode requires --json") - baseline_result = _loc_baseline_mode(args) + baseline_result = _baseline_mode(args) if baseline_result is not None: return baseline_result doctor_result = _doctor_mode(args, raw_arguments) @@ -491,8 +505,11 @@ def main() -> int: scope = resolve_invocation(args, invocation, configuration) linked_targets: set[Path] = set() baseline_loaded = hasattr(scope, "root") - if baseline_loaded and loc_baseline.baseline_path(scope.root).exists(): - linked_targets = loc_baseline.validate_explicit_scope( + if baseline_loaded and ( + loc_baseline.baseline_path(scope.root).exists() + or markdown_baseline.baseline_path(scope.root).exists() + ): + linked_targets = baseline_files.validate_explicit_scope( args.paths, invocation, scope.root, tuple(selected.physical_path for selected in scope.selected_files), ) @@ -506,7 +523,8 @@ def main() -> int: print_text(data) return exit_code(data["overall"], args.ci) except Exception as exc: - write_mode = args.create_loc_baseline or args.update_loc_baseline + write_mode = any((args.create_loc_baseline, args.update_loc_baseline, + args.create_markdown_baseline, args.update_markdown_baseline)) return _print_tool_error(str(exc), args.json and not write_mode) diff --git a/src/agent_code_guard/guards/markdown_document_size.py b/src/agent_code_guard/guards/markdown_document_size.py index 7281f75..d70362e 100644 --- a/src/agent_code_guard/guards/markdown_document_size.py +++ b/src/agent_code_guard/guards/markdown_document_size.py @@ -46,13 +46,27 @@ def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> return Config(True, review_at) -def run(root: Path, config: Config, facts: MarkdownFacts) -> GuardResult: +def run( + root: Path, config: Config, facts: MarkdownFacts, baseline: dict[str, int] | None = None, +) -> GuardResult: assert config.review_at is not None - findings = [MarkdownDocumentFinding( - fact.reporting_path or _path(fact.path, root), fact.physical_lines, - "review" if fact.physical_lines > config.review_at else "pass", - {"reviewAt": config.review_at}, - ) for fact in facts.documents] + findings = [] + for fact in facts.documents: + path = fact.reporting_path or _path(fact.path, root) + allowance = baseline.get(path) if baseline is not None else None + state = "review" if fact.physical_lines > config.review_at else "pass" + ratchet_status = None + if allowance is not None: + if fact.physical_lines <= config.review_at: + ratchet_status = "notNeeded" + elif fact.physical_lines <= allowance: + state, ratchet_status = "pass", "within" + else: + ratchet_status = "exceeded" + findings.append(MarkdownDocumentFinding( + path, fact.physical_lines, state, {"reviewAt": config.review_at}, + allowance, ratchet_status, + )) findings.sort(key=lambda finding: finding.path) return GuardResult("markdownDocumentSize", "review" if any(item.state == "review" for item in findings) else "pass", findings) diff --git a/src/agent_code_guard/human_output.py b/src/agent_code_guard/human_output.py index c3c57e1..6e6ffd0 100644 --- a/src/agent_code_guard/human_output.py +++ b/src/agent_code_guard/human_output.py @@ -84,11 +84,16 @@ def _markdown_lines(data: dict[str, object]) -> list[str]: document_result = data["guards"].get("markdownDocumentSize") if document_result: for finding in document_result["findings"]: - if finding["state"] != "review": + allowance = finding.get("baselineLines") + if finding["state"] != "review" and allowance is None: continue + detail = "" + if allowance is not None: + status = "no longer needed" if finding["ratchetStatus"] == "notNeeded" else finding["ratchetStatus"] + detail = f"; baseline {allowance}, {status}" lines.append( - f"REVIEW: {finding['path']} — Markdown document is {finding['measured']} lines " - f"(review {finding['thresholds']['reviewAt']})" + f"{finding['state'].upper()}: {finding['path']} — Markdown document is {finding['measured']} lines " + f"(review {finding['thresholds']['reviewAt']}{detail})" ) section_result = data["guards"].get("markdownSectionSize") if section_result: diff --git a/src/agent_code_guard/loc_baseline.py b/src/agent_code_guard/loc_baseline.py index 12a1ad0..0851502 100644 --- a/src/agent_code_guard/loc_baseline.py +++ b/src/agent_code_guard/loc_baseline.py @@ -3,12 +3,20 @@ from __future__ import annotations import json -import os -import tempfile from pathlib import Path from typing import Any from .file_selection import is_within +from .baseline_files import ( + atomic_create as _atomic_create, + atomic_replace as _atomic_replace, + canonical_path as _canonical_path, + in_bounds as _in_bounds, + require_regular_inside as _require_regular_inside, + resolve_bounds as _resolve_bounds, + validate_explicit_scope, + validate_paths, +) from .guards import loc from .invocation import SelectedFile from .path_matching import matches_path_glob @@ -77,41 +85,6 @@ def validate_overlap(entries: dict[str, int], config: loc.Config) -> None: raise ValueError(f"LOC baseline path overlaps allowedLargeFiles: {path}") -def validate_paths(root: Path, entries: dict[str, int]) -> None: - for relative in entries: - candidate = root / Path(relative) - resolved = candidate.resolve(strict=False) - if not is_within(resolved, root): - raise ValueError(f"LOC baseline path escapes analysis root: {relative}") - current = root - for part in Path(relative).parts: - current = current / part - if current.is_symlink(): - raise ValueError(f"LOC baseline path traverses a symlink: {relative}") - - -def validate_explicit_scope( - values: list[str], invocation: Path, root: Path, selected_files: tuple[Path, ...], -) -> set[Path]: - """Validate raw bounds before resolution erases empty directories and file-link identity.""" - linked_targets: set[Path] = set() - directly_reached: set[Path] = set() - for value in values or ["."]: - path = Path(value) if Path(value).is_absolute() else invocation / value - resolved = path.resolve() - if not is_within(resolved, root): - raise ValueError(f"baseline scope is outside analysis root: {value}") - if path.is_symlink() and path.is_file(): - linked_targets.add(resolved) - elif path.is_file(): - directly_reached.add(resolved) - elif path.is_dir(): - directly_reached.update( - selected.resolve() for selected in selected_files if is_within(selected, resolved) - ) - return linked_targets - directly_reached - - def create(root: Path, files: tuple[Path, ...], config: loc.Config) -> int: target = baseline_path(root) validate_storage_path(root) @@ -212,72 +185,6 @@ def serialize(entries: dict[str, int]) -> bytes: return (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8") -def _atomic_replace(target: Path, content: bytes) -> None: - temporary = _write_temporary(target, content) - try: - os.replace(temporary, target) - finally: - try: - temporary.unlink() - except FileNotFoundError: - pass - - -def _atomic_create(target: Path, content: bytes) -> None: - temporary = _write_temporary(target, content) - try: - os.link(temporary, target) - except FileExistsError as exc: - raise ValueError(f"LOC baseline already exists: {RELATIVE_PATH}") from exc - finally: - try: - temporary.unlink() - except FileNotFoundError: - pass - - -def _write_temporary(target: Path, content: bytes) -> Path: - descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) - temporary = Path(temporary_name) - try: - with os.fdopen(descriptor, "wb") as handle: - handle.write(content) - handle.flush() - os.fsync(handle.fileno()) - return temporary - except Exception: - temporary.unlink(missing_ok=True) - raise - - -def _resolve_bounds(values: list[str], invocation: Path, root: Path) -> list[tuple[Path, bool]]: - bounds = [] - for value in values or ["."]: - path = Path(value) if Path(value).is_absolute() else invocation / value - if not path.exists(): - raise FileNotFoundError(f"explicit path does not exist: {value}") - if path.is_symlink(): - raise ValueError(f"baseline bounds may not be symlinks: {value}") - resolved = path.resolve() - if not is_within(resolved, root): - raise ValueError(f"baseline scope is outside analysis root: {value}") - bounds.append((resolved, path.is_dir())) - return bounds - - -def _in_bounds(relative: str, bounds: list[tuple[Path, bool]], root: Path) -> bool: - candidate = (root / Path(relative)).resolve(strict=False) - return any( - is_within(candidate, bound) if is_directory else candidate == bound - for bound, is_directory in bounds - ) - - -def _require_regular_inside(path: Path, root: Path) -> None: - if path.is_symlink() or not path.is_file() or not is_within(path, root): - raise ValueError(f"baseline scope contains an unsafe or outside-root path: {path}") - - def _require_enabled(config: loc.Config) -> None: if not config.enabled: raise ValueError("LOC guard must be enabled for baseline writes") @@ -293,12 +200,3 @@ def _exact_keys(value: Any, expected: set[str], location: str) -> None: if missing: raise ValueError(f"missing LOC baseline property: {location}.{missing[0]}") raise ValueError(f"unknown LOC baseline property: {location}.{unknown[0]}") - - -def _canonical_path(value: str) -> bool: - if not value or "\\" in value or value.endswith("/") or "//" in value: - return False - path = Path(value) - if path.is_absolute() or path.drive or value.startswith("//"): - return False - return all(part not in {"", ".", ".."} for part in value.split("/")) diff --git a/src/agent_code_guard/markdown_baseline.py b/src/agent_code_guard/markdown_baseline.py new file mode 100644 index 0000000..68132dc --- /dev/null +++ b/src/agent_code_guard/markdown_baseline.py @@ -0,0 +1,171 @@ +"""Explicit, non-increasing allowances for reviewed Markdown documents.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from . import baseline_files +from .file_selection import is_within +from .guards.markdown_document_size import Config + +RELATIVE_PATH = '.agent-tools/code-guard.markdown-baseline.json' + + +def baseline_path(root: Path) -> Path: + return root / RELATIVE_PATH + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f'duplicate Markdown baseline property: {key}') + result[key] = value + return result + + +def _exact_keys(value: Any, keys: set[str], location: str) -> None: + if not isinstance(value, dict) or set(value) != keys: + raise ValueError(f'{location} must be an object with exactly these keys: {", ".join(sorted(keys))}') + + +def load(path: Path) -> dict[str, int]: + try: + data = json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=_unique_object) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f'invalid Markdown baseline: {exc}') from exc + _exact_keys(data, {'version', 'markdownDocumentSize'}, 'Markdown baseline') + if type(data['version']) is not int or data['version'] != 1: + raise ValueError('Markdown baseline version must be the integer 1') + _exact_keys(data['markdownDocumentSize'], {'files'}, 'baseline.markdownDocumentSize') + files = data['markdownDocumentSize']['files'] + if not isinstance(files, list): + raise ValueError('baseline.markdownDocumentSize.files must be an array') + entries = {} + for item in files: + _exact_keys(item, {'path', 'allowedLines'}, 'Markdown baseline entry') + relative, allowance = item['path'], item['allowedLines'] + _validate_relative(relative) + if relative in entries: + raise ValueError(f'duplicate Markdown baseline path: {relative}') + if type(allowance) is not int or allowance <= 0: + raise ValueError('Markdown baseline allowedLines must be a positive integer') + entries[relative] = allowance + if list(entries) != sorted(entries): + raise ValueError('Markdown baseline entries must be sorted by path') + return entries + + +def _validate_relative(relative: Any) -> None: + if ( + not isinstance(relative, str) or not baseline_files.canonical_path(relative) + or ':' in relative or '\x00' in relative + or Path(relative).suffix.lower() != '.md' + ): + raise ValueError('Markdown baseline path must be a safe normalized relative .md path') + + +def _validate_storage(root: Path) -> None: + target = baseline_path(root) + directory = target.parent + if directory.is_symlink() or (directory.exists() and not directory.is_dir()): + raise ValueError('Markdown baseline directory must be a real directory inside the analysis root') + if target.is_symlink() or not is_within(directory, root): + raise ValueError('Markdown baseline path must not traverse a symlink or escape the analysis root') + + +def load_if_present(root: Path) -> dict[str, int] | None: + target = baseline_path(root) + if not target.exists() and not target.is_symlink(): + return None + _validate_storage(root) + entries = load(target) + baseline_files.validate_paths(root, entries, 'Markdown') + return entries + + +def _require_enabled(config: Config) -> None: + if not config.enabled: + raise ValueError('Markdown document-size guard must be enabled for baseline writes') + + +def _measure(path: Path, root: Path) -> int: + relative = path.relative_to(root).as_posix() + _validate_relative(relative) + baseline_files.validate_paths(root, {relative: 1}, 'Markdown') + baseline_files.require_regular_inside(path, root) + from .markdown import scan_text + return scan_text(path, path.read_text(encoding='utf-8')).physical_lines + + +def _serialize(entries: dict[str, int]) -> bytes: + data = {'version': 1, 'markdownDocumentSize': {'files': [ + {'path': path, 'allowedLines': entries[path]} for path in sorted(entries) + ]}} + return (json.dumps(data, ensure_ascii=False, indent=2) + '\n').encode('utf-8') + + +def create(root: Path, files: tuple[Path, ...], config: Config) -> int: + _require_enabled(config) + _validate_storage(root) + target = baseline_path(root) + if target.exists(): + raise ValueError(f'Markdown baseline already exists: {RELATIVE_PATH}') + entries = {} + for path in files: + if path.suffix.lower() == '.md': + measured = _measure(path, root) + if measured > config.review_at: + entries[path.relative_to(root).as_posix()] = measured + created_directory = not target.parent.exists() + try: + target.parent.mkdir(exist_ok=True) + baseline_files.atomic_create(target, _serialize(entries), 'Markdown') + except Exception: + if created_directory: + try: + target.parent.rmdir() + except OSError: + pass + raise + return len(entries) + + +def update( + root: Path, raw_bounds: list[str], invocation: Path, config: Config, + scope_excluded: tuple[Path, ...], +) -> tuple[int, int, int]: + _require_enabled(config) + entries = load_if_present(root) + if entries is None: + raise ValueError(f'Markdown baseline does not exist: {RELATIVE_PATH}') + bounds = baseline_files.resolve_bounds(raw_bounds, invocation, root) + excluded = set(scope_excluded) + proposed = dict(entries) + lowered = removed = unchanged = 0 + for relative, allowance in entries.items(): + if not baseline_files.in_bounds(relative, bounds, root): + continue + path = root / relative + if not path.exists() or path in excluded: + proposed.pop(relative) + removed += 1 + continue + measured = _measure(path, root) + if measured > allowance: + raise ValueError(f'Markdown baseline update would increase allowance for {relative}: {allowance} to {measured}') + if measured <= config.review_at: + proposed.pop(relative) + removed += 1 + elif measured < allowance: + proposed[relative] = measured + lowered += 1 + else: + unchanged += 1 + target = baseline_path(root) + content = _serialize(proposed) + if content != target.read_bytes(): + baseline_files.atomic_replace(target, content) + return lowered, removed, unchanged diff --git a/src/agent_code_guard/result_model.py b/src/agent_code_guard/result_model.py index 812a75d..12b9791 100644 --- a/src/agent_code_guard/result_model.py +++ b/src/agent_code_guard/result_model.py @@ -78,14 +78,22 @@ class MarkdownDocumentFinding: state: str thresholds: dict[str, int] + baseline_lines: int | None = None + ratchet_status: str | None = None + def to_json(self) -> dict[str, Any]: - return { + result = { "path": self.path, "measured": self.measured, "state": self.state, "thresholds": self.thresholds, } + if self.baseline_lines is not None: + result["baselineLines"] = self.baseline_lines + result["ratchetStatus"] = self.ratchet_status + return result + @dataclass(frozen=True) class MarkdownSectionFinding: diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index d0f2aa3..9619d58 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -198,8 +198,12 @@ def test_version_does_not_import_or_initialize_providers(self) -> None: from agent_code_guard.code_guard import main with patch.object(sys, 'argv', ['code-guard', '--version']): result = main() -loaded = sorted(name for name in sys.modules if name.startswith( - ('agent_code_guard.analysis', 'agent_code_guard.markdown', 'tree_sitter') +provider_packages = ( + 'agent_code_guard.analysis', 'agent_code_guard.markdown', + 'tree_sitter', 'tree_sitter_language_pack', +) +loaded = sorted(name for name in sys.modules if any( + name == package or name.startswith(package + '.') for package in provider_packages )) print(json.dumps({'result': result, 'loaded': loaded})) """ diff --git a/tests/test_markdown_baseline.py b/tests/test_markdown_baseline.py new file mode 100644 index 0000000..4bc3d52 --- /dev/null +++ b/tests/test_markdown_baseline.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from tests.helpers import CodeGuardTestCase, git, init_git, write_lines + + +BASELINE = Path('.agent-tools/code-guard.markdown-baseline.json') + + +def entries(root: Path) -> list[dict]: + return json.loads((root / BASELINE).read_text(encoding='utf-8'))['markdownDocumentSize']['files'] + + +class MarkdownBaselineTests(CodeGuardTestCase): + def test_document_lifecycle_outputs_and_independent_section_review(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + init_git(root) + document = root / 'architecture.md' + write_lines(document, 845) + write_lines(root / 'boundary.md', 800) + ordinary = self.run_guard(root, '.', '--json') + self.assertEqual(ordinary.returncode, 1) + self.assertFalse((root / BASELINE).exists()) + self.assertNotIn('baselineLines', self.read_json(ordinary)['guards']['markdownDocumentSize']['findings'][0]) + created = self.run_guard(root, '.', '--create-markdown-baseline') + self.assertEqual((created.returncode, created.stderr), (0, '')) + self.assertEqual(entries(root), [{'path': 'architecture.md', 'allowedLines': 845}]) + before = (root / BASELINE).read_bytes() + accepted = self.run_guard(root, '.', '--json') + self.assertEqual(accepted.returncode, 0) + finding = self.read_json(accepted)['guards']['markdownDocumentSize']['findings'][0] + self.assertEqual((finding['state'], finding['baselineLines'], finding['ratchetStatus']), ('pass', 845, 'within')) + self.assertEqual(finding['thresholds'], {'reviewAt': 800}) + self.assertEqual(self.read_json(accepted)['requiredPolicies'], []) + self.assertIn('baseline 845, within', self.run_guard(root, '.').stdout) + compact = self.run_guard(root, '.', '--json', '--json-mode', 'compact') + self.assertEqual(self.read_json(compact)['guards']['markdownDocumentSize']['findings'], []) + write_lines(document, 840) + self.assertEqual(self.run_guard(root, '.', '--json').returncode, 0) + self.assertEqual((root / BASELINE).read_bytes(), before) + lowered = self.run_guard(root, '.', '--update-markdown-baseline') + self.assertIn('1 lowered', lowered.stdout) + self.assertEqual(entries(root)[0]['allowedLines'], 840) + before = (root / BASELINE).read_bytes() + write_lines(document, 846) + growth = self.run_guard(root, '.', '--json') + finding = self.read_json(growth)['guards']['markdownDocumentSize']['findings'][0] + self.assertEqual((growth.returncode, finding['state'], finding['ratchetStatus']), (1, 'review', 'exceeded')) + self.assertEqual(self.run_guard(root, '.', '--ci').returncode, 0) + self.assertEqual(self.run_guard(root, '.', '--update-markdown-baseline').returncode, 3) + self.assertEqual((root / BASELINE).read_bytes(), before) + write_lines(document, 800) + write_lines(root / 'new.md', 845) + normal = self.read_json(self.run_guard(root, '.', '--json')) + by_path = {item['path']: item for item in normal['guards']['markdownDocumentSize']['findings']} + self.assertEqual(by_path['architecture.md']['ratchetStatus'], 'notNeeded') + self.assertEqual(by_path['new.md']['state'], 'review') + self.assertEqual(self.run_guard(root, '.', '--update-markdown-baseline').returncode, 0) + self.assertEqual(entries(root), []) + document.write_text('# Section\n' + 'body\n' * 799, encoding='utf-8') + self.assertEqual(self.read_json(self.run_guard(root, 'architecture.md', '--json'))['guards']['markdownSectionSize']['state'], 'review') + + def test_accepted_document_keeps_section_review_active(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + document = root / 'architecture.md' + document.write_text('# Section\n' + 'body\n' * 844, encoding='utf-8') + self.assertEqual(self.run_guard(root, '.', '--create-markdown-baseline').returncode, 0) + result = self.run_guard(root, '.', '--json') + data = self.read_json(result) + self.assertEqual(result.returncode, 1) + self.assertEqual(data['guards']['markdownDocumentSize']['state'], 'pass') + self.assertEqual(data['guards']['markdownSectionSize']['state'], 'review') + self.assertEqual(data['requiredPolicies'], ['markdownSectionSize']) + + def test_bounded_update_prunes_deletions_exclusions_and_never_adds(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + init_git(root) + for name in ('docs/lower.md', 'docs/deleted.md', 'other.md'): + write_lines(root / name, 805) + self.assertEqual(self.run_guard(root, '.', '--create-markdown-baseline').returncode, 0) + before = (root / BASELINE).read_bytes() + self.assertEqual(self.run_guard(root, '.', '--create-markdown-baseline').returncode, 3) + self.assertEqual((root / BASELINE).read_bytes(), before) + write_lines(root / 'docs/lower.md', 802) + (root / 'docs/deleted.md').unlink() + write_lines(root / 'docs/new.md', 805) + result = self.run_guard(root / 'docs', '.', '--update-markdown-baseline') + self.assertEqual((result.returncode, result.stderr), (0, '')) + self.assertEqual(entries(root), [ + {'path': 'docs/lower.md', 'allowedLines': 802}, + {'path': 'other.md', 'allowedLines': 805}, + ]) + stamp = (root / BASELINE).stat().st_mtime_ns + self.assertEqual(self.run_guard(root, '.', '--update-markdown-baseline').returncode, 0) + self.assertEqual((root / BASELINE).stat().st_mtime_ns, stamp) + self.assertEqual(self.run_guard(root, 'docs', '--scope-exclude', 'docs/**', '--update-markdown-baseline').returncode, 0) + self.assertEqual(entries(root), [{'path': 'other.md', 'allowedLines': 805}]) + + def test_configuration_and_git_selection_preserve_document_contract(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + init_git(root) + (root / '.agent-tools').mkdir() + config = root / '.agent-tools/code-guard.config.json' + config.write_text(json.dumps({'version': 1, 'guards': { + 'loc': {'enabled': False}, 'markdownDocumentSize': {'reviewAt': 3}, + }, 'scope': {'exclude': ['excluded.md']}}), encoding='utf-8') + write_lines(root / 'accepted.MD', 5) + write_lines(root / 'excluded.md', 5) + self.assertEqual(self.run_guard(root, '.', '--create-markdown-baseline').returncode, 0) + self.assertEqual(entries(root), [{'path': 'accepted.MD', 'allowedLines': 5}]) + git(root, 'add', '.') + git(root, 'commit', '-m', 'accepted documents') + write_lines(root / 'accepted.MD', 6) + result = self.run_guard(root, '.', '--changed-only', '--json') + self.assertEqual(result.returncode, 1) + self.assertEqual(self.read_json(result)['guards']['markdownDocumentSize']['findings'][0]['ratchetStatus'], 'exceeded') + config.write_text(json.dumps({'version': 1, 'guards': {'markdownDocumentSize': {'enabled': False}}}), encoding='utf-8') + self.assertEqual(self.run_guard(root, '.', '--update-markdown-baseline').returncode, 3) + + def test_malformed_baselines_fail_closed_without_mutation(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + init_git(root) + write_lines(root / 'doc.md', 845) + baseline = root / BASELINE + baseline.parent.mkdir() + valid_entry = {'path': 'doc.md', 'allowedLines': 845} + invalid_files = [[valid_entry, valid_entry]] + invalid_files.extend( + [{'path': path, 'allowedLines': 845}] + for path in ('../doc.md', '/doc.md', 'C:/doc.md', './doc.md', 'a//doc.md', 'a\\doc.md', 'doc.py') + ) + invalid_files.extend( + [{'path': 'doc.md', 'allowedLines': value}] + for value in (True, 0, -1, 845.0, '845') + ) + documents = [json.dumps({'version': 1, 'markdownDocumentSize': {'files': files}}) for files in invalid_files] + documents.extend(( + '{', + '{"version":1,"version":1,"markdownDocumentSize":{"files":[]}}', + '{"version":1.0,"markdownDocumentSize":{"files":[]}}', + '{"version":1,"markdownDocumentSize":{"files":[]},"unknown":true}', + )) + for content in documents: + with self.subTest(content=content): + baseline.write_text(content, encoding='utf-8') + for mode in ('--json', '--update-markdown-baseline'): + self.assertEqual(self.run_guard(root, '.', mode).returncode, 3) + self.assertEqual(baseline.read_text(encoding='utf-8'), content) + + def test_write_mode_rejects_incompatible_options_without_writing(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + init_git(root) + write_lines(root / 'doc.md', 845) + for options in ( + ('--json',), ('--ci',), ('--changed-only',), ('--staged',), + ('--base-ref', 'HEAD'), ('--version',), ('--skill-path',), + ('--create-loc-baseline',), ('--update-markdown-baseline',), + ('--count-blank-lines',), ('--exclude', '*.md'), ('doctor',), + ): + with self.subTest(options=options): + result = self.run_guard(root, *options, '--create-markdown-baseline') + self.assertEqual((result.returncode, result.stdout), (3, '')) + self.assertFalse((root / BASELINE).exists()) + + def test_symlinks_and_outside_scope_cannot_gain_an_allowance(self): + with tempfile.TemporaryDirectory() as temp, tempfile.TemporaryDirectory() as other: + root, outside = Path(temp), Path(other) + init_git(root) + write_lines(root / 'doc.md', 845) + write_lines(outside / 'outside.md', 845) + self.assertEqual(self.run_guard(root, str(outside), '--create-markdown-baseline').returncode, 3) + try: + (root / 'alias.md').symlink_to(root / 'doc.md') + except OSError: + self.skipTest('file symlinks unavailable') + self.assertEqual(self.run_guard(root, '.', '--create-markdown-baseline').returncode, 0) + self.assertEqual(self.run_guard(root, 'alias.md', '--json').returncode, 1) + self.assertEqual(self.run_guard(root, 'doc.md', '--json').returncode, 0) + self.assertEqual(self.run_guard(root, str(outside), '--json').returncode, 3) + (root / 'doc.md').unlink() + (root / 'doc.md').symlink_to(outside / 'outside.md') + before = (root / BASELINE).read_bytes() + for mode in ('--json', '--update-markdown-baseline'): + self.assertEqual(self.run_guard(root, '.', mode).returncode, 3) + self.assertEqual((root / BASELINE).read_bytes(), before)