diff --git a/README.md b/README.md index 092db0ff9c..a11183bc2c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ specify init my-project --integration codex --ignore-agent-tools | 自动扩展 | `agent-context` | `extensions/agent-context` | 维护 AGENTS、CLAUDE、Copilot 等 agent context 文件里的 Spec Kit 受管段。 | | 默认扩展 | `arch` | `extensions/arch` | 生成或反向生成项目级 4+1 架构视图,形成架构 SSOT。 | | 默认扩展 | `discovery` | `extensions/discovery` | 在正式计划前做可行性、技术选型、旧代码评估、接口理解、PoC 和场景化技术决策。 | -| 默认扩展 | `intake` | `extensions/intake` | 把 PRD、设计稿、Figma、测试用例等来源归一化为 SDD 可消费的证据包。 | +| 默认扩展 | `intake` | `extensions/intake` | 把 PRD、设计稿、Figma、HTML SSOT、结构化 IR、测试用例等来源归一化为 SDD 可消费的证据包。 | | 默认扩展 | `preview` | `extensions/preview` | 从规格和计划生成低、中、高保真 Markdown 或自包含 HTML 预览。 | | 默认扩展 | `repository-governance` | `extensions/repository-governance` | 生成仓库治理 SSOT,帮助 agent 明确目录责任、读取顺序和事实证据。 | | 默认预设 | `workflow-preset` | `presets/workflow-preset` | 强化 BDD、NFR、UIF、设计产物、任务验证策略和 implement handoff 编排。 | @@ -147,6 +147,8 @@ specify init my-project --integration codex --ignore-agent-tools ```text /speckit.intake.prd /speckit.intake.visual-design +/speckit.intake.figma2htmlssot +/speckit.intake.ir /speckit.intake.test-cases ``` @@ -154,6 +156,7 @@ specify init my-project --integration codex --ignore-agent-tools - PRD、产品说明、Markdown、PDF、导出的文档。 - 图片、线框图、设计 PDF、Figma 文件、Figma 页面或节点。 +- Figma 派生的 HTML visual SSOT 和结构化 UI acceptance IR。 - 既有测试、Gherkin、手工测试用例、QA 导出、测试管理表格。 主要产物: @@ -161,6 +164,8 @@ specify init my-project --integration codex --ignore-agent-tools ```text specs//intake/prd/ specs//intake/visual-design/ +specs//intake/visual-design/figma2htmlssot/ +specs//intake/visual-design/structured-ir/ specs//intake/test-cases/ ``` @@ -479,6 +484,8 @@ specify bundle build --path ./my-bundle ```text /speckit.intake.prd /speckit.intake.visual-design +/speckit.intake.figma2htmlssot +/speckit.intake.ir /speckit.intake.test-cases /speckit.specify /speckit.clarify @@ -489,6 +496,8 @@ specify bundle build --path ./my-bundle ```text /speckit.intake.visual-design +/speckit.intake.figma2htmlssot +/speckit.intake.ir /speckit.specify /speckit.preview.low-md /speckit.plan diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 0e34a4e796..15d68c9c57 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -57,7 +57,7 @@ The following community-contributed extensions are available in [`catalog.commun | GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | | Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | -| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | +| Intake | Normalize PRD, design, HTML SSOT, structured IR, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4c889df9f7..cf091684e7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1404,7 +1404,7 @@ "intake": { "name": "Intake", "id": "intake", - "description": "Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts.", + "description": "Normalize PRD, design, HTML SSOT, structured IR, and test-case evidence into SDD-ready intake artifacts", "author": "bigsmartben", "version": "0.1.3", "download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.3.zip", @@ -1425,7 +1425,7 @@ ] }, "provides": { - "commands": 4, + "commands": 5, "hooks": 1 }, "tags": [ @@ -1557,12 +1557,31 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "bash", "version": ">=4.4", "required": true }, - { "name": "git", "required": true }, - { "name": "curl", "required": true }, - { "name": "jq", "required": true }, - { "name": "gitleaks", "required": false }, - { "name": "trufflehog", "required": false } + { + "name": "bash", + "version": ">=4.4", + "required": true + }, + { + "name": "git", + "required": true + }, + { + "name": "curl", + "required": true + }, + { + "name": "jq", + "required": true + }, + { + "name": "gitleaks", + "required": false + }, + { + "name": "trufflehog", + "required": false + } ] }, "provides": { @@ -3745,8 +3764,14 @@ "requires": { "speckit_version": ">=0.2.0", "tools": [ - { "name": "gh", "required": true }, - { "name": "python3", "required": true } + { + "name": "gh", + "required": true + }, + { + "name": "python3", + "required": true + } ] }, "provides": { @@ -4040,11 +4065,27 @@ "requires": { "speckit_version": ">=0.10.0", "tools": [ - { "name": "rtk", "required": false }, - { "name": "headroom", "required": false }, - { "name": "token-router", "required": false }, - { "name": "ollama", "required": false }, - { "name": "python", "version": ">=3.10", "required": false } + { + "name": "rtk", + "required": false + }, + { + "name": "headroom", + "required": false + }, + { + "name": "token-router", + "required": false + }, + { + "name": "ollama", + "required": false + }, + { + "name": "python", + "version": ">=3.10", + "required": false + } ] }, "provides": { diff --git a/extensions/catalog.json b/extensions/catalog.json index 64de6922f5..d613435f77 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -73,7 +73,7 @@ "name": "Intake", "id": "intake", "version": "0.1.3", - "description": "Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts", + "description": "Normalize PRD, design, HTML SSOT, structured IR, and test-case evidence into SDD-ready intake artifacts", "author": "bigsmartben", "repository": "https://github.com/bigsmartben/spec-kit-intake", "license": "MIT", @@ -82,7 +82,7 @@ "speckit_version": ">=0.8.10.dev0" }, "provides": { - "commands": 4, + "commands": 5, "hooks": 1 }, "tags": [ diff --git a/extensions/intake/.github/workflows/community-source-contract.yml b/extensions/intake/.github/workflows/community-source-contract.yml new file mode 100644 index 0000000000..f762462422 --- /dev/null +++ b/extensions/intake/.github/workflows/community-source-contract.yml @@ -0,0 +1,172 @@ +name: Community Source Contract + +permissions: + contents: read + +on: + pull_request: + push: + branches: ["main"] + workflow_dispatch: + +concurrency: + group: community-source-contract-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + source-contract: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install manifest validation dependencies + run: python3 -m pip install --upgrade PyYAML jsonschema + + - name: Validate source manifest contract + run: | + python3 - <<'PY' + import re + from pathlib import Path + + import yaml + + root = Path.cwd() + manifest_path = root / "extension.yml" + manifest_kind = "extension" + if not manifest_path.is_file(): + manifest_path = root / "preset.yml" + manifest_kind = "preset" + if not manifest_path.is_file(): + raise SystemExit("expected extension.yml or preset.yml") + + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit(f"{manifest_path.name} must contain a mapping") + if data.get("schema_version") != "1.0": + raise SystemExit("schema_version must be 1.0") + + meta = data.get(manifest_kind) + if not isinstance(meta, dict): + raise SystemExit(f"missing {manifest_kind} metadata") + + item_id = meta.get("id") + version = meta.get("version") + repository = meta.get("repository") + if not isinstance(item_id, str) or not re.fullmatch(r"[a-z][a-z0-9-]*", item_id): + raise SystemExit(f"invalid {manifest_kind} id: {item_id!r}") + if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise SystemExit(f"invalid semver version: {version!r}") + if not isinstance(repository, str) or not repository.startswith("https://github.com/"): + raise SystemExit("repository must be an https GitHub URL") + + for required_file in ("README.md", "LICENSE", "CHANGELOG.md"): + if not (root / required_file).is_file(): + raise SystemExit(f"missing {required_file}") + + requires = data.get("requires") + if not isinstance(requires, dict) or not requires.get("speckit_version"): + raise SystemExit("requires.speckit_version is required") + + provides = data.get("provides") + if not isinstance(provides, dict): + raise SystemExit("provides must be a mapping") + + referenced_files = [] + if manifest_kind == "extension": + commands = provides.get("commands") + if not isinstance(commands, list) or not commands: + raise SystemExit("extension provides.commands must be a non-empty list") + for command in commands: + if not isinstance(command, dict): + raise SystemExit("command entries must be mappings") + name = command.get("name") + file_name = command.get("file") + if not isinstance(name, str) or not name.startswith("speckit."): + raise SystemExit(f"invalid command name: {name!r}") + if not isinstance(file_name, str): + raise SystemExit(f"command {name!r} missing file") + referenced_files.append(file_name) + for config in provides.get("config", []) or []: + if isinstance(config, dict) and isinstance(config.get("template"), str): + referenced_files.append(config["template"]) + else: + templates = provides.get("templates") + if not isinstance(templates, list) or not templates: + raise SystemExit("preset provides.templates must be a non-empty list") + for template in templates: + if not isinstance(template, dict): + raise SystemExit("template entries must be mappings") + file_name = template.get("file") + if not isinstance(file_name, str): + raise SystemExit(f"template {template.get('name')!r} missing file") + referenced_files.append(file_name) + + missing = [path for path in referenced_files if not (root / path).is_file()] + if missing: + raise SystemExit(f"manifest references missing files: {missing}") + + tags = data.get("tags") + if not isinstance(tags, list) or not tags: + raise SystemExit("tags must be a non-empty list") + PY + + - name: Validate integration repository boundary + run: | + python3 - <<'PY' + from pathlib import Path + + blocked_patterns = ( + "gh pr create --repo " + "github/spec-kit", + "repos/" + "github/spec-kit" + "/dispatches", + "repos/" + "github/spec-kit" + "/pulls", + "github.com/" + "github/spec-kit" + "/compare", + ) + offenders = [] + for workflow in Path(".github/workflows").glob("*.yml"): + text = workflow.read_text(encoding="utf-8") + for pattern in blocked_patterns: + if pattern in text: + offenders.append(f"{workflow}: {pattern}") + if offenders: + raise SystemExit( + "source repositories must not automate direct submissions to github/spec-kit:\n" + + "\n".join(offenders) + ) + PY + + - name: Install project test dependencies + if: hashFiles('requirements-dev.txt') != '' + run: python3 -m pip install -r requirements-dev.txt + + - name: Run shell contract tests + if: hashFiles('tests/repository-first-contract.sh') != '' + run: bash tests/repository-first-contract.sh + + - name: Run extension validator + if: hashFiles('tests/validate-extension.py') != '' + run: python3 tests/validate-extension.py + + - name: Run Python tests from requirements projects + if: hashFiles('requirements-dev.txt') != '' && hashFiles('tests/test_*.py') != '' + run: | + python3 - <<'PY' + import importlib.util + import subprocess + import sys + + if importlib.util.find_spec("pytest") is not None: + subprocess.run([sys.executable, "-m", "pytest", "-q"], check=True) + else: + subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"], + check=True, + ) + PY diff --git a/extensions/intake/.github/workflows/extension-artifact.yml b/extensions/intake/.github/workflows/extension-artifact.yml new file mode 100644 index 0000000000..9d3dde8f3e --- /dev/null +++ b/extensions/intake/.github/workflows/extension-artifact.yml @@ -0,0 +1,159 @@ +name: Extension Release Artifact + +permissions: + contents: read + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Release version. Defaults to extension.yml or tag version." + required: false + type: string + +concurrency: + group: extension-release-artifact-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-verify: + runs-on: ubuntu-latest + env: + INPUT_VERSION: ${{ inputs.version || '' }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install packaging dependencies + run: python3 -m pip install --upgrade PyYAML + + - name: Build and verify extension artifact + id: artifact + run: | + python3 - <<'PY' + import os + import json + import re + import zipfile + from pathlib import Path + from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + + import yaml + + root = Path.cwd() + manifest = yaml.safe_load(Path("extension.yml").read_text(encoding="utf-8")) + extension = manifest["extension"] + extension_id = extension["id"] + manifest_version = extension["version"] + tag_version = os.environ.get("GITHUB_REF_NAME", "") + if tag_version.startswith("v"): + tag_version = tag_version[1:] + input_version = os.environ.get("INPUT_VERSION", "") + version = input_version or tag_version or manifest_version + + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise SystemExit(f"version must use X.Y.Z format: {version}") + if tag_version and version != tag_version: + raise SystemExit(f"version {version} does not match tag v{tag_version}") + if version != manifest_version: + raise SystemExit(f"version {version} does not match extension.yml {manifest_version}") + + include_roots = [ + "extension.yml", + "README.md", + "CHANGELOG.md", + "LICENSE", + "commands", + "scripts", + "templates", + "schemas", + "config-template.yml", + ] + forbidden_prefixes = (".git/", ".github/", "tests/", "docs/superpowers/") + files = [] + for item in include_roots: + path = root / item + if path.is_file(): + files.append(path) + elif path.is_dir(): + files.extend(file for file in path.rglob("*") if file.is_file()) + + zip_name = f"{extension_id}-v{version}.zip" + with ZipFile(zip_name, "w", compression=ZIP_DEFLATED) as archive: + for file_path in sorted(files, key=lambda path: path.relative_to(root).as_posix()): + name = file_path.relative_to(root).as_posix() + if name.startswith(forbidden_prefixes): + continue + info = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, file_path.read_bytes()) + + required = {"extension.yml", "README.md", "LICENSE"} + for command in manifest["provides"]["commands"]: + required.add(command["file"]) + for config in manifest["provides"].get("config", []) or []: + template = config.get("template") + if template: + required.add(template) + + with zipfile.ZipFile(zip_name) as archive: + names = set(archive.namelist()) + missing = sorted(required - names) + if missing: + raise SystemExit(f"artifact missing required entries: {missing}") + forbidden = sorted(name for name in names if name.startswith(forbidden_prefixes)) + if forbidden: + raise SystemExit(f"artifact contains forbidden entries: {forbidden[:10]}") + + repository = os.environ["GITHUB_REPOSITORY"] + source_commit = os.environ["GITHUB_SHA"] + if tag_version: + download_url = f"https://github.com/{repository}/archive/refs/tags/v{version}.zip" + else: + download_url = f"https://github.com/{repository}/archive/{source_commit}.zip" + provenance = { + "repository_url": f"https://github.com/{repository}", + "release_version": version, + "source_commit_sha": source_commit, + "download_url": download_url, + "validation_evidence": { + "workflow": os.environ["GITHUB_WORKFLOW"], + "run_id": os.environ["GITHUB_RUN_ID"], + "artifact_name": zip_name, + "manifest_version": manifest_version, + "required_entries_verified": sorted(required), + "forbidden_prefixes_checked": list(forbidden_prefixes), + }, + } + provenance_name = "release-provenance.json" + Path(provenance_name).write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"zip_name={zip_name}\n") + output.write(f"provenance_name={provenance_name}\n") + PY + + - name: Upload verified artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.zip_name }} + path: ${{ steps.artifact.outputs.zip_name }} + + - name: Upload release provenance + uses: actions/upload-artifact@v4 + with: + name: release-provenance + path: ${{ steps.artifact.outputs.provenance_name }} diff --git a/extensions/intake/CHANGELOG.md b/extensions/intake/CHANGELOG.md index 127ac0187c..f4a3af4214 100644 --- a/extensions/intake/CHANGELOG.md +++ b/extensions/intake/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Added + +- Added `/speckit.intake.ir` for structured UI acceptance IR with CI-friendly DOM, ARIA, token, state, locator, relation, and assertion evidence. +- Added structured IR schemas and `validate_structured_ir_intake.py` for source readiness, cross-reference, provider-evidence, product-ambiguity, locator, downstream-ownership, and CI assertion checks. + ## [0.1.3] - 2026-06-29 ### Added diff --git a/extensions/intake/README.md b/extensions/intake/README.md index 28ccd96b4a..b8d697d29d 100644 --- a/extensions/intake/README.md +++ b/extensions/intake/README.md @@ -1,6 +1,6 @@ # Spec Kit Intake Extension -Extract, normalize, and validate SDD-ready intake artifacts from PRDs, visual designs, Figma-derived HTML visual SSOT bundles, test cases, and other software sources before downstream Spec Kit workflows project them into requirements. +Extract, normalize, and validate SDD-ready intake artifacts from PRDs, visual designs, Figma-derived HTML visual SSOT bundles, structured UI acceptance IR, test cases, and other software sources before downstream Spec Kit workflows project them into requirements. The first goal of intake is not to generate requirements. It is to preserve as much input information as possible and turn it into structured material that SDD `specify` can consume accurately. @@ -16,6 +16,7 @@ Intake artifacts are validated in two layers: JSON Schema checks enforce the req - PDF design packs and annotated review documents - Figma files, pages, frames, nodes, components, variables, and exported screenshots - Figma-derived HTML visual SSOT bundles with traceable component-state and page coverage +- Structured UI acceptance IR with CI-friendly DOM, ARIA, token, state, locator, relation, and assertion facts - Existing test cases, Gherkin files, QA exports, and test management spreadsheets ## Intake Scenario Coverage @@ -26,7 +27,8 @@ Intake commands are organized by vertical source domain. Each domain uses the sa | --- | --- | --- | --- | | PRD | product briefs, Markdown PRDs, exported docs, PDFs, issue or epic links, mixed stakeholder notes | `prd-intake.yaml` | source identity, product intent traceability, scope boundaries, acceptance evidence, clarification gaps | | Visual design | static images, wireframes, PDF design packs, Markdown design briefs, Figma files or selected nodes | `visual-requirements.yaml` | source integrity, fidelity rules, visual requirement traceability, parity planning, Figma metadata completeness when relevant | -| Figma to HTML SSOT | Figma files or selected nodes projected into runnable HTML visual acceptance surfaces | `visual-spec.html` | Figma node coverage, component-instance-state coverage, page coverage, asset traceability, viewport screenshots, known gaps | +| Figma to HTML SSOT | Figma files or selected nodes projected into runnable HTML visual acceptance surfaces | `visual-spec.html` | Figma node coverage, component-state coverage, page coverage, asset traceability, viewport screenshots, known gaps | +| Structured UI acceptance IR | visual design evidence and optional HTML SSOT enhancement refs projected into deterministic UI acceptance facts | `structured-ir.yaml` and `ir-assertions.yaml` | source traceability, provider/product gap separation, provider-neutral locators, DOM/ARIA/token/state/relation assertions, CI-low-cost readiness | | Test cases | automated tests, Gherkin files, manual QA cases, spreadsheets, test management exports, bug or issue repro steps | `test-case-intake.yaml` | scenario traceability, assertion extraction, fixture evidence, coverage gaps, flaky or skipped case reporting | Vertical instructions should never convert source evidence directly into downstream-owned requirement IDs, implementation tasks, or code component names. They produce provider-neutral intake facts that downstream workflows can consume with source refs intact. @@ -35,6 +37,7 @@ Vertical instructions should never convert source evidence directly into downstr - `/speckit.intake.visual-design` captures or validates visual design evidence, source manifests, Figma metadata when available, inventories, and readiness for the active feature. - `/speckit.intake.figma2htmlssot` creates or validates a Figma-derived HTML visual SSOT bundle with node, component-state, page, asset, viewport, and screenshot coverage. +- `/speckit.intake.ir` creates or validates structured UI acceptance IR for CI-friendly DOM, ARIA, token, state, locator, relation, and assertion checks. - `/speckit.intake.prd` captures or validates PRD evidence and normalizes product intent, scope, business rules, acceptance criteria, and clarification items. - `/speckit.intake.test-cases` captures or validates test case evidence and normalizes scenarios, assertions, fixtures, and coverage gaps. @@ -62,6 +65,10 @@ specs//intake/ │ ├── coverage-report.md │ ├── known-gaps.md │ └── screenshots/ +│ └── structured-ir/ +│ ├── structured-ir.yaml +│ ├── ir-assertions.yaml +│ └── ir-evidence-packet.md └── test-cases/ ├── source-manifest.yaml ├── source-files/ @@ -71,10 +78,11 @@ specs//intake/ Figma metadata artifacts are required for Figma visual-design sources. Image, PDF, and Markdown visual-design sources use `design-source-manifest.yaml`, source-file checksums, extracted visual requirements, and visual parity evidence instead. PRD and test-case domains use their own source manifests and normalized intake files. -Machine-readable JSON Schemas live under `templates/schemas/` and are used by the validators before readiness rules run. HTML SSOT bundles use `figma-map.schema.json`, `assets-manifest.schema.json`, and `html-ssot-coverage.schema.json`. +Machine-readable JSON Schemas live under `templates/schemas/` and are used by the validators before readiness rules run. HTML SSOT bundles use `figma-map.schema.json`, `assets-manifest.schema.json`, and `html-ssot-coverage.schema.json`. Structured IR uses `structured-ir.schema.json` and `ir-assertions.schema.json`. All intake commands provide capture instructions, evidence contracts, and readiness validation. Visual design validation additionally checks visual fidelity and Figma metadata parity. HTML SSOT validation is owned by `scripts/python/validate_html_ssot.py`, including cross-file checks for selectors, assets, screenshots, coverage, and known gaps. +Structured IR validation is owned by `scripts/python/validate_structured_ir_intake.py`, including source readiness, schema, cross-reference, locator, downstream-ownership, provider-evidence, product-ambiguity, and CI assertion checks. ## Requirements @@ -98,6 +106,14 @@ From a Spec Kit project: specify extension add intake --from https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.3.zip ``` +Release artifacts must include source-backed provenance for the `bigsmartben/spec-kit` integration fork. The release workflow uploads `release-provenance.json` with: + +- `repository_url` +- `release_version` +- `source_commit_sha` +- `download_url` +- `validation_evidence` + Then run: ```text @@ -105,6 +121,8 @@ Then run: /speckit.intake.visual-design validate /speckit.intake.figma2htmlssot build /speckit.intake.figma2htmlssot validate +/speckit.intake.ir build +/speckit.intake.ir validate /speckit.intake.prd capture /speckit.intake.prd validate /speckit.intake.test-cases capture @@ -146,7 +164,21 @@ Figma-derived HTML SSOT passes only when: - screenshot coverage and visual-diff status are recorded - known gaps are explicit and no blocking gap remains unresolved -The HTML SSOT validator emits blocker codes such as `HTML_SSOT_REQUIRED_ARTIFACT_MISSING`, `HTML_SSOT_FIGMA_NODE_COVERAGE_INCOMPLETE`, `HTML_SSOT_COMPONENT_STATE_COVERAGE_INCOMPLETE`, `HTML_SSOT_PAGE_COVERAGE_INCOMPLETE`, `HTML_SSOT_ASSET_TRACEABILITY_INCOMPLETE`, `HTML_SSOT_VIEWPORT_CAPTURE_INCOMPLETE`, `HTML_SSOT_VISUAL_DIFF_BLOCKED`, and `HTML_SSOT_KNOWN_GAP_UNRESOLVED`. +The HTML SSOT validator emits blocker codes such as `HTML_SSOT_SOURCE_INTAKE_BLOCKED`, `HTML_SSOT_REQUIRED_ARTIFACT_MISSING`, `HTML_SSOT_SCHEMA_INVALID`, `HTML_SSOT_FIGMA_NODE_COVERAGE_INCOMPLETE`, `HTML_SSOT_COMPONENT_STATE_COVERAGE_INCOMPLETE`, `HTML_SSOT_PAGE_COVERAGE_INCOMPLETE`, `HTML_SSOT_ASSET_TRACEABILITY_INCOMPLETE`, `HTML_SSOT_VIEWPORT_CAPTURE_INCOMPLETE`, `HTML_SSOT_VISUAL_DIFF_BLOCKED`, and `HTML_SSOT_KNOWN_GAP_UNRESOLVED`. + +## Structured IR Readiness Gate + +Structured UI acceptance IR passes only when: + +- upstream visual-design intake is ready +- `structured-ir.yaml`, `ir-assertions.yaml`, and `ir-evidence-packet.md` exist +- IR items preserve source refs, page, region, role, state, viewport, provider-neutral locator strategy, expectations, acceptance intent, confidence, status, and blockers +- assertions reference existing IR items and include ready `ci_low_cost` checks +- missing provider evidence and product ambiguity are represented with distinct blocker paths +- locator strategies avoid implementation-owned CSS selectors, XPath, generated class names, downstream test IDs, code component names, tasks, or requirement IDs +- HTML SSOT, screenshots, and visual diffs remain enhancement evidence rather than the primary acceptance substrate + +The structured IR validator emits blocker codes such as `IR_SOURCE_INTAKE_BLOCKED`, `IR_REQUIRED_ARTIFACT_MISSING`, `IR_SCHEMA_INVALID`, `IR_INTAKE_INCOMPLETE`, `IR_PROVIDER_EVIDENCE_MISSING`, `IR_PRODUCT_AMBIGUITY_UNRESOLVED`, `IR_ASSERTION_COVERAGE_INCOMPLETE`, `IR_LOCATOR_STRATEGY_INVALID`, `IR_DOWNSTREAM_OWNERSHIP_LEAK`, and `IR_READY_WITHOUT_EVIDENCE`. ## Development @@ -168,6 +200,12 @@ Validate HTML SSOT bundles: python scripts/python/validate_html_ssot.py specs//intake/visual-design/figma2htmlssot ``` +Validate structured IR artifacts: + +```bash +python scripts/python/validate_structured_ir_intake.py specs//intake/visual-design/structured-ir +``` + Validate PRD artifacts: ```bash diff --git a/extensions/intake/commands/speckit.intake.figma2htmlssot.md b/extensions/intake/commands/speckit.intake.figma2htmlssot.md index 9a4733a15c..ba83928002 100644 --- a/extensions/intake/commands/speckit.intake.figma2htmlssot.md +++ b/extensions/intake/commands/speckit.intake.figma2htmlssot.md @@ -17,7 +17,7 @@ Classify the input before proceeding: ## Goal -Create, update, or validate a Figma-to-HTML visual single source of truth (SSOT) bundle for the active Spec Kit feature. The HTML bundle defines visual requirements and acceptance surfaces by preserving Figma traceability, source coverage, component-instance-state granularity, page-level composition, assets, responsive behavior, and known gaps. +Create, update, or validate a Figma-to-HTML visual single source of truth (SSOT) bundle for the active Spec Kit feature. The HTML bundle defines visual requirements and acceptance surfaces by preserving Figma traceability, source coverage, component-state granularity, page-level composition, assets, responsive behavior, and known gaps. Default output directory: @@ -53,11 +53,11 @@ component instance + state + content sample + container constraint + viewport Use this hierarchy: -- Component-instance-state: minimum runnable screenshot and comparison unit for tokens, local layout, states, overflow, and local interaction surfaces. +- Component-state: minimum runnable screenshot and comparison unit for tokens, local layout, states, overflow, and local interaction surfaces. - Section: composition unit for spacing, ordering, alignment, and local responsive behavior across multiple component instances. - Page: final release gate for information completeness, cross-section layout, first-viewport experience, scrolling, and target runtime acceptance. -Component-level acceptance cannot replace page-level acceptance. Page-level acceptance cannot replace required component-instance-state coverage. +Component-level acceptance cannot replace page-level acceptance. Page-level acceptance cannot replace required component-state coverage. ## Context Loading @@ -105,7 +105,7 @@ data-required="true" 5. Preserve design tokens and visual facts in CSS custom properties or documented token mappings when available. Record unmapped tokens as gaps instead of silently flattening them. 6. Export or reference assets through `assets-manifest.json`; do not embed untraceable base64 assets unless the source ref and checksum are recorded. -7. Capture target runtime screenshots for every required component-instance-state, section, and page surface across the declared viewport set. +7. Capture target runtime screenshots for every required component-state, section, and page surface across the declared viewport set. 8. Compare Figma and HTML screenshots when tooling is available. Record thresholds, accepted exceptions, and blocking difference categories in `coverage-report.md`. 9. Validate the HTML SSOT bundle before reporting readiness: @@ -126,20 +126,11 @@ python .specify/extensions/intake/scripts/python/validate_html_ssot.py /intake/visual-design/structured-ir/ +``` + +Normative authority: + +- `templates/intake-visual-design-contract.md` defines visual source evidence semantics. +- `templates/intake-structured-ir-contract.md` defines structured IR semantics, boundary, readiness, and blocker codes. +- `templates/schemas/structured-ir.schema.json` defines the machine-readable IR records. +- `templates/schemas/ir-assertions.schema.json` defines CI-friendly assertion records. +- `scripts/python/validate_structured_ir_intake.py` defines readiness validation. +- This command owns structured IR routing, capture orchestration, validation invocation, and report shape. + +## Operating Boundaries + +- Treat structured IR as the primary low-cost UI acceptance substrate. +- Treat HTML SSOT, screenshots, and visual diffs as enhancement evidence only; do not make them the primary acceptance contract. +- Preserve original source refs, visual requirement refs, DES refs, optional HTML SSOT refs, and explicit blocker refs. +- Do not generate downstream-owned requirement IDs, tasks, code component names, implementation-owned selectors, final test framework files, or product semantics. +- Do not infer business rules, permissions, validation behavior, analytics, data sources, security, or compliance behavior from visual appearance alone. +- Distinguish missing provider evidence from product ambiguity with separate blocker codes and evidence records. +- If required source evidence is unavailable, create a blocked `ir-evidence-packet.md` and stop before claiming readiness. + +## Completeness Units + +The minimum structured acceptance unit is: + +```text +IR item + source ref + semantic locator strategy + expectation + assertion + viewport/state +``` + +Use this hierarchy: + +- IR item: provider-neutral DOM, ARIA, token, state, content, relation, and locator-strategy fact. +- Assertion: low-cost check over one or more IR items. +- CI substrate: the set of ready assertions with `ci_suitability: ci_low_cost`. + +## Context Loading + +1. Verify the current directory is a Spec Kit project by checking for `.specify/`, unless `$ARGUMENTS` points to a standalone artifact directory for extension development. +2. Identify the active feature: + - Prefer `SPECIFY_FEATURE` when set. + - Otherwise use the current Git branch name when it matches a directory under `specs/`. + - Otherwise inspect `specs/` and choose the most recent feature directory only if there is a single clear candidate. + - If the feature cannot be identified and no standalone artifact directory was provided, stop and ask the user to set `SPECIFY_FEATURE` or run from the feature branch. +3. Read `.specify/extensions/intake/intake-config.yml` when present. +4. Read `templates/intake-visual-design-contract.md`, `templates/intake-structured-ir-contract.md`, and existing visual-design intake artifacts before creating or validating IR artifacts. +5. Read optional `figma2htmlssot/` artifacts only as enhancement evidence. +6. Read any existing structured IR artifacts and preserve stable valid IR IDs unless the source ref or normalized fact changes. + +## Mode Routing + +- Build mode: use when `$ARGUMENTS` names source refs, visual requirement refs, target viewports, required states, semantic regions, DOM/ARIA/token expectations, or asks to build, generate, derive, update, or recapture IR. +- Validate mode: use when `$ARGUMENTS` includes `validate`, `check`, `gate`, `assertions`, `CI`, `readiness`, or only names an existing structured IR output directory. +- Build then validate: use when both source and validation intent are present, or after build artifacts are updated. + +## Build Procedure + +1. Resolve the upstream visual-design intake directory and target structured IR output directory. +2. Ensure upstream visual-design intake passes readiness: + +```bash +python .specify/extensions/intake/scripts/python/validate_visual_design_intake.py +``` + +3. Create or update: + - `structured-ir.yaml`: source-backed UI acceptance facts. + - `ir-assertions.yaml`: low-cost assertions over IR items. + - `ir-evidence-packet.md`: readiness summary, blocker separation, and next corrective action. +4. For each IR item, record stable `IR-*` ID, source refs, optional visual requirement refs, page, region, role, state, viewport, provider-neutral locator strategy, expectations, acceptance intent, confidence, status, and blockers. +5. For each assertion, record stable `IRA-*` ID, `IR-*` refs, assertion type, expected value, evidence refs, CI suitability, status, and blockers. +6. Record missing provider evidence as `IR_PROVIDER_EVIDENCE_MISSING`. +7. Record unresolved product ambiguity as `IR_PRODUCT_AMBIGUITY_UNRESOLVED`. +8. Record locator or ownership violations as `IR_LOCATOR_STRATEGY_INVALID` or `IR_DOWNSTREAM_OWNERSHIP_LEAK`. +9. Validate before reporting readiness: + +```bash +python .specify/extensions/intake/scripts/python/validate_structured_ir_intake.py +``` + +## Validation Procedure + +1. Resolve the structured IR output directory from `$ARGUMENTS` or the active feature. +2. Run: + +```bash +python .specify/extensions/intake/scripts/python/validate_structured_ir_intake.py +``` + +3. The validator confirms: + - upstream visual-design intake is ready + - required artifacts exist + - schemas validate + - item and assertion counts match actual arrays + - assertions reference existing IR items + - source refs and provider evidence are complete + - product ambiguities are explicit and not collapsed into missing provider evidence + - locator strategies are provider-neutral + - no downstream-owned requirement IDs, tasks, code component names, implementation-owned selectors, or product semantics leak into the IR + - at least one ready `ci_low_cost` assertion exists + - `ir-evidence-packet.md` readiness is PASS only when no blocking issue remains + +4. Apply these blocker codes when validation fails: + - `IR_SOURCE_INTAKE_BLOCKED` + - `IR_REQUIRED_ARTIFACT_MISSING` + - `IR_SCHEMA_INVALID` + - `IR_INTAKE_INCOMPLETE` + - `IR_PROVIDER_EVIDENCE_MISSING` + - `IR_PRODUCT_AMBIGUITY_UNRESOLVED` + - `IR_ASSERTION_COVERAGE_INCOMPLETE` + - `IR_LOCATOR_STRATEGY_INVALID` + - `IR_DOWNSTREAM_OWNERSHIP_LEAK` + - `IR_READY_WITHOUT_EVIDENCE` + +## Readiness Authority + +Use this precedence when sources disagree: + +1. Upstream visual-design intake is canonical for source evidence and limitations. +2. `structured-ir.yaml` is canonical for provider-neutral UI acceptance facts. +3. `ir-assertions.yaml` is canonical for low-cost CI assertion records. +4. HTML SSOT, screenshots, and visual diffs are enhancement evidence only. +5. `ir-evidence-packet.md` explains readiness, accepted exceptions, and blockers for human review. + +Do not promote structured IR as ready when provider evidence is missing, product ambiguity is unresolved, assertions are not CI-suitable, or downstream implementation ownership has leaked into the intake layer. + +## Report + +Return: + +- mode executed: build, validate, or build_then_validate +- output or validated directory +- upstream visual-design intake readiness +- IR item count +- assertion count +- ready CI-low-cost assertion count +- provider evidence gaps +- product ambiguity gaps +- locator or ownership violations +- readiness result +- blocker codes +- next corrective action when blocked diff --git a/extensions/intake/config-template.yml b/extensions/intake/config-template.yml index f525db136b..3e350cdd1c 100644 --- a/extensions/intake/config-template.yml +++ b/extensions/intake/config-template.yml @@ -4,6 +4,7 @@ artifacts: base_dir: "specs/{feature}/intake" visual_design_dir: "visual-design" figma2htmlssot_dir: "figma2htmlssot" + structured_ir_dir: "structured-ir" prd_dir: "prd" test_cases_dir: "test-cases" prd_source_manifest: "source-manifest.yaml" @@ -20,10 +21,16 @@ artifacts: html_ssot_known_gaps: "known-gaps.md" html_ssot_screenshots_dir: "screenshots" html_ssot_validator: "scripts/python/validate_html_ssot.py" + structured_ir: "structured-ir.yaml" + structured_ir_assertions: "ir-assertions.yaml" + structured_ir_evidence_packet: "ir-evidence-packet.md" + structured_ir_validator: "scripts/python/validate_structured_ir_intake.py" prd_contract: "templates/intake-prd-contract.md" prd_evidence_packet_template: "templates/intake-prd-evidence-packet-template.md" visual_design_contract: "templates/intake-visual-design-contract.md" visual_design_evidence_packet_template: "templates/intake-visual-design-evidence-packet-template.md" + structured_ir_contract: "templates/intake-structured-ir-contract.md" + structured_ir_evidence_packet_template: "templates/intake-structured-ir-evidence-packet-template.md" test_cases_contract: "templates/intake-test-cases-contract.md" test_cases_evidence_packet_template: "templates/intake-test-cases-evidence-packet-template.md" schemas_dir: "templates/schemas" @@ -38,6 +45,8 @@ artifacts: html_ssot_figma_map_schema: "templates/schemas/figma-map.schema.json" html_ssot_assets_manifest_schema: "templates/schemas/assets-manifest.schema.json" html_ssot_coverage_schema: "templates/schemas/html-ssot-coverage.schema.json" + structured_ir_schema: "templates/schemas/structured-ir.schema.json" + structured_ir_assertions_schema: "templates/schemas/ir-assertions.schema.json" metadata_glob: "figma-metadata.part-*.xml" metadata_index: "figma-metadata.index.yaml" node_inventory: "figma-node-inventory.yaml" @@ -65,6 +74,11 @@ readiness: - "high" require_source_integrity: true require_visual_requirements: true + require_structured_ir: true + require_structured_ir_source_refs: true + require_structured_ir_provider_evidence: true + require_structured_ir_ci_assertions: true + require_structured_ir_provider_product_gap_separation: true require_visual_source_refs: true require_fidelity_rules_applied: true require_visual_parity_plan: true diff --git a/extensions/intake/extension.yml b/extensions/intake/extension.yml index 329086788b..8b7a570297 100644 --- a/extensions/intake/extension.yml +++ b/extensions/intake/extension.yml @@ -4,7 +4,7 @@ extension: id: intake name: "Intake" version: "0.1.3" - description: "Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts" + description: "Normalize PRD, design, HTML SSOT, structured IR, and test-case evidence into SDD-ready intake artifacts" author: "bigsmartben" repository: "https://github.com/bigsmartben/spec-kit-intake" homepage: "https://github.com/bigsmartben/spec-kit-intake" @@ -26,6 +26,9 @@ provides: - name: "speckit.intake.figma2htmlssot" file: "commands/speckit.intake.figma2htmlssot.md" description: "Create or validate a Figma-derived HTML visual SSOT bundle with node, component-state, page, asset, viewport, and screenshot coverage" + - name: "speckit.intake.ir" + file: "commands/speckit.intake.ir.md" + description: "Create or validate structured UI acceptance IR for CI-friendly DOM, ARIA, token, state, locator, relation, and assertion checks" - name: "speckit.intake.prd" file: "commands/speckit.intake.prd.md" description: "Capture or validate PRD evidence and normalize product intent, scope, rules, and acceptance inputs for SDD workflows" @@ -58,6 +61,7 @@ defaults: base_dir: "specs/{feature}/intake" visual_design_dir: "visual-design" figma2htmlssot_dir: "figma2htmlssot" + structured_ir_dir: "structured-ir" prd_dir: "prd" test_cases_dir: "test-cases" prd_source_manifest: "source-manifest.yaml" @@ -74,10 +78,16 @@ defaults: html_ssot_known_gaps: "known-gaps.md" html_ssot_screenshots_dir: "screenshots" html_ssot_validator: "scripts/python/validate_html_ssot.py" + structured_ir: "structured-ir.yaml" + structured_ir_assertions: "ir-assertions.yaml" + structured_ir_evidence_packet: "ir-evidence-packet.md" + structured_ir_validator: "scripts/python/validate_structured_ir_intake.py" prd_contract: "templates/intake-prd-contract.md" prd_evidence_packet_template: "templates/intake-prd-evidence-packet-template.md" visual_design_contract: "templates/intake-visual-design-contract.md" visual_design_evidence_packet_template: "templates/intake-visual-design-evidence-packet-template.md" + structured_ir_contract: "templates/intake-structured-ir-contract.md" + structured_ir_evidence_packet_template: "templates/intake-structured-ir-evidence-packet-template.md" test_cases_contract: "templates/intake-test-cases-contract.md" test_cases_evidence_packet_template: "templates/intake-test-cases-evidence-packet-template.md" schemas_dir: "templates/schemas" @@ -92,6 +102,8 @@ defaults: html_ssot_figma_map_schema: "templates/schemas/figma-map.schema.json" html_ssot_assets_manifest_schema: "templates/schemas/assets-manifest.schema.json" html_ssot_coverage_schema: "templates/schemas/html-ssot-coverage.schema.json" + structured_ir_schema: "templates/schemas/structured-ir.schema.json" + structured_ir_assertions_schema: "templates/schemas/ir-assertions.schema.json" metadata_glob: "figma-metadata.part-*.xml" metadata_index: "figma-metadata.index.yaml" node_inventory: "figma-node-inventory.yaml" @@ -118,6 +130,11 @@ defaults: - "high" require_source_integrity: true require_visual_requirements: true + require_structured_ir: true + require_structured_ir_source_refs: true + require_structured_ir_provider_evidence: true + require_structured_ir_ci_assertions: true + require_structured_ir_provider_product_gap_separation: true require_visual_source_refs: true require_fidelity_rules_applied: true require_visual_parity_plan: true @@ -129,3 +146,13 @@ defaults: fail_on_unbounded_inference_for_figma: true fail_on_unsupported_visual_claims: true fail_on_blocker_lint_errors: true + capture: + preserve_original_sources: true + source_file_dir: "source-files" + prd_intake_file: "prd-intake.yaml" + visual_requirements_file: "visual-requirements.yaml" + test_case_intake_file: "test-case-intake.yaml" + shard_prefix: "figma-metadata.part-" + preserve_raw_metadata: true + allow_screenshot_only_intake: true + default_screenshot_level: "L0" diff --git a/extensions/intake/scripts/python/intake_validator_common.py b/extensions/intake/scripts/python/intake_validator_common.py index fa9448f37b..65460aaed0 100644 --- a/extensions/intake/scripts/python/intake_validator_common.py +++ b/extensions/intake/scripts/python/intake_validator_common.py @@ -149,6 +149,8 @@ def validate_source_manifest( "source_integrity_complete": manifest.get("source_integrity_complete"), "captured_at": manifest.get("captured_at"), "capture_method": manifest.get("capture_method"), + "snapshot_status": manifest.get("snapshot_status"), + "integrity_gap_reason": manifest.get("integrity_gap_reason"), "missing_required_fields": missing_manifest_fields, } @@ -196,21 +198,32 @@ def validate_source_files( return validated_files: list[dict[str, Any]] = [] + manifest_remote_gap = ( + str(manifest.get("snapshot_status") or "").strip() in {"not_available", "not_required"} + and non_empty(manifest.get("integrity_gap_reason")) + ) for source_file in source_files: if not isinstance(source_file, dict): blocker_codes.append(missing_file_code) continue + rel_path = str(source_file.get("path") or "").strip() + remote_ref = is_remote_ref(rel_path) + remote_gap_fields = {"byte_size", "sha256"} if remote_ref and manifest_remote_gap else set() missing_source_file_fields = [ - field for field in required_source_file_fields if field not in source_file + field + for field in required_source_file_fields + if field not in source_file and field not in remote_gap_fields ] - rel_path = str(source_file.get("path") or "").strip() expected = str(source_file.get("sha256") or "").replace("sha256:", "").strip() file_detail: dict[str, Any] = { "path": rel_path, "exists": False, "sha256_match": None, "missing_required_fields": missing_source_file_fields, + "checksum_status": source_file.get("checksum_status"), + "snapshot_status": manifest.get("snapshot_status"), + "integrity_gap_reason": manifest.get("integrity_gap_reason"), } if missing_source_file_fields: @@ -221,7 +234,7 @@ def validate_source_files( validated_files.append(file_detail) continue - if is_remote_ref(rel_path): + if remote_ref: file_detail["exists"] = True file_detail["remote_ref"] = True validated_files.append(file_detail) @@ -337,20 +350,6 @@ def parse_evidence_packet_status(evidence_text: str) -> dict[str, Any]: result["ready_gate"] = ready_gate return result - ready_match = re.search( - r"^\s*[-*]?\s*ready[_ ]gate:\s*(PASS|BLOCKED)\s*$", - text, - re.IGNORECASE | re.MULTILINE, - ) - if ready_match: - ready_gate = ready_match.group(1).upper() - result["ready_gate"] = ready_gate - result["metadata"] = {"ready_gate": ready_gate, "blockers": []} - result["warnings"].append( - "evidence packet uses legacy Markdown ready_gate; add YAML front matter metadata" - ) - return result - result["errors"].append("evidence packet readiness metadata not found") return result diff --git a/extensions/intake/scripts/python/validate_structured_ir_intake.py b/extensions/intake/scripts/python/validate_structured_ir_intake.py new file mode 100644 index 0000000000..d35c39e27f --- /dev/null +++ b/extensions/intake/scripts/python/validate_structured_ir_intake.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +"""Validate Spec Kit structured UI acceptance IR intake artifacts.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Any + +from intake_validator_common import ( + emit, + is_truthy, + load_yaml, + non_empty, + parse_evidence_packet_status, + validate_json_schema, +) + + +BLOCKERS = { + "SOURCE_INTAKE_BLOCKED": "IR_SOURCE_INTAKE_BLOCKED", + "REQUIRED_ARTIFACT_MISSING": "IR_REQUIRED_ARTIFACT_MISSING", + "SCHEMA_INVALID": "IR_SCHEMA_INVALID", + "INTAKE_INCOMPLETE": "IR_INTAKE_INCOMPLETE", + "PROVIDER_EVIDENCE_MISSING": "IR_PROVIDER_EVIDENCE_MISSING", + "PRODUCT_AMBIGUITY_UNRESOLVED": "IR_PRODUCT_AMBIGUITY_UNRESOLVED", + "ASSERTION_COVERAGE_INCOMPLETE": "IR_ASSERTION_COVERAGE_INCOMPLETE", + "LOCATOR_STRATEGY_INVALID": "IR_LOCATOR_STRATEGY_INVALID", + "DOWNSTREAM_OWNERSHIP_LEAK": "IR_DOWNSTREAM_OWNERSHIP_LEAK", + "READY_WITHOUT_EVIDENCE": "IR_READY_WITHOUT_EVIDENCE", +} + +FORBIDDEN_FIELD_NAMES = { + "requirement_id", + "requirement_ids", + "task_id", + "task_ids", + "implementation_task", + "implementation_tasks", + "code_component", + "code_components", + "component_name", + "component_names", + "css_selector", + "xpath", + "selector", +} +FORBIDDEN_LOCATOR_PATTERNS = [ + re.compile(r"^\s*[.#][A-Za-z0-9_-]+"), + re.compile(r"^\s*//"), + re.compile(r"^\s*/html\b"), + re.compile(r"\b(css|xpath|querySelector)\b", re.IGNORECASE), +] +PRODUCT_AMBIGUITY_MARKERS = { + "PRODUCT_AMBIGUITY", + "PRODUCT_AMBIGUITY_UNRESOLVED", + "IR_PRODUCT_AMBIGUITY_UNRESOLVED", +} +PROVIDER_EVIDENCE_MARKERS = { + "MISSING_PROVIDER_EVIDENCE", + "PROVIDER_EVIDENCE_MISSING", + "IR_PROVIDER_EVIDENCE_MISSING", +} +LOCATOR_MARKERS = {"LOCATOR_STRATEGY_INVALID", "IR_LOCATOR_STRATEGY_INVALID"} +OWNERSHIP_MARKERS = {"DOWNSTREAM_OWNERSHIP_LEAK", "IR_DOWNSTREAM_OWNERSHIP_LEAK"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ir_dir", help="Directory containing structured IR artifacts") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON") + args = parser.parse_args() + + ir_dir = Path(args.ir_dir) + blocker_codes: list[str] = [] + warnings: list[str] = [] + details: dict[str, Any] = {"ir_dir": str(ir_dir)} + + if not ir_dir.exists() or not ir_dir.is_dir(): + blocker_codes.extend( + [ + BLOCKERS["SOURCE_INTAKE_BLOCKED"], + BLOCKERS["REQUIRED_ARTIFACT_MISSING"], + BLOCKERS["READY_WITHOUT_EVIDENCE"], + ] + ) + return emit( + label="Structured IR", + json_mode=args.json, + details=details, + blockers=blocker_codes, + warnings=warnings, + ) + + validate_source_intake(ir_dir, details, blocker_codes) + ir_doc = validate_structured_ir(ir_dir, details, blocker_codes) + assertions_doc = validate_ir_assertions(ir_dir, details, blocker_codes) + validate_cross_references(ir_doc, assertions_doc, details, blocker_codes) + validate_ir_evidence_packet(ir_dir, details, blocker_codes, warnings) + + return emit( + label="Structured IR", + json_mode=args.json, + details=details, + blockers=blocker_codes, + warnings=warnings, + ) + + +def validate_source_intake( + ir_dir: Path, + details: dict[str, Any], + blocker_codes: list[str], +) -> None: + upstream = ir_dir.parent + packet = upstream / "visual-evidence-packet.md" + if not packet.exists(): + details["source_intake"] = {"missing": True} + blocker_codes.append(BLOCKERS["SOURCE_INTAKE_BLOCKED"]) + return + + packet_status = parse_evidence_packet_status(packet.read_text(encoding="utf-8", errors="replace")) + metadata = packet_status["metadata"] + details["source_intake"] = { + "path": str(packet), + "ready_gate": packet_status["ready_gate"], + "blockers": metadata.get("blockers"), + "errors": packet_status["errors"], + } + source_blockers = metadata.get("blockers") + if ( + packet_status["ready_gate"] != "PASS" + or packet_status["errors"] + or (isinstance(source_blockers, list) and source_blockers) + ): + blocker_codes.append(BLOCKERS["SOURCE_INTAKE_BLOCKED"]) + + +def validate_structured_ir( + ir_dir: Path, + details: dict[str, Any], + blocker_codes: list[str], +) -> dict[str, Any]: + ir_path = ir_dir / "structured-ir.yaml" + if not ir_path.exists(): + blocker_codes.append(BLOCKERS["REQUIRED_ARTIFACT_MISSING"]) + details["structured_ir"] = {"missing": True} + return {} + + validate_json_schema( + instance_path=ir_path, + schema_name="structured-ir.schema.json", + details_key="structured_ir", + details=details, + blocker_codes=blocker_codes, + schema_error_code=BLOCKERS["SCHEMA_INVALID"], + ) + + ir_doc = load_yaml(ir_path) + items = ir_doc.get("items") + if not isinstance(items, list): + items = [] + + declared_count = parse_count(ir_doc.get("ir_item_count"), len(items)) + item_errors: list[dict[str, Any]] = [] + invalid_locators: list[str] = [] + ownership_leaks: list[str] = [] + provider_evidence_gaps: list[str] = [] + product_ambiguity_gaps: list[str] = [] + blocker_lint_items: list[str] = [] + has_ready_item = False + + details["structured_ir"] = { + "ir_complete": ir_doc.get("ir_complete"), + "source_refs_complete": ir_doc.get("source_refs_complete"), + "provider_evidence_complete": ir_doc.get("provider_evidence_complete"), + "product_ambiguities_recorded": ir_doc.get("product_ambiguities_recorded"), + "downstream_ownership_free": ir_doc.get("downstream_ownership_free"), + "ir_item_count": declared_count, + "actual_item_count": len(items), + "blocker_lint_errors": ir_doc.get("blocker_lint_errors"), + } + + if not is_truthy(ir_doc.get("ir_complete")) or declared_count <= 0: + blocker_codes.append(BLOCKERS["INTAKE_INCOMPLETE"]) + if declared_count != len(items): + blocker_codes.append(BLOCKERS["INTAKE_INCOMPLETE"]) + if not is_truthy(ir_doc.get("source_refs_complete")): + blocker_codes.append(BLOCKERS["PROVIDER_EVIDENCE_MISSING"]) + if not is_truthy(ir_doc.get("provider_evidence_complete")): + blocker_codes.append(BLOCKERS["PROVIDER_EVIDENCE_MISSING"]) + if not is_truthy(ir_doc.get("product_ambiguities_recorded")): + blocker_codes.append(BLOCKERS["PRODUCT_AMBIGUITY_UNRESOLVED"]) + if not is_truthy(ir_doc.get("downstream_ownership_free")): + blocker_codes.append(BLOCKERS["DOWNSTREAM_OWNERSHIP_LEAK"]) + if non_empty(ir_doc.get("product_ambiguities")): + blocker_codes.append(BLOCKERS["PRODUCT_AMBIGUITY_UNRESOLVED"]) + if non_empty(ir_doc.get("blocker_lint_errors")): + blocker_codes.append(BLOCKERS["INTAKE_INCOMPLETE"]) + + for index, item in enumerate(items): + if not isinstance(item, dict): + item_errors.append({"index": index, "error": "item must be a mapping"}) + continue + + item_id = str(item.get("id") or f"item-{index}") + if item.get("status") == "ready": + has_ready_item = True + + required_non_empty = [ + "id", + "source_refs", + "page", + "region", + "role", + "state", + "viewport", + "locator", + "expectations", + "acceptance_intent", + "evidence_type", + "confidence", + "status", + ] + missing = [ + field + for field in required_non_empty + if not non_empty(item.get(field)) + ] + if "blockers" not in item: + missing.append("blockers") + if missing: + item_errors.append({"id": item_id, "missing_fields": missing}) + + if not valid_source_refs(item.get("source_refs")): + provider_evidence_gaps.append(item_id) + + evidence_type = str(item.get("evidence_type") or "") + if evidence_type in {"missing", "unsupported"} or non_empty(item.get("missing_evidence")): + provider_evidence_gaps.append(item_id) + + blockers = as_string_set(item.get("blockers")) + if blockers: + blocker_lint_items.append(item_id) + if blockers & PROVIDER_EVIDENCE_MARKERS: + provider_evidence_gaps.append(item_id) + if blockers & PRODUCT_AMBIGUITY_MARKERS or non_empty(item.get("ambiguity_refs")): + product_ambiguity_gaps.append(item_id) + if blockers & LOCATOR_MARKERS: + invalid_locators.append(item_id) + if blockers & OWNERSHIP_MARKERS: + ownership_leaks.append(item_id) + + locator = item.get("locator") + if not locator_is_valid(locator): + invalid_locators.append(item_id) + if has_downstream_ownership_leak(item): + ownership_leaks.append(item_id) + + details["structured_ir"]["item_errors"] = item_errors + details["structured_ir"]["provider_evidence_gaps"] = sorted(set(provider_evidence_gaps)) + details["structured_ir"]["product_ambiguity_gaps"] = sorted(set(product_ambiguity_gaps)) + details["structured_ir"]["invalid_locators"] = sorted(set(invalid_locators)) + details["structured_ir"]["ownership_leaks"] = sorted(set(ownership_leaks)) + details["structured_ir"]["blocker_lint_items"] = sorted(set(blocker_lint_items)) + + if item_errors or blocker_lint_items or not has_ready_item: + blocker_codes.append(BLOCKERS["INTAKE_INCOMPLETE"]) + if provider_evidence_gaps: + blocker_codes.append(BLOCKERS["PROVIDER_EVIDENCE_MISSING"]) + if product_ambiguity_gaps: + blocker_codes.append(BLOCKERS["PRODUCT_AMBIGUITY_UNRESOLVED"]) + if invalid_locators: + blocker_codes.append(BLOCKERS["LOCATOR_STRATEGY_INVALID"]) + if ownership_leaks: + blocker_codes.append(BLOCKERS["DOWNSTREAM_OWNERSHIP_LEAK"]) + + return ir_doc + + +def validate_ir_assertions( + ir_dir: Path, + details: dict[str, Any], + blocker_codes: list[str], +) -> dict[str, Any]: + assertions_path = ir_dir / "ir-assertions.yaml" + if not assertions_path.exists(): + blocker_codes.append(BLOCKERS["REQUIRED_ARTIFACT_MISSING"]) + details["ir_assertions"] = {"missing": True} + return {} + + validate_json_schema( + instance_path=assertions_path, + schema_name="ir-assertions.schema.json", + details_key="ir_assertions", + details=details, + blocker_codes=blocker_codes, + schema_error_code=BLOCKERS["SCHEMA_INVALID"], + ) + + assertions_doc = load_yaml(assertions_path) + assertions = assertions_doc.get("assertions") + if not isinstance(assertions, list): + assertions = [] + + declared_count = parse_count(assertions_doc.get("assertion_count"), len(assertions)) + assertion_errors: list[dict[str, Any]] = [] + non_ci_assertions: list[str] = [] + product_ambiguity_gaps: list[str] = [] + provider_evidence_gaps: list[str] = [] + blocker_lint_assertions: list[str] = [] + ready_ci_count = 0 + + details["ir_assertions"] = { + "assertions_complete": assertions_doc.get("assertions_complete"), + "ci_assertions_complete": assertions_doc.get("ci_assertions_complete"), + "assertion_count": declared_count, + "actual_assertion_count": len(assertions), + "blocker_lint_errors": assertions_doc.get("blocker_lint_errors"), + } + + if not is_truthy(assertions_doc.get("assertions_complete")) or declared_count <= 0: + blocker_codes.append(BLOCKERS["ASSERTION_COVERAGE_INCOMPLETE"]) + if not is_truthy(assertions_doc.get("ci_assertions_complete")): + blocker_codes.append(BLOCKERS["ASSERTION_COVERAGE_INCOMPLETE"]) + if declared_count != len(assertions): + blocker_codes.append(BLOCKERS["ASSERTION_COVERAGE_INCOMPLETE"]) + if non_empty(assertions_doc.get("blocker_lint_errors")): + blocker_codes.append(BLOCKERS["ASSERTION_COVERAGE_INCOMPLETE"]) + + for index, assertion in enumerate(assertions): + if not isinstance(assertion, dict): + assertion_errors.append({"index": index, "error": "assertion must be a mapping"}) + continue + + assertion_id = str(assertion.get("id") or f"assertion-{index}") + required_non_empty = [ + "id", + "ir_refs", + "assertion_type", + "acceptance_intent", + "expected", + "evidence_refs", + "ci_suitability", + "status", + ] + missing = [ + field + for field in required_non_empty + if not non_empty(assertion.get(field)) + ] + if "blockers" not in assertion: + missing.append("blockers") + if missing: + assertion_errors.append({"id": assertion_id, "missing_fields": missing}) + + status = str(assertion.get("status") or "") + ci_suitability = str(assertion.get("ci_suitability") or "") + if status == "ready" and ci_suitability == "ci_low_cost": + ready_ci_count += 1 + elif status == "ready": + non_ci_assertions.append(assertion_id) + + if not valid_source_refs(assertion.get("evidence_refs")): + provider_evidence_gaps.append(assertion_id) + + blockers = as_string_set(assertion.get("blockers")) + if blockers: + blocker_lint_assertions.append(assertion_id) + if blockers & PRODUCT_AMBIGUITY_MARKERS: + product_ambiguity_gaps.append(assertion_id) + if blockers & PROVIDER_EVIDENCE_MARKERS: + provider_evidence_gaps.append(assertion_id) + if blockers & LOCATOR_MARKERS: + blocker_codes.append(BLOCKERS["LOCATOR_STRATEGY_INVALID"]) + if blockers & OWNERSHIP_MARKERS: + blocker_codes.append(BLOCKERS["DOWNSTREAM_OWNERSHIP_LEAK"]) + if has_downstream_ownership_leak(assertion): + blocker_codes.append(BLOCKERS["DOWNSTREAM_OWNERSHIP_LEAK"]) + + details["ir_assertions"]["assertion_errors"] = assertion_errors + details["ir_assertions"]["ready_ci_assertion_count"] = ready_ci_count + details["ir_assertions"]["non_ci_ready_assertions"] = non_ci_assertions + details["ir_assertions"]["provider_evidence_gaps"] = sorted(set(provider_evidence_gaps)) + details["ir_assertions"]["product_ambiguity_gaps"] = sorted(set(product_ambiguity_gaps)) + details["ir_assertions"]["blocker_lint_assertions"] = sorted(set(blocker_lint_assertions)) + + if assertion_errors or blocker_lint_assertions or non_ci_assertions or ready_ci_count == 0: + blocker_codes.append(BLOCKERS["ASSERTION_COVERAGE_INCOMPLETE"]) + if provider_evidence_gaps: + blocker_codes.append(BLOCKERS["PROVIDER_EVIDENCE_MISSING"]) + if product_ambiguity_gaps: + blocker_codes.append(BLOCKERS["PRODUCT_AMBIGUITY_UNRESOLVED"]) + + return assertions_doc + + +def validate_cross_references( + ir_doc: dict[str, Any], + assertions_doc: dict[str, Any], + details: dict[str, Any], + blocker_codes: list[str], +) -> None: + items = ir_doc.get("items") + assertions = assertions_doc.get("assertions") + if not isinstance(items, list) or not isinstance(assertions, list): + return + + ir_ids = {item.get("id") for item in items if isinstance(item, dict)} + missing_refs: list[str] = [] + for assertion in assertions: + if not isinstance(assertion, dict): + continue + for ir_ref in assertion.get("ir_refs") or []: + if ir_ref not in ir_ids: + missing_refs.append(str(ir_ref)) + + details["ir_cross_refs"] = { + "ir_item_ids": sorted(str(item_id) for item_id in ir_ids if item_id), + "missing_ir_refs": sorted(set(missing_refs)), + } + if missing_refs: + blocker_codes.append(BLOCKERS["ASSERTION_COVERAGE_INCOMPLETE"]) + + +def validate_ir_evidence_packet( + ir_dir: Path, + details: dict[str, Any], + blocker_codes: list[str], + warnings: list[str], +) -> None: + evidence_path = ir_dir / "ir-evidence-packet.md" + if not evidence_path.exists(): + blocker_codes.append(BLOCKERS["REQUIRED_ARTIFACT_MISSING"]) + return + + details["ir_evidence_packet"] = evidence_path.name + packet_status = parse_evidence_packet_status( + evidence_path.read_text(encoding="utf-8", errors="replace") + ) + details["ir_evidence_packet_metadata"] = packet_status["metadata"] + if packet_status["warnings"]: + warnings.extend(packet_status["warnings"]) + if packet_status["errors"]: + blocker_codes.append(BLOCKERS["READY_WITHOUT_EVIDENCE"]) + return + + packet_blockers = packet_status["metadata"].get("blockers") + has_packet_blockers = isinstance(packet_blockers, list) and bool(packet_blockers) + if packet_status["ready_gate"] != "PASS" or has_packet_blockers: + blocker_codes.append(BLOCKERS["READY_WITHOUT_EVIDENCE"]) + return + + current_blockers = [code for code in blocker_codes if code != BLOCKERS["READY_WITHOUT_EVIDENCE"]] + if current_blockers: + blocker_codes.append(BLOCKERS["READY_WITHOUT_EVIDENCE"]) + + +def parse_count(value: Any, fallback: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return fallback + + +def valid_source_refs(value: Any) -> bool: + return isinstance(value, list) and bool(value) and all(str(ref).strip() for ref in value) + + +def as_string_set(value: Any) -> set[str]: + if not isinstance(value, list): + return set() + return {str(item).strip() for item in value if str(item).strip()} + + +def locator_is_valid(locator: Any) -> bool: + if not isinstance(locator, dict): + return False + if locator.get("implementation_owned") is not False: + return False + strategy = str(locator.get("strategy") or "") + if strategy in {"css", "xpath", "query-selector", "implementation-selector"}: + return False + value = str(locator.get("value") or "") + if not value: + return False + return not any(pattern.search(value) for pattern in FORBIDDEN_LOCATOR_PATTERNS) + + +def has_downstream_ownership_leak(value: Any) -> bool: + if isinstance(value, dict): + for key, nested in value.items(): + normalized = str(key).strip().lower().replace("-", "_") + if normalized in FORBIDDEN_FIELD_NAMES: + return True + if normalized == "id" and isinstance(nested, str) and looks_like_downstream_id(nested): + return True + if has_downstream_ownership_leak(nested): + return True + elif isinstance(value, list): + return any(has_downstream_ownership_leak(item) for item in value) + elif isinstance(value, str): + return bool(re.search(r"\b(implementation task|code component|css selector|xpath)\b", value, re.IGNORECASE)) + return False + + +def looks_like_downstream_id(value: str) -> bool: + return bool(re.match(r"^(REQ|FR|NFR|TASK|US|AC)-\d+", value.strip(), re.IGNORECASE)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/extensions/intake/scripts/python/validate_visual_design_intake.py b/extensions/intake/scripts/python/validate_visual_design_intake.py index 582510171e..ab0515a86e 100644 --- a/extensions/intake/scripts/python/validate_visual_design_intake.py +++ b/extensions/intake/scripts/python/validate_visual_design_intake.py @@ -173,6 +173,8 @@ def validate_visual_contract( "page_or_frame_count": manifest.get("page_or_frame_count"), "processed_count": manifest.get("processed_count"), "extraction_scope": manifest.get("extraction_scope"), + "snapshot_status": manifest.get("snapshot_status"), + "integrity_gap_reason": manifest.get("integrity_gap_reason"), } required_manifest_fields = [ @@ -219,22 +221,33 @@ def validate_source_files( return validated_files: list[dict[str, Any]] = [] + manifest_remote_gap = ( + str(manifest.get("snapshot_status") or "").strip() in {"not_available", "not_required"} + and manifest.get("integrity_gap_reason") not in (None, "", [], {}) + ) for source_file in source_files: if not isinstance(source_file, dict): blocker_codes.append(BLOCKERS["VISUAL_SOURCE_FILE_MISSING"]) continue required_source_file_fields = ["path", "mime_type", "byte_size", "sha256", "role"] + rel_path = str(source_file.get("path") or "").strip() + remote_ref = is_remote_ref(rel_path) + remote_gap_fields = {"byte_size", "sha256"} if remote_ref and manifest_remote_gap else set() missing_source_file_fields = [ - field for field in required_source_file_fields if field not in source_file + field + for field in required_source_file_fields + if field not in source_file and field not in remote_gap_fields ] - rel_path = str(source_file.get("path") or "").strip() expected = str(source_file.get("sha256") or "").replace("sha256:", "").strip() file_detail: dict[str, Any] = { "path": rel_path, "exists": False, "sha256_match": None, "missing_required_fields": missing_source_file_fields, + "checksum_status": source_file.get("checksum_status"), + "snapshot_status": manifest.get("snapshot_status"), + "integrity_gap_reason": manifest.get("integrity_gap_reason"), } if missing_source_file_fields: @@ -245,7 +258,7 @@ def validate_source_files( validated_files.append(file_detail) continue - if is_remote_ref(rel_path): + if remote_ref: file_detail["exists"] = True file_detail["remote_ref"] = True validated_files.append(file_detail) @@ -722,7 +735,10 @@ def validate_evidence_packet( if packet_status["warnings"]: warnings.extend(packet_status["warnings"]) if packet_status["errors"]: - blocker_codes.append(BLOCKERS["VISUAL_READY_WITHOUT_EVIDENCE"]) + if visual_gate_active: + blocker_codes.append(BLOCKERS["VISUAL_READY_WITHOUT_EVIDENCE"]) + else: + blocker_codes.append(BLOCKERS["READY_WITHOUT_COMPLETENESS_PROOF"]) return if packet_status["ready_gate"] != "PASS": diff --git a/extensions/intake/templates/intake-prd-contract.md b/extensions/intake/templates/intake-prd-contract.md index e1ce4b567b..e440f3f31b 100644 --- a/extensions/intake/templates/intake-prd-contract.md +++ b/extensions/intake/templates/intake-prd-contract.md @@ -18,8 +18,9 @@ Required source fields: - source_files: - path: - mime_type: - - byte_size: - - sha256: + - byte_size: required for local files or captured snapshots + - sha256: required for local files or captured snapshots + - checksum_status: verified|unavailable|not_applicable - role: original|export|attachment|snapshot - source_integrity_complete: - captured_at: @@ -32,7 +33,7 @@ Source-specific requirements: - Markdown or text PRDs must record heading coverage, linked asset refs, and parsed section coverage. - PDF or exported docs must record original file hash, page count, processed page count, and text extraction status. - URL, issue, or epic sources must record stable URL, retrieval timestamp, visible title, author or owner, and snapshot refs; unavailable values must be represented by `snapshot_status` and `integrity_gap_reason`. -- Remote source refs may use a stable URL as `source_files[].path`; when no local snapshot exists, record `retrieval_metadata`, `snapshot_status`, and `integrity_gap_reason` instead of marking source integrity complete. +- Remote source refs may use a stable URL as `source_files[].path`; when no local snapshot exists, record `retrieval_metadata`, `snapshot_status`, `integrity_gap_reason`, and `checksum_status: unavailable|not_applicable` instead of marking source integrity complete. - Mixed stakeholder notes must record each source separately and mark conflicting or unsupported claims. ## Vertical Scenario Coverage diff --git a/extensions/intake/templates/intake-structured-ir-contract.md b/extensions/intake/templates/intake-structured-ir-contract.md new file mode 100644 index 0000000000..0b40adcb49 --- /dev/null +++ b/extensions/intake/templates/intake-structured-ir-contract.md @@ -0,0 +1,120 @@ +# Structured IR Intake Contract + +Required structured UI acceptance IR artifacts and readiness gates. Runtime agents or external intake tools derive CI-friendly UI acceptance facts from traceable visual/design evidence before downstream workflows choose their own test runner, selectors, requirement IDs, or implementation ownership. + +Structured IR does not generate requirements, tasks, code component names, implementation-owned selectors, or product semantics. It preserves source-backed DOM, ARIA, token, state, relation, locator-strategy, and assertion facts that can be consumed by low-cost CI checks. + +## Artifact Family + +Default directory: + +```text +specs//intake/visual-design/structured-ir/ +``` + +Required files: + +- `structured-ir.yaml` +- `ir-assertions.yaml` +- `ir-evidence-packet.md` + +Optional enhancement refs may point to `figma2htmlssot/visual-spec.html`, `figma-map.json`, screenshots, or visual diff reports, but HTML SSOT and screenshots are not the primary acceptance substrate. + +## Source Boundary + +Structured IR is downstream of source evidence and upstream of implementation tests: + +1. Visual/design intake records source-backed facts, limitations, Figma metadata, and visual requirements. +2. HTML SSOT records runnable visual preview and screenshot comparison surfaces when available. +3. Structured IR records deterministic acceptance facts suitable for CI. + +If source evidence is missing, truncated, contradictory, or blocked, structured IR must record `IR_PROVIDER_EVIDENCE_MISSING`. If the source is clear but product behavior is ambiguous, it must record `IR_PRODUCT_AMBIGUITY_UNRESOLVED`. Do not collapse these into one generic gap. + +## `structured-ir.yaml` + +The file must normalize UI acceptance facts into provider-neutral records. + +Top-level fields: + +- ir_complete: true|false +- ir_item_count: integer +- source_refs_complete: true|false +- provider_evidence_complete: true|false +- product_ambiguities_recorded: true|false +- downstream_ownership_free: true|false +- product_ambiguities: array +- blocker_lint_errors: array +- items: array + +Each item must include: + +- id: stable `IR-*` identifier owned by intake +- source_refs: preserved source evidence refs +- des_refs: optional design evidence source refs +- visual_requirement_refs: optional refs to `visual-requirements.yaml` +- html_ssot_refs: optional enhancement refs only +- page, region, role, state, viewport +- locator: provider-neutral strategy, value, and `implementation_owned: false` +- expectations: DOM, ARIA, design token, state, content, or relation facts +- acceptance_intent: what low-cost check this fact supports +- evidence_type: observed|inferred|candidate|unsupported|missing|out_of_scope +- confidence: low|medium|high +- status: ready|blocked|reference_only|out_of_scope +- blockers: array + +Locator strategies must not be implementation-owned CSS selectors, XPath, generated class names, or downstream test IDs. Candidate test IDs may be recorded only as `test-id-candidate` and must remain intake-owned guidance, not implementation ownership. + +## `ir-assertions.yaml` + +The file must describe low-cost assertions over IR items. + +Top-level fields: + +- assertions_complete: true|false +- assertion_count: integer +- ci_assertions_complete: true|false +- blocker_lint_errors: array +- assertions: array + +Each assertion must include: + +- id: stable `IRA-*` identifier owned by intake +- ir_refs: one or more `IR-*` refs +- assertion_type: visible|hidden|enabled|disabled|contains_text|role|aria|token|relation|state|viewport +- acceptance_intent +- expected +- evidence_refs +- ci_suitability: ci_low_cost|manual_review|blocked +- status: ready|blocked|reference_only|out_of_scope +- blockers + +Ready assertions should use `ci_suitability: ci_low_cost`. Manual review and blocked assertions are allowed as explicit evidence, but they cannot satisfy CI readiness. + +## Readiness + +Structured IR intake is ready only when: + +- upstream visual-design intake readiness is PASS +- required IR artifacts exist +- both schemas validate +- item and assertion counts match their arrays +- every ready assertion references an existing IR item +- source refs and provider evidence are complete +- product ambiguities are explicitly recorded and unresolved ambiguity does not masquerade as missing provider evidence +- locators are provider-neutral and not implementation-owned +- no downstream requirement IDs, tasks, code component names, implementation-owned selectors, or product semantics leak into the IR +- at least one ready `ci_low_cost` assertion exists +- `ir-evidence-packet.md` front matter reports `ready_gate: PASS` with no blockers + +## Blocker Codes + +- `IR_SOURCE_INTAKE_BLOCKED` +- `IR_REQUIRED_ARTIFACT_MISSING` +- `IR_SCHEMA_INVALID` +- `IR_INTAKE_INCOMPLETE` +- `IR_PROVIDER_EVIDENCE_MISSING` +- `IR_PRODUCT_AMBIGUITY_UNRESOLVED` +- `IR_ASSERTION_COVERAGE_INCOMPLETE` +- `IR_LOCATOR_STRATEGY_INVALID` +- `IR_DOWNSTREAM_OWNERSHIP_LEAK` +- `IR_READY_WITHOUT_EVIDENCE` diff --git a/extensions/intake/templates/intake-structured-ir-evidence-packet-template.md b/extensions/intake/templates/intake-structured-ir-evidence-packet-template.md new file mode 100644 index 0000000000..a4eb669973 --- /dev/null +++ b/extensions/intake/templates/intake-structured-ir-evidence-packet-template.md @@ -0,0 +1,41 @@ +--- +ready_gate: BLOCKED +blockers: [] +source_ref_count: 0 +extracted_item_count: 0 +generated_at: "" +--- + +# Structured IR Evidence Packet + +Purpose: summarize structured UI acceptance IR readiness while preserving enough traceability for downstream CI workflows to consume deterministic DOM, ARIA, token, state, relation, locator-strategy, and assertion facts. + +This packet is a human-readable readiness summary. Machine-readable UI acceptance facts are recorded in `structured-ir.yaml` and `ir-assertions.yaml` and validated by `templates/schemas/structured-ir.schema.json` and `templates/schemas/ir-assertions.schema.json`. This packet does not define downstream requirement IDs, implementation tasks, code component names, implementation-owned selectors, or final product behavior. + +## Source Boundary + +- Visual/design intake directory: +- Visual/design readiness: +- HTML SSOT enhancement refs, if used: +- Screenshot or visual diff refs, if used: + +## IR Summary + +- structured-ir.yaml: +- ir-assertions.yaml: +- structured IR item count: +- assertion count: +- CI-low-cost assertion count: + +## Evidence Separation + +- Missing provider evidence: +- Product ambiguities: +- Accepted out-of-scope surfaces: +- Manual-review-only assertions: + +## Readiness + +- ready_gate: +- blockers: +- next corrective action: diff --git a/extensions/intake/templates/intake-test-cases-contract.md b/extensions/intake/templates/intake-test-cases-contract.md index 64a14ef3bc..0b5c162418 100644 --- a/extensions/intake/templates/intake-test-cases-contract.md +++ b/extensions/intake/templates/intake-test-cases-contract.md @@ -18,8 +18,9 @@ Required source fields: - source_files: - path: - mime_type: - - byte_size: - - sha256: + - byte_size: required for local files or captured snapshots + - sha256: required for local files or captured snapshots + - checksum_status: verified|unavailable|not_applicable - role: original|export|attachment|snapshot - source_integrity_complete: - captured_at: @@ -33,7 +34,7 @@ Source-specific requirements: - Gherkin sources must record feature, background, scenario, examples, tags, and step coverage. - Spreadsheets or test management exports must record sheet or suite names, row IDs, case IDs, priorities, statuses, and imported range coverage. - Issue or bug repro sources must record stable URLs, repro steps, expected and actual behavior, environment, and linked artifacts. -- Remote source refs may use a stable URL as `source_files[].path`; when no local snapshot exists, record retrieval metadata and mark unavailable checksum fields as explicit gaps instead of pretending integrity is complete. +- Remote source refs may use a stable URL as `source_files[].path`; when no local snapshot exists, record retrieval metadata, `snapshot_status`, `integrity_gap_reason`, and `checksum_status: unavailable|not_applicable` instead of pretending integrity is complete. - Mixed source packets must preserve source precedence and record duplicate or conflicting scenarios. ## Vertical Scenario Coverage diff --git a/extensions/intake/templates/intake-visual-design-contract.md b/extensions/intake/templates/intake-visual-design-contract.md index bd9211e92f..528c2013e0 100644 --- a/extensions/intake/templates/intake-visual-design-contract.md +++ b/extensions/intake/templates/intake-visual-design-contract.md @@ -19,8 +19,9 @@ Required source fields: - source_files: - path: - mime_type: - - byte_size: - - sha256: + - byte_size: required for local files or captured snapshots + - sha256: required for local files or captured snapshots + - checksum_status: verified|unavailable|not_applicable - role: original|rendered_page|screenshot|asset|markdown - source_integrity_complete: - captured_at: @@ -35,6 +36,7 @@ Source-specific requirements: - PDF sources must record original PDF hash, page count, processed page count, rendered page references, and text extraction status. - Markdown sources must record original Markdown hash, embedded or linked asset refs, heading structure, and design-note parsing status. - Figma sources must additionally satisfy the Figma provider contract below. +- Remote source refs may use `http://`, `https://`, or `figma://` as `source_files[].path`; when no local snapshot exists, record retrieval metadata, `snapshot_status`, `integrity_gap_reason`, and `checksum_status: unavailable|not_applicable` instead of marking source integrity complete. ## Fidelity Profile diff --git a/extensions/intake/templates/schemas/html-ssot-coverage.schema.json b/extensions/intake/templates/schemas/html-ssot-coverage.schema.json index 235a758e1f..c7bdc62118 100644 --- a/extensions/intake/templates/schemas/html-ssot-coverage.schema.json +++ b/extensions/intake/templates/schemas/html-ssot-coverage.schema.json @@ -19,7 +19,7 @@ "ready_gate": { "enum": ["PASS", "BLOCKED"] }, "blockers": { "type": "array", - "items": { "type": "string", "minLength": 1 } + "items": { "$ref": "#/$defs/blocker_code" } }, "required_nodes_total": { "type": "integer", "minimum": 0 }, "required_nodes_covered": { "type": "integer", "minimum": 0 }, @@ -30,5 +30,21 @@ "visual_diff_status": { "enum": ["pass", "blocked", "not_applicable"] }, "accepted_exceptions": { "type": "array" } }, + "$defs": { + "blocker_code": { + "enum": [ + "HTML_SSOT_SOURCE_INTAKE_BLOCKED", + "HTML_SSOT_REQUIRED_ARTIFACT_MISSING", + "HTML_SSOT_FIGMA_NODE_COVERAGE_INCOMPLETE", + "HTML_SSOT_COMPONENT_STATE_COVERAGE_INCOMPLETE", + "HTML_SSOT_PAGE_COVERAGE_INCOMPLETE", + "HTML_SSOT_ASSET_TRACEABILITY_INCOMPLETE", + "HTML_SSOT_VIEWPORT_CAPTURE_INCOMPLETE", + "HTML_SSOT_VISUAL_DIFF_BLOCKED", + "HTML_SSOT_KNOWN_GAP_UNRESOLVED", + "HTML_SSOT_SCHEMA_INVALID" + ] + } + }, "additionalProperties": true } diff --git a/extensions/intake/templates/schemas/ir-assertions.schema.json b/extensions/intake/templates/schemas/ir-assertions.schema.json new file mode 100644 index 0000000000..2c83888542 --- /dev/null +++ b/extensions/intake/templates/schemas/ir-assertions.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spec-kit-intake.local/schemas/ir-assertions.schema.json", + "title": "Structured UI Acceptance IR Assertions", + "type": "object", + "required": [ + "assertions_complete", + "assertion_count", + "ci_assertions_complete", + "assertions" + ], + "properties": { + "assertions_complete": { "type": "boolean" }, + "assertion_count": { "type": "integer", "minimum": 0 }, + "ci_assertions_complete": { "type": "boolean" }, + "blocker_lint_errors": { "type": "array" }, + "assertions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "ir_refs", + "assertion_type", + "acceptance_intent", + "expected", + "evidence_refs", + "ci_suitability", + "status", + "blockers" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^IRA-[A-Za-z0-9._:-]+$" + }, + "ir_refs": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "assertion_type": { + "enum": [ + "visible", + "hidden", + "enabled", + "disabled", + "contains_text", + "role", + "aria", + "token", + "relation", + "state", + "viewport" + ] + }, + "acceptance_intent": { "type": "string", "minLength": 1 }, + "expected": { + "type": ["string", "object", "array", "boolean", "number"], + "minLength": 1 + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "ci_suitability": { "enum": ["ci_low_cost", "manual_review", "blocked"] }, + "status": { "enum": ["ready", "blocked", "reference_only", "out_of_scope"] }, + "blockers": { + "type": "array", + "items": { "$ref": "#/$defs/blocker_code" } + } + }, + "additionalProperties": true + } + } + }, + "$defs": { + "blocker_code": { + "enum": [ + "IR_SOURCE_INTAKE_BLOCKED", + "IR_REQUIRED_ARTIFACT_MISSING", + "IR_SCHEMA_INVALID", + "IR_INTAKE_INCOMPLETE", + "IR_PROVIDER_EVIDENCE_MISSING", + "IR_PRODUCT_AMBIGUITY_UNRESOLVED", + "IR_ASSERTION_COVERAGE_INCOMPLETE", + "IR_LOCATOR_STRATEGY_INVALID", + "IR_DOWNSTREAM_OWNERSHIP_LEAK", + "IR_READY_WITHOUT_EVIDENCE" + ] + } + }, + "additionalProperties": true +} diff --git a/extensions/intake/templates/schemas/prd-intake.schema.json b/extensions/intake/templates/schemas/prd-intake.schema.json index 6b06e80f88..43bce129fe 100644 --- a/extensions/intake/templates/schemas/prd-intake.schema.json +++ b/extensions/intake/templates/schemas/prd-intake.schema.json @@ -61,17 +61,43 @@ ] }, "statement": { "type": "string", "minLength": 1 }, - "source_refs": { "type": "array", "minItems": 1 }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, "evidence_type": { "enum": ["observed", "inferred", "missing", "out_of_scope"] }, "confidence": { "enum": ["low", "medium", "high"] }, "confidence_rationale": { "type": "string", "minLength": 1 }, "downstream_hint": { "type": "string", "minLength": 1 }, "acceptance_or_validation_signal": { "type": "string", "minLength": 1 }, - "blockers": { "type": "array" } + "blockers": { + "type": "array", + "items": { "$ref": "#/$defs/blocker_code" } + } }, "additionalProperties": true } } }, + "$defs": { + "blocker_code": { + "enum": [ + "PRD_SOURCE_MANIFEST_MISSING", + "PRD_SOURCE_TYPE_UNSUPPORTED", + "PRD_SOURCE_FILE_MISSING", + "PRD_SOURCE_HASH_MISMATCH", + "PRD_SOURCE_INTEGRITY_INCOMPLETE", + "PRD_INTAKE_MISSING", + "PRD_FACTS_UNTRACEABLE", + "PRD_ACCEPTANCE_EVIDENCE_MISSING", + "PRD_CLARIFICATION_MARKING_MISSING", + "PRD_READY_WITHOUT_EVIDENCE", + "PRD_EVIDENCE_PACKET_MISSING", + "PRD_BLOCKER_LINT_ERRORS", + "PRD_SCHEMA_INVALID" + ] + } + }, "additionalProperties": true } diff --git a/extensions/intake/templates/schemas/prd-source-manifest.schema.json b/extensions/intake/templates/schemas/prd-source-manifest.schema.json index f92b104101..247488e6d7 100644 --- a/extensions/intake/templates/schemas/prd-source-manifest.schema.json +++ b/extensions/intake/templates/schemas/prd-source-manifest.schema.json @@ -86,6 +86,32 @@ } } }, + { + "if": { + "properties": { + "source_files": { + "contains": { + "type": "object", + "properties": { + "path": { "pattern": "^(https?://|figma://)" } + }, + "required": ["path"] + } + } + }, + "required": ["source_files"] + }, + "then": { "required": ["snapshot_status", "integrity_gap_reason"] } + }, + { + "if": { + "properties": { + "snapshot_status": { "enum": ["not_available", "not_required"] } + }, + "required": ["snapshot_status"] + }, + "then": { "required": ["integrity_gap_reason"] } + }, { "if": { "properties": { "source_type": { "const": "mixed" } } }, "then": { "required": ["source_precedence"] } @@ -94,14 +120,37 @@ "$defs": { "source_file": { "type": "object", - "required": ["path", "mime_type", "byte_size", "sha256", "role"], + "required": ["path", "mime_type", "role"], "properties": { "path": { "type": "string", "minLength": 1 }, "mime_type": { "type": "string", "minLength": 1 }, "byte_size": { "type": "integer", "minimum": 0 }, "sha256": { "type": "string", "minLength": 1 }, + "checksum_status": { "enum": ["verified", "unavailable", "not_applicable"] }, "role": { "enum": ["original", "export", "attachment", "snapshot"] } }, + "allOf": [ + { + "if": { + "properties": { + "path": { "pattern": "^(https?://|figma://)" } + }, + "required": ["path"] + }, + "then": { + "anyOf": [ + { "required": ["byte_size", "sha256"] }, + { + "required": ["checksum_status"], + "properties": { + "checksum_status": { "enum": ["unavailable", "not_applicable"] } + } + } + ] + }, + "else": { "required": ["byte_size", "sha256"] } + } + ], "additionalProperties": true } }, diff --git a/extensions/intake/templates/schemas/structured-ir.schema.json b/extensions/intake/templates/schemas/structured-ir.schema.json new file mode 100644 index 0000000000..1d276312fa --- /dev/null +++ b/extensions/intake/templates/schemas/structured-ir.schema.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spec-kit-intake.local/schemas/structured-ir.schema.json", + "title": "Structured UI Acceptance IR", + "type": "object", + "required": [ + "ir_complete", + "ir_item_count", + "source_refs_complete", + "provider_evidence_complete", + "product_ambiguities_recorded", + "downstream_ownership_free", + "items" + ], + "properties": { + "ir_complete": { "type": "boolean" }, + "ir_item_count": { "type": "integer", "minimum": 0 }, + "source_refs_complete": { "type": "boolean" }, + "provider_evidence_complete": { "type": "boolean" }, + "product_ambiguities_recorded": { "type": "boolean" }, + "downstream_ownership_free": { "type": "boolean" }, + "product_ambiguities": { "type": "array" }, + "blocker_lint_errors": { "type": "array" }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "source_refs", + "page", + "region", + "role", + "state", + "viewport", + "locator", + "expectations", + "acceptance_intent", + "evidence_type", + "confidence", + "status", + "blockers" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^IR-[A-Za-z0-9._:-]+$" + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "des_refs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "visual_requirement_refs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "html_ssot_refs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "page": { "type": "string", "minLength": 1 }, + "region": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "state": { "type": "string", "minLength": 1 }, + "viewport": { "type": "string", "minLength": 1 }, + "locator": { + "type": "object", + "required": ["strategy", "value", "implementation_owned"], + "properties": { + "strategy": { + "enum": [ + "role", + "text", + "label", + "aria-label", + "semantic-region", + "source-ref", + "visual-anchor", + "test-id-candidate" + ] + }, + "value": { "type": "string", "minLength": 1 }, + "implementation_owned": { "const": false } + }, + "additionalProperties": true + }, + "expectations": { + "type": "object", + "minProperties": 1, + "properties": { + "dom": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "aria": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "design_tokens": { "type": "array" }, + "relations": { "type": "array" }, + "state": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "content": { "type": "array", "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": true + }, + "acceptance_intent": { "type": "string", "minLength": 1 }, + "evidence_type": { + "enum": ["observed", "inferred", "candidate", "unsupported", "missing", "out_of_scope"] + }, + "confidence": { "enum": ["low", "medium", "high"] }, + "status": { "enum": ["ready", "blocked", "reference_only", "out_of_scope"] }, + "blockers": { + "type": "array", + "items": { "$ref": "#/$defs/blocker_code" } + }, + "missing_evidence": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "ambiguity_refs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + }, + "additionalProperties": true + } + } + }, + "$defs": { + "blocker_code": { + "enum": [ + "IR_SOURCE_INTAKE_BLOCKED", + "IR_REQUIRED_ARTIFACT_MISSING", + "IR_SCHEMA_INVALID", + "IR_INTAKE_INCOMPLETE", + "IR_PROVIDER_EVIDENCE_MISSING", + "IR_PRODUCT_AMBIGUITY_UNRESOLVED", + "IR_ASSERTION_COVERAGE_INCOMPLETE", + "IR_LOCATOR_STRATEGY_INVALID", + "IR_DOWNSTREAM_OWNERSHIP_LEAK", + "IR_READY_WITHOUT_EVIDENCE" + ] + } + }, + "additionalProperties": true +} diff --git a/extensions/intake/templates/schemas/test-case-intake.schema.json b/extensions/intake/templates/schemas/test-case-intake.schema.json index 829f78953c..ab22c2fb4c 100644 --- a/extensions/intake/templates/schemas/test-case-intake.schema.json +++ b/extensions/intake/templates/schemas/test-case-intake.schema.json @@ -68,7 +68,11 @@ ] }, "scenario": { "type": "string", "minLength": 1 }, - "source_refs": { "type": "array", "minItems": 1 }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, "evidence_type": { "enum": ["observed", "inferred", "missing", "out_of_scope"] }, "confidence": { "enum": ["low", "medium", "high"] }, "confidence_rationale": { "type": "string", "minLength": 1 }, @@ -79,11 +83,34 @@ "assertions": { "type": "array" }, "fixtures_or_test_data": { "type": "array" }, "coverage_signal": { "type": "string", "minLength": 1 }, - "blockers": { "type": "array" } + "blockers": { + "type": "array", + "items": { "$ref": "#/$defs/blocker_code" } + } }, "additionalProperties": true } } }, + "$defs": { + "blocker_code": { + "enum": [ + "TEST_SOURCE_MANIFEST_MISSING", + "TEST_SOURCE_TYPE_UNSUPPORTED", + "TEST_SOURCE_FILE_MISSING", + "TEST_SOURCE_HASH_MISMATCH", + "TEST_SOURCE_INTEGRITY_INCOMPLETE", + "TEST_CASE_INTAKE_MISSING", + "TEST_SCENARIOS_UNTRACEABLE", + "TEST_ASSERTIONS_MISSING", + "TEST_FIXTURE_EVIDENCE_MISSING", + "TEST_COVERAGE_GAPS_MISSING", + "TEST_READY_WITHOUT_EVIDENCE", + "TEST_EVIDENCE_PACKET_MISSING", + "TEST_BLOCKER_LINT_ERRORS", + "TEST_SCHEMA_INVALID" + ] + } + }, "additionalProperties": true } diff --git a/extensions/intake/templates/schemas/test-case-source-manifest.schema.json b/extensions/intake/templates/schemas/test-case-source-manifest.schema.json index b4ca696b65..e5a5feeae6 100644 --- a/extensions/intake/templates/schemas/test-case-source-manifest.schema.json +++ b/extensions/intake/templates/schemas/test-case-source-manifest.schema.json @@ -104,6 +104,32 @@ } } }, + { + "if": { + "properties": { + "source_files": { + "contains": { + "type": "object", + "properties": { + "path": { "pattern": "^(https?://|figma://)" } + }, + "required": ["path"] + } + } + }, + "required": ["source_files"] + }, + "then": { "required": ["snapshot_status", "integrity_gap_reason"] } + }, + { + "if": { + "properties": { + "snapshot_status": { "enum": ["not_available", "not_required"] } + }, + "required": ["snapshot_status"] + }, + "then": { "required": ["integrity_gap_reason"] } + }, { "if": { "properties": { "source_type": { "const": "mixed" } } }, "then": { "required": ["source_precedence"] } @@ -112,14 +138,37 @@ "$defs": { "source_file": { "type": "object", - "required": ["path", "mime_type", "byte_size", "sha256", "role"], + "required": ["path", "mime_type", "role"], "properties": { "path": { "type": "string", "minLength": 1 }, "mime_type": { "type": "string", "minLength": 1 }, "byte_size": { "type": "integer", "minimum": 0 }, "sha256": { "type": "string", "minLength": 1 }, + "checksum_status": { "enum": ["verified", "unavailable", "not_applicable"] }, "role": { "enum": ["original", "export", "attachment", "snapshot"] } }, + "allOf": [ + { + "if": { + "properties": { + "path": { "pattern": "^(https?://|figma://)" } + }, + "required": ["path"] + }, + "then": { + "anyOf": [ + { "required": ["byte_size", "sha256"] }, + { + "required": ["checksum_status"], + "properties": { + "checksum_status": { "enum": ["unavailable", "not_applicable"] } + } + } + ] + }, + "else": { "required": ["byte_size", "sha256"] } + } + ], "additionalProperties": true } }, diff --git a/extensions/intake/templates/schemas/visual-requirements.schema.json b/extensions/intake/templates/schemas/visual-requirements.schema.json index 8ddbee68cd..014e2357ff 100644 --- a/extensions/intake/templates/schemas/visual-requirements.schema.json +++ b/extensions/intake/templates/schemas/visual-requirements.schema.json @@ -88,7 +88,11 @@ ] }, "requirement": { "type": "string", "minLength": 1 }, - "source_refs": { "type": "array", "minItems": 1 }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, "evidence_type": { "enum": ["observed", "inferred", "candidate", "unsupported", "missing", "out_of_scope"] }, @@ -105,7 +109,10 @@ "properties": { "signal": { "type": "string", "minLength": 1 }, "weight": { "type": "number" }, - "source_refs": { "type": "array" } + "source_refs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } }, "additionalProperties": true } @@ -122,12 +129,15 @@ "type": "array", "items": { "type": "string", "minLength": 1 } }, - "blocker_code": { "type": "string", "minLength": 1 }, + "blocker_code": { "$ref": "#/$defs/blocker_code" }, "reason": { "type": "string", "minLength": 1 }, "engineering_action": { "type": "string", "minLength": 1 }, "acceptance_check": { "type": "string", "minLength": 1 }, "fidelity_level": { "enum": ["low", "medium", "high"] }, - "blockers": { "type": "array" } + "blockers": { + "type": "array", + "items": { "$ref": "#/$defs/blocker_code" } + } }, "allOf": [ { @@ -184,7 +194,11 @@ ], "properties": { "downstream_use": { "const": "blocked" }, - "blockers": { "type": "array", "minItems": 1 } + "blockers": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/blocker_code" } + } } } } @@ -193,5 +207,42 @@ } } }, + "$defs": { + "blocker_code": { + "enum": [ + "VISUAL_SOURCE_MANIFEST_MISSING", + "VISUAL_SOURCE_TYPE_UNSUPPORTED", + "VISUAL_FIDELITY_LEVEL_UNSUPPORTED", + "VISUAL_SOURCE_FILE_MISSING", + "VISUAL_SOURCE_HASH_MISMATCH", + "VISUAL_SOURCE_INTEGRITY_INCOMPLETE", + "VISUAL_REQUIREMENTS_MISSING", + "VISUAL_REQUIREMENTS_UNTRACEABLE", + "VISUAL_FIDELITY_RULES_MISSING", + "VISUAL_PARITY_PLAN_MISSING", + "VISUAL_READY_WITHOUT_EVIDENCE", + "VISUAL_EVIDENCE_PACKET_MISSING", + "VISUAL_BLOCKER_LINT_ERRORS", + "VISUAL_INFERENCE_CONTRACT_INVALID", + "VISUAL_SCHEMA_INVALID", + "FIGMA_RENDER_NODE_MISMATCH", + "FIGMA_HIDDEN_LAYER_POLLUTION", + "FIGMA_NON_INSTANCE_COMPONENT", + "FIGMA_PROTOTYPE_METADATA_MISSING", + "FIGMA_UNSUPPORTED_STATE_INFERENCE", + "FIGMA_BUSINESS_RULE_UNSUPPORTED", + "FIGMA_INTERACTION_CONFLICT", + "FIGMA_RESPONSIVE_RULE_MISSING", + "FIGMA_LOW_CONFIDENCE_CANDIDATE", + "FIGMA_RAW_METADATA_MISSING", + "FIGMA_RAW_METADATA_SUMMARY_SUBSTITUTION", + "FIGMA_RAW_METADATA_TRUNCATED", + "FIGMA_SELECTED_SUBTREE_INCOMPLETE", + "FIGMA_METADATA_INDEX_MISSING", + "FIGMA_METADATA_PARITY_FAILED", + "FIGMA_READY_WITHOUT_COMPLETENESS_PROOF" + ] + } + }, "additionalProperties": true } diff --git a/extensions/intake/templates/schemas/visual-source-manifest.schema.json b/extensions/intake/templates/schemas/visual-source-manifest.schema.json index f058b2ff57..b3c19d46e1 100644 --- a/extensions/intake/templates/schemas/visual-source-manifest.schema.json +++ b/extensions/intake/templates/schemas/visual-source-manifest.schema.json @@ -28,7 +28,10 @@ "minItems": 1, "items": { "$ref": "#/$defs/source_file" } }, - "source_details": { "type": "object" } + "source_details": { "type": "object" }, + "retrieval_metadata": { "type": "object" }, + "snapshot_status": { "enum": ["captured", "not_available", "not_required"] }, + "integrity_gap_reason": { "type": "string", "minLength": 1 } }, "allOf": [ { @@ -112,21 +115,70 @@ } } } + }, + { + "if": { + "properties": { + "source_files": { + "contains": { + "type": "object", + "properties": { + "path": { "pattern": "^(https?://|figma://)" } + }, + "required": ["path"] + } + } + }, + "required": ["source_files"] + }, + "then": { "required": ["snapshot_status", "integrity_gap_reason"] } + }, + { + "if": { + "properties": { + "snapshot_status": { "enum": ["not_available", "not_required"] } + }, + "required": ["snapshot_status"] + }, + "then": { "required": ["integrity_gap_reason"] } } ], "$defs": { "source_file": { "type": "object", - "required": ["path", "mime_type", "byte_size", "sha256", "role"], + "required": ["path", "mime_type", "role"], "properties": { "path": { "type": "string", "minLength": 1 }, "mime_type": { "type": "string", "minLength": 1 }, "byte_size": { "type": "integer", "minimum": 0 }, "sha256": { "type": "string", "minLength": 1 }, + "checksum_status": { "enum": ["verified", "unavailable", "not_applicable"] }, "role": { "enum": ["original", "rendered_page", "screenshot", "asset", "markdown"] } }, + "allOf": [ + { + "if": { + "properties": { + "path": { "pattern": "^(https?://|figma://)" } + }, + "required": ["path"] + }, + "then": { + "anyOf": [ + { "required": ["byte_size", "sha256"] }, + { + "required": ["checksum_status"], + "properties": { + "checksum_status": { "enum": ["unavailable", "not_applicable"] } + } + } + ] + }, + "else": { "required": ["byte_size", "sha256"] } + } + ], "additionalProperties": true } }, diff --git a/extensions/intake/tests/test_extension_contract.py b/extensions/intake/tests/test_extension_contract.py index c2b8d2d2ff..bf844b8ca4 100644 --- a/extensions/intake/tests/test_extension_contract.py +++ b/extensions/intake/tests/test_extension_contract.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +import yaml ROOT = Path(__file__).resolve().parents[1] @@ -13,6 +14,7 @@ PRD_VALIDATOR = ROOT / "scripts" / "python" / "validate_prd_intake.py" TEST_CASE_VALIDATOR = ROOT / "scripts" / "python" / "validate_test_cases_intake.py" HTML_SSOT_VALIDATOR = ROOT / "scripts" / "python" / "validate_html_ssot.py" +STRUCTURED_IR_VALIDATOR = ROOT / "scripts" / "python" / "validate_structured_ir_intake.py" def write_visual_intake_fixture(intake: Path, source_type: str, fidelity: str, file_name: str): @@ -390,6 +392,99 @@ def write_html_ssot_fixture(html_dir: Path): (html_dir / "known-gaps.md").write_text("# Known Gaps\n\nNone.\n", encoding="utf-8") +def write_structured_ir_fixture(ir_dir: Path): + visual_intake = ir_dir.parent + write_visual_intake_fixture(visual_intake, "figma", "high", "figma-source.txt") + ir_dir.mkdir(parents=True, exist_ok=True) + + (ir_dir / "structured-ir.yaml").write_text( + "\n".join( + [ + "ir_complete: true", + "ir_item_count: 1", + "source_refs_complete: true", + "provider_evidence_complete: true", + "product_ambiguities_recorded: true", + "downstream_ownership_free: true", + "product_ambiguities: []", + "blocker_lint_errors: []", + "items:", + " - id: IR-home-save-default", + " source_refs:", + " - figma://node/2", + " visual_requirement_refs:", + " - ../visual-requirements.yaml#VR-001", + " html_ssot_refs:", + " - ../figma2htmlssot/visual-spec.html#[data-figma-node-id='2']", + " page: home", + " region: header", + " role: button", + " state: default", + " viewport: desktop", + " locator:", + " strategy: role", + " value: button[name='Save']", + " implementation_owned: false", + " expectations:", + " dom:", + " - button element is present", + " aria:", + " - accessible name is Save", + " design_tokens:", + " - token: color.primary", + " source_ref: figma://variables/color-primary", + " relations:", + " - type: appears-before", + " target: main content", + " acceptance_intent: Save control is semantically discoverable at desktop viewport", + " evidence_type: observed", + " confidence: high", + " status: ready", + " blockers: []", + "", + ] + ), + encoding="utf-8", + ) + + (ir_dir / "ir-assertions.yaml").write_text( + "\n".join( + [ + "assertions_complete: true", + "assertion_count: 1", + "ci_assertions_complete: true", + "blocker_lint_errors: []", + "assertions:", + " - id: IRA-home-save-visible", + " ir_refs:", + " - IR-home-save-default", + " assertion_type: visible", + " acceptance_intent: Save control is visible and discoverable", + " expected: true", + " evidence_refs:", + " - structured-ir.yaml#IR-home-save-default", + " ci_suitability: ci_low_cost", + " status: ready", + " blockers: []", + "", + ] + ), + encoding="utf-8", + ) + + (ir_dir / "ir-evidence-packet.md").write_text( + "---\n" + "ready_gate: PASS\n" + "blockers: []\n" + "source_ref_count: 1\n" + "extracted_item_count: 1\n" + "generated_at: '2026-07-01T00:00:00Z'\n" + "---\n" + "# Structured IR Evidence Packet\n", + encoding="utf-8", + ) + + def test_manifest_loads_with_spec_kit_checkout(): spec_kit_src = ROOT.parent / "spec-kit" / "src" if not spec_kit_src.exists(): @@ -403,8 +498,8 @@ def test_manifest_loads_with_spec_kit_checkout(): "from specify_cli.extensions import ExtensionManifest; " "m=ExtensionManifest(Path('extension.yml')); " "assert m.id == 'intake'; " - "assert len(m.commands) == 4; " - "assert {c['name'] for c in m.commands} == {'speckit.intake.visual-design', 'speckit.intake.figma2htmlssot', 'speckit.intake.prd', 'speckit.intake.test-cases'}; " + "assert len(m.commands) == 5; " + "assert {c['name'] for c in m.commands} == {'speckit.intake.visual-design', 'speckit.intake.figma2htmlssot', 'speckit.intake.ir', 'speckit.intake.prd', 'speckit.intake.test-cases'}; " "assert m.hooks" ) @@ -419,6 +514,53 @@ def test_manifest_loads_with_spec_kit_checkout(): assert result.returncode == 0, result.stderr +def test_config_template_matches_extension_defaults(): + extension = yaml.safe_load((ROOT / "extension.yml").read_text(encoding="utf-8-sig")) + config = yaml.safe_load((ROOT / "config-template.yml").read_text(encoding="utf-8")) + + defaults = extension["defaults"] + assert defaults["artifacts"] == config["artifacts"] + assert defaults["readiness"] == config["readiness"] + assert defaults["capture"] == config["capture"] + + +def test_manifest_declared_files_exist(): + extension = yaml.safe_load((ROOT / "extension.yml").read_text(encoding="utf-8-sig")) + + for command in extension["provides"]["commands"]: + assert (ROOT / command["file"]).exists(), command["file"] + for config in extension["provides"].get("config", []): + assert (ROOT / config["template"]).exists(), config["template"] + + for value in extension["defaults"]["artifacts"].values(): + if not isinstance(value, str): + continue + if value.startswith(("commands/", "templates/", "scripts/python/")): + assert (ROOT / value).exists(), value + + +def test_release_provenance_contract_is_documented_and_generated(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + workflow = (ROOT / ".github" / "workflows" / "extension-artifact.yml").read_text(encoding="utf-8") + for field in [ + "repository_url", + "release_version", + "source_commit_sha", + "download_url", + "validation_evidence", + ]: + assert field in readme + assert field in workflow + assert "release-provenance.json" in workflow + + +def test_readme_release_url_matches_extension_version(): + extension = yaml.safe_load((ROOT / "extension.yml").read_text(encoding="utf-8-sig")) + version = extension["extension"]["version"] + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert f"archive/refs/tags/v{version}.zip" in readme + + def test_html_ssot_schema_and_validator_paths_are_declared(): extension = ROOT / "extension.yml" config = ROOT / "config-template.yml" @@ -434,6 +576,25 @@ def test_html_ssot_schema_and_validator_paths_are_declared(): assert (ROOT / "templates" / "schemas" / "html-ssot-coverage.schema.json").exists() +def test_structured_ir_schema_and_validator_paths_are_declared(): + extension = ROOT / "extension.yml" + config = ROOT / "config-template.yml" + assert "commands/speckit.intake.ir.md" in extension.read_text(encoding="utf-8-sig") + for document in (extension.read_text(encoding="utf-8-sig"), config.read_text(encoding="utf-8")): + assert "scripts/python/validate_structured_ir_intake.py" in document + assert "templates/intake-structured-ir-contract.md" in document + assert "templates/intake-structured-ir-evidence-packet-template.md" in document + assert "templates/schemas/structured-ir.schema.json" in document + assert "templates/schemas/ir-assertions.schema.json" in document + + assert STRUCTURED_IR_VALIDATOR.exists() + assert (ROOT / "commands" / "speckit.intake.ir.md").exists() + assert (ROOT / "templates" / "intake-structured-ir-contract.md").exists() + assert (ROOT / "templates" / "intake-structured-ir-evidence-packet-template.md").exists() + assert (ROOT / "templates" / "schemas" / "structured-ir.schema.json").exists() + assert (ROOT / "templates" / "schemas" / "ir-assertions.schema.json").exists() + + def test_validator_blocks_missing_directory(): result = subprocess.run( [sys.executable, str(VALIDATOR), "missing-dir"], @@ -476,6 +637,20 @@ def test_test_case_validator_blocks_missing_directory(): assert "TEST_EVIDENCE_PACKET_MISSING" in result.stdout +def test_structured_ir_validator_blocks_missing_directory(): + result = subprocess.run( + [sys.executable, str(STRUCTURED_IR_VALIDATOR), "missing-dir"], + cwd=ROOT, + text=True, + capture_output=True, + ) + + assert result.returncode == 1 + assert "IR_SOURCE_INTAKE_BLOCKED" in result.stdout + assert "IR_REQUIRED_ARTIFACT_MISSING" in result.stdout + assert "IR_READY_WITHOUT_EVIDENCE" in result.stdout + + @pytest.mark.parametrize( ("source_type", "fidelity", "file_name"), [ @@ -504,6 +679,63 @@ def test_validator_passes_visual_source_matrix(source_type, fidelity, file_name) shutil.rmtree(work_dir) +def test_visual_validator_allows_remote_source_gap_but_blocks_integrity(): + work_dir = ROOT / ".tmp" / "test-validator-remote-source-gap" + if work_dir.exists(): + shutil.rmtree(work_dir) + intake = work_dir / "visual-design" + write_visual_intake_fixture(intake, "figma", "high", "figma-source.txt") + + (intake / "design-source-manifest.yaml").write_text( + "\n".join( + [ + "source_type: figma", + "required_fidelity: high", + "source_integrity_complete: false", + "captured_at: '2026-07-01T00:00:00Z'", + "capture_method: figma_url", + "page_or_frame_count: 1", + "processed_count: 1", + "extraction_scope: selected_node", + "snapshot_status: not_available", + "integrity_gap_reason: Figma source URL was provided without a local export snapshot.", + "retrieval_metadata:", + " retrieved_at: '2026-07-01T00:00:00Z'", + " stable_url: https://www.figma.com/file/example", + " visible_title: Fixture design", + "source_files:", + " - path: figma://file/example", + " mime_type: application/x-figma", + " checksum_status: unavailable", + " role: original", + "source_details:", + " file_url: https://www.figma.com/file/example", + " file_key: example", + " selected_node_ids:", + " - '1'", + "", + ] + ), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--json", str(intake)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert "VISUAL_SOURCE_INTEGRITY_INCOMPLETE" in payload["blockers"] + assert "VISUAL_SCHEMA_INVALID" not in payload["blockers"] + assert "VISUAL_SOURCE_FILE_MISSING" not in payload["blockers"] + assert payload["details"]["source_files"][0]["remote_ref"] is True + + shutil.rmtree(work_dir) + + def test_prd_validator_passes_complete_minimal_intake(): work_dir = ROOT / ".tmp" / "test-prd-validator-pass" if work_dir.exists(): @@ -524,6 +756,58 @@ def test_prd_validator_passes_complete_minimal_intake(): shutil.rmtree(work_dir) +def test_prd_validator_allows_remote_source_gap_but_blocks_integrity(): + work_dir = ROOT / ".tmp" / "test-prd-validator-remote-source-gap" + if work_dir.exists(): + shutil.rmtree(work_dir) + intake = work_dir / "prd" + write_prd_intake_fixture(intake) + + (intake / "source-manifest.yaml").write_text( + "\n".join( + [ + "source_type: url", + "source_integrity_complete: false", + "captured_at: '2026-07-01T00:00:00Z'", + "capture_method: remote_url", + "document_version: remote-v1", + "extraction_scope: full", + "snapshot_status: not_available", + "integrity_gap_reason: Source URL was accessible but no local snapshot was provided.", + "retrieval_metadata:", + " retrieved_at: '2026-07-01T00:00:00Z'", + " stable_url: https://example.com/prd", + " visible_title: Remote PRD", + " author_or_owner: product", + "source_files:", + " - path: https://example.com/prd", + " mime_type: text/html", + " checksum_status: unavailable", + " role: original", + "", + ] + ), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(PRD_VALIDATOR), "--json", str(intake)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert "PRD_SOURCE_INTEGRITY_INCOMPLETE" in payload["blockers"] + assert "PRD_READY_WITHOUT_EVIDENCE" in payload["blockers"] + assert "PRD_SCHEMA_INVALID" not in payload["blockers"] + assert "PRD_SOURCE_FILE_MISSING" not in payload["blockers"] + assert payload["details"]["source_files"][0]["remote_ref"] is True + + shutil.rmtree(work_dir) + + def test_prd_validator_blocks_untraceable_facts(): work_dir = ROOT / ".tmp" / "test-prd-validator-untraceable" if work_dir.exists(): @@ -579,6 +863,156 @@ def test_prd_validator_blocks_invalid_confidence_enum(): shutil.rmtree(work_dir) +@pytest.mark.parametrize( + ( + "kind", + "writer", + "validator", + "artifact", + "source_refs_line", + "schema_blocker", + "detail_key", + ), + [ + ( + "prd", + write_prd_intake_fixture, + PRD_VALIDATOR, + "prd-intake.yaml", + " source_refs: ['source-files/feature-prd.md#L3']", + "PRD_SCHEMA_INVALID", + "prd_intake", + ), + ( + "test-case", + write_test_case_intake_fixture, + TEST_CASE_VALIDATOR, + "test-case-intake.yaml", + " source_refs: ['source-files/test_feature.py#L1']", + "TEST_SCHEMA_INVALID", + "test_case_intake", + ), + ( + "visual", + write_image_visual_intake_fixture, + VALIDATOR, + "visual-requirements.yaml", + " source_refs: ['source-files/wireframe.png#full']", + "VISUAL_SCHEMA_INVALID", + "visual_requirements", + ), + ], +) +def test_validators_require_string_source_refs( + kind, + writer, + validator, + artifact, + source_refs_line, + schema_blocker, + detail_key, +): + work_dir = ROOT / ".tmp" / f"test-{kind}-validator-numeric-source-ref" + if work_dir.exists(): + shutil.rmtree(work_dir) + intake = work_dir / kind + writer(intake) + + path = intake / artifact + text = path.read_text(encoding="utf-8") + text = text.replace(source_refs_line, " source_refs: [123]") + path.write_text(text, encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(validator), "--json", str(intake)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert schema_blocker in payload["blockers"] + assert payload["details"]["schema_validation"][detail_key]["valid"] is False + + shutil.rmtree(work_dir) + + +@pytest.mark.parametrize( + ( + "kind", + "writer", + "validator", + "artifact", + "anchor_line", + "schema_blocker", + "detail_key", + ), + [ + ( + "prd", + write_prd_intake_fixture, + PRD_VALIDATOR, + "prd-intake.yaml", + " acceptance_or_validation_signal: Draft save behavior is explicitly stated.", + "PRD_SCHEMA_INVALID", + "prd_intake", + ), + ( + "test-case", + write_test_case_intake_fixture, + TEST_CASE_VALIDATOR, + "test-case-intake.yaml", + " coverage_signal: happy_path_present_error_path_missing", + "TEST_SCHEMA_INVALID", + "test_case_intake", + ), + ( + "visual", + write_image_visual_intake_fixture, + VALIDATOR, + "visual-requirements.yaml", + " fidelity_level: low", + "VISUAL_SCHEMA_INVALID", + "visual_requirements", + ), + ], +) +def test_validators_reject_unknown_blocker_codes( + kind, + writer, + validator, + artifact, + anchor_line, + schema_blocker, + detail_key, +): + work_dir = ROOT / ".tmp" / f"test-{kind}-validator-unknown-blocker" + if work_dir.exists(): + shutil.rmtree(work_dir) + intake = work_dir / kind + writer(intake) + + path = intake / artifact + text = path.read_text(encoding="utf-8") + text = text.replace(anchor_line, f"{anchor_line}\n blockers: [NOT_A_BLOCKER]") + path.write_text(text, encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(validator), "--json", str(intake)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert schema_blocker in payload["blockers"] + assert payload["details"]["schema_validation"][detail_key]["valid"] is False + + shutil.rmtree(work_dir) + + @pytest.mark.parametrize( ( "kind", @@ -826,6 +1260,57 @@ def test_test_case_validator_passes_complete_minimal_intake(): shutil.rmtree(work_dir) +def test_test_case_validator_allows_remote_source_gap_but_blocks_integrity(): + work_dir = ROOT / ".tmp" / "test-case-validator-remote-source-gap" + if work_dir.exists(): + shutil.rmtree(work_dir) + intake = work_dir / "test-cases" + write_test_case_intake_fixture(intake) + + (intake / "source-manifest.yaml").write_text( + "\n".join( + [ + "source_type: issue", + "source_integrity_complete: false", + "captured_at: '2026-07-01T00:00:00Z'", + "capture_method: remote_issue", + "framework_or_format: issue", + "execution_scope: regression", + "snapshot_status: not_available", + "integrity_gap_reason: Issue was referenced without a local exported snapshot.", + "retrieval_metadata:", + " retrieved_at: '2026-07-01T00:00:00Z'", + " stable_url: https://example.com/issues/1", + " visible_title: Regression case", + "source_files:", + " - path: https://example.com/issues/1", + " mime_type: text/html", + " checksum_status: unavailable", + " role: original", + "", + ] + ), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(TEST_CASE_VALIDATOR), "--json", str(intake)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert "TEST_SOURCE_INTEGRITY_INCOMPLETE" in payload["blockers"] + assert "TEST_READY_WITHOUT_EVIDENCE" in payload["blockers"] + assert "TEST_SCHEMA_INVALID" not in payload["blockers"] + assert "TEST_SOURCE_FILE_MISSING" not in payload["blockers"] + assert payload["details"]["source_files"][0]["remote_ref"] is True + + shutil.rmtree(work_dir) + + def test_test_case_validator_blocks_missing_assertions_and_coverage(): work_dir = ROOT / ".tmp" / "test-case-validator-missing-assertions" if work_dir.exists(): @@ -1410,3 +1895,211 @@ def test_html_ssot_validator_blocks_incomplete_coverage(edit_kind, expected_bloc assert expected_blocker in payload["blockers"] shutil.rmtree(work_dir) + + +def test_structured_ir_validator_passes_complete_minimal_bundle(): + work_dir = ROOT / ".tmp" / "test-structured-ir-validator-pass" + if work_dir.exists(): + shutil.rmtree(work_dir) + ir_dir = work_dir / "visual-design" / "structured-ir" + write_structured_ir_fixture(ir_dir) + + result = subprocess.run( + [sys.executable, str(STRUCTURED_IR_VALIDATOR), str(ir_dir)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Structured IR intake readiness: PASS" in result.stdout + + shutil.rmtree(work_dir) + + +def test_structured_ir_validator_blocks_source_intake_blocked(): + work_dir = ROOT / ".tmp" / "test-structured-ir-source-blocked" + if work_dir.exists(): + shutil.rmtree(work_dir) + ir_dir = work_dir / "visual-design" / "structured-ir" + write_structured_ir_fixture(ir_dir) + packet = ir_dir.parent / "visual-evidence-packet.md" + packet.write_text( + "---\n" + "ready_gate: BLOCKED\n" + "blockers: [VISUAL_REQUIREMENTS_MISSING]\n" + "source_ref_count: 1\n" + "extracted_item_count: 0\n" + "generated_at: '2026-07-01T00:00:00Z'\n" + "---\n", + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(STRUCTURED_IR_VALIDATOR), "--json", str(ir_dir)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert "IR_SOURCE_INTAKE_BLOCKED" in payload["blockers"] + assert "IR_READY_WITHOUT_EVIDENCE" in payload["blockers"] + + shutil.rmtree(work_dir) + + +def test_structured_ir_validator_reports_schema_errors_in_json(): + work_dir = ROOT / ".tmp" / "test-structured-ir-schema-error" + if work_dir.exists(): + shutil.rmtree(work_dir) + ir_dir = work_dir / "visual-design" / "structured-ir" + write_structured_ir_fixture(ir_dir) + text = (ir_dir / "structured-ir.yaml").read_text(encoding="utf-8") + (ir_dir / "structured-ir.yaml").write_text( + text.replace(" role: button\n", ""), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(STRUCTURED_IR_VALIDATOR), "--json", str(ir_dir)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert "IR_SCHEMA_INVALID" in payload["blockers"] + assert payload["details"]["schema_validation"]["structured_ir"]["valid"] is False + + shutil.rmtree(work_dir) + + +@pytest.mark.parametrize( + ("artifact", "detail_key"), + [ + ("structured-ir.yaml", "structured_ir"), + ("ir-assertions.yaml", "ir_assertions"), + ], +) +def test_structured_ir_validator_rejects_unknown_blocker_codes(artifact, detail_key): + work_dir = ROOT / ".tmp" / f"test-structured-ir-unknown-blocker-{detail_key}" + if work_dir.exists(): + shutil.rmtree(work_dir) + ir_dir = work_dir / "visual-design" / "structured-ir" + write_structured_ir_fixture(ir_dir) + + path = ir_dir / artifact + text = path.read_text(encoding="utf-8") + path.write_text(text.replace(" blockers: []", " blockers: [NOT_A_BLOCKER]", 1), encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(STRUCTURED_IR_VALIDATOR), "--json", str(ir_dir)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert "IR_SCHEMA_INVALID" in payload["blockers"] + assert payload["details"]["schema_validation"][detail_key]["valid"] is False + + shutil.rmtree(work_dir) + + +@pytest.mark.parametrize( + ("edit_kind", "expected_blocker"), + [ + ("provider_evidence", "IR_PROVIDER_EVIDENCE_MISSING"), + ("provider_evidence_blocker", "IR_PROVIDER_EVIDENCE_MISSING"), + ("product_ambiguity", "IR_PRODUCT_AMBIGUITY_UNRESOLVED"), + ("locator", "IR_LOCATOR_STRATEGY_INVALID"), + ("ownership", "IR_DOWNSTREAM_OWNERSHIP_LEAK"), + ("assertion_coverage", "IR_ASSERTION_COVERAGE_INCOMPLETE"), + ("assertion_blocker", "IR_ASSERTION_COVERAGE_INCOMPLETE"), + ("cross_ref", "IR_ASSERTION_COVERAGE_INCOMPLETE"), + ("evidence_packet", "IR_READY_WITHOUT_EVIDENCE"), + ], +) +def test_structured_ir_validator_blocks_readiness_failures(edit_kind, expected_blocker): + work_dir = ROOT / ".tmp" / f"test-structured-ir-{edit_kind}" + if work_dir.exists(): + shutil.rmtree(work_dir) + ir_dir = work_dir / "visual-design" / "structured-ir" + write_structured_ir_fixture(ir_dir) + + if edit_kind == "provider_evidence": + text = (ir_dir / "structured-ir.yaml").read_text(encoding="utf-8") + (ir_dir / "structured-ir.yaml").write_text( + text.replace("provider_evidence_complete: true", "provider_evidence_complete: false"), + encoding="utf-8", + ) + elif edit_kind == "provider_evidence_blocker": + text = (ir_dir / "structured-ir.yaml").read_text(encoding="utf-8") + (ir_dir / "structured-ir.yaml").write_text( + text.replace(" blockers: []", " blockers: [IR_PROVIDER_EVIDENCE_MISSING]", 1), + encoding="utf-8", + ) + elif edit_kind == "product_ambiguity": + text = (ir_dir / "structured-ir.yaml").read_text(encoding="utf-8") + (ir_dir / "structured-ir.yaml").write_text( + text.replace("product_ambiguities: []", "product_ambiguities:\n - Save disabled conditions are not specified."), + encoding="utf-8", + ) + elif edit_kind == "locator": + text = (ir_dir / "structured-ir.yaml").read_text(encoding="utf-8") + (ir_dir / "structured-ir.yaml").write_text( + text.replace(" value: button[name='Save']", " value: '#save-button'"), + encoding="utf-8", + ) + elif edit_kind == "ownership": + text = (ir_dir / "structured-ir.yaml").read_text(encoding="utf-8") + (ir_dir / "structured-ir.yaml").write_text( + text.replace(" blockers: []", " blockers: []\n code_component: SaveButton", 1), + encoding="utf-8", + ) + elif edit_kind == "assertion_coverage": + text = (ir_dir / "ir-assertions.yaml").read_text(encoding="utf-8") + (ir_dir / "ir-assertions.yaml").write_text( + text.replace(" ci_suitability: ci_low_cost", " ci_suitability: manual_review"), + encoding="utf-8", + ) + elif edit_kind == "assertion_blocker": + text = (ir_dir / "ir-assertions.yaml").read_text(encoding="utf-8") + (ir_dir / "ir-assertions.yaml").write_text( + text.replace(" blockers: []", " blockers: [IR_PROVIDER_EVIDENCE_MISSING]", 1), + encoding="utf-8", + ) + elif edit_kind == "cross_ref": + text = (ir_dir / "ir-assertions.yaml").read_text(encoding="utf-8") + (ir_dir / "ir-assertions.yaml").write_text( + text.replace(" - IR-home-save-default", " - IR-missing"), + encoding="utf-8", + ) + elif edit_kind == "evidence_packet": + (ir_dir / "ir-evidence-packet.md").write_text( + "---\n" + "ready_gate: BLOCKED\n" + "blockers: [IR_PROVIDER_EVIDENCE_MISSING]\n" + "source_ref_count: 1\n" + "extracted_item_count: 1\n" + "generated_at: '2026-07-01T00:00:00Z'\n" + "---\n", + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(STRUCTURED_IR_VALIDATOR), "--json", str(ir_dir)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert expected_blocker in payload["blockers"] + + shutil.rmtree(work_dir) diff --git a/tests/integrations/community_defaults.py b/tests/integrations/community_defaults.py index 0b19f24635..5944d14b53 100644 --- a/tests/integrations/community_defaults.py +++ b/tests/integrations/community_defaults.py @@ -31,6 +31,7 @@ "speckit.discovery.decision", "speckit.intake.visual-design", "speckit.intake.figma2htmlssot", + "speckit.intake.ir", "speckit.intake.prd", "speckit.intake.test-cases", "speckit.preview.low-md",