diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3246393 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts run under Linux bash, so they keep LF line endings even when a +# contributor's Git uses core.autocrlf=true (CI owner review, 6 October 2026). +# A shell script without one of these extensions gets its own line below. +*.sh text eol=lf +*.bash text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bfff1c5..d3b792e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,40 +1,58 @@ name: Bug report -description: Report a reproducible software or firmware defect +description: Report a reproducible software or firmware defect with the evidence a fix needs title: "[Bug]: " labels: ["bug", "triage"] body: - type: markdown attributes: - value: "Do not include credentials, confidential information, export-controlled data, or third-party material you cannot disclose." - - type: textarea - id: summary + value: | + Issues are public. Do not include credentials, personal data, internal document links or prices. + The fix PR links this issue in its Work package section and adds a test that fails on the defect. + - type: input + id: repository + attributes: + label: Repository and path + placeholder: "openAMRobot/, path/to/file" + validations: + required: true + - type: input + id: commit attributes: - label: Summary - description: What happened, and what did you expect? + label: Commit SHA + description: Full or short SHA of the checkout where the defect reproduces. + placeholder: "d1ac6b64db83" validations: required: true - type: textarea id: reproduce attributes: - label: Reproduction steps + label: Exact commands + description: Commands that reproduce the defect, one per line, from a clean checkout. + render: shell validations: required: true - - type: input - id: version + - type: textarea + id: expected attributes: - label: Repository version, tag, or commit + label: Expected and actual result + description: Paste the relevant output; state the decision ID from decisions.yaml if a decided value is wrong. validations: required: true - - type: textarea - id: environment + - type: dropdown + id: safety attributes: - label: Environment - description: OS, ROS version, hardware, browser, and relevant dependencies. + label: Safety impact + description: E-stop, brake, contactor, watchdog, motor-enable and charge-inhibit defects are reported, never fixed by an agent. + options: + - None + - Motion, power, battery, actuator or safety I/O (platform lead reviews) + validations: + required: true - type: textarea - id: logs + id: not_verified attributes: - label: Logs and supporting evidence - description: Remove personal, confidential, and security-sensitive information. + label: Not verified + description: What you could not check, for example real hardware or another ROS distribution. - type: checkboxes id: provenance attributes: diff --git a/.github/ISSUE_TEMPLATE/interface_change_request.yml b/.github/ISSUE_TEMPLATE/contract_change_request.yml similarity index 64% rename from .github/ISSUE_TEMPLATE/interface_change_request.yml rename to .github/ISSUE_TEMPLATE/contract_change_request.yml index 1748695..002c9d7 100644 --- a/.github/ISSUE_TEMPLATE/interface_change_request.yml +++ b/.github/ISSUE_TEMPLATE/contract_change_request.yml @@ -1,11 +1,20 @@ -name: Interface change request -description: Propose a controlled change to a ROS, API, message, schema, or other shared interface. -title: "[INTERFACE] " +name: Contract change request +description: Propose a change to a message, service, action, schema, topic name, launch argument name or configuration ID. +title: "[CONTRACT] " +labels: ["contract-change", "triage"] body: - type: markdown attributes: value: | - Use this form before changing an interface consumed by another repository, node, service, tool, or operator surface. + Use this form before changing a contract consumed by another repository, node, service, tool, or operator surface: + messages, services, actions, schemas, topic names, launch argument names and configuration IDs (for example mast_1350). + The change lands in one PR that updates the contract package and every consumer together; that PR links this issue + in its Work package section. + + To change an approved technical decision (a value, limit, exclusion or distinction in the decisions register, + openAMRobot/.github decisions.yaml), follow "Changing a decision" in AGENTS.md: this issue names the entry, the owner + updates the source document, and one reviewed PR updates the register entry, its supersedes history, its check + patterns with a test, and every affected consumer together. No tool rewrites the register or a source document. Keep generic interfaces generic. Do not add application-specific manipulation behavior or authoritative safety behavior to a telemetry or navigation contract without an explicit owning design decision. @@ -22,11 +31,20 @@ body: id: contract attributes: label: Contract or interface - description: Name the message, service, action, API, schema, topic, or file being changed. + description: Name the message, service, action, schema, topic name, launch argument name, configuration ID or file being changed. placeholder: "repository, package, path, and interface name" validations: required: true + - type: input + id: decision_entry + attributes: + label: Decisions register entry + description: The decisions.yaml ID this request changes, or "none" for a contract that is not a recorded decision. + placeholder: "MAST-INSTALL-HEIGHT / none" + validations: + required: true + - type: dropdown id: change_class attributes: @@ -35,9 +53,9 @@ body: options: - Documentation-only or implementation clarification - Add an enum, reason code, or constant without changing field layout - - Add, remove, rename, reorder, or retype a field — version bump and consumer rebuild required - - Change the meaning, units, defaults, or validity of an existing value — version bump and consumer review required - - Other — explain below + - Add, remove, rename, reorder, or retype a field; version bump and consumer rebuild required + - Change the meaning, units, defaults, or validity of an existing value; version bump and consumer review required + - Other, explain below validations: required: true @@ -107,3 +125,5 @@ body: required: true - label: I have not treated telemetry as authoritative safety evidence. required: true + - label: The implementing PR will update the contract package and its consumers together, or list each consumer PR. + required: true diff --git a/.github/ISSUE_TEMPLATE/decision_review.yml b/.github/ISSUE_TEMPLATE/decision_review.yml new file mode 100644 index 0000000..a2c86ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/decision_review.yml @@ -0,0 +1,63 @@ +name: Decision register review +description: Confirm or change one approved decision after the Watchdog review reminder. +title: "[decision-review] " +labels: + - decision-review + - watchdog-review +body: + - type: markdown + attributes: + value: | + Use this form for one `decisions.yaml` entry. Do not paste private Drive links, prices, + credentials or customer information into a public issue. The Watchdog never changes the + register automatically; the final update is a reviewed pull request. + - type: input + id: decision_id + attributes: + label: Decision ID + description: The exact entry ID, for example COMPUTE or NAV-LIDAR. + placeholder: COMPUTE + validations: + required: true + - type: dropdown + id: outcome + attributes: + label: Review outcome + options: + - Confirm current decision + - Propose a decision change + - Insufficient evidence + validations: + required: true + - type: input + id: source_key + attributes: + label: Controlling source key and item + description: Use a traceable key and item/date, not a private URL. + placeholder: P-03-rev18.7 item 14, 6 October 2026 + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Evidence summary + description: Summarize what was checked and include public repository paths or commit SHAs where useful. + placeholder: The controlling source still says ...; implementation checked at ... + validations: + required: true + - type: textarea + id: requested_action + attributes: + label: Requested action + description: State whether the owner should extend review_by, open a source change, or update consumers. + validations: + required: true + - type: checkboxes + id: ground_truth + attributes: + label: Ground-truth confirmation + options: + - label: I am responsible for, or have obtained, the controlling-source confirmation. + required: true + - label: I have not included private links, credentials, customer information or unverified values. + required: true diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.yml b/.github/ISSUE_TEMPLATE/good_first_issue.yml new file mode 100644 index 0000000..29a7579 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/good_first_issue.yml @@ -0,0 +1,63 @@ +name: Good first issue +description: A small, self-contained task for a first-time contributor (maintainers open these) +title: "[Good first issue]: " +labels: ["good first issue", "triage"] +body: + - type: markdown + attributes: + value: | + A good first issue can be done in one PR, needs no hardware and no access beyond a fork, and + touches no safety path or shared contract. Maintainers add the area label from CONTRIBUTING.md. + - type: dropdown + id: area + attributes: + label: Area + options: + - documentation (openamrobot-docs) + - navigation and bring-up (openamr-platform-sw) + - interfaces (openamrobot-interfaces) + - manipulation (openamrobot-manipulation) + - operator UI (openamrobot-ui) + - manifest and release (openamrobot-manifest, openamrobot-release) + - CI and harness (.github) + validations: + required: true + - type: input + id: repository + attributes: + label: Repository and files + placeholder: "openAMRobot/: path/to/file" + validations: + required: true + - type: textarea + id: task + attributes: + label: Task + description: What needs to become true, in two or three sentences. + validations: + required: true + - type: textarea + id: done + attributes: + label: Done when + description: Observable pass/fail conditions, including the test that must fail before and pass after. + value: | + - [ ] + validations: + required: true + - type: textarea + id: verify + attributes: + label: How to verify + description: The exact command a contributor runs, and the result it prints. + render: shell + validations: + required: true + - type: input + id: mentor + attributes: + label: Reviewer role + description: Role from maintainers.yaml that reviews the PR. + placeholder: "docs-owner" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/harness_mistake.yml b/.github/ISSUE_TEMPLATE/harness_mistake.yml new file mode 100644 index 0000000..1f744a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/harness_mistake.yml @@ -0,0 +1,61 @@ +name: Harness mistake +description: Record an agent or process mistake so the monthly retro can turn it into a rule, check or template change +title: "[Harness]: " +labels: ["harness"] +body: + - type: markdown + attributes: + value: | + One mistake per issue. The monthly retro reads every open issue with the label harness and + proposes one PR against AGENTS.md, the checkers or the templates in openAMRobot/.github. + Describe the mistake, not the person. No names, internal links or credentials. + - type: input + id: where + attributes: + label: Where it happened + description: Repository and PR or issue link. + placeholder: "openAMRobot/#" + validations: + required: true + - type: dropdown + id: actor + attributes: + label: Who made the mistake + options: + - Agent (AI-assisted or automated) + - Contributor process + - Review process + - Automation or workflow + validations: + required: true + - type: input + id: template + attributes: + label: Prompt template and version + description: For agent runs, the agent-prompts/ file and the harness commit SHA it came from. + placeholder: "agent-prompts/docs-fix.md @ " + - type: textarea + id: mistake + attributes: + label: What went wrong + description: The observable result and the evidence (diff line, log line, comment link). + validations: + required: true + - type: dropdown + id: caught + attributes: + label: What caught it + options: + - A gate (check or CI) + - Human review + - Found after merge + - Not caught yet + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed harness change + description: A rule, a check, or a template change that would make this class of mistake unmergeable. Name who decides if it cannot be checked. + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7bc74eb..843187b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,41 +1,62 @@ + + ## Summary -Describe what changed, why it is needed, and the exact repository scope. + + +## Work package + + + +## Integration Gate + + + +## Tests + + + +## Evidence + +Base SHA: +Head SHA: + +``` + +``` + +## Dependencies -## Standards impact + -- **Repository type / subsystem:** -- **Safety impact:** none / motion / power / battery / actuator / safety-I/O / other -- **Interface or compatibility impact:** -- **Documentation impact and canonical source:** -- **Readiness impact:** none / evidence added / evidence invalidated +## Safety impact -- [ ] I reviewed the [Engineering Quality Standard](../ENGINEERING_QUALITY_STANDARD.md). -- [ ] Documentation changes follow the [Documentation Information Architecture](https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_INFORMATION_ARCHITECTURE.md). -- [ ] I have not described planned or unverified work as passing, validated, production-ready, or release-ready. + -## Validation +## STATE.md -List commands, tests, simulation runs, hardware checks, documentation checks, and observable results. + -## Safety and compatibility +## Not verified -Describe effects on robot motion, power, actuators, batteries, safety I/O, networking, APIs, interfaces, migration, releases, or supported hardware. Write “None” only after considering each area. + -## Intellectual property and provenance +## AI disclosure -- [ ] Every commit is signed off under the [DCO](../DCO.md). -- [ ] I am covered by an accepted [Individual or Corporate Contributor Agreement](../CLA.md). -- [ ] I have authority from any relevant employer, university, client, sponsor, co-author, or organization. -- [ ] I identified all third-party code, designs, data, media, models, and adapted examples with source and licence. -- [ ] I disclosed material generative-AI assistance and reviewed the output for provenance, security, correctness, and licence risk. -- [ ] I did not submit unauthorized confidential, personal, proprietary, credential, or export-controlled information. -- [ ] Required copyright, licence, patent, modification, and attribution notices are included. + -## Quality +## Contribution terms -- [ ] The change is focused and contains no unrelated artifacts. -- [ ] Documentation and notices are updated. -- [ ] Tests appropriate to the change pass. -- [ ] I reviewed the final diff. -- [ ] I understand that submission does not guarantee acceptance and that accepted contributions are governed by the Contributor Agreement and applicable outbound licence. +- [ ] Every commit is signed off under the [DCO](https://github.com/openAMRobot/.github/blob/main/DCO.md). +- [ ] I am covered by an accepted [Contributor Agreement](https://github.com/openAMRobot/.github/blob/main/CLA.md). +- [ ] Third-party material is identified with source and licence. +- [ ] No confidential, personal, credential or export-controlled information is included. +- [ ] No partner, customer or private person is named; the application is Use_Case_1. diff --git a/.github/workflows/repository-quality-reusable.yml b/.github/workflows/repository-quality-reusable.yml index 8601a47..302f30e 100644 --- a/.github/workflows/repository-quality-reusable.yml +++ b/.github/workflows/repository-quality-reusable.yml @@ -2,6 +2,47 @@ name: Reusable repository quality on: workflow_call: + inputs: + harness_ref: + description: >- + Ref of openAMRobot/.github that provides decisions.yaml and the checkers. + Pin it to the same commit SHA as the `uses:` line of the caller. + type: string + required: false + default: main + harness_checks: + description: >- + Run the decisions, public-extract and shared-rules checks. Off by default so that + existing callers on @main are unchanged until they opt in (rollout/README.md). + type: boolean + required: false + default: false + harness_warn: + description: >- + Warn-only mode for rollout step (c): run the same checks and report every finding as a + warning, never failing the job. Use it on main until one run is green, then switch to + harness_checks: true. If both are true, harness_checks (enforcing) wins. + type: boolean + required: false + default: false + agents_md_exception: + description: >- + Non-empty, human-readable reason to allow a repository without AGENTS.md when + harness_checks is true. The reason is printed in the job summary. + type: string + required: false + default: "" + + verify: + description: Run rollout/verify.sh (or the repository's own tools/verify.sh) as job quality/test. + type: boolean + required: false + default: false + verify_container: + description: Container image for the verify job, e.g. ros:jazzy-ros-base for ROS 2 repositories. + type: string + required: false + default: "" permissions: contents: read @@ -12,7 +53,20 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history only when the harness checks run (they diff against the base); + # default callers keep the shallow checkout. The string '0' keeps the + # expression truthy; a bare 0 would always fall through to 1. + fetch-depth: ${{ (inputs.harness_checks || inputs.harness_warn) && '0' || '1' }} + + - name: Check out OpenAMRobot harness + if: inputs.harness_checks || inputs.harness_warn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: openAMRobot/.github + ref: ${{ inputs.harness_ref }} + path: .openamrobot-harness - name: Verify governance baseline shell: bash @@ -53,3 +107,169 @@ jobs: for path in tracked("*.xml"): ET.parse(path) + + - name: Prepare harness checks + if: inputs.harness_checks || inputs.harness_warn + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + if [ "$EVENT_NAME" = pull_request ]; then + git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/changed-files.txt" + echo "scope=changed files ($(wc -l < "$RUNNER_TEMP/changed-files.txt"))" + fi + + - name: Workflow policy + if: inputs.harness_checks || inputs.harness_warn + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE: ${{ inputs.harness_checks }} + run: | + set -uo pipefail + h=.openamrobot-harness + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ] && [ "$EVENT_NAME" = pull_request ]; then export WATCHDOG_ANNOTATION=error; fi + python3 "$h/tools/check_workflow_policy.py" --root . | tee "$RUNNER_TEMP/workflow-policy.txt" + status=${PIPESTATUS[0]} + if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then + exit "$status" + fi + if [ "$status" -ne 0 ]; then + echo "::warning::workflow policy exit $status reported, not enforced (warn-only mode or push scan)" + fi + + # harness_checks: true (enforcing): pull requests scan changed files and block; + # push and schedule scan the full checkout and report warnings; an invalid + # register or usage error (exit 2) always fails. + # harness_warn: true only (rollout step c): same scans, every finding is a warning. + - name: Decisions of record + if: inputs.harness_checks || inputs.harness_warn + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.event.repository.name }} + ENFORCE: ${{ inputs.harness_checks }} + run: | + set -uo pipefail + h=.openamrobot-harness + scope=() + if [ "$EVENT_NAME" = pull_request ]; then + scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") + fi + # Findings become inline annotations (tools/watchdog_report.py): errors when they block. + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ] && [ "$EVENT_NAME" = pull_request ]; then export WATCHDOG_ANNOTATION=error; fi + python3 "$h/tools/check_decisions.py" --decisions "$h/decisions.yaml" \ + --maintainers "$h/maintainers.yaml" --root . \ + --repository "$REPOSITORY" "${scope[@]}" | tee "$RUNNER_TEMP/decisions.txt" + status=${PIPESTATUS[0]} + if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then + exit "$status" + fi + if [ "$status" -ne 0 ]; then + echo "::warning::exit $status reported, not enforced (warn-only mode or push scan)" + fi + + - name: Public extract + if: inputs.harness_checks || inputs.harness_warn + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.event.repository.name }} + ENFORCE: ${{ inputs.harness_checks }} + run: | + set -uo pipefail + h=.openamrobot-harness + scope=() + if [ "$EVENT_NAME" = pull_request ]; then + scope=(--changed-files "$RUNNER_TEMP/changed-files.txt") + fi + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ] && [ "$EVENT_NAME" = pull_request ]; then export WATCHDOG_ANNOTATION=error; fi + python3 "$h/tools/check_public_extract.py" --root . --allowlist "$h/public-extract-allowlist.yaml" \ + --repository "$REPOSITORY" "${scope[@]}" | tee "$RUNNER_TEMP/extract.txt" + status=${PIPESTATUS[0]} + if [ "$ENFORCE" = true ] && { [ "$EVENT_NAME" = pull_request ] || [ "$status" -eq 2 ]; }; then + exit "$status" + fi + if [ "$status" -ne 0 ]; then + echo "::warning::exit $status reported, not enforced (warn-only mode or push scan)" + fi + + - name: Shared agent rules + if: inputs.harness_checks || inputs.harness_warn + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.event.repository.name }} + ENFORCE: ${{ inputs.harness_checks }} + AGENTS_MD_EXCEPTION: ${{ inputs.agents_md_exception }} + run: | + set -uo pipefail + if [ -f AGENTS.md ]; then + export WATCHDOG_ANNOTATION=warning + if [ "$ENFORCE" = true ]; then export WATCHDOG_ANNOTATION=error; fi + python3 .openamrobot-harness/tools/check_agent_rules.py \ + --canonical .openamrobot-harness/agent-rules/SHARED_RULES.md --file AGENTS.md + status=$? + if [ "$status" -ne 0 ] && [ "$ENFORCE" = true ]; then exit "$status"; fi + if [ "$status" -ne 0 ]; then + echo "::warning::shared rules drift (exit $status), not enforced in warn-only mode" + fi + else + if [ -n "$AGENTS_MD_EXCEPTION" ]; then + echo "AGENTS.md exception: $AGENTS_MD_EXCEPTION" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### AGENTS.md exception" + echo + echo "$AGENTS_MD_EXCEPTION" + } >> "$GITHUB_STEP_SUMMARY" + fi + elif [ "$ENFORCE" = true ]; then + echo "::error::AGENTS.md is required when harness_checks is true; pass agents_md_exception with a human-readable reason" + exit 1 + else + echo "::warning::No AGENTS.md; pass agents_md_exception with a human-readable reason before enforcing" + fi + fi + + verify: + name: quality/test + if: inputs.verify + runs-on: ubuntu-24.04 + container: ${{ inputs.verify_container || null }} + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Check out OpenAMRobot harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: openAMRobot/.github + ref: ${{ inputs.harness_ref }} + path: .openamrobot-harness + + - name: Verify (install, build, lint, test, evidence; zero tests fail) + shell: bash + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + VERIFY_PATH="$PATH" bash .openamrobot-harness/rollout/verify.sh "$GITHUB_WORKSPACE" + + - name: Upload verification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verification-evidence + path: .verification/run.*/ + include-hidden-files: true + if-no-files-found: warn + diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index d01ecc4..7793633 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: permissions: contents: read @@ -12,3 +13,77 @@ jobs: repository-quality: name: repository-quality uses: ./.github/workflows/repository-quality-reusable.yml + with: + # This repository is the harness: check each change against its own head. + harness_ref: ${{ github.event.pull_request.head.sha || github.sha }} + harness_checks: true + + harness-tests: + name: quality/test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Unit tests of every checker (zero-test guard) + shell: bash + run: | + # verify.sh runs tests without user site-packages; use the system package. + python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + # The lint stage runs shellcheck when present; install it so the result does not depend on the image. + command -v shellcheck >/dev/null || { sudo apt-get update && sudo apt-get install -y shellcheck; } + VERIFY_PATH="$PATH" bash rollout/verify.sh + - name: Validate decisions.yaml + run: python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only + - name: Upload evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: harness-verification + path: .verification/run.*/ + include-hidden-files: true + if-no-files-found: warn + + ros-verifier-smoke: + name: ros-verifier-smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Check out pinned interfaces fixture + env: + INTERFACES_SHA: fa7c438e33bd807c174fa0747119d7d627faa3bf + run: | + set -euo pipefail + interfaces="$RUNNER_TEMP/openamrobot-interfaces" + git clone --quiet https://github.com/openAMRobot/openamrobot-interfaces.git "$interfaces" + git -C "$interfaces" checkout --quiet --detach "$INTERFACES_SHA" + # Exercise the source-only scan with normal generated workspace directories present. + mkdir -p "$interfaces/ros2/build" "$interfaces/ros2/install" "$interfaces/ros2/log" + test -d "$interfaces/ros2/build" + test -d "$interfaces/ros2/install" + test -d "$interfaces/ros2/log" + + - name: Run generic verifier in ROS 2 Jazzy + run: | + set -euo pipefail + interfaces="$RUNNER_TEMP/openamrobot-interfaces" + docker run --rm --init \ + -v "$GITHUB_WORKSPACE:/work/harness:ro" \ + -v "$interfaces:/work/interfaces" \ + -w /work \ + ros:jazzy-ros-base bash -ceu ' + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip python3-jsonschema ros-jazzy-rmw-cyclonedds-cpp shellcheck + rosdep update + python3 -c "import jsonschema" 2>/dev/null || python3 -m pip install --break-system-packages --quiet jsonschema + git config --global --add safe.directory /work/interfaces + test -d /work/interfaces/ros2/build + test -d /work/interfaces/ros2/install + test -d /work/interfaces/ros2/log + VERIFY_NO_DELEGATE=1 VERIFY_TEST="bash tools/verify.sh" bash /work/harness/rollout/verify.sh /work/interfaces + ' diff --git a/.github/workflows/watchdog-org-scan.yml b/.github/workflows/watchdog-org-scan.yml new file mode 100644 index 0000000..522e032 --- /dev/null +++ b/.github/workflows/watchdog-org-scan.yml @@ -0,0 +1,207 @@ +name: Watchdog organization scan + +# Deterministic organization scan. Product repositories are cloned read-only. The only write +# is a central, deduplicated issue control surface in openAMRobot/.github. The workflow never +# edits decisions.yaml, pushes a branch, opens a PR, or runs an AI agent. See WATCHDOG.md. +# +# Fail closed: the scan is COMPLETE only when every repository in rollout/repositories.yaml was +# scanned. A repository that cannot be listed, cloned, scanned or reported is BLOCKED; the run is +# then INCOMPLETE, the dashboard says so, missing repositories are never treated as clean, and +# the job fails. See WATCHDOG.md, "Incomplete scans". +# +# Schedule: every Thursday at 14:00 Berlin time (timezone Europe/Berlin, so the run follows +# daylight saving time), plus manual dispatch. +# +# Issue mode: the repository variable WATCHDOG_ISSUE_MODE (Settings > Secrets and variables > +# Actions > Variables) selects what the sync step writes. +# unset or "dashboard" only the single "[watchdog] Organization dashboard" issue, updated in +# place; it lists every active group and every scan-blocked repository. +# "groups" also one deduplicated issue per active group (finding, decision review, +# blocked scan), as described in WATCHDOG.md. +# The platform lead decides when to switch to "groups" (WATCHDOG.md, "Watchdog issue mode"). + +on: + schedule: + - cron: "0 14 * * 4" + timezone: "Europe/Berlin" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: watchdog-org-scan + cancel-in-progress: false + +jobs: + scan: + name: watchdog-org-scan + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: harness + persist-credentials: false + + - name: Scan repositories + shell: bash + env: + WATCHDOG_ANNOTATIONS: "0" + HARNESS: harness + WORK: ${{ runner.temp }}/watchdog + run: | + # Fail closed: any unguarded error stops the step, and the EXIT trap marks the scan + # INCOMPLETE. Commands that may fail for one repository are guarded so that + # repository is recorded as BLOCKED and the loop continues. + set -euo pipefail + summary="${GITHUB_STEP_SUMMARY:-$WORK/summary.md}" + support="$HARNESS/tools/watchdog_scan_support.py" + finished=0 + on_exit() { + if [ "$finished" -ne 1 ]; then + { + echo "## OpenAMRobot Watchdog organization scan: INCOMPLETE" + echo + echo "Scan INCOMPLETE: the scan step stopped before every repository was scanned. Missing repositories are not clean." + } >> "$summary" + echo "::error::Scan INCOMPLETE: the scan step stopped before every repository was scanned" + fi + } + trap on_exit EXIT + mkdir -p "$WORK/repos" "$WORK/out" + rm -f "$WORK/expected.json" "$WORK/list.txt" + echo '[]' > "$WORK/blocked.json" + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + + # Repository list: a failure stops the step; expected.json is written before list.txt. + if ! python3 "$support" list --repositories "$HARNESS/rollout/repositories.yaml" \ + --list "$WORK/list.txt" --expected "$WORK/expected.json"; then + { + echo "## OpenAMRobot Watchdog organization scan: INCOMPLETE" + echo + echo "Scan INCOMPLETE: repository list could not be generated." + } >> "$summary" + echo "::error::Scan INCOMPLETE: repository list could not be generated" + finished=1 + exit 1 + fi + expected_count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORK/expected.json") + listed_count=$(wc -l < "$WORK/list.txt") + if [ ! -s "$WORK/list.txt" ] || [ "$listed_count" -ne "$expected_count" ]; then + { + echo "## OpenAMRobot Watchdog organization scan: INCOMPLETE" + echo + echo "Scan INCOMPLETE: repository list could not be generated ($listed_count of $expected_count entries usable)." + } >> "$summary" + echo "::error::Scan INCOMPLETE: repository list could not be generated ($listed_count of $expected_count entries usable)" + finished=1 + exit 1 + fi + + block() { # block + python3 "$support" block --blocked "$WORK/blocked.json" --repository "$1" --reason "$2" + echo "| $1 | BLOCKED: $3 | not scanned |" >> "$WORK/table.md" + } + : > "$WORK/table.md" + : > "$WORK/details.md" + while read -r org name branch; do + dest="$WORK/repos/$name" + if ! git clone --quiet --depth 1 --branch "$branch" "https://github.com/$org/$name.git" "$dest"; then + block "$name" "clone failed" "clone failed" + continue + fi + if ! sha=$(git -C "$dest" rev-parse HEAD); then + block "$name" "commit could not be read" "commit could not be read" + continue + fi + status=0 + python3 "$HARNESS/tools/watchdog.py" --root "$dest" --repository "$name" --report-only \ + --json "$WORK/out/$name.json" --markdown "$WORK/details.md" > "$WORK/out/$name.txt" || status=$? + if [ "$status" -ne 0 ]; then + rm -f "$WORK/out/$name.json" + block "$name" "watchdog exit $status at $sha" "watchdog exit $status" + continue + fi + if ! total=$(python3 "$support" enrich --report "$WORK/out/$name.json" --repository "$name" --commit "$sha"); then + if [ -f "$WORK/out/$name.json" ]; then mv "$WORK/out/$name.json" "$WORK/out/$name.json.invalid"; fi + block "$name" "report enrichment failed at $sha" "report enrichment failed" + continue + fi + echo "| $name | $sha | $total |" >> "$WORK/table.md" + tail -n 12 "$WORK/out/$name.txt" || true + done < "$WORK/list.txt" + + # Every expected repository needs a report or a BLOCKED entry; missing ones are added. + if ! counts=$(python3 "$support" finalize --expected "$WORK/expected.json" \ + --reports "$WORK/out" --blocked "$WORK/blocked.json"); then + exit 1 # the EXIT trap writes the INCOMPLETE summary + fi + read -r scanned expected <<< "$counts" + if [ "$scanned" -eq "$expected" ]; then + heading="COMPLETE" + else + heading="INCOMPLETE: $scanned of $expected repositories scanned" + fi + { + echo "## OpenAMRobot Watchdog organization scan: $heading" + echo + echo "Thursday run, deterministic and reportable. Results are published to the [watchdog] Organization dashboard issue in the harness repository; per-group issues only when WATCHDOG_ISSUE_MODE is groups." + echo "A clean result shows textual consistency only, not mechanical, electrical, safety or release correctness." + if [ "$scanned" -ne "$expected" ]; then + echo + echo "BLOCKED repositories were not scanned and are not clean. The job fails until every repository is scanned." + fi + echo + echo "| Repository | Commit | Findings |" + echo "|---|---|---:|" + cat "$WORK/table.md" + python3 -c ' + import json, sys + for b in json.load(open(sys.argv[1])): + if b.get("reason") == "no report produced": + print("| {} | BLOCKED: no report produced | not scanned |".format(b.get("repository"))) + ' "$WORK/blocked.json" + echo + echo "## Findings per decision or rule, per repository" + echo + cat "$WORK/details.md" + } >> "$summary" + finished=1 + if [ "$scanned" -ne "$expected" ]; then + echo "::error::Scan INCOMPLETE: $scanned of $expected repositories scanned; the summary lists the BLOCKED ones" + exit 1 + fi + + - name: Synchronize Watchdog issues + if: always() + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Empty when the variable is unset; the tool then uses dashboard mode. + WATCHDOG_ISSUE_MODE: ${{ vars.WATCHDOG_ISSUE_MODE }} + HARNESS: harness + WORK: ${{ runner.temp }}/watchdog + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + python3 "$HARNESS/tools/watchdog_issue_sync.py" \ + --reports "$WORK/out" \ + --blocked "$WORK/blocked.json" \ + --expected "$WORK/expected.json" \ + --maintainers "$HARNESS/maintainers.yaml" \ + --decisions "$HARNESS/decisions.yaml" \ + --repository "$GITHUB_REPOSITORY" \ + --run-url "$RUN_URL" \ + --run-date "$(date -u +%F)" \ + --apply | tee -a "${GITHUB_STEP_SUMMARY:-$WORK/summary.md}" + + - name: Upload scan logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: watchdog-org-scan + path: ${{ runner.temp }}/watchdog/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84d8c1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.verification/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 482413c..f807da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,61 +1,100 @@ - -# OpenAMRobot agent rules -Canonical shared block: openAMRobot/.github, agent-rules/SHARED_RULES.md. -This block is copied verbatim; GitHub does not propagate it between repositories. - -## Before editing -- Read AGENTS.md, CONTRIBUTING.md, applicable nested instructions, code and tests. -- Record the base SHA; inspect relevant open PRs and accessible branches/forks for overlap. -- Do not infer contributor inactivity from absent public branches; disclose inaccessible work. -- Follow the approved task scope and applicable plan/contracts. Report contradictions. -- Reuse maintained upstream packages and existing implementation; minimize custom glue. -- Do not replace working legacy support merely because the new-robot BOM excludes it. - -## Boundaries -- Shared ROS contracts belong in openamrobot-interfaces; identify their actual acceptance status. -- New contract proposals stay isolated and labelled Proposed, pending owner review. -- Do not author or modify safety implementation: E-stop, brakes, motion interlocks, - watchdogs, actuator enable or power-protection logic. Report required changes. -- Status display and isolated test fixtures do not implement or validate physical safety. -- Arm vendor SDKs stay behind Device Packages; none in UI or mission consumers. -- Preserve Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P distinctions. -- Jetson is the 2.0 reference compute; retain correctly labelled historical material. -- Public application name: Use_Case_1. No customer/partner names, secrets or private data. -- Preserve third-party provenance. Do not change licensing, NOTICE or CODEOWNERS - without an explicit task that authorizes those files and the appropriate review. -- Never connect untrusted/automated PR tests to physical motion hardware or secrets. - -## Delivery -- Use a contributor branch/fork and draft PR by default. Never merge, force-push, - modify protection/settings or bypass checks in ordinary implementation tasks. -- Read applicable CLA/DCO rules. Never invent an exemption, identity or attestation. -- Use git commit -s only with the verified contributor identity and provenance authority. -- Disclose material AI assistance, dependencies and licence implications. -- Report base/head SHAs, scope, safety impact, exact commands/results and evidence links. -- For bug fixes show the regression fails before and passes after; for new features - demonstrate a meaningful deliberate fault is detected. Explain non-applicability. -- Keep a Not verified section. SKIP/BLOCKED is not PASS; fixtures/fake hardware are - not integrated simulation, physical acceptance or release readiness. -- Do not weaken checks, use empty suites as evidence or invent successful test results. -- If blocked, stop the blocked activity, report command/error/next step and continue - independent in-scope work. Do not repeatedly reinstall or expand the architecture. -- Owner alignment and approval status must be truthful. A draft or notification is - not evidence that a required discussion or technical acceptance has happened. - + +# OpenAMRobot rules for contributors and agents +Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md, copied verbatim into every +repository's AGENTS.md (tools/check_agent_rules.py reports drift). This file holds process; +approved technical values live in openAMRobot/.github decisions.yaml. Labels: [check: tool] +means the tool detects that violation, and only in repositories where its workflow is installed +and required (rollout/README.md); [template: file]; [human: role, evidence] is a reviewer +decision. A text check proves textual consistency, never mechanical, electrical or safety correctness. + +## Decisions +- decisions.yaml is the only register of approved values, limits, exclusions and distinctions. + No file states a contradicting value; kept history carries `decision-allow: `. + [check: check_decisions.py, listed patterns only] [human: entry's reviewer, entry's evidence] +- Changing a decision: (1) open a contract change request issue naming the entry; (2) the + owner updates the source document; (3) one reviewed PR updates the register entry, its + supersedes history, its check patterns with a test, and every affected consumer, or links + each consumer PR; (4) the owner approves. [template: contract change request] [human: owner] +- Source documents are provenance. CI reads only the pinned register, never a drive or the + docs site; a disagreement is reported to the owner, and no tool rewrites either side. [human: owner] +- Read STATE.md before work in a repository; update it in the same PR. [check: check_pr_evidence.py] + +## Contracts +- Messages, services, actions, schemas, topic names, launch argument names and configuration + IDs change only through a contract change request and one PR that updates the contract + package and its consumers together. [template: contract change request] [human: software lead] +- New contract proposals stay labelled Proposed until the owner accepts them. [human: software lead] + +## Tests +- Every behaviour change carries a test that fails when the change is reverted; the Tests + section shows that failing run. [template: PR Tests section] [human: reviewer, the revert run] +- A suite that executes zero tests fails. [check: verify.sh; check_pr_evidence.py on reported counts] +- skip, xfail and importorskip name a tracking issue on the same line. [check: verify.sh] +- SKIP or BLOCKED is not PASS. Fixtures and fake hardware are not simulation, physical + acceptance or release readiness. [human: reviewer, Not verified section] + +## Evidence +- Every PR states base SHA, head SHA, exact commands, test counts and a Not verified section. + [check: check_pr_evidence.py, presence only] [human: reviewer, that the commands were run] +- A draft becomes ready only when the evidence check passes on the current head. [human: author; + ruleset required check once installed] + +## Dependencies and licences +- Nothing is added, removed or upgraded as a side effect. A changed dependency manifest needs + a Dependencies section naming each change, its licence and source. [check: check_pr_evidence.py] +- Licence headers and package.xml tags match the licence map: MIT software and firmware, + CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [human: repository owner] +- The Integration Gate section lists overlapping PRs, reused existing or upstream work and + what was rejected. Third-party provenance stays intact. [template: PR template] +- LICENSE, LICENSING.md, NOTICE and CODEOWNERS change only in a PR whose task names them. + [human: platform lead] + +## Safety +- No agent authors or modifies E-stop, brake, contactor, watchdog, motor-enable or + charge-inhibit logic; agents report the need in an issue. [check: check_pr_evidence.py fails + a safety-path change whose AI disclosure is not None] [human: platform lead, undisclosed use] +- A safety-path change needs two human approvals including the platform lead. The check only + reports "safety path touched, two human approvals required" and that reviewers are requested; + approvals are a ruleset requirement. [human: platform lead and one more maintainer] +- Functional telemetry, watchdogs, status displays and fixtures are never safety evidence. + [check: check_decisions.py wording only] [human: platform lead, hardwired safety-chain test record] +- Automated or untrusted PR jobs never reach motion hardware or secrets. [human: CI owner] + +## Publication +- Public material (docs/, assets/, README.md, any path containing "public") has no internal + document links, prices, contact data or credentials. [check: check_public_extract.py] +- It names no private person, customer or partner; the application name is Use_Case_1. [human: docs owner] + +## Agents +- Before any write, state a precondition block: repository (exact full name), branch, parent + SHA, expected outcome. Read-only unless the task says otherwise. [template: agent-prompts/] +- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the thread. + [human: work-package owner] +- The PR's AI disclosure section names the tool and what it produced. [check: section present] +- Commits carry DCO sign-off with the contributor's own identity; never invent an identity or + attestation. [human: maintainer; DCO check where installed] +- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 compute. + [check: check_decisions.py] Arm vendor SDKs stay behind device packages. [human: software lead] + +## Failure +- A failed precondition (repository, branch, SHA, access, source) stops the task. Report the + command, the error and the next step; never work around it. [template: agent-prompts/] +- Never merge, force-push, change settings, weaken a check or invent a result. [human: ruleset] + +## Learning +- Every agent or process mistake gets an issue labelled harness. [template: harness mistake form] +- The monthly retro turns harness issues into one PR against this block. [human: software lead] + # Repository-specific rules: .github -- This repository owns shared policy and reusable workflow implementations. -- agent-rules/SHARED_RULES.md is the canonical marked block; root AGENTS.md also embeds it. -- Keep repository additions small and versioned. Preserve existing governance rules. -- Proposed checker: python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --root /path/to/workspace -- Explicit file checks: python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file /path/to/repo/AGENTS.md -- This checker does not fetch repositories; the caller supplies the intended checkouts. -- Arumuga owns CI rollout. Do not claim this local checker is already deployed org-wide. - -## Canonical context -- [Plans](https://drive.google.com/drive/folders/15zWoBPd6qSt96TToWakN9rhfjNyq-hoz) -- [D-02 AI framework](https://drive.google.com/file/d/1Drs4tKbxAo6jsRlaCRK1eAMkzds7NTx-/view) -- [D-03 consistency](https://drive.google.com/file/d/1jbELSAeWRxuxB-IO-s7QlSlWK38WemQA/view) -- [Interfaces](https://github.com/openAMRobot/openamrobot-interfaces) -- [Contribution rules](https://github.com/openAMRobot/.github/blob/main/CONTRIBUTING.md) -Read relevant sources; if inaccessible, use an approved supplied excerpt and disclose limits. +- This repository owns shared policy, the decisions register (decisions.yaml), maintainers.yaml, + the checkers under tools/ and the reusable workflows. agent-rules/SHARED_RULES.md is canonical. +- Run before every PR: `bash rollout/verify.sh` (unit tests of every checker, zero-test guard), + `python3 tools/check_decisions.py --decisions decisions.yaml --maintainers maintainers.yaml --validate-only` + and `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file AGENTS.md`. +- Drift across repositories: `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --root `. + The checker does not fetch repositories; the caller supplies the checkouts. +- A change to the shared block bumps its marker version; rollout/README.md lists the order in + which repositories adopt it. [human: CI owner] +- Files under rollout/ are proposals for other repositories; they take effect only when that + repository's owner merges them. Do not claim rollout status that has not happened. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdef1cf..ed4ec4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,82 +1,111 @@ # Contributing to OpenAMRobot -OpenAMRobot welcomes technically sound contributions that support safe, reproducible, maintainable robotics. - -## Before contributing - -1. Read the repository README, licence, notices, and contribution instructions. -2. For substantial work, open an issue or discussion before implementation. -3. Confirm that you have authority to contribute, including any employer, university, client, sponsor, or co-author authorization. -4. Complete the contributor-agreement process described in [CLA.md](CLA.md). -5. Sign every commit under the [DCO](DCO.md). - -## Engineering quality and documentation architecture - -Every contribution must follow the [OpenAMRobot Engineering Quality Standard](ENGINEERING_QUALITY_STANDARD.md). Before implementation, identify the repository type, affected subsystem, safety impact, required validation evidence, compatibility impact, and documentation owner. - -Implementation-sensitive facts remain canonical in the owning repository. Documentation contributions and corresponding GitHub Pages updates must follow the [Documentation Information Architecture](https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_INFORMATION_ARCHITECTURE.md). Automated CI/CD enforcement is being introduced separately; until then, authors and reviewers must apply these requirements explicitly in each pull request. - -## Contribution workflow - -1. Fork the relevant repository. -2. Create a focused feature branch. -3. Make and test the change. -4. Commit with `git commit -s`. -5. Update documentation and applicable notices. -6. Open a pull request using the repository template. -7. Address review, CI, DCO, CLA, safety, and provenance findings. - -Direct pushes to protected default branches are not an external contribution path. - -## Intellectual property - -OpenAMRobot is operated by **Botshare LTD**. The applicable Contributor Agreement governs assignment of transferable economic rights in accepted external contributions to Botshare LTD. Accepted material is distributed under the applicable repository or file licence. - -DCO sign-off is mandatory but does not replace the Contributor Agreement. - -See: - -- [IP Policy](IP_POLICY.md) -- [CLA process](CLA.md) -- [Individual Contributor Agreement](INDIVIDUAL_CONTRIBUTOR_AGREEMENT.md) -- [Corporate Contributor Agreement](CORPORATE_CONTRIBUTOR_AGREEMENT.md) -- [DCO](DCO.md) - -## Third-party and AI-assisted material - -A pull request must identify material not created independently by the contributor, including code, CAD, schematics, documentation, images, datasets, models, generated output, and copied or adapted examples. - -For every such item provide: - -- source and author or owner; -- applicable licence or written permission; -- modifications made; -- required copyright, patent, and attribution notices; -- material use of generative AI and the contributor's review of the output. - -Do not submit material with unclear or incompatible rights. - -## Confidentiality and privacy - -Do not submit secrets, credentials, personal data, client information, unpublished inventions, export-controlled information, or confidential/proprietary material without explicit written authorization. - -## Pull-request quality - -Pull requests must: - -- explain the problem, solution, scope, and alternatives; -- contain focused changes only; -- include testing and observable results; -- identify safety, compatibility, migration, and deployment effects; -- update documentation and notices; -- avoid generated/build artifacts unless the repository explicitly requires them. - -Robot-motion, power, battery, actuator, safety-I/O, and autonomous-behaviour changes require explicit safety analysis and appropriate simulation or hardware validation. - -## Acceptance - -Submission and review do not guarantee acceptance. A contribution is accepted only when an authorized maintainer merges it into an official repository or Botshare LTD confirms acceptance in writing. - -## Conduct and contact - -Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Report security issues through [SECURITY.md](SECURITY.md). Questions about contribution rights may be sent to info@botshare.ai. +OpenAMRobot is an open dual-arm mobile manipulator. Software and firmware are MIT, hardware is +CERN-OHL-P-2.0 and documentation is CC-BY-4.0. Every contribution, written by a person or with +an AI tool, passes the same automated checks before a maintainer reads it. This page is the +whole path. Engineering detail lives in the +[Engineering Quality Standard](ENGINEERING_QUALITY_STANDARD.md); the rules the checks enforce +are in [AGENTS.md](AGENTS.md). + +## 1. Find a task + +- Look for issues labelled + [good first issue](https://github.com/search?q=org%3AopenAMRobot+label%3A%22good+first+issue%22+state%3Aopen&type=issues). + Each one names the files, what "done" means, the command that verifies it and the reviewer. +- Areas and where they live: + + | Area | Repository | + |---|---| + | documentation | openamrobot-docs | + | navigation and bring-up | openamr-platform-sw | + | base firmware | openamr-platform-fw | + | hardware (CAD, BOM, wiring) | openamr-platform-hw, openamr-upperbody-hw | + | interfaces (messages, schemas) | openamrobot-interfaces | + | manipulation | openamrobot-manipulation | + | operator UI | openamrobot-ui | + | manifest and release | openamrobot-manifest, openamrobot-release | + | CI and this harness | .github | + +- For anything larger, open a **work package** issue first and wait for the area owner to + agree the scope. To change a message, schema, topic name, launch argument name or + configuration ID, open a **contract change request** instead. +- Before you start, read the repository's STATE.md (if it has one) and check open pull + requests for the same work. + +## 2. Set up + +1. Sign the contributor agreement once: see [CLA.md](CLA.md). +2. Fork the repository and create **one branch per work package**. +3. Sign off every commit: `git commit -s`. The sign-off certifies the [DCO](DCO.md) with your + own name and e-mail. + +## 3. Make the change + +- Add a test that fails without your change. A test run that executes zero tests counts as a + failure. If you must skip a test, name the tracking issue on the same line. +- Run the repository's verification (`tools/verify.sh`, or the command in its README). +- Approved technical decisions (values, limits, exclusions such as "no RS485 in 2.0", and + distinctions such as "1700 mm is the assembled-height envelope, not a shoulder height") are + in the register [decisions.yaml](decisions.yaml). Follow it. To change one, open a + **contract change request** naming the entry; the process is "Changing a decision" in + [AGENTS.md](AGENTS.md): the owner updates the source document, then one reviewed PR + updates the register and every affected repository together. +- Do not add, remove or upgrade a dependency unless the task asks for it. +- Do not change E-stop, brake, contactor, watchdog, motor-enable or charge-inhibit logic + unless the platform lead has agreed it in the issue; such changes need two human reviewers. + +## 4. Open a draft pull request + +Open the PR as a **draft** and fill in every section of the template: work package, +Integration Gate (what existed, what you reused), tests, evidence (base SHA, head SHA, exact +commands, test counts), dependencies, safety impact, STATE.md, Not verified and AI disclosure. + +## 5. What the automated checks verify + +These checks run in a repository once it has installed them; they block a merge only where +the repository's ruleset requires them ([rollout status](rollout/workflows/SETUP.md)). A text +check proves that a file is consistent with the register, not that a design is mechanically, +electrically or functionally safe; a named reviewer checks that. + +The [Watchdog guide](WATCHDOG.md) explains every check, how to read a finding, how to fix it +and how to run all checks locally with one command. + +| Check | Fails when | +|---|---| +| repository-quality | governance files missing, merge markers, invalid JSON or XML | +| decisions register | a changed file contains a listed contradicting phrase, or cites a superseded source | +| public extract | docs, assets, README or public files contain internal document links, prices, e-mail addresses, phone numbers or credential-like strings | +| shared agent rules | AGENTS.md differs from the organization's shared block | +| PR evidence (one comment, updated on each push) | a template section is empty, SHAs or commands are missing, tests changed without a reported run, a dependency changed without a note, safety files changed without two human reviewers requested (the two approvals themselves come from the ruleset) | +| quality/test | build, lint or tests fail, or zero tests ran | +| DCO and contributor agreement | a commit lacks sign-off, or no agreement is on record | + +## 6. From draft to ready + +Mark the PR **ready for review** when every check is green on the current head and the +evidence comment says PASS. A PR prepared by an AI agent stays draft until the work-package +owner writes adopt, adapt or reject in the thread. + +## 7. Who reviews what + +| Change | Reviewer (role in [maintainers.yaml](maintainers.yaml)) | +|---|---| +| platform, hardware, firmware, decisions.yaml | platform lead | +| robot software, AI, interfaces, agent rules | software lead | +| workflows, verify.sh, quality gates | CI owner | +| manifest, release, installation | release owner | +| documentation site | documentation owner | +| safety paths | two human approvals, including the platform lead, who reviews last (ruleset) | + +A maintainer merges; approval or a green check alone does not accept a contribution. + +## Legal, conduct and contact + +Contributions are governed by the [IP Policy](IP_POLICY.md), the +[Individual](INDIVIDUAL_CONTRIBUTOR_AGREEMENT.md) or +[Corporate](CORPORATE_CONTRIBUTOR_AGREEMENT.md) Contributor Agreement, the +[AI contribution policy](AI_CONTRIBUTION_POLICY.md) and the +[third-party policy](THIRD_PARTY_POLICY.md). Identify any material you did not create +yourself with its source and licence. Do not submit secrets, personal data or confidential +material. Follow the [Code of Conduct](CODE_OF_CONDUCT.md); report security issues through +[SECURITY.md](SECURITY.md). Questions about contribution rights: info@botshare.ai. diff --git a/ENGINEERING_QUALITY_STANDARD.md b/ENGINEERING_QUALITY_STANDARD.md index 26079ad..e3c116a 100644 --- a/ENGINEERING_QUALITY_STANDARD.md +++ b/ENGINEERING_QUALITY_STANDARD.md @@ -380,9 +380,12 @@ The standard is operational, not merely published, when: ## 13. Cycle schedule, workstream H -The phases above map onto the 14 September to 13 November cycle as -workstream H. Lead: Documentation & Release Lead, executed by the DevOps -members of that team. +The phases above map onto development cycle 2 as workstream H. The cycle +opened 14 September and ends 20 November 2026 with the v2.0.0-rc.1 GitHub +pre-release; v2.0.0 follows on 18 December 2026 after physical integration, +testing and acceptance (register entry RELEASE-MILESTONES in decisions.yaml). +Lead: Documentation & Release Lead, executed by the DevOps members of that +team. | **\#** | **Phase** | **Deliverable** | **Due** | |--------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------| @@ -401,12 +404,12 @@ provenance, the compatibility dashboard and formal R3 approval records go to ROADMAP-v0.3.md. Saying so now is cheaper than discovering it on 10 November. -**Readiness declaration for v0.2.** The four pilot repositories target -**R2**. Every other active repository targets **R1**. **No component -claims R3 in this cycle.** v0.2 is the first release built from -immutable component refs, which is the precondition for R3, not the -evidence for it. A component that builds is R1, and building has never -been the difficult part. +**Release readiness.** Development cycle 2 ends 20 November 2026 with +v2.0.0-rc.1; v2.0.0 follows on 18 December 2026 (RELEASE-MILESTONES). The +earlier v0.2 readiness declaration is superseded by those milestones. +Readiness levels are evidence, not targets: a component that builds is R1, +building has never been the difficult part, and a release built from +immutable component refs is the precondition for R3, not the evidence for it. ## 14. How this standard binds the plan set diff --git a/GOVERNANCE.md b/GOVERNANCE.md index a58ce0b..e5d0c10 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -40,6 +40,22 @@ Empty placeholder governance files do not satisfy this baseline. Only repositories, releases, domains, documentation sites, and communications designated by Botshare LTD may claim official OpenAMRobot status. Forks and compatible products must not imply endorsement or certification. +## Platform-lead-authored pull requests + +When the platform lead authors a pull request, the platform lead's reconciliation comment is +the author's statement of source alignment; it is not an approval. Before merge, the PR must +have: + +- an approval from the software lead; and +- an approval from every scoped owner whose repository or path responsibility is touched, + including CI/CD, documentation and release owners when their scopes are affected. + +The author cannot satisfy any of those approvals. Safety-path changes still require the +separate two-human-approval ruleset gate, including the platform lead through CODEOWNERS. +If a scoped owner is unavailable, the organization owner records a dated exception and its +replacement reviewer before merge. A draft remains a draft until all required owner gates, +required checks, DCO/CLA and source reconciliation are complete. + ## Changes to governance Governance changes require review by an authorized Botshare LTD representative. No governance change may remove authentic third-party notices or claim rights Botshare LTD does not own. diff --git a/README.md b/README.md index 557f5db..ef7e296 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ The ecosystem encourages open-source collaboration, education, research, and ind ## Contribution - [Contributing](CONTRIBUTING.md) +- [The OpenAMRobot Watchdog](WATCHDOG.md): what the automatic checks look at and how to fix a finding - [Developer Certificate of Origin](DCO.md) - [Code of Conduct](CODE_OF_CONDUCT.md) - [Contributors](CONTRIBUTORS.md) diff --git a/ROADMAP.md b/ROADMAP.md index 3ea9acd..a2357d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,18 +111,18 @@ Vision - Encoders - Safety I/O -## Mid-Level Compute -- Raspberry Pi 5 +## Main Compute (OpenAMRobot 2.0) +- NVIDIA Jetson Orin NX on the reComputer Robotics J401 carrier - Navigation - SLAM - ROS 2 System Management - -## High-Level AI Compute -- NVIDIA Jetson Orin / Orin NX - Perception - Manipulation - VLA / Policy Inference +The Raspberry Pi mid-level computer is legacy and remains only in the Gate A test +configuration of the existing robot. + ## Wearable Edge Compute - RK3588-based Modules (optional) - Sensor Fusion diff --git a/WATCHDOG.md b/WATCHDOG.md new file mode 100644 index 0000000..1cce153 --- /dev/null +++ b/WATCHDOG.md @@ -0,0 +1,324 @@ +# The OpenAMRobot Watchdog + +The Watchdog is a set of automatic checks. It reads the files in a repository and tells you +when something disagrees with what the project has agreed. For every finding it tells you what +it found, why that matters and exactly how to fix it. + +## What the Watchdog is and why OpenAMRobot uses it + +OpenAMRobot is spread over many repositories: software, firmware, hardware files and +documentation. The approved technical decisions (which computer, which LiDAR, which release +dates, what is not in release 2.0) are written down in one place: +[decisions.yaml](decisions.yaml), the *decisions register*. The platform lead approves every +entry. + +The Watchdog compares every repository with that register. When a README, a launch file or a +bill of materials still names an old part or an old value, the Watchdog points to the exact +file and line. This catches drift early, before it confuses a builder or a reviewer, and it +saves review time: reviewers do not have to remember every decision. + +The checks are plain Python scripts in [tools/](tools/). They use no AI and no network. They +only read files and never change them. + +## What it is not + +- It is **not** safety evidence. A clean result does not show that a robot is safe. +- It is **not** electrical, mechanical or release evidence. It does not test hardware, + calculate loads or decide that a release is ready. +- It checks **textual consistency only**: that the words in a file agree with the register. + The people named in each register entry review the substance. + +## The checks + +| Check | What it looks at | Why it matters | Typical finding | How to fix | Who owns it | +|---|---|---|---|---|---| +| Decisions of record (`tools/check_decisions.py`) | Text files covered by each entry in [decisions.yaml](decisions.yaml) | Builders and reviewers must see the approved value, not an old one | `Mismatch with approved decision: COMPUTE (14 finding(s))`, then `README.md:44: found 'Raspberry Pi 5'` | Correct the line, or label it as history with an accepted word (table below), or propose a decision change | Each entry's `owner` role, usually the platform lead | +| Public extract (`tools/check_public_extract.py`) | `docs/`, `assets/`, every `README.md` and any path containing `public` | Public pages must not leak internal links, personal contact data, prices or secrets | `Should not be public: google-drive-link (1 finding(s))`, then `docs/a.md:3: found 'https://drive.google.com/...'` | Remove the value or move it to an internal place; approved public contacts are listed below, and new ones go on the allowlist ([public-extract-allowlist.yaml](public-extract-allowlist.yaml)) after review | Docs owner | +| Shared agent rules (`tools/check_agent_rules.py`) | `AGENTS.md` and `CLAUDE.md` | Every contributor and agent follows the same rules; a changed copy quietly changes them | `Shared agent rules out of date: shared-rules (1 finding(s))`, then `AGENTS.md:1: found 'shared block differs from canonical'` | Copy the block between the BEGIN and END markers from [agent-rules/SHARED_RULES.md](agent-rules/SHARED_RULES.md) unchanged | Software lead | +| Workflow policy (`tools/check_workflow_policy.py`) | `.github/workflows/*.yml` | A tag or branch can be moved to new code; a full commit SHA cannot | `Workflow not pinned: unpinned-action (1 finding(s))`, then `.github/workflows/ci.yml:12: found 'actions/checkout@v4'` | Pin the action to its 40-character commit SHA and keep the version as a comment | CI owner | +| PR evidence and AI disclosure (`tools/check_pr_evidence.py`) | The pull request description | Reviewers need the base and head commits, exact commands, test counts, a Not verified section and an honest AI disclosure | `Missing section: Not verified` | Fill every section of the pull request template | CI owner | +| verify.sh (`rollout/verify.sh`) | The repository's build and tests | A test run that runs zero tests proves nothing; a skipped test must say why | `FAIL: zero tests executed` or `FAIL: skip/xfail/importorskip without a tracking issue` | Add real tests; put the tracking issue on the same line as each skip | CI owner | +| Decision freshness (`review_by` in the register) | The `review_by` date of each register entry | An old decision may no longer be true | `Review due: COMPUTE review_by 2026-11-18 is past due` (a warning, never a failure) | The entry's owner confirms the decision or starts a change | Each entry's `owner` role | + +## How to read a finding + +Findings are grouped by decision. Each group says once what the decision is, why it matters, +how to fix it and where to read more, then lists every place it was found. This is the real +COMPUTE group from the scan of `openamr-platform-hw` on 7 October 2026 (3 of its 14 places +shown): + +```text +Mismatch with approved decision: COMPUTE (14 finding(s)) + Decision: Reference compute: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401; Pi is legacy. + Why: Jetson Orin NX is the 2.0 reference compute; label Raspberry Pi material as legacy. + Fix: Name the Jetson Orin NX, or label Raspberry Pi material as legacy or Gate A. + More: COMPUTE in decisions.yaml: https://github.com/openAMRobot/.github/blob/main/decisions.yaml#L711 | WATCHDOG.md: ... + Found: + README.md:44: found 'Raspberry Pi 5' + README.md:81: found 'Raspberry Pi 5' + electrical/computing/raspberry-pi.md:1: found 'Raspberry Pi 5' +``` + +- The first line names the kind of finding, the decision ID (`COMPUTE`) and how many places + disagree with it. +- **Decision** is the current decision in one line. +- **Why** says what is wrong. If one decision has several checks, each reason gets its own line. +- **Fix** says what to change. +- **More** links to the register entry and to this page. +- **Found** lists each place as `file:line` and the text that was found. + +In GitHub, each place is also shown inline in the pull request diff with the full message, +and the job summary has a table of findings per decision. + +The line before the fix (the README describes the robot that exists today, which still uses +the Raspberry Pi): + +```text +| Compute | **Raspberry Pi 5, 8 GB** | Ubuntu Server 24.04 + ROS 2 Jazzy. | +``` + +After the fix, the line says clearly that this is legacy (Gate A) material, so the finding +disappears: + +```text +| Compute (legacy, Gate A robot) | **Raspberry Pi 5, 8 GB** | Ubuntu Server 24.04 + ROS 2 Jazzy. | +``` + +If the line was meant to describe release 2.0, the right fix is to name the Jetson Orin NX +instead. + +At the end of every run the Watchdog prints a summary: the total number of findings, the +number per decision and the next step. + +## How to fix a finding + +Pick one of these, in this order: + +1. **Correct the line** to the current decision. +2. **Label historical material clearly.** If the line describes the existing robot, an older + revision or OpenAMRobot 3.0, say so *on the same line*, using one of the words the check + accepts for that decision (table below). For example `legacy`, `Gate A`, `superseded`, + `historical` or `3.0`. Case does not matter. If no accepted word fits, keep the line and add + a marker on the same line or the line above: + `decision-allow: `. The Watchdog still reports the marker, so a reviewer sees + it. +3. **If the decision itself is wrong**, do not edit the register in your pull request. Open a + [contract change request](https://github.com/openAMRobot/.github/issues/new?template=contract_change_request.yml) + that names the entry. The owner updates the source document first; then one reviewed pull + request updates the register and every affected repository. + +**Never weaken a check to make CI pass.** Do not delete a pattern, widen an exception or skip +a step. If you believe a finding is wrong, see the FAQ below. + +### Words each decision accepts + +The Watchdog accepts a line when one of these words appears on the same line. Where a +decision has several checks, each check is named by the message shown in the finding's +**Why** line. `supersed` also matches *superseded* and *supersedes*. The table is generated +from the register with `python3 tools/watchdog.py --accepted-words`, and a test keeps it +identical to the register. + + +| Decision | Status | Accepted on the same line (any one; case does not matter) | +|---|---|---| +| BOM-ISSUE-IN-FORCE | recorded | *BOM Issue 7.3 is the canonical hardware BOM*: `supersed (superseded, supersedes)`, `replaced`, `previous`, `earlier`
*B-01 is superseded*: `supersed (superseded, supersedes)`
*BOM Issue 7.3 is the canonical hardware BOM*: `supersed (superseded, supersedes)`, `replaced`, `previous`, `earlier`, `historical`
*superseded source still cited (P-03-rev18.1 line 7); cite P-03-rev18.7*: no label; correct the line or add a decision-allow marker | +| MAST-INSTALL-HEIGHT | superseded | `supersed (superseded, supersedes)`, `legacy`, `historical`, `earlier`, `previous`, `rev 18.1 to rev 18.6` | +| MAST-POSITIONS | superseded | `supersed (superseded, supersedes)`, `legacy`, `historical`, `earlier`, `previous`, `rev 18.1 to rev 18.6` | +| MAST-TOP-HEIGHT | superseded | `supersed (superseded, supersedes)`, `legacy`, `historical`, `earlier`, `previous`, `rev 18.1 to rev 18.6` | +| MAX-ASSEMBLED-HEIGHT | recorded | *maximum assembled height is 1700 mm*: no label; correct the line or add a decision-allow marker
*1700 mm is the assembled-height envelope, not a shoulder height or mast position*: `max`, `envelope`, `not shoulder, not the shoulder`, `higher`, `supersed (superseded, supersedes)` | +| DATUM-HEIGHT-STACK | recorded | *the steel chassis deck top is 294 mm above the floor*: `supersed (superseded, supersedes)`, `historical`, `legacy`, `earlier`, `previous`
*the lift base-plate top face is 304 mm above the floor, the reference for lift and shoulder heights*: `supersed (superseded, supersedes)`, `historical`, `legacy`, `earlier`, `previous` | +| FRAMES-REP105 | recorded | *base_link is at the drive-axle midpoint and axle height; base_footprint is on the floor*: `base_footprint`, `not`, `never`, `supersed (superseded, supersedes)`, `historical`, `legacy`
*base_footprint is on the floor under the drive-axle midpoint*: `not`, `never`, `supersed (superseded, supersedes)`, `historical`, `legacy`
*imu_link sits on the centreline away from motor magnetic fields*: `away`, `not`, `never`, `supersed (superseded, supersedes)`, `historical`, `legacy` | +| BATTERY-PLACEMENT | recorded | `supersed (superseded, supersedes)`, `historical`, `previous`, `earlier`, `rev 18.1 to rev 18.6` | +| SPEED-CEILING | recorded | *1.5 m/s is a command ceiling, not an operating limit*: `ceiling`, `analytical`
*1.5 m/s is not an accepted operating speed*: `ceiling`, `analytical`, `not` | +| DRIVETRAIN | recorded | *the selected motor variant is ZLLG80ASM250-L-B*: no label; correct the line or add a decision-allow marker
*ZLTECH is the selected 2.0 drivetrain, not an option*: no label; correct the line or add a decision-allow marker
*brake fail-safe function and ratings are open supplier-evidence gates*: `pending`, `F2A`, `verif`, `not yet`, `unverified` | +| RS485-NOT-IN-2-0 | recorded | *RS485 is not provisioned in 2.0*: `not used`, `no RS485`, `not provisioned`, `without`, `legacy`, `Gate A`, `removed`, `not in 2.0`
*CAN1 and CAN2 are dedicated; no upper-body serial link to the base*: no label; correct the line or add a decision-allow marker | +| BASE-CONTROLLER-GATES | recorded | *Teensy is the Gate A controller and the release fallback, not a bench target*: no label; correct the line or add a decision-allow marker
*ICM-42688-P is gated; MPU6500 remains the Gate A IMU*: `Gate B`, `conditional`, `20 Nov`, `candidate`, `pending`, `not yet`, `after`
*the Gate A IMU is the MPU6500*: no label; correct the line or add a decision-allow marker | +| BASE-CONTROLLER-IO | recorded | *the base controller is the STM32H723ZG on the NUCLEO-H723ZG; label STM32H743 / NUCLEO-H743ZI2 material as superseded*: `supersed (superseded, supersedes)`, `legacy`, `historical`, `replaced`
*the ultrasonic sensors are two MaxBotix MB7060 on dedicated STM32 UARTs at 9600 8N1; no sensor I2C*: `supersed (superseded, supersedes)`, `legacy`, `historical`, `replaced`
*micro-ROS runs over UDP on Ethernet between MCU and Jetson; USB is for the bench only*: `bench`, `legacy`, `Gate A`, `Teensy`, `supersed (superseded, supersedes)`, `historical` | +| IMU-TOPIC-OWNERSHIP | recorded | *firmware publishes /imu/data_raw; /imu/data belongs to the host filter*: `host`, `filter (not unfiltered)`, `EKF`, `madgwick`, `must not`, `never`, `outdated`, `older revision(s)`
*firmware must not publish filtered /imu/data*: no label; correct the line or add a decision-allow marker | +| CAMERAS | recorded | *the base camera is the Orbbec Gemini 336L*: `legacy`, `historical`, `previous`, `not used`, `replaced`
*the base camera tilts about 10 degrees up*: no label; correct the line or add a decision-allow marker
*the 2.0 base camera is the Orbbec Gemini 336L*: `legacy`, `Gate A`, `historical`
*the Gemini 336L up-tilt positions are 5, 10 and 15 degrees, baseline 10*: `supersed (superseded, supersedes)`, `historical`, `legacy` | +| HEAD-CAMERA-IDENTITY | recorded | `supersed (superseded, supersedes)`, `historical`, `legacy`, `replaced`, `not` | +| POWER-RAILS | recorded | *there is no 12 V rail in 2.0*: `no 12`, `not`, `never`, `legacy`
*there is no 12 V rail in 2.0*: `legacy` | +| DOCK-NO-CONTACTS | recorded | *2.0 docking has no dock contacts*: `no`, `not`, `without`, `3.0`, `never`
*no dock or charge pilot in 2.0*: `no`
*wireless charging belongs to 3.0*: `3.0`, `later`, `deferred`, `not` | +| DOCKING-NOT-CHARGING | recorded | *2.0 docking is positioning only*: `not`, `never`, `instead`, `non-charging`, `legacy`
*never report charging from a docking pose*: no label; correct the line or add a decision-allow marker
*docking success never establishes charging*: `not`, `never`, `does not` | +| TELEMETRY-NOT-SAFETY-EVIDENCE | recorded | `not`, `never`, `functional`, `no substitute` | +| SAFETY-PROCUREMENT | recorded | *the EDM variant is not selected*: `not`, `unselected`, `open`
*no single-channel or uncertified E-stop recommendation*: no label; correct the line or add a decision-allow marker
*CTL-004 is bench-only*: `bench-only`, `until` | +| COMPUTE | recorded | `legacy`, `historical`, `removed`, `supersed (superseded, supersedes)`, `Gate A`, `previous`, `earlier` | +| NAV-LIDAR | recorded | *the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); the Hokuyo UST-10LX was dropped*: `supersed (superseded, supersedes)`, `dropped`, `legacy`, `historical`
*the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); label RPLIDAR A1 material as legacy*: `legacy`, `Gate A`, `existing robot`, `historical`, `replaced` | +| RELEASE-MILESTONES | recorded | *the OpenAMRobot 2.0 final release is 18 December 2026*: `supersed (superseded, supersedes)`, `previous`, `earlier`, `historical`
*there is no v0.2 release; development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, and v2.0.0 follows on 18 December 2026*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`
*development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, not 13 November*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous` | +| LIFT | recorded | *the lift is approved in principle for 2.0*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`, `no longer`, `rev 18.1 to rev 18.6`
*the fixed mast is no longer the baseline; the lift is approved in principle*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`, `legacy`, `replace (replaced, replacement)`, `instead of`, `no longer`, `rev 18.1 to rev 18.6`
*the lift is not deferred to 3.0; it is approved in principle for 2.0*: `supersed (superseded, supersedes)`, `historical`, `earlier`, `previous`, `no longer`, `rev 18.1 to rev 18.6` | +| NO-SUSPENSION | recorded | `3.0`, `no`, `not fitted`, `without` | +| DRIVE-TRACK | open | not scanned until the decision is taken | + + +## Approved public contacts + +Public files (`docs/`, `assets/`, every `README.md` and any path containing `public`) carry no +personal contact data and no prices. The public-extract check accepts only these kinds of +contact, each listed with its reason in +[public-extract-allowlist.yaml](public-extract-allowlist.yaml): + +| Kind | What is accepted | Where | +|---|---|---| +| Organisation | Any address on the `botshare.ai` domain. Lookalike domains are still flagged. | Anywhere | +| Contributors and maintainers | An address on a `Signed-off-by:` or `Co-authored-by:` line, and any address in `CONTRIBUTORS.md`, `MAINTAINERS.md` or `maintainers.yaml`. Publication is agreed through the DCO, the CLA and the contributor privacy notice. | Anywhere | +| Supplier role addresses | An address whose name part is a role: `sales`, `info`, `support`, `service`, `contact`, `export`, `trade`, `office` or `marketing`, optionally followed by digits (for example `trade26@`). An address that names a person is still flagged. | `datasheets/` only | +| Third-party licence notices | The author address on a copyright, licence or `@author` line of third-party code, which must stay to preserve provenance. | Notice lines only | + +**Prices are never public**, except the approved organisation pricing (the sponsorship tiers +and robot offerings on this organisation's README and profile). Supplier prices, quotes and +price lists are removed, never allowlisted. + +**To request a new entry**, open an issue that names the file, the exact text and why it must +be public. The docs owner decides entries for published pages; the platform lead decides +entries about company or commercial information. Until the entry is merged, the finding +stays. + +## Run it locally in one command + +From a checkout of `openAMRobot/.github`, next to the repository you want to check: + +```bash +python3 -m pip install --user PyYAML # once, if PyYAML is missing +python3 tools/watchdog.py --root ../openamrobot-docs +``` + +It runs decisions of record, public extract, shared agent rules (when the repository has an +`AGENTS.md`), workflow policy and decision freshness, prints each finding with its fix, then a +summary table. Exit status: 0 clean, 1 findings, 2 a configuration or usage error. Useful +options: + +- `--report-only`: never fail on findings (exit 0), for a first look. +- `--repository NAME`: the repository name, if the folder has another name. +- `--json FILE` and `--markdown FILE`: machine-readable and Markdown output. + +PR evidence and verify.sh run on their own: `python3 tools/check_pr_evidence.py --help` and +`bash rollout/verify.sh `. + +## In CI + +- **In a repository's pull requests**, the reusable workflow + [repository-quality-reusable.yml](.github/workflows/repository-quality-reusable.yml) runs the + same checks. With `harness_warn: true` every finding is a warning and the job never fails. + With `harness_checks: true` a finding in a changed file fails the pull request. Findings + appear inline in the pull request diff, and the job summary shows a table grouped by + decision. +- **Across the organization**, [watchdog-org-scan.yml](.github/workflows/watchdog-org-scan.yml) + runs every Thursday at 14:00 Berlin time (`Europe/Berlin`, so it follows daylight saving + time) and on demand. It clones every repository in + [rollout/repositories.yaml](rollout/repositories.yaml) on its default branch and writes one + summary plus JSON/Markdown artifacts. Findings do not fail the scan, but a clone or checker + failure is reported as **BLOCKED** and fails the job after the report is written. +- **GitHub issue control surface:** by default the scan keeps one central issue, + "[watchdog] Organization dashboard", in `openAMRobot/.github`. It lists every active group + (repository, check, group, finding count, owner and file links) and every repository whose + scan was blocked. With per-group issues turned on (see [Watchdog issue mode](#watchdog-issue-mode)), + it also keeps one deduplicated issue per group. Issues are assigned to + `BotshareAI` when GitHub permits assignment and always mention `@BotshareAI`; public issue + text redacts URLs, email addresses and credential-shaped values. Repeated evidence is not + posted repeatedly. When a finding disappears, the Watchdog comments that it is no longer + detected and leaves closure to the human owner. If the same finding returns after closure, the Watchdog reopens the issue and posts the new observation. +- The workflow has `contents: read` and `issues: write` only. It reads product repositories + anonymously, writes only issues in the harness repository, never edits `decisions.yaml`, + never pushes a branch, never opens a pull request, and never uses AI. The platform lead + updates the ground truth through a normal reviewed PR. + +## GitHub-native operating loop + +The weekly loop is deliberately split at the ground-truth boundary: + +1. The Thursday Action scans all repositories listed in `rollout/repositories.yaml`. +2. The Action updates the central dashboard in `openAMRobot/.github`. In `groups` mode it also + writes or updates one issue per active group. +3. Each dashboard row, and each group issue in `groups` mode, identifies the repository commit, + exact location, owner role, reason and fix. It is a textual signal, not a safety or release + acceptance. +4. A due decision appears on the dashboard as a `decision-review` row (and as its own + `decision-review` issue in `groups` mode). The platform lead confirms the controlling + source and either extends the review window or starts a source-first change. +5. The platform lead updates `decisions.yaml` in a normal reviewed PR. The Watchdog never + guesses a new value and never edits the register itself. +6. The next run verifies the fixing PR's result. Human owners close issues only after review. + +The dashboard is the durable weekly record. Its `Shared rules` column distinguishes `pass`, +`drift` and `not enrolled`; `not enrolled` is rollout status, not a contradiction. The uploaded +JSON artifacts preserve the exact repository SHAs and evidence used by the run. + +### Incomplete scans + +The organization scan fails closed. A result is only called complete when every repository in +[rollout/repositories.yaml](rollout/repositories.yaml) was scanned. + +- **BLOCKED** means one repository was not scanned: it could not be cloned, `tools/watchdog.py` + failed, its report could not be read or enriched, or no report was produced for it. The + reason is shown next to the repository. +- **INCOMPLETE** means the run as a whole did not cover every repository. The job summary + heading then says "INCOMPLETE: N of M repositories scanned" instead of "COMPLETE", and the + dashboard issue opens with a "Scan INCOMPLETE" banner. If the repository list itself cannot + be read, the scan stops and the dashboard is not overwritten; only an INCOMPLETE notice is + posted. +- The job is red whenever a repository is BLOCKED or the scan is INCOMPLETE. +- A missing repository is never treated as clean. Its findings from an earlier run stay on the + dashboard as "last known", and its existing issues are not marked as no longer detected. Only + a repository that was actually scanned can clear its own findings. + +### Watchdog issue mode + +The repository variable `WATCHDOG_ISSUE_MODE` in `openAMRobot/.github` decides which issues the +organization scan writes: + +| Value | What the scan writes | +|---|---| +| not set (default), or `dashboard` | Only the single "[watchdog] Organization dashboard" issue, edited in place when its content changes. It lists every active group and every scan-blocked repository. No other issue is opened, commented on or reopened. | +| `groups` | The dashboard, plus one deduplicated issue per active group (finding group, decision review, blocked scan), with the comment, reopen and "no longer detected" behaviour described above. | + +Any other value stops the sync step with an error instead of guessing. Scan-blocked +repositories appear on the dashboard in both modes. + +**To turn on per-group issues:** in `openAMRobot/.github`, open Settings, then Secrets and +variables, then Actions, then Variables, and add the repository variable `WATCHDOG_ISSUE_MODE` +with the value `groups`. The next Thursday run (or a manual run of the workflow) uses it. To go +back, delete the variable or set it to `dashboard`; issues that already exist stay as they are. + +**Who decides:** the platform lead decides when to switch to `groups`, for example once the +first repositories have finished rollout and their owners are ready to work from one issue per +group. Until then the dashboard alone keeps the organization view in one place. + +## Adopting it in a repository + +The full steps are in [rollout/README.md](rollout/README.md). In short, one pull request per +step, each merged by the repository owner: + +- [ ] **Pilot first:** `openamrobot-interfaces` completes every step before any other repository starts. +- [ ] (a) Copy the shared agent rules into `AGENTS.md`; `CLAUDE.md` contains only `@AGENTS.md`; add `STATE.md`. +- [ ] (b) Pin the reusable workflow and `harness_ref` to one harness commit SHA. +- [ ] (c) Turn on warn-only (`harness_warn: true`); fix or label the findings until a run on main is green. +- [ ] (d) Turn on enforcing (`harness_checks: true`). +- [ ] (e) The repository's ruleset requires the check. + +Only after step (e) does a finding block a merge. + +## Status of the AI workflows + +The example AI workflows in `rollout/workflows/` (docs sync, weekly audit, monthly retro) are +**design only**. They stay inactive until +[issue #43](https://github.com/openAMRobot/.github/issues/43) is closed with its activation +evidence. The Watchdog itself uses no AI. + +## FAQ + +**I think a finding is a false positive.** First check whether the line really reads as the +current decision to a newcomer. If it is history, label it (see the table). If it is +genuinely correct and no label fits, open an issue labelled `harness` with the file, line and +finding. The CI owner fixes the pattern in this repository with a regression test. Do not +change the check in your own pull request. + +**A decision changed.** The register is updated in one reviewed pull request, and the +Watchdog then reports every line that still shows the old value. Fix them in your repository, +or label them as history. + +**A value is still open.** Entries with status `open` are not scanned. Write "open" or +"to be decided" and do not invent a value. The register entry says who decides it. + +**Who do I ask?** The owner role of the check in the table above. Roles and their people are +in [maintainers.yaml](maintainers.yaml). For a decision, ask the role named in its `owner` +field. diff --git a/agent-prompts/README.md b/agent-prompts/README.md new file mode 100644 index 0000000..7b6e57b --- /dev/null +++ b/agent-prompts/README.md @@ -0,0 +1,20 @@ +# Agent prompt templates + +Each template is a prompt an agent (or a person) runs as written, after filling the +angle-bracket fields. Every template has the same three fixed parts: + +1. **Precondition block.** Filled in and posted before the first write. The agent checks each + line; a mismatch is a failed precondition. +2. **Expected outcome block.** What the run must produce, so the reviewer can compare. +3. **Failure rule.** A failed precondition stops the task. The agent reports the command, the + error and the next step, and opens an issue with the label harness when the failure shows a + gap in the harness. It never works around the failure. + +Record every run in agent-runs.md with the template name and the harness commit SHA. + +| Template | Use | +|---|---| +| [read-only-audit.md](read-only-audit.md) | Compare repositories against decisions.yaml and the plan; write a report, change nothing | +| [push-from-bundle.md](push-from-bundle.md) | Push a prepared, reviewed change set to a contributor branch and open a draft PR | +| [docs-fix.md](docs-fix.md) | Correct a documentation page that contradicts a decision or a repository | +| [evaluator-pass.md](evaluator-pass.md) | Check another agent's PR against the rules before a human reads it | diff --git a/agent-prompts/docs-fix.md b/agent-prompts/docs-fix.md new file mode 100644 index 0000000..5d23554 --- /dev/null +++ b/agent-prompts/docs-fix.md @@ -0,0 +1,58 @@ +# Documentation fix + +Template version: 2. Harness: openAMRobot/.github at ``. + +## Precondition block (post before the first write) + +``` +repository: openAMRobot/openamrobot-docs (or the repository that owns the README) +branch: , from main +parent SHA:
+pages: +reason: decision in decisions.yaml, or @:: +expected outcome: see below +``` + +The owning repository is the source of truth for commands, versions, parameters and contracts; +the docs site links to it. If the page and the owning repository disagree and decisions.yaml +does not settle it, stop and report instead of choosing. + +## Verified and planned content + +Every technical claim the page states or changes is one of two kinds, and the PR shows which: + +- **Verified fact:** supported by the owning repository. The PR (and the page, where the page + cites sources) gives the reference as `@::` or the decisions.yaml + entry ID. +- **Planned or experimental content:** a roadmap item, an open decision, an untested + configuration or a design not yet built. The page labels it **Planned** or **Experimental** + where it appears, never as current behaviour. + +Never invent a technical claim the owning repository does not support. When no source exists, +leave the claim out and report the gap in the PR instead of writing it. + +## Task + +1. Run `python3 tools/check_decisions.py` and `python3 tools/check_public_extract.py` from the + harness on the pages; keep the output. +2. Correct only the stated pages. Keep history marked as history (`decision-allow: `). + Label legacy material as legacy rather than deleting it. +3. Run the docs repository's own checks (`scripts/check_docs.sh`, strict MkDocs build). +4. Open a draft PR with the filled template. + +No internal document links, prices, personal names, contact data or credentials in any page. +No change to safety guidance beyond removing a contradiction; new safety guidance is written by +the platform lead. + +## Expected outcome + +- Draft PR changing only the listed pages; both checkers report zero findings on them. +- Every new or changed technical claim carries a source reference in the owning repository or a + Planned/Experimental label. +- The Not verified section states whether the site build and link check ran. + +## Failure rule + +A failed precondition stops the task. Report the command, the error and the next step. Do not +fix a different page, a different repository or an unrecorded value. Open an issue labelled +harness when the failure shows a gap in this template or the harness. diff --git a/agent-prompts/evaluator-pass.md b/agent-prompts/evaluator-pass.md new file mode 100644 index 0000000..e9f7ca7 --- /dev/null +++ b/agent-prompts/evaluator-pass.md @@ -0,0 +1,43 @@ +# Evaluator pass + +Template version: 1. Harness: openAMRobot/.github at ``. + +A second agent reads a PR before a human does and reports what the gates cannot see. It never +approves, requests changes, merges or pushes. + +## Precondition block (post before reading the diff) + +``` +repository: openAMRobot/ +pull request: # +head SHA: +base SHA: +rules: AGENTS.md shared block at +write access: none (report goes to the requesting person) +expected outcome: see below +``` + +If the PR head moved after the block was written, stop and restart with the new head. + +## Task + +1. Run check_pr_evidence.py, check_decisions.py and check_public_extract.py on the PR and record + their output. +2. For every rule in the shared block marked [decides: ...], state whether the PR needs that + decision and who makes it. +3. Check that each claimed test fails when the change is reverted: revert the change locally, + run the stated command, and record the result. +4. Check that nothing in the Evidence or Tests section overstates what ran (fixture as simulation, + skip as pass, draft as accepted). + +## Expected outcome + +- A report with: gate results, decisions needed and their deciders, revert-test result, and a + list of overstated claims, each with file and line. +- No write to the repository or the PR. + +## Failure rule + +A failed precondition stops the task. Report the command, the error and the next step. Do not +evaluate a different head or repository. Open an issue labelled harness when the failure shows +a gap in this template or the harness. diff --git a/agent-prompts/push-from-bundle.md b/agent-prompts/push-from-bundle.md new file mode 100644 index 0000000..12c5055 --- /dev/null +++ b/agent-prompts/push-from-bundle.md @@ -0,0 +1,45 @@ +# Push from bundle + +Template version: 1. Harness: openAMRobot/.github at ``. + +Use when a change set has been prepared and reviewed elsewhere (a patch, a git bundle or a +folder of files) and must be pushed to a contributor branch as a draft PR. + +## Precondition block (post before the first write) + +``` +repository: openAMRobot/ +branch: , not main +parent SHA: +bundle: , sha256 +files expected: +reviewed by: in +expected outcome: see below +``` + +Before writing, check that the remote default branch still contains the parent SHA, that the +bundle hash matches, and that the files the bundle changes are exactly the expected list. A +bundle prepared for one repository is never applied to another with a similar name. + +## Task + +1. Fetch the repository, create the branch from the parent SHA and apply the bundle. +2. Run the repository's verification (`tools/verify.sh`, or `rollout/verify.sh` from the harness). +3. Commit with `git commit -s` only under the contributor identity configured for this session. +4. Push the branch and open a draft PR using the repository's PR template, filled in, with + the AI disclosure and the verification evidence. + +Never force-push, merge, change settings, or push to main. + +## Expected outcome + +- One draft PR whose diff equals the bundle, on the stated parent SHA. +- The PR evidence check comment shows PASS, or the PR description lists each failure and why. +- The PR stays draft until the work-package owner writes adopt, adapt or reject. + +## Failure rule + +A failed precondition stops the task: wrong repository, parent SHA not found, hash mismatch, +unexpected files, or verification failure. Report the command, the error and the next step. +Do not rebase, regenerate or edit the bundle to make it fit. Open an issue labelled harness +when the failure shows a gap in this template or the harness. diff --git a/agent-prompts/read-only-audit.md b/agent-prompts/read-only-audit.md new file mode 100644 index 0000000..9a5c3a7 --- /dev/null +++ b/agent-prompts/read-only-audit.md @@ -0,0 +1,50 @@ +# Read-only audit + +Template version: 1. Harness: openAMRobot/.github at ``. + +## Precondition block (post before starting) + +``` +repository: openAMRobot/ (report destination) and the audited checkouts +branch: , created from main +parent SHA:
+audited SHAs: @, one line per checkout +previous report: /ISSUES.csv at , or "none" +decisions: openAMRobot/.github decisions.yaml at +write access: audit repository only; every audited repository is read-only +expected outcome: see below +``` + +Check every line before reading anything else. If a repository, branch or SHA differs from the +block, or a source cannot be read, apply the failure rule. + +## Task + +1. Run `python3 tools/check_decisions.py --decisions decisions.yaml --root --repository ` + for every audited checkout and keep the output. Treat any `WARNING` line for a past + `review_by` date as a warning in REPORT.md; it is not a contradiction and must not be + silently dropped. +2. Compare each finding of the previous ISSUES.csv with the current checkouts: mark it + resolved (cite the SHA and line that fixed it), still present, or changed. +3. Add new findings for contradictions between the plan documents supplied to you, decisions.yaml + and the repositories. Each finding has: id (area prefix and number), severity (Blocker, Major, + Minor, Question), sources quoted with file and line, decision of record, fix, file to change + and owner role from maintainers.yaml. Use roles, never personal names. +4. Write REPORT.md and ISSUES.csv (same columns as the previous one) to a new dated folder. + +Do not modify any audited repository, comment on any PR or issue, or run code that reaches +hardware or secrets. + +## Expected outcome + +- One new folder `-alignment-audit/` with REPORT.md and ISSUES.csv on the audit branch. +- A summary table: counts by area and severity, new, resolved and still-present findings. +- A `Decision review warnings` section listing every register entry past `review_by`, with its owner role and next human action. +- A "What could not be checked" section with the reason for each gap. +- No change in any audited repository. + +## Failure rule + +A failed precondition stops the task. Report the command, the error and the next step. Do not +substitute another repository, branch or source, and do not guess a value that could not be read. +Open an issue labelled harness when the failure shows a gap in this template or the harness. diff --git a/agent-rules/SHARED_RULES.md b/agent-rules/SHARED_RULES.md index 5c4b862..79fbbb9 100644 --- a/agent-rules/SHARED_RULES.md +++ b/agent-rules/SHARED_RULES.md @@ -1,44 +1,87 @@ - -# OpenAMRobot agent rules -Canonical shared block: openAMRobot/.github, agent-rules/SHARED_RULES.md. -This block is copied verbatim; GitHub does not propagate it between repositories. - -## Before editing -- Read AGENTS.md, CONTRIBUTING.md, applicable nested instructions, code and tests. -- Record the base SHA; inspect relevant open PRs and accessible branches/forks for overlap. -- Do not infer contributor inactivity from absent public branches; disclose inaccessible work. -- Follow the approved task scope and applicable plan/contracts. Report contradictions. -- Reuse maintained upstream packages and existing implementation; minimize custom glue. -- Do not replace working legacy support merely because the new-robot BOM excludes it. - -## Boundaries -- Shared ROS contracts belong in openamrobot-interfaces; identify their actual acceptance status. -- New contract proposals stay isolated and labelled Proposed, pending owner review. -- Do not author or modify safety implementation: E-stop, brakes, motion interlocks, - watchdogs, actuator enable or power-protection logic. Report required changes. -- Status display and isolated test fixtures do not implement or validate physical safety. -- Arm vendor SDKs stay behind Device Packages; none in UI or mission consumers. -- Preserve Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P distinctions. -- Jetson is the 2.0 reference compute; retain correctly labelled historical material. -- Public application name: Use_Case_1. No customer/partner names, secrets or private data. -- Preserve third-party provenance. Do not change licensing, NOTICE or CODEOWNERS - without an explicit task that authorizes those files and the appropriate review. -- Never connect untrusted/automated PR tests to physical motion hardware or secrets. - -## Delivery -- Use a contributor branch/fork and draft PR by default. Never merge, force-push, - modify protection/settings or bypass checks in ordinary implementation tasks. -- Read applicable CLA/DCO rules. Never invent an exemption, identity or attestation. -- Use git commit -s only with the verified contributor identity and provenance authority. -- Disclose material AI assistance, dependencies and licence implications. -- Report base/head SHAs, scope, safety impact, exact commands/results and evidence links. -- For bug fixes show the regression fails before and passes after; for new features - demonstrate a meaningful deliberate fault is detected. Explain non-applicability. -- Keep a Not verified section. SKIP/BLOCKED is not PASS; fixtures/fake hardware are - not integrated simulation, physical acceptance or release readiness. -- Do not weaken checks, use empty suites as evidence or invent successful test results. -- If blocked, stop the blocked activity, report command/error/next step and continue - independent in-scope work. Do not repeatedly reinstall or expand the architecture. -- Owner alignment and approval status must be truthful. A draft or notification is - not evidence that a required discussion or technical acceptance has happened. - + +# OpenAMRobot rules for contributors and agents +Canonical: openAMRobot/.github, agent-rules/SHARED_RULES.md, copied verbatim into every +repository's AGENTS.md (tools/check_agent_rules.py reports drift). This file holds process; +approved technical values live in openAMRobot/.github decisions.yaml. Labels: [check: tool] +means the tool detects that violation, and only in repositories where its workflow is installed +and required (rollout/README.md); [template: file]; [human: role, evidence] is a reviewer +decision. A text check proves textual consistency, never mechanical, electrical or safety correctness. + +## Decisions +- decisions.yaml is the only register of approved values, limits, exclusions and distinctions. + No file states a contradicting value; kept history carries `decision-allow: `. + [check: check_decisions.py, listed patterns only] [human: entry's reviewer, entry's evidence] +- Changing a decision: (1) open a contract change request issue naming the entry; (2) the + owner updates the source document; (3) one reviewed PR updates the register entry, its + supersedes history, its check patterns with a test, and every affected consumer, or links + each consumer PR; (4) the owner approves. [template: contract change request] [human: owner] +- Source documents are provenance. CI reads only the pinned register, never a drive or the + docs site; a disagreement is reported to the owner, and no tool rewrites either side. [human: owner] +- Read STATE.md before work in a repository; update it in the same PR. [check: check_pr_evidence.py] + +## Contracts +- Messages, services, actions, schemas, topic names, launch argument names and configuration + IDs change only through a contract change request and one PR that updates the contract + package and its consumers together. [template: contract change request] [human: software lead] +- New contract proposals stay labelled Proposed until the owner accepts them. [human: software lead] + +## Tests +- Every behaviour change carries a test that fails when the change is reverted; the Tests + section shows that failing run. [template: PR Tests section] [human: reviewer, the revert run] +- A suite that executes zero tests fails. [check: verify.sh; check_pr_evidence.py on reported counts] +- skip, xfail and importorskip name a tracking issue on the same line. [check: verify.sh] +- SKIP or BLOCKED is not PASS. Fixtures and fake hardware are not simulation, physical + acceptance or release readiness. [human: reviewer, Not verified section] + +## Evidence +- Every PR states base SHA, head SHA, exact commands, test counts and a Not verified section. + [check: check_pr_evidence.py, presence only] [human: reviewer, that the commands were run] +- A draft becomes ready only when the evidence check passes on the current head. [human: author; + ruleset required check once installed] + +## Dependencies and licences +- Nothing is added, removed or upgraded as a side effect. A changed dependency manifest needs + a Dependencies section naming each change, its licence and source. [check: check_pr_evidence.py] +- Licence headers and package.xml tags match the licence map: MIT software and firmware, + CERN-OHL-P-2.0 hardware, CC-BY-4.0 documentation. [human: repository owner] +- The Integration Gate section lists overlapping PRs, reused existing or upstream work and + what was rejected. Third-party provenance stays intact. [template: PR template] +- LICENSE, LICENSING.md, NOTICE and CODEOWNERS change only in a PR whose task names them. + [human: platform lead] + +## Safety +- No agent authors or modifies E-stop, brake, contactor, watchdog, motor-enable or + charge-inhibit logic; agents report the need in an issue. [check: check_pr_evidence.py fails + a safety-path change whose AI disclosure is not None] [human: platform lead, undisclosed use] +- A safety-path change needs two human approvals including the platform lead. The check only + reports "safety path touched, two human approvals required" and that reviewers are requested; + approvals are a ruleset requirement. [human: platform lead and one more maintainer] +- Functional telemetry, watchdogs, status displays and fixtures are never safety evidence. + [check: check_decisions.py wording only] [human: platform lead, hardwired safety-chain test record] +- Automated or untrusted PR jobs never reach motion hardware or secrets. [human: CI owner] + +## Publication +- Public material (docs/, assets/, README.md, any path containing "public") has no internal + document links, prices, contact data or credentials. [check: check_public_extract.py] +- It names no private person, customer or partner; the application name is Use_Case_1. [human: docs owner] + +## Agents +- Before any write, state a precondition block: repository (exact full name), branch, parent + SHA, expected outcome. Read-only unless the task says otherwise. [template: agent-prompts/] +- Agent PRs stay draft until the work-package owner writes adopt, adapt or reject in the thread. + [human: work-package owner] +- The PR's AI disclosure section names the tool and what it produced. [check: section present] +- Commits carry DCO sign-off with the contributor's own identity; never invent an identity or + attestation. [human: maintainer; DCO check where installed] +- Gate A Teensy/MPU6500 and Gate B STM32/ICM-42688-P stay distinct; Jetson is the 2.0 compute. + [check: check_decisions.py] Arm vendor SDKs stay behind device packages. [human: software lead] + +## Failure +- A failed precondition (repository, branch, SHA, access, source) stops the task. Report the + command, the error and the next step; never work around it. [template: agent-prompts/] +- Never merge, force-push, change settings, weaken a check or invent a result. [human: ruleset] + +## Learning +- Every agent or process mistake gets an issue labelled harness. [template: harness mistake form] +- The monthly retro turns harness issues into one PR against this block. [human: software lead] + diff --git a/agent-runs.md b/agent-runs.md new file mode 100644 index 0000000..52619bd --- /dev/null +++ b/agent-runs.md @@ -0,0 +1,26 @@ +# Agent run log + +One row per agent run that produced a report, a PR or a push. This is a public file: it +records sanitized failure categories, never the underlying content (no finding text, quotes, +personal details, internal links or supplier data). Details stay with the owner of the run. + +The work-package owner records the outcome in the PR thread (adopted, adapted or rejected); +this log copies it. "pending" means no owner decision is recorded; "not recorded" means the +available evidence does not say. Every mistake category names the harness change that now +addresses it, with its state: (a) implemented and tested in this repository, (b) supplied +under rollout/ and not installed anywhere, (c) human gate. + +| Date | Task | Template version | Environment | Model | Outcome | Mistake category | Harness change (state) | +|---|---|---|---|---|---|---|---| +| 2026-09-28 | Read-only alignment audit across repositories and plan documents | none (pre-harness) | read-only session, limited API access | not recorded | pending | Severity under-rated in the first pass and corrected on lead review | evaluator-pass prompt (b); lead review of severities (c) | +| 2026-09-28 | Documentation PRs for the 2.0 design section | none (pre-harness) | contributor branch | not recorded | adapted after review | Internal links, prices and owner names in a public asset | check_public_extract.py (a); ruleset install (b) | +| 2026-09-28 | Documentation PRs for the 2.0 design section | none (pre-harness) | contributor branch | not recorded | adapted after review | Decision presented as recorded before the source recorded it | decisions register with provenance and owner confirmation (a, c) | +| 2026-09-28 | Documentation PR citing a newer decision revision | none (pre-harness) | contributor branch | not recorded | pending | Decision cited from a source revision not held in any repository | owner confirmed the revision in force; register cites it by item (c) | +| 2026-09-28 | README alignment pushes in upper-body repositories | none (pre-harness) | contributor branches | not recorded | pending | none recorded | decision patterns keep the change from regressing (a) | +| 2026-09-28 | README alignment push in this repository | none (pre-harness) | contributor branch | not recorded | adopted (merged) | Superseded scope wording left in one line | check_decisions.py reports it on full scan (a) | +| 2026-09-17 | CI pushes in upper-body repositories | none (pre-harness) | contributor branches | not recorded | adopted (merged) | DCO sign-off under an identity that is not the contributor's | shared rule on sign-off identity (c); push-from-bundle prompt (b) | +| not recorded | Agent task against a mis-named repository | none (pre-harness) | not recorded | not recorded | not recorded | Repository mismatch stopped by precondition | failure rule in every agent prompt (b) | +| 2026-09-29 | This harness: rules, register, checks, templates and rollout | agent-prompts v1 (created by this run) | cloud session; read-only clones; API scoped to two repositories | not recorded | pending | Repository name mismatch in the task; worked around read-only and reported instead of stopping | precondition blocks name repositories by exact full name (b) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Reviewer handles not resolvable from organization evidence | maintainers.yaml leaves unverified handles empty (a); organization owner fills them (c) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Harness document restated a superseded value; caught by its own decisions check before push | check_decisions.py on changed files (a) | +| 2026-09-29 | This harness | agent-prompts v1 | same | not recorded | pending | Evidence check reported a test fixture as a dependency change (false positive), found by running the check on this PR's own description | fixture paths excluded, with a test (a) | diff --git a/decisions.yaml b/decisions.yaml new file mode 100644 index 0000000..8916650 --- /dev/null +++ b/decisions.yaml @@ -0,0 +1,868 @@ +# OpenAMRobot register of approved technical decisions. +# +# Two layers, kept separate: +# - AGENTS.md and CONTRIBUTING.md hold process: how a decision is proposed, +# reviewed, recorded, changed, tested and propagated. +# - This file holds the approved technical decisions repositories follow: +# values with units, allowed configurations, limits, exclusions and +# semantic distinctions. +# +# CI evaluates only this pinned, version-controlled file. No check fetches a +# plan document, a drive or the documentation site. Source documents are +# provenance: when a source and this register disagree, a human resolves it +# (the entry's owner) and no tool rewrites either side. tools/check_decisions.py +# only reads files and reports. +# +# What the scan proves: that a scanned file does not contain a listed +# contradicting phrase. It proves textual consistency only, never mechanical, +# electrical or safety correctness. Each entry's `verification.human` names +# the reviewer and the evidence that the scan cannot provide. +# +# Schema (schema_version 1), one entry per decision: +# id unique, upper case, stable +# title one line +# summary optional; the decision in one line of at most 120 characters, +# printed with each finding (the long value stays here) +# fix_hint optional; how to fix a finding, one line of at most 120 characters +# kind value | configuration | limit | exclusion | distinction +# status recorded (scanned and reported) | open (not decided; not scanned) | +# superseded (replaced by a later decision; only its citation is scanned) +# value/values exactly as the source states it; nothing invented +# unit SI unit or none +# date date the decision was taken (null when the source gives none) +# review_by review date; six weeks after date, or six weeks after this register update when date is null +# source document (a key under sources) and item +# supersedes earlier values with their source; optional `citation`, a +# pattern that finds text still citing the superseded source +# applies_to repositories (globs) and files (globs); exclude optional +# check patterns; each has a (?P...) group, optional files +# (globs narrowing this pattern), optional line-level `unless` +# exemption and a message +# verification machine: what the scan detects; human: reviewer role and +# the evidence they need +# owner role in maintainers.yaml who approves changes to this entry +# superseded_by for status superseded: document (a key under sources), item, +# optional decision (the replacing entry), citation (a pattern +# with a (?P...) group for text still presenting the +# superseded value as current) and optional unless; `check` +# is then not used +# +# Changing an entry follows "Changing a decision" in AGENTS.md: change request +# issue, source document first, then one reviewed PR that updates this file and +# every affected consumer together. +schema_version: 1 + +in_force: + source: P-03-rev18.7 + date: 2026-10-06 + +sources: + P-03-rev18.2: + title: P-03 Decision Addendum, revision 18.2, 28 September 2026 (earlier revision; superseded by revision 18.7 as the addendum in force) + evidence: >- + Not held in any repository. Confirmed by the platform lead on 29 September 2026 + as the addendum in force at that date. Values that cite it were seeded from its citation on the + documentation site (openamrobot-docs main e0f2aac, + docs/reference/openamrobot-2/index.md lines 30-39) and from that confirmation. + P-03-rev18.4: + title: P-03 Decision Addendum, revision 18.4 (earlier revision) + evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.7: + title: P-03 Decision Addendum, revision 18.7, 6 October 2026 (the addendum in force) + evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.6: + title: P-03 Decision Addendum, revision 18.6 (earlier revision) + evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.5: + title: P-03 Decision Addendum, revision 18.5, 6 October 2026 (earlier revision) + evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.3: + title: P-03 Decision Addendum, revision 18.3 (earlier revision) + evidence: plan-set document, not held in any repository; cited by item + P-03-rev18.1: + title: P-03 Decision Addendum, revision 18.1 + evidence: plan-set document, not held in any repository; cited by item and line + P-00-rev18.1: + title: P-00 Master Coordination Plan, revision 18.1 + evidence: plan-set document, not held in any repository; cited by text line + BOM-Issue-7.3: + title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3 (canonical per P-03 rev18.7) + evidence: not held in any repository; provenance only, CI never reads it + BOM-Issue-7: + title: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7, 27 September 2026 (earlier issue; canonical per P-03 rev18.2 item 7 until Issue 7.3) + evidence: not held in any repository; provenance only, CI never reads it + I8-WP: + title: I8 base-controller status work package + evidence: plan-set work package, not held in any repository + DOCK-WP: + title: 2.0 docking work package + evidence: plan-set work package, not held in any repository + +# Never scanned: this register, checker fixtures, change logs and the Watchdog guide +# (WATCHDOG.md quotes findings and its accepted-words table is generated from this register). +exclude: + - decisions.yaml + - WATCHDOG.md + - tests/fixtures/** + - "**/CHANGELOG.md" + - agent-runs.md + +decisions: + - id: BOM-ISSUE-IN-FORCE + title: Canonical hardware BOM issue for OpenAMRobot 2.0 + summary: "BOM Issue 7.3 is the canonical OpenAMRobot 2.0 hardware BOM." + fix_hint: "Name Issue 7.3 as canonical, or label older issues (Issue 6, Issue 7, B-01) as superseded." + kind: value + status: recorded + value: OpenAMRobot 2.0 Detailed Hardware BOM, Issue 7.3 + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: BOM issue in force} + supersedes: + - {value: Issue 7, source: P-03-rev18.2 item 7} + - value: Issue 6, or an explicitly approved successor + source: P-03-rev18.1 line 7 + citation: '(?PP-03[^\n]{0,30}(?:rev(?:ision)?\.?\s*)?18\.1[^\n]{0,30}line\s*7)' + - {value: B-01 development BOM and evidence register rev18, source: P-03-rev18.1 line 7} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} + check: + - pattern: '(?P(?:canonical|in force|of record)[^\n]{0,40}\bIssue\s*6\b|\bIssue\s*6\b[^\n]{0,40}\b(?:canonical|in force|of record))' + unless: 'supersed|replaced|previous|earlier' + message: BOM Issue 7.3 is the canonical hardware BOM (P-03 rev18.7) + - pattern: '(?PB-01[^\n]{0,60}\b(?:canonical|current))' + unless: 'supersed' + message: B-01 is superseded + - pattern: '(?P(?:canonical|in force|of record)[^\n]{0,40}\bIssue\s*7(?!\.3)(?:\.\d+)?\b|\bIssue\s*7(?!\.3)(?:\.\d+)?\b[^\n]{0,40}\b(?:canonical|in force|of record))' + unless: 'supersed|replaced|previous|earlier|historical' + message: BOM Issue 7.3 is the canonical hardware BOM (P-03 rev18.7) + verification: + machine: Issue 6, Issue 7 (other than 7.3) or B-01 presented as the canonical BOM; citations of P-03 rev18.1 line 7 + human: {reviewer: platform-lead, evidence: the BOM file header and issue number in the released BOM} + owner: platform-lead + + - id: MAST-INSTALL-HEIGHT + title: Shoulder-axis installation height of the fixed mast (superseded by LIFT) + summary: "Superseded: the fixed-mast 1350 mm installation height was replaced by LIFT." + fix_hint: "Describe the lift (shoulder axis 1000 to 1350 mm), or label the fixed-mast text as superseded or historical." + kind: configuration + status: superseded + value: 1350 + unit: mm + configuration_id: mast_1350 + date: 2026-09-28 + review_by: 2026-11-09 + source: {document: P-03-rev18.2, item: shoulder height} + supersedes: + - value: 1400 + configuration_id: mast_1400 + source: P-03-rev18.1 item 6 line 15 + citation: '(?PP-03[^\n]{0,30}(?:rev(?:ision)?\.?\s*)?18\.1[^\n]{0,30}item\s*6)' + - {value: 1300, configuration_id: mast_1300, source: working baseline before P-03-rev18.1 item 6} + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf", "**/*.launch.py", "**/package.xml", "**/*.html"] + superseded_by: + document: P-03-rev18.7 + item: item 8 + decision: LIFT + citation: '(?P\bmast_1350\b[^\n]{0,60}\b(?:baseline|installation)|(?:shoulder[- ]axis|installation) height\s*(?:at|of|is|:)?\s*1350\s*mm)' + unless: 'supersed|legacy|historical|earlier|previous|rev ?18\.[1-6]\b' + verification: + machine: text still presenting the superseded fixed-mast value as current (citation pattern) + human: {reviewer: platform-lead, evidence: URDF/Xacro mast configuration and the general arrangement drawing at 1350 mm} + owner: platform-lead + + - id: MAST-POSITIONS + title: Indexed mast mounting positions (superseded by LIFT) + summary: "Superseded: the indexed fixed-mast positions were replaced by LIFT." + fix_hint: "Describe the lift positions, or label mast_1300/1400/1450 text as superseded or historical." + kind: configuration + status: superseded + values: [1300, 1350, 1400, 1450] + unit: mm + step_mm: 50 + date: 2026-09-28 + review_by: 2026-11-09 + source: {document: P-03-rev18.2, item: shoulder height} + supersedes: + - {value: nine positions 1300 to 1700 mm (mast_1300 to mast_1700), source: P-00-rev18.1 txt 293-294} + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf", "**/*.launch.py", "**/*.html"] + superseded_by: + document: P-03-rev18.7 + item: item 8 + decision: LIFT + citation: '(?P\bmast_1(?:300|400|450)\b|\b(?:four|4) indexed (?:mast )?(?:mounting )?positions|\bindexed mast (?:mounting )?positions)' + unless: 'supersed|legacy|historical|earlier|previous|rev ?18\.[1-6]\b' + verification: + machine: text still presenting the superseded fixed-mast value as current (citation pattern) + human: {reviewer: platform-lead, evidence: mast drawing with index holes} + owner: platform-lead + + - id: MAST-TOP-HEIGHT + title: Mast top height above the floor (own COTS mast, one MISUMI HFS6-60120 profile; superseded by LIFT) + summary: "Superseded: the 1500 mm mast top on an HFS6-60120 profile was replaced by LIFT." + fix_hint: "Describe the lift, or label the mast-top or HFS6-60120 text as superseded or historical." + kind: value + status: superseded + value: 1500 + unit: mm + date: 2026-09-28 + review_by: 2026-11-09 + source: {document: P-03-rev18.2, item: mast} + supersedes: + - {value: 1554, source: general arrangement before 28 September 2026} + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] + superseded_by: + document: P-03-rev18.7 + item: item 8 + decision: LIFT + citation: '(?P\bmast top[^\n]{0,20}\b1500\s*mm|\bHFS6-60120\b)' + unless: 'supersed|legacy|historical|earlier|previous|rev ?18\.[1-6]\b' + verification: + machine: text still presenting the superseded fixed-mast value as current (citation pattern) + human: {reviewer: platform-lead, evidence: mast drawing and CAD} + owner: platform-lead + + - id: MAX-ASSEMBLED-HEIGHT + title: 1700 mm is the maximum assembled-height envelope, not a shoulder-axis height + summary: "1700 mm is the maximum assembled-height envelope, not a shoulder height." + fix_hint: "Use 1700 mm only as the assembled-height envelope; give shoulder heights from the LIFT entry." + kind: distinction + status: recorded + value: 1700 + unit: mm + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.7, item: item 8} + note: First recorded in P-03-rev18.1 item 6 line 15 and repeated in rev18.2; kept unchanged by rev18.7 item 8 with the lift. The robot may be lower, never higher. + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] + check: + - pattern: '(?Pmax(?:imum)?\s+(?:assembled\s+|robot\s+)?height\s*(?:of|is|:)?\s*(?!1700)1\d{3}\s*mm)' + message: maximum assembled height is 1700 mm + - pattern: '(?P(?:shoulder[- ]axis|shoulder height|mast_)[^\n]{0,20}\b1700\b)' + unless: 'max|envelope|not (?:the )?shoulder|higher|supersed' + message: 1700 mm is the assembled-height envelope, not a shoulder height or mast position + verification: + machine: another maximum-height value, or 1700 mm presented as a shoulder height or mast position + human: {reviewer: platform-lead, evidence: assembled-height measurement on the general arrangement} + owner: platform-lead + + - id: DATUM-HEIGHT-STACK + title: Height datum and base height stack + summary: "Floor Z = 0; steel deck top 294 mm; lift base-plate top face 304 mm is the height reference." + fix_hint: "Use 294 mm for the deck top and 304 mm for the base-plate top face, or label old values as historical." + kind: configuration + status: recorded + values: + - floor Z = 0 + - steel chassis deck top 294 mm (MMP STEP) + - top cover 2 mm plastic, or optional 0.5 to 0.8 mm sheet metal + - 10 mm aluminium lift base plate bears on the steel deck; the cover is cut out around it + - base-plate top face 304 mm is the reference for lift and shoulder heights + unit: mm + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: item 15} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"]} + check: + - pattern: '(?P\bdeck top\b[^\n]{0,25}?\b(?!294\b)\d{3}\s*mm)' + unless: 'supersed|historical|legacy|earlier|previous' + message: the steel chassis deck top is 294 mm above the floor (P-03 rev18.7 item 15) + - pattern: '(?P\bbase[- ]plate top(?: face)?\b[^\n]{0,25}?\b(?!304\b)\d{3}\s*mm)' + unless: 'supersed|historical|legacy|earlier|previous' + message: the lift base-plate top face is 304 mm above the floor, the reference for lift and shoulder heights (P-03 rev18.7 item 15) + verification: + machine: a deck-top height other than 294 mm or a base-plate top face other than 304 mm + human: {reviewer: platform-lead, evidence: MMP STEP deck height and the lift base-plate drawing} + owner: platform-lead + + - id: FRAMES-REP105 + title: Base frames follow REP 105 + summary: "REP 105: base_footprint on the floor, base_link at the axle midpoint, imu_link away from motors." + fix_hint: "Place base_footprint on the floor, base_link at axle height, and imu_link on the centreline away from motors." + kind: configuration + status: recorded + values: + - base_footprint on the floor under the drive-axle midpoint + - base_link at the drive-axle midpoint and axle height; x forward, z up + - imu_link on the centreline, away from motor magnetic fields + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: item 15} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf", "**/*.launch.py"]} + check: + - pattern: '(?P\bbase_link\b[^\n]{0,30}\b(?:on|at) the floor\b)' + unless: 'base_footprint|\bnot\b|never|supersed|historical|legacy' + message: base_link is at the drive-axle midpoint and axle height; base_footprint is on the floor (P-03 rev18.7 item 15) + - pattern: '(?P\bbase_footprint\b[^\n]{0,30}\bat (?:the )?axle height\b)' + unless: '\bnot\b|never|supersed|historical|legacy' + message: base_footprint is on the floor under the drive-axle midpoint (P-03 rev18.7 item 15) + - pattern: '(?P\bimu_link\b[^\n]{0,40}\b(?:next to|beside|on|near|above) the (?:drive |hub |wheel )?motors?\b)' + unless: '\baway\b|\bnot\b|never|supersed|historical|legacy' + message: imu_link sits on the centreline away from motor magnetic fields (P-03 rev18.7 item 15) + verification: + machine: base_link placed on the floor, base_footprint at axle height, or imu_link next to a motor + human: {reviewer: software-lead, evidence: URDF frame tree and real TF on the robot matching REP 105} + owner: platform-lead + + - id: BATTERY-PLACEMENT + title: Battery pack and placement + summary: "One 8S1P LF105 pack, 25.6 V, 105 Ah, as close to the rear edge as practical." + fix_hint: "Describe the pack as close to the rear edge as practical; label the 25 percent position as superseded." + kind: value + status: recorded + value: One 8S1P EVE LF105 LiFePO4 pack, 25.6 V, 105 Ah, placed as close to the rear edge as practical while preserving enclosure, service and safety clearances + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: item 9} + supersedes: + - {value: fit the existing battery bay first, source: P-00-rev18.1 txt 77} + - {value: centred at 25 percent of the robot length from the rear, source: P-03-rev18.2 battery} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?Pbattery[^\n]{0,40}\bcent(?:red|ered) at \d+\s*(?:%|percent)|\b25\s*(?:%|percent) of the (?:robot )?length from the rear)' + unless: 'supersed|historical|previous|earlier|rev ?18\.[1-6]\b' + message: the battery sits as close to the rear edge as practical while preserving enclosure, service and safety clearances (P-03 rev18.7 item 9) + verification: + machine: a battery centred at a fixed percentage of the length, including the superseded 25 percent position + human: {reviewer: platform-lead, evidence: general arrangement and mass model} + owner: platform-lead + note: The rev18.1 plan set did not record a placement; P-03 rev18.2 recorded 25 percent; rev18.7 item 9 replaces it. + + - id: SPEED-CEILING + title: 1.5 m/s is a command ceiling, not an operating speed + summary: "1.5 m/s is a command ceiling (analytical limit), not an accepted operating speed." + fix_hint: "Call 1.5 m/s a command ceiling; take operating speed from the stability model and stopping tests." + kind: limit + status: recorded + value: 1.5 m/s command ceiling, treated as an analytical limit; the accepted operating speed follows from the stability model and stopping tests + unit: m/s + date: 2026-09-28 + review_by: 2026-11-09 + source: {document: P-03-rev18.2, item: speed} + supersedes: + - {value: 1.5 m/s treated as an accepted operating speed, source: P-00-rev18.1 txt 60 and 352} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?Psoftware speed limit\s*(?:of\s*)?1\.5\s*m/s)' + unless: 'ceiling|analytical' + message: 1.5 m/s is a command ceiling, not an operating limit + - pattern: '(?P(?:operating|validated|rated) speed\s*(?:of|is|:)?\s*1\.5\s*m/s)' + unless: 'ceiling|analytical|not' + message: 1.5 m/s is not an accepted operating speed + verification: + machine: 1.5 m/s presented as a software or operating speed limit in text + human: {reviewer: platform-lead, evidence: stability model and recorded stopping tests; configured velocity limits in the Nav2 and base parameters} + owner: platform-lead + + - id: DRIVETRAIN + title: Drivetrain + summary: "Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D driver on CAN1." + fix_hint: "Name the ZLLG80ASM250-L-B as selected; mark brake ratings as pending supplier evidence." + kind: value + status: recorded + value: Two ZLTECH ZLLG80ASM250-L-B hub motors with brakes, one ZLAC8015D V4.2 driver on CAN1 (CANopen), 200 mm wheels + unit: none + date: 2026-09-21 + review_by: 2026-11-02 + source: {document: P-00-rev18.1, item: txt 16} + applies_to: + repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs", "openamrobot-release", ".github"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.xacro", "**/*.urdf"] + check: + - pattern: '(?PZLLG80ASM250-L)(?!-B)\b' + message: the selected motor variant is ZLLG80ASM250-L-B + - pattern: '(?PZL ?TECH[^\n]{0,40}\b(?:alternative|option(?:al)?)\b)' + message: ZLTECH is the selected 2.0 drivetrain, not an option + - pattern: '(?Pfail-safe brakes?)' + unless: 'pending|F2A|verif|not yet|unverified' + message: brake fail-safe function and ratings are open supplier-evidence gates + verification: + machine: the unbraked motor variant, ZLTECH described as optional, brakes described as fail-safe + human: {reviewer: platform-lead, evidence: supplier datasheets for the brake rating and fail-safe behaviour} + owner: platform-lead + + - id: RS485-NOT-IN-2-0 + title: Release 2.0 has no RS485 hardware, fallback, adapter or commissioning path + summary: "Release 2.0 has no RS485 hardware, fallback, adapter or commissioning path." + fix_hint: "Remove RS485 from 2.0 text, or label it as legacy, Gate A or not in 2.0." + kind: exclusion + status: recorded + value: none + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.1, item: item 4} + applies_to: + repositories: ["openamr-platform-hw", "openamr-platform-fw", "openamr-upperbody-*", "openamrobot-docs"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] + check: + - pattern: '(?PRS-?485)' + unless: 'not used|no RS-?485|not provisioned|without|legacy|Gate A|removed|not in 2\.0' + message: RS485 is not provisioned in 2.0 + - pattern: '(?PCAN or serial)' + message: CAN1 and CAN2 are dedicated; no upper-body serial link to the base + verification: + machine: RS485 mentioned without a negation or legacy label; "CAN or serial" links + human: {reviewer: platform-lead, evidence: wiring diagram and BOM without RS485 parts} + owner: platform-lead + + - id: BASE-CONTROLLER-GATES + title: Base-controller gates and IMU gating stay distinct + summary: "Gate A (Teensy, MPU6500) and Gate B (STM32H723ZG) stay distinct; ICM-42688-P is gated." + fix_hint: "Call the Teensy the Gate A controller, the IMU MPU6500, and the ICM-42688-P conditional (Gate B)." + kind: distinction + status: recorded + values: + - Gate A, Jetson with the existing Teensy, ZBLD/PWM drivetrain and MPU6500 (legacy test configuration) + - Gate B, Jetson with the STM32H723ZG (NUCLEO-H723ZG bench board) and the ZLTECH drivers + - ICM-42688-P enters the manufacturing BOM only after side-by-side robot evidence; adoption decision 20 November 2026 + - STM32 go/no-go 6 November 2026; fallback is the validated legacy Teensy/PWM build behind I8 + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-00-rev18.1, item: "txt 63, 101, 106, 344; I8-WP adoption gate; Gate B board per P-03-rev18.5 item 14"} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?PTeensy[^\n]{0,20}\bbench target)' + message: Teensy is the Gate A controller and the release fallback, not a bench target + - pattern: '(?PICM-42688-P[^\n]{0,40}\b(?:selected|baseline|chosen)\b)' + unless: 'Gate B|conditional|20 Nov|candidate|pending|not yet|after' + message: ICM-42688-P is gated; MPU6500 remains the Gate A IMU + - pattern: '\b(?PMPU6050)\b' + message: the Gate A IMU is the MPU6500 + verification: + machine: Teensy called a bench target, ICM-42688-P called selected without its gate, MPU6050 named as the IMU + human: {reviewer: platform-lead, evidence: gate records (Gate A test log, STM32 go/no-go record, IMU side-by-side data)} + owner: platform-lead + + - id: BASE-CONTROLLER-IO + title: Base-controller MCU, sensor and IMU buses, CAN mapping and micro-ROS transport + summary: "STM32H723ZG; MB7060 on dedicated UARTs; CAN1/2/3 split; micro-ROS over UDP on Ethernet." + fix_hint: "Use the STM32H723ZG and MB7060 on UART; label STM32H743 or MB7040 text as superseded; USB is bench only." + kind: configuration + status: recorded + values: + - Base controller STM32H723ZG on NUCLEO-H723ZG + - TF-Luna on UART, one per UART + - two MaxBotix MB7060 serial sensors, each on one dedicated STM32 UART, 9600 8N1; no sensor I2C off the controller board + - IMU on a dedicated SPI, Mode 0 + - CAN1 traction only, CAN2 BMS, CAN3 reserved for upper-body auxiliary actuators (CANopen, no safety function) + - MCU to Jetson over Ethernet, micro-ROS over UDP; USB for the bench only + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: item 14} + supersedes: + - {value: STM32H743 on NUCLEO-H743ZI2, source: P-00-rev18.1 (Gate B bench board)} + - {value: MB7040 one per I2C bus, source: P-03-rev18.5 item 14} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?PNUCLEO-H743ZI2|STM32H743)' + unless: 'supersed|legacy|historical|replaced' + message: the base controller is the STM32H723ZG on the NUCLEO-H723ZG; label STM32H743 / NUCLEO-H743ZI2 material as superseded + - pattern: '(?P\bMB7040\b)' + unless: 'supersed|legacy|historical|replaced' + message: the ultrasonic sensors are two MaxBotix MB7060 on dedicated STM32 UARTs at 9600 8N1; no sensor I2C (P-03 rev18.7 item 14) + - pattern: '(?P\bmicro-?ROS\b[^\n]{0,30}\bover (?:USB|serial)\b)' + unless: 'bench|legacy|Gate A|Teensy|supersed|historical' + message: micro-ROS runs over UDP on Ethernet between MCU and Jetson; USB is for the bench only (P-03 rev18.7 item 14) + verification: + machine: NUCLEO-H743ZI2 or STM32H743 without a superseded, legacy, historical or replaced label; MB7040 without such a label; micro-ROS over USB or serial outside the bench or Gate A + human: {reviewer: platform-lead, evidence: P-03 rev18.7 item 14 and the MCU I/O allocation in openamr-platform-hw} + owner: platform-lead + + - id: IMU-TOPIC-OWNERSHIP + title: IMU topic ownership + summary: "Firmware publishes /imu/data_raw; the host filter and EKF own /imu/data." + fix_hint: "Make firmware publish /imu/data_raw and leave /imu/data to the host filter." + kind: distinction + status: recorded + values: + - firmware publishes /imu/data_raw + - the host filter and EKF own /imu/data + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-00-rev18.1, item: txt 15 and 345; I8-WP line 224} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", "**/*.ino", "**/*.cpp", "**/*.h"] + check: + - pattern: '(?:micro-?ROS agent|Teensy|firmware|MCU|STM32)[^\n]{0,80}(?P/imu/data)(?!_raw)\b' + unless: '\bhost\b|(?/?imu/data)"' + files: ["**/*.ino", "**/*.cpp", "**/*.c", "**/*.h"] + message: firmware must not publish filtered /imu/data + verification: + machine: text attributing /imu/data to firmware; firmware sources containing the literal topic imu/data + human: {reviewer: software-lead, evidence: ros2 topic info on the running bring-up showing one publisher per topic} + owner: software-lead + + - id: CAMERAS + title: Cameras + summary: "Base camera Orbbec Gemini 336L, about 243 mm up, tilted 5, 10 or 15 degrees up (baseline 10)." + fix_hint: "Name the Gemini 336L with an up-tilt of 5, 10 or 15 degrees, or label other cameras as legacy." + kind: value + status: recorded + values: + - base camera Orbbec Gemini 336L, fixed to base_link on the front face about 243 mm above the floor; up-tilt positions 5, 10 and 15 degrees, baseline 10 degrees + - head camera Stereolabs ZED Mini (SKU ZED-121210) on the lift carriage, see HEAD-CAMERA-IDENTITY + - two in-hand RGB wrist cameras; no separate torso scene camera + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: "item 15 (wrist cameras as in P-00-rev18.1 txt 85 and 97)"} + supersedes: + - {value: "base camera tilted about 10 degrees upward; head camera identity open", source: P-00-rev18.1 txt 85 and 97} + applies_to: + repositories: ["openamr-*", "openamrobot-docs", "openamrobot-manipulation", "openamrobot-ui"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.launch.py", "**/*.xacro", "**/*.urdf"] + check: + - pattern: '(?P\b(?:D4[35]5i?|RealSense)\b)' + unless: 'legacy|historical|previous|not used|replaced' + message: the base camera is the Orbbec Gemini 336L + - pattern: '(?Pdown-tilted depth camera)' + message: the base camera tilts about 10 degrees up + - pattern: '(?PIMX708|Pi Camera Module 3)' + unless: 'legacy|Gate A|historical' + message: the 2.0 base camera is the Orbbec Gemini 336L + - pattern: '(?PGemini 336L[^\n]{0,60}?\b(?!(?:5|10|15)\b)\d{1,2}\s*(?:°|deg(?:rees?)?)\s*(?:up|upward)\b)' + unless: 'supersed|historical|legacy' + message: the Gemini 336L up-tilt positions are 5, 10 and 15 degrees, baseline 10 (P-03 rev18.7 item 15) + verification: + machine: other camera models without a legacy label; a down-tilted base camera; a Gemini 336L up-tilt other than 5, 10 or 15 degrees + human: {reviewer: software-lead, evidence: camera joint rpy in URDF and real TF showing the upward tilt} + owner: platform-lead + + - id: HEAD-CAMERA-IDENTITY + title: Head camera commercial identity and mounting + summary: "Head camera: Stereolabs ZED Mini, SKU ZED-121210, on the lift carriage." + fix_hint: "Name the ZED Mini (ZED-121210), or label other ZED models as superseded or legacy." + kind: value + status: recorded + values: + - Stereolabs ZED Mini, SKU ZED-121210, supplied with the OpenArm 2.0 set + - mounted on the lift carriage + - pitch 15 to 35 degrees down in 5 degree steps, baseline 25 degrees + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: item 15} + supersedes: + - {value: "ZED-121210 per the plan; identity open because the general arrangement named a different ZED model", source: P-00-rev18.1 txt 85} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml", "**/*.xacro", "**/*.urdf"]} + check: + - pattern: '(?P\bZED[ -]?(?:2i?|X(?:[ -]?Mini)?)\b)' + unless: 'supersed|historical|legacy|replaced|\bnot\b' + message: the head camera is the Stereolabs ZED Mini, SKU ZED-121210 (P-03 rev18.7 item 15) + verification: + machine: another ZED model (ZED 2, ZED 2i, ZED X, ZED X Mini) without a superseded or legacy label + human: {reviewer: platform-lead, evidence: supplier SKU on the OpenArm 2.0 set and the lift-carriage mount drawing with the pitch steps} + owner: platform-lead + + - id: POWER-RAILS + title: Power rails; no 12 V rail + summary: "25.6 V battery bus, regulated 24 V and 5 V branches; there is no 12 V rail." + fix_hint: "Remove the 12 V rail from 2.0 text, or label it as legacy." + kind: exclusion + status: recorded + values: + - main battery bus 25.6 V nominal (not a regulated 24 V rail) + - regulated 24 V branch (safety relay, brake coils, peripherals) + - regulated 5 V (includes the navigation LiDAR) + - no 12 V rail + unit: V + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.1, item: "item 2 line 10; P-03-rev18.4 item 13"} + applies_to: + repositories: ["openamr-platform-*", "openamr-upperbody-*", "openamrobot-docs"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"] + check: + - pattern: '(?P\b12\s*V\s+rail)' + unless: '\bno 12|not|never|legacy' + message: there is no 12 V rail in 2.0 + - pattern: '(?P24\s*V?\s*(?:->|→|to)\s*5\s*V?\s*/\s*12\s*V)' + unless: 'legacy' + message: there is no 12 V rail in 2.0 + verification: + machine: a 12 V rail or a 24 V to 5/12 V converter in text + human: {reviewer: platform-lead, evidence: power distribution schematic} + owner: platform-lead + + - id: DOCK-NO-CONTACTS + title: Release 2.0 docking has no dock contacts or dock pilot; wireless charging is 3.0 + summary: "2.0 docking is positioning only: no dock contacts or pilot; wireless charging is 3.0." + fix_hint: "Say the dock has no contacts and charging is manual and wired; put wireless charging in 3.0." + kind: exclusion + status: recorded + value: Positioning only; manual wired charging via PWR-019 with independent charge-plug-presence inhibition; no dock contacts or dock pilot; wireless charging belongs to 3.0 + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.1, item: item 3 line 11} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/*.launch.py", "**/*.xacro", "**/*.urdf"] + check: + - pattern: '(?Pcharg(?:e|ing)[ -]contacts?)' + unless: '\bno\b|\bnot\b|without|3\.0|never' + message: 2.0 docking has no dock contacts + - pattern: '(?P(?:charge|charger|dock|DC)\s*\+?\s*pilot)' + unless: '\bno\b' + message: no dock or charge pilot in 2.0 + - pattern: '(?Pwireless charging)' + unless: '3\.0|later|deferred|not ' + message: wireless charging belongs to 3.0 + verification: + machine: charge contacts or pilots without a negation; wireless charging without a 3.0 label + human: {reviewer: platform-lead, evidence: dock drawing and harness list without contacts} + owner: platform-lead + + - id: DOCKING-NOT-CHARGING + title: Docking success never establishes charging + summary: "Docked means an accepted pose; docking never establishes charging or external power." + fix_hint: "Report docking as a pose result only; never infer charging from it." + kind: distinction + status: recorded + value: Docked means an accepted pose within tolerance; pose success never establishes charging or external power + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.1, item: item 3 line 11; DOCK-WP lines 27 and 70} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-ui", "openamrobot-interfaces"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.py", "**/*.ts", "**/*.tsx", "**/*.js"] + check: + - pattern: '(?PSimpleChargingDock|ready to charge|charging target)' + unless: 'not |never|instead|non-charging|legacy' + message: 2.0 docking is positioning only + - pattern: '(?PisCharging[^\n]{0,20}\breturn\s+true|return\s+true[^\n]{0,20}isCharging)' + message: never report charging from a docking pose + - pattern: '(?Pdock(?:ed|ing)?[^\n]{0,60}\b(?:connected to (?:external )?power|is charging|charging (?:started|confirmed)))' + unless: '\bnot\b|never|does not' + message: docking success never establishes charging + verification: + machine: charging dock plugins, forced isCharging, text deriving charging or external power from docking + human: {reviewer: software-lead, evidence: docking state machine and UI source showing charge state comes only from the charge-plug-presence input} + owner: platform-lead + + - id: TELEMETRY-NOT-SAFETY-EVIDENCE + title: Functional telemetry is never safety evidence + summary: "Telemetry, watchdogs and BMS data are functional, never safety evidence." + fix_hint: "Describe telemetry and watchdogs as functional, not as a safety layer." + kind: distinction + status: recorded + value: Firmware status, watchdogs, collision monitoring and BMS telemetry are functional; E-stop, brake and actuator power removal are hardwired and independent of software + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-00-rev18.1, item: txt 70 and 78; I8-WP line 669; DOCK-WP line 110} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} + check: + - pattern: '(?P(?:watchdog|collision monitor|telemetry|status message|diagnostic)s?[^\n]{0,40}\bsafety (?:layer|function|evidence|guarantee))' + unless: '\bnot\b|never|functional|no substitute' + message: functional telemetry is not a safety layer or safety evidence + verification: + machine: text calling watchdogs, collision monitoring, telemetry or diagnostics a safety layer, function or evidence + human: {reviewer: platform-lead, evidence: hardwired safety-chain test record; a software PASS is never accepted in its place} + owner: platform-lead + + - id: SAFETY-PROCUREMENT + title: Safety-chain procurement boundary (record only; this register implements no safety function) + summary: "Two dual-channel latching E-stops into a monitored safety relay; EDM contactor unselected." + fix_hint: "Do not recommend single-channel or uncertified E-stops; keep the EDM variant open and CTL-004 bench-only." + kind: limit + status: recorded + values: + - two installed E-stops (base front panel and chest), red latching mushroom, dual normally-closed channels into a monitored safety relay + - K1 and K2 contactors from the LEV100 family; the linked-feedback (EDM) variant is unselected under one CONTACTOR gate + - CTL-004 is bench-only until an installed solution is qualified + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.1, item: items 4 and 5 lines 12-13} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?P(?:EDM|linked[- ]feedback) variant[^\n]{0,30}\b(?:selected|chosen|decided))' + unless: '\bnot\b|unselected|open' + message: the EDM variant is not selected + - pattern: '(?Pfine for prototyp\w*|single[- ]contact[^\n]{0,30}e-?stop)' + message: no single-channel or uncertified E-stop recommendation + - pattern: '(?PCTL-004[^\n]{0,60}\b(?:installed|robot interface))' + unless: 'bench-only|until' + message: CTL-004 is bench-only + verification: + machine: text claiming the EDM variant is chosen, recommending a single-channel or uncertified E-stop, or presenting CTL-004 as installed + human: {reviewer: platform-lead, evidence: safety-chain design and component certificates; two human approvals per the repository ruleset} + owner: platform-lead + + - id: COMPUTE + title: Reference compute + summary: "Reference compute: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401; Pi is legacy." + fix_hint: "Name the Jetson Orin NX, or label Raspberry Pi material as legacy or Gate A." + kind: value + status: recorded + value: NVIDIA Jetson Orin NX 16 GB on a reComputer Robotics J401 carrier with NVMe; Raspberry Pi removed from active support + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-00-rev18.1, item: "txt 14, 90 and 278"} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?PRaspberry Pi 5)' + unless: 'legacy|historical|removed|supersed|Gate A|previous|earlier' + message: Jetson Orin NX is the 2.0 reference compute; label Raspberry Pi material as legacy + verification: + machine: Raspberry Pi 5 without a legacy label + human: {reviewer: software-lead, evidence: bring-up documentation on the Jetson} + owner: platform-lead + + - id: NAV-LIDAR + title: Navigation LiDAR + summary: "Navigation LiDAR: SLAMTEC RPLIDAR S3 (S3M1-R2) on USB, regulated 5 V rail." + fix_hint: "Name the RPLIDAR S3; label Hokuyo as dropped or superseded and the A1 as legacy (existing robot)." + kind: value + status: recorded + value: SLAMTEC RPLIDAR S3 (S3M1-R2), connected over USB, powered from the regulated 5 V rail (functional sensing, not a safety device) + unit: none + backup: RPLIDAR S2E, only if the S3 is unavailable + date: 2026-10-03 + review_by: 2026-11-14 + source: {document: P-03-rev18.4, item: item 13} + supersedes: + - {value: Hokuyo UST-10LX, source: P-03-rev18.3 item 11 (dropped on cost)} + - {value: RPLIDAR A1, source: existing robot (Gate A)} + applies_to: + repositories: ["openamr-platform-*", "openamrobot-docs", "openamrobot-release"] + files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/package.xml", "**/*.html"] + check: + - pattern: '(?P\bHokuyo\b|\bUST-10LX\b)' + unless: 'supersed|dropped|legacy|historical' + message: the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); the Hokuyo UST-10LX was dropped + - pattern: '(?P\bRPLIDAR A1(?:M8)?\b|\bA1M8\b)' + unless: 'legacy|Gate A|existing robot|historical|replaced' + message: the 2.0 navigation LiDAR is the RPLIDAR S3 (S3M1-R2); label RPLIDAR A1 material as legacy (Gate A) + verification: + machine: Hokuyo or UST-10LX without a superseded or legacy label; RPLIDAR A1 or A1M8 without a legacy label + human: {reviewer: software-lead, evidence: bring-up launch and scan topic from the sllidar_ros2 driver with the RPLIDAR S3} + owner: software-lead + + - id: RELEASE-MILESTONES + title: OpenAMRobot 2.0 release milestones + summary: "v2.0.0-rc.1 on 20 November 2026; v2.0.0 final release on 18 December 2026." + fix_hint: "Use 20 November (v2.0.0-rc.1) and 18 December 2026 (v2.0.0); label v0.2 or 13 November as superseded." + kind: value + status: recorded + values: + - "20 November 2026: OpenAMRobot 2.0 pre-release, version v2.0.0-rc.1 (GitHub pre-release; full readiness, frozen platform baseline, end of development cycle 2)" + - "23 November to 18 December 2026: physical integration, testing and acceptance" + - "18 December 2026: OpenAMRobot 2.0 final release, version v2.0.0" + unit: none + date: 2026-10-01 + review_by: 2026-11-12 + source: {document: P-03-rev18.6, item: item 12} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.html", "**/*.yaml", "**/*.yml"]} + check: + # The schema requires at least one check: a 2.0 final release given another date. + - pattern: '(?P2\.0 final release[^\n]{0,20}?\b(?!18 December 2026)\d{1,2} (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{4})' + unless: 'supersed|previous|earlier|historical' + message: the OpenAMRobot 2.0 final release is 18 December 2026 + - pattern: '(?P\bv0\.2\b[^\n]{0,40}\brelease\b|\brelease\b[^\n]{0,20}\bv0\.2\b|\breadiness declaration for v0\.2\b)' + unless: 'supersed|historical|earlier|previous' + message: there is no v0.2 release; development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, and v2.0.0 follows on 18 December 2026 + - pattern: '(?P\bcycle\b[^\n]{0,60}\b13 November(?: 2026)?\b|\b13 November(?: 2026)?\b[^\n]{0,60}\bcycle\b)' + unless: 'supersed|historical|earlier|previous' + message: development cycle 2 ends 20 November 2026 with v2.0.0-rc.1, not 13 November + verification: + machine: text giving the 2.0 final release a date other than 18 December 2026; a v0.2 release or a development cycle ending 13 November 2026 without a superseded label + human: {reviewer: platform-lead, evidence: the release plan and versions in P-03 rev18.6 item 12} + owner: platform-lead + + - id: LIFT + title: Lift approved in principle for OpenAMRobot 2.0 + summary: "The lift is approved in principle for 2.0; shoulder axis 1000 to 1350 mm; release gates open." + fix_hint: "Describe the lift as approved in principle, or label fixed-mast and 3.0 lift text as superseded." + kind: configuration + status: recorded + values: + - lift approved in principle + - DOLD Hexalift V1 350 mm primary; TiMOTION TL3 400 mm fallback + - shoulder axis 1000 to 1350 mm + - base plate fore-aft positions centre, +50, +100, +150 and +200 mm + - lift motion only in the stowed or carry safe pose with the base stopped + - hold-and-move limits 0.05 m/s (drawer reversal) and 0.3 m/s (carrying) + - "release gates: holding on E-stop and power loss, supplier CAD, base-plate geometry and the CAN3 lift interface (items 6, 8, 14, 15)" + unit: none + date: 2026-10-06 + review_by: 2026-11-17 + source: {document: P-03-rev18.7, item: "item 8; release gates items 6, 8, 14, 15"} + supersedes: + - {value: "Lift removed from OpenAMRobot 2.0; fixed mast; lift on the 3.0 roadmap (former entry LIFT-REMOVED)", source: P-00-rev18.1 txt 18} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html", "**/package.xml", "**/*.xacro", "**/*.urdf"]} + check: + - pattern: '(?P\bno (?:linear |actuated )?lift\b(?=\s*(?:[.,;:)]|$|(?:in|for|on)\b)))' + unless: 'supersed|historical|earlier|previous|no longer|rev ?18\.[1-6]\b' + message: the lift is approved in principle for 2.0 (P-03 rev18.7 item 8) + - pattern: '(?P\bfixed[- ]mast\b)' + unless: 'supersed|historical|earlier|previous|legacy|replace|instead of|no longer|rev ?18\.[1-6]\b' + message: the fixed mast is no longer the baseline; the lift is approved in principle (P-03 rev18.7 item 8) + - pattern: '(?P\blift\b[^\n]{0,40}\b(?:deferred|moved|postponed)\b[^\n]{0,25}\b3\.0\b|\blift\b[^\n]{0,15}\bin (?:OpenAMRobot )?3\.0\b|\blift (?:is |was )?removed\b|\blift\b[^\n]{0,40}\b(?:OpenAMRobot )?3\.0 scope\b|\b3\.0 lift\b)' + unless: 'supersed|historical|earlier|previous|no longer|rev ?18\.[1-6]\b' + message: the lift is not deferred to 3.0; it is approved in principle for 2.0 (P-03 rev18.7 item 8) + verification: + machine: text saying 2.0 has no lift, presenting a fixed mast as the current baseline, or deferring the lift to 3.0 + human: {reviewer: platform-lead, evidence: "P-03 rev18.7 item 8 and the release-gate records: holding on E-stop and power loss, supplier CAD, base-plate geometry and the CAN3 lift interface"} + owner: platform-lead + + - id: NO-SUSPENSION + title: Release 2.0 has no suspension + summary: "Release 2.0 has no suspension; sprung drive wheels are a 3.0 item." + fix_hint: "Say there is no suspension in 2.0, or put sprung drive wheels in 3.0." + kind: exclusion + status: recorded + value: No suspension is fitted in 2.0; sprung drive wheels are a 3.0 item + unit: none + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-03-rev18.1, item: item 1} + applies_to: {repositories: ["*"], files: ["**/*.md", "**/*.yaml", "**/*.yml", "**/*.html"]} + check: + - pattern: '(?Psprung drive(?: module| wheels?)?|drive(?:train)? suspension)' + unless: '3\.0|\bno\b|not fitted|without' + message: no suspension in 2.0 + verification: + machine: sprung drive or drive suspension without a 3.0 or negation label + human: {reviewer: platform-lead, evidence: chassis drawing} + owner: platform-lead + + - id: DRIVE-TRACK + title: Drive track + summary: "Open: 400 mm in the general arrangement; the final drive track is still an open input." + fix_hint: "Not scanned while open; give the track as 400 mm (general arrangement) until the value is decided." + kind: value + status: open + value: 400 mm in the general arrangement; the final track is an open input + unit: mm + date: null + review_by: 2026-11-18 # six weeks after this register update (2026-10-07) + source: {document: P-00-rev18.1, item: open inputs} + applies_to: {repositories: ["openamr-platform-*", "openamrobot-ui"], files: ["**/*.xacro", "**/*.urdf", "**/*.yaml", "**/*.sdf"]} + check: + - pattern: '(?P0\.4075)' + message: track value disagrees with the general arrangement + verification: + machine: none while open + human: {reviewer: platform-lead, evidence: final hub-motor bracket design} + owner: platform-lead diff --git a/maintainers.yaml b/maintainers.yaml new file mode 100644 index 0000000..d1ef5ba --- /dev/null +++ b/maintainers.yaml @@ -0,0 +1,77 @@ +# Maintainers map for automation. Handles only; no names or contact data. +# An empty handle means no GitHub handle is recorded yet; the organization owner +# fills it once the person has confirmed their account and joined the +# organization. Automation that needs that role names the role and mentions nobody. +# Changes to this file need a code-owner review. This repository's .github/CODEOWNERS +# names owner handles, not a team: @wikki26, @BotshareAI and @panthera-momagdii for +# every path; each repository's own CODEOWNERS names its owner handles. The author's +# own approval never counts. +schema_version: 1 + +roles: + platform-lead: + handle: BotshareAI + covers: platform, hardware, safety chain, decisions of record, final review + software-lead: + handle: panthera-momagdii + covers: robot software, AI, interfaces, agent rules + ci-owner: + handle: wikki26 + covers: CI/CD, reusable workflows, verify.sh, quality gates + release-owner: + handle: KARTHIKEYAN124 + covers: release, installation, manifest + docs-owner: + handle: anandgawai123456-glitch + covers: documentation site, public extracts + +review_policy: + platform-lead-authored: + required_roles: [software-lead] + scoped_owners_required: true + author_statement_counts_as_approval: false + safety_paths_still_require_platform_lead: true + unavailable_owner_requires_org_owner_exception: true + +# Owner of audit findings by ID prefix, used by the weekly alignment audit. +audit_prefixes: + GEO: platform-lead + BOM: platform-lead + ELE: platform-lead + TEAM: platform-lead + SW: software-lead + CI: ci-owner + PR: ci-owner + DOC: docs-owner + +# Owner role per repository, used by docs-sync and the rollout CODEOWNERS proposal. +repositories: + .github: [ci-owner, platform-lead] + openamrobot-interfaces: [software-lead] + openamr-platform-sw: [software-lead] + openamr-platform-fw: [platform-lead] + openamr-platform-hw: [platform-lead] + openamr-upperbody-sw: [software-lead] + openamr-upperbody-fw: [platform-lead] + openamr-upperbody-hw: [platform-lead] + openamrobot-manipulation: [software-lead] + openamrobot-ui: [software-lead] + openamrobot-comm: [software-lead] + openamrobot-manifest: [release-owner] + openamrobot-release: [release-owner] + openamrobot-docs: [docs-owner] + +# Paths whose change needs two human approvals including the platform lead. +# check_pr_evidence.py reports "safety path touched, two human approvals +# required" and checks that reviewers are requested; the approvals themselves +# are enforced by each repository's ruleset (rollout/workflows/SETUP.md). +safety_paths: + - "**/*estop*" + - "**/*e_stop*" + - "**/*emergency*" + - "**/*brake*" + - "**/*contactor*" + - "**/*watchdog*" + - "**/*motor_enable*" + - "**/*charge_inhibit*" + - "**/*safety*" diff --git a/profile/README.md b/profile/README.md index 813dff8..d87db8a 100644 --- a/profile/README.md +++ b/profile/README.md @@ -15,7 +15,7 @@ OpenAMRobot combines: - autonomous mobile robotics - dual-arm manipulation -- adjustable linear lift systems +- a lift for the arms, approved in principle for OpenAMRobot 2.0 (release gates still open) - AI-based perception - wearable embodied AI data collection - ROS 2 software infrastructure @@ -164,9 +164,9 @@ openAMRobot/ ├── openamr-platform-sw # AMR ROS 2: sim, nav2, docking, control, drivers, perception ├── openamr-platform-fw # AMR firmware: motor/sensor bridges, safety I/O ├── openamr-platform-hw # AMR mechanical, electrical, CAD, BOM -├── openamr-upperbody-sw # arm+fixed mast (lift in 3.0), MoveIt, bringup -├── openamr-upperbody-fw # end-effector, safety I/O; lift in 3.0 -├── openamr-upperbody-hw # fixed mast, lift in 3.0, plates, wiring, BOM +├── openamr-upperbody-sw # arm + lift (approved in principle), MoveIt, bringup +├── openamr-upperbody-fw # end-effector, safety I/O +├── openamr-upperbody-hw # lift (approved in principle), plates, wiring, BOM ``` @@ -177,9 +177,9 @@ openAMRobot/ | [`openamr-platform-sw`](https://github.com/openAMRobot/openamr-platform-sw) | ROS 2 software: simulation, navigation, docking, drivers, perception, bringup | | [`openamr-platform-fw`](https://github.com/openAMRobot/openamr-platform-fw) | Embedded firmware, microcontroller systems, motor interfaces, hardware communication | | [`openamr-platform-hw`](https://github.com/openAMRobot/openamr-platform-hw) | CAD, chassis, electrical, BOMs, manufacturing files, mechatronics | -| [`openamr-upperbody-sw`](https://github.com/openAMRobot/openamr-upperbody-sw) | Arm + fixed mast (lift in 3.0), MoveIt on the combined model, bringup | -| [`openamr-upperbody-fw`](https://github.com/openAMRobot/openamr-upperbody-fw) | End-effector, upper-body safety I/O; lift in 3.0 | -| [`openamr-upperbody-hw`](https://github.com/openAMRobot/openamr-upperbody-hw) | Fixed mast (lift in 3.0), mounting plates, wiring, BOM | +| [`openamr-upperbody-sw`](https://github.com/openAMRobot/openamr-upperbody-sw) | Arm + lift (approved in principle), MoveIt on the combined model, bringup | +| [`openamr-upperbody-fw`](https://github.com/openAMRobot/openamr-upperbody-fw) | End-effector, upper-body safety I/O | +| [`openamr-upperbody-hw`](https://github.com/openAMRobot/openamr-upperbody-hw) | Lift (approved in principle), mounting plates, wiring, BOM | | [`openamrobot-manipulation`](https://github.com/openAMRobot/openamrobot-manipulation) | Arm framework: manipulation server, Device Package format, arms (OpenArm 2.0 primary, LeRobot SO-101 fixture) | | [`openamrobot-interfaces`](https://github.com/openAMRobot/openamrobot-interfaces) | Shared ROS 2 messages, services, actions, schemas, interface contracts | | [`openamrobot-comm`](https://github.com/openAMRobot/openamrobot-comm) | APIs, middleware, telemetry, transport protocols, interoperability | @@ -448,7 +448,7 @@ Support open-source robotics, ROS 2 development, AI robotics education, and dual - Hub-motor drive; suspension in 3.0 - mechanical + control integration -- Robotic arm integration; linear lift in 3.0 +- Robotic arm integration; lift approved in principle for 2.0 - mounts - drivers - wiring @@ -491,4 +491,4 @@ Contributor attribution and legally non-waivable authorship or moral rights rema See the canonical [IP Policy](https://github.com/openAMRobot/.github/blob/main/IP_POLICY.md), [Contribution Guide](https://github.com/openAMRobot/.github/blob/main/CONTRIBUTING.md), and [Contributor Agreement Process](https://github.com/openAMRobot/.github/blob/main/CLA.md). -**Botshare LTD** · HE479056 · Chrysanthou Mylona 1, Panayides Building, Office 1, 3030 Limassol, Cyprus · alex@botshare.ai · https://botshare.ai +**Botshare LTD** · HE479056 · Chrysanthou Mylona 1, Panayides Building, Floor 2, Office 1, 3030 Limassol, Cyprus · info@botshare.ai · https://botshare.ai diff --git a/public-extract-allowlist.yaml b/public-extract-allowlist.yaml new file mode 100644 index 0000000..d3dd222 --- /dev/null +++ b/public-extract-allowlist.yaml @@ -0,0 +1,78 @@ +# Allowlist for tools/check_public_extract.py. Kept narrow on purpose: it admits +# published contact and licensing information, not e-mail addresses in general. +# +# Policy: the canonical documentation policy is openamrobot-docs/docs/DOCUMENTATION_STANDARD.md +# (https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_STANDARD.md). +# This allowlist is an implementation detail of the public-extract check, not policy. It +# separates intentional public content (organization contact, licensing information, +# documentation) from accidental leakage (internal document links, private contact data, +# prices, credentials). If this file and the standard disagree, the standard wins and this +# file is corrected; a new kind of allowed content starts as a change to the standard. +# +# Entry fields: rule, match (regular expression for the whole matched text), +# optional paths and repositories (globs), optional line (regular expression +# the whole source line must contain), and reason. GitHub handles such as +# @BotshareAI are not e-mail addresses and need no entry. +# +# Adding an entry is a review decision: the docs owner decides entries about +# published pages, the platform lead decides entries about company or +# commercial information. Each entry below states why it is legitimate. +allow: + - rule: email + match: 'info@botshare\.ai' + reason: >- + Published organization contact for licensing and maintainers, named in + DOCUMENTATION_STANDARD.md and CONTRIBUTING.md. + - rule: email + match: '[^@\s]+@(?:example\.(?:com|org|net)|users\.noreply\.github\.com)' + reason: Documentation placeholders and GitHub no-reply identities, which identify no person. + - rule: email + match: '[^@\s]+@[^@\s]+' + line: '@author\b|\b(?:copyright|licen[cs]e[ds]?|spdx-license-identifier|authors?:|maintainers?:)|\(c\)' + reason: >- + Licence and copyright notices of third-party code must keep their author + contact to preserve provenance (THIRD_PARTY_POLICY.md). Only lines that + are such notices qualify. + - rule: email + match: '[^@\s]+@(?:[a-z0-9-]+\.)*botshare\.ai' + reason: >- + Organisation contacts: any address on the botshare.ai domain (or its subdomains) is an + organisation address. Approved by the platform lead on 7 October 2026. Lookalike domains + such as botshare-ai.com or botshare.ai.example.org do not match. + - rule: email + match: '[^@\s]+@[^@\s]+' + line: '\b(?:signed-off-by|co-authored-by):' + reason: >- + Contributors and maintainers: an address on a Signed-off-by or Co-authored-by line. + Publication is agreed through the DCO, the CLA and the contributor privacy notice. + - rule: email + match: '[^@\s]+@[^@\s]+' + paths: ["CONTRIBUTORS.md", "MAINTAINERS.md", "maintainers.yaml"] + reason: >- + Contributors and maintainers: an address listed in CONTRIBUTORS.md, MAINTAINERS.md or + maintainers.yaml. Publication is agreed through the DCO, the CLA and the contributor + privacy notice. + - rule: email + match: '(?:sales|info|support|service|contact|export|trade|office|marketing)\d*@[^@\s]+' + paths: ["datasheets/**"] + reason: >- + Supplier role addresses in re-hosted supplier datasheet notes (datasheets/ only): the local + part names a role, not a person. An address that names a person stays flagged. Prices are + never allowlisted here. + - rule: price + match: '(?:from\s+)?€\d[\d,.]*(?:/mo)?' + paths: ["README.md"] + repositories: [".github"] + reason: >- + Public pricing: the sponsorship tiers on the organization governance README. + Approved as published pricing by the platform lead on 29 September 2026. + - rule: price + match: '€\d[\d,.]*(?:/mo)?' + paths: ["profile/README.md"] + repositories: [".github"] + reason: >- + Public pricing: the robot offerings and sponsorship tiers on the organization + profile. Approved as published pricing by the platform lead on 29 September 2026. + - rule: credential + match: '.*(?:your|example|placeholder|changeme|xxxx|<[^>]*>).*' + reason: Placeholder values in setup instructions, not secrets. diff --git a/rollout/README.md b/rollout/README.md new file mode 100644 index 0000000..98e0949 --- /dev/null +++ b/rollout/README.md @@ -0,0 +1,197 @@ +# Rolling out the harness to other repositories + +Files in this folder are proposals for other repositories. Each takes effect only when that +repository's owner merges it, and a check blocks a merge only once the repository's ruleset +requires it. Until a repository completes the steps below, none of its failure classes are +machine-blocked. The organization-owner steps and the state of every check are in +[workflows/SETUP.md](workflows/SETUP.md). + +## What each repository adopts + +The organization Watchdog is centralized in this repository: it runs against the repository list, +creates the dashboard and grouped issues here, and does not require copying its checker code +into product repositories. Product repositories still adopt the reusable workflow separately +when they want findings to block their own pull requests. + +| Item | What the repository does | Checked by, once installed and required | +|---|---|---| +| AGENTS.md, CLAUDE.md | Copy the shared block v2 verbatim from `agent-rules/SHARED_RULES.md`, add a short repository-specific section; CLAUDE.md contains only `@AGENTS.md` | drift step in the reusable workflow | +| STATE.md | Copy `STATE.md.example`, fill it, keep it current | `check_pr_evidence.py` STATE.md rule | +| PR template | Delete the local `.github/PULL_REQUEST_TEMPLATE.md` so the organization template applies, or replace it with a copy that keeps every heading | `check_pr_evidence.py` sections | +| verify.sh | Keep an existing `tools/verify.sh`; otherwise enable `verify: true` in the caller (the harness `rollout/verify.sh` runs), with `.openamrobot/verify.env` if the layout needs it | `quality/test` job | +| Reusable workflow caller | Pin `uses:` and `harness_ref` to one harness SHA; then `harness_warn: true`; then `harness_checks: true` (steps b to d below) | reviewer of the caller PR | +| PR assistant | Copy `workflows/pr-assistant.yml` | `quality/pr-evidence` required check | +| Docs sync sender | Copy `workflows/docs-sync-caller.yml` (not in openamrobot-docs) | none; failure shows in Actions | +| decisions register | Nothing to copy; CI reads the pinned register from the harness. Fix flagged lines or mark kept history with `decision-allow: ` | `check_decisions.py` (text only; the entry's reviewer checks the substance) | + +All eight product repositories checked on 29 September 2026 carry a local PR template +(openamr-platform-fw, openamr-platform-sw, openamrobot-docs, openamrobot-interfaces, +openamrobot-manifest, openamrobot-manipulation, openamrobot-release, openamrobot-ui). A local +template overrides the organization one, so "inherited" requires deleting it. + +## Order + +**Before any repository starts:** this PR merges, and the organization owner completes +SETUP.md sections 2 to 5 (secrets, Apps, Actions settings, labels). SETUP.md section 1 +(pinning) is not done organization-wide; each repository pins in its own step (b). + +**Per repository, strictly in this order.** Each step is one PR in that repository, opened as a +draft by the repository owner (or with the push-from-bundle prompt) and merged by the owner. +A step starts only after the previous one is merged. + +| Step | Change in the repository | Done when | +|---|---|---| +| (a) Shared rules v2 | AGENTS.md carries the shared block v2 verbatim; CLAUDE.md is `@AGENTS.md`; STATE.md added; local PR template removed or aligned | the drift checker passes on the repository's AGENTS.md | +| (b) Pin the harness | caller `uses: openAMRobot/.github/...@` and `harness_ref: `; `harness_checks` stays unset (false) | the caller runs the baseline steps at the pinned SHA | +| (c) Warn-only | add `harness_warn: true`; the decisions, public-extract and drift steps run and report findings as warnings, never failing | one push run on main is green with every remaining warning either fixed, tracked in an issue, or marked `decision-allow` | +| (d) Enforce | replace `harness_warn: true` with `harness_checks: true` | one enforced run on main is green and one PR passes with the checks enforced | +| (e) Require | the ruleset requires `repository-quality / repository-quality` (and `quality/pr-evidence`, `quality/test` once installed), per SETUP.md section 6 | a PR merges through the ruleset | + +Only from step (e) does a failing check block a merge in that repository. Safety-path +approvals come from the ruleset and CODEOWNERS, not from a check. + +### Pilot: openamrobot-interfaces first + +openamrobot-interfaces completes steps (a) to (e) before any other repository sets +`harness_warn` or `harness_checks`. It also installs the PR assistant and `verify: true` +(the job delegates to its existing `tools/verify.sh`). The pilot is complete when all of the +following are recorded in its STATE.md and in one comment on the harness rollout issue, and the +CI owner and the release owner have both written "pilot accepted" there: + +1. The five step PRs, linked in order. +2. The run URLs of a green warn-only run on main and a green enforced run on main. +3. An enforced PR run that blocked a deliberate contradiction on a throwaway branch (never + merged) and a clean PR that passed. +4. A `quality/test` artifact produced through delegation, whose `summary.json` records the + delegated exit status, duration and test counts. +5. One PR-assistant summary comment updated in place across two pushes, with no CHECKER ERROR + on a normal PR. +6. A PR merged through the ruleset that requires the checks. +7. No check or register pattern weakened to get green; any false positive fixed in the harness + with a test. +8. A rollback shown: reverting the caller to the previous pin restores the previous behaviour. + +If a criterion fails, the rollout stops, the finding becomes an issue labelled harness, and the +pilot repeats the failed step after the harness fix. + +### After the pilot + +The remaining repositories follow steps (a) to (e), one repository at a time: + +1. openamrobot-manifest, openamrobot-manipulation, openamrobot-ui (they already carry the v1 + block, so step (a) is an update). openamrobot-ui adds `.openamrobot/verify.env` as in + VERIFY.md; its zero-tests rule fails until real tests replace `--passWithNoTests`, so the + verify.env PR merges together with the first real tests. +2. openamr-platform-sw: first colcon build and test gate; container `ros:jazzy-ros-base`. +3. openamrobot-docs: keeps `scripts/check_docs.sh` and the strict MkDocs build via + `VERIFY_TEST`; adds the docs-sync receiver. +4. openamrobot-release (release owner). See the release interaction below. +5. openamr-platform-fw, openamr-platform-hw, openamr-upperbody-*, openamrobot-comm. + `verify: true` only once a build or test exists; a repository with nothing to test declares + that in STATE.md instead of passing an empty suite. +6. Weekly audit in audits, then monthly retro in .github. Both are designs until a first + supervised run exercises permissions, credentials, deduplication, failure handling and the + issue lifecycle; the audit never closes an issue, it comments "no longer detected". + +## Release-manifest interaction + +- The release builder packages what the manifest names. A release PR in + openamrobot-release runs the same decisions check on release notes and metadata (today it + flags the legacy compute named in `release-metadata/RELEASE_NOTES.md`, decision COMPUTE). +- **Evidence record per component.** For every component in a release, the release manifest + records: + + | Field | Source | + |---|---| + | component commit SHA | the manifest pin; must equal `head_sha` in the component's `summary.json` | + | package or contract version | `package.xml` / `package.json` version, or the interface contract version, where one exists; otherwise "none" | + | harness SHA | `harness_sha` in `summary.json`; must equal the component's `harness_ref` pin | + | workflow run URL | the `quality/test` run that produced the artifact | + | artifact identifier or digest | the Actions artifact ID and its SHA-256 digest (the upload step prints both) | + + The `summary.json` schema is in VERIFY.md. It is written for delegated runs too, so + openamrobot-interfaces (which keeps its own `tools/verify.sh`) produces the same record. +- **Decision-register updates and pinned consumers.** A component is checked against the + register at its harness pin. A register update affects a pinned consumer only when that + consumer moves its harness pin, or when the release owner explicitly revalidates it against + the new register (a new `quality/test` run recorded as new evidence). Until then its recorded + evidence stands for the pin it names. +- **Historical evidence is preserved as recorded.** Release evidence is never regenerated or + edited after a release; a later register change or harness update produces new evidence for + a later release, and the earlier record keeps its original SHAs, run URL and digest. +- A register update that makes a revalidated component fail is a release blocker for that + component in the next release, not a CI fault and not a change to past releases. + +## CODEOWNERS proposal + +Not applied by this PR; CODEOWNERS changes need an explicit task and the platform lead's +review. Proposal, in every repository's `.github/CODEOWNERS`: + +``` +# Default owner stays. +* @BotshareAI + +# Software repositories (openamr-platform-sw, openamr-upperbody-sw, openamrobot-interfaces, +# openamrobot-manipulation, openamrobot-ui, openamrobot-comm): add the software lead. +* @BotshareAI @panthera-momagdii + +# Every repository: CI owner for workflows. maintainers.yaml ci-owner: wikki26. +/.github/workflows/ @BotshareAI @wikki26 + +# openamrobot-docs only: documentation owner. maintainers.yaml docs-owner: anandgawai123456-glitch. +* @BotshareAI @anandgawai123456-glitch + +# openAMRobot/.github only: policy and harness files. +/decisions.yaml @BotshareAI +/maintainers.yaml @BotshareAI +/agent-rules/ @BotshareAI @panthera-momagdii +/tools/ @BotshareAI @wikki26 + +# Release owner (maintainers.yaml release-owner: KARTHIKEYAN124). Replace the handle with +# @KARTHIKEYAN124 @wikki26 once that team exists. Added only after write access is confirmed. +# openamrobot-manifest and openamrobot-release: whole repository. +* @BotshareAI @KARTHIKEYAN124 +# openamrobot-release: release workflow (after the lines above, so it wins for this path). +/.github/workflows/build-release.yml @BotshareAI @KARTHIKEYAN124 +# openamrobot-manifest: manifest validation workflow. +/.github/workflows/manifest-validation.yml @BotshareAI @KARTHIKEYAN124 +# openamrobot-docs: installation documentation, placed after the docs-owner line. +/docs/build/software/ @BotshareAI @anandgawai123456-glitch @KARTHIKEYAN124 +/docs/reference/openamrobot-manifest/ @BotshareAI @anandgawai123456-glitch @KARTHIKEYAN124 +/docs/reference/openamrobot-release/ @BotshareAI @anandgawai123456-glitch @KARTHIKEYAN124 +``` + +The release workflow lines matter because the "every repository" CI owner line for +`/.github/workflows/` would otherwise route those files away from the release owner; they +must appear after it. The installation paths are the pages in openamrobot-docs today that +tell a user how to build, flash and verify an installation (`docs/build/software/`) and the +setup pages of the two release repositories; the docs owner confirms the list when the file +is written. + +Platform-lead-authored PRs + +The platform lead may author a PR, but cannot approve it through the reconciliation comment. +The merge gate is an approval from the software lead plus an approval from every owner whose +scope the PR touches. The author is excluded from that set. Safety-path changes retain the +separate two-human-approval ruleset requirement, including platform-lead CODEOWNERS review. +Record any unavailable-owner exception with the organization owner before making the PR ready. + +How GitHub applies these lines (documented GitHub behaviour, not something this harness +checks): + +- A code owner must have write access to the repository for the ownership to take effect. + A line whose account or team lacks write access, or is not a collaborator, is ignored for + review requests and required approvals, so the release owner's access is confirmed first. + The same holds for the ci-owner and docs-owner handles. +- The last matching pattern in the file takes precedence. A later line replaces the owners of + an earlier line for the paths it matches; owners are not merged across lines. Specific paths + therefore go after the broad `*` and `/.github/workflows/` lines, and each specific line + repeats every owner it still needs. +- When a line lists several owners, an approval from any one of them satisfies the code owner + requirement; approval from all of them is not required. A ruleset can add further + requirements (a minimum approval count, a required team review), and only the ruleset does. +- This repository's own `.github/CODEOWNERS` is a single line, `* @wikki26 @BotshareAI + @panthera-momagdii`: an approval from any one of the three satisfies the code-owner + requirement for every path. GitHub never counts the author's own approval, so a PR authored + by one of them still needs the approval of one of the other two. Each listed account needs + write access to this repository for the entry to count. diff --git a/rollout/STATE.md.example b/rollout/STATE.md.example new file mode 100644 index 0000000..ee6af8e --- /dev/null +++ b/rollout/STATE.md.example @@ -0,0 +1,30 @@ +# STATE + +Read this file before any work in this repository and update it in the same PR as the work. +The PR evidence check fails when STATE.md exists and a PR neither updates it nor says +"no change" with a reason. Keep it under 60 lines; facts only, each with a date or SHA. + +## Repository + +- Type and lifecycle: , +- Readiness: (evidence: ) +- Owner role: +- Verification: ``; last PASS on main at () + +## Current work + +| Work package | Branch or PR | Status | Next step | +|---|---|---|---| +| # | # | draft / in review / blocked | | + +## Decisions this repository implements + +List decisions.yaml IDs whose values appear in this repository, for example MAST-INSTALL-HEIGHT. + +## Known gaps + +- , tracked in # + +## Not verified + +- , since diff --git a/rollout/VERIFY.md b/rollout/VERIFY.md new file mode 100644 index 0000000..f2737fa --- /dev/null +++ b/rollout/VERIFY.md @@ -0,0 +1,104 @@ +# verify.sh: reference verification script + +`rollout/verify.sh` is the verification entry point for repositories that do not have their +own. A repository that already has `tools/verify.sh` keeps it: the script detects it and +delegates. openamrobot-interfaces is the reference implementation and is not duplicated here. +Delegation still produces the same `summary.json` (see "Evidence summary" below), written +around the delegated run. + +## What it runs + +| Stage | Default action | Fails when | +|---|---|---| +| prerequisites | detect ROS 2 (`package.xml`), Node (`package.json`), Python (`tests/`, `pyproject.toml`, `setup.py`) | nothing is detected and no `VERIFY_TEST` is set | +| install | `npm ci`, or `rosdep check` for ROS 2 on the source packages only (never `build/`, `install/`, `log/` or a `COLCON_IGNORE` folder) | a dependency does not resolve | +| build | `colcon build` in a copied workspace, or `npm run build` | the build fails | +| lint | `py_compile` for tracked Python, `bash -n` (and `shellcheck` when installed) for shell, `npm run lint` | any file fails | +| test-markers | scan tracked test sources | a skip, xfail or importorskip does not name an issue (`#123` or `issues/123`) on the same line | +| test | `colcon test` and `colcon test-result`, `npm test`, `pytest` or `unittest` | a test fails, or zero tests executed (total minus skipped is zero) | +| evidence | keep logs | never | + +When delegating, verify.sh runs `tools/verify.sh`, captures its combined output in +`verification.log`, records its exit status and duration, parses test counts from the output +with the same parsers, records the `Evidence:` path the delegated script prints, writes +`summary.json` and exits with the delegated exit status. It does not apply the zero-tests or +skip rules to a delegated run; those remain the delegated script's responsibility, and +`counts_parsed: false` shows when no count could be read. + +Every stage runs under `env -i` with a fresh `HOME`, as in the interfaces script, so no overlay, +Python path or user package leaks in. The one exception is rosdep's prepared state: the caller's +`${ROS_HOME:-$HOME/.ros}/rosdep` (user sources list and cache) is copied into the fresh `HOME`, and +`ROSDEP_SOURCE_PATH` is passed through when set, so `rosdep check` does not report an +uninitialised rosdep. Output goes to `.verification/run.*/`: +`verification.log`, `test.log`, `result.txt` (PASS, or FAIL with the stage) and `summary.json` +(result, failed stage, stages passed, test totals, head and base SHA). + +## Evidence summary (minimum schema) + +Every run, delegated or not, writes `.verification/run.*/summary.json` with at least these +fields. A repository-native verifier that writes its own evidence adds these fields or is +wrapped by verify.sh; a release consumes only this schema. If `summary.json` cannot be +written, the run fails: an otherwise passing run exits 1 with `FAIL: evidence-summary`, and a +failing run (delegated or not) keeps its own exit status. + +| Field | Meaning | +|---|---| +| `schema_version` | `1` | +| `mode` | `harness` (verify.sh ran the stages) or `delegated` (the repository's `tools/verify.sh` ran) | +| `result`, `exit_code` | `PASS` only when `exit_code` is 0; the exit status the job reported | +| `failed_stage` | stage name, or `tools/verify.sh` for a delegated failure; null on success | +| `stages_passed` | stages verify.sh completed (empty when delegated) | +| `tests_total`, `tests_skipped`, `counts_parsed` | counts parsed from the test output; null with `counts_parsed: false` when none could be read | +| `duration_seconds` | wall time of the whole run | +| `head_sha`, `base_sha` | component commit verified and its merge base with main | +| `harness_sha` | commit of the harness that provided verify.sh | +| `delegated_script`, `delegated_evidence` | for delegated runs, the script and the evidence path it printed | + +The workflow run URL and the artifact digest are not known inside the run; the release +record adds them (rollout/README.md, release section). + +## Overrides + +`.openamrobot/verify.env` in the repository may set shell commands `VERIFY_INSTALL`, +`VERIFY_BUILD`, `VERIFY_LINT`, `VERIFY_TEST`, and `VERIFY_ROS_DISTRO` (default `jazzy`); +`VERIFY_ROS_SETUP` replaces the ROS setup file (default `/opt/ros/$VERIFY_ROS_DISTRO/setup.bash`). +Overrides replace a stage's command; they do not switch off the zero-tests or skip rules. +Example for openamrobot-ui, whose web app lives in `web/`: + +``` +VERIFY_INSTALL="cd web && npm ci" +VERIFY_BUILD="cd web && npm run build" +VERIFY_LINT="cd web && npx eslint src" +VERIFY_TEST="cd web && CI=true npm test -- --watchAll=false" +``` + +With that override the UI's current `--passWithNoTests` suite fails the zero-tests rule +until real tests exist. + +## How the reusable workflow calls it + +`repository-quality-reusable.yml` has an opt-in job `quality/test`: + +```yaml +jobs: + repository-quality: + uses: openAMRobot/.github/.github/workflows/repository-quality-reusable.yml@ + with: + harness_ref: + verify: true + verify_container: ros:jazzy-ros-base # ROS 2 repositories only +``` + +The job checks out the repository and the harness at `harness_ref`, runs +`.openamrobot-harness/rollout/verify.sh "$GITHUB_WORKSPACE"` (which delegates to +`tools/verify.sh` when present) and uploads `.verification/run.*/` as the artifact +`verification-evidence`. The PR evidence section links that artifact. + +## Run locally + +``` +bash /path/to/openAMRobot/.github/rollout/verify.sh /path/to/repository +``` + +The tests in `tests/test_verify_sh.py` exercise the passing path, zero tests, a fully skipped +suite, a skip without an issue, a skip with an issue, nothing detected, and delegation. diff --git a/rollout/repositories.yaml b/rollout/repositories.yaml new file mode 100644 index 0000000..50b6cdd --- /dev/null +++ b/rollout/repositories.yaml @@ -0,0 +1,19 @@ +# Active public openAMRobot repositories, the single list the Watchdog organization scan +# (.github/workflows/watchdog-org-scan.yml) clones and checks on their default branch. +# Add or remove a repository here only; tests/test_org_scan.py validates this file. +organization: openAMRobot +repositories: + - {name: .github, default_branch: main} + - {name: openamr-platform-sw, default_branch: main} + - {name: openamr-platform-fw, default_branch: main} + - {name: openamr-platform-hw, default_branch: main} + - {name: openamr-upperbody-sw, default_branch: main} + - {name: openamr-upperbody-fw, default_branch: main} + - {name: openamr-upperbody-hw, default_branch: main} + - {name: openamrobot-interfaces, default_branch: main} + - {name: openamrobot-manipulation, default_branch: main} + - {name: openamrobot-ui, default_branch: main} + - {name: openamrobot-comm, default_branch: main} + - {name: openamrobot-docs, default_branch: main} + - {name: openamrobot-manifest, default_branch: main} + - {name: openamrobot-release, default_branch: main} diff --git a/rollout/verify.sh b/rollout/verify.sh new file mode 100755 index 0000000..d0f7914 --- /dev/null +++ b/rollout/verify.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# OpenAMRobot reference verification: install, build, lint, test, evidence. +# +# Usage: verify.sh [REPOSITORY_ROOT] (default: the git top level of the current directory) +# +# Follows openamrobot-interfaces/tools/verify.sh: every stage runs in a clean +# environment, all output goes to .verification/run.*/verification.log, and +# result.txt says PASS or FAIL with the failing stage. This script adds: +# - project detection: ROS 2 (colcon), Node (npm), Python (unittest or pytest); +# - the zero-tests rule: a test stage that executes zero tests fails; +# - the skip rule: skip, xfail and importorskip must name a tracking issue +# (#123 or an issues/123 URL) on the same line; +# - summary.json (schema in rollout/VERIFY.md) with result, exit code, duration, +# test counts, head/base SHA and the harness SHA. +# A repository that already has tools/verify.sh keeps it; this script delegates +# to it (openamrobot-interfaces is the reference implementation) and still writes +# summary.json around the delegated run: exit status, duration and test counts +# parsed from the delegated output. +# Per-repository overrides live in .openamrobot/verify.env (VERIFY_INSTALL, +# VERIFY_BUILD, VERIFY_LINT, VERIFY_TEST, VERIFY_ROS_DISTRO); each is a shell command. +# VERIFY_ROS_SETUP overrides the ROS setup file (default /opt/ros/$VERIFY_ROS_DISTRO/setup.bash). +set -eo pipefail + +root=$(cd -- "${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && pwd) +self=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}") +started=$(date +%s) + +# Print "total skipped parsed" for a test log (parsed is 1 when any known runner summary matched). +count_tests() { + python3 - "$1" <<'PY' +import re, sys +text = open(sys.argv[1], encoding="utf-8", errors="replace").read() +total = skipped = 0 +parsed = False +for pattern, t, s in [ + (r"Summary: (\d+) tests?, \d+ errors?, \d+ failures?, (\d+) skipped", 1, 2), # colcon test-result + (r"^Ran (\d+) tests? in", 1, None), # unittest + (r"^Tests:\s+(?:.*?(\d+) skipped, )?.*?(\d+) total", 2, 1), # jest + (r"^\s+Tests\s+(?:.*?(\d+) skipped.*?)?\((\d+)\)", 2, 1), # vitest +]: + for m in re.finditer(pattern, text, re.M): + parsed = True + total += int(m.group(t) or 0) + skipped += int(m.group(s) or 0) if s else 0 +m = re.findall(r"=+ (?:(\d+) passed)?(?:, )?(?:(\d+) skipped)?.* in [\d.]+s", text) # pytest +for passed, skip in m: + parsed = True + total += int(passed or 0) + int(skip or 0) + skipped += int(skip or 0) +skipped += sum(int(n) for n in re.findall(r"skipped=(\d+)", text)) # unittest +print(total, skipped, 1 if parsed else 0) +PY +} + +# Write summary.json (minimum evidence schema, rollout/VERIFY.md). Arguments: +# run mode exit_code failed_stage tests_total tests_skipped counts_parsed delegated_script delegated_evidence stages... +# Returns non-zero when summary.json cannot be written; callers fail the run on that. +write_summary() { + python3 - "$root" "$self" "$started" "$@" <<'PY' +import json, subprocess, sys, time +root, self_path, started, run, mode, code, failed, total, skipped, parsed, dscript, devidence, *done = sys.argv[1:] +def git(where, *a): + try: + return subprocess.run(["git", "-C", where, *a], capture_output=True, text=True, check=True).stdout.strip() + except Exception: + return None +import os +json.dump({ + "schema_version": 1, + "mode": mode, + "result": "PASS" if code == "0" else "FAIL", + "exit_code": int(code), + "failed_stage": None if code == "0" else (failed or None), + "stages_passed": done, + "tests_total": int(total) if parsed == "1" else None, + "tests_skipped": int(skipped) if parsed == "1" else None, + "counts_parsed": parsed == "1", + "duration_seconds": int(time.time()) - int(started), + "head_sha": git(root, "rev-parse", "HEAD"), + "base_sha": git(root, "merge-base", "HEAD", "origin/main"), + "harness_sha": git(os.path.dirname(self_path), "rev-parse", "HEAD"), + "delegated_script": dscript or None, + "delegated_evidence": devidence or None, +}, open(f"{run}/summary.json", "w"), indent=2) +PY +} + +mkdir -p "$root/.verification" +run=$(mktemp -d "$root/.verification/run.XXXXXX") + +if [ -f "$root/tools/verify.sh" ] && [ "$root/tools/verify.sh" != "$self" ] && [ -z "${VERIFY_NO_DELEGATE:-}" ]; then + # Delegate, but keep the evidence: capture output, exit status, duration and counts. + echo "Delegating to the repository's own tools/verify.sh" + set +e + bash "$root/tools/verify.sh" 2>&1 | tee "$run/verification.log" + status=${PIPESTATUS[0]} + set -e + read -r d_total d_skipped d_parsed < <(count_tests "$run/verification.log") + d_evidence=$(sed -n 's/^Evidence: //p' "$run/verification.log" | tail -1) + if ! write_summary "$run" delegated "$status" "tools/verify.sh" "$d_total" "$d_skipped" "$d_parsed" \ + "tools/verify.sh" "$d_evidence"; then + echo "FAIL: could not write $run/summary.json" + # Keep a delegated failure status; turn a delegated success into a failure. + if [ "$status" -eq 0 ]; then status=1; fi + echo "FAIL: delegated tools/verify.sh (exit $status; summary.json not written)" | tee "$run/result.txt" + echo "Evidence: $run" + exit "$status" + fi + if [ "$status" -eq 0 ]; then + echo "PASS: delegated tools/verify.sh" | tee "$run/result.txt" + else + echo "FAIL: delegated tools/verify.sh (exit $status)" | tee "$run/result.txt" + fi + echo "Evidence: $run" + exit "$status" +fi + +exec > >(tee "$run/verification.log") 2>&1 +stage=prerequisites +stages=() +tests_total=0 +tests_skipped=0 +counts_parsed=0 + +finish() { + result=$? + if ! write_summary "$run" harness "$result" "$stage" "$tests_total" "$tests_skipped" "$counts_parsed" "" "" \ + "${stages[@]}"; then + echo "FAIL: could not write $run/summary.json" + # A run without its evidence summary never passes; an earlier failure keeps its status. + if [ "$result" -eq 0 ]; then result=1; stage=evidence-summary; fi + fi + if [ "$result" -eq 0 ]; then + echo "PASS: all detected verification stages" | tee "$run/result.txt" + else + echo "FAIL: $stage (exit $result)" | tee "$run/result.txt" + fi + echo "Evidence: $run" + exit "$result" +} +trap finish EXIT + +pass() { stages+=("$stage"); echo "PASS: $stage"; } + +# Never inherit overlays, Python paths or prefixes from the caller. The only caller +# state passed in is rosdep's: ROSDEP_SOURCE_PATH when set, and a copy of the caller's +# rosdep sources list and cache (below). +clean_bash() { + env -i HOME="$run/home" PATH="${VERIFY_PATH:-/usr/local/bin:/usr/bin:/bin}" LANG=C.UTF-8 \ + PYTHONNOUSERSITE=1 PYTHONDONTWRITEBYTECODE=1 CI="${CI:-}" \ + ${ROSDEP_SOURCE_PATH:+"ROSDEP_SOURCE_PATH=$ROSDEP_SOURCE_PATH"} \ + bash --noprofile --norc -eo pipefail "$@" +} +mkdir -p "$run/home" +# rosdep keeps its user sources list and cache under ${ROS_HOME:-$HOME/.ros}/rosdep. The +# clean HOME would hide the state the environment prepared ("rosdep not initialized"), so +# copy it in; a copy keeps the caller's cache unchanged by the run. +caller_rosdep="${ROS_HOME:-${HOME:-/nonexistent}/.ros}/rosdep" +if [ -d "$caller_rosdep" ]; then + mkdir -p "$run/home/.ros" && cp -a "$caller_rosdep" "$run/home/.ros/rosdep" +fi + +# ROS package directories of the source tree only: never colcon's build/, install/ or +# log/ (any directory with those names or a COLCON_IGNORE marker), .verification/, +# node_modules/ or checker fixtures. +ros_source_paths() { + find "$root" \( -name .git -o -name .verification -o -name node_modules -o -name build \ + -o -name install -o -name log -o -path "$root/tests/fixtures" \) -prune \ + -o -type d -exec test -e '{}/COLCON_IGNORE' \; -prune \ + -o -name package.xml -printf '%h\n' | sort -u +} + +if [ -f "$root/.openamrobot/verify.env" ]; then + # shellcheck disable=SC1091 + source "$root/.openamrobot/verify.env" +fi +distro=${VERIFY_ROS_DISTRO:-jazzy} +ros_setup=${VERIFY_ROS_SETUP:-/opt/ros/$distro/setup.bash} + +ros=false; node=false; python=false +if find "$root" -name package.xml -not -path '*/node_modules/*' -not -path '*/.verification/*' \ + -not -path '*/tests/fixtures/*' | grep -q .; then ros=true; fi +[ -f "$root/package.json" ] && node=true +if [ -f "$root/pyproject.toml" ] || [ -f "$root/setup.py" ] || [ -d "$root/tests" ]; then python=true; fi +echo "Detected: ros=$ros node=$node python=$python" +command -v git python3 >/dev/null +if $ros; then test -f "$ros_setup"; fi +if $node; then command -v npm >/dev/null; fi +if ! $ros && ! $node && ! $python && [ -z "${VERIFY_TEST:-}" ]; then + echo "FAIL: no buildable or testable project detected; set VERIFY_TEST in .openamrobot/verify.env" + exit 1 +fi +pass + +stage=install +if [ -n "${VERIFY_INSTALL:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_INSTALL" +elif $node; then clean_bash -c "cd '$root' && npm ci" +elif $ros; then + mapfile -t ros_paths < <(ros_source_paths) + # shellcheck disable=SC2016 # $@ expands inside the clean shell + clean_bash -c "source '$ros_setup' && rosdep check --from-paths \"\$@\" --ignore-src --rosdistro $distro" \ + rosdep-check "${ros_paths[@]}" +fi +pass + +stage=build +if [ -n "${VERIFY_BUILD:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_BUILD" +elif $ros; then + # The run directory lives under root; archive selected source so the copy cannot + # recurse into .verification/run.* or carry generated workspaces into colcon. + mkdir -p "$run/ws/src/repo" + tar -C "$root" \ + --exclude=.git \ + --exclude=.verification \ + --exclude=node_modules \ + --exclude=build \ + --exclude=install \ + --exclude=log \ + --exclude='*/build' \ + --exclude='*/install' \ + --exclude='*/log' \ + -cf - . | tar -C "$run/ws/src/repo" -xf - + clean_bash -c "source '$ros_setup' && cd '$run/ws' && colcon build --event-handlers console_direct+" +elif $node; then clean_bash -c "cd '$root' && npm run build --if-present" +fi +pass + +stage=lint +if [ -n "${VERIFY_LINT:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_LINT" +else + git -C "$root" ls-files -z '*.py' | (cd "$root" && xargs -0 -r python3 -m py_compile) + git -C "$root" ls-files -z '*.sh' | (cd "$root" && xargs -0 -r -n1 bash -n) + if command -v shellcheck >/dev/null; then + git -C "$root" ls-files -z '*.sh' | (cd "$root" && xargs -0 -r shellcheck -S warning) + fi + if $node; then clean_bash -c "cd '$root' && npm run lint --if-present"; fi +fi +pass + +stage=test-markers +# Every skip, xfail or importorskip names a tracking issue on the same line. +unmarked=$(cd "$root" && git ls-files -z -- '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.cpp' '*.hpp' '*.c' '*.h' \ + | xargs -0 -r grep -nE '(pytest\.mark\.(skip|skipif|xfail)|pytest\.(skip|xfail|importorskip)\(|unittest\.skip|self\.skipTest\(|\b(it|test|describe)\.skip\(|\bxit\(|GTEST_SKIP)' 2>/dev/null \ + | grep -vE '(#[0-9]+|issues/[0-9]+)' || true) +if [ -n "$unmarked" ]; then + echo "$unmarked" + echo "FAIL: skip/xfail/importorskip without a tracking issue (#123 or issues/123) on the same line" + exit 1 +fi +pass + +stage="test" +log="$run/test.log" +# Run the suite without aborting on its exit status: the zero-tests rule is +# checked first (Python 3.12+ unittest exits 5 on "NO TESTS RAN"), then any +# non-zero status fails the stage. +set +e +if [ -n "${VERIFY_TEST:-}" ]; then clean_bash -c "cd '$root' && $VERIFY_TEST" 2>&1 | tee "$log" +elif $ros; then + clean_bash -c "source '$ros_setup' && cd '$run/ws' && colcon test --event-handlers console_direct+ && colcon test-result --verbose" 2>&1 | tee "$log" +elif $node; then clean_bash -c "cd '$root' && npm test" 2>&1 | tee "$log" +elif [ -f "$root/pyproject.toml" ] && python3 -c 'import pytest' 2>/dev/null; then + clean_bash -c "cd '$root' && python3 -m pytest -rs" 2>&1 | tee "$log" +else + clean_bash -c "cd '$root' && python3 -m unittest discover -s tests -v" 2>&1 | tee "$log" +fi +test_status=${PIPESTATUS[0]} +set -e +read -r tests_total tests_skipped counts_parsed < <(count_tests "$log") +echo "Tests executed: $((tests_total - tests_skipped)) of $tests_total (skipped $tests_skipped)" +if [ "$((tests_total - tests_skipped))" -le 0 ]; then + echo "FAIL: zero tests executed; an empty or fully skipped suite is not evidence" + exit 1 +fi +if [ "$test_status" -ne 0 ]; then + echo "FAIL: test command exited with status $test_status" + exit "$test_status" +fi +pass + +stage=evidence +cp "$log" "$run/test-output.log" 2>/dev/null || true +pass diff --git a/rollout/workflows/SETUP.md b/rollout/workflows/SETUP.md new file mode 100644 index 0000000..e3aac19 --- /dev/null +++ b/rollout/workflows/SETUP.md @@ -0,0 +1,167 @@ +# Setup for the organization owner + +Everything here needs organization-owner or repository-admin rights. The session that wrote +this file configured none of it. + +## State of every check and workflow + +Each item is in one of three states: + +- **(a)** implemented and tested in this repository; +- **(b)** supplied under `rollout/` as an example, not installed anywhere; +- **(c)** a human gate. + +No failure class is machine-blocked in product repositories until the relevant workflow is installed +in each repository and its check is required by that repository's ruleset. That installation +is a rollout step (rollout/README.md), not a present fact. + +| Check or workflow | State | What was exercised in the authoring session | +|---|---|---| +| `tools/check_decisions.py` with `decisions.yaml` | (a) | Unit tests on a fixture repository (matching value, contradicting value, unlisted file type, superseded citation, allow marker, exclusion); read-only dry runs on local clones of product repositories | +| `tools/check_public_extract.py` with the allowlist | (a) | Unit tests; dry runs on local clones | +| `tools/check_pr_evidence.py` | (a) | Unit tests; local run on a simulated pull_request event; nothing posted | +| `tools/check_agent_rules.py` (drift) | (a) | Unit tests; run on this repository and on local clones | +| `tools/sync_audit_issues.py` | (a) | Unit tests with a fake API; nothing created or commented | +| `rollout/verify.sh` | (a) | Unit tests; run on this repository and on a local clone of openamrobot-manifest; not on ROS 2 or Node repositories | +| `repository-quality-reusable.yml`, harness steps (`harness_warn: true` warn-only, `harness_checks: true` enforcing) | (a) for this repository's own caller; (b) for every other repository | `run:` steps dry run locally with checkouts simulated; never run on GitHub. Off by default, so existing `@main` callers are unchanged until they opt in | +| `repository-quality-reusable.yml`, `quality/test` job (`verify: true`) | (b) | Never run on GitHub | +| `repository-quality.yml` in this repository (`quality/test`, harness checks) | (a) once merged; never run on GitHub yet | Its commands ran locally | +| `pr-assistant.yml` | (b) | Its two checker commands ran locally; the workflow never ran | +| `.github/workflows/watchdog-org-scan.yml` + `tools/watchdog_issue_sync.py` | (a) in the harness repository | Thursday 14:00 Europe/Berlin organization scan, central dashboard (the only issue written while `WATCHDOG_ISSUE_MODE` is unset), stable grouped issue deduplication in `groups` mode, redaction, no-longer-detected comments, re-opening of reappearing findings and blocked-scan behavior are covered by tests; it never edits `decisions.yaml` or product repositories | +| `docs-sync-caller.yml`, `docs-sync.yml` | (b), design only | Never run | +| `monthly-retro.yml` | (b), design only | Never run | +| Two human approvals on safety paths | (c) enforced by a ruleset, section 6 | `check_pr_evidence.py` only reports "safety path touched, two human approvals required" and whether reviewers are requested; it does not count approvals as a gate | +| Decision-register changes | (c) the entry's owner | The register's own schema validation is (a) | +| Every `[human: ...]` rule in AGENTS.md | (c) | Not machine-checked | + +**Documentation policy.** The canonical documentation policy is +[`openamrobot-docs/docs/DOCUMENTATION_STANDARD.md`](https://github.com/openAMRobot/openamrobot-docs/blob/main/docs/DOCUMENTATION_STANDARD.md), +owned by the documentation owner. `tools/check_public_extract.py` and +`public-extract-allowlist.yaml` implement one part of it: they separate intentional public +content (organization contact, licensing information, documentation) from accidental leakage +(internal document links, private contact data, prices, credentials). The allowlist is an +implementation detail, not policy; when it and the standard disagree, the standard wins. + +## 1. Pin the harness + +Pinning is done per repository, in the order of rollout/README.md, not organization-wide: + +1. After the harness PR merges, take its merge commit SHA as ``. +2. Step (b): in the repository's caller, replace `@main` with `@` and add + `with: harness_ref: `. Do not set `harness_checks` here. +3. Step (c): add `harness_warn: true` (warn-only) until a run on main is green. +4. Step (d): replace it with `harness_checks: true`. Step (e) then adds the ruleset (section 6). +5. Replace `` in each workflow copied from `rollout/workflows/`. + +openamrobot-interfaces is the pilot and completes all five steps before any other repository +starts step (c). + +## 2. GitHub-native Watchdog publication + +The deterministic Watchdog uses the repository-scoped `GITHUB_TOKEN` only for `issues: write` in +`openAMRobot/.github`. It cannot push code or create pull requests. The job reads product +repositories anonymously and publishes a redacted central issue dashboard. No Anthropic secret, +Claude App or AI workflow is needed for this path. + +The dashboard is safe to activate after PR #38 merges. A decision-register change remains a +human-reviewed PR; the Action only creates a review issue and never rewrites the register. + +## 3. Secrets + +| Secret | Scope | Used by | +|---|---|---| +| `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`, then change the input name) | audits, openamrobot-docs, .github | weekly audit, docs sync, monthly retro | +| `AUDIT_APP_ID`, `AUDIT_APP_PRIVATE_KEY` | audits | weekly audit issue sync | +| `DOCS_SYNC_APP_ID`, `DOCS_SYNC_APP_PRIVATE_KEY` | organization secret, product repositories | docs sync sender | +| `RETRO_APP_ID`, `RETRO_APP_PRIVATE_KEY` | .github | monthly retro | + +The three App secret pairs may point to one GitHub App. The PR assistant uses only +`GITHUB_TOKEN`. AI workflow activation is tracked separately in [issue #43](https://github.com/openAMRobot/.github/issues/43) and remains disabled until every checklist item is evidenced. + +Authentication mode must be chosen before activation. The examples use `ANTHROPIC_API_KEY` for Anthropic API authentication and retain `id-token: write` because the official Claude GitHub App path uses GitHub OIDC for the action's default GitHub token. If the organization chooses Anthropic Workload Identity Federation instead, remove the API-key secret, add the federation identifiers required by Anthropic, and keep `id-token: write`; do not configure both Anthropic credential modes by accident. Record the selected mode and the manual disposable-branch test in issue #43. + +## 4. GitHub Apps + +1. **Claude GitHub App** (github.com/apps/claude), on audits, openamrobot-docs and .github + only. It asks for Contents, Issues and Pull requests read and write. The current Claude Code + documentation lists further permissions because the App is shared with other Claude + features. Grant what the install screen asks, on those three repositories only. +2. **Harness App** (organization-owned, private), installed on the product repositories and + audits, with these permissions: + - Issues: read and write. + - Pull requests: read. + - Contents: read and write. This is needed only for `repository_dispatch` to openamrobot-docs. + - Metadata: read. + + It gets no administration, workflow or secrets permission. + +## 5. Actions settings + +- Default workflow token: read repository contents only. +- "Allow GitHub Actions to create and approve pull requests": off. +- If actions are allow-listed, allow exactly these, pinned by commit SHA in the files: + - `actions/checkout` v7.0.1 + - `actions/upload-artifact` v7.0.1 + - `actions/create-github-app-token` v3.2.0 + - `anthropics/claude-code-action` v1.0.236 + + The live workflows in this repository are pinned to full commit SHAs; the workflow-policy check fails on any unpinned external action or unresolved `` in `.github/workflows/`. Examples under `rollout/` are exempt until copied. +- Require approval for workflows from first-time fork contributors. + +## 6. Labels (every repository) + +| Label | Used by | +|---|---| +| `harness` | harness mistake form, monthly retro | +| `good first issue` | good first issue form, CONTRIBUTING.md | +| `contract-change` | contract change request form | +| `watchdog-finding`, `watchdog-review`, `watchdog-report`, `blocker`, `major`, `review` | deterministic Watchdog issue sync; the Action creates missing labels in the harness repository |\n| `audit-finding`, `blocker`, `major` | legacy weekly audit issue sync | +| `triage`, `bug` | existing forms | +| `area:docs`, `area:navigation`, `area:interfaces`, `area:manipulation`, `area:ui`, `area:release`, `area:ci` | good first issue triage | + +## 7. Rulesets on main (per repository) + +For every active repository: + +- Require a pull request, CODEOWNERS review and conversation resolution. +- Block force pushes and deletions; restrict bypass to the organization owner. +- Required status checks, each added after it has passed on main once: + - `repository-quality / repository-quality` (with `harness_checks: true`) + - `quality/pr-evidence` (once `pr-assistant.yml` is installed) + - `quality/test` (once `verify: true` is set) + +**Two human approvals on safety paths.** A ruleset rule, not a check, supplies these. The +paths are listed under `safety_paths` in maintainers.yaml. For each repository that contains +such paths, add a ruleset with "Require approvals: 2" and "Require review from Code Owners", +and a CODEOWNERS entry that makes the platform lead an owner of those paths: + +| Repository | Safety-relevant paths to cover | Approvals | +|---|---|---| +| openamr-platform-fw | E-stop, brake, contactor, watchdog, motor-enable, charge-inhibit sources | 2, platform lead via CODEOWNERS | +| openamr-platform-hw | safety chain wiring, E-stop and contactor documents | 2, platform lead via CODEOWNERS | +| openamr-platform-sw | watchdog, collision monitor, docking and charge-state code | 2, platform lead via CODEOWNERS | +| openamr-upperbody-fw, openamr-upperbody-hw | arm power and E-stop integration | 2, platform lead via CODEOWNERS | +| openamrobot-ui | E-stop and stop controls, charge-state display | 2, platform lead via CODEOWNERS | +| openamrobot-docs | `docs/safety/` and safety sections of reference pages | 2, platform lead via CODEOWNERS | + +GitHub rulesets apply approval counts per branch, not per path. Where a repository does not +want two approvals on every PR, the path-level requirement rests on CODEOWNERS for the lead's +approval. The second approval stays a human gate (c) that `check_pr_evidence.py` reports. + +Single-maintainer repositories keep required checks. The CODEOWNERS waiver follows section 8 +of the Engineering Quality Standard. + + +**Shared rules and exceptions.** With `harness_checks: true`, a repository must contain +`AGENTS.md`. A repository without it may pass only by supplying the reusable-workflow input +`agents_md_exception` with a non-empty human-readable reason; the reason is written to the +job summary. Warn-only mode reports the missing file but never turns it into a silent pass. +For platform-lead-authored PRs, apply the approval policy in `GOVERNANCE.md` and +`maintainers.yaml`: software-lead approval plus every scoped-owner approval; the author's +reconciliation comment is not an approval. + +## 8. Maintainers map + +Fill the empty handles in `maintainers.yaml` (ci-owner, release-owner, docs-owner) once the people confirm +their GitHub accounts and join the organization. Until then, automation names the role and +mentions nobody. diff --git a/rollout/workflows/docs-sync-caller.yml b/rollout/workflows/docs-sync-caller.yml new file mode 100644 index 0000000..b3a1d0c --- /dev/null +++ b/rollout/workflows/docs-sync-caller.yml @@ -0,0 +1,41 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Permissions, credentials, deduplication and failure handling have +# not been exercised. +# +# Docs sync, sender side: copy to .github/workflows/docs-sync.yml in every +# repository except openamrobot-docs. On push to main it sends one +# repository_dispatch event to openamrobot-docs with the repository and the +# before/after SHAs. The Claude Code action does not run on push events, so the +# work happens in openamrobot-docs (docs-sync.yml). Secrets: DOCS_SYNC_APP_ID, +# DOCS_SYNC_APP_PRIVATE_KEY (see SETUP.md). +name: Docs sync (notify) + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Token for openamrobot-docs + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.DOCS_SYNC_APP_ID }} + private-key: ${{ secrets.DOCS_SYNC_APP_PRIVATE_KEY }} + owner: openAMRobot + repositories: openamrobot-docs + + - name: Dispatch + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + gh api repos/openAMRobot/openamrobot-docs/dispatches -f event_type=source-changed \ + -f "client_payload[repository]=${{ github.event.repository.name }}" \ + -f "client_payload[before]=${{ github.event.before }}" \ + -f "client_payload[after]=${{ github.sha }}" diff --git a/rollout/workflows/docs-sync.yml b/rollout/workflows/docs-sync.yml new file mode 100644 index 0000000..9be28c7 --- /dev/null +++ b/rollout/workflows/docs-sync.yml @@ -0,0 +1,68 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Permissions, credentials, deduplication and failure handling have +# not been exercised. +# +# Docs sync, receiver side: copy to .github/workflows/docs-sync.yml in +# openamrobot-docs. On a source-changed dispatch it compares the source diff +# with the documentation pages and, when a page became wrong, opens one draft +# PR following agent-prompts/docs-fix.md. It never merges and never edits the +# source repository. Replace . Secret: ANTHROPIC_API_KEY. +name: Docs sync + +on: + repository_dispatch: + types: [source-changed] + +permissions: + contents: write + pull-requests: write + id-token: write + +concurrency: + group: docs-sync-${{ github.event.client_payload.repository }} + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Validate payload + env: + REPO: ${{ github.event.client_payload.repository }} + BEFORE: ${{ github.event.client_payload.before }} + AFTER: ${{ github.event.client_payload.after }} + run: | + set -euo pipefail + [[ "$REPO" =~ ^(openamr|openamrobot)-[a-z0-9-]+$|^\.github$ ]] || { echo "bad repository"; exit 1; } + [[ "$AFTER" =~ ^[0-9a-f]{40}$ ]] || { echo "bad sha"; exit 1; } + [[ "$BEFORE" =~ ^[0-9a-f]{40}$ ]] || { echo "bad sha"; exit 1; } + { echo "SRC=$REPO"; echo "BEFORE=$BEFORE"; echo "AFTER=$AFTER"; } >> "$GITHUB_ENV" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out harness and source diff (read-only) + run: | + set -euo pipefail + git clone --quiet https://github.com/openAMRobot/.github .harness + git -C .harness checkout --quiet + git clone --quiet --filter=blob:none "https://github.com/openAMRobot/$SRC" .source + git -C .source diff --stat "$BEFORE" "$AFTER" > .source-diff-stat.txt + git -C .source diff "$BEFORE" "$AFTER" -- '*.md' '*.yaml' '*.yml' '*.launch.py' '*.xacro' '*.urdf' '*.msg' '*.srv' '*.action' 'package.xml' > .source-diff.txt + wc -l .source-diff.txt + + - name: Compare and draft a fix + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Source repository openAMRobot/${{ env.SRC }} changed from ${{ env.BEFORE }} to ${{ env.AFTER }}. + The diff is in .source-diff.txt (stat in .source-diff-stat.txt); the full source is in .source/. + Find documentation pages in this repository that state a command, version, parameter, topic, + configuration ID or value that the diff made wrong. If none, print "no page affected" and stop. + Otherwise follow .harness/agent-prompts/docs-fix.md exactly, on branch docs-sync/${{ env.SRC }}-, + parent SHA ${{ github.sha }}, and open one draft PR with the filled PR template. + Never edit .source/ or .harness/, never merge, never push to main. + claude_args: | + --max-turns 80 + --allowedTools "Read,Grep,Glob,Edit,Write,Bash(git switch -c docs-sync/*),Bash(git add docs/*),Bash(git commit -s *),Bash(git push origin docs-sync/*),Bash(gh pr create --draft *),Bash(python3 .harness/tools/check_decisions.py:*),Bash(python3 .harness/tools/check_public_extract.py:*),Bash(bash scripts/check_docs.sh)" diff --git a/rollout/workflows/monthly-retro.yml b/rollout/workflows/monthly-retro.yml new file mode 100644 index 0000000..0d0e776 --- /dev/null +++ b/rollout/workflows/monthly-retro.yml @@ -0,0 +1,78 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Permissions, credentials, deduplication and failure handling have +# not been exercised. +# +# Monthly retro: copy to .github/workflows/monthly-retro.yml in openAMRobot/.github. +# Reads open issues labelled harness across the organization and recent review +# comments that describe agent mistakes, then opens one draft PR here proposing +# changes to AGENTS.md, the checkers or the templates. Humans decide in the PR. +# Secrets: ANTHROPIC_API_KEY; RETRO_APP_ID and RETRO_APP_PRIVATE_KEY for an App +# with read access to issues and pull requests across the organization. +name: Monthly harness retro + +on: + schedule: + - cron: "23 6 1 * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + retro: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Organization read token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RETRO_APP_ID }} + private-key: ${{ secrets.RETRO_APP_PRIVATE_KEY }} + owner: openAMRobot + + - name: Collect harness issues and agent-mistake review comments + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + set -euo pipefail + since=$(date -u -d '35 days ago' +%Y-%m-%d) + gh search issues --owner openAMRobot --label harness --state open --limit 200 \ + --json repository,number,title,body,url > retro-harness-issues.json + gh search prs --owner openAMRobot --updated ">=$since" --limit 200 --json repository,number > retro-prs.json + python3 - <<'PY' + import json, re, subprocess + out = [] + for pr in json.load(open("retro-prs.json")): + repo = pr["repository"]["nameWithOwner"] + data = subprocess.run(["gh", "api", f"repos/{repo}/pulls/{pr['number']}/comments", "--paginate"], + capture_output=True, text=True).stdout or "[]" + for c in json.loads(data): + if re.search(r"\b(agent|claude|ai[- ]generated|harness)\b", c.get("body", ""), re.I): + out.append({"repo": repo, "pr": pr["number"], "url": c["html_url"], "body": c["body"][:2000]}) + json.dump(out, open("retro-review-comments.json", "w"), indent=2) + print(len(out), "review comments") + PY + + - name: Propose rule and template changes + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + You run the monthly harness retro for openAMRobot/.github at parent SHA ${{ github.sha }}. + Inputs: retro-harness-issues.json and retro-review-comments.json. Group the mistakes by class. + For each class propose exactly one of: a machine-checked gate (change a checker under tools/ with a + test), a template change, or a human decision point with a named decider role from maintainers.yaml. + Advice that nothing checks is not a rule. Keep AGENTS.md under 120 lines and edit the shared block + only in agent-rules/SHARED_RULES.md and AGENTS.md together, bumping the marker version. + Add one row per class to agent-runs.md. Run `bash rollout/verify.sh` and + `python3 tools/check_agent_rules.py --canonical agent-rules/SHARED_RULES.md --file AGENTS.md`. + Then create branch retro/, commit with sign-off, push, and open ONE draft PR using the + PR template, linking every issue it addresses. Never merge, never close issues, never push to main. + claude_args: | + --max-turns 120 + --allowedTools "Read,Grep,Glob,Edit,Write,Bash(bash rollout/verify.sh),Bash(python3 tools/*),Bash(git switch -c retro/*),Bash(git add *),Bash(git commit -s *),Bash(git push origin retro/*),Bash(gh pr create --draft *)" diff --git a/rollout/workflows/pr-assistant.yml b/rollout/workflows/pr-assistant.yml new file mode 100644 index 0000000..ef39fda --- /dev/null +++ b/rollout/workflows/pr-assistant.yml @@ -0,0 +1,92 @@ +# EXAMPLE (state b): supplied under rollout/, not installed anywhere. Its two checker +# commands ran locally in the authoring session; the workflow itself has never run. +# +# PR assistant: copy to .github/workflows/pr-assistant.yml in each repository. +# +# Runs check_decisions.py on the changed files and check_pr_evidence.py on the +# description, then creates or updates one summary comment. It never approves, +# requests changes or merges, and never executes code from the pull request: +# the PR head is checked out as data only, the checkers come from the pinned +# harness. pull_request_target is used so fork PRs can receive the comment; +# no secret other than GITHUB_TOKEN is available to the job. +# Replace with the openAMRobot/.github commit to run. +name: PR assistant + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review, review_requested, review_request_removed] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: pr-assistant-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + evidence: + name: quality/pr-evidence + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out harness (trusted code) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: openAMRobot/.github + ref: + path: harness + persist-credentials: false + + - name: Check out PR head (data only, never executed) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: pr-head + persist-credentials: false + + - name: Collect changed files and reviews + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate > files.json + # Every path the PR touches, including removed files and the old name of a + # rename: the evidence, safety-path and dependency rules must see deletions. + jq -r '.[] | .filename, (.previous_filename // empty)' files.json | sort -u > changed_all.txt + # Only files present at the PR head: the decisions scan reads their content. + jq -r '.[] | select(.status != "removed") | .filename' files.json | sort -u > changed_existing.txt + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/reviews" --paginate > reviews.json + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate | + jq -r '.[].commit.message' > commit-messages.txt + echo "changed (all): $(wc -l < changed_all.txt); present at head: $(wc -l < changed_existing.txt)" + + - name: Decisions of record on the diff + env: + REPOSITORY: ${{ github.event.repository.name }} + run: | + # Record the exit status explicitly; the evidence step fails closed on + # any outcome other than 0 (clean) or 1 (contradictions reported). + set +e + python3 harness/tools/check_decisions.py --decisions harness/decisions.yaml \ + --maintainers harness/maintainers.yaml --root pr-head \ + --repository "$REPOSITORY" --changed-files changed_existing.txt > decisions.txt 2>&1 + status=$? + set -e + printf '{"exit_code": %d}\n' "$status" > decisions-status.json + echo "check_decisions exit $status" + cat decisions.txt + + - name: Evidence check and summary comment + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python3 harness/tools/check_pr_evidence.py --event "$GITHUB_EVENT_PATH" \ + --changed-files changed_all.txt --maintainers harness/maintainers.yaml \ + --reviews reviews.json --commit-messages commit-messages.txt --root pr-head --decisions-report decisions.txt \ + --decisions-status decisions-status.json \ + --output "$GITHUB_STEP_SUMMARY" --post diff --git a/rollout/workflows/weekly-alignment-audit.yml b/rollout/workflows/weekly-alignment-audit.yml new file mode 100644 index 0000000..713a162 --- /dev/null +++ b/rollout/workflows/weekly-alignment-audit.yml @@ -0,0 +1,107 @@ +# DESIGN ONLY (state b): an example under rollout/, not installed anywhere and +# never run. Its permissions, credentials, deduplication, failure handling and +# issue lifecycle have not been exercised. +# +# Weekly alignment audit: copy to .github/workflows/ in the private audit +# repository (openAMRobot/audits). Read-only towards every product repository. +# +# 1. Clones the product repositories anonymously (read-only) and the harness. +# 2. Runs Claude Code with agent-prompts/read-only-audit.md, starting from the +# newest previous ISSUES.csv, and writes a new dated report folder here. +# 3. sync_audit_issues.py opens issues for new Blocker and Major findings with +# the owner from maintainers.yaml. It never closes an issue: for a finding +# the run no longer reports, it comments "no longer detected" once and the +# issue's owner decides whether to close it. +# Replace . Secrets: ANTHROPIC_API_KEY; AUDIT_APP_ID and +# AUDIT_APP_PRIVATE_KEY for the issue-writing GitHub App (see SETUP.md). +name: Weekly alignment audit + +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: write + id-token: write + +concurrency: + group: weekly-alignment-audit + cancel-in-progress: false + +jobs: + audit: + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Check out audit repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: openAMRobot/.github + ref: + path: .harness + persist-credentials: false + + - name: Clone product repositories read-only + run: | + set -euo pipefail + mkdir -p .repos + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --user 'PyYAML==6.0.2' + for r in $(python3 -c "import yaml;print(' '.join(k for k in yaml.safe_load(open('.harness/maintainers.yaml'))['repositories'] if k != '.github'))") .github; do + git clone --quiet --depth 50 "https://github.com/openAMRobot/$r" ".repos/$r" + echo "$r $(git -C ".repos/$r" rev-parse HEAD)" >> .repos/SHAS.txt + done + echo "PREVIOUS=$(ls -d 20*-alignment-audit 2>/dev/null | sort | tail -1)" >> "$GITHUB_ENV" + echo "FOLDER=$(date -u +%Y-%m-%d)-alignment-audit" >> "$GITHUB_ENV" + git switch -c "audit/$(date -u +%Y-%m-%d)" + + - name: Run read-only audit agent + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Follow .harness/agent-prompts/read-only-audit.md exactly. + Precondition block values: report repository ${{ github.repository }}, branch audit/ (current), + parent SHA ${{ github.sha }}, audited checkouts and SHAs in .repos/SHAS.txt, + previous report ${{ env.PREVIOUS }}/ISSUES.csv, decisions .harness/decisions.yaml at . + Write the new report to ${{ env.FOLDER }}/REPORT.md and ${{ env.FOLDER }}/ISSUES.csv. + Add a status column to ISSUES.csv with open or resolved for every finding of the previous report. + Do not write outside ${{ env.FOLDER }}. Do not run git push, gh or any network command. + claude_args: | + --max-turns 200 + --allowedTools "Read,Grep,Glob,Write,Edit,Bash(python3 .harness/tools/check_decisions.py:*),Bash(python3 .harness/tools/check_public_extract.py:*),Bash(git -C .repos/*:*)" + + - name: Validate register and report review windows + run: | + set -euo pipefail + python3 .harness/tools/check_decisions.py \ + --decisions .harness/decisions.yaml \ + --maintainers .harness/maintainers.yaml --validate-only | tee "$FOLDER/DECISIONS-VALIDATION.txt" + + - name: Commit report + run: | + set -euo pipefail + test -s "$FOLDER/ISSUES.csv" && test -s "$FOLDER/REPORT.md" + git add "$FOLDER" + git -c user.name="openamrobot-audit[bot]" -c user.email="audit@users.noreply.github.com" \ + commit -m "audit: $FOLDER" + git push origin HEAD + + - name: Issue-writing token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.AUDIT_APP_ID }} + private-key: ${{ secrets.AUDIT_APP_PRIVATE_KEY }} + owner: openAMRobot + + - name: Open finding issues; comment on findings no longer detected (never closes) + env: + GITHUB_TOKEN: ${{ steps.app.outputs.token }} + run: | + python3 .harness/tools/sync_audit_issues.py --issues "$FOLDER/ISSUES.csv" \ + --maintainers .harness/maintainers.yaml --decisions .harness/decisions.yaml \ + --fallback-repository audits --report "$FOLDER@$(git rev-parse --short HEAD)" --apply diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..e7ffc60 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,15 @@ +# Checker fixtures + +Synthetic files for `tests/test_check_decisions.py`. They contain no plan, audit or supplier +content. `decisions.yaml` here is a fixture register, not the organization register. + +| Case | Fixture file | Expected result | +|---|---|---| +| matching value | `decisions_repo/config/params.yaml` (mast_1350) | no finding | +| contradicting value | `decisions_repo/README.md` line 3, `launch/robot.launch.py`, `urdf/robot.xacro`, `package.xml` | one finding each | +| value in an unlisted file type | `decisions_repo/legacy/notes.txt` | not scanned; counted as "not scanned" | +| superseded decision still cited | `decisions_repo/docs/citation.md` line 3 | finding "superseded source still cited"; line 4 (current revision) clean | +| kept history with marker | `decisions_repo/docs/history.md` line 4 | reported as ALLOWED, not silenced | +| legacy exemption | `decisions_repo/docs/history.md` line 5 | no finding | +| open decision | `undecided-value` in history.md | not scanned | +| excluded path | `decisions_repo/CHANGELOG.md` | not scanned | diff --git a/tests/fixtures/decisions.yaml b/tests/fixtures/decisions.yaml new file mode 100644 index 0000000..259fe1f --- /dev/null +++ b/tests/fixtures/decisions.yaml @@ -0,0 +1,66 @@ +# Fixture register for tests/test_check_decisions.py. Synthetic values only; +# not decisions of record. +schema_version: 1 +in_force: {source: FIX-DOC, date: 2026-10-07} +sources: + FIX-DOC: {title: Fixture decision document revision 2, evidence: tests only} +exclude: ["**/CHANGELOG.md"] +decisions: + - id: FIX-MAST + title: Fixture installation height + kind: configuration + status: recorded + value: 1350 + unit: mm + date: 2026-09-28 + review_by: 2026-11-18 + source: {document: FIX-DOC, item: item 1} + supersedes: + - value: 1400 + source: FIX-DOC revision 1 item 1 + citation: '(?PFIX-DOC\s+rev(?:ision)?\s*1\b[^\n]{0,20}item\s*1)' + applies_to: + repositories: ["*"] + files: ["**/*.md", "**/*.yaml", "**/*.launch.py", "**/*.xacro", "**/package.xml"] + check: + - pattern: '(?Pmast_1400)\b[^\n]{0,40}baseline' + unless: 'legacy' + message: baseline is mast_1350 + verification: + machine: mast_1400 named as baseline; citations of the superseded revision + human: {reviewer: platform-lead, evidence: fixture drawing} + owner: platform-lead + - id: FIX-IMU + title: Fixture topic ownership + kind: distinction + status: recorded + values: [firmware publishes /imu/data_raw, host filter owns /imu/data] + unit: none + date: null + review_by: 2026-11-18 + source: {document: FIX-DOC, item: item 2} + applies_to: {repositories: ["platform-*"], files: ["**/*.launch.py", "**/*.xacro"]} + check: + - pattern: '"(?P/?imu/data)"' + files: ["**/*.launch.py", "**/*.xacro"] + message: firmware must not publish /imu/data + verification: + machine: literal /imu/data in launch or xacro files + human: {reviewer: software-lead, evidence: topic list from a running system} + owner: software-lead + - id: FIX-OPEN + title: Fixture open decision, never scanned + kind: value + status: open + value: undecided + unit: none + date: null + review_by: 2026-11-18 + source: {document: FIX-DOC, item: item 3} + applies_to: {repositories: ["*"], files: ["**/*.md"]} + check: + - pattern: '(?Pundecided-value)' + verification: + machine: none while open + human: {reviewer: platform-lead, evidence: a recorded decision} + owner: platform-lead diff --git a/tests/fixtures/decisions_repo/CHANGELOG.md b/tests/fixtures/decisions_repo/CHANGELOG.md new file mode 100644 index 0000000..f3adda1 --- /dev/null +++ b/tests/fixtures/decisions_repo/CHANGELOG.md @@ -0,0 +1 @@ +- mast_1400 baseline replaced (excluded path) diff --git a/tests/fixtures/decisions_repo/README.md b/tests/fixtures/decisions_repo/README.md new file mode 100644 index 0000000..6ac6736 --- /dev/null +++ b/tests/fixtures/decisions_repo/README.md @@ -0,0 +1,4 @@ +# Fixture robot + +The shoulder uses mast_1400 as the baseline. +Current value: mast_1350 is the baseline. diff --git a/tests/fixtures/decisions_repo/config/params.yaml b/tests/fixtures/decisions_repo/config/params.yaml new file mode 100644 index 0000000..368fd3c --- /dev/null +++ b/tests/fixtures/decisions_repo/config/params.yaml @@ -0,0 +1 @@ +mast_id: mast_1350 # baseline diff --git a/tests/fixtures/decisions_repo/docs/citation.md b/tests/fixtures/decisions_repo/docs/citation.md new file mode 100644 index 0000000..cdee487 --- /dev/null +++ b/tests/fixtures/decisions_repo/docs/citation.md @@ -0,0 +1,4 @@ +# Superseded citation fixture + +The height follows FIX-DOC rev 1 item 1. +The height follows FIX-DOC item 1. diff --git a/tests/fixtures/decisions_repo/docs/history.md b/tests/fixtures/decisions_repo/docs/history.md new file mode 100644 index 0000000..3fe0e77 --- /dev/null +++ b/tests/fixtures/decisions_repo/docs/history.md @@ -0,0 +1,6 @@ +# History + +decision-allow: FIX-MAST quoted from the superseded plan for history +Before 28 September, mast_1400 was the baseline. +The legacy robot used mast_1400 as its baseline. +Nothing here says undecided-value in an enforced way. diff --git a/tests/fixtures/decisions_repo/launch/robot.launch.py b/tests/fixtures/decisions_repo/launch/robot.launch.py new file mode 100644 index 0000000..fd8dada --- /dev/null +++ b/tests/fixtures/decisions_repo/launch/robot.launch.py @@ -0,0 +1,3 @@ +# Fixture launch file +ARGS = {"mast": "mast_1400"} # mast_1400 baseline for the arms +REMAP = [("imu", "/imu/data")] diff --git a/tests/fixtures/decisions_repo/legacy/notes.txt b/tests/fixtures/decisions_repo/legacy/notes.txt new file mode 100644 index 0000000..5068b97 --- /dev/null +++ b/tests/fixtures/decisions_repo/legacy/notes.txt @@ -0,0 +1 @@ +mast_1400 baseline in a file type no decision applies to diff --git a/tests/fixtures/decisions_repo/package.xml b/tests/fixtures/decisions_repo/package.xml new file mode 100644 index 0000000..26442d3 --- /dev/null +++ b/tests/fixtures/decisions_repo/package.xml @@ -0,0 +1,6 @@ + + + fixture + Configured for mast_1400 baseline. + MIT + diff --git a/tests/fixtures/decisions_repo/urdf/robot.xacro b/tests/fixtures/decisions_repo/urdf/robot.xacro new file mode 100644 index 0000000..5a6ef0e --- /dev/null +++ b/tests/fixtures/decisions_repo/urdf/robot.xacro @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_check_agent_rules.py b/tests/test_check_agent_rules.py new file mode 100644 index 0000000..5f10fa0 --- /dev/null +++ b/tests/test_check_agent_rules.py @@ -0,0 +1,82 @@ +"""Tests for tools/check_agent_rules.py (shared-block drift check).""" +import contextlib +import io +import os +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_agent_rules as car # noqa: E402 + +CANONICAL = ROOT / "agent-rules" / "SHARED_RULES.md" + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class Drift(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.shared = CANONICAL.read_text(encoding="utf-8") + + def repo(self, name, agents, claude="@AGENTS.md\n"): + d = Path(self.tmp.name, name) + d.mkdir() + (d / "AGENTS.md").write_text(agents, encoding="utf-8") + (d / "CLAUDE.md").write_text(claude, encoding="utf-8") + return d + + def run_main(self, *args): + err = io.StringIO() + with contextlib.redirect_stdout(err), contextlib.redirect_stderr(err): + try: + code = car.main(["--canonical", str(CANONICAL), *map(str, args)]) + except SystemExit as exc: + code = exc.code + return code, err.getvalue() + + def test_identical_block_passes(self): + self.repo("a", self.shared + "\n# Repository-specific rules: a\n") + self.assertEqual(self.run_main("--root", self.tmp.name)[0], 0) + + def test_root_agents_md_passes(self): + self.assertEqual(self.run_main("--file", ROOT / "AGENTS.md")[0], 0) + + def test_edited_block_fails(self): + self.repo("a", self.shared.replace("zero tests fails", "zero tests is fine")) + code, err = self.run_main("--root", self.tmp.name) + self.assertEqual(code, 1) + self.assertIn("shared block differs from canonical", err) + self.assertIn("Shared agent rules out of date: ", err) + self.assertIn("Fix: Copy the block between the BEGIN and END markers", err) + self.assertIn("Shared agent rules summary: 1 finding(s) (shared-rules 1)", err) + + def test_older_version_fails_with_version_message(self): + old = self.shared.replace("SHARED RULES v2", "SHARED RULES v1") + self.repo("a", old) + self.assertIn("shared block is v1, canonical is v2", self.run_main("--root", self.tmp.name)[1]) + + def test_line_limit_and_claude_import(self): + self.repo("long", self.shared + "x\n" * 120) + self.repo("claude", self.shared, claude="@AGENTS.md\nextra rules\n") + err = self.run_main("--root", self.tmp.name)[1] + self.assertIn("must be under 120 lines", err) + self.assertIn("CLAUDE.md must import @AGENTS.md", err) + + def test_empty_selection_is_refused(self): + self.assertEqual(self.run_main("--root", self.tmp.name)[0], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check_decisions.py b/tests/test_check_decisions.py new file mode 100644 index 0000000..e5ab32e --- /dev/null +++ b/tests/test_check_decisions.py @@ -0,0 +1,499 @@ +"""Tests for tools/check_decisions.py against the fixture repository. + +All text in these tests is synthetic. The real-register tests use invented +sentences that exercise each pattern; they quote no plan, audit or supplier +document. +""" +import contextlib +import io +import os +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_decisions as cd # noqa: E402 + +FIXTURES = ROOT / "tests" / "fixtures" +REPO = FIXTURES / "decisions_repo" +DECISIONS = FIXTURES / "decisions.yaml" + + +def run(*args): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + code = cd.main([str(a) for a in args]) + return code, out.getvalue() + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class FixtureMatrix(unittest.TestCase): + """Matching value, contradicting value, unlisted file type, superseded citation.""" + + def setUp(self): + self.decisions = cd.load_decisions(DECISIONS, ROOT / "maintainers.yaml") + self.stats = {} + self.findings, self.allowed = cd.scan(REPO, self.decisions, "platform-fixture", stats=self.stats) + + def at(self, rel): + return [f for f in self.findings if f["file"] == rel] + + def test_matching_value_is_clean(self): + self.assertEqual(self.at("config/params.yaml"), []) + + def test_contradicting_value_reports_file_line_found_and_decided(self): + (f,) = self.at("README.md") + self.assertEqual((f["line"], f["found"], f["decided"], f["source"]), (3, "mast_1400", "1350 mm", "FIX-DOC item 1")) + + def test_unlisted_file_type_is_not_scanned_but_counted(self): + self.assertEqual(self.at("legacy/notes.txt"), []) + self.assertGreaterEqual(self.stats["unscanned"], 1) + only = {} + cd.scan(REPO, self.decisions, "platform-fixture", only=["legacy/notes.txt"], stats=only) + self.assertEqual(only["unscanned"], 1) + + def test_superseded_decision_still_cited(self): + (f,) = self.at("docs/citation.md") + self.assertEqual((f["line"], f["found"]), (3, "FIX-DOC rev 1 item 1")) + self.assertIn("superseded source still cited", f["message"]) + + def test_all_findings(self): + where = sorted((f["file"], f["line"], f["id"]) for f in self.findings) + self.assertEqual(where, [ + ("README.md", 3, "FIX-MAST"), + ("docs/citation.md", 3, "FIX-MAST"), + ("launch/robot.launch.py", 2, "FIX-MAST"), + ("launch/robot.launch.py", 3, "FIX-IMU"), + ("package.xml", 4, "FIX-MAST"), + ("urdf/robot.xacro", 2, "FIX-MAST"), + ]) + + def test_allow_marker_is_reported_not_silenced(self): + self.assertEqual([(a["file"], a["line"]) for a in self.allowed], [("docs/history.md", 4)]) + self.assertIn("superseded plan", self.allowed[0]["reason"]) + + def test_unless_exemption_and_excluded_paths(self): + files = {f["file"] for f in self.findings} + self.assertNotIn("CHANGELOG.md", files) + self.assertFalse(any(f["file"] == "docs/history.md" for f in self.findings)) + + def test_repository_filter_and_per_check_files(self): + findings, _ = cd.scan(REPO, self.decisions, "docs-fixture") + self.assertNotIn("FIX-IMU", {f["id"] for f in findings}) + self.assertEqual(len(findings), 5) + + def test_open_decisions_are_not_scanned(self): + self.assertNotIn("FIX-OPEN", {f["id"] for f in self.findings}) + + def test_changed_files_scope(self): + self.assertEqual(cd.scan(REPO, self.decisions, "x", only=["config/params.yaml"])[0], []) + self.assertEqual(len(cd.scan(REPO, self.decisions, "x", only=["README.md", "gone.md"])[0]), 1) + + def test_checker_never_writes(self): + before = {p: p.stat().st_mtime_ns for p in REPO.rglob("*") if p.is_file()} + before[DECISIONS] = DECISIONS.stat().st_mtime_ns + run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + after = {p: p.stat().st_mtime_ns for p in before} + self.assertEqual(before, after) + + +class CommandLine(unittest.TestCase): + def test_exit_one_on_contradiction(self): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + self.assertEqual(code, 1) + self.assertIn("Mismatch with approved decision: FIX-MAST (5 finding(s))", out) + self.assertIn("\n README.md:3: found 'mast_1400'\n", out) + self.assertIn("result: 6 contradiction(s), 1 allowed", out) + self.assertIn("textual consistency only", out) + + def test_finding_explains_decision_why_fix_and_links(self): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + block = out.split("Mismatch with approved decision: FIX-MAST (5 finding(s))", 1)[1].split("\nMismatch", 1)[0] + self.assertIn("\n Decision: 1350 mm\n", block) + # Decision, Why, Fix and More once per group; one Why line per distinct reason. + self.assertEqual(block.count(" Decision:"), 1) + self.assertEqual(block.count(" Fix:"), 1) + self.assertIn("\n Why: baseline is mast_1350.\n superseded source still cited", block) + self.assertIn("\n Fix: ", block) + self.assertIn("FIX-MAST in decisions.yaml: https://github.com/openAMRobot/.github/blob/main/decisions.yaml#L", block) + self.assertIn("WATCHDOG.md#decisions-of-record", block) + self.assertIn("\n Found:\n README.md:3: found 'mast_1400'\n docs/citation.md:3: found 'FIX-DOC rev 1 item 1'\n", block) + self.assertEqual(block.count(": found "), 5) + self.assertIn("Decisions of record summary: 6 finding(s) (FIX-MAST 5, FIX-IMU 1)", out) + self.assertIn("Next step: ", out) + self.assertNotIn("CONTRADICTION ", out) + + def test_github_actions_annotations_and_job_summary(self): + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp, "summary.md") + env = {"GITHUB_ACTIONS": "true", "WATCHDOG_ANNOTATION": "error", "GITHUB_STEP_SUMMARY": str(summary)} + with unittest.mock.patch.dict("os.environ", env): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + self.assertEqual(code, 1) + self.assertIn("::error file=README.md,line=3,title=Mismatch with approved decision (FIX-MAST)::", out) + self.assertEqual(out.count("::error file="), 6) + text = summary.read_text(encoding="utf-8") + self.assertIn("| FIX-MAST | 5 |", text) + self.assertIn("
FIX-MAST: 5 finding(s)", text) + + def test_no_annotations_outside_github_actions(self): + with unittest.mock.patch.dict("os.environ", {"GITHUB_ACTIONS": ""}): + code, out = run("--decisions", DECISIONS, "--root", REPO, "--repository", "platform-x") + self.assertNotIn("::warning", out) + self.assertNotIn("::error", out) + + def test_exit_zero_when_clean(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "README.md").write_text("mast_1350 is the baseline\n", encoding="utf-8") + code, out = run("--decisions", DECISIONS, "--root", tmp) + self.assertEqual(code, 0, out) + + def test_exit_two_on_missing_root(self): + self.assertEqual(run("--decisions", DECISIONS, "--root", "/nonexistent-root")[0], 2) + + +class Schema(unittest.TestCase): + def write(self, text): + tmp = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) + tmp.write(text) + tmp.close() + self.addCleanup(Path(tmp.name).unlink) + return tmp.name + + BASE = """schema_version: 1 +in_force: {source: D, date: 2026-10-07} +sources: {D: {title: t}} +decisions: + - id: A + title: t + kind: value + status: recorded + value: 1 + unit: mm + date: null + review_by: 2026-11-18 + source: {document: D, item: i} + applies_to: {repositories: ["*"], files: ["**/*.md"]} + check: [{pattern: '(?Px)'}] + verification: {machine: x in text, human: {reviewer: platform-lead, evidence: drawing}} + owner: platform-lead +""" + + def test_base_is_valid(self): + self.assertEqual(len(cd.load_decisions(self.write(self.BASE), ROOT / "maintainers.yaml")), 1) + + def test_requires_in_force_and_review_by(self): + with self.assertRaisesRegex(cd.DecisionError, "in_force"): + cd.load_decisions(self.write(self.BASE.replace("in_force: {source: D, date: 2026-10-07}\n", ""))) + with self.assertRaisesRegex(cd.DecisionError, "review_by"): + cd.load_decisions(self.write(self.BASE.replace(" review_by: 2026-11-18\n", ""))) + + def test_review_warnings_are_non_blocking_and_deterministic(self): + data = {"decisions": [{"id": "A", "review_by": "2026-10-06"}, + {"id": "B", "review_by": "2026-10-08"}]} + self.assertEqual(cd.review_warnings(data, cd.date(2026, 10, 7)), + ["A review_by 2026-10-06 is past due"]) + + def test_rejects_invalid_entries(self): + cases = { + "duplicate id": self.BASE + self.BASE.split("decisions:\n", 1)[1], + "pattern needs": self.BASE.replace("(?Px)", "x"), + "not listed under sources": self.BASE.replace("document: D", "document: E"), + "status must be": self.BASE.replace("status: recorded", "status: maybe"), + "kind must be": self.BASE.replace("kind: value", "kind: wish"), + "missing owner": self.BASE.replace(" owner: platform-lead\n", ""), + "needs value": self.BASE.replace(" value: 1\n", ""), + "repositories and files": self.BASE.replace(', files: ["**/*.md"]', ""), + "verification needs": self.BASE.replace("machine: x in text, ", ""), + "schema_version": self.BASE.replace("schema_version: 1", "schema_version: 9"), + } + for expected, text in cases.items(): + with self.subTest(expected=expected): + with self.assertRaisesRegex(cd.DecisionError, expected): + cd.load_decisions(self.write(text)) + + def test_summary_and_fix_hint_are_optional_short_single_lines(self): + ok = self.BASE.replace(" title: t\n", " title: t\n summary: Mast is 1 mm.\n fix_hint: Use 1 mm.\n") + decision = cd.load_decisions(self.write(ok))[0] + self.assertEqual((decision["summary"], decision["fix_hint"]), ("Mast is 1 mm.", "Use 1 mm.")) + for field in ("summary", "fix_hint"): + for bad in ("'" + "x" * 121 + "'", "''", "|\n two\n lines", "[a]"): + with self.subTest(field=field, value=bad): + text = self.BASE.replace(" title: t\n", f" title: t\n {field}: {bad}\n") + with self.assertRaisesRegex(cd.DecisionError, f"{field} must be one non-empty line"): + cd.load_decisions(self.write(text)) + + SUPERSEDED = BASE.replace("status: recorded", "status: superseded").replace( + " check: [{pattern: '(?Px)'}]\n", + " superseded_by: {document: D, item: j, decision: B, citation: '(?Pold)', unless: 'history'}\n") + + def test_superseded_entry_scans_only_its_citation(self): + (d,) = cd.load_decisions(self.write(self.SUPERSEDED), ROOT / "maintainers.yaml") + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "a.md").write_text("x here\nold value\nold value in history\n", encoding="utf-8") + findings, _ = cd.scan(tmp, [d], "r") + self.assertEqual([(f["line"], f["found"]) for f in findings], [(2, "old")]) + self.assertIn("superseded by D j (B)", findings[0]["message"]) + self.assertEqual(findings[0]["decided"], "superseded by B") + + def test_superseded_entry_needs_superseded_by(self): + cases = { + "superseded needs superseded_by": self.SUPERSEDED.replace( + " superseded_by: {document: D, item: j, decision: B, citation: '(?Pold)', unless: 'history'}\n", ""), + "superseded_by document 'E' not listed": self.SUPERSEDED.replace("superseded_by: {document: D", "superseded_by: {document: E"), + "superseded_by citation needs": self.SUPERSEDED.replace("'(?Pold)'", "'old'"), + "missing check": self.BASE.replace(" check: [{pattern: '(?Px)'}]\n", ""), + } + for expected, text in cases.items(): + with self.subTest(expected=expected): + with self.assertRaisesRegex(cd.DecisionError, expected): + cd.load_decisions(self.write(text)) + + def test_owner_and_reviewer_must_be_maintainers_roles(self): + with self.assertRaisesRegex(cd.DecisionError, "owner 'somebody' is not a role"): + cd.load_decisions(self.write(self.BASE.replace("owner: platform-lead", "owner: somebody")), + ROOT / "maintainers.yaml") + with self.assertRaisesRegex(cd.DecisionError, "reviewer 'somebody' is not a role"): + cd.load_decisions(self.write(self.BASE.replace("reviewer: platform-lead", "reviewer: somebody")), + ROOT / "maintainers.yaml") + + +class RealRegister(unittest.TestCase): + """The organization's decisions.yaml is valid and its patterns behave on synthetic text.""" + + @classmethod + def setUpClass(cls): + cls.decisions = cd.load_decisions(ROOT / "decisions.yaml", ROOT / "maintainers.yaml") + + def ids(self, name, text, repository="openamr-platform-sw"): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return sorted(f["id"] for f in cd.scan(tmp, self.decisions, repository)[0]) + + def test_every_entry_has_provenance_verification_and_owner(self): + for d in self.decisions: + self.assertTrue(d["source"]["item"], d["id"]) + self.assertTrue(d["verification"]["human"]["evidence"], d["id"]) + + def test_every_entry_has_summary_and_fix_hint(self): + for d in self.decisions: + with self.subTest(id=d["id"]): + self.assertTrue(d.get("summary")) + self.assertTrue(d.get("fix_hint")) + self.assertTrue(d.get("_line")) + + def test_finding_prints_summary_not_long_value(self): + records = [{"file": "a.md", "line": 1, "id": d["id"], "found": "x", "message": "m"} for d in self.decisions] + for record, d in zip(cd.to_watchdog(records, self.decisions), self.decisions): + with self.subTest(id=d["id"]): + self.assertEqual(record["decision"], d["summary"]) + self.assertEqual(record["fix"], d["fix_hint"]) + self.assertLessEqual(len(record["decision"]), 120) + + def test_non_numeric_decisions_are_present(self): + kinds = {d["id"]: d["kind"] for d in self.decisions} + for did in ("MAX-ASSEMBLED-HEIGHT", "NO-SUSPENSION", "RS485-NOT-IN-2-0", "DOCK-NO-CONTACTS", + "DOCKING-NOT-CHARGING", "TELEMETRY-NOT-SAFETY-EVIDENCE"): + self.assertIn(kinds[did], {"exclusion", "distinction"}, did) + + def test_imu_topic_attributed_to_firmware(self): + self.assertEqual(self.ids("launch/a.launch.py", "# the MCU bridge publishes /imu/data and /odom\n"), + ["IMU-TOPIC-OWNERSHIP"]) + self.assertEqual(self.ids("launch/a.launch.py", "# MCU bridge: /odom/unfiltered, /imu/data\n"), + ["IMU-TOPIC-OWNERSHIP"]) + self.assertEqual(self.ids("docs/imu.md", "The MCU publishes /imu/data_raw; the host filter publishes /imu/data.\n"), []) + + def test_imu_topic_history_label_is_accepted(self): + # Regression: a line that labels the old firmware topic as outdated history is not a finding. + self.assertEqual(self.ids("electrical/sensors/imu.md", + "*(Older revisions of this doc said the firmware publishes `/imu/data` directly" + " \u2014 that is outdated;\n", "openamr-platform-hw"), []) + self.assertEqual(self.ids("docs/imu.md", "The firmware publishes `/imu/data` directly.\n", + "openamr-platform-hw"), ["IMU-TOPIC-OWNERSHIP"]) + + def test_shoulder_height_versus_envelope(self): + self.assertEqual(self.ids("docs/a.md", "Shoulder axis at 1700 mm.\n", "openamrobot-docs"), ["MAX-ASSEMBLED-HEIGHT"]) + self.assertEqual(self.ids("docs/a.md", "Maximum assembled height 1700 mm, not a shoulder height.\n", + "openamrobot-docs"), []) + + def test_superseded_mast_entries(self): + cases = { + "Shoulder-axis installation height 1350 mm.\n": ["MAST-INSTALL-HEIGHT"], + "The mast_1350 slot is the installation baseline.\n": ["MAST-INSTALL-HEIGHT"], + "Four indexed mast positions, mast_1300 to mast_1450.\n": ["MAST-POSITIONS"], + "Mast top at 1500 mm on one MISUMI HFS6-60120 profile.\n": ["MAST-TOP-HEIGHT"], + "Maximum assembled height 1600 mm.\n": ["MAX-ASSEMBLED-HEIGHT"], + } + for text, expected in cases.items(): + self.assertEqual(self.ids("docs/a.md", text, "x"), expected, text) + for text in ("Shoulder axis 1000 to 1350 mm on the lift.\n", + "The superseded mast_1350 installation baseline (P-03 rev18.2).\n", + "Maximum assembled height 1700 mm, not a shoulder height.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + by_id = {d["id"]: d for d in self.decisions} + for did in ("MAST-INSTALL-HEIGHT", "MAST-POSITIONS", "MAST-TOP-HEIGHT"): + self.assertEqual(by_id[did]["status"], "superseded", did) + self.assertEqual((by_id[did]["superseded_by"]["document"], by_id[did]["superseded_by"]["item"]), + ("P-03-rev18.7", "item 8"), did) + self.assertEqual(by_id["MAX-ASSEMBLED-HEIGHT"]["source"]["document"], "P-03-rev18.7") + + def test_exclusions(self): + self.assertEqual(self.ids("docs/a.md", "The base uses sprung drive wheels.\n", "x"), ["NO-SUSPENSION"]) + self.assertEqual(self.ids("docs/a.md", "No sprung drive wheels in 2.0.\n", "x"), []) + self.assertEqual(self.ids("docs/a.md", "The dock has two charging contacts.\n", "openamrobot-docs"), ["DOCK-NO-CONTACTS"]) + self.assertEqual(self.ids("docs/a.md", "There are no charging contacts.\n", "openamrobot-docs"), []) + self.assertEqual(self.ids("docs/a.md", "The drive talks RS485 to the base.\n", "openamr-platform-hw"), ["RS485-NOT-IN-2-0"]) + self.assertEqual(self.ids("docs/a.md", "The lift controller moves the arms.\n", "x"), []) + + def test_docking_never_establishes_charging(self): + self.assertEqual(self.ids("src/dock.py", "def isCharging(self): return true\n", "openamr-platform-sw"), + ["DOCKING-NOT-CHARGING"]) + self.assertEqual(self.ids("web/a.ts", "// when docked the robot is connected to external power\n", + "openamrobot-ui"), ["DOCKING-NOT-CHARGING"]) + + def test_legacy_charging_dock_fields_are_accepted(self): + # Regression: a comment that labels the upstream charging-dock fields as legacy is not a finding. + self.assertEqual(self.ids("config/dock_trigger.yaml", + " # Legacy fields read by opennav_docking::SimpleChargingDock \u2014 kept so the\n", + "openamr-platform-sw"), []) + self.assertEqual(self.ids("config/nav2_params.yaml", " plugin: 'opennav_docking::SimpleChargingDock'\n", + "openamr-platform-sw"), ["DOCKING-NOT-CHARGING"]) + + def test_telemetry_is_not_safety_evidence(self): + self.assertEqual(self.ids("docs/a.md", "The watchdog is our safety layer.\n", "x"), ["TELEMETRY-NOT-SAFETY-EVIDENCE"]) + self.assertEqual(self.ids("docs/a.md", "The watchdog is functional, not a safety layer.\n", "x"), []) + + def test_estop_recommendation(self): + self.assertEqual(self.ids("docs/a.md", "An uncertified button is fine for prototypes.\n", "x"), + ["SAFETY-PROCUREMENT"]) + + def test_bom_issue_in_force(self): + self.assertEqual(self.ids("docs/a.md", "The canonical BOM is Issue 6.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) + self.assertEqual(self.ids("docs/a.md", "BOM per P-03 rev18.1 line 7.\n", "x"), ["BOM-ISSUE-IN-FORCE"]) + self.assertEqual(self.ids("docs/a.md", "Issue 7.3 is canonical; Issue 6 is superseded.\n", "x"), []) + + def test_bom_issue_7_3_in_force(self): + for text in ("The canonical BOM is Issue 7.\n", "BOM Issue 7.2 is the BOM of record.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["BOM-ISSUE-IN-FORCE"], text) + for text in ("BOM Issue 7.3 is canonical.\n", "Issue 7 was canonical until Issue 7.3 superseded it.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_nav_lidar(self): + repo = "openamr-platform-sw" + self.assertEqual(self.ids("docs/a.md", "Navigation LiDAR: Hokuyo UST-10LX.\n", repo), ["NAV-LIDAR"]) + self.assertEqual(self.ids("docs/a.md", "The UST-10LX was dropped on cost.\n", repo), []) + self.assertEqual(self.ids("docs/a.md", "Mount the RPLIDAR A1M8 on the base.\n", repo), ["NAV-LIDAR"]) + self.assertEqual(self.ids("docs/a.md", "RPLIDAR A1 on the existing robot (Gate A).\n", repo), []) + self.assertEqual(self.ids("docs/a.md", "RPLIDAR S3 (S3M1-R2) via sllidar_ros2; the RPLIDAR is on USB.\n", + repo), []) + + def test_release_milestones(self): + self.assertEqual(self.ids("docs/a.md", "OpenAMRobot 2.0 final release: 18 December 2026.\n", "x"), []) + self.assertEqual(self.ids("docs/a.md", "OpenAMRobot 2.0 final release: 30 November 2026.\n", "x"), + ["RELEASE-MILESTONES"]) + + def test_base_controller_io(self): + repo = "openamrobot-docs" + self.assertEqual(self.ids("docs/a.md", "Gate B: STM32H743 bench controller.\n", repo), ["BASE-CONTROLLER-IO"]) + self.assertEqual(self.ids("docs/a.md", "Bench board: NUCLEO-H743ZI2.\n", repo), ["BASE-CONTROLLER-IO"]) + for line in ("The STM32H743 is superseded by the STM32H723ZG.\n", + "Legacy bench board: NUCLEO-H743ZI2.\n", + "Historical note: the STM32H743 bench build.\n", + "The NUCLEO-H743ZI2 was replaced by the NUCLEO-H723ZG.\n", + "Base controller: STM32H723ZG on a NUCLEO-H723ZG bench board.\n"): + self.assertEqual(self.ids("docs/a.md", line, repo), [], line) + + def test_lift_approved_in_principle(self): + for text in ("OpenAMRobot 2.0 has no lift.\n", + "- a fixed mast for the arms\n", + "The linear lift is deferred to OpenAMRobot 3.0.\n", + "Fixed mast (lift in 3.0), mounting plates.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["LIFT"], text) + for text in ("Lift approved in principle: DOLD Hexalift V1 350 mm primary, TiMOTION TL3 400 mm fallback.\n", + "Lift motion only in the stowed or carry safe pose with the base stopped.\n", + "No lift motion while the base moves.\n", + "The fixed mast is superseded by the lift (P-03 rev18.7 item 8).\n", + "The lift column moves the shoulder axis from 1000 to 1350 mm.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_datum_height_stack(self): + for text in ("The steel chassis deck top is at 300 mm.\n", "Base-plate top face 310 mm above the floor.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["DATUM-HEIGHT-STACK"], text) + for text in ("Steel chassis deck top 294 mm (MMP STEP); floor Z = 0.\n", + "Base-plate top face 304 mm, the reference for lift and shoulder heights.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_frames_rep105(self): + for text in ("base_link sits on the floor under the robot.\n", + "base_footprint is at axle height.\n", + "imu_link is mounted next to the drive motor.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["FRAMES-REP105"], text) + for text in ("base_footprint on the floor under the drive-axle midpoint; base_link at axle height, x forward, z up.\n", + "imu_link on the centreline, away from the motor magnetic fields.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_battery_placement_rear_edge(self): + self.assertEqual(self.ids("docs/a.md", "The battery is centred at 25 percent of the length from the rear.\n", "x"), + ["BATTERY-PLACEMENT"]) + for text in ("The battery sits as close to the rear edge as practical, keeping service clearances.\n", + "The battery was centred at 25 percent (superseded by P-03 rev18.7 item 9).\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_base_controller_io_rev18_7(self): + for text in ("Two MB7040 sensors, one per I2C bus.\n", "micro-ROS over USB to the Jetson.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["BASE-CONTROLLER-IO"], text) + for text in ("Two MaxBotix MB7060 sensors, each on a dedicated STM32 UART at 9600 8N1.\n", + "MCU to Jetson over Ethernet, micro-ROS over UDP; micro-ROS over USB on the bench only.\n", + "MB7040 on I2C is superseded.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_head_camera_recorded_and_base_camera_tilt(self): + for text in ("Head camera: ZED 2i on the mast.\n", "The head camera is a ZED X Mini.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["HEAD-CAMERA-IDENTITY"], text) + self.assertEqual(self.ids("docs/a.md", "The Gemini 336L is tilted 20 degrees up.\n", "openamrobot-docs"), + ["CAMERAS"]) + for text in ("Stereolabs ZED Mini (SKU ZED-121210) on the lift carriage, pitch 25 degrees down.\n", + "Gemini 336L about 243 mm above the floor, tilted 10 degrees up (positions 5, 10, 15).\n"): + self.assertEqual(self.ids("docs/a.md", text, "openamrobot-docs"), [], text) + + def test_release_milestones_no_v0_2_or_13_november(self): + for text in ("v0.2 is the first release built from the harness.\n", + "Readiness declaration for v0.2.\n", + "Development cycle 2 ends 13 November 2026.\n", + "The 14 September to 13 November cycle.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["RELEASE-MILESTONES"], text) + for text in ("Development cycle 2 ends 20 November 2026 with v2.0.0-rc.1.\n", + "The v0.2 release plan is superseded by v2.0.0-rc.1.\n", + "The cycle originally ended 13 November (superseded by RELEASE-MILESTONES).\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_lift_check_after_cross_repository_dry_run(self): + for text in ("- **Lift module:** separate OpenAMRobot 3.0 scope.\n", + "Scope: the future OpenAMRobot 3.0 lift controller.\n", + "There is no lift in 2.0.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), ["LIFT"], text) + for text in ("No lift firmware is implemented yet; the CAN3 lift interface is a release gate.\n", + "No lift controller exists yet.\n", + "Planning groups: arm, arm+lift.\n"): + self.assertEqual(self.ids("docs/a.md", text, "x"), [], text) + + def test_legacy_label_exempts_compute(self): + self.assertEqual(self.ids("README.md", "Legacy build: Raspberry Pi 5.\n", "openamr-platform-hw"), []) + self.assertEqual(self.ids("README.md", "Compute: Raspberry Pi 5.\n", "openamr-platform-hw"), ["COMPUTE"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check_pr_evidence.py b/tests/test_check_pr_evidence.py new file mode 100644 index 0000000..a5d6e40 --- /dev/null +++ b/tests/test_check_pr_evidence.py @@ -0,0 +1,458 @@ +"""Tests for tools/check_pr_evidence.py.""" +import contextlib +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_pr_evidence as ev # noqa: E402 + +MAINTAINERS = yaml.safe_load((ROOT / "maintainers.yaml").read_text(encoding="utf-8")) +HEAD = "8ce9314fa9a404564fa7e954cd84f25bcba2b829" +BODY = f"""## Summary +Adds a filter node. + +## Work package +#12 + +## Integration Gate +Reused the upstream madgwick filter; no overlapping PRs. + +## Tests +Ran 12 tests, 0 skipped. Reverting the change makes test_topic_owner fail. + +## Evidence +Base SHA: d1ac6b64db830f001eb4d9d45a48910209b180bf +Head SHA: {HEAD[:12]} + +``` +$ bash tools/verify.sh +PASS +``` + +## Dependencies +None + +## Safety impact +None + +## STATE.md +Updated. + +## Not verified +Hardware run on the robot. + +## AI disclosure +None + +## Contribution terms + +- [x] No partner, customer or private person is named; the application is Use_Case_1. +""" + + +def pr(body=BODY, draft=False, reviewers=(), author="contributor"): + return {"number": 7, "body": body, "draft": draft, "head": {"sha": HEAD}, + "user": {"login": author}, "requested_reviewers": [{"login": r} for r in reviewers]} + + +def evaluate(body=BODY, changed=("src/node.py",), **kw): + reviews = kw.pop("reviews", ()) + has_state = kw.pop("has_state", False) + commit_messages = kw.pop("commit_messages", ()) + return ev.evaluate(pr(body, **kw), list(changed), MAINTAINERS, reviews, has_state, commit_messages) + + +class Sections(unittest.TestCase): + def test_complete_description_passes(self): + failures, warnings, _ = evaluate() + self.assertEqual((failures, warnings), ([], [])) + + def test_each_required_section_is_enforced(self): + for name in ev.SECTIONS: + with self.subTest(section=name): + body = BODY.replace(f"## {name}\n", "## Something else\n") + self.assertIn(f"Missing section: {name}", evaluate(body)[0]) + + def test_empty_section_fails(self): + body = BODY.replace("## Not verified\nHardware run on the robot.", "## Not verified\n\n") + self.assertIn("Empty section: Not verified", evaluate(body)[0]) + + def test_unfilled_template_fails(self): + template = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") + failures = evaluate(template)[0] + self.assertIn("Evidence: no base SHA (write `Base SHA: `)", failures) + self.assertIn("Evidence: no exact command (use a code block or `$ command` lines)", failures) + self.assertTrue(any(f.startswith("Empty section") for f in failures)) + + def test_public_use_checkbox_is_required_when_terms_are_present(self): + body = BODY.replace( + "- [x] No partner, customer or private person is named; the application is Use_Case_1.", + "- [ ] No partner, customer or private person is named; the application is Use_Case_1." + ) + self.assertIn("Contribution terms: check the Use_Case_1/no-private-person checkbox", + evaluate(body)[0]) + + def test_template_contains_every_required_section(self): + template = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") + secs = ev.sections(template) + for name in ev.SECTIONS + ["Dependencies", "STATE.md"]: + self.assertIsNotNone(ev.find(secs, name), name) + + +class EvidenceRules(unittest.TestCase): + def test_missing_shas(self): + body = BODY.replace("Base SHA: d1ac6b64db830f001eb4d9d45a48910209b180bf\n", "").replace(f"Head SHA: {HEAD[:12]}", "") + failures = evaluate(body)[0] + self.assertIn("Evidence: no base SHA (write `Base SHA: `)", failures) + self.assertIn("Evidence: no head SHA (write `Head SHA: `)", failures) + + def test_stale_head_fails_when_ready_and_warns_in_draft(self): + body = BODY.replace(HEAD[:12], "0123456789ab") + self.assertTrue(any("is not the PR head" in f for f in evaluate(body)[0])) + failures, warnings, _ = evaluate(body, draft=True) + self.assertEqual(failures, []) + self.assertTrue(any("update before ready" in w for w in warnings)) + + def test_dollar_command_lines_count_as_commands(self): + body = BODY.replace("```\n$ bash tools/verify.sh\nPASS\n```", "$ bash tools/verify.sh") + self.assertEqual(evaluate(body)[0], []) + + +class TestRules(unittest.TestCase): + def test_test_change_without_count_fails(self): + body = BODY.replace("Ran 12 tests, 0 skipped.", "Tests were run.") + failures = evaluate(body, changed=["tests/test_node.py"])[0] + self.assertIn("Test files changed (1) but no test run with a count is reported", failures) + + def test_zero_tests_fails(self): + body = BODY.replace("Ran 12 tests, 0 skipped.", "Ran 0 tests.") + self.assertIn("Reported test run executed zero tests", evaluate(body, changed=["pkg/test/test_a.py"])[0]) + + def test_test_change_with_count_passes(self): + self.assertEqual(evaluate(changed=["web/src/app.test.ts"])[0], []) + + +class DependencyAndStateRules(unittest.TestCase): + def test_manifest_change_needs_dependencies_section(self): + failures = evaluate(changed=["ros2/pkg/package.xml"])[0] + self.assertTrue(any(f.startswith("Dependency manifests changed") for f in failures)) + body = BODY.replace("## Dependencies\nNone", "## Dependencies\nAdded imu_filter_madgwick (BSD-3-Clause, ROS index)") + self.assertEqual(evaluate(body, changed=["ros2/pkg/package.xml"])[0], []) + + def test_fixture_manifests_are_not_dependencies(self): + self.assertEqual(evaluate(changed=["tests/fixtures/repo/package.xml", "pkg/testdata/package.json"])[0], []) + + def test_state_md_must_be_updated_or_explained(self): + self.assertIn("STATE.md exists but is not updated; update it or write 'no change' with a reason", + evaluate(has_state=True)[0]) + self.assertEqual(evaluate(changed=["src/node.py", "STATE.md"], has_state=True)[0], []) + body = BODY.replace("## STATE.md\nUpdated.", "## STATE.md\nNo change: typo fix only.") + self.assertEqual(evaluate(body, has_state=True)[0], []) + + +class SafetyRules(unittest.TestCase): + CHANGED = ["firmware/src/estop_monitor.cpp"] + + def test_safety_path_needs_two_humans_including_platform_lead(self): + failures = evaluate(changed=self.CHANGED, reviewers=["someone"])[0] + self.assertIn("Safety path touched, two human approvals required: only 1 human reviewer(s) requested", failures) + self.assertIn("Safety path touched, two human approvals required: platform lead @BotshareAI is not requested", failures) + + def test_bots_and_author_do_not_count(self): + failures = evaluate(changed=self.CHANGED, reviewers=["BotshareAI", "claude[bot]", "contributor"])[0] + self.assertIn("Safety path touched, two human approvals required: only 1 human reviewer(s) requested", failures) + + def test_two_humans_with_lead_pass_including_submitted_reviews(self): + reviews = [{"user": {"login": "panthera-momagdii"}, "state": "COMMENTED"}] + failures, _, notes = evaluate(changed=self.CHANGED, reviewers=["BotshareAI"], reviews=reviews) + self.assertEqual(failures, []) + self.assertTrue(any(n.startswith("Safety path touched, two human approvals required") for n in notes)) + + def test_requested_reviewers_are_not_approvals(self): + reviews = [{"user": {"login": "panthera-momagdii"}, "state": "APPROVED"}, + {"user": {"login": "claude[bot]"}, "state": "APPROVED"}] + notes = evaluate(changed=self.CHANGED, reviewers=["BotshareAI"], reviews=reviews)[2] + self.assertTrue(any("Human approvals so far: 1 (information only" in n for n in notes)) + + def test_ai_assisted_safety_change_fails(self): + body = BODY.replace("## AI disclosure\nNone", "## AI disclosure\nClaude Code drafted the watchdog change.") + failures = evaluate(body, changed=["fw/watchdog.c"], reviewers=["BotshareAI", "panthera-momagdii"])[0] + self.assertTrue(any("agents do not author safety logic" in f for f in failures)) + + def test_ai_disclosure_names_tool_and_scope(self): + body = BODY.replace("## AI disclosure\nNone", "## AI disclosure\nAI-assisted.") + failures = evaluate(body, commit_messages=["Generated with Claude Code"])[0] + self.assertTrue(any("must name the AI tool" in f for f in failures)) + self.assertTrue(any("must state the scope" in f for f in failures)) + + def test_ai_disclosure_can_pass_with_tool_and_scope(self): + body = BODY.replace( + "## AI disclosure\nNone", + "## AI disclosure\nClaude Code drafted the documentation and tests; a human reviewed the diff." + ) + self.assertEqual(evaluate(body, commit_messages=["Generated with Claude Code"])[0], []) + + def test_ai_markers_in_commit_messages_are_checked(self): + failures = evaluate(commit_messages=["Implement feature\n\nCo-Authored-By: Claude "])[0] + self.assertTrue(any("AI assistance is visible" in f for f in failures)) + + +class DecisionReport(unittest.TestCase): + def test_contradictions_become_failures(self): + report = ("decisions: 3 loaded\n" + "Mismatch with approved decision: MAST-INSTALL-HEIGHT (1 finding(s))\n Decision: x\n" + " Why: y\n Found:\n docs/a.md:4: found 'mast_1400'\n\n" + "ALLOWED docs/h.md:2: MAST-INSTALL-HEIGHT found 'mast_1400'; reason: history\n") + self.assertEqual(ev.decision_failures(report), [ + "Decision contradiction: docs/a.md:4: MAST-INSTALL-HEIGHT found 'mast_1400'; decision: x"]) + + def test_reads_the_real_grouped_checker_output(self): + fixtures = ROOT / "tests" / "fixtures" + proc = subprocess.run([sys.executable, str(ROOT / "tools" / "check_decisions.py"), + "--decisions", str(fixtures / "decisions.yaml"), + "--root", str(fixtures / "decisions_repo"), "--repository", "platform-x"], + capture_output=True, text=True, env={**os.environ, "GITHUB_ACTIONS": ""}) + failures = ev.decision_failures(proc.stdout) + self.assertEqual(len(failures), 6) + self.assertIn("Decision contradiction: README.md:3: FIX-MAST found 'mast_1400'; decision: 1350 mm", failures) + self.assertEqual(ev.decision_verdict(proc.stdout, {"exit_code": proc.returncode}), (failures, [])) + + def test_long_reports_are_truncated(self): + report = "Mismatch with approved decision: X (25 finding(s))\n Found:\n" + "\n".join( + f" f.md:{i}: found 'a'" for i in range(25)) + failures = ev.decision_failures(report) + self.assertEqual(len(failures), 21) + self.assertEqual(failures[-1], "... and 5 more decision contradictions") + + +class DeletedFiles(unittest.TestCase): + """A PR that only deletes files must still reach the evidence rules.""" + + def test_deleting_a_safety_path_file_is_flagged(self): + failures, _, notes = evaluate(changed=["firmware/src/estop_monitor.cpp"]) + self.assertIn("Safety path touched, two human approvals required: only 0 human reviewer(s) requested", + failures) + self.assertTrue(any(n.startswith("Safety path touched") for n in notes)) + + def test_deleting_a_dependency_manifest_is_flagged(self): + failures = evaluate(changed=["ros2/pkg/package.xml"])[0] + self.assertTrue(any(f.startswith("Dependency manifests changed (ros2/pkg/package.xml)") for f in failures)) + + @unittest.skipIf(shutil.which("jq") is None, "jq is not installed; tracking issue openAMRobot/.github#42") + def test_pr_assistant_passes_deleted_files_to_the_evidence_checker(self): + workflow = yaml.safe_load((ROOT / "rollout" / "workflows" / "pr-assistant.yml").read_text(encoding="utf-8")) + steps = {s.get("name"): s.get("run", "") for s in workflow["jobs"]["evidence"]["steps"]} + collect = steps["Collect changed files and reviews"] + jq_all = re.search(r"jq -r '([^']+)' files.json \| sort -u > changed_all.txt", collect).group(1) + jq_existing = re.search(r"jq -r '([^']+)' files.json \| sort -u > changed_existing.txt", collect).group(1) + files = [ + {"filename": "firmware/src/estop_monitor.cpp", "status": "removed"}, + {"filename": "ros2/pkg/package.xml", "status": "removed"}, + {"filename": "fw/brake_ctrl_v2.c", "previous_filename": "fw/brake_ctrl.c", "status": "renamed"}, + {"filename": "README.md", "status": "modified"}, + ] + + def run_jq(expr): + out = subprocess.run(["jq", "-r", expr], input=json.dumps(files), capture_output=True, + text=True, check=True).stdout + return sorted(set(out.split())) + + changed_all, existing = run_jq(jq_all), run_jq(jq_existing) + self.assertEqual(changed_all, ["README.md", "firmware/src/estop_monitor.cpp", "fw/brake_ctrl.c", + "fw/brake_ctrl_v2.c", "ros2/pkg/package.xml"]) + self.assertEqual(existing, ["README.md", "fw/brake_ctrl_v2.c"]) + self.assertIn("--changed-files changed_all.txt", steps["Evidence check and summary comment"]) + self.assertIn("--changed-files changed_existing.txt", steps["Decisions of record on the diff"]) + failures = evaluate(changed=changed_all)[0] + self.assertTrue(any(f.startswith("Safety path touched") for f in failures)) + self.assertTrue(any(f.startswith("Dependency manifests changed") for f in failures)) + + +class JqMissing(unittest.TestCase): + def test_wiring_test_names_its_tracking_issue_when_jq_is_missing(self): + with tempfile.TemporaryDirectory() as empty_path: + proc = subprocess.run( + [sys.executable, "-m", "unittest", "-v", + "test_check_pr_evidence.DeletedFiles.test_pr_assistant_passes_deleted_files_to_the_evidence_checker"], + cwd=ROOT / "tests", env=dict(os.environ, PATH=empty_path), capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("skipped 'jq is not installed; tracking issue openAMRobot/.github#42'", proc.stderr) + + +CLEAN = "decisions: 23 loaded\nresult: 0 contradiction(s), 0 allowed, scope 3 changed file(s)\n" +TWO = ("decisions: 23 loaded\n" + "Mismatch with approved decision: MAST-INSTALL-HEIGHT (1 finding(s))\n Decision: x\n Found:\n" + " docs/a.md:4: found 'mast_1400'\n\n" + "Mismatch with approved decision: COMPUTE (1 finding(s))\n Decision: Jetson\n Found:\n" + " docs/b.md:9: found 'Raspberry Pi 5'\n\n" + "result: 2 contradiction(s), 0 allowed, scope 2 changed file(s)\n") + + +class DecisionCheckerFailsClosed(unittest.TestCase): + """Any decisions-check outcome other than the two documented ones is a checker error.""" + + def test_documented_outcomes(self): + self.assertEqual(ev.decision_verdict(CLEAN, {"exit_code": 0}), ([], [])) + failures, errors = ev.decision_verdict(TWO, {"exit_code": 1}) + self.assertEqual((len(failures), errors), (2, [])) + + def test_real_checker_exception_is_a_checker_error(self): + with tempfile.TemporaryDirectory() as tmp: + # --changed-files pointing at a directory makes check_decisions.py raise. + proc = subprocess.run([sys.executable, str(ROOT / "tools" / "check_decisions.py"), + "--decisions", str(ROOT / "decisions.yaml"), "--root", tmp, + "--changed-files", tmp], capture_output=True, text=True) + report = proc.stdout + proc.stderr + self.assertIn("Traceback (most recent call last)", report) + failures, errors = ev.decision_verdict(report, {"exit_code": proc.returncode}) + self.assertEqual(failures, []) + self.assertEqual(len(errors), 1) + self.assertTrue(errors[0].startswith("checker error: check_decisions.py crashed")) + + def test_nonzero_exit_with_empty_output_is_a_checker_error(self): + for code in (1, 137): + with self.subTest(exit_code=code): + failures, errors = ev.decision_verdict("", {"exit_code": code}) + self.assertEqual(failures, []) + self.assertTrue(errors and errors[0].startswith("checker error")) + + def test_exit_zero_without_a_result_line_is_a_checker_error(self): + self.assertTrue(ev.decision_verdict("", {"exit_code": 0})[1]) + + def test_invalid_register_and_mismatched_counts_are_checker_errors(self): + self.assertTrue(ev.decision_verdict("INVALID decisions file:\nA: bad", {"exit_code": 2})[1][0] + .startswith("checker error: check_decisions.py exit 2")) + mismatched = TWO.replace("result: 2", "result: 3") + self.assertTrue(ev.decision_verdict(mismatched, {"exit_code": 1})[1]) + self.assertTrue(ev.decision_verdict(CLEAN, {"exit_code": 1})[1]) + + def test_missing_or_unreadable_status_is_a_checker_error(self): + for status in (None, {}, {"exit_code": "1"}, {"exit_code": True}): + with self.subTest(status=status): + self.assertTrue(ev.decision_verdict(CLEAN, status)[1][0].startswith( + "checker error: decisions status file missing")) + + def run_main(self, tmp, report=None, status=None, status_path=None): + event, changed = Path(tmp, "event.json"), Path(tmp, "changed.txt") + event.write_text(json.dumps({"pull_request": pr()}), encoding="utf-8") + changed.write_text("src/node.py\n", encoding="utf-8") + args = ["--event", str(event), "--changed-files", str(changed), + "--maintainers", str(ROOT / "maintainers.yaml"), "--output", str(Path(tmp, "s.md")), + "--decisions-report", str(Path(tmp, "decisions.txt"))] + if report is not None: + Path(tmp, "decisions.txt").write_text(report, encoding="utf-8") + if status is not None: + Path(tmp, "status.json").write_text(json.dumps(status), encoding="utf-8") + args += ["--decisions-status", str(status_path or Path(tmp, "status.json"))] + with contextlib.redirect_stdout(io.StringIO()): + code = ev.main(args) + return code, Path(tmp, "s.md").read_text(encoding="utf-8") + + def test_missing_status_file_fails_closed(self): + with tempfile.TemporaryDirectory() as tmp: + code, summary = self.run_main(tmp, report=CLEAN, status_path=Path(tmp, "absent.json")) + self.assertEqual(code, 1) + self.assertIn("PR evidence check: CHECKER ERROR", summary) + self.assertIn("decisions status file missing", summary) + + def test_missing_report_file_fails_closed(self): + with tempfile.TemporaryDirectory() as tmp: + code, summary = self.run_main(tmp, status={"exit_code": 0}) + self.assertEqual(code, 1) + self.assertIn("PR evidence check: CHECKER ERROR", summary) + + def test_clean_run_through_main_passes(self): + with tempfile.TemporaryDirectory() as tmp: + code, summary = self.run_main(tmp, report=CLEAN, status={"exit_code": 0}) + self.assertEqual(code, 0, summary) + self.assertIn("PR evidence check: PASS", summary) + + def test_pr_assistant_step_records_status_of_a_crashing_checker(self): + workflow = yaml.safe_load((ROOT / "rollout" / "workflows" / "pr-assistant.yml").read_text(encoding="utf-8")) + steps = {s.get("name"): s for s in workflow["jobs"]["evidence"]["steps"]} + self.assertIn("--decisions-status decisions-status.json", steps["Evidence check and summary comment"]["run"]) + script = steps["Decisions of record on the diff"]["run"] + with tempfile.TemporaryDirectory() as tmp: + tools = Path(tmp, "harness", "tools") + tools.mkdir(parents=True) + (tools / "check_decisions.py").write_text("raise RuntimeError('simulated checker crash')\n", + encoding="utf-8") + Path(tmp, "pr-head").mkdir() + Path(tmp, "changed_existing.txt").write_text("README.md\n", encoding="utf-8") + proc = subprocess.run(["bash", "-eo", "pipefail", "-c", script], cwd=tmp, capture_output=True, + text=True, env={"PATH": os.environ["PATH"], "REPOSITORY": "x"}) + self.assertEqual(proc.returncode, 0, proc.stderr) + status = json.loads(Path(tmp, "decisions-status.json").read_text(encoding="utf-8")) + report = Path(tmp, "decisions.txt").read_text(encoding="utf-8") + self.assertEqual(status, {"exit_code": 1}) + failures, errors = ev.decision_verdict(report, status) + self.assertEqual(failures, []) + self.assertTrue(errors[0].startswith("checker error: check_decisions.py crashed")) + + +class Comment(unittest.TestCase): + def test_render_checker_error_verdict(self): + text = ev.render([], [], [], pr(), ["checker error: x"]) + self.assertIn("PR evidence check: CHECKER ERROR", text) + self.assertIn("Checker errors (fails closed", text) + + def test_render_has_marker_and_status(self): + text = ev.render(["x"], [], [], pr()) + self.assertTrue(text.startswith(ev.MARKER)) + self.assertIn("PR evidence check: FAIL", text) + self.assertIn("never approves or merges", text) + + def test_upsert_updates_existing_comment(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url)) + if method == "GET": + return [{"id": 1, "body": "other"}, {"id": 2, "body": ev.MARKER + " old"}] + return {} + + self.assertEqual(ev.upsert_comment("o/r", 7, "new", "t", call=fake), "updated") + self.assertEqual(calls[-1], ("PATCH", "https://api.github.com/repos/o/r/issues/comments/2")) + + def test_upsert_creates_when_absent(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url)) + return [] if method == "GET" else {} + + self.assertEqual(ev.upsert_comment("o/r", 7, "new", "t", call=fake), "created") + self.assertEqual(calls[-1], ("POST", "https://api.github.com/repos/o/r/issues/7/comments")) + + +class CommandLine(unittest.TestCase): + def test_main_exit_codes(self): + with tempfile.TemporaryDirectory() as tmp: + event, changed = Path(tmp, "event.json"), Path(tmp, "changed.txt") + changed.write_text("src/node.py\n", encoding="utf-8") + event.write_text(json.dumps({"pull_request": pr()}), encoding="utf-8") + args = ["--event", str(event), "--changed-files", str(changed), + "--maintainers", str(ROOT / "maintainers.yaml"), "--output", str(Path(tmp, "s.md"))] + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(ev.main(args), 0) + event.write_text(json.dumps({"pull_request": pr(body="## Summary\nx\n")}), encoding="utf-8") + self.assertEqual(ev.main(args), 1) + self.assertIn(ev.MARKER, Path(tmp, "s.md").read_text(encoding="utf-8")) + event.write_text(json.dumps({"push": {}}), encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(ev.main(args), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check_public_extract.py b/tests/test_check_public_extract.py new file mode 100644 index 0000000..6663048 --- /dev/null +++ b/tests/test_check_public_extract.py @@ -0,0 +1,190 @@ +"""Tests for tools/check_public_extract.py. + +Credential-like strings are assembled at run time so that this file itself +contains nothing a secret scanner would report. +""" +import contextlib +import io +import os +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_public_extract as pe # noqa: E402 + +AT = "@" +DRIVE = "https://" + "drive" + ".google.com/file/d/abc123/view" +DOCS = "https://" + "docs" + ".google.com/document/d/xyz/edit" +TOKEN = "gh" + "p_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0" +KEY = "AK" + "IA" + "ABCDEFGHIJKLMNOP" + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class Rules(unittest.TestCase): + def hits(self, text, rel="docs/page.md", allow=(), repository=None): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return [(f["rule"], f["found"]) for f in pe.scan(tmp, list(allow), repository)] + + def test_google_drive_and_docs_links(self): + self.assertEqual(self.hits(f"See [plan]({DRIVE}) and {DOCS}.\n"), + [("google-drive-link", DRIVE), ("google-drive-link", DOCS)]) + + def test_email(self): + self.assertEqual(self.hits(f"Contact jane.doe{AT}example-lab.org today.\n"), + [("email", f"jane.doe{AT}example-lab.org")]) + + def test_email_like_code_is_not_an_email(self): + self.assertEqual(self.hits(f"ros_type ROS_TYPE{AT}gz.msgs.GzType\n"), []) + + def test_phone(self): + self.assertEqual([r for r, _ in self.hits("Call +49 170 1234567 or tel:+357-22-123456.\n")], + ["phone", "phone"]) + + def test_versions_dates_and_shas_are_not_phones(self): + self.assertEqual(self.hits("v1.0.236, 2026-09-28, 8ce9314fa9a404564fa7e954cd84f25bcba2b829, 0.4075 m\n"), []) + + def test_prices(self): + found = [f for _, f in self.hits("Costs €1,475 or USD 2,049.99 or 300 EUR or $5/mo.\n")] + self.assertEqual(found, ["€1,475", "USD 2,049.99", "300 EUR", "$5/mo"]) + + def test_shell_variables_are_not_prices(self): + self.assertEqual(self.hits("Run `echo $HOME` and ${VAR}.\n"), []) + + def test_credentials(self): + text = f"token {TOKEN}\naws {KEY}\napi_key = \"s3cr3tvalue99\"\n-----BEGIN RSA PRIVATE KEY-----\n" + self.assertEqual([r for r, _ in self.hits(text)], ["credential"] * 4) + + def test_scope(self): + text = f"mail a.person{AT}lab.org\n" + self.assertEqual(len(self.hits(text, "README.md")), 1) + self.assertEqual(len(self.hits(text, "pkg/README.md")), 1) + self.assertEqual(len(self.hits(text, "assets/diagram.html")), 1) + self.assertEqual(len(self.hits(text, "web/public/app.js")), 1) + self.assertEqual(self.hits(text, "src/module.py"), []) + self.assertEqual(self.hits(text, "docs/image.png"), []) + + def test_allowlist_rule_match_path_and_repository(self): + allow = pe.load_allowlist(self.write_allow( + "allow:\n - {rule: email, match: 'a\\.person@lab\\.org', paths: ['docs/*']," + " repositories: ['docs-repo'], reason: organization contact}\n")) + text = f"mail a.person{AT}lab.org\n" + self.assertEqual(self.hits(text, allow=allow, repository="docs-repo"), []) + self.assertEqual(len(self.hits(text, allow=allow, repository="other-repo")), 1) + self.assertEqual(len(self.hits(text, "README.md", allow=allow, repository="docs-repo")), 1) + + def test_allowlist_needs_reason(self): + with self.assertRaisesRegex(ValueError, "reason"): + pe.load_allowlist(self.write_allow("allow:\n - {rule: email, match: 'x'}\n")) + + def test_organization_allowlist_loads_and_exempts_placeholders(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + self.assertEqual(self.hits('API_KEY="sk-ant-your-key-here"\n', allow=allow), []) + self.assertEqual(self.hits(f"info{AT}botshare.ai\n", allow=allow), []) + self.assertEqual(self.hits(f"someone{AT}botshare.ai\n", allow=allow), []) + + def test_organization_allowlist_admits_only_notice_lines(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + self.assertEqual(self.hits(f" * @author A. Writer - writer{AT}uni.example-lab.org\n", "web/public/lib.js", allow=allow), []) + self.assertEqual(self.hits(f"Copyright (c) 2014 A. Writer , MIT License\n", "README.md", allow=allow), []) + self.assertEqual(len(self.hits(f"Write to writer{AT}lab.org for a quote.\n", "README.md", allow=allow)), 1) + + def test_public_pricing_is_allowed_only_where_approved(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + row = "| First Mover - \u20ac5 | tier |\n" + self.assertEqual(self.hits(row, "README.md", allow=allow, repository=".github"), []) + self.assertEqual(self.hits(row, "profile/README.md", allow=allow, repository=".github"), []) + self.assertEqual(len(self.hits(row, "README.md", allow=allow, repository="openamrobot-ui")), 1) + self.assertEqual(len(self.hits(row, "docs/page.md", allow=allow, repository=".github")), 1) + + def test_approved_public_contacts(self): + allow = pe.load_allowlist(ROOT / "public-extract-allowlist.yaml") + allowed = [ + (f"Write to alex{AT}botshare.ai\n", "profile/README.md"), + (f"Ops: team{AT}mail.botshare.ai\n", "docs/page.md"), + (f"Signed-off-by: A. Writer \n", "docs/page.md"), + (f"Co-authored-by: B. Coder \n", "docs/page.md"), + (f"- B. Coder, coder{AT}lab.org\n", "CONTRIBUTORS.md"), + (f"> sales{AT}zd-motor.com\n", "datasheets/ZDmotor/README.md"), + (f"> trade26{AT}zd-motor.com\n", "datasheets/ZDmotor/README.md"), + (f"Support: support{AT}vendor.cn\n", "datasheets/vendor/README.md"), + ] + for text, rel in allowed: + with self.subTest(allowed=text): + self.assertEqual(self.hits(text, rel, allow=allow, repository="openamr-platform-hw"), []) + flagged = [ + (f"Contact: finkle{AT}zlingkj.com\n", "datasheets/ZLTech/README.md", "email"), + (f"Contact: salesperson{AT}vendor.cn\n", "datasheets/vendor/README.md", "email"), + (f"Write to alex{AT}botshare-ai.com\n", "docs/page.md", "email"), + (f"Write to alex{AT}botshare.ai.example.org\n", "docs/page.md", "email"), + (f"Write to alex{AT}notbotshare.ai\n", "docs/page.md", "email"), + (f"> sales{AT}zd-motor.com\n", "docs/suppliers.md", "email"), + ("Wheel: ZLLG80ASM250-L V1.0 - 115USD/1pc\n", "datasheets/ZLTech/README.md", "price"), + ("Driver: \u20ac145 per piece\n", "datasheets/ZLTech/README.md", "price"), + ] + for text, rel, rule in flagged: + with self.subTest(flagged=text): + found = self.hits(text, rel, allow=allow, repository="openamr-platform-hw") + self.assertEqual([r for r, _ in found], [rule]) + + def test_handles_are_not_emails(self): + self.assertEqual(self.hits("Reviewed by @BotshareAI and @panthera-momagdii.\n"), []) + + def write_allow(self, text): + tmp = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) + tmp.write(text) + tmp.close() + self.addCleanup(Path(tmp.name).unlink) + return tmp.name + + +class CommandLine(unittest.TestCase): + def run_main(self, *args): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + return pe.main([str(a) for a in args]), out.getvalue() + + def test_exit_codes_and_changed_files(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "docs").mkdir() + Path(tmp, "docs/a.md").write_text(f"see {DRIVE}\n", encoding="utf-8") + Path(tmp, "docs/b.md").write_text("clean\n", encoding="utf-8") + code, out = self.run_main("--root", tmp) + self.assertEqual(code, 1) + self.assertIn("Should not be public: google-drive-link (1 finding(s))", out) + self.assertIn("\n docs/a.md:1: found 'https://drive.google.com", out) + self.assertIn("\n Rule: Public files do not link to internal Google Drive", out) + self.assertIn("\n Fix: Remove the link", out) + self.assertIn("WATCHDOG.md#public-extract", out) + self.assertIn("Public extract summary: 1 finding(s) (google-drive-link 1)", out) + self.assertIn("result: 1 finding(s)", out) + changed = Path(tmp, "changed.txt") + changed.write_text("docs/b.md\n", encoding="utf-8") + self.assertEqual(self.run_main("--root", tmp, "--changed-files", changed)[0], 0) + + def test_every_rule_has_guidance(self): + self.assertEqual(set(pe.GUIDE), set(pe.RULES)) + + def test_invalid_allowlist_is_usage_error(self): + with tempfile.TemporaryDirectory() as tmp: + bad = Path(tmp, "allow.yaml") + bad.write_text("allow:\n - {rule: nosuchrule, match: x, reason: y}\n", encoding="utf-8") + self.assertEqual(self.run_main("--root", tmp, "--allowlist", bad)[0], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check_workflow_policy.py b/tests/test_check_workflow_policy.py new file mode 100644 index 0000000..fd20b23 --- /dev/null +++ b/tests/test_check_workflow_policy.py @@ -0,0 +1,84 @@ +"""Tests for tools/check_workflow_policy.py.""" +import contextlib +import io +import os +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_workflow_policy as policy # noqa: E402 + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class WorkflowPolicy(unittest.TestCase): + def write(self, root, path, text): + target = Path(root, path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + def test_full_sha_and_local_workflow_pass(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, ".github/workflows/ok.yml", """ +name: ok +jobs: + build: + uses: ./.github/workflows/reusable.yml + test: + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +""") + self.assertEqual(policy.findings(tmp), []) + + def test_tag_branch_and_placeholder_fail(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, ".github/workflows/bad.yml", """ +jobs: + build: + steps: + - uses: actions/checkout@v4 + - uses: actions/upload-artifact@main + - uses: owner/action + - run: echo +""") + errors = policy.findings(tmp) + self.assertEqual(len(errors), 4) + self.assertTrue(any("full commit SHA" in e for e in errors)) + self.assertTrue(any("no immutable" in e for e in errors)) + self.assertTrue(any("" in e for e in errors)) + + def test_command_line_explains_each_finding(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, ".github/workflows/bad.yml", "jobs:\n b:\n steps:\n - uses: actions/checkout@v4\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = policy.main(["--root", tmp]) + text = out.getvalue() + self.assertEqual(code, 1) + self.assertIn("Workflow not pinned: unpinned-action (1 finding(s))", text) + self.assertIn("\n .github/workflows/bad.yml:4: found ", text) + self.assertIn("\n Rule: ", text) + self.assertIn("\n Fix: ", text) + self.assertIn("Workflow policy summary: 1 finding(s) (unpinned-action 1)", text) + self.assertIn("result: 1 workflow policy finding(s)", text) + + def test_rollout_examples_are_outside_scope(self): + with tempfile.TemporaryDirectory() as tmp: + self.write(tmp, "rollout/workflows/example.yml", + "uses: actions/checkout@\n") + self.assertEqual(policy.findings(tmp), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_line_endings.py b/tests/test_line_endings.py new file mode 100644 index 0000000..c77e1de --- /dev/null +++ b/tests/test_line_endings.py @@ -0,0 +1,49 @@ +"""Shell scripts keep LF line endings on every checkout (.gitattributes).""" +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def git(*args, cwd=ROOT): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout + + +def shell_scripts(): + scripts = [] + for rel in git("ls-files", "-z").split("\0"): + path = ROOT / rel + if rel and path.is_file(): + with path.open("rb") as handle: + first = handle.readline(80) + if rel.endswith((".sh", ".bash")) or (first.startswith(b"#!") and b"sh" in first): + scripts.append(rel) + return scripts + + +class LineEndings(unittest.TestCase): + def test_every_shell_script_is_marked_lf(self): + scripts = shell_scripts() + self.assertIn("rollout/verify.sh", scripts) + for rel in scripts: + with self.subTest(script=rel): + self.assertTrue(git("check-attr", "eol", "--", rel).strip().endswith("eol: lf"), rel) + + def test_autocrlf_checkout_keeps_lf(self): + with tempfile.TemporaryDirectory() as tmp: + src, dst = Path(tmp, "src"), Path(tmp, "dst") + (src / "rollout").mkdir(parents=True) + shutil.copy(ROOT / ".gitattributes", src / ".gitattributes") + (src / "rollout" / "verify.sh").write_bytes(b"#!/usr/bin/env bash\necho ok\n") + git("init", "-q", cwd=src) + git("add", ".", cwd=src) + git("-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-m", "t", cwd=src) + git("-c", "core.autocrlf=true", "clone", "-q", "--config", "core.autocrlf=true", str(src), str(dst), cwd=tmp) + self.assertNotIn(b"\r\n", (dst / "rollout" / "verify.sh").read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_org_scan.py b/tests/test_org_scan.py new file mode 100644 index 0000000..77ff3d2 --- /dev/null +++ b/tests/test_org_scan.py @@ -0,0 +1,306 @@ +"""Tests for the Watchdog organization scan: rollout/repositories.yaml and its workflow. + +The scan step is taken from the workflow file and run with a fake `git` that creates small +local repositories, so no network is used. +""" +import contextlib +import io +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import watchdog_issue_sync as wis # noqa: E402 +LIST = ROOT / "rollout" / "repositories.yaml" +WORKFLOW = ROOT / ".github" / "workflows" / "watchdog-org-scan.yml" +ACTIVE = {".github", "openamr-platform-sw", "openamr-platform-fw", "openamr-platform-hw", + "openamr-upperbody-sw", "openamr-upperbody-fw", "openamr-upperbody-hw", + "openamrobot-interfaces", "openamrobot-manipulation", "openamrobot-ui", "openamrobot-comm", + "openamrobot-docs", "openamrobot-manifest", "openamrobot-release"} + +FAKE_GIT = """#!/bin/bash +# clone --quiet --depth 1 --branch ; rev-parse prints a fixed SHA +if [ "$1" = clone ]; then + dest="${@: -1}" + for fail in ${FAIL_CLONES-openamrobot-comm}; do + case "$dest" in */"$fail") exit 128;; esac + done + mkdir -p "$dest/docs" + echo "Compute is a Raspberry Pi 5." > "$dest/docs/a.md" + exit 0 +fi +if [ "$1" = -C ] && [ "$3" = rev-parse ]; then echo abc1234; exit 0; fi +exec "$REAL_GIT" "$@" +""" + + +def workflow(): + data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + return data, data.get(True, data.get("on")) + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class RepositoryList(unittest.TestCase): + def test_lists_all_active_repositories_once(self): + data = yaml.safe_load(LIST.read_text(encoding="utf-8")) + self.assertEqual(data["organization"], "openAMRobot") + names = [r["name"] for r in data["repositories"]] + self.assertEqual(len(names), 14) + self.assertEqual(len(set(names)), len(names), "duplicate repository") + self.assertEqual(set(names), ACTIVE) + for r in data["repositories"]: + with self.subTest(repository=r["name"]): + self.assertEqual(set(r), {"name", "default_branch"}) + self.assertRegex(r["name"], r"^[A-Za-z0-9._-]+$") + self.assertRegex(r["default_branch"], r"^[A-Za-z0-9._/-]+$") + + +class Workflow(unittest.TestCase): + def test_scans_on_thursday_and_writes_only_control_surface_issues(self): + data, on = workflow() + self.assertEqual(data["permissions"], {"contents": "read", "issues": "write"}) + self.assertEqual(set(on), {"schedule", "workflow_dispatch"}) + self.assertEqual(on["schedule"][0]["cron"], "0 14 * * 4") + self.assertEqual(on["schedule"][0]["timezone"], "Europe/Berlin") + for job in data["jobs"].values(): + self.assertNotIn("permissions", job) + + def test_never_pushes_code_or_runs_ai(self): + text = WORKFLOW.read_text(encoding="utf-8") + for forbidden in ("git push", "git commit", "gh issue", "gh pr", "pull-requests: write", + "contents: write", "anthropic", "claude-code-action"): + self.assertNotIn(forbidden, text) + self.assertIn("--report-only", text) + self.assertIn("watchdog_issue_sync.py", text) + self.assertIn("--apply", text) + self.assertIn("GITHUB_TOKEN", text) + self.assertIn("WATCHDOG.md", text) + + def test_issue_mode_comes_from_the_repository_variable(self): + data, _ = workflow() + step = next(s for s in data["jobs"]["scan"]["steps"] if s["name"] == "Synchronize Watchdog issues") + self.assertEqual(step["env"]["WATCHDOG_ISSUE_MODE"], "${{ vars.WATCHDOG_ISSUE_MODE }}") + self.assertNotIn("--mode", step["run"]) # the tool reads the variable; unset means dashboard + text = WORKFLOW.read_text(encoding="utf-8") + self.assertIn('unset or "dashboard"', text) + self.assertIn('"groups"', text) + + def test_actions_pinned_to_full_sha(self): + data, _ = workflow() + for job in data["jobs"].values(): + for step in job["steps"]: + if "uses" in step: + self.assertRegex(step["uses"], r"@[0-9a-f]{40}$") + + def test_scan_reports_every_repository_and_blocks_on_clone_failure(self): + result = run_scan() + # openamrobot-comm fails to clone in the fake: reported BLOCKED, job fails after the summary. + self.assertEqual(result.code, 1, result.output) + self.assertIn("| openamrobot-comm | BLOCKED: clone failed | not scanned |", result.summary) + self.assertIn("## OpenAMRobot Watchdog organization scan: INCOMPLETE: 13 of 14 repositories scanned", result.summary) + self.assertIn("BLOCKED repositories were not scanned and are not clean.", result.summary) + rows = re.findall(r"^\| ([.\w-]+) \| abc1234 \| (\d+) \|$", result.summary, re.M) + self.assertEqual(len(rows), 13) + self.assertIn(("openamr-platform-sw", "1"), rows) + self.assertIn("### openamr-platform-sw: 1 finding(s)", result.summary) + self.assertIn("| COMPUTE | 1 |", result.summary) + self.assertEqual(result.blocked, [{"repository": "openamrobot-comm", "reason": "clone failed"}]) + # Default dashboard mode: the summary must not promise per-group issues. + self.assertIn("Results are published to the [watchdog] Organization dashboard issue in the harness repository; " + "per-group issues only when WATCHDOG_ISSUE_MODE is groups.", result.summary) + self.assertNotIn("synchronized to deduplicated issues", result.summary) + self.assertNotIn("::warning", result.output) + + +def make_harness(tmp, repositories=None, watchdog_wrapper=None): + """A harness checkout for the scan step: links to this repository, with an optional + replacement repositories.yaml (False = missing) and an optional watchdog.py wrapper.""" + harness = Path(tmp, "harness") + harness.mkdir() + for name in ("decisions.yaml", "maintainers.yaml", "public-extract-allowlist.yaml", "agent-rules"): + os.symlink(ROOT / name, harness / name) + (harness / "rollout").mkdir() + if repositories is not False: + text = LIST.read_text(encoding="utf-8") if repositories is None else repositories + (harness / "rollout" / "repositories.yaml").write_text(text, encoding="utf-8") + tools = harness / "tools" + tools.mkdir() + for path in (ROOT / "tools").glob("*.py"): + if not (watchdog_wrapper and path.name == "watchdog.py"): + os.symlink(path, tools / path.name) + if watchdog_wrapper: + (tools / "watchdog.py").write_text(watchdog_wrapper.replace("REAL", repr(str(ROOT / "tools" / "watchdog.py"))), + encoding="utf-8") + return harness + + +class ScanResult: + def __init__(self, proc, work, summary): + self.code = proc.returncode + self.output = proc.stdout + proc.stderr + self.summary = summary + self.blocked = json.loads(Path(work, "blocked.json").read_text(encoding="utf-8")) if Path(work, "blocked.json").exists() else None + self.expected = json.loads(Path(work, "expected.json").read_text(encoding="utf-8")) if Path(work, "expected.json").exists() else None + self.reports = sorted(p.name for p in Path(work, "out").glob("*.json")) if Path(work, "out").is_dir() else [] + self.work = work + + +def run_scan(fail_clones="openamrobot-comm", repositories=None, watchdog_wrapper=None, keep=None): + """Run the workflow's "Scan repositories" step with a fake git and a fake harness.""" + data, _ = workflow() + step = next(s for s in data["jobs"]["scan"]["steps"] if s["name"] == "Scan repositories") + with tempfile.TemporaryDirectory() as tmp: + bin_dir = Path(tmp, "bin") + bin_dir.mkdir() + git = bin_dir / "git" + git.write_text(FAKE_GIT, encoding="utf-8") + git.chmod(git.stat().st_mode | stat.S_IEXEC) + make_harness(tmp, repositories, watchdog_wrapper) + summary = Path(tmp, "summary.md") + work = Path(tmp, "work") + env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", HARNESS="harness", + WORK=str(work), GITHUB_STEP_SUMMARY=str(summary), + REAL_GIT=shutil.which("git") or "/usr/bin/git", + WATCHDOG_ANNOTATIONS="0", FAIL_CLONES=fail_clones) + proc = subprocess.run(["bash", "-c", step["run"]], cwd=tmp, env=env, capture_output=True, text=True) + text = summary.read_text(encoding="utf-8") if summary.exists() else "" + result = ScanResult(proc, work, text) + if keep is not None: + shutil.copytree(work, keep, dirs_exist_ok=True) + return result + + +def sync(work, expected=True): + """Plan the issue sync on a scan's output exactly as the workflow step does (no --apply).""" + args = ["--reports", str(Path(work, "out")), "--blocked", str(Path(work, "blocked.json")), + "--maintainers", str(ROOT / "maintainers.yaml"), "--decisions", str(ROOT / "decisions.yaml"), + "--run-url", "run", "--run-date", "2026-10-08"] + if expected: + args += ["--expected", str(Path(work, "expected.json"))] + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = wis.main(args) + return code, out.getvalue() + err.getvalue() + + +WRAPPER = """import runpy, sys +args = sys.argv +repo = args[args.index("--repository") + 1] +if repo == "openamrobot-ui": + sys.exit(3) # watchdog.py fails for one repository +if repo == "openamrobot-docs": + open(args[args.index("--json") + 1], "w").write("{corrupt") # report enrichment will fail + sys.exit(0) +runpy.run_path(REAL, run_name="__main__") +""" + + +class FailClosed(unittest.TestCase): + """CI/CD review of 8 Oct: an incomplete scan is never published as a clean complete result.""" + + def test_happy_path_is_complete_and_passes(self): + with tempfile.TemporaryDirectory() as keep: + result = run_scan(fail_clones="", keep=keep) + self.assertEqual(result.code, 0, result.output) + self.assertIn("## OpenAMRobot Watchdog organization scan: COMPLETE", result.summary) + self.assertNotIn("INCOMPLETE", result.summary) + self.assertEqual(result.blocked, []) + self.assertEqual(len(result.expected), 14) + self.assertEqual(len(result.reports), 14) + code, out = sync(keep) + self.assertEqual(code, 0, out) + self.assertIn("complete", out) + self.assertNotIn("INCOMPLETE", out) + + def test_broken_repository_list_stops_the_scan(self): + for label, text in (("broken", "organization: openAMRobot\nrepositories: [\n"), ("missing", False)): + with self.subTest(case=label), tempfile.TemporaryDirectory() as keep: + result = run_scan(fail_clones="", repositories=text, keep=keep) + self.assertNotEqual(result.code, 0, result.output) + self.assertIn("## OpenAMRobot Watchdog organization scan: INCOMPLETE", result.summary) + self.assertIn("Scan INCOMPLETE: repository list could not be generated.", result.summary) + self.assertIn("::error::Scan INCOMPLETE: repository list could not be generated", result.output) + self.assertNotIn(": COMPLETE", result.summary) + self.assertIsNone(result.expected) + self.assertEqual(result.reports, []) + # The sync refuses to publish a clean dashboard without the expected list. + code, out = sync(keep) + self.assertEqual(code, 1, out) + self.assertIn("the expected repository list could not be read", out) + + def test_empty_repository_list_is_incomplete_not_a_zero_repository_scan(self): + with tempfile.TemporaryDirectory() as keep: + result = run_scan(fail_clones="", repositories="organization: openAMRobot\nrepositories: []\n", keep=keep) + self.assertNotEqual(result.code, 0, result.output) + self.assertIn("## OpenAMRobot Watchdog organization scan: INCOMPLETE", result.summary) + self.assertIn("Scan INCOMPLETE: repository list could not be generated (0 of 0 entries usable).", result.summary) + self.assertNotIn(": COMPLETE", result.summary) + self.assertEqual(result.expected, []) + # An empty expected list is unreadable for the sync, never a valid zero-repository scan. + code, out = sync(keep) + self.assertEqual(code, 1, out) + self.assertIn("the expected repository list could not be read", out) + self.assertNotIn("complete)", out) + + def test_short_repository_list_fails_and_missing_repositories_are_blocked(self): + data = yaml.safe_load(LIST.read_text(encoding="utf-8")) + del data["repositories"][3]["default_branch"] # openamr-platform-hw cannot be listed + with tempfile.TemporaryDirectory() as keep: + result = run_scan(fail_clones="", repositories=yaml.safe_dump(data), keep=keep) + self.assertEqual(result.code, 1, result.output) + self.assertIn("Scan INCOMPLETE: repository list could not be generated (13 of 14 entries usable).", result.summary) + self.assertEqual(len(result.expected), 14) + code, out = sync(keep) + self.assertEqual(code, 1, out) + self.assertIn("INCOMPLETE, 0 of 14 repositories scanned", out) + blocked, status = wis.completeness(result.expected, [], []) + self.assertEqual({b["reason"] for b in blocked}, {"no report produced"}) + self.assertIn("openamr-platform-hw", {b["repository"] for b in blocked}) + + def test_watchdog_and_enrichment_failures_block_only_their_repository(self): + with tempfile.TemporaryDirectory() as keep: + result = run_scan(fail_clones="", watchdog_wrapper=WRAPPER, keep=keep) + self.assertEqual(result.code, 1, result.output) + self.assertIn({"repository": "openamrobot-ui", "reason": "watchdog exit 3 at abc1234"}, result.blocked) + self.assertIn({"repository": "openamrobot-docs", "reason": "report enrichment failed at abc1234"}, result.blocked) + self.assertEqual(len(result.blocked), 2) + self.assertIn("| openamrobot-ui | BLOCKED: watchdog exit 3 | not scanned |", result.summary) + self.assertIn("| openamrobot-docs | BLOCKED: report enrichment failed | not scanned |", result.summary) + self.assertIn("INCOMPLETE: 12 of 14 repositories scanned", result.summary) + self.assertEqual(len(result.reports), 12) # the corrupt report is set aside, not published + self.assertNotIn("openamrobot-docs.json", result.reports) + code, out = sync(keep) + self.assertEqual(code, 1, out) + self.assertIn("INCOMPLETE, 12 of 14 repositories scanned", out) + + def test_step_runs_with_errexit_and_pipefail(self): + data, _ = workflow() + for name in ("Scan repositories", "Synchronize Watchdog issues"): + step = next(s for s in data["jobs"]["scan"]["steps"] if s["name"] == name) + self.assertIn("set -euo pipefail", step["run"], name) + sync_step = next(s for s in data["jobs"]["scan"]["steps"] if s["name"] == "Synchronize Watchdog issues") + self.assertEqual(sync_step["if"], "always()") + self.assertIn('--expected "$WORK/expected.json"', sync_step["run"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reusable_workflow.py b/tests/test_reusable_workflow.py new file mode 100644 index 0000000..33b8931 --- /dev/null +++ b/tests/test_reusable_workflow.py @@ -0,0 +1,143 @@ +"""Run the harness steps of repository-quality-reusable.yml in enforce and warn-only mode. + +The step scripts are taken from the workflow file itself and run with a fake harness whose +checkers print a finding and exit with a chosen status. This covers rollout step (c) +(harness_warn: report, never fail) and step (d) (harness_checks: true, blocking). +""" +import os +import subprocess +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "repository-quality-reusable.yml" +HARNESS_STEPS = ["Check out OpenAMRobot harness", "Prepare harness checks", "Workflow policy", + "Decisions of record", "Public extract", "Shared agent rules"] + + +def load(): + data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + inputs = data[True]["workflow_call"]["inputs"] if True in data else data["on"]["workflow_call"]["inputs"] + steps = {s["name"]: s for s in data["jobs"]["repository-quality"]["steps"]} + return inputs, steps + + +FAKE = """import os, sys +print("{line}") +# Stand-in for tools/watchdog_report.emit_github: annotate at the level the step chose. +print("::" + os.environ.get("WATCHDOG_ANNOTATION", "unset") + " file=a.md,line=1::finding") +print("result: 1 contradiction(s), 0 allowed, scope full checkout") +sys.exit({code}) +""" + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class HarnessModes(unittest.TestCase): + def setUp(self): + self.inputs, self.steps = load() + + def run_step(self, name, code, enforce, event="pull_request", tool="check_decisions.py", + agents=True, exception=""): + with tempfile.TemporaryDirectory() as tmp: + tools = Path(tmp, ".openamrobot-harness", "tools") + tools.mkdir(parents=True) + for t in ("check_decisions.py", "check_public_extract.py", "check_agent_rules.py", + "check_workflow_policy.py"): + line = ("Mismatch with approved decision: X (1 finding(s))" if t == "check_decisions.py" + else "Should not be public: price (1 finding(s))") + (tools / t).write_text(FAKE.format(line=line, code=code if t == tool else 0), encoding="utf-8") + if agents: + Path(tmp, "AGENTS.md").write_text("x\n", encoding="utf-8") + Path(tmp, "changed-files.txt").write_text("a.md\n", encoding="utf-8") + env = dict(os.environ, EVENT_NAME=event, REPOSITORY="demo", + ENFORCE="true" if enforce else "false", + AGENTS_MD_EXCEPTION=exception, RUNNER_TEMP=tmp) + proc = subprocess.run(["bash", "-c", self.steps[name]["run"]], cwd=tmp, env=env, + capture_output=True, text=True) + return proc.returncode, proc.stdout + proc.stderr + + def test_inputs_default_off(self): + self.assertFalse(self.inputs["harness_checks"]["default"]) + self.assertFalse(self.inputs["harness_warn"]["default"]) + + def test_full_history_only_when_harness_checks_run(self): + expr = str(self.steps["Check out repository"]["with"]["fetch-depth"]) + inner = expr.strip() + self.assertTrue(inner.startswith("${{") and inner.endswith("}}"), expr) + inner = inner[3:-2].replace("&&", " and ").replace("||", " or ") + for checks in (False, True): + for warn in (False, True): + depth = eval(inner.replace("inputs.harness_checks", str(checks)) # noqa: S307 - test-only + .replace("inputs.harness_warn", str(warn)), {}) + with self.subTest(harness_checks=checks, harness_warn=warn): + self.assertEqual(str(depth), "0" if (checks or warn) else "1") + + def test_every_harness_step_runs_in_either_mode(self): + for name in HARNESS_STEPS: + self.assertEqual(self.steps[name]["if"], "inputs.harness_checks || inputs.harness_warn", name) + + def test_enforce_blocks_a_pull_request_finding(self): + for name, tool in (("Decisions of record", "check_decisions.py"), ("Public extract", "check_public_extract.py")): + with self.subTest(step=name): + code, out = self.run_step(name, 1, enforce=True, tool=tool) + self.assertEqual(code, 1, out) + + def test_warn_only_reports_but_never_fails(self): + for name, tool in (("Decisions of record", "check_decisions.py"), ("Public extract", "check_public_extract.py"), + ("Shared agent rules", "check_agent_rules.py")): + for status in (1, 2): + with self.subTest(step=name, status=status): + code, out = self.run_step(name, status, enforce=False, tool=tool) + self.assertEqual(code, 0, out) + self.assertIn("::warning::", out) + + def test_enforce_on_push_warns_but_fails_on_invalid_register(self): + code, out = self.run_step("Decisions of record", 1, enforce=True, event="push") + self.assertEqual(code, 0, out) + self.assertIn("::warning file=a.md,line=1::", out) + self.assertEqual(self.run_step("Decisions of record", 2, enforce=True, event="push")[0], 2) + + def test_annotation_level_follows_enforcement(self): + for name, tool in (("Decisions of record", "check_decisions.py"), ("Public extract", "check_public_extract.py"), + ("Workflow policy", "check_workflow_policy.py")): + with self.subTest(step=name): + self.assertIn("::error file=a.md,line=1::", self.run_step(name, 1, enforce=True, tool=tool)[1]) + self.assertIn("::warning file=a.md,line=1::", + self.run_step(name, 1, enforce=True, event="push", tool=tool)[1]) + self.assertIn("::warning file=a.md,line=1::", self.run_step(name, 1, enforce=False, tool=tool)[1]) + self.assertIn("::error file=a.md,line=1::", + self.run_step("Shared agent rules", 1, enforce=True, tool="check_agent_rules.py")[1]) + self.assertIn("::warning file=a.md,line=1::", + self.run_step("Shared agent rules", 1, enforce=False, tool="check_agent_rules.py")[1]) + + def test_enforce_fails_on_shared_rules_drift(self): + self.assertEqual(self.run_step("Shared agent rules", 1, enforce=True, tool="check_agent_rules.py")[0], 1) + + def test_enforce_requires_agents_file_or_exception(self): + code, out = self.run_step("Shared agent rules", 0, enforce=True, agents=False) + self.assertEqual(code, 1, out) + self.assertIn("AGENTS.md is required", out) + code, out = self.run_step("Shared agent rules", 0, enforce=True, agents=False, + exception="legacy repository; migration tracked in #43") + self.assertEqual(code, 0, out) + self.assertIn("AGENTS.md exception", out) + + def test_clean_run_passes_in_both_modes(self): + for enforce in (True, False): + self.assertEqual(self.run_step("Decisions of record", 0, enforce=enforce)[0], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_audit_issues.py b/tests/test_sync_audit_issues.py new file mode 100644 index 0000000..ade8f42 --- /dev/null +++ b/tests/test_sync_audit_issues.py @@ -0,0 +1,145 @@ +"""Tests for tools/sync_audit_issues.py. All rows are synthetic.""" +import contextlib +import csv +import io +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import sync_audit_issues as sai # noqa: E402 + +MAINTAINERS = yaml.safe_load((ROOT / "maintainers.yaml").read_text(encoding="utf-8")) +FIELDS = ["id", "severity", "area", "source_a", "value_a", "source_b", "value_b", + "decision_of_record", "fix", "file_to_change", "owner"] + + +def row(fid, severity="Major", target="openamr-platform-sw ros2/src/pkg/launch/a.launch.py", **kw): + base = dict.fromkeys(FIELDS, "") + base.update(id=fid, severity=severity, area="topic", file_to_change=target, + fix="synthetic private fix text", owner="Synthetic Person", value_a="synthetic private quote") + base.update(kw) + return base + + +def issue(repo, number, fid, state="open"): + return {"repository": repo, "number": number, "title": f"[audit] {fid}: topic", "state": state} + + +class Plan(unittest.TestCase): + def run_plan(self, rows, existing=()): + return sai.plan(rows, list(existing), MAINTAINERS, "audits", "2099-01-01-alignment-audit@abc1234") + + def test_opens_blocker_and_major_only(self): + to_open, _ = self.run_plan([row("ELE-901", "Blocker"), row("SW-902", "Minor"), row("GEO-903", "Question")]) + self.assertEqual([i["title"] for i in to_open], ["[audit] ELE-901: topic"]) + self.assertEqual(to_open[0]["repository"], "openamr-platform-sw") + self.assertEqual(to_open[0]["labels"], ["audit-finding", "blocker"]) + + def test_owner_from_maintainers_map_not_from_csv(self): + to_open, _ = self.run_plan([row("SW-901"), row("DOC-901", target="openamrobot-docs docs/a.md")]) + self.assertIn("Owner: @panthera-momagdii (software-lead)", to_open[0]["body"]) + self.assertIn("Owner: @anandgawai123456-glitch (docs-owner)", to_open[1]["body"]) + self.assertNotIn("Synthetic Person", to_open[0]["body"] + to_open[1]["body"]) + + def test_role_without_handle_mentions_nobody(self): + maintainers = {**MAINTAINERS, "roles": {**MAINTAINERS["roles"], "docs-owner": {"handle": None}}} + to_open, _ = sai.plan([row("DOC-901", target="openamrobot-docs docs/a.md")], [], maintainers, + "audits", "2099-01-01-alignment-audit@abc1234") + self.assertIn("Owner: docs-owner, no handle recorded", to_open[0]["body"]) + + def test_public_issue_is_an_extract(self): + body = self.run_plan([row("SW-901")])[0][0]["body"] + self.assertNotIn("synthetic private quote", body) + self.assertNotIn("synthetic private fix", body) + self.assertIn("`ros2/src/pkg/launch/a.launch.py`", body) + + def test_findings_without_public_target_go_to_private_fallback(self): + to_open, _ = self.run_plan([row("TEAM-901", target="plan document only")]) + self.assertEqual(to_open[0]["repository"], "audits") + self.assertIn("synthetic private quote", to_open[0]["body"]) + + def test_existing_issue_is_not_duplicated(self): + self.assertEqual(self.run_plan([row("ELE-901", "Blocker")], [issue("openamr-platform-sw", 5, "ELE-901")]), ([], [])) + + def test_no_longer_detected_is_commented_never_closed(self): + existing = [issue("openamr-platform-sw", 5, "ELE-901"), issue("openamrobot-docs", 9, "DOC-901"), + issue("openamrobot-docs", 3, "DOC-902", "closed"), + {"repository": "openamrobot-docs", "number": 4, "title": "Unrelated", "state": "open"}] + _, notify = self.run_plan([row("ELE-901", status="resolved")], existing) + self.assertEqual(sorted((i["repository"], i["number"]) for i in notify), + [("openamr-platform-sw", 5), ("openamrobot-docs", 9)]) + + +class Api(unittest.TestCase): + def test_apply_creates_and_comments_but_never_closes(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url)) + return [] if method == "GET" else {} + + sai.apply("org", [{"repository": "r", "title": "t", "body": "b", "labels": ["audit-finding"]}], + [{"repository": "r", "number": 2}], "tok", "run@abc", call=fake) + self.assertEqual(calls, [ + ("POST", "https://api.github.com/repos/org/r/issues"), + ("GET", "https://api.github.com/repos/org/r/issues/2/comments?per_page=100"), + ("POST", "https://api.github.com/repos/org/r/issues/2/comments"), + ]) + self.assertFalse(any(m == "PATCH" for m, _ in calls)) + + def test_no_longer_detected_comment_is_posted_once(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append(method) + return [{"body": sai.NOT_DETECTED + " earlier"}] if method == "GET" else {} + + sai.apply("org", [], [{"repository": "r", "number": 2}], "tok", "run", call=fake) + self.assertEqual(calls, ["GET"]) + + def test_fetch_existing_parses_search(self): + item = {"repository_url": "https://api.github.com/repos/org/r", "number": 1, + "title": "[audit] SW-901: x", "state": "open"} + got = sai.fetch_existing("org", "tok", call=lambda m, u, t, d=None: {"items": [item]}) + self.assertEqual(got, [{"repository": "r", "number": 1, "title": "[audit] SW-901: x", "state": "open"}]) + + +class CommandLine(unittest.TestCase): + def test_review_warnings_are_reported(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, "decisions.yaml") + path.write_text( + "decisions:\n" + " - id: OLD\n" + " review_by: 2026-10-06\n" + " - id: CURRENT\n" + " review_by: 2026-10-08\n", + encoding="utf-8", + ) + self.assertEqual( + sai.review_warnings(path, sai.date(2026, 10, 7)), + ["OLD review_by 2026-10-06 is past due"], + ) + + def test_dry_run_from_csv(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp, "ISSUES.csv") + with open(path, "w", newline="", encoding="utf-8") as stream: + w = csv.DictWriter(stream, fieldnames=FIELDS) + w.writeheader() + w.writerows([row("ELE-901", "Blocker"), row("SW-902", "Minor")]) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = sai.main(["--issues", str(path), "--maintainers", str(ROOT / "maintainers.yaml"), + "--fallback-repository", "audits", "--report", "x@1"]) + self.assertEqual(code, 0) + self.assertIn("plan: 1 to open, 0 to comment 'no longer detected', 0 closed", out.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..222b735 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,88 @@ +"""Structural tests for issue forms, agent prompts and harness text files.""" +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +FORMS = ROOT / ".github" / "ISSUE_TEMPLATE" +PROMPTS = ROOT / "agent-prompts" +# Files written for the harness; plain typography is a rule for them. +HARNESS_FILES = [ + "AGENTS.md", "agent-rules/SHARED_RULES.md", "CONTRIBUTING.md", "decisions.yaml", + "maintainers.yaml", "public-extract-allowlist.yaml", "agent-runs.md", + ".github/PULL_REQUEST_TEMPLATE.md", *[f"agent-prompts/{p.name}" for p in PROMPTS.glob("*.md")], + *[str(p.relative_to(ROOT)) for p in (ROOT / "rollout").rglob("*") if p.is_file()], + *[str(p.relative_to(ROOT)) for p in (ROOT / "tools").glob("*.py")], +] + + +class IssueForms(unittest.TestCase): + def load(self, name): + return yaml.safe_load((FORMS / name).read_text(encoding="utf-8")) + + def test_every_form_is_valid(self): + for path in FORMS.glob("*.yml"): + if path.name == "config.yml": + continue + with self.subTest(form=path.name): + form = self.load(path.name) + self.assertTrue(form["name"] and form["description"] and form["body"]) + ids = [item["id"] for item in form["body"] if "id" in item] + self.assertEqual(len(ids), len(set(ids))) + + def test_labels_used_by_automation(self): + self.assertIn("harness", self.load("harness_mistake.yml")["labels"]) + self.assertIn("good first issue", self.load("good_first_issue.yml")["labels"]) + self.assertIn("contract-change", self.load("contract_change_request.yml")["labels"]) + self.assertIn("decision-review", self.load("decision_review.yml")["labels"]) + self.assertIn("watchdog-review", self.load("decision_review.yml")["labels"]) + + def test_decision_review_requires_source_and_ground_truth(self): + form = self.load("decision_review.yml") + ids = {item.get("id") for item in form["body"]} + self.assertTrue({"decision_id", "outcome", "source_key", "evidence", "requested_action", "ground_truth"} <= ids) + text = (FORMS / "decision_review.yml").read_text(encoding="utf-8") + self.assertIn("never changes the", text) + self.assertIn("private Drive links", text) + + def test_bug_report_asks_for_sha_and_commands(self): + ids = {item.get("id") for item in self.load("bug_report.yml")["body"]} + self.assertTrue({"repository", "commit", "reproduce", "not_verified"} <= ids) + + +class AgentPrompts(unittest.TestCase): + def test_each_prompt_has_the_three_fixed_parts(self): + prompts = [p for p in PROMPTS.glob("*.md") if p.name != "README.md"] + self.assertEqual(sorted(p.name for p in prompts), + ["docs-fix.md", "evaluator-pass.md", "push-from-bundle.md", "read-only-audit.md"]) + for path in prompts: + text = path.read_text(encoding="utf-8") + with self.subTest(prompt=path.name): + for heading in ("## Precondition block", "## Expected outcome", "## Failure rule"): + self.assertIn(heading, text) + block = text.split("## Precondition block", 1)[1].split("```")[1] + self.assertIn("repository:", block) + self.assertRegex(block, r"(parent|head) SHA:") + self.assertIn("expected outcome:", block) + self.assertIn("A failed precondition stops the task", text) + + def test_docs_fix_separates_verified_from_planned_content(self): + text = (PROMPTS / "docs-fix.md").read_text(encoding="utf-8") + self.assertIn("## Verified and planned content", text) + self.assertIn("@::", text.split("## Verified and planned content", 1)[1]) + self.assertIn("**Planned** or **Experimental**", text) + self.assertIn("Never invent a technical claim the owning repository does not support", text) + + +class Typography(unittest.TestCase): + def test_no_em_or_en_dashes_in_harness_files(self): + for rel in HARNESS_FILES: + path = ROOT / rel + if path.exists(): + with self.subTest(file=rel): + self.assertNotRegex(path.read_text(encoding="utf-8"), "[–—]") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verify_sh.py b/tests/test_verify_sh.py new file mode 100644 index 0000000..af51643 --- /dev/null +++ b/tests/test_verify_sh.py @@ -0,0 +1,259 @@ +"""Tests for rollout/verify.sh: zero-test rule, skip rule, delegation.""" +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERIFY = ROOT / "rollout" / "verify.sh" +PASSING = "import unittest\n\nclass T(unittest.TestCase):\n def test_one(self):\n self.assertTrue(True)\n" + + +def make_repo(files): + tmp = tempfile.TemporaryDirectory() + root = Path(tmp.name) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + return tmp, root + + +def verify(root): + proc = subprocess.run(["bash", str(VERIFY), str(root)], capture_output=True, text=True, timeout=120, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(root)}) + runs = sorted((root / ".verification").glob("run.*")) + summary = json.loads((runs[-1] / "summary.json").read_text()) if runs else {} + return proc.returncode, proc.stdout + proc.stderr, summary + + +class VerifyScript(unittest.TestCase): + def check(self, files): + tmp, root = make_repo(files) + self.addCleanup(tmp.cleanup) + return verify(root) + + def test_passing_suite_passes_with_counts(self): + code, out, summary = self.check({"tests/test_a.py": PASSING}) + self.assertEqual(code, 0, out) + self.assertEqual((summary["result"], summary["tests_total"]), ("PASS", 1)) + + def test_zero_tests_fail(self): + code, out, summary = self.check({"tests/test_a.py": "# no tests yet\n"}) + self.assertNotEqual(code, 0) + self.assertIn("zero tests executed", out) + self.assertEqual(summary["failed_stage"], "test") + + def test_failing_test_fails(self): + body = PASSING.replace("self.assertTrue(True)", "self.fail('deliberate')") + code, out, summary = self.check({"tests/test_a.py": body}) + self.assertNotEqual(code, 0) + self.assertIn("test command exited with status", out) + self.assertEqual(summary["failed_stage"], "test") + + def test_fully_skipped_suite_fails(self): + body = PASSING.replace(" def test_one", " @unittest.skip('flaky, see #12')\n def test_one") + code, out, _ = self.check({"tests/test_a.py": body}) + self.assertNotEqual(code, 0) + self.assertIn("zero tests executed", out) + + def test_skip_without_issue_fails(self): + body = PASSING + "\n @unittest." + "skip('later')\n def test_two(self):\n pass\n" + code, out, summary = self.check({"tests/test_a.py": body}) + self.assertNotEqual(code, 0) + self.assertEqual(summary["failed_stage"], "test-markers") + + def test_skip_with_issue_is_allowed(self): + body = PASSING + "\n @unittest.skip('hardware only, see #12')\n def test_two(self):\n pass\n" + code, out, summary = self.check({"tests/test_a.py": body}) + self.assertEqual(code, 0, out) + self.assertEqual((summary["tests_total"], summary["tests_skipped"]), (2, 1)) + + def test_nothing_detected_fails(self): + code, out, _ = self.check({"notes.txt": "hello\n"}) + self.assertNotEqual(code, 0) + self.assertIn("no buildable or testable project detected", out) + + def test_delegates_to_existing_tools_verify(self): + code, out, _ = self.check({"tools/verify.sh": "echo repository-own-verify\nexit 0\n"}) + self.assertEqual(code, 0) + self.assertIn("repository-own-verify", out) + + def test_delegation_writes_summary_with_delegated_status_and_counts(self): + script = ("echo 'Ran 3 tests in 0.010s'\n" + "echo 'Evidence: /work/.verification/run.native'\n" + "exit 0\n") + code, out, summary = self.check({"tools/verify.sh": script}) + self.assertEqual(code, 0, out) + self.assertEqual(summary["mode"], "delegated") + self.assertEqual((summary["result"], summary["exit_code"]), ("PASS", 0)) + self.assertEqual((summary["tests_total"], summary["tests_skipped"], summary["counts_parsed"]), (3, 0, True)) + self.assertEqual(summary["delegated_evidence"], "/work/.verification/run.native") + self.assertIsInstance(summary["duration_seconds"], int) + for key in ("schema_version", "head_sha", "base_sha", "harness_sha", "delegated_script"): + self.assertIn(key, summary) + + def test_delegated_failure_is_recorded_and_propagated(self): + code, out, summary = self.check({"tools/verify.sh": "echo 'Ran 2 tests in 0.1s'\necho boom\nexit 7\n"}) + self.assertEqual(code, 7, out) + self.assertEqual((summary["result"], summary["exit_code"]), ("FAIL", 7)) + self.assertIn("FAIL: delegated tools/verify.sh (exit 7)", out) + + def test_delegation_without_recognisable_counts_says_so(self): + _, _, summary = self.check({"tools/verify.sh": "echo done\nexit 0\n"}) + self.assertEqual((summary["tests_total"], summary["counts_parsed"]), (None, False)) + + +# A directory named summary.json inside the run directory makes the summary write fail (also as root). +BLOCK_SUMMARY_SH = 'for d in "$(dirname "$0")"/../.verification/run.*; do mkdir -p "$d/summary.json"; done\n' +BLOCK_SUMMARY_PY = ("import glob, os, unittest\n\nclass T(unittest.TestCase):\n def test_one(self):\n" + " for d in glob.glob('.verification/run.*'):\n" + " os.makedirs(os.path.join(d, 'summary.json'), exist_ok=True)\n") + + +class SummaryWriteFailure(unittest.TestCase): + """A run whose summary.json cannot be written never reports success (release-owner review).""" + + def run_blocked(self, files): + tmp, root = make_repo(files) + self.addCleanup(tmp.cleanup) + proc = subprocess.run(["bash", str(VERIFY), str(root)], capture_output=True, text=True, timeout=120, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(root)}) + run = sorted((root / ".verification").glob("run.*"))[-1] + self.assertTrue((run / "summary.json").is_dir(), "the test did not block the summary write") + return proc.returncode, proc.stdout + proc.stderr, (run / "result.txt").read_text() + + def test_harness_run_fails_when_summary_cannot_be_written(self): + code, out, result = self.run_blocked({"tests/test_a.py": BLOCK_SUMMARY_PY}) + self.assertNotEqual(code, 0, out) + self.assertIn("could not write", out) + self.assertNotIn("PASS: all detected verification stages", out) + self.assertTrue(result.startswith("FAIL: evidence-summary"), result) + + def test_delegated_success_fails_when_summary_cannot_be_written(self): + code, out, result = self.run_blocked({"tools/verify.sh": BLOCK_SUMMARY_SH + "echo 'Ran 3 tests in 0.1s'\nexit 0\n"}) + self.assertNotEqual(code, 0, out) + self.assertNotIn("PASS: delegated", out) + self.assertTrue(result.startswith("FAIL: delegated tools/verify.sh (exit 1; summary.json not written)"), result) + + def test_delegated_failure_status_is_kept_when_summary_cannot_be_written(self): + code, out, result = self.run_blocked({"tools/verify.sh": BLOCK_SUMMARY_SH + "exit 7\n"}) + self.assertEqual(code, 7, out) + self.assertIn("exit 7; summary.json not written", result) + + +# A fake ROS install: setup.bash puts a fake rosdep on PATH. The fake needs an initialised +# rosdep cache under $HOME/.ros/rosdep (as the real one does) and fails on any package.xml +# that names an unresolvable key; it logs every path it is asked to scan. +FAKE_ROSDEP = r"""#!/usr/bin/env bash +log="$(dirname "$0")/../calls.log" +[ "$1" = check ] || exit 2 +shift +if [ ! -f "$HOME/.ros/rosdep/sources.cache" ]; then + echo "ERROR: your rosdep installation has not been initialized yet"; exit 1 +fi +paths=() +while [ $# -gt 0 ]; do + if [ "$1" = --from-paths ]; then + shift + while [ $# -gt 0 ] && [ "${1#--}" = "$1" ]; do paths+=("$1"); shift; done + else shift; fi +done +for p in "${paths[@]}"; do + echo "scan $p" >> "$log" + if grep -rl --include=package.xml unresolvable_generated_key "$p" >/dev/null 2>&1; then + echo "ERROR: Cannot locate rosdep definition for [unresolvable_generated_key]"; exit 1 + fi +done +echo "All system dependencies have been satisfied" +""" +PACKAGE_XML = ('\n{name}0.0.0' + 'dm' + 'MIT{deps}\n') +ROS_ENV = 'VERIFY_BUILD="true"\nVERIFY_TEST="python3 -m unittest discover -s tests -v"\n' + + +class RosdepState(unittest.TestCase): + """CI owner review (6 October): rosdep state in the clean HOME; generated folders not scanned.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.base = Path(tmp.name) + ros = self.base / "ros" + (ros / "bin").mkdir(parents=True) + (ros / "bin" / "rosdep").write_text(FAKE_ROSDEP, encoding="utf-8") + (ros / "bin" / "rosdep").chmod(0o755) + (ros / "setup.bash").write_text(f'export PATH="{ros}/bin:$PATH"\n', encoding="utf-8") + fake_colcon = ros / "bin" / "colcon" + fake_colcon.write_text( + "#!/usr/bin/env bash\n" + "if [ \"$1\" = test-result ]; then\n" + " echo 'Summary: 1 tests, 0 errors, 0 failures, 0 skipped'\n" + "fi\n", + encoding="utf-8", + ) + fake_colcon.chmod(0o755) + self.ros = ros + self.home = self.base / "home" + self.home.mkdir() + + def init_caller_rosdep(self): + cache = self.home / ".ros" / "rosdep" + cache.mkdir(parents=True) + (cache / "sources.cache").write_text("prepared by the environment\n", encoding="utf-8") + + def run_verify(self, files): + files = {"tests/test_a.py": PASSING, ".openamrobot/verify.env": ROS_ENV, + "ros2/src/pkg_a/package.xml": PACKAGE_XML.format(name="pkg_a", deps="rclpy"), + **files} + tmp, root = make_repo(files) + self.addCleanup(tmp.cleanup) + proc = subprocess.run(["bash", str(VERIFY), str(root)], capture_output=True, text=True, timeout=120, + env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(self.home), + "VERIFY_ROS_SETUP": str(self.ros / "setup.bash")}) + calls = self.ros / "calls.log" + scanned = calls.read_text().split("\n") if calls.exists() else [] + return proc.returncode, proc.stdout + proc.stderr, root, scanned + + def test_caller_rosdep_cache_is_available_in_clean_home(self): + self.init_caller_rosdep() + code, out, _, _ = self.run_verify({}) + self.assertEqual(code, 0, out) + self.assertIn("All system dependencies have been satisfied", out) + self.assertIn("PASS: install", out) + self.assertTrue((self.home / ".ros" / "rosdep" / "sources.cache").is_file()) + + def test_without_rosdep_state_the_install_stage_fails(self): + code, out, _, _ = self.run_verify({}) + self.assertNotEqual(code, 0, out) + self.assertIn("not been initialized", out) + self.assertIn("FAIL: install", out) + + def test_generated_build_install_log_folders_are_not_scanned(self): + self.init_caller_rosdep() + bad = PACKAGE_XML.format(name="pkg_a", deps="unresolvable_generated_key") + code, out, root, scanned = self.run_verify({ + "ros2/build/pkg_a/package.xml": bad, "ros2/build/COLCON_IGNORE": "", + "ros2/install/pkg_a/share/pkg_a/package.xml": bad, "ros2/install/COLCON_IGNORE": "", + "ros2/log/latest/package.xml": bad, + "ros2/generated/COLCON_IGNORE": "", "ros2/generated/pkg_a/package.xml": bad, + }) + self.assertEqual(code, 0, out) + scanned = [line for line in scanned if line] + self.assertEqual(scanned, [f"scan {root / 'ros2' / 'src' / 'pkg_a'}"]) + + + def test_ros_build_copy_excludes_verification_workspace(self): + self.init_caller_rosdep() + code, out, _, _ = self.run_verify({".openamrobot/verify.env": ""}) + self.assertEqual(code, 0, out) + self.assertNotIn("into itself", out) + self.assertNotIn("cp: cannot copy", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py new file mode 100644 index 0000000..c320b6c --- /dev/null +++ b/tests/test_watchdog.py @@ -0,0 +1,193 @@ +"""Tests for tools/watchdog.py, the one-command Watchdog runner.""" +import contextlib +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +import unittest.mock +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import watchdog # noqa: E402 + +DRIVE = "https://drive.google.com/file/d/abc123/view" + + +def run(*args): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + code = watchdog.main([str(a) for a in args]) + return code, out.getvalue() + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class Watchdog(unittest.TestCase): + def make(self, tmp, agents=True): + repo = Path(tmp, "openamr-platform-sw") + (repo / "docs").mkdir(parents=True) + (repo / "docs/a.md").write_text("Compute is a Raspberry Pi 5.\n", encoding="utf-8") + (repo / "README.md").write_text(f"See {DRIVE}\n", encoding="utf-8") + wf = repo / ".github/workflows" + wf.mkdir(parents=True) + (wf / "ci.yml").write_text("jobs:\n a:\n steps:\n - uses: actions/checkout@v4\n", encoding="utf-8") + if agents: + (repo / "AGENTS.md").write_text("no shared block\n", encoding="utf-8") + (repo / "CLAUDE.md").write_text("@AGENTS.md\n", encoding="utf-8") + return repo + + def test_all_checks_run_and_summary_groups_by_decision(self): + with tempfile.TemporaryDirectory() as tmp: + repo = self.make(tmp) + code, out = run("--root", repo) + self.assertEqual(code, 1) + self.assertIn("OpenAMRobot Watchdog: openamr-platform-sw", out) + self.assertIn("Mismatch with approved decision: COMPUTE (1 finding(s))", out) + self.assertIn("\n docs/a.md:1: found 'Raspberry Pi 5'\n", out) + self.assertIn("Should not be public: google-drive-link (1 finding(s))", out) + self.assertIn("\n README.md:1: found 'https://drive.google.com", out) + self.assertIn("Shared agent rules out of date: shared-rules (1 finding(s))", out) + self.assertIn("\n AGENTS.md:1: found ", out) + self.assertIn("Workflow not pinned: unpinned-action (1 finding(s))", out) + self.assertIn("\n .github/workflows/ci.yml:4: found ", out) + self.assertIn("Total: 4 finding(s) (COMPUTE 1, google-drive-link 1, shared-rules 1, unpinned-action 1)", out) + self.assertIn("WATCHDOG.md", out) + + def test_report_only_never_fails_on_findings(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(run("--root", self.make(tmp), "--report-only")[0], 0) + + def test_clean_repository_and_missing_agents(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp, "demo") + repo.mkdir() + (repo / "README.md").write_text("Reference compute is the Jetson.\n", encoding="utf-8") + code, out = run("--root", repo) + self.assertEqual(code, 0, out) + self.assertIn("Not run: the repository has no AGENTS.md.", out) + self.assertIn("Total: 0 finding(s)", out) + self.assertIn("Next step: nothing to fix.", out) + + def test_this_repository_passes_its_own_agents_check(self): + report = watchdog.run(ROOT, ".github") + self.assertEqual(report["results"]["shared-rules"], []) + + def test_usage_and_configuration_errors_exit_two(self): + self.assertEqual(run("--root", "/nonexistent/path")[0], 2) + with tempfile.TemporaryDirectory() as tmp: + harness = Path(tmp, "harness") + shutil.copytree(ROOT / "tools", harness / "tools") + shutil.copytree(ROOT / "agent-rules", harness / "agent-rules") + shutil.copy(ROOT / "maintainers.yaml", harness) + shutil.copy(ROOT / "public-extract-allowlist.yaml", harness) + (harness / "decisions.yaml").write_text("schema_version: 9\n", encoding="utf-8") + code, out = run("--root", ROOT, "--harness", harness, "--report-only") + self.assertEqual(code, 2) + self.assertIn("INVALID configuration", out) + + def test_json_and_markdown_outputs(self): + with tempfile.TemporaryDirectory() as tmp: + repo = self.make(tmp, agents=False) + md, js = Path(tmp, "summary.md"), Path(tmp, "r.json") + run("--root", repo, "--report-only", "--markdown", md, "--json", js, "--repository", "x") + run("--root", repo, "--report-only", "--markdown", md, "--repository", "y") + text = md.read_text(encoding="utf-8") + self.assertIn("### x: 3 finding(s)", text) + self.assertIn("### y: 3 finding(s)", text) + self.assertIn("| Shared agent rules | not run (no AGENTS.md) |", text) + self.assertIn("| COMPUTE | 1 |", text) + data = json.loads(js.read_text(encoding="utf-8")) + self.assertEqual(data["repository"], "x") + self.assertEqual(len(data["results"]["decisions"]), 1) + finding = data["results"]["decisions"][0] + # JSON keeps full detail per finding, unlike the compact text report. + self.assertEqual((finding["file"], finding["line"], finding["found"]), ("docs/a.md", 1, "Raspberry Pi 5")) + self.assertTrue(finding["decision"] and finding["why"] and finding["fix"] and finding["links"]) + + def test_freshness_reports_past_review_dates(self): + self.assertEqual(watchdog.freshness(ROOT / "decisions.yaml", date(2000, 1, 1)), []) + overdue = watchdog.freshness(ROOT / "decisions.yaml", date(2100, 1, 1)) + self.assertTrue(overdue) + self.assertTrue(all("past due" in w for w in overdue)) + with tempfile.TemporaryDirectory() as tmp: + report = watchdog.run(Path(tmp), "x", today=date(2100, 1, 1)) + text = "\n".join(watchdog.render(report)) + self.assertIn("Review due: ", text) + self.assertEqual(watchdog.total(report), 0) + + +class Guide(unittest.TestCase): + """WATCHDOG.md stays in step with the register and the checks.""" + + @classmethod + def setUpClass(cls): + cls.text = (ROOT / "WATCHDOG.md").read_text(encoding="utf-8") + + def test_accepted_words_table_matches_register(self): + code, out = run("--accepted-words") + self.assertEqual(code, 0) + section = self.text.split("\n", 1)[1].split("", 1)[0] + self.assertEqual(section, out, "regenerate with: python3 tools/watchdog.py --accepted-words") + + def test_every_decision_is_listed(self): + for d in watchdog.cd.load_decisions(ROOT / "decisions.yaml"): + self.assertIn(f"| {d['id']} | {d['status']} |", self.text) + + def test_link_anchors_used_by_findings_exist(self): + for anchor in ("decisions-of-record", "public-extract", "shared-agent-rules", "workflow-policy", + "pr-evidence", "verify", "decision-freshness"): + self.assertIn(f'', self.text) + + def test_plain_words(self): + self.assertEqual(watchdog.plain_words(r"supersed|\bnot\b|rev ?18\.[1-6]\b|3\.0"), + ["supersed (superseded, supersedes)", "not", "rev 18.1 to rev 18.6", "3.0"]) + self.assertEqual(watchdog.split_alternatives(r"a(?:b|c)|[|]|d"), ["a(?:b|c)", "[|]", "d"]) + + def test_guide_is_excluded_only_at_the_repository_root(self): + decisions = watchdog.cd.load_decisions(ROOT / "decisions.yaml") + with tempfile.TemporaryDirectory() as tmp: + for name in ("WATCHDOG.md", "docs/WATCHDOG.md"): + Path(tmp, name).parent.mkdir(parents=True, exist_ok=True) + Path(tmp, name).write_text("Compute is a Raspberry Pi 5.\n", encoding="utf-8") + found = watchdog.cd.scan(tmp, decisions, ".github")[0] + self.assertEqual(sorted(f["file"] for f in found), ["docs/WATCHDOG.md"]) + + def test_approved_public_contacts_section(self): + section = self.text.split("## Approved public contacts", 1)[1].split("\n## ", 1)[0] + for kind in ("| Organisation |", "| Contributors and maintainers |", "| Supplier role addresses |", + "| Third-party licence notices |", "`datasheets/` only", "**Prices are never public**", + "docs owner", "platform lead"): + self.assertIn(kind, section) + + def test_issue_mode_section(self): + section = self.text.split("### Watchdog issue mode", 1)[1].split("\n## ", 1)[0] + for text in ("`WATCHDOG_ISSUE_MODE`", "not set (default), or `dashboard`", "`groups`", + "**To turn on per-group issues:**", "the platform lead decides when", + "Scan-blocked\nrepositories appear on the dashboard in both modes"): + self.assertIn(text, section) + + def test_incomplete_scans_section(self): + section = self.text.split("### Incomplete scans", 1)[1].split("\n### ", 1)[0] + for text in ("**BLOCKED**", "**INCOMPLETE**", "INCOMPLETE: N of M repositories scanned", + "The job is red", "never treated as clean"): + self.assertIn(text, section) + + def test_guide_is_linked(self): + for name in ("README.md", "CONTRIBUTING.md"): + self.assertIn("WATCHDOG.md", (ROOT / name).read_text(encoding="utf-8"), name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watchdog_issue_sync.py b/tests/test_watchdog_issue_sync.py new file mode 100644 index 0000000..865e48d --- /dev/null +++ b/tests/test_watchdog_issue_sync.py @@ -0,0 +1,355 @@ +import contextlib +import copy +import io +import json +import os +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import watchdog_issue_sync as wis # noqa: E402 + + +MAINTAINERS = yaml.safe_load((ROOT / "maintainers.yaml").read_text(encoding="utf-8")) + + +def report(found=True): + return { + "repository": "openamrobot-docs", + "commit": "a" * 40, + "results": { + "decisions": [{"group": "COMPUTE", "file": "README.md", "line": 4, + "found": "Raspberry Pi 5", "decision": "Jetson is current.", + "why": "Current 2.0 compute must be named.", "fix": "Label legacy or use Jetson.", "links": []}] if found else [], + "public-extract": [], "shared-rules": [], "workflow-policy": [], + }, + "freshness": ["COMPUTE review_by 2026-10-01 is past due"] if found else [], + } + + +class PurePlan(unittest.TestCase): + def test_fingerprint_is_stable_when_line_moves(self): + a = wis.grouped_items([report()], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS)[0] + changed = copy.deepcopy(report()) + changed["results"]["decisions"][0]["line"] = 99 + b = wis.grouped_items([changed], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS)[0] + self.assertEqual(a["marker"], b["marker"]) + + def test_plan_deduplicates_due_warning_and_opens_dashboard_candidates(self): + data = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, [], "run", "2026-10-08", mode="groups") + self.assertEqual(len(data["open"]), 2) + self.assertTrue(any(i["kind"] == "finding" for i in data["open"])) + self.assertTrue(any(i["kind"] == "review" for i in data["open"])) + self.assertIsNone(data["dashboard"]) + + def test_existing_issue_is_not_duplicated_and_changed_evidence_comments(self): + first = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, [], "run", "2026-10-08", mode="groups") + finding = next(i for i in first["open"] if i["kind"] == "finding") + existing = [{"number": 7, "title": finding["payload"]["title"], "body": finding["payload"]["body"], + "state": "open", "comments": [], "url": "https://github.com/openAMRobot/.github/issues/7"}] + second = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, existing, "run", "2026-10-08", mode="groups") + self.assertFalse(any(i["kind"] == "finding" for i in second["open"])) + changed = copy.deepcopy(report()) + changed["results"]["decisions"][0]["found"] = "old Pi" + third = wis.plan([changed], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, existing, "run", "2026-10-08", mode="groups") + self.assertTrue(any("still detected" in u["body"] for u in third["updates"])) + + def test_missing_finding_is_never_closed(self): + first = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, [], "run", "2026-10-08", mode="groups") + finding = next(i for i in first["open"] if i["kind"] == "finding") + existing = [{"number": 8, "title": finding["payload"]["title"], "body": finding["payload"]["body"], + "state": "open", "comments": [], "url": "https://github.com/openAMRobot/.github/issues/8"}] + second = wis.plan([report(False)], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, existing, "run", "2026-10-15", mode="groups") + self.assertTrue(any(wis.NO_LONGER_MARKER in u["body"] for u in second["updates"])) + self.assertFalse(any(u["issue"].get("number") == 8 and "PATCH" in u for u in second["updates"])) + + def test_reappearing_closed_finding_is_reopened(self): + first = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, [], "run", "2026-10-08", mode="groups") + finding = next(i for i in first["open"] if i["kind"] == "finding") + existing = [{"number": 9, "title": finding["payload"]["title"], "body": finding["payload"]["body"], + "state": "closed", "comments": [], "url": "https://github.com/openAMRobot/.github/issues/9"}] + second = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, existing, "run", "2026-10-15", mode="groups") + reopen = [u for u in second["updates"] if u["issue"].get("number") == 9] + self.assertEqual(len(reopen), 1) + self.assertTrue(reopen[0]["reopen"]) + + def test_public_issue_body_redacts_untrusted_values(self): + item = wis.grouped_items([report()], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS)[0] + item["findings"][0]["found"] = "https://drive.google.com/x user@example.com sk-ant-123456789" + body = wis.body_for(item, "run", "2026-10-08") + self.assertNotIn("drive.google.com", body) + self.assertNotIn("user@example.com", body) + self.assertNotIn("sk-ant-", body) + + def test_dashboard_observation_is_stable_marker(self): + data = wis.plan([report()], [], {"COMPUTE": {"owner": "platform-lead"}}, MAINTAINERS, [], "run", "2026-10-08", mode="groups") + body = wis.dashboard_body([report()], [], data, "run", "2026-10-08", []) + self.assertIsNotNone(wis.OBSERVATION_RE.search(body)) + self.assertIn("Shared rules", body) + + +class Api(unittest.TestCase): + def test_apply_reopens_and_comments_without_closing(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url, data)) + if method == "POST" and url.endswith("/issues"): + return {"html_url": "https://github.com/openAMRobot/.github/issues/10"} + return {} + + plan = {"open": [], "updates": [{"issue": {"number": 9, "title": "finding", "url": "u"}, + "body": "reappeared", "reopen": True}], + "active": [], "dashboard": None, "mode": "groups"} + wis.apply("openAMRobot/.github", plan, [], [], "token", "run", "2026-10-15", call=fake) + methods = [method for method, _, _ in calls] + self.assertIn("PATCH", methods) + self.assertIn("POST", methods) + self.assertNotIn("DELETE", methods) + self.assertTrue(any("/issues/9/comments" in url for _, url, _ in calls)) + + +def blocked(): + return [{"repository": "openamrobot-comm", "reason": "clone failed"}] + + +class IssueMode(unittest.TestCase): + DECISIONS = {"COMPUTE": {"owner": "platform-lead"}} + + def test_unset_or_empty_switch_means_dashboard(self): + self.assertEqual(wis.resolve_mode(None), "dashboard") + self.assertEqual(wis.resolve_mode(""), "dashboard") + self.assertEqual(wis.resolve_mode(" Groups "), "groups") + with self.assertRaisesRegex(ValueError, "WATCHDOG_ISSUE_MODE"): + wis.resolve_mode("everything") + + def test_dashboard_mode_plans_no_group_issue_writes(self): + first = wis.plan([report()], [], self.DECISIONS, MAINTAINERS, [], "run", "2026-10-08", mode="groups") + finding = next(i for i in first["open"] if i["kind"] == "finding") + existing = [{"number": 7, "title": finding["payload"]["title"], "body": finding["payload"]["body"], + "state": "closed", "comments": [], "url": "u7"}] + data = wis.plan([report()], blocked(), self.DECISIONS, MAINTAINERS, existing, "run", "2026-10-08") + self.assertEqual(data["mode"], "dashboard") + self.assertEqual((data["open"], data["updates"]), ([], [])) + self.assertEqual(len(data["active"]), 3) # finding group, decision review, blocked repository + + def test_dashboard_lists_every_active_group_and_blocked_repositories(self): + for mode in ("dashboard", "groups"): + with self.subTest(mode=mode): + data = wis.plan([report()], blocked(), self.DECISIONS, MAINTAINERS, [], "run", "2026-10-08", mode=mode) + body = wis.dashboard_body([report()], blocked(), data, "run", "2026-10-08", []) + self.assertIn(f"Issue mode: `{mode}`", body) + self.assertIn("| Repository | Check | Group | Findings | Owner | Files |", body) + self.assertIn("| openamrobot-docs | decisions | COMPUTE | 1 | platform-lead (BotshareAI) | " + "[README.md:4](https://github.com/openAMRobot/openamrobot-docs/blob/" + "a" * 40 + "/README.md#L4) |", body) + self.assertIn("| decisions.yaml | decision-review | COMPUTE | 1 |", body) + self.assertIn("| openamrobot-comm | scan | **BLOCKED** | - | ci-owner", body) + self.assertIn("| openamrobot-comm | unavailable | - | - | - | **BLOCKED** (clone failed) |", body) + + def test_file_links_are_capped_per_group(self): + many = copy.deepcopy(report()) + many["results"]["decisions"] = [dict(report()["results"]["decisions"][0], line=n) for n in range(1, 9)] + data = wis.plan([many], [], self.DECISIONS, MAINTAINERS, [], "run", "2026-10-08") + body = wis.dashboard_body([many], [], data, "run", "2026-10-08", []) + self.assertIn("| 8 |", body) + self.assertIn("#L5), and 3 more |", body) + self.assertNotIn("#L6)", body) + + def run_apply(self, mode, dashboard=None): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url, data)) + return {"html_url": "https://github.com/openAMRobot/.github/issues/1"} if url.endswith("/issues") else {} + + data = wis.plan([report()], blocked(), self.DECISIONS, MAINTAINERS, [dashboard] if dashboard else [], "run", + "2026-10-08", mode=mode) + wis.apply("openAMRobot/.github", data, [report()], blocked(), "token", "run", "2026-10-08", call=fake) + return [(m, u.split("/repos/openAMRobot/.github/", 1)[1], d) for m, u, d in calls] + + def test_dashboard_mode_creates_only_the_dashboard(self): + calls = self.run_apply("dashboard") + issue_posts = [d for m, u, d in calls if m == "POST" and u == "issues"] + self.assertEqual([d["title"] for d in issue_posts], ["[watchdog] Organization dashboard"]) + self.assertFalse([c for c in calls if "/comments" in c[1]]) + self.assertEqual({d["name"] for m, u, d in calls if u == "labels"}, {"watchdog-report"}) + + def test_dashboard_mode_updates_the_dashboard_in_place_once(self): + old = {"number": 5, "title": "[watchdog] Organization dashboard", "state": "open", "comments": [], + "body": wis.DASHBOARD_MARKER + "\n\nold", "url": "u5"} + calls = self.run_apply("dashboard", old) + writes = [(m, u) for m, u, d in calls if m in ("PATCH", "POST") and u != "labels"] + self.assertEqual(writes, [("PATCH", "issues/5")]) + body = next(d for m, u, d in calls if m == "PATCH")["body"] + same = dict(old, body=body) + self.assertEqual([(m, u) for m, u, d in self.run_apply("dashboard", same) if u != "labels"], []) + + def test_groups_mode_keeps_per_group_issues(self): + calls = self.run_apply("groups") + titles = [d["title"] for m, u, d in calls if m == "POST" and u == "issues"] + self.assertIn("[watchdog] openamrobot-docs: decisions / COMPUTE", titles) + self.assertIn("[watchdog] Decision review: COMPUTE", titles) + self.assertIn("[watchdog] Scan blocked: openamrobot-comm", titles) + self.assertIn("[watchdog] Organization dashboard", titles) + + def test_command_line_reads_the_switch(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "r.json").write_text(json.dumps(report()), encoding="utf-8") + args = ["--reports", tmp, "--maintainers", str(ROOT / "maintainers.yaml"), + "--decisions", str(ROOT / "decisions.yaml"), "--run-url", "run"] + for value, expected in ((None, "mode dashboard"), ("", "mode dashboard"), ("groups", "mode groups")): + env = {k: v for k, v in os.environ.items() if k != "WATCHDOG_ISSUE_MODE"} + if value is not None: + env["WATCHDOG_ISSUE_MODE"] = value + out = io.StringIO() + with unittest.mock.patch.dict(os.environ, env, clear=True), contextlib.redirect_stdout(out): + self.assertEqual(wis.main(args), 0) + self.assertIn(expected, out.getvalue()) + err = io.StringIO() + with unittest.mock.patch.dict(os.environ, {"WATCHDOG_ISSUE_MODE": "all"}), contextlib.redirect_stderr(err): + self.assertEqual(wis.main(args), 2) + + +def docs_report(): + return report() + + +def comm_report(): + data = report() + data["repository"] = "openamrobot-comm" + return data + + +class FailClosed(unittest.TestCase): + """CI/CD review of 8 Oct: missing or broken reports are BLOCKED, never clean.""" + DECISIONS = {"COMPUTE": {"owner": "platform-lead"}} + EXPECTED = ["openamrobot-docs", "openamrobot-comm"] + + def test_missing_report_is_blocked_with_banner(self): + blocked, status = wis.completeness(self.EXPECTED, [docs_report()], []) + self.assertEqual(blocked, [{"repository": "openamrobot-comm", "reason": "no report produced"}]) + self.assertEqual(status, {"scanned": 1, "expected": 2, "incomplete": True}) + for mode in ("dashboard", "groups"): + with self.subTest(mode=mode): + data = wis.plan([docs_report()], blocked, self.DECISIONS, MAINTAINERS, [], "run", "2026-10-08", mode, status) + body = wis.dashboard_body([docs_report()], blocked, data, "run", "2026-10-08", []) + self.assertIn("**Scan INCOMPLETE on 2026-10-08: 1 of 2 repositories scanned. Results below are partial; " + "missing repositories are listed as BLOCKED and are not clean.**", body) + self.assertLess(body.index("Scan INCOMPLETE"), body.index("## OpenAMRobot Watchdog dashboard")) + self.assertIn("| openamrobot-comm | unavailable | - | - | - | **BLOCKED** (no report produced) |", body) + self.assertNotIn("| openamrobot-comm | `", body) # never listed with a commit, never clean + + def test_complete_run_has_no_banner(self): + blocked, status = wis.completeness(self.EXPECTED, [docs_report(), comm_report()], []) + self.assertEqual((blocked, status["incomplete"]), ([], False)) + data = wis.plan([docs_report(), comm_report()], blocked, self.DECISIONS, MAINTAINERS, [], "run", "2026-10-08", "dashboard", status) + self.assertNotIn("INCOMPLETE", wis.dashboard_body([docs_report(), comm_report()], [], data, "run", "2026-10-08", [])) + + def existing_issues(self): + first = wis.plan([docs_report(), comm_report()], [], self.DECISIONS, MAINTAINERS, [], "run", "2026-10-01", mode="groups") + issues = [] + for number, item in enumerate(i for i in first["open"] if i["kind"] == "finding"): + issues.append({"number": 20 + number, "title": item["payload"]["title"], "body": item["payload"]["body"], + "state": "open", "comments": [], "url": f"u{number}"}) + return issues + + def test_incomplete_run_never_clears_findings_of_unscanned_repositories(self): + existing = self.existing_issues() + clean_docs = report(False) # docs was scanned and its finding is gone + blocked, status = wis.completeness(self.EXPECTED, [clean_docs], []) + data = wis.plan([clean_docs], blocked, self.DECISIONS, MAINTAINERS, existing, "run", "2026-10-08", "groups", status) + cleared = {u["issue"]["title"] for u in data["updates"] if wis.NO_LONGER_MARKER in u["body"]} + self.assertIn("[watchdog] openamrobot-docs: decisions / COMPUTE", cleared) + self.assertNotIn("[watchdog] openamrobot-comm: decisions / COMPUTE", cleared) + self.assertFalse(any(u.get("reopen") is False and "openamrobot-comm" in u["issue"]["title"] and wis.NO_LONGER_MARKER in u["body"] + for u in data["updates"])) + + def test_incomplete_dashboard_keeps_last_known_rows_of_unscanned_repositories(self): + full = [docs_report(), comm_report()] + first = wis.plan(full, [], self.DECISIONS, MAINTAINERS, [], "run", "2026-10-01", "dashboard", wis.completeness(self.EXPECTED, full, [])[1]) + previous = {"number": 5, "state": "open", "comments": [], "url": "u5", + "body": wis.dashboard_body(full, [], first, "run", "2026-10-01", [])} + blocked, status = wis.completeness(self.EXPECTED, [docs_report()], []) + data = wis.plan([docs_report()], blocked, self.DECISIONS, MAINTAINERS, [previous], "run", "2026-10-08", "dashboard", status) + body = wis.dashboard_body([docs_report()], blocked, data, "run", "2026-10-08", []) + self.assertIn("| openamrobot-comm | decisions | COMPUTE | 1 |", body) + self.assertIn("last known from an earlier run, not rescanned", body) + + def test_malformed_report_is_blocked_not_skipped(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "openamrobot-docs.json").write_text(json.dumps(report()), encoding="utf-8") + Path(tmp, "openamrobot-comm.json").write_text("{not json", encoding="utf-8") + reports, failures = wis.load_reports_checked(tmp) + self.assertEqual([r["repository"] for r in reports], ["openamrobot-docs"]) + self.assertEqual(failures, [{"repository": "openamrobot-comm", "reason": "malformed report"}]) + for mode in ("dashboard", "groups"): + with self.subTest(mode=mode): + code, out = self.main(tmp, mode=mode) + self.assertEqual(code, 1, out) + self.assertIn("INCOMPLETE, 1 of 2 repositories scanned", out) + + def main(self, reports, expected="write", mode="dashboard"): + with tempfile.TemporaryDirectory() as tmp: + args = ["--reports", str(reports), "--maintainers", str(ROOT / "maintainers.yaml"), + "--decisions", str(ROOT / "decisions.yaml"), "--run-url", "run", "--run-date", "2026-10-08", + "--mode", mode] + if expected == "write": + Path(tmp, "expected.json").write_text(json.dumps(self.EXPECTED), encoding="utf-8") + elif expected is not None: + Path(tmp, "expected.json").write_text(expected, encoding="utf-8") + args += ["--expected", str(Path(tmp, "expected.json"))] + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + code = wis.main(args) + return code, out.getvalue() + + def test_command_line_exit_codes(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "openamrobot-docs.json").write_text(json.dumps(report()), encoding="utf-8") + code, out = self.main(tmp) + self.assertEqual(code, 1, out) # a report is missing for openamrobot-comm + self.assertIn("INCOMPLETE, 1 of 2 repositories scanned", out) + Path(tmp, "openamrobot-comm.json").write_text(json.dumps(comm_report()), encoding="utf-8") + code, out = self.main(tmp) + self.assertEqual(code, 0, out) + self.assertIn("complete)", out) + + def test_unreadable_expected_list_refuses_to_publish(self): + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "openamrobot-docs.json").write_text(json.dumps(report()), encoding="utf-8") + for expected in (None, "{not json", "[]"): + with self.subTest(expected=expected): + code, out = self.main(tmp, expected=expected) + self.assertEqual(code, 1, out) + self.assertIn("the expected repository list could not be read", out) + self.assertNotIn("Watchdog issue plan", out) + + def test_unreadable_expected_list_posts_only_the_banner(self): + calls = [] + + def fake(method, url, token, data=None): + calls.append((method, url.split("/repos/openAMRobot/.github/", 1)[1], data)) + return {} + + dashboard = {"number": 5, "state": "open", "comments": [], "url": "u5", + "body": wis.DASHBOARD_MARKER + "\nprevious results"} + wis.publish_unreadable("openAMRobot/.github", [dashboard], "expected.json: missing", "token", "run", "2026-10-08", call=fake) + writes = [(m, u) for m, u, d in calls if u != "labels"] + self.assertEqual(writes, [("POST", "issues/5/comments")]) # the dashboard body is not overwritten + self.assertIn("Scan INCOMPLETE on 2026-10-08: the expected repository list could not be read", + calls[-1][2]["body"]) + calls.clear() + wis.publish_unreadable("openAMRobot/.github", [], "expected.json: missing", "token", "run", "2026-10-08", call=fake) + created = [d for m, u, d in calls if u == "issues"] + self.assertEqual(len(created), 1) + self.assertNotIn("| Repository |", created[0]["body"]) # banner only, no results table + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watchdog_report.py b/tests/test_watchdog_report.py new file mode 100644 index 0000000..43ed444 --- /dev/null +++ b/tests/test_watchdog_report.py @@ -0,0 +1,129 @@ +"""Tests for tools/watchdog_report.py, the shared Watchdog output format.""" +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import watchdog_report as wr # noqa: E402 + + +def sample(): + return [ + wr.finding("Mismatch with approved decision", "A", "docs/x.md", 4, "old", "A is new.", "Why A.", "Use new.", + [("WATCHDOG.md", wr.DOCS)]), + wr.finding("Mismatch with approved decision", "B", "README.md", 2, "b,c:d", "B is b.", "Why B.", "Use b."), + wr.finding("Mismatch with approved decision", "A", "docs/y.md", 9, "old", "A is new.", "Why A.", "Use new."), + ] + + +def setUpModule(): + # Tests never write to the real GitHub Actions log or job summary of the CI run. + patcher = unittest.mock.patch.dict(os.environ) + patcher.start() + unittest.addModuleCleanup(patcher.stop) + for key in ("GITHUB_ACTIONS", "GITHUB_STEP_SUMMARY", "WATCHDOG_ANNOTATION", "WATCHDOG_ANNOTATIONS"): + os.environ.pop(key, None) + + +class Render(unittest.TestCase): + def test_grouped_blocks_and_closing_summary(self): + lines = wr.render(sample(), "Decisions of record", "fix it.") + text = "\n".join(lines) + self.assertLess(text.index("Mismatch with approved decision: A (2 finding(s))"), + text.index("Mismatch with approved decision: B (1 finding(s))")) + self.assertIn("Mismatch with approved decision: A (2 finding(s))\n" + " Decision: A is new.\n Why: Why A.\n Fix: Use new.\n" + f" More: WATCHDOG.md: {wr.DOCS}\n" + " Found:\n docs/x.md:4: found 'old'\n docs/y.md:9: found 'old'\n", text) + # The group's guidance is printed once, not per finding. + self.assertEqual(text.count(" Decision: A is new."), 1) + self.assertEqual(lines[-2], "Decisions of record summary: 3 finding(s) (A 2, B 1)") + self.assertEqual(lines[-1], "Next step: fix it.") + + def test_distinct_reasons_in_one_group_are_each_printed_once(self): + items = sample()[:1] + [dict(sample()[2], why="Other reason."), dict(sample()[2], file="z.md", why="Why A.")] + text = "\n".join(wr.render(items, "T", "n")) + self.assertIn(" Why: Why A.\n Other reason.\n Fix:", text) + self.assertEqual(text.count("Why A."), 1) + + def test_annotations_keep_full_detail_per_finding(self): + notes = wr.annotations(sample()) + self.assertEqual(len(notes), 3) + self.assertTrue(all("Fix: " in n and wr.DOCS in n for n in notes)) + self.assertIn("file=docs/y.md,line=9,", notes[2]) + + def test_clean_run_says_nothing_to_fix(self): + self.assertEqual(wr.render([], "Public extract", "x"), + ["Public extract summary: 0 finding(s)", "Next step: nothing to fix."]) + + def test_rule_word(self): + self.assertIn(" Rule: A is new.", wr.render(sample()[:1], "T", "n", decision_word="Rule")) + + +class GitHubOutput(unittest.TestCase): + def test_annotation_level_and_escaping(self): + warn = wr.annotations(sample()) + self.assertEqual(len(warn), 3) + self.assertTrue(warn[0].startswith( + "::warning file=docs/x.md,line=4,title=Mismatch with approved decision (A)::Found 'old'.")) + self.assertTrue(all(a.startswith("::error ") for a in wr.annotations(sample(), "error"))) + self.assertTrue(wr.annotations(sample(), "bogus")[0].startswith("::warning ")) + odd = wr.finding("L", "G", "a,b:c.md", 1, "x\ny", "d", "w", "f") + line = wr.annotations([odd])[0] + self.assertIn("file=a%2Cb%3Ac.md,", line) + self.assertNotIn("\n", line) + + def test_markdown_summary_table_and_details(self): + md = wr.markdown(sample(), "Decisions of record", "fix it.") + self.assertIn("### Decisions of record: 3 finding(s)", md) + self.assertIn("| A | 2 | Use new. |", md) + self.assertIn("
B: 1 finding(s)", md) + self.assertIn("- `docs/y.md:9` found `old`", md) + self.assertIn("No findings", wr.markdown([], "T", "n")) + + def test_emit_only_inside_github_actions(self): + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp, "s.md") + self.assertEqual(wr.emit_github(sample(), "T", "n", {"GITHUB_STEP_SUMMARY": str(summary)}), []) + self.assertFalse(summary.exists()) + env = {"GITHUB_ACTIONS": "true", "GITHUB_STEP_SUMMARY": str(summary), "WATCHDOG_ANNOTATIONS": "0"} + self.assertEqual(wr.emit_github(sample(), "T", "n", env), []) + env.pop("WATCHDOG_ANNOTATIONS") + env["WATCHDOG_ANNOTATION"] = "error" + out = io.StringIO() + with contextlib.redirect_stdout(out): + lines = wr.emit_github(sample(), "T", "n", env) + self.assertEqual(out.getvalue().splitlines(), lines) + self.assertEqual(len(lines), 3) + self.assertTrue(lines[0].startswith("::error ")) + self.assertIn("| A | 2 |", summary.read_text(encoding="utf-8")) + + +class TestIsolation(unittest.TestCase): + """Run inside GitHub Actions, the suite must not annotate the CI run or write its job summary.""" + + def test_suite_does_not_leak_into_the_ci_run(self): + if os.environ.get("WATCHDOG_ISOLATION_CHILD"): + return # this is the child run started below; the parent makes the assertions + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp, "summary.md") + summary.write_text("", encoding="utf-8") + env = dict(os.environ, GITHUB_ACTIONS="true", GITHUB_STEP_SUMMARY=str(summary), + WATCHDOG_ISOLATION_CHILD="1") + proc = subprocess.run([sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")], + cwd=ROOT, env=env, capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr[-2000:]) + output = proc.stdout + proc.stderr + self.assertNotRegex(output, r"(?m)^::(?:error|warning|notice) ") + self.assertEqual(summary.read_text(encoding="utf-8"), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_agent_rules.py b/tools/check_agent_rules.py index 2c8f1b7..b2a5e8e 100644 --- a/tools/check_agent_rules.py +++ b/tools/check_agent_rules.py @@ -1,26 +1,60 @@ #!/usr/bin/env python3 -"""Offline shared-rule drift check; caller supplies canonical file and checkouts.""" +"""Offline shared-rule drift check; caller supplies canonical file and checkouts. + +The marker version (v1, v2, ...) is read from the canonical file, so a +repository that still carries an older block fails with a version message. +""" import argparse +import re from pathlib import Path import sys -BEGIN = '' -END = '' -def block(path): + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import watchdog_report as wr # noqa: E402 + +LABEL = 'Shared agent rules out of date' +NEXT_STEP = ('copy the shared block from agent-rules/SHARED_RULES.md in openAMRobot/.github unchanged, ' + 'keep repository-specific rules below it, and keep CLAUDE.md as the single line @AGENTS.md.') +WHY = 'Every repository gives contributors and agents the same rules; a drifted copy quietly changes them.' + + +def fix_for(message): + if 'lines' in message: + return 'Shorten the repository-specific part so AGENTS.md stays under 120 lines.' + if 'CLAUDE.md' in message: + return 'Make CLAUDE.md contain only the line @AGENTS.md.' + return 'Copy the block between the BEGIN and END markers from agent-rules/SHARED_RULES.md unchanged.' + + +MARKER = re.compile(r'') +MAX_LINES = 120 + + +def block(path, version=None): s = path.read_text(encoding='utf-8') - if s.count(BEGIN) != 1 or s.count(END) != 1: - raise ValueError('expected exactly one v1 begin/end marker') - start, end = s.index(BEGIN), s.index(END) - if end < start: + found = MARKER.findall(s) + versions = {v for _, v in found} + if version and versions and version not in versions: + raise ValueError(f'shared block is {"/".join(sorted(versions))}, canonical is {version}') + if [k for k, _ in found] != ['BEGIN', 'END'] or len(versions) != 1: + raise ValueError('expected exactly one begin/end marker pair of one version') + v = versions.pop() + begin = f'' + end = f'' + start, stop = s.index(begin), s.index(end) + if stop < start: raise ValueError('reversed markers') - return s[start:end + len(END)] -def main(): - p = argparse.ArgumentParser(description=__doc__) + return v, s[start:stop + len(end)] + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument('--canonical', required=True, type=Path) p.add_argument('--root', type=Path, help='workspace containing repository directories') p.add_argument('--file', action='append', type=Path, default=[]) - a = p.parse_args() + a = p.parse_args(argv) try: - expected = block(a.canonical) + version, expected = block(a.canonical) except (OSError, ValueError) as e: p.error(f'canonical: {e}') files = list(a.file) @@ -31,20 +65,28 @@ def main(): files = sorted(set(files)) if not files: p.error('no AGENTS.md files selected; refusing empty success') - failed = False + problems = [] for path in files: try: text = path.read_text(encoding='utf-8') - if block(path) != expected: + if block(path, version)[1] != expected: raise ValueError('shared block differs from canonical') - if len(text.splitlines()) >= 120: - raise ValueError('AGENTS.md must be under 120 lines') + if len(text.splitlines()) >= MAX_LINES: + raise ValueError(f'AGENTS.md must be under {MAX_LINES} lines') if (path.parent / 'CLAUDE.md').read_text(encoding='utf-8').strip() != '@AGENTS.md': raise ValueError('CLAUDE.md must import @AGENTS.md without duplicate rules') - print(f'PASS {path}') + print(f'PASS {path} ({version})') except (OSError, ValueError) as e: - failed = True - print(f'FAIL {path}: {e}', file=sys.stderr) - return 1 if failed else 0 + problems.append(wr.finding( + LABEL, 'shared-rules', str(path), 1, str(e), + f'AGENTS.md carries the canonical shared block {version} from openAMRobot/.github.', + WHY, fix_for(str(e)), [('WATCHDOG.md', f'{wr.DOCS}#shared-agent-rules')])) + if problems: + for line in wr.render(problems, 'Shared agent rules', NEXT_STEP, decision_word='Rule'): + print(line) + wr.emit_github(problems, 'Shared agent rules', NEXT_STEP) + return 1 if problems else 0 + + if __name__ == '__main__': sys.exit(main()) diff --git a/tools/check_decisions.py b/tools/check_decisions.py new file mode 100644 index 0000000..f83276d --- /dev/null +++ b/tools/check_decisions.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Check a repository checkout against the decisions register (decisions.yaml). + +Every decision names the file globs it applies to and the patterns that +reveal a contradicting value; a superseded entry may also name a pattern for +text that still cites the superseded source. This tool validates the schema, +scans the checkout and reports file, line, found value and decided value. +It reads only the pinned register and the checkout: it fetches nothing and +never writes to either. A match proves a textual contradiction only, never +mechanical, electrical or safety correctness; each entry names the human +reviewer and evidence for that. Exit status: 0 clean, 1 contradiction found, +2 invalid register or usage error. + +An entry with status `superseded` names the decision that replaced it in +`superseded_by` (document, item, optional decision id) and a `citation` +pattern; only that pattern is scanned, for text still presenting the +superseded value as current. + +A line that must keep a superseded value (history, changelog, legacy +material) carries the marker `decision-allow: ` on the same line +or the line directly above it. The marker is reported, never silently ignored. +""" +import argparse +from datetime import date +import fnmatch +import json +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import watchdog_report as wr # noqa: E402 + +LABEL = "Mismatch with approved decision" +NEXT_STEP = ("correct each line to the current decision, label historical material with the words the " + "decision accepts (WATCHDOG.md, How to fix), or open a contract change request if the " + "decision itself is wrong.") +HINT_MAX = 120 + +SCHEMA_VERSION = 1 +STATUSES = {"recorded", "open", "superseded"} +KINDS = {"value", "configuration", "limit", "exclusion", "distinction"} +REQUIRED = ("id", "title", "kind", "status", "date", "review_by", "source", "applies_to", + "verification", "owner") +DATE_FORMAT = re.compile(r"^\d{4}-\d{2}-\d{2}$") +DEFAULT_FILES = [ + "**/*.md", "**/*.yaml", "**/*.yml", "**/*.launch.py", "**/*.launch.xml", + "**/*.launch", "**/*.urdf", "**/*.xacro", "**/package.xml", "**/README*", +] +ALLOW = re.compile(r"decision-allow:\s*(?P[A-Z0-9][A-Z0-9-]*)\s+(?P\S.*)") +SKIP_DIRS = {".git", "node_modules", ".verification", "build", "install", "log"} + + +class DecisionError(ValueError): + pass + + + +def as_date(value): + """Return a date for an ISO date or a YAML date, otherwise None.""" + if isinstance(value, date): + return value + if isinstance(value, str) and DATE_FORMAT.fullmatch(value): + try: + return date.fromisoformat(value) + except ValueError: + return None + return None + + +def review_warnings(data, today=None): + """Return non-blocking warnings for entries whose review window has passed.""" + today = today or date.today() + warnings = [] + for entry in (data or {}).get("decisions") or []: + review_by = as_date(entry.get("review_by")) if isinstance(entry, dict) else None + if review_by and review_by < today: + warnings.append(f"{entry.get('id', '')} review_by {review_by.isoformat()} is past due") + return warnings + + +def load_decisions(path, maintainers=None): + """Load and validate decisions.yaml; return the list of decisions.""" + try: + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise DecisionError(f"{path}: {exc}") from exc + if not isinstance(data, dict) or data.get("schema_version") != SCHEMA_VERSION: + raise DecisionError(f"{path}: schema_version must be {SCHEMA_VERSION}") + sources = data.get("sources") or {} + decisions = data.get("decisions") + if not isinstance(decisions, list) or not decisions: + raise DecisionError(f"{path}: decisions must be a non-empty list") + roles = None + if maintainers: + roles = set((yaml.safe_load(Path(maintainers).read_text(encoding="utf-8")) or {}).get("roles", {})) + seen = set() + errors = [] + in_force = data.get("in_force") + if not isinstance(in_force, dict) or not in_force.get("source") or not as_date(in_force.get("date")): + errors.append(f"{path}: in_force needs source and ISO date") + elif in_force["source"] not in sources: + errors.append(f"{path}: in_force source {in_force['source']!r} not listed under sources") + for index, d in enumerate(decisions): + where = f"decisions[{index}]" + if not isinstance(d, dict): + errors.append(f"{where}: not a mapping") + continue + missing = [key for key in REQUIRED if key not in d] + if d.get("status") != "superseded" and "check" not in d: + missing.append("check") + if missing: + errors.append(f"{where}: missing {', '.join(missing)}") + continue + where = d["id"] + if d["id"] in seen: + errors.append(f"{where}: duplicate id") + seen.add(d["id"]) + if d["status"] not in STATUSES: + errors.append(f"{where}: status must be one of {sorted(STATUSES)}") + if d["kind"] not in KINDS: + errors.append(f"{where}: kind must be one of {sorted(KINDS)}") + if d.get("date") is not None and as_date(d.get("date")) is None: + errors.append(f"{where}: date must be null or an ISO date") + if as_date(d.get("review_by")) is None: + errors.append(f"{where}: review_by must be an ISO date") + ver = d["verification"] + human = ver.get("human") if isinstance(ver, dict) else None + if not isinstance(ver, dict) or not ver.get("machine") or not isinstance(human, dict) \ + or not human.get("reviewer") or not human.get("evidence"): + errors.append(f"{where}: verification needs machine and human (reviewer, evidence)") + elif roles is not None and human["reviewer"] not in roles: + errors.append(f"{where}: reviewer {human['reviewer']!r} is not a role in the maintainers map") + if "value" not in d and "values" not in d: + errors.append(f"{where}: needs value or values") + for field in ("summary", "fix_hint"): + text = d.get(field) + if text is not None and (not isinstance(text, str) or not text.strip() or "\n" in text + or len(text) > HINT_MAX): + errors.append(f"{where}: {field} must be one non-empty line of at most {HINT_MAX} characters") + src = d["source"] + if not isinstance(src, dict) or not src.get("document") or not src.get("item"): + errors.append(f"{where}: source needs document and item") + elif src["document"] not in sources: + errors.append(f"{where}: source document {src['document']!r} not listed under sources") + if roles is not None and d["owner"] not in roles: + errors.append(f"{where}: owner {d['owner']!r} is not a role in the maintainers map") + for sup in d.get("supersedes") or []: + if not isinstance(sup, dict) or "value" not in sup or not sup.get("source"): + errors.append(f"{where}: each supersedes entry needs value and source") + elif sup.get("citation"): + try: + if "found" not in re.compile(sup["citation"], re.IGNORECASE).groupindex: + errors.append(f"{where}: citation needs a (?P...) group") + except re.error as exc: + errors.append(f"{where}: bad citation pattern: {exc}") + applies = d["applies_to"] + if not isinstance(applies, dict) or not applies.get("repositories") or not applies.get("files"): + errors.append(f"{where}: applies_to needs repositories and files") + if d["status"] == "superseded": + by = d.get("superseded_by") + if not isinstance(by, dict) or not by.get("document") or not by.get("item") or not by.get("citation"): + errors.append(f"{where}: superseded needs superseded_by with document, item and citation") + else: + if by["document"] not in sources: + errors.append(f"{where}: superseded_by document {by['document']!r} not listed under sources") + try: + if "found" not in re.compile(by["citation"], re.IGNORECASE).groupindex: + errors.append(f"{where}: superseded_by citation needs a (?P...) group") + except re.error as exc: + errors.append(f"{where}: bad superseded_by citation pattern: {exc}") + continue + checks = d["check"] + if not isinstance(checks, list) or not checks: + errors.append(f"{where}: check must be a non-empty list of patterns") + continue + for c in checks: + try: + rx = re.compile(c["pattern"], re.IGNORECASE) + if "found" not in rx.groupindex: + errors.append(f"{where}: pattern needs a (?P...) group") + if c.get("unless"): + re.compile(c["unless"], re.IGNORECASE) + except (KeyError, TypeError, re.error) as exc: + errors.append(f"{where}: bad check pattern: {exc}") + if errors: + raise DecisionError("\n".join(errors)) + exclude = data.get("exclude") or [] + entry_lines = {} + for number, text in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1): + m = re.match(r"\s*-\s+id:\s*(\S+)", text) + if m: + entry_lines.setdefault(m.group(1), number) + for d in decisions: + d["_line"] = entry_lines.get(d["id"]) + d["_exclude"] = list(exclude) + list(d["applies_to"].get("exclude") or []) + if d["status"] == "superseded": + by = d["superseded_by"] + # A superseded entry is scanned only for text that still presents it as current. + d["_checks"] = [{"pattern": by["citation"], "unless": by.get("unless"), + "message": f"superseded decision presented as current; superseded by " + f"{by['document']} {by['item']}" + + (f" ({by['decision']})" if by.get("decision") else "")}] + continue + d["_checks"] = list(d["check"]) + [ + {"pattern": sup["citation"], "unless": sup.get("citation_unless"), + "message": f"superseded source still cited ({sup['source']}); cite {d['source']['document']}"} + for sup in d.get("supersedes") or [] if isinstance(sup, dict) and sup.get("citation")] + return decisions + + +def decided_text(d): + if d["status"] == "superseded": + by = d["superseded_by"] + return f"superseded by {by.get('decision') or by['document'] + ' ' + by['item']}" + value = d["values"] if "values" in d else d["value"] + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + unit = d.get("unit") + return f"{value} {unit}".strip() if unit and unit != "none" else str(value) + + +def short_decision(d, limit=HINT_MAX): + """One short line for a finding: the entry's summary, else its value cut to size.""" + text = d.get("summary") or decided_text(d) + return text if len(text) <= limit else text[:limit - 3].rstrip() + "..." + + +def to_watchdog(records, decisions): + """Turn scan() records into Watchdog findings with decision, why, fix and links.""" + by_id = {d["id"]: d for d in decisions} + out = [] + for r in records: + d = by_id[r["id"]] + anchor = f"#L{d['_line']}" if d.get("_line") else "" + out.append(wr.finding( + LABEL, r["id"], r["file"], r["line"], r["found"], + short_decision(d), + (r.get("message") or "this line disagrees with the approved decision").rstrip(".") + ".", + d.get("fix_hint") or "Correct the line to the current decision, or label historical material.", + [(f"{d['id']} in decisions.yaml", f"{wr.REGISTER}{anchor}"), + ("WATCHDOG.md", f"{wr.DOCS}#decisions-of-record")])) + return out + + +def repository_matches(d, repository): + repos = d["applies_to"]["repositories"] + return repository is None or any(fnmatch.fnmatch(repository, r) for r in repos) + + +def glob_match(rel, patterns): + rel = rel.replace("\\", "/") + for pattern in patterns: + if fnmatch.fnmatch(rel, pattern): + return True + if pattern.startswith("**/") and fnmatch.fnmatch(rel, pattern[3:]): + return True + return False + + +def list_files(root): + root = Path(root) + try: + out = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True + ).stdout.decode("utf-8", "replace") + files = [f for f in out.split("\0") if f] + if files: + return files + except (OSError, subprocess.CalledProcessError): + pass + files = [] + for path in root.rglob("*"): + rel = path.relative_to(root) + if path.is_file() and not SKIP_DIRS.intersection(rel.parts): + files.append(rel.as_posix()) + return sorted(files) + + +def scan(root, decisions, repository=None, only=None, stats=None): + """Return (findings, allowed) for the checkout at root.""" + root = Path(root) + files = list(only) if only is not None else list_files(root) + findings, allowed = [], [] + unscanned = 0 + active = [d for d in decisions if d["status"] in ("recorded", "superseded") and repository_matches(d, repository)] + for rel in files: + path = root / rel + if not path.is_file(): + continue + relevant = [d for d in active if glob_match(rel, d["applies_to"].get("files") or DEFAULT_FILES) + and not glob_match(rel, d["_exclude"])] + if not relevant: + unscanned += 1 + continue + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (UnicodeDecodeError, OSError): + continue + for number, line in enumerate(lines, 1): + for d in relevant: + hit = False + for c in d["_checks"]: + if hit: + break + if c.get("files") and not glob_match(rel, c["files"]): + continue + for m in re.finditer(c["pattern"], line, re.IGNORECASE): + if c.get("unless") and re.search(c["unless"], line, re.IGNORECASE): + continue + record = { + "id": d["id"], "file": rel, "line": number, + "found": m.group("found").strip(), "decided": decided_text(d), + "source": f"{d['source']['document']} {d['source']['item']}", + "message": c.get("message", ""), + } + hit = True + marker = allow_marker(lines, number, d["id"]) + if marker: + record["reason"] = marker + allowed.append(record) + else: + findings.append(record) + break + if stats is not None: + stats["unscanned"] = unscanned + return findings, allowed + + +def allow_marker(lines, number, decision_id): + for candidate in (lines[number - 1], lines[number - 2] if number > 1 else ""): + m = ALLOW.search(candidate) + if m and m.group("id") == decision_id: + return m.group("reason").strip() + return None + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--decisions", type=Path, required=True) + p.add_argument("--maintainers", type=Path, help="maintainers.yaml; validates owner roles") + p.add_argument("--root", type=Path, default=Path("."), help="checkout to scan") + p.add_argument("--repository", help="repository name used for applies_to.repositories") + p.add_argument("--changed-files", type=Path, help="scan only the paths listed in this file") + p.add_argument("--validate-only", action="store_true") + p.add_argument("--json", type=Path, help="write findings as JSON") + a = p.parse_args(argv) + try: + decisions = load_decisions(a.decisions, a.maintainers) + except DecisionError as exc: + print(f"INVALID decisions file:\n{exc}", file=sys.stderr) + return 2 + print(f"decisions: {len(decisions)} loaded, " + f"{sum(d['status'] == 'recorded' for d in decisions)} recorded and scanned, " + f"{sum(d['status'] == 'open' for d in decisions)} open, " + f"{sum(d['status'] == 'superseded' for d in decisions)} superseded (citations scanned)") + try: + register_data = yaml.safe_load(a.decisions.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + register_data = {} + for warning in review_warnings(register_data): + print(f"WARNING {warning}") + if a.validate_only: + return 0 + if not a.root.is_dir(): + print(f"root is not a directory: {a.root}", file=sys.stderr) + return 2 + only = None + if a.changed_files: + only = [line.strip() for line in a.changed_files.read_text(encoding="utf-8").splitlines() if line.strip()] + stats = {} + findings, allowed = scan(a.root, decisions, a.repository, only, stats) + for f in allowed: + print(f"Allowed by decision-allow marker: {f['file']}:{f['line']}: {f['id']} found {f['found']!r}; " + f"reason: {f['reason']}") + friendly = to_watchdog(findings, decisions) + for line in wr.render(friendly, "Decisions of record", NEXT_STEP): + print(line) + wr.emit_github(friendly, "Decisions of record", NEXT_STEP) + if a.json: + a.json.write_text(json.dumps({"findings": findings, "allowed": allowed}, indent=2), encoding="utf-8") + scope = f"{len(only)} changed file(s)" if only is not None else "full checkout" + print(f"result: {len(findings)} contradiction(s), {len(allowed)} allowed, scope {scope}; " + f"{stats['unscanned']} file(s) not scanned (no decision covers their type or path)") + print("note: a clean result shows textual consistency only, not mechanical, electrical or safety correctness") + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_pr_evidence.py b/tools/check_pr_evidence.py new file mode 100644 index 0000000..ffa53e7 --- /dev/null +++ b/tools/check_pr_evidence.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Check a pull request description and diff against the evidence rules. + +Fails when a required template section is missing or empty, when the +Evidence section lacks base SHA, head SHA or a command, when test files +change without a reported non-zero test run, or when safety paths change +and fewer than two human reviewers (including the platform lead) are +requested. Requesting reviewers is not approval: this check reports "safety +path touched, two human approvals required" and counts approvals for +information only; the approvals themselves are enforced by the repository +ruleset (rollout/workflows/SETUP.md), not by this check. Writes one +Markdown summary; a decisions-check run that ends in anything other than +its two documented outcomes (exit 0 clean, exit 1 with contradictions) is a +"checker error" and fails closed. With --post it creates or updates a single PR comment +identified by a hidden marker. Exit status: 0 pass, 1 fail, 2 usage error. +""" +import argparse +import fnmatch +import json +import os +import re +import sys +import urllib.request +from pathlib import Path + +import yaml + +MARKER = "" +SECTIONS = ["Work package", "Integration Gate", "Tests", "Evidence", "Not verified", "AI disclosure"] +SHA = r"\b[0-9a-f]{7,40}\b" +TEST_PATH = [ + "test/*", "tests/*", "*/test/*", "*/tests/*", "*test_*.py", "*_test.py", "*_test.*", + "*.test.*", "*.spec.*", "*/__tests__/*", +] +DEPENDENCY_FILES = [ + "package.xml", "requirements*.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile*", + "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "*.repos", + "platformio.ini", "Cargo.toml", "Cargo.lock", "go.mod", "go.sum", +] +NONE = re.compile(r"^\s*(?:none|no|n/a)\b", re.I) +COUNT = re.compile( + r"(?:\bran\s+(?P\d+)\s+tests?)|(?:\b(?P\d+)\s+(?:tests?\s+)?passed)|(?:\b(?P\d+)\s+tests?\b)", + re.I, +) + + +def sections(body): + """Map heading text (level 2 or 3) to its content, HTML comments removed.""" + body = re.sub(r"", "", body or "", flags=re.S) + out, current = {}, None + for line in body.splitlines(): + m = re.match(r"^#{2,3}\s+(.+?)\s*$", line) + if m: + current = m.group(1).strip().lower() + out[current] = [] + elif current is not None: + out[current].append(line) + return {k: "\n".join(v).strip() for k, v in out.items()} + + +def find(secs, name): + name = name.lower() + for key, value in secs.items(): + if key == name or key.startswith(name): + return value + return None + + +def is_test_path(path): + return any(fnmatch.fnmatch(path, p) for p in TEST_PATH) + + +def reported_counts(text): + counts = [] + for m in COUNT.finditer(text or ""): + counts.append(int(next(g for g in m.groups() if g is not None))) + return counts + + +def human(login): + return bool(login) and not login.endswith("[bot]") + + +AI_TOOL = re.compile(r"\b(?:Claude(?:\s+Code)?|Anthropic|ChatGPT|OpenAI|Codex|Copilot|Gemini|Cursor|Devin)\b", re.I) +AI_MARKER = re.compile(r"(?:AI[-\s]+assisted|generated with|co-authored-by:.*(?:bot|claude|copilot|chatgpt|openai|anthropic|codex))", re.I) +AI_SCOPE = re.compile(r"\b(?:scope|drafted|generated|reviewed|changed|implemented|tested|research|documentation|workflow|code|text|analysis|reconciliation)\b", re.I) + + +def ai_assistance_detected(pr, commit_messages=()): + text = (pr.get("body") or "") + "\n" + "\n".join(commit_messages or ()) + return bool(AI_TOOL.search(text) or AI_MARKER.search(text)) + + +def check_ai_disclosure(pr, commit_messages, disclosure): + if not ai_assistance_detected(pr, commit_messages): + return [] + if not disclosure or NONE.match(disclosure): + return ["AI assistance is visible in the PR or commit messages, but AI disclosure is empty; name the tool and scope"] + failures = [] + if not AI_TOOL.search(disclosure): + failures.append("AI disclosure must name the AI tool used (for example Claude Code, ChatGPT or Codex)") + if not AI_SCOPE.search(disclosure): + failures.append("AI disclosure must state the scope of assistance (what it drafted, changed, tested or reviewed)") + return failures + + +def evaluate(pr, changed, maintainers, reviews=(), has_state=False, commit_messages=()): + """Return (failures, warnings, notes) for a pull_request payload.""" + failures, warnings, notes = [], [], [] + secs = sections(pr.get("body") or "") + for name in SECTIONS: + content = find(secs, name) + if content is None: + failures.append(f"Missing section: {name}") + elif not re.sub(r"[-*\s:|]|\[ \]", "", content): + failures.append(f"Empty section: {name}") + + evidence = find(secs, "Evidence") or "" + base = re.search(r"base(?:\s+sha)?\s*[:=]\s*`?(" + SHA + ")", evidence, re.I) + head = re.search(r"head(?:\s+sha)?\s*[:=]\s*`?(" + SHA + ")", evidence, re.I) + if not base: + failures.append("Evidence: no base SHA (write `Base SHA: `)") + if not head: + failures.append("Evidence: no head SHA (write `Head SHA: `)") + elif pr.get("head", {}).get("sha") and not pr["head"]["sha"].startswith(head.group(1)): + text = f"Evidence: stated head {head.group(1)} is not the PR head {pr['head']['sha'][:12]}" + (warnings if pr.get("draft") else failures).append(text + ("; update before ready for review" if pr.get("draft") else "")) + fenced = [b for b in re.findall(r"```[^\n]*\n(.*?)```", evidence, re.S) if b.strip()] + if not fenced and not re.search(r"^\s*\$ \S", evidence, re.M): + failures.append("Evidence: no exact command (use a code block or `$ command` lines)") + + tests_changed = sorted(p for p in changed if is_test_path(p)) + if tests_changed: + counts = reported_counts((find(secs, "Tests") or "") + "\n" + evidence) + if not counts: + failures.append(f"Test files changed ({len(tests_changed)}) but no test run with a count is reported") + elif max(counts) == 0: + failures.append("Reported test run executed zero tests") + else: + notes.append(f"Test files changed: {len(tests_changed)}; reported run counts: {counts}") + + deps = sorted(p for p in changed if any(fnmatch.fnmatch(p.rsplit("/", 1)[-1], g) for g in DEPENDENCY_FILES) + and not re.search(r"(^|/)(fixtures|testdata)/", p)) + if deps: + section = find(secs, "Dependencies") or "" + if not section or NONE.match(section): + failures.append(f"Dependency manifests changed ({', '.join(deps[:5])}) but the Dependencies " + "section does not name the change, licence and source") + + if has_state and "STATE.md" not in changed: + section = find(secs, "STATE.md") or "" + if not re.search(r"no change\W+\w", section, re.I): + failures.append("STATE.md exists but is not updated; update it or write 'no change' with a reason") + + ai_disclosure = find(secs, "AI disclosure") or "" + failures.extend(check_ai_disclosure(pr, commit_messages, ai_disclosure)) + + contribution_terms = find(secs, "Contribution terms") + if contribution_terms is not None and not re.search( + r"\[x\]\s+No partner, customer or private person is named; the application is Use_Case_1\.", + contribution_terms, re.I + ): + failures.append("Contribution terms: check the Use_Case_1/no-private-person checkbox") + + roles = (maintainers or {}).get("roles", {}) + lead = (roles.get("platform-lead") or {}).get("handle") + safety = [p for p in changed if any(fnmatch.fnmatch(p, g) or fnmatch.fnmatch(p, g.replace("**/", "")) + for g in (maintainers or {}).get("safety_paths", []))] + if safety: + people = {u.get("login") for u in pr.get("requested_reviewers") or []} + people |= {r.get("user", {}).get("login") for r in reviews} + people = {p for p in people if human(p) and p != pr.get("user", {}).get("login")} + approvals = {r.get("user", {}).get("login") for r in reviews if r.get("state") == "APPROVED"} + approvals = {p for p in approvals if human(p) and p != pr.get("user", {}).get("login")} + notes.append(f"Safety path touched, two human approvals required: {', '.join(safety[:10])}. " + f"Human approvals so far: {len(approvals)} (information only; the ruleset enforces approvals, " + "this check does not)") + if len(people) < 2: + failures.append(f"Safety path touched, two human approvals required: only {len(people)} " + "human reviewer(s) requested") + if lead and lead not in people: + failures.append(f"Safety path touched, two human approvals required: platform lead @{lead} " + "is not requested") + ai = find(secs, "AI disclosure") or "" + if ai and not NONE.match(ai): + failures.append("Safety paths changed in a PR with AI assistance; agents do not author " + "safety logic, a human authors it and the agent reports the need in an issue") + return failures, warnings, notes + + +# First line of each check_decisions.py finding (tools/watchdog_report.py format). +DECISION_FINDING = "Mismatch with approved decision: " +DECISION_GROUP = re.compile(r"^Mismatch with approved decision: (?P\S+) \(\d+ finding\(s\)\)$") +DECISION_ITEM = re.compile(r"^ (?P\S.*?:\d+): found (?P.*)$") + + +def decision_findings(report): + """One line per finding from the grouped Watchdog report: place, ID, found text and the + group's one-line Decision ("docs/a.md:4: ID found 'x'; decision: ...").""" + out = [] + group = decision = None + for line in report.splitlines(): + m = DECISION_GROUP.match(line) + if m: + group, decision = m.group("id"), None + continue + if group is None: + continue + if line.startswith(" Decision:"): + decision = line[len(" Decision:"):].strip() + continue + item = DECISION_ITEM.match(line) + if item: + text = f"{item.group('place')}: {group} found {item.group('found')}" + out.append(text + (f"; decision: {decision}" if decision else "")) + elif not line.startswith(" "): + group = None + return out + + +def decision_failures(report, limit=20): + """Turn check_decisions.py findings into summary failures.""" + lines = decision_findings(report) + out = [f"Decision contradiction: {l}" for l in lines[:limit]] + if len(lines) > limit: + out.append(f"... and {len(lines) - limit} more decision contradictions") + if "INVALID decisions file" in report: + out.append("decisions.yaml is invalid; see the workflow log") + return out + + +RESULT_LINE = re.compile(r"^result: (\d+) contradiction\(s\)", re.M) + + +def decision_verdict(report, status): + """Classify a check_decisions.py run; return (failures, checker_errors). + + report is the checker's combined output (None if the file is missing); + status is the parsed status file, e.g. {"exit_code": 1} (None if missing). + Only the two documented policy outcomes are accepted: + exit 0 with "result: 0 contradiction(s)" and no finding lines (clean); + exit 1 with "Mismatch with approved decision:" finding lines whose count matches the result line. + Everything else fails closed as a checker error: a crash, exit 2 (invalid + register or usage), any other exit code, empty output, or a missing file. + """ + if report is None: + return [], ["checker error: decisions report missing; the decisions check did not produce output"] + code = status.get("exit_code") if isinstance(status, dict) else None + if not isinstance(code, int) or isinstance(code, bool): + return [], ["checker error: decisions status file missing or unreadable; exit status of " + "check_decisions.py unknown"] + head = " | ".join(l for l in report.strip().splitlines()[-3:]) or "no output" + if "Traceback (most recent call last)" in report: + return [], [f"checker error: check_decisions.py crashed (exit {code}): {head}"] + listed = decision_failures(report) + contradictions = decision_findings(report) + m = RESULT_LINE.search(report) + reported = int(m.group(1)) if m else None + if code == 0 and reported == 0 and not contradictions: + return [], [] + if code == 1 and reported is not None and reported == len(contradictions) > 0: + return listed, [] + if code == 2: + return [], [f"checker error: check_decisions.py exit 2 (invalid register or usage error): {head}"] + return [], [f"checker error: unexpected check_decisions.py outcome (exit {code}, " + f"{len(contradictions)} contradiction line(s), result line " + f"{'missing' if reported is None else reported}): {head}"] + + +def render(failures, warnings, notes, pr, checker_errors=()): + status = "CHECKER ERROR" if checker_errors else ("FAIL" if failures else "PASS") + lines = [MARKER, f"### PR evidence check: {status}", "", + f"Head checked: `{pr.get('head', {}).get('sha', 'unknown')[:12]}`. " + "This comment is updated in place on every push. It never approves or merges.", ""] + for title, items in (("Checker errors (fails closed; the result of the check is unknown)", + list(checker_errors)), + ("Failures", failures), ("Warnings", warnings), ("Notes", notes)): + if items: + lines.append(f"**{title}**") + lines += [f"- {i}" for i in items] + lines.append("") + if not (failures or warnings or notes or checker_errors): + lines.append("All required sections and evidence are present.") + lines.append("Rules: AGENTS.md in openAMRobot/.github; template: .github/PULL_REQUEST_TEMPLATE.md.") + return "\n".join(lines) + "\n" + + +def api(method, url, token, data=None): + req = urllib.request.Request(url, method=method, data=json.dumps(data).encode() if data else None, + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b"null") + + +def upsert_comment(repo, number, body, token, call=api): + base = f"https://api.github.com/repos/{repo}/issues" + page = 1 + while True: + comments = call("GET", f"{base}/{number}/comments?per_page=100&page={page}", token) + for c in comments: + if MARKER in (c.get("body") or ""): + call("PATCH", f"{base}/comments/{c['id']}", token, {"body": body}) + return "updated" + if len(comments) < 100: + break + page += 1 + call("POST", f"{base}/{number}/comments", token, {"body": body}) + return "created" + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--event", type=Path, default=os.environ.get("GITHUB_EVENT_PATH")) + p.add_argument("--changed-files", type=Path, required=True) + p.add_argument("--maintainers", type=Path, required=True) + p.add_argument("--reviews", type=Path, help="JSON list of PR reviews") + p.add_argument("--commit-messages", type=Path, help="one or more PR commit messages, one per line or JSON text") + p.add_argument("--root", type=Path, help="checkout of the PR head; enables the STATE.md rule") + p.add_argument("--decisions-report", type=Path, + help="combined output of check_decisions.py on the diff") + p.add_argument("--decisions-status", type=Path, + help='JSON status file written by the workflow, e.g. {"exit_code": 1}; required with --decisions-report') + p.add_argument("--output", type=Path, help="write the Markdown summary here") + p.add_argument("--post", action="store_true", help="create or update the PR comment") + a = p.parse_args(argv) + if not a.event: + p.error("--event or GITHUB_EVENT_PATH is required") + event = json.loads(Path(a.event).read_text(encoding="utf-8")) + pr = event.get("pull_request") + if not pr: + print("not a pull_request event; nothing to check", file=sys.stderr) + return 2 + changed = [l.strip() for l in a.changed_files.read_text(encoding="utf-8").splitlines() if l.strip()] + maintainers = yaml.safe_load(a.maintainers.read_text(encoding="utf-8")) + reviews = json.loads(a.reviews.read_text(encoding="utf-8")) if a.reviews else [] + commit_messages = a.commit_messages.read_text(encoding="utf-8").splitlines() if a.commit_messages else [] + has_state = bool(a.root and (a.root / "STATE.md").is_file()) + failures, warnings, notes = evaluate(pr, changed, maintainers, reviews, has_state, commit_messages) + checker_errors = [] + if a.decisions_report or a.decisions_status: + report = status = None + try: + report = a.decisions_report.read_text(encoding="utf-8") if a.decisions_report else None + except OSError: + report = None + try: + status = json.loads(a.decisions_status.read_text(encoding="utf-8")) if a.decisions_status else None + except (OSError, ValueError): + status = None + decision_fails, checker_errors = decision_verdict(report, status) + failures += decision_fails + summary = render(failures, warnings, notes, pr, checker_errors) + print(summary) + if a.output: + a.output.write_text(summary, encoding="utf-8") + if a.post: + token, repo = os.environ.get("GITHUB_TOKEN"), os.environ.get("GITHUB_REPOSITORY") + if not token or not repo: + print("--post needs GITHUB_TOKEN and GITHUB_REPOSITORY", file=sys.stderr) + return 2 + print(f"comment {upsert_comment(repo, pr['number'], summary, token)}") + return 1 if failures or checker_errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_public_extract.py b/tools/check_public_extract.py new file mode 100644 index 0000000..860bb38 --- /dev/null +++ b/tools/check_public_extract.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Fail when public material contains internal links, personal contact data, +prices or credential-like strings. + +Scope: files under docs/ or assets/, every README.md, and every path that +contains "public". Rules: google-drive-link, email, phone, price, credential. +An allowlist file (YAML) can exempt a match; every entry needs a rule, a +regular expression for the matched text, optional path, repository and line +conditions, and a reason. GitHub handles (@name) are not e-mail addresses and +are never reported. +Exit status: 0 clean, 1 findings, 2 usage or configuration error. +""" +import argparse +import fnmatch +import json +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import watchdog_report as wr # noqa: E402 + +LABEL = "Should not be public" +NEXT_STEP = ("remove each value from the public file or move it to an internal place; if it is " + "intentionally public (organization contact, licensing), the docs owner decides an allowlist entry.") +# Per rule: the rule in one line, why it matters, how to fix. +GUIDE = { + "google-drive-link": ("Public files do not link to internal Google Drive or Docs documents.", + "Internal documents are private; a public link leaks their existence or breaks for readers.", + "Remove the link, or publish the content in the repository or documentation site and link there."), + "email": ("Public files carry no personal e-mail addresses.", + "Personal contact data must not be published; the organization contact is the only listed address.", + "Use the organization contact address or a GitHub handle instead."), + "phone": ("Public files carry no phone numbers.", + "Personal contact data must not be published.", + "Remove the number; point readers to the organization contact or a GitHub issue."), + "price": ("Public files carry no prices outside the documented public pricing.", + "Prices change and are commercial information; stale or internal prices mislead readers.", + "Remove the price, or link to the one canonical public pricing page."), + "credential": ("Public files never contain credentials or secrets.", + "A published secret can be abused at once and must be treated as compromised.", + "Remove it, rotate the secret now, and load it from a secret store instead."), +} + + +def to_watchdog(findings): + out = [] + for f in findings: + rule, why, fix = GUIDE[f["rule"]] + out.append(wr.finding(LABEL, f["rule"], f["file"], f["line"], f["found"], rule, why, fix, + [("WATCHDOG.md", f"{wr.DOCS}#public-extract")])) + return out + +RULES = { + "google-drive-link": re.compile(r"https?://(?:drive|docs)\.google\.com/[^\s\"'<>)\]]*[^\s\"'<>)\].,;:]", re.I), + "email": re.compile(r"(? with the harness merge SHA.") +GUIDE = { + "unpinned-action": ("Workflow not pinned", + "Every action in a live workflow is pinned to a full 40-character commit SHA.", + "A tag or branch can be moved to different code; a SHA cannot.", + "Replace the tag with the release's commit SHA, e.g. actions/checkout@ # v4.4.0."), + "harness-placeholder": ("Placeholder left in workflow", + "Live workflows contain no placeholder from a copied example.", + "The placeholder is not a valid reference, so the workflow cannot run as intended.", + "Replace with the openAMRobot/.github merge SHA (rollout/README.md step b)."), + "unreadable": ("Workflow unreadable", + "Live workflow files are readable UTF-8 text.", + "An unreadable workflow cannot be checked.", + "Re-save the file as UTF-8 text."), +} +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") +USES = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)") + + +def workflow_files(root): + directory = Path(root) / ".github" / "workflows" + if not directory.is_dir(): + return [] + return sorted(path for path in directory.rglob("*") + if path.is_file() and path.suffix in {".yml", ".yaml"}) + + +def records(root): + """Structured findings: dicts with file, line, rule and text.""" + root = Path(root) + out = [] + for path in workflow_files(root): + rel = path.relative_to(root).as_posix() + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as exc: + out.append({"file": rel, "line": 1, "rule": "unreadable", "text": f"cannot read workflow: {exc}"}) + continue + for number, line in enumerate(lines, 1): + if "" in line: + out.append({"file": rel, "line": number, "rule": "harness-placeholder", + "text": "unresolved placeholder in live workflow"}) + match = USES.match(line) + if not match: + continue + reference = match.group(1) + if reference.startswith("./") or reference.startswith("docker://"): + continue + if "@" not in reference: + out.append({"file": rel, "line": number, "rule": "unpinned-action", + "text": f"action reference has no immutable @: {reference}", "found": reference}) + continue + action, ref = reference.rsplit("@", 1) + if not action or not FULL_SHA.fullmatch(ref): + out.append({"file": rel, "line": number, "rule": "unpinned-action", + "text": f"action is not pinned to a full commit SHA: {reference}", "found": reference}) + return out + + +def findings(root): + """Findings as "path:line: text" strings.""" + return [f"{r['file']}:{r['line']}: {r['text']}" for r in records(root)] + + +def to_watchdog(recs): + out = [] + for r in recs: + label, rule, why, fix = GUIDE[r["rule"]] + found = r.get("found") or ("" if r["rule"] == "harness-placeholder" else r["text"]) + out.append(wr.finding(label, r["rule"], r["file"], r["line"], found, rule, why, fix, + [("WATCHDOG.md", f"{wr.DOCS}#workflow-policy")])) + return out + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--root", type=Path, default=Path(".")) + args = parser.parse_args(argv) + if not args.root.is_dir(): + print(f"root is not a directory: {args.root}", file=sys.stderr) + return 2 + recs = records(args.root) + errors = [f"{r['file']}:{r['line']}: {r['text']}" for r in recs] + friendly = to_watchdog(recs) + if friendly: + for line in wr.render(friendly, "Workflow policy", NEXT_STEP, decision_word="Rule"): + print(line) + wr.emit_github(friendly, "Workflow policy", NEXT_STEP) + if errors: + print(f"result: {len(errors)} workflow policy finding(s)") + return 1 + print("result: 0 workflow policy findings") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sync_audit_issues.py b/tools/sync_audit_issues.py new file mode 100644 index 0000000..8e8947d --- /dev/null +++ b/tools/sync_audit_issues.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Plan and apply issue updates from an alignment-audit ISSUES.csv. + +Opens one issue per Blocker or Major finding that has no issue yet, in the +repository named in file_to_change (or the fallback repository), assigned to +the owner role from maintainers.yaml by audit ID prefix. It never closes an +issue: when an open audit issue's finding is absent from the new CSV or has +status resolved, it comments "no longer detected" once and leaves closure to +the issue's owner, because one run not reporting a finding is not proof that +it is fixed. + +Issues in public repositories are public extracts: they carry the finding ID, +severity, area, the repository paths to change and the owner handle, never +the free-text columns, which can quote internal documents. The full text goes +only to the fallback (private) repository. +Exit status: 0 success, 2 usage error. +""" +import argparse +from datetime import date +import csv +import json +import os +import re +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +import yaml + +LABEL = "audit-finding" +NOT_DETECTED = "" +OPEN_SEVERITIES = {"Blocker", "Major"} +REPO = re.compile(r"\b(openamr(?:obot)?-[a-z0-9-]+|\.github)\b") + + + +def review_warnings(path, today=None): + """Return warnings for decision entries whose review window has passed.""" + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} + today = today or date.today() + warnings = [] + for entry in data.get("decisions") or []: + value = entry.get("review_by") if isinstance(entry, dict) else None + if isinstance(value, date): + review_by = value + else: + try: + review_by = date.fromisoformat(str(value)) + except (TypeError, ValueError): + continue + if review_by < today: + warnings.append(f"{entry.get('id', '')} review_by {review_by.isoformat()} is past due") + return warnings + +def read_csv(path): + with open(path, newline="", encoding="utf-8") as stream: + return list(csv.DictReader(stream)) + + +def title_for(row): + return f"[audit] {row['id']}: {row.get('area', '').strip()}"[:120] + + +def finding_id(title): + m = re.match(r"\[audit\] ([A-Z]+-\d+)", title or "") + return m.group(1) if m else None + + +def target_repository(row, public_repos, fallback): + for name in REPO.findall(row.get("file_to_change", "")): + if name in public_repos: + return name + return fallback + + +def owner_handle(row, maintainers): + role = maintainers.get("audit_prefixes", {}).get(row["id"].split("-")[0]) + handle = (maintainers.get("roles", {}).get(role) or {}).get("handle") if role else None + return role or "unassigned", handle + + +def body_for(row, repository, fallback, maintainers, report): + role, handle = owner_handle(row, maintainers) + owner = f"@{handle} ({role})" if handle else f"{role}, no handle recorded" + paths = sorted(set(re.findall(r"[\w./-]+\.(?:md|ya?ml|py|xml|xacro|urdf|launch\.py|json|html)", + row.get("file_to_change", "")))) + lines = [f"Audit finding **{row['id']}** ({row['severity']}), area: {row.get('area', '')}.", "", + f"Owner: {owner}", f"Source: {report}", ""] + if paths: + lines += ["Files to change:"] + [f"- `{p}`" for p in paths] + [""] + if repository == fallback: + for key in ("value_a", "value_b", "decision_of_record", "fix"): + if row.get(key): + lines += [f"**{key}**: {row[key]}", ""] + else: + lines += ["Details are in the audit report named above. This issue is a public extract.", ""] + lines.append("Opened by the weekly alignment audit. Close it with the fixing PR; the next audit verifies.") + return "\n".join(lines) + + +def plan(new_rows, existing, maintainers, fallback, report): + """Return (to_open, to_notify). existing: list of {repository, number, title, state}.""" + public_repos = set(maintainers.get("repositories", {})) + known = {} + for issue in existing: + fid = finding_id(issue.get("title")) + if fid: + known.setdefault(fid, []).append(issue) + active = {r["id"]: r for r in new_rows if (r.get("status") or "open").lower() != "resolved"} + to_open = [] + for fid, row in active.items(): + if row.get("severity") not in OPEN_SEVERITIES or fid in known: + continue + repository = target_repository(row, public_repos, fallback) + to_open.append({"repository": repository, "title": title_for(row), + "body": body_for(row, repository, fallback, maintainers, report), + "labels": [LABEL, row["severity"].lower()]}) + to_notify = [dict(issue, id=fid) for fid, issues in known.items() if fid not in active + for issue in issues if issue.get("state") == "open"] + return to_open, to_notify + + +def api(method, url, token, data=None): + req = urllib.request.Request(url, method=method, data=json.dumps(data).encode() if data else None, + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b"null") + + +def fetch_existing(org, token, call=api): + query = urllib.parse.quote(f"org:{org} label:{LABEL} is:issue") + out, page = [], 1 + while True: + data = call("GET", f"https://api.github.com/search/issues?q={query}&per_page=100&page={page}", token) + for item in data.get("items", []): + out.append({"repository": item["repository_url"].rsplit("/", 1)[-1], "number": item["number"], + "title": item["title"], "state": item["state"]}) + if len(data.get("items", [])) < 100: + return out + page += 1 + + +def apply(org, to_open, to_notify, token, report, call=api): + """Create issues; comment once on issues no longer detected. Never closes.""" + base = f"https://api.github.com/repos/{org}" + for issue in to_open: + call("POST", f"{base}/{issue['repository']}/issues", token, + {"title": issue["title"], "body": issue["body"], "labels": issue["labels"]}) + for issue in to_notify: + url = f"{base}/{issue['repository']}/issues/{issue['number']}/comments" + comments = call("GET", f"{url}?per_page=100", token) or [] + if any(NOT_DETECTED in (c.get("body") or "") for c in comments): + continue + call("POST", url, token, {"body": ( + f"{NOT_DETECTED}\nNo longer detected by the alignment audit {report}. " + "The owner closes this issue after checking the fix; the audit does not close it.")}) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--issues", type=Path, required=True, help="new ISSUES.csv") + p.add_argument("--maintainers", type=Path, required=True) + p.add_argument("--fallback-repository", required=True, help="private repository for findings without a public target") + p.add_argument("--report", required=True, help="report reference, e.g. folder@SHA") + p.add_argument("--existing", type=Path, help="JSON list of existing audit issues (dry run input)") + p.add_argument("--org", default="openAMRobot") + p.add_argument("--apply", action="store_true", help="fetch existing issues, then create and close via the API") + p.add_argument("--plan-output", type=Path) + p.add_argument("--decisions", type=Path, help="decision register; past review_by dates are warnings") + a = p.parse_args(argv) + maintainers = yaml.safe_load(a.maintainers.read_text(encoding="utf-8")) + rows = read_csv(a.issues) + if not rows or "id" not in rows[0] or "severity" not in rows[0]: + print("ISSUES.csv needs id and severity columns", file=sys.stderr) + return 2 + token = os.environ.get("GITHUB_TOKEN") + if a.apply and not token: + print("--apply needs GITHUB_TOKEN", file=sys.stderr) + return 2 + existing = json.loads(a.existing.read_text(encoding="utf-8")) if a.existing else ( + fetch_existing(a.org, token) if a.apply else []) + warnings = review_warnings(a.decisions) if a.decisions else [] + for warning in warnings: + print(f"WARNING decision-review {warning}") + to_open, to_notify = plan(rows, existing, maintainers, a.fallback_repository, a.report) + for issue in to_open: + print(f"OPEN {issue['repository']}: {issue['title']}") + for issue in to_notify: + print(f"COMMENT {issue['repository']}#{issue['number']}: {issue['id']} no longer detected (not closed)") + print(f"plan: {len(to_open)} to open, {len(to_notify)} to comment 'no longer detected', 0 closed") + if a.plan_output: + a.plan_output.write_text(json.dumps({"open": to_open, "no_longer_detected": to_notify, "decision_review_warnings": warnings}, indent=2), + encoding="utf-8") + if a.apply: + apply(a.org, to_open, to_notify, token, a.report) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/watchdog.py b/tools/watchdog.py new file mode 100755 index 0000000..a609dd8 --- /dev/null +++ b/tools/watchdog.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""OpenAMRobot Watchdog: run every repository check against one checkout. + + python3 tools/watchdog.py --root ../openamrobot-docs + +Runs, in one go and with the register and allowlist of this harness checkout: +decisions of record, public extract, shared agent rules (when AGENTS.md exists), +workflow policy and decision freshness. Prints each check's findings in the +shared Watchdog format (what was found, the decision, why, how to fix, links), +then one summary table. See WATCHDOG.md. + +It only reads files. It proves textual consistency only, never mechanical, +electrical, safety or release correctness. + +Exit status: 0 clean, 1 findings, 2 invalid register, allowlist or usage error. +With --report-only, findings never change the exit status (0); usage errors still exit 2. +""" +import argparse +import json +import re +import sys +from datetime import date +from pathlib import Path + +import yaml + +TOOLS = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS)) +import check_agent_rules as car # noqa: E402 +import check_decisions as cd # noqa: E402 +import check_public_extract as pe # noqa: E402 +import check_workflow_policy as wp # noqa: E402 +import watchdog_report as wr # noqa: E402 + +HARNESS = TOOLS.parent +CHECKS = ("decisions", "public-extract", "shared-rules", "workflow-policy") +TITLES = {"decisions": "Decisions of record", "public-extract": "Public extract", + "shared-rules": "Shared agent rules", "workflow-policy": "Workflow policy"} +NEXT_STEPS = {"decisions": cd.NEXT_STEP, "public-extract": pe.NEXT_STEP, + "shared-rules": car.NEXT_STEP, "workflow-policy": wp.NEXT_STEP} + + +def shared_rules(root, canonical): + """Findings for AGENTS.md drift; None when the repository has no AGENTS.md.""" + path = Path(root, "AGENTS.md") + if not path.is_file(): + return None + version, expected = car.block(canonical) + try: + text = path.read_text(encoding="utf-8") + if car.block(path, version)[1] != expected: + raise ValueError("shared block differs from canonical") + if len(text.splitlines()) >= car.MAX_LINES: + raise ValueError(f"AGENTS.md must be under {car.MAX_LINES} lines") + if (path.parent / "CLAUDE.md").read_text(encoding="utf-8").strip() != "@AGENTS.md": + raise ValueError("CLAUDE.md must import @AGENTS.md without duplicate rules") + except (OSError, ValueError) as exc: + return [wr.finding(car.LABEL, "shared-rules", "AGENTS.md", 1, str(exc), + f"AGENTS.md carries the canonical shared block {version} from openAMRobot/.github.", + car.WHY, car.fix_for(str(exc)), [("WATCHDOG.md", f"{wr.DOCS}#shared-agent-rules")])] + return [] + + +def freshness(register_path, today=None): + """Decision freshness: entries whose review_by date has passed (never blocking).""" + data = yaml.safe_load(Path(register_path).read_text(encoding="utf-8")) or {} + return cd.review_warnings(data, today) + + +def run(root, repository, harness=HARNESS, today=None): + """Run every check; return a result dict. Raises ValueError on configuration errors.""" + root = Path(root) + register = Path(harness, "decisions.yaml") + try: + decisions = cd.load_decisions(register, Path(harness, "maintainers.yaml")) + except cd.DecisionError as exc: + raise ValueError(f"invalid decisions register: {exc}") from exc + try: + allow = pe.load_allowlist(Path(harness, "public-extract-allowlist.yaml")) + except (OSError, ValueError, yaml.YAMLError) as exc: + raise ValueError(f"invalid public extract allowlist: {exc}") from exc + stats = {} + found, allowed = cd.scan(root, decisions, repository, None, stats) + results = { + "decisions": cd.to_watchdog(found, decisions), + "public-extract": pe.to_watchdog(pe.scan(root, allow, repository)), + "shared-rules": shared_rules(root, Path(harness, "agent-rules", "SHARED_RULES.md")), + "workflow-policy": wp.to_watchdog(wp.records(root)), + } + return {"repository": repository, "results": results, "allowed": allowed, + "freshness": freshness(register, today), "unscanned": stats.get("unscanned", 0)} + + +SPECIAL_WORDS = { + r"rev ?18\.[1-6]": "rev 18.1 to rev 18.6", + r"RS-?485": "RS485 or RS-485", + r"no RS-?485": "no RS485", + r"not (?:the )?shoulder": "not shoulder, not the shoulder", + r"(? 1 and why else "" + cells.append(label + (", ".join(f"`{w}`" for w in words) if words + else "no label; correct the line or add a decision-allow marker")) + rows.append(f"| {d['id']} | {d['status']} | " + "
".join(cells).replace("|", "\\|") + " |") + return rows + + +def total(report): + return sum(len(v) for v in report["results"].values() if v) + + +def per_decision(report): + """Counts per group (decision ID or rule), across all checks, in first-seen order.""" + counts = {} + for items in report["results"].values(): + for f in items or []: + counts[f["group"]] = counts.get(f["group"], 0) + 1 + return counts + + +def render(report): + lines = [f"OpenAMRobot Watchdog: {report['repository']}", ""] + for check in CHECKS: + items = report["results"][check] + lines.append(f"## {TITLES[check]}") + if items is None: + lines += ["Not run: the repository has no AGENTS.md.", ""] + continue + if check == "decisions": + for a in report["allowed"]: + lines.append(f"Allowed by decision-allow marker: {a['file']}:{a['line']}: {a['id']} " + f"found {a['found']!r}; reason: {a['reason']}") + lines += wr.render(items, TITLES[check], NEXT_STEPS[check], + decision_word="Decision" if check == "decisions" else "Rule") + lines.append("") + lines.append("## Decision freshness") + lines += [f"Review due: {w}. The decision owner confirms or updates the entry." for w in report["freshness"]] \ + or ["Every decision is within its review_by date."] + lines.append("") + lines.append("== Watchdog summary ==") + lines.append(f"{'Check':<22}Findings") + for check in CHECKS: + items = report["results"][check] + lines.append(f"{TITLES[check]:<22}{'not run (no AGENTS.md)' if items is None else len(items)}") + lines.append(f"{'Decision freshness':<22}{len(report['freshness'])} review(s) due (warning only)") + counts = per_decision(report) + lines.append(f"Total: {total(report)} finding(s)" + + (" (" + ", ".join(f"{g} {n}" for g, n in counts.items()) + ")" if counts else "")) + lines.append("Next step: " + ("fix each finding as its Fix line says, or see WATCHDOG.md " + "for historical labels and how to propose a decision change." + if counts else "nothing to fix.")) + lines.append(f"Guide: {wr.DOCS}") + lines.append("note: a clean result shows textual consistency only, not mechanical, electrical, " + "safety or release correctness") + return lines + + +def markdown_row(report): + """One Markdown section for the organization scan summary.""" + md = [f"### {report['repository']}: {total(report)} finding(s)", ""] + md += ["| Check | Findings |", "|---|---:|"] + for check in CHECKS: + items = report["results"][check] + md.append(f"| {TITLES[check]} | {'not run (no AGENTS.md)' if items is None else len(items)} |") + counts = per_decision(report) + if counts: + md += ["", "| Decision or rule | Findings |", "|---|---:|"] + md += [f"| {g} | {n} |" for g, n in counts.items()] + md.append("") + return "\n".join(md) + "\n" + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--root", type=Path, default=Path("."), help="repository checkout to check") + p.add_argument("--repository", help="repository name (default: the root directory name)") + p.add_argument("--harness", type=Path, default=HARNESS, + help="openAMRobot/.github checkout with decisions.yaml (default: this one)") + p.add_argument("--report-only", action="store_true", help="never fail on findings") + p.add_argument("--json", type=Path, help="write the full result as JSON") + p.add_argument("--markdown", type=Path, help="append a per-repository Markdown summary to this file") + p.add_argument("--accepted-words", action="store_true", + help="print the labels each decision accepts (the table in WATCHDOG.md) and exit") + a = p.parse_args(argv) + if a.accepted_words: + try: + decisions = cd.load_decisions(Path(a.harness, "decisions.yaml"), Path(a.harness, "maintainers.yaml")) + except cd.DecisionError as exc: + print(f"INVALID configuration: {exc}", file=sys.stderr) + return 2 + print("\n".join(accepted_words_table(decisions))) + return 0 + if not a.root.is_dir(): + print(f"root is not a directory: {a.root}", file=sys.stderr) + return 2 + repository = a.repository or a.root.resolve().name + try: + report = run(a.root, repository, a.harness) + except (OSError, ValueError) as exc: + print(f"INVALID configuration: {exc}", file=sys.stderr) + return 2 + for line in render(report): + print(line) + for check in CHECKS: + if report["results"][check]: + wr.emit_github(report["results"][check], f"{TITLES[check]} ({repository})", NEXT_STEPS[check]) + if a.json: + a.json.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8") + if a.markdown: + with a.markdown.open("a", encoding="utf-8") as handle: + handle.write(markdown_row(report)) + if a.report_only: + return 0 + return 1 if total(report) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/watchdog_issue_sync.py b/tools/watchdog_issue_sync.py new file mode 100755 index 0000000..ae91cdf --- /dev/null +++ b/tools/watchdog_issue_sync.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +"""Synchronise deterministic Watchdog findings into one GitHub control surface. + +The organization scan is read-only against product repositories. This tool writes only to +the harness repository: it creates or updates deduplicated issues, assigns the platform lead +when possible, and keeps a small dashboard issue. It never edits decisions.yaml, pushes a +branch, opens a pull request, or closes a human-owned issue. + +Issue mode (repository variable WATCHDOG_ISSUE_MODE, or --mode): + dashboard (default when unset) create or update only the single + "[watchdog] Organization dashboard" issue, which lists every active group with + repository, check, group, finding count, owner and file links; + groups additionally open and maintain one deduplicated issue per active group. +Scan-blocked repositories always appear on the dashboard in both modes. The platform lead +decides when to switch to groups (WATCHDOG.md, "Watchdog issue mode"). + +Fail closed: with --expected (the workflow always passes it), every expected repository needs a +report or a BLOCKED entry. A repository with neither, or with a malformed report, is listed as +BLOCKED ("no report produced", "malformed report"), the dashboard opens with an INCOMPLETE +banner, findings of repositories that were not scanned are never cleared, and the tool exits +non-zero. If the expected list itself cannot be read, the dashboard is not overwritten: only an +INCOMPLETE notice is posted and the tool exits non-zero. + +The register remains human-approved. A decision-review issue tells the platform lead when an +entry is due; the owner then updates the source document first and uses a normal reviewed PR +to update decisions.yaml and affected repositories. +""" +import argparse +from collections import OrderedDict +from datetime import date +import hashlib +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +import yaml + + +DASHBOARD_MARKER = "" +NO_LONGER_MARKER = "" +OBSERVATION_PREFIX = "" +MARKER_RE = re.compile(r"") +OBSERVATION_RE = re.compile(r"") +FINDING_SEVERITY = { + "decisions": "major", + "public-extract": "blocker", + "shared-rules": "major", + "workflow-policy": "major", +} +LABEL_METADATA = { + "watchdog-finding": ("1f6feb", "A deterministic Watchdog finding"), + "watchdog-review": ("8250df", "A decision-register review reminder"), + "watchdog-report": ("5319e7", "The organization Watchdog dashboard"), + "decision-review": ("fbca04", "A human review of one decisions.yaml entry"), + "blocker": ("b60205", "Blocks a complete or safe result"), + "major": ("d93f0b", "Requires owner action"), + "review": ("fbca04", "Requires human review"), +} +OWNER_BY_CHECK = { + "public-extract": "docs-owner", + "shared-rules": "software-lead", + "workflow-policy": "ci-owner", +} +MODES = ("dashboard", "groups") +DEFAULT_MODE = "dashboard" +DASHBOARD_TITLE = "[watchdog] Organization dashboard" +DASHBOARD_FILE_LINKS = 5 + + +def resolve_mode(value): + """WATCHDOG_ISSUE_MODE: empty or unset means dashboard; anything else must be a known mode.""" + mode = (value or "").strip().lower() or DEFAULT_MODE + if mode not in MODES: + raise ValueError(f"WATCHDOG_ISSUE_MODE must be one of {', '.join(MODES)}, not {value!r}") + return mode + + +REDACT_URL = re.compile(r"https?://[^\s`|]+", re.IGNORECASE) +REDACT_EMAIL = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b") +REDACT_SECRET = re.compile(r"\b(?:ghp_|github_pat_|sk-ant-|AKIA)[A-Za-z0-9_./+-]{8,}\b") + + +def digest(*parts): + """Return a short, stable identifier for one logical finding.""" + raw = "\0".join(str(part) for part in parts) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20] + + +def marker(kind, key): + return f"" + + +def observation(value): + return f"{OBSERVATION_PREFIX}{digest(json.dumps(value, sort_keys=True, default=str))}{OBSERVATION_SUFFIX}" + + +def scrub(value, limit=240): + """Make untrusted repository text safe for a public issue.""" + text = str(value or "").replace("\r", " ").replace("\n", " ") + text = REDACT_SECRET.sub("[redacted credential]", text) + text = REDACT_URL.sub("[redacted URL]", text) + text = REDACT_EMAIL.sub("[redacted email]", text) + text = text.replace("|", "/").replace("`", "'").strip() + return text if len(text) <= limit else text[: limit - 3] + "..." + + +def load_reports_checked(directory): + """Return (reports, failures): a report that cannot be read or lacks results is a failure + for its repository (named after the file), never silently skipped.""" + reports, failures = [], [] + for path in sorted(Path(directory).glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + data = None + if isinstance(data, dict) and data.get("repository") and isinstance(data.get("results"), dict): + reports.append(data) + else: + failures.append({"repository": path.stem, "reason": "malformed report"}) + return reports, failures + + +def load_reports(directory): + return load_reports_checked(directory)[0] + + +def load_blocked(path): + if not path or not Path(path).exists(): + return [] + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] # entries are recovered below: every expected repository without a report is BLOCKED + return [b for b in data if isinstance(b, dict)] if isinstance(data, list) else [] + + +class ExpectedError(ValueError): + pass + + +def load_expected(path): + """The repositories the scan had to cover. Raises ExpectedError when it cannot be read.""" + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ExpectedError(f"{path}: {exc}") from exc + if not isinstance(data, list) or not data or not all(isinstance(x, str) and x for x in data): + raise ExpectedError(f"{path}: not a non-empty list of repository names") + return data + + +def completeness(expected, reports, blocked): + """Add a BLOCKED entry for every expected repository with neither report nor blocked entry. + Returns (blocked, status) with status {"scanned", "expected", "incomplete"}.""" + blocked = list(blocked) + names = {b.get("repository") for b in blocked} + reported = {r.get("repository") for r in reports} + universe = list(expected) if expected is not None else sorted(reported | names) + for name in universe: + if name not in reported and name not in names: + blocked.append({"repository": name, "reason": "no report produced"}) + names.add(name) + scanned = sum(1 for name in universe if name in reported and name not in names) + return blocked, {"scanned": scanned, "expected": len(universe), "incomplete": bool(blocked) or scanned < len(universe)} + + +def incomplete_banner(run_date, status): + return (f"**Scan INCOMPLETE on {run_date}: {status['scanned']} of {status['expected']} repositories scanned. " + "Results below are partial; missing repositories are listed as BLOCKED and are not clean.**") + + +def unreadable_banner(run_date, reason): + return (f"**Scan INCOMPLETE on {run_date}: the expected repository list could not be read ({scrub(reason)}). " + "No results were published from this run, and no repository is reported as clean.**") + + +def owner_for(check, group, decisions, maintainers): + role = OWNER_BY_CHECK.get(check) + if check == "decisions": + entry = decisions.get(group, {}) + role = entry.get("owner") or "platform-lead" + roles = maintainers.get("roles", {}) + handle = (roles.get(role) or {}).get("handle") + return role, handle + + +def grouped_items(reports, decisions, maintainers): + """Convert raw reports to one stable issue candidate per logical group.""" + active = OrderedDict() + for report in reports: + repo = report.get("repository", "unknown") + for check, items in (report.get("results") or {}).items(): + if not items: + continue + groups = OrderedDict() + for item in items: + groups.setdefault(item.get("group", "unknown"), []).append(item) + for group, findings in groups.items(): + key = digest("finding", repo, check, group) + role, handle = owner_for(check, group, decisions, maintainers) + active[marker("finding", key)] = { + "kind": "finding", + "key": key, + "marker": marker("finding", key), + "fingerprint": key, + "repository": repo, + "commit": report.get("commit", "unknown"), + "check": check, + "group": group, + "severity": FINDING_SEVERITY.get(check, "major"), + "owner_role": role, + "owner_handle": handle, + "findings": findings, + } + return list(active.values()) + + +def review_items(reports, decisions, maintainers): + """Return one review issue per due register entry, without duplicate reports.""" + due = OrderedDict() + for report in reports: + for warning in report.get("freshness") or []: + decision_id = str(warning).split(" review_by", 1)[0] + if decision_id in due: + continue + entry = decisions.get(decision_id, {}) + role = entry.get("owner") or "platform-lead" + handle = (maintainers.get("roles", {}).get(role) or {}).get("handle") + key = digest("review", decision_id) + due[marker("review", key)] = { + "kind": "review", + "key": key, + "marker": marker("review", key), + "fingerprint": key, + "decision_id": decision_id, + "warning": warning, + "owner_role": role, + "owner_handle": handle, + "severity": "review", + } + return list(due.values()) + + +def blocked_items(blocked, maintainers): + out = [] + for item in blocked: + repo = item.get("repository", "unknown") + key = digest("blocked", repo) + out.append({ + "kind": "blocked", + "key": key, + "marker": marker("blocked", key), + "fingerprint": key, + "repository": repo, + "reason": item.get("reason", "scan did not complete"), + "severity": "blocker", + "owner_role": "ci-owner", + "owner_handle": (maintainers.get("roles", {}).get("ci-owner") or {}).get("handle"), + }) + return out + + +def issue_title(item): + if item["kind"] == "review": + return f"[watchdog] Decision review: {item['decision_id']}" + if item["kind"] == "blocked": + return f"[watchdog] Scan blocked: {item['repository']}" + return f"[watchdog] {item['repository']}: {item['check']} / {item['group']}"[:120] + + +def file_link(repository, commit, path, line): + if not repository or repository == "unknown" or commit == "unknown": + return f"`{path}:{line}`" + return f"[{path}:{line}](https://github.com/openAMRobot/{repository}/blob/{commit}/{path}#L{line})" + + +def body_for(item, run_url, run_date): + owner = f"@{item['owner_handle']} ({item['owner_role']})" if item.get("owner_handle") else item.get("owner_role", "unassigned") + lines = [item["marker"], observation(observation_payload(item)), "", "## OpenAMRobot Watchdog finding", "", f"Owner: {owner}", "@BotshareAI", f"Detected: {run_date}"] + if item["kind"] == "review": + lines += [f"Decision entry: `{item['decision_id']}`", f"Review status: {scrub(item['warning'])}", "", "This is a review reminder, not an automatic decision change.", "Confirm the controlling source first. If the source is unchanged, update `review_by` through a reviewed PR. If the source changed, update the source document first, then update `decisions.yaml` and affected consumers in one reviewed PR."] + elif item["kind"] == "blocked": + lines += [f"Repository: `{item['repository']}`", f"Status: **BLOCKED** - {scrub(item['reason'])}", "", "The scan did not produce a complete result. Treat the organization audit as incomplete until this is resolved."] + else: + lines += [f"Repository: `{item['repository']}`", f"Commit: `{item['commit']}`", f"Check: `{item['check']}`", f"Group: `{item['group']}`", f"Severity: **{item['severity']}**", "", f"Decision or rule: {scrub(item['findings'][0].get('decision', ''))}", f"Why: {scrub(item['findings'][0].get('why', ''))}", f"Fix: {scrub(item['findings'][0].get('fix', ''))}", "", "| Location | Observed text |", "|---|---|"] + for finding in item["findings"]: + location = file_link(item["repository"], item["commit"], finding.get("file", "?"), finding.get("line", 1)) + lines.append(f"| {location} | {scrub(finding.get('found', ''))} |") + lines += ["", "This is a textual consistency signal only. It does not prove mechanical, electrical, safety or release correctness."] + lines += ["", f"Run: {run_url}", "", "The Watchdog never edits `decisions.yaml`, pushes a branch, opens a pull request, or closes this issue automatically. Resolve it with the responsible owner and a reviewed change where needed."] + return "\n".join(lines) + + +def observation_payload(item): + if item["kind"] == "finding": + return [(f.get("file"), f.get("line"), f.get("found"), f.get("decision"), f.get("why"), f.get("fix")) for f in item["findings"]] + return item.get("warning") or item.get("reason") + + +def observation_comment(item, run_url, run_date): + return f"{observation(observation_payload(item))}\nWatchdog rerun on {run_date}: the finding is still detected. See the issue body for the current evidence. Run: {run_url}" + + +def no_longer_comment(item, run_url, run_date): + return f"{NO_LONGER_MARKER}\nWatchdog rerun on {run_date} no longer detected this finding. The issue remains open for the owner to verify and close. Run: {run_url}" + + +def extract_markers(text): + return {marker(kind, key): (kind, key) for kind, key in MARKER_RE.findall(text or "")} + + +def last_observation(issue): + texts = [issue.get("body", "")] + [c.get("body", "") for c in issue.get("comments", [])] + found = [] + for text in texts: + found.extend(OBSERVATION_RE.findall(text)) + return found[-1] if found else None + + +def has_no_longer(issue): + return any(NO_LONGER_MARKER in (c.get("body") or "") for c in issue.get("comments", [])) + + +REPOSITORY_LINE = re.compile(r"^Repository: `([^`]+)`", re.M) + + +def issue_repository(issue): + match = REPOSITORY_LINE.search(issue.get("body") or "") + return match.group(1) if match else None + + +def may_clear(kind, issue, scanned_repos, reports): + """Only a repository that was actually scanned in this run can clear its own issue.""" + if scanned_repos is None: + return True + if kind == "review": + return bool(reports) + return issue_repository(issue) in scanned_repos + + +def carried_rows(dashboard, blocked_repos): + """Group rows of the previous dashboard for repositories that were not scanned this run, + kept as last known so an incomplete run never makes them look resolved.""" + if not dashboard or not blocked_repos: + return [] + body = dashboard.get("body") or "" + if "### Active groups" not in body: + return [] + rows = [] + for line in body.split("### Active groups", 1)[1].splitlines(): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) != 6 or cells[0] in ("Repository", "---", "-") or cells[1] == "scan": + continue + if cells[0] in blocked_repos: + files = cells[5] + if not files.startswith("last known"): + files = f"last known from an earlier run, not rescanned: {files}" + rows.append((cells[0], cells[1], cells[2], cells[3], cells[4], files)) + return rows + + +def plan(reports, blocked, decisions, maintainers, existing, run_url, run_date, mode=DEFAULT_MODE, status=None): + active = grouped_items(reports, decisions, maintainers) + review_items(reports, decisions, maintainers) + blocked_items(blocked, maintainers) + active_by_marker = {item["marker"]: item for item in active} + existing_by_marker = {} + dashboard = None + for issue in existing: + markers = extract_markers(issue.get("body", "")) + for item_marker in markers: + existing_by_marker[item_marker] = issue + if DASHBOARD_MARKER in (issue.get("body") or "") and issue.get("state") == "open": + dashboard = issue + scanned_repos = None + blocked_repos = set() + if status is not None: + blocked_repos = {b.get("repository") for b in blocked} + scanned_repos = {r.get("repository") for r in reports} - blocked_repos + carried = carried_rows(dashboard, blocked_repos) if status and status["incomplete"] else [] + opens = [] + updates = [] + if mode == "dashboard": + # Dashboard mode writes nothing but the dashboard issue: no group issue is opened, + # commented on or reopened. Existing group issues are left exactly as they are. + return {"active": active, "open": opens, "updates": updates, "dashboard": dashboard, + "run_date": run_date, "run_url": run_url, "mode": mode, "status": status, "carried": carried} + for item in active: + current = digest(json.dumps(observation_payload(item), sort_keys=True, default=str)) + issue = existing_by_marker.get(item["marker"]) + labels = ["watchdog-finding", item["severity"]] if item["kind"] == "finding" else ["watchdog-review", "decision-review", item["severity"]] + payload = {"title": issue_title(item), "body": body_for(item, run_url, run_date), "labels": labels, "assignees": ["BotshareAI"]} + if not issue: + opens.append({**item, "payload": payload, "observation_hash": current}) + elif issue.get("state") != "open": + updates.append({"issue": issue, "body": observation_comment(item, run_url, run_date), "reopen": True}) + elif last_observation(issue) != current: + updates.append({"issue": issue, "body": observation_comment(item, run_url, run_date), "reopen": False}) + for item_marker, issue in existing_by_marker.items(): + if item_marker not in active_by_marker and issue.get("state") == "open" and not has_no_longer(issue): + kind = MARKER_RE.search(item_marker).group(1) + if not may_clear(kind, issue, scanned_repos, reports): + continue # not scanned in this run: leave the issue exactly as it is + updates.append({"issue": issue, "body": no_longer_comment({"key": item_marker}, run_url, run_date)}) + return {"active": active, "open": opens, "updates": updates, "dashboard": dashboard, "run_date": run_date, "run_url": run_url, "mode": mode, "status": status, "carried": carried} + + +def group_rows(active): + """One dashboard row per active group: repository, check, group, count, owner, file links.""" + rows = [] + for item in active: + owner = item.get("owner_role", "unassigned") + if item.get("owner_handle"): + owner = f"{owner} ({item['owner_handle']})" + if item["kind"] == "finding": + links = [file_link(item["repository"], item["commit"], f.get("file", "?"), f.get("line", 1)) + for f in item["findings"][:DASHBOARD_FILE_LINKS]] + more = len(item["findings"]) - DASHBOARD_FILE_LINKS + if more > 0: + links.append(f"and {more} more") + rows.append((item["repository"], item["check"], item["group"], str(len(item["findings"])), owner, ", ".join(links))) + elif item["kind"] == "review": + rows.append(("decisions.yaml", "decision-review", item["decision_id"], "1", owner, scrub(item["warning"]))) + else: + rows.append((item["repository"], "scan", "**BLOCKED**", "-", owner, scrub(item["reason"]))) + return rows + + +def dashboard_body(reports, blocked, plan_data, run_url, run_date, links): + mode = plan_data.get("mode", DEFAULT_MODE) + status = plan_data.get("status") + rows = group_rows(plan_data["active"]) + list(plan_data.get("carried") or []) + summary = {"date": run_date, "mode": mode, "status": status, + "repositories": [(r.get("repository"), r.get("commit"), sum(len(v or []) for v in (r.get("results") or {}).values()), len(r.get("freshness") or [])) for r in reports], + "blocked": [(b.get("repository"), b.get("reason")) for b in blocked], + "groups": [row[:4] for row in rows]} + banner = [incomplete_banner(run_date, status), ""] if status and status["incomplete"] else [] + lines = [DASHBOARD_MARKER, observation(summary), ""] + banner + ["## OpenAMRobot Watchdog dashboard", "", "@BotshareAI", f"Run: {run_date} ([GitHub Actions run]({run_url}))", f"Issue mode: `{mode}`" + (" (only this dashboard is written; see WATCHDOG.md, Watchdog issue mode)" if mode == "dashboard" else " (one issue per active group as well)"), "", "The deterministic Watchdog scans the repositories listed in `rollout/repositories.yaml`. It reads product repositories and writes only this control surface. It does not use AI and never edits `decisions.yaml` automatically.", "", "| Repository | Commit | Findings | Review warnings | Shared rules | Status |", "|---|---|---:|---:|---|---|"] + for report in reports: + findings = sum(len(value or []) for value in (report.get("results") or {}).values()) + warnings = len(report.get("freshness") or []) + shared = report.get("results", {}).get("shared-rules") + adoption = "not enrolled" if shared is None else ("drift" if shared else "pass") + status = "findings" if findings or warnings else "clean" + lines.append(f"| {scrub(report.get('repository'))} | `{scrub(report.get('commit', 'unknown'))}` | {findings} | {warnings} | {adoption} | {status} |") + for item in blocked: + lines.append(f"| {scrub(item.get('repository'))} | unavailable | - | - | - | **BLOCKED** ({scrub(item.get('reason'))}) |") + lines += ["", f"### Active groups ({len(rows)})", "", "| Repository | Check | Group | Findings | Owner | Files |", "|---|---|---|---:|---|---|"] + for repo, check, group, count, owner, files in rows: + lines.append(f"| {scrub(repo)} | {check} | {group if group == '**BLOCKED**' else scrub(group)} | {count} | {scrub(owner)} | {files or '-'} |") + if not rows: + lines.append("| - | - | no active group | 0 | - | - |") + if mode == "groups": + lines += ["", f"Grouped issue actions in this run: {len(plan_data['open'])} new, {len(plan_data['updates'])} comments, {len(plan_data['active'])} active groups.", "", "| Action | Issue |", "|---|---|"] + for title, url in links: + lines.append(f"| {scrub(title)} | [open](<{url}>) |") + lines += ["", "A clean result proves textual consistency only, not mechanical, electrical, safety or release correctness. Owners close finding issues after checking the fixing PR. A decision change follows the source-first, reviewed-PR process."] + return "\n".join(lines) + + +def api(method, url, token, data=None): + request = urllib.request.Request(url, method=method, data=json.dumps(data).encode("utf-8") if data is not None else None, headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "Content-Type": "application/json", "User-Agent": "openamrobot-watchdog"}) + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read() or b"null") + + +def fetch_existing(repository, token, call=api): + existing = [] + page = 1 + while True: + url = f"https://api.github.com/repos/{repository}/issues?state=all&per_page=100&page={page}" + items = call("GET", url, token) or [] + for item in items: + if item.get("pull_request"): + continue + body = item.get("body") or "" + if not (DASHBOARD_MARKER in body or MARKER_RE.search(body)): + continue + comments = call("GET", f"https://api.github.com/repos/{repository}/issues/{item['number']}/comments?per_page=100", token) or [] + existing.append({"number": item["number"], "title": item["title"], "body": body, "state": item["state"], "comments": comments, "url": item.get("html_url", "")}) + if len(items) < 100: + return existing + page += 1 + + +def create_issue(repository, payload, token, call=api): + url = f"https://api.github.com/repos/{repository}/issues" + try: + return call("POST", url, token, payload) + except urllib.error.HTTPError: + fallback = dict(payload) + fallback.pop("assignees", None) + try: + return call("POST", url, token, fallback) + except urllib.error.HTTPError: + fallback.pop("labels", None) + return call("POST", url, token, fallback) + + +def ensure_labels(repository, labels, token, call=api): + """Create the small controlled label vocabulary if an owner has not created it yet.""" + for name in sorted(set(labels)): + color, description = LABEL_METADATA.get(name, ("6e7781", "OpenAMRobot Watchdog label")) + try: + call("POST", f"https://api.github.com/repos/{repository}/labels", token, + {"name": name, "color": color, "description": description}) + except urllib.error.HTTPError as exc: + if exc.code != 422: # GitHub returns 422 when the label already exists. + raise + + +def apply(repository, plan_data, reports, blocked, token, run_url, run_date, call=api): + mode = plan_data.get("mode", DEFAULT_MODE) + links = [] + labels = ["watchdog-report"] + for item in plan_data["open"]: + labels.extend(item["payload"]["labels"]) + ensure_labels(repository, labels, token, call) + for item in plan_data["open"]: + created = create_issue(repository, item["payload"], token, call) + links.append((item["payload"]["title"], created.get("html_url", ""))) + for update in plan_data["updates"]: + issue = update["issue"] + if update.get("reopen"): + call("PATCH", f"https://api.github.com/repos/{repository}/issues/{issue['number']}", token, {"state": "open"}) + call("POST", f"https://api.github.com/repos/{repository}/issues/{issue['number']}/comments", token, {"body": update["body"]}) + links.append((issue.get("title", "watchdog issue"), issue.get("url", ""))) + dashboard_text = dashboard_body(reports, blocked, plan_data, run_url, run_date, links) + dashboard = plan_data.get("dashboard") + dashboard_payload = {"title": DASHBOARD_TITLE, "body": dashboard_text, "labels": ["watchdog-report"], "assignees": ["BotshareAI"]} + dashboard_match = OBSERVATION_RE.search(dashboard_text) + dashboard_hash = dashboard_match.group(1) if dashboard_match else digest(dashboard_text) + if not dashboard: + create_issue(repository, dashboard_payload, token, call) + elif mode == "dashboard": + # Update the one dashboard in place, and only when its content changed. + current = OBSERVATION_RE.search(dashboard.get("body") or "") + if not current or current.group(1) != dashboard_hash: + call("PATCH", f"https://api.github.com/repos/{repository}/issues/{dashboard['number']}", token, + {"title": DASHBOARD_TITLE, "body": dashboard_text}) + elif last_observation(dashboard) != dashboard_hash: + call("POST", f"https://api.github.com/repos/{repository}/issues/{dashboard['number']}/comments", token, {"body": f"{OBSERVATION_PREFIX}{dashboard_hash}{OBSERVATION_SUFFIX}\n{dashboard_text}"}) + print(f"Watchdog issue sync (mode {mode}{', INCOMPLETE' if (plan_data.get('status') or {}).get('incomplete') else ''}): {len(plan_data['open'])} opened, {len(plan_data['updates'])} comments, dashboard updated={not bool(dashboard)}") + for title, url in links: + print(f"- {title}: {url}") + + +def publish_unreadable(repository, existing, reason, token, run_url, run_date, call=api): + """The expected list is unreadable: never overwrite the dashboard with results.""" + text = f"{unreadable_banner(run_date, reason)}\n\nRun: {run_url}" + dashboard = next((i for i in existing if DASHBOARD_MARKER in (i.get("body") or "") and i.get("state") == "open"), None) + ensure_labels(repository, ["watchdog-report"], token, call) + if dashboard: + call("POST", f"https://api.github.com/repos/{repository}/issues/{dashboard['number']}/comments", token, {"body": text}) + else: + create_issue(repository, {"title": DASHBOARD_TITLE, "body": f"{DASHBOARD_MARKER}\n{text}", + "labels": ["watchdog-report"], "assignees": ["BotshareAI"]}, token, call) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--reports", type=Path, required=True) + parser.add_argument("--blocked", type=Path) + parser.add_argument("--maintainers", type=Path, required=True) + parser.add_argument("--decisions", type=Path, required=True) + parser.add_argument("--repository", default="openAMRobot/.github") + parser.add_argument("--run-url", required=True) + parser.add_argument("--run-date", default=date.today().isoformat()) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--mode", help="dashboard or groups; default: WATCHDOG_ISSUE_MODE, else dashboard") + parser.add_argument("--expected", type=Path, + help="JSON list of repositories the scan had to cover (the workflow always passes it)") + args = parser.parse_args(argv) + try: + mode = resolve_mode(args.mode if args.mode is not None else os.environ.get("WATCHDOG_ISSUE_MODE")) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + maintainers = yaml.safe_load(args.maintainers.read_text(encoding="utf-8")) or {} + raw = yaml.safe_load(args.decisions.read_text(encoding="utf-8")) or {} + decisions = {entry.get("id"): entry for entry in raw.get("decisions", []) if isinstance(entry, dict) and entry.get("id")} + token = os.environ.get("GITHUB_TOKEN") + if args.apply and not token: + print("--apply requires GITHUB_TOKEN", file=sys.stderr) + return 2 + existing = fetch_existing(args.repository, token) if args.apply else [] + expected = None + if args.expected is not None: + try: + expected = load_expected(args.expected) + except ExpectedError as exc: + print(unreadable_banner(args.run_date, str(exc))) + if args.apply: + publish_unreadable(args.repository, existing, str(exc), token, args.run_url, args.run_date) + return 1 + reports, failures = load_reports_checked(args.reports) + failed = {f["repository"] for f in failures} + blocked = [b for b in load_blocked(args.blocked) if b.get("repository") not in failed] + failures + blocked, status = completeness(expected, reports, blocked) + data = plan(reports, blocked, decisions, maintainers, existing, args.run_url, args.run_date, mode, status) + state = f"INCOMPLETE, {status['scanned']} of {status['expected']} repositories scanned" if status["incomplete"] else "complete" + print(f"Watchdog issue plan (mode {mode}, {state}): {len(data['open'])} new, {len(data['updates'])} comments, {len(data['active'])} active groups") + if args.apply: + apply(args.repository, data, reports, blocked, token, args.run_url, args.run_date) + return 1 if status["incomplete"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/watchdog_report.py b/tools/watchdog_report.py new file mode 100644 index 0000000..8a1c566 --- /dev/null +++ b/tools/watchdog_report.py @@ -0,0 +1,136 @@ +"""Shared, contributor-friendly output for the OpenAMRobot Watchdog checks. + +Every check reports findings in the same compact shape, one block per decision (or rule): + +