From a2e8f1bd7a411ec061bd422d2e92bc695816746f Mon Sep 17 00:00:00 2001 From: wantsong Date: Fri, 19 Jun 2026 02:56:10 +0800 Subject: [PATCH] feat: add lifecycle status guard scan skill --- docs/install-sync.md | 2 + registry/skills-index.md | 1 + skills/lifecycle-status-guard-scan/README.md | 41 ++ skills/lifecycle-status-guard-scan/SKILL.md | 102 ++++ .../agents/openai.yaml | 4 + .../lifecycle-guard-config.example.yaml | 42 ++ .../scripts/lifecycle_status_guard_scan.py | 448 ++++++++++++++++++ .../tests/test_lifecycle_status_guard_scan.py | 265 +++++++++++ 8 files changed, 905 insertions(+) create mode 100644 skills/lifecycle-status-guard-scan/README.md create mode 100644 skills/lifecycle-status-guard-scan/SKILL.md create mode 100644 skills/lifecycle-status-guard-scan/agents/openai.yaml create mode 100644 skills/lifecycle-status-guard-scan/fixtures/lifecycle-guard-config.example.yaml create mode 100644 skills/lifecycle-status-guard-scan/scripts/lifecycle_status_guard_scan.py create mode 100644 skills/lifecycle-status-guard-scan/tests/test_lifecycle_status_guard_scan.py diff --git a/docs/install-sync.md b/docs/install-sync.md index 03a628d..ee72cbb 100644 --- a/docs/install-sync.md +++ b/docs/install-sync.md @@ -22,6 +22,7 @@ PowerShell: ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip +powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill lifecycle-status-guard-scan powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations ``` @@ -29,6 +30,7 @@ Use `-Force` to back up and replace an existing installed copy: ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip -Force +powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill lifecycle-status-guard-scan -Force powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force ``` diff --git a/registry/skills-index.md b/registry/skills-index.md index be4c633..0a60a81 100644 --- a/registry/skills-index.md +++ b/registry/skills-index.md @@ -15,6 +15,7 @@ Existing CCPE content is not migrated here. | --- | --- | --- | --- | --- | --- | --- | | `bundle-zip` | Create zip archives from explicit file lists while preserving source-relative paths and validating entries by readback. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\bundle-zip` | `installed` | `default` | `C:\Users\wangq\.agents\skills\bundle-zip` | none | | `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none | +| `lifecycle-status-guard-scan` | Scan configured Markdown, JSON, YAML, and text files for lifecycle/status overclaim candidates and missing local evidence markers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\lifecycle-status-guard-scan` | `installed` | `default` | `C:\Users\wangq\.agents\skills\lifecycle-status-guard-scan` | none | | `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none | | `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none | diff --git a/skills/lifecycle-status-guard-scan/README.md b/skills/lifecycle-status-guard-scan/README.md new file mode 100644 index 0000000..ff165d5 --- /dev/null +++ b/skills/lifecycle-status-guard-scan/README.md @@ -0,0 +1,41 @@ +# Lifecycle Status Guard Scan + +Source Skill for scanning lifecycle/status overclaim candidates in Markdown, JSON, YAML, and text files. + +The canonical entry point is `SKILL.md`; the deterministic scanner is in `scripts/lifecycle_status_guard_scan.py`. + +## Original Source + +```text +Original source: C:\Users\wangq\Documents\Codex\ccpe-system\requirements\skills-vault\2026-06-19-lifecycle-status-guard-scan.md +Migration date: 2026-06-19 +Migration status: first public Skill implementation +Behavior preserved: deterministic scan only; no lifecycle judgment or file edits +Known gaps: no project-specific policy preset beyond the example fixture +``` + +## Layout + +```text +SKILL.md +README.md +agents/openai.yaml +fixtures/lifecycle-guard-config.example.yaml +scripts/lifecycle_status_guard_scan.py +tests/test_lifecycle_status_guard_scan.py +``` + +## Usage + +```powershell +conda run -n skills-vault python .\scripts\lifecycle_status_guard_scan.py ` + --scan-root C:\path\project ` + --config C:\path\lifecycle-guard-config.yaml ` + --output-dir C:\path\helper-outputs +``` + +## Tests + +```powershell +conda run -n skills-vault python -B -m unittest discover -s skills/lifecycle-status-guard-scan/tests -v +``` diff --git a/skills/lifecycle-status-guard-scan/SKILL.md b/skills/lifecycle-status-guard-scan/SKILL.md new file mode 100644 index 0000000..4009318 --- /dev/null +++ b/skills/lifecycle-status-guard-scan/SKILL.md @@ -0,0 +1,102 @@ +--- +name: lifecycle-status-guard-scan +description: Use when Codex needs to scan Markdown, JSON, YAML, or text files for lifecycle/status overclaims such as stable, accepted, owner-approved, CCRA-approved, released, production-ready, final, or equivalent Chinese phrasing before review, release, handoff, or governance work. +--- + +# Lifecycle Status Guard Scan + +## Purpose + +Run a deterministic, configurable scan for lifecycle/status claim candidates. Use the bundled script to produce Markdown and JSON reports that a human, Owner, CCRA reviewer, or upper-layer agent can judge. + +The scanner is evidence-producing only. It must not edit status fields, promote or downgrade lifecycle state, or decide whether an approval claim is true. + +## Command + +```powershell +conda run -n skills-vault python .\scripts\lifecycle_status_guard_scan.py ` + --scan-root C:\path\project ` + --config C:\path\lifecycle-guard-config.yaml ` + --output-dir C:\path\helper-outputs +``` + +When installed under `C:\Users\wangq\.agents\skills\lifecycle-status-guard-scan`, run from that Skill directory or use the installed script path. + +## Config + +```yaml +watched_paths: + - "**/*.md" + - "**/*.json" + - "**/*.yaml" +status_fields: + - status + - lifecycle +forbidden_status_values: + - stable + - accepted +warning_status_values: + - candidate +required_evidence_markers: + - owner_decision + - ccra_review +approved_phrases: + - owner-approved + - ccra-approved + - 所有者批准 +forbidden_phrases: + - production-ready +allowed_contexts: + - do not say + - example: +``` + +Config files may be JSON, YAML, or YML. + +## Rules + +- Treat `approved_phrases` as approval-claim phrases requiring evidence. They are not a whitelist and do not mean the claim is accepted. +- If an approval claim phrase lacks a nearby `required_evidence_markers` match, report a blocking phrase finding. +- If an approval claim phrase has nearby evidence, report only an evidence-present observation. Do not judge whether the approval is real. +- For structured fields, associate evidence only within the same front matter block or JSON/YAML object that contains the matched field. +- For phrase claims, associate evidence only within the configured nearby text window, default 240 characters. +- Apply `allowed_contexts` only to phrase-level claims, such as negations, examples, or policy text. Never use allowed contexts to exempt structured fields like `status: stable`. + +## Outputs + +The script writes: + +```text +lifecycle-status-guard-scan.md +lifecycle-status-guard-scan.json +``` + +Reports include: + +- files scanned +- possible overclaims +- field-level findings +- phrase-level findings +- missing evidence markers +- warnings +- blocking errors +- evidence-present observations +- machine-readable summary + +Exit code is `1` when blocking findings exist and `0` when the scan has only warnings or observations. + +## Safety + +- Read only configured files under `scan_root`. +- Write only the two report files under `output_dir`. +- Do not modify scanned files. +- Do not treat evidence markers as proof of approval; they only prevent a missing-evidence blocking result. + +## Validation + +After changing scanner behavior, run: + +```powershell +conda run -n skills-vault python -B -m unittest discover -s skills/lifecycle-status-guard-scan/tests -v +conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills/lifecycle-status-guard-scan +``` diff --git a/skills/lifecycle-status-guard-scan/agents/openai.yaml b/skills/lifecycle-status-guard-scan/agents/openai.yaml new file mode 100644 index 0000000..e6f0801 --- /dev/null +++ b/skills/lifecycle-status-guard-scan/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Lifecycle Status Guard Scan" + short_description: "Scan lifecycle claims for evidence gaps" + default_prompt: "Use $lifecycle-status-guard-scan to scan configured project files for lifecycle or approval claims that need evidence." diff --git a/skills/lifecycle-status-guard-scan/fixtures/lifecycle-guard-config.example.yaml b/skills/lifecycle-status-guard-scan/fixtures/lifecycle-guard-config.example.yaml new file mode 100644 index 0000000..116b206 --- /dev/null +++ b/skills/lifecycle-status-guard-scan/fixtures/lifecycle-guard-config.example.yaml @@ -0,0 +1,42 @@ +watched_paths: + - "**/*.md" + - "**/*.json" + - "**/*.yaml" + - "**/*.yml" +status_fields: + - status + - lifecycle + - model.status + - model.lifecycle +forbidden_status_values: + - stable + - accepted + - owner-approved + - ccra-approved +warning_status_values: + - candidate + - ready-for-review +required_evidence_markers: + - owner_decision + - owner decision + - ccra_review + - ccra review + - web ccra + - 所有者决定记录 +approved_phrases: + - owner-approved + - ccra-approved + - Owner approved + - CCRA approved + - 所有者批准 + - CCRA批准 +forbidden_phrases: + - production-ready + - released to production + - final approved +allowed_contexts: + - do not say + - must not claim + - example: + - 示例: +evidence_window_chars: 240 diff --git a/skills/lifecycle-status-guard-scan/scripts/lifecycle_status_guard_scan.py b/skills/lifecycle-status-guard-scan/scripts/lifecycle_status_guard_scan.py new file mode 100644 index 0000000..6451835 --- /dev/null +++ b/skills/lifecycle-status-guard-scan/scripts/lifecycle_status_guard_scan.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import argparse +import fnmatch +import json +import pathlib +import re +import sys +from collections.abc import Iterable, Mapping +from typing import Any + +import yaml + + +REPORT_BASENAME = "lifecycle-status-guard-scan" +DEFAULT_EVIDENCE_WINDOW_CHARS = 240 + + +def as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, Iterable): + return [str(item) for item in value if item is not None] + return [str(value)] + + +def lower_set(values: Iterable[str]) -> set[str]: + return {value.casefold() for value in values} + + +def normalize_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: + return { + "watched_paths": as_list(raw_config.get("watched_paths")) or ["**/*"], + "status_fields": as_list(raw_config.get("status_fields")), + "forbidden_status_values": lower_set(as_list(raw_config.get("forbidden_status_values"))), + "warning_status_values": lower_set(as_list(raw_config.get("warning_status_values"))), + "required_evidence_markers": as_list(raw_config.get("required_evidence_markers")), + "approved_phrases": as_list(raw_config.get("approved_phrases")), + "forbidden_phrases": as_list(raw_config.get("forbidden_phrases")), + "allowed_contexts": as_list(raw_config.get("allowed_contexts")), + "evidence_window_chars": int( + raw_config.get("evidence_window_chars", DEFAULT_EVIDENCE_WINDOW_CHARS) + ), + } + + +def load_config(path: pathlib.Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + if path.suffix.lower() == ".json": + parsed = json.loads(text) + else: + parsed = yaml.safe_load(text) + if not isinstance(parsed, Mapping): + raise ValueError("Config must be a JSON or YAML object.") + return normalize_config(parsed) + + +def rel_path(path: pathlib.Path, root: pathlib.Path) -> str: + return path.relative_to(root).as_posix() + + +def discover_files(scan_root: pathlib.Path, watched_paths: list[str]) -> list[pathlib.Path]: + files: dict[pathlib.Path, None] = {} + for pattern in watched_paths: + for candidate in scan_root.glob(pattern): + if candidate.is_file(): + files[candidate.resolve()] = None + return sorted(files) + + +def parse_markdown_front_matter(text: str) -> Any | None: + if not text.startswith("---"): + return None + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return None + for index in range(1, len(lines)): + if lines[index].strip() == "---": + front_matter = "\n".join(lines[1:index]) + parsed = yaml.safe_load(front_matter) if front_matter.strip() else {} + return parsed if isinstance(parsed, Mapping) else None + return None + + +def parse_structured_content(path: pathlib.Path, text: str) -> Any | None: + suffix = path.suffix.lower() + try: + if suffix == ".json": + return json.loads(text) + if suffix in {".yaml", ".yml"}: + return yaml.safe_load(text) + if suffix in {".md", ".markdown"}: + return parse_markdown_front_matter(text) + except Exception: + return None + return None + + +def scalar_values(value: Any) -> list[str]: + if isinstance(value, (str, int, float, bool)): + return [str(value)] + if isinstance(value, list): + values: list[str] = [] + for item in value: + values.extend(scalar_values(item)) + return values + return [] + + +def object_contains_marker(value: Any, markers: list[str]) -> bool: + if not markers: + return False + marker_needles = [marker.casefold() for marker in markers] + + def walk(current: Any) -> bool: + if isinstance(current, Mapping): + for key, nested in current.items(): + key_text = str(key).casefold() + if any(marker in key_text for marker in marker_needles): + return True + if walk(nested): + return True + return False + if isinstance(current, list): + return any(walk(item) for item in current) + text = str(current).casefold() + return any(marker in text for marker in marker_needles) + + return walk(value) + + +def line_col(text: str, index: int) -> tuple[int, int]: + line = text.count("\n", 0, index) + 1 + last_newline = text.rfind("\n", 0, index) + column = index + 1 if last_newline == -1 else index - last_newline + return line, column + + +def make_context(text: str, index: int, length: int, window: int) -> str: + start = max(0, index - window) + end = min(len(text), index + length + window) + return re.sub(r"\s+", " ", text[start:end]).strip() + + +def context_has_any(context: str, values: list[str]) -> bool: + folded = context.casefold() + return any(value.casefold() in folded for value in values) + + +def find_phrase_matches(text: str, phrase: str) -> Iterable[int]: + if not phrase: + return [] + folded_text = text.casefold() + folded_phrase = phrase.casefold() + matches: list[int] = [] + start = 0 + while True: + index = folded_text.find(folded_phrase, start) + if index == -1: + break + matches.append(index) + start = index + max(len(folded_phrase), 1) + return matches + + +def path_matches(field_path: str, key: str, status_fields: list[str]) -> bool: + if not status_fields: + return False + candidates = {field_path, key} + return any( + field in candidates or fnmatch.fnmatch(field_path, field) or fnmatch.fnmatch(key, field) + for field in status_fields + ) + + +def child_path(parent_path: str, key: str) -> str: + return f"{parent_path}.{key}" if parent_path else key + + +def list_path(parent_path: str, index: int) -> str: + return f"{parent_path}[{index}]" if parent_path else f"[{index}]" + + +def scan_structured( + data: Any, + file_rel: str, + config: dict[str, Any], + report: dict[str, Any], +) -> None: + status_fields = config["status_fields"] + forbidden_values = config["forbidden_status_values"] + warning_values = config["warning_status_values"] + evidence_markers = config["required_evidence_markers"] + + def walk(current: Any, path: str, parent: Any) -> None: + if isinstance(current, Mapping): + for key_obj, value in current.items(): + key = str(key_obj) + current_path = child_path(path, key) + if path_matches(current_path, key, status_fields): + for scalar in scalar_values(value): + value_key = scalar.casefold() + if value_key in forbidden_values: + evidence_present = object_contains_marker(parent, evidence_markers) + record = { + "file": file_rel, + "source": "structured", + "path": current_path, + "field": key, + "value": scalar, + "severity": "blocking" if not evidence_present else "observation", + "evidence_present": evidence_present, + "message": ( + "Forbidden lifecycle/status value lacks local evidence marker." + if not evidence_present + else "Lifecycle/status value has a local evidence marker; approval validity is not decided by this scan." + ), + } + if evidence_present: + observation = dict(record) + observation["kind"] = "field_evidence_present" + report["observations"].append(observation) + else: + report["field_level_findings"].append(record) + report["blocking_errors"].append(record["message"]) + report["missing_evidence_markers"].append( + { + "file": file_rel, + "path": current_path, + "required_evidence_markers": evidence_markers, + } + ) + elif value_key in warning_values: + report["warnings"].append( + { + "file": file_rel, + "source": "structured", + "path": current_path, + "field": key, + "value": scalar, + "severity": "warning", + "message": "Warning lifecycle/status value matched configured policy.", + } + ) + walk(value, current_path, value if isinstance(value, Mapping) else current) + elif isinstance(current, list): + for index, item in enumerate(current): + walk(item, list_path(path, index), item if isinstance(item, Mapping) else parent) + + walk(data, "", data) + + +def scan_phrases( + text: str, + file_rel: str, + config: dict[str, Any], + report: dict[str, Any], +) -> None: + evidence_markers = config["required_evidence_markers"] + allowed_contexts = config["allowed_contexts"] + window = config["evidence_window_chars"] + + for phrase in config["approved_phrases"]: + for index in find_phrase_matches(text, phrase): + context = make_context(text, index, len(phrase), window) + if context_has_any(context, allowed_contexts): + continue + evidence_present = context_has_any(context, evidence_markers) + line, column = line_col(text, index) + record = { + "file": file_rel, + "source": "text", + "phrase": phrase, + "line": line, + "column": column, + "severity": "blocking" if not evidence_present else "observation", + "evidence_present": evidence_present, + "context": context, + "message": ( + "Approval claim phrase lacks nearby evidence marker." + if not evidence_present + else "Approval claim phrase has nearby evidence marker; approval validity is not decided by this scan." + ), + } + if evidence_present: + observation = dict(record) + observation["kind"] = "evidence_present_claim" + report["observations"].append(observation) + else: + report["phrase_level_findings"].append(record) + report["blocking_errors"].append(record["message"]) + report["missing_evidence_markers"].append( + { + "file": file_rel, + "line": line, + "phrase": phrase, + "required_evidence_markers": evidence_markers, + } + ) + + for phrase in config["forbidden_phrases"]: + for index in find_phrase_matches(text, phrase): + context = make_context(text, index, len(phrase), window) + if context_has_any(context, allowed_contexts): + continue + line, column = line_col(text, index) + record = { + "file": file_rel, + "source": "text", + "phrase": phrase, + "line": line, + "column": column, + "severity": "blocking", + "evidence_present": context_has_any(context, evidence_markers), + "context": context, + "message": "Forbidden phrase matched configured policy.", + } + report["phrase_level_findings"].append(record) + report["blocking_errors"].append(record["message"]) + + +def empty_report(scan_root: pathlib.Path, config_path: pathlib.Path) -> dict[str, Any]: + return { + "scan_root": str(scan_root), + "config_path": str(config_path), + "files_scanned": [], + "possible_overclaims": [], + "field_level_findings": [], + "phrase_level_findings": [], + "missing_evidence_markers": [], + "warnings": [], + "blocking_errors": [], + "observations": [], + "machine_readable_summary": {}, + } + + +def finalize_report(report: dict[str, Any]) -> None: + report["possible_overclaims"] = [ + *report["field_level_findings"], + *report["phrase_level_findings"], + ] + report["machine_readable_summary"] = { + "status": "FAIL" if report["blocking_errors"] else "PASS", + "files_scanned_count": len(report["files_scanned"]), + "field_finding_count": len(report["field_level_findings"]), + "phrase_finding_count": len(report["phrase_level_findings"]), + "missing_evidence_count": len(report["missing_evidence_markers"]), + "warning_count": len(report["warnings"]), + "blocking_count": len(report["blocking_errors"]), + "observation_count": len(report["observations"]), + } + + +def write_markdown_report(report: dict[str, Any], output_path: pathlib.Path) -> None: + summary = report["machine_readable_summary"] + lines = [ + "# Lifecycle Status Guard Scan", + "", + "## Summary", + "", + f"- Status: `{summary['status']}`", + f"- Files scanned: {summary['files_scanned_count']}", + f"- Blocking findings: {summary['blocking_count']}", + f"- Warnings: {summary['warning_count']}", + f"- Evidence-present observations: {summary['observation_count']}", + "", + "## Blocking Findings", + "", + ] + if report["possible_overclaims"]: + for finding in report["possible_overclaims"]: + location = finding.get("path") or f"line {finding.get('line')}" + claim = finding.get("value") or finding.get("phrase") + lines.append(f"- `{finding['file']}` {location}: `{claim}` - {finding['message']}") + else: + lines.append("- None") + lines.extend(["", "## Warnings", ""]) + if report["warnings"]: + for warning in report["warnings"]: + lines.append( + f"- `{warning['file']}` {warning.get('path', '')}: `{warning.get('value', '')}` - {warning['message']}" + ) + else: + lines.append("- None") + lines.extend(["", "## Observations", ""]) + if report["observations"]: + for observation in report["observations"]: + location = observation.get("path") or f"line {observation.get('line')}" + claim = observation.get("value") or observation.get("phrase") + lines.append( + f"- `{observation['file']}` {location}: `{claim}` - evidence marker present; approval validity not decided." + ) + else: + lines.append("- None") + lines.extend(["", "## Files Scanned", ""]) + for file_name in report["files_scanned"]: + lines.append(f"- `{file_name}`") + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def run(scan_root: pathlib.Path, config_path: pathlib.Path, output_dir: pathlib.Path) -> int: + scan_root = scan_root.resolve() + config_path = config_path.resolve() + if not scan_root.is_dir(): + raise ValueError(f"Scan root is not a directory: {scan_root}") + if not config_path.is_file(): + raise ValueError(f"Config file does not exist: {config_path}") + + config = load_config(config_path) + report = empty_report(scan_root, config_path) + + for path in discover_files(scan_root, config["watched_paths"]): + file_rel = rel_path(path, scan_root) + text = path.read_text(encoding="utf-8", errors="replace") + report["files_scanned"].append(file_rel) + structured = parse_structured_content(path, text) + if isinstance(structured, (Mapping, list)): + scan_structured(structured, file_rel, config, report) + scan_phrases(text, file_rel, config, report) + + finalize_report(report) + output_dir.mkdir(parents=True, exist_ok=True) + json_path = output_dir / f"{REPORT_BASENAME}.json" + md_path = output_dir / f"{REPORT_BASENAME}.md" + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + write_markdown_report(report, md_path) + return 1 if report["blocking_errors"] else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Scan configured lifecycle/status claims.") + parser.add_argument("--scan-root", required=True, type=pathlib.Path) + parser.add_argument("--config", required=True, type=pathlib.Path) + parser.add_argument("--output-dir", required=True, type=pathlib.Path) + args = parser.parse_args(argv) + + try: + return run(args.scan_root, args.config, args.output_dir) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/lifecycle-status-guard-scan/tests/test_lifecycle_status_guard_scan.py b/skills/lifecycle-status-guard-scan/tests/test_lifecycle_status_guard_scan.py new file mode 100644 index 0000000..d818067 --- /dev/null +++ b/skills/lifecycle-status-guard-scan/tests/test_lifecycle_status_guard_scan.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SKILL_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = SKILL_ROOT / "scripts" / "lifecycle_status_guard_scan.py" +TMP_ROOT = REPO_ROOT / "tmp" / "lifecycle-status-guard-scan-tests" + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + text=True, + capture_output=True, + check=False, + ) + + +def write_text(path: pathlib.Path, text: str) -> pathlib.Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def write_config(path: pathlib.Path, config: dict[str, object]) -> pathlib.Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def load_report(output_dir: pathlib.Path) -> dict[str, object]: + return json.loads((output_dir / "lifecycle-status-guard-scan.json").read_text(encoding="utf-8")) + + +class LifecycleStatusGuardScanTest(unittest.TestCase): + def setUp(self) -> None: + TMP_ROOT.mkdir(parents=True, exist_ok=True) + self.tmp_dir = tempfile.TemporaryDirectory(dir=TMP_ROOT) + self.root = pathlib.Path(self.tmp_dir.name) / "scan-root" + self.output_dir = pathlib.Path(self.tmp_dir.name) / "output" + self.config_path = pathlib.Path(self.tmp_dir.name) / "config.json" + self.root.mkdir() + + def tearDown(self) -> None: + self.tmp_dir.cleanup() + + @classmethod + def tearDownClass(cls) -> None: + if TMP_ROOT.exists(): + shutil.rmtree(TMP_ROOT) + + def run_scan(self, config: dict[str, object]) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]: + write_config(self.config_path, config) + result = run_cli( + "--scan-root", + str(self.root), + "--config", + str(self.config_path), + "--output-dir", + str(self.output_dir), + ) + return result, load_report(self.output_dir) + + def test_markdown_front_matter_stable_without_evidence_is_blocking(self) -> None: + write_text( + self.root / "model-card.md", + "---\nstatus: stable\nname: QPI\n---\n\nEngineering checks passed.\n", + ) + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "status_fields": ["status"], + "forbidden_status_values": ["stable", "accepted"], + "required_evidence_markers": ["owner_decision", "ccra_review"], + } + ) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(report["machine_readable_summary"]["blocking_count"], 1) + finding = report["field_level_findings"][0] + self.assertEqual(finding["field"], "status") + self.assertEqual(finding["value"], "stable") + self.assertFalse(finding["evidence_present"]) + + def test_draft_status_is_allowed(self) -> None: + write_text(self.root / "model-card.md", "---\nstatus: draft\n---\n\nStill in draft.\n") + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "status_fields": ["status"], + "forbidden_status_values": ["stable", "accepted"], + "warning_status_values": ["candidate"], + "required_evidence_markers": ["owner_decision"], + } + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["field_level_findings"], []) + self.assertEqual(report["blocking_errors"], []) + + def test_owner_approved_phrase_without_nearby_evidence_is_blocking(self) -> None: + write_text(self.root / "handoff.md", "The model is owner-approved for stable use.\n") + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "approved_phrases": ["owner-approved"], + "required_evidence_markers": ["owner decision", "owner_decision"], + } + ) + + self.assertEqual(result.returncode, 1, result.stderr) + finding = report["phrase_level_findings"][0] + self.assertEqual(finding["phrase"], "owner-approved") + self.assertEqual(finding["severity"], "blocking") + self.assertFalse(finding["evidence_present"]) + + def test_owner_approved_phrase_with_nearby_evidence_is_observation_only(self) -> None: + write_text( + self.root / "handoff.md", + "The model is owner-approved. Owner decision: accepted in review note 2026-06-19.\n", + ) + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "approved_phrases": ["owner-approved"], + "required_evidence_markers": ["owner decision"], + } + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["blocking_errors"], []) + self.assertEqual(report["observations"][0]["kind"], "evidence_present_claim") + + def test_ccra_approved_phrase_without_review_reference_is_blocking(self) -> None: + write_text(self.root / "review.md", "Local output says ccra-approved and final.\n") + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "approved_phrases": ["ccra-approved"], + "required_evidence_markers": ["ccra review", "web ccra"], + } + ) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(report["phrase_level_findings"][0]["phrase"], "ccra-approved") + + def test_json_status_evidence_must_be_in_same_object(self) -> None: + write_text( + self.root / "models.json", + json.dumps( + { + "models": [ + {"name": "qpi", "status": "stable"}, + {"name": "other", "owner_decision": "approved elsewhere"}, + ] + }, + ensure_ascii=False, + ), + ) + + result, report = self.run_scan( + { + "watched_paths": ["**/*.json"], + "status_fields": ["status"], + "forbidden_status_values": ["stable"], + "required_evidence_markers": ["owner_decision"], + } + ) + + self.assertEqual(result.returncode, 1, result.stderr) + finding = report["field_level_findings"][0] + self.assertEqual(finding["source"], "structured") + self.assertEqual(finding["path"], "models[0].status") + self.assertFalse(finding["evidence_present"]) + + def test_json_status_with_evidence_in_same_object_is_observation_only(self) -> None: + write_text( + self.root / "models.json", + json.dumps( + {"model": {"status": "stable", "owner_decision": "owner accepted"}}, + ensure_ascii=False, + ), + ) + + result, report = self.run_scan( + { + "watched_paths": ["**/*.json"], + "status_fields": ["status"], + "forbidden_status_values": ["stable"], + "required_evidence_markers": ["owner_decision"], + } + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["blocking_errors"], []) + self.assertEqual(report["observations"][0]["kind"], "field_evidence_present") + + def test_allowed_context_exempts_phrase_but_not_structured_status_field(self) -> None: + write_text( + self.root / "policy.md", + "---\nstatus: stable\n---\n\nPolicy: do not say owner-approved without a decision.\n", + ) + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "status_fields": ["status"], + "forbidden_status_values": ["stable"], + "approved_phrases": ["owner-approved"], + "allowed_contexts": ["do not say"], + "required_evidence_markers": ["owner decision"], + } + ) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(len(report["field_level_findings"]), 1) + self.assertEqual(report["phrase_level_findings"], []) + + def test_chinese_approval_phrase_and_utf8_filename(self) -> None: + write_text(self.root / "模型状态.md", "本轮已经获得所有者批准,可以进入稳定状态。\n") + + result, report = self.run_scan( + { + "watched_paths": ["**/*.md"], + "approved_phrases": ["所有者批准"], + "required_evidence_markers": ["所有者决定记录"], + } + ) + + self.assertEqual(result.returncode, 1, result.stderr) + finding = report["phrase_level_findings"][0] + self.assertEqual(finding["phrase"], "所有者批准") + self.assertIn("模型状态.md", finding["file"]) + + def test_warning_status_value_does_not_fail_scan(self) -> None: + write_text(self.root / "model.yml", "lifecycle: candidate\n") + + result, report = self.run_scan( + { + "watched_paths": ["**/*.yml"], + "status_fields": ["lifecycle"], + "warning_status_values": ["candidate"], + } + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["warnings"][0]["severity"], "warning") + self.assertEqual(report["blocking_errors"], []) + + +if __name__ == "__main__": + unittest.main()