diff --git a/.github/labels.yml b/.github/labels.yml index 50e9e86c0e..ec37034e86 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,9 +1,9 @@ # Declarative label source of truth for DeerFlow. # # This file is the single source of truth for repository labels used by the -# auto-labeling workflows (.github/workflows/pr-labeler.yml, pr-triage.yml, -# issue-triage.yml). Auto-labelers can only apply labels that already exist, -# so every label referenced by a workflow MUST be declared here. +# auto-labeling workflow (.github/workflows/triage.yml). Auto-labelers can only +# apply labels that already exist, so every label applied by that workflow MUST +# be declared here; scripts/check_label_declarations.py enforces that pair. # # Apply with: uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME] # CI keeps it in sync via .github/workflows/label-sync.yml (runs on changes here). @@ -30,7 +30,7 @@ labels: color: d876e3 description: Further information is requested - # ── Area (auto, by changed paths — see .github/labeler.yml) ─────────────── + # ── Area (auto, by changed paths — see workflows/triage.yml) ────────────── # Mirrors the "Surface area" section of the pull request template. - name: "area:frontend" color: c5def5 @@ -60,7 +60,7 @@ labels: color: c5def5 description: Dependency manifests / lockfiles - # ── Size (auto, by additions + deletions — see pr-triage.yml) ───────────── + # ── Size (auto, by additions + deletions — see workflows/triage.yml) ────── - name: "size/XS" color: "009900" description: PR changes < 20 lines @@ -77,7 +77,7 @@ labels: color: ee5500 description: PR changes 700+ lines - # ── Risk (auto, by changed paths — see pr-triage.yml) ───────────────────── + # ── Risk (auto, by changed paths — see workflows/triage.yml) ────────────── - name: "risk:low" color: 0e8a16 description: "Low risk: docs / i18n / assets only" diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml index ab4cf01f82..7d692bcfe2 100644 --- a/.github/workflows/lint-check.yml +++ b/.github/workflows/lint-check.yml @@ -38,6 +38,19 @@ jobs: --after "${{ github.sha }}" --github-annotations + label-declarations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Check every label applied by triage.yml is declared in labels.yml + run: python scripts/check_label_declarations.py + lint-backend: runs-on: ubuntu-latest steps: diff --git a/backend/tests/test_label_declarations.py b/backend/tests/test_label_declarations.py new file mode 100644 index 0000000000..1261df1295 --- /dev/null +++ b/backend/tests/test_label_declarations.py @@ -0,0 +1,195 @@ +"""Offline checks of the triage workflow's literal label declarations.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("label_declarations", REPO_ROOT / "scripts/check_label_declarations.py") +assert SPEC is not None and SPEC.loader is not None +checker = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(checker) + + +APPLIED = { + "area:frontend", + "area:backend", + "area:agents", + "area:sandbox", + "area:skills", + "area:mcp", + "area:ci", + "area:docs", + "area:deps", + "size/XS", + "size/S", + "size/M", + "size/L", + "size/XL", + "risk:low", + "risk:medium", + "risk:high", + "needs-validation", + "first-time-contributor", + "reviewing", + "needs-triage", +} + + +def workflow(script: str) -> str: + return "jobs:\n triage:\n steps:\n - uses: actions/github-script@v8\n with:\n script: |\n" + "\n".join(" " + line for line in script.splitlines()) + "\n" + + +@pytest.fixture +def files(tmp_path, monkeypatch): + labels = tmp_path / "labels.yml" + triage = tmp_path / "triage.yml" + labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(20)), encoding="utf-8") + triage.write_text(workflow("github.rest.issues.addLabels({labels: ['label-0']});"), encoding="utf-8") + monkeypatch.setattr(checker, "LABELS_FILE", labels) + monkeypatch.setattr(checker, "TRIAGE_WORKFLOW", triage) + return labels, triage + + +def test_repository_labels_are_declared(): + assert checker.applied_labels(checker.TRIAGE_WORKFLOW) == APPLIED + assert checker.main() == 0 + + +def test_missing_declaration_is_named(files, capsys): + labels, triage = files + labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(1, 21)), encoding="utf-8") + assert checker.main() == 1 + assert " - label-0\n" in capsys.readouterr().err + + +@pytest.mark.parametrize("count", [0, 19]) +def test_declaration_parse_floor(files, count): + labels, _ = files + labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(count)), encoding="utf-8") + with pytest.raises(SystemExit, match=f"Only {count} labels parsed"): + checker.main() + + +def test_empty_extraction_fails(files): + _, triage = files + triage.write_text(workflow("core.info('no label operations');"), encoding="utf-8") + with pytest.raises(SystemExit, match="No label literals"): + checker.main() + + +@pytest.mark.parametrize("label", ["area:infra", "area:i18n", "awaiting-review", "priority:P1", "urgent", "needs approval", "待审核"]) +def test_all_literal_label_names_are_checked(files, capsys, label): + _, triage = files + triage.write_text(workflow(f"github.rest.issues.addLabels({{labels: ['label-0', '{label}']}});"), encoding="utf-8") + assert checker.main() == 1 + assert f" - {label}\n" in capsys.readouterr().err + + +@pytest.mark.parametrize("comment", ["// someday maybe add 'needs-changes' here", "/* someday maybe add 'needs-changes' here */", "// addLabels({labels: ['needs-changes']});"]) +def test_javascript_comments_are_not_labels(files, comment): + _, triage = files + triage.write_text(workflow(f"github.rest.issues.addLabels({{labels: ['label-0']}}); {comment}"), encoding="utf-8") + assert checker.main() == 0 + + +def test_yaml_comments_are_not_labels(files): + _, triage = files + triage.write_text("# someday maybe add 'needs-changes' here\n" + workflow("addLabels({labels: ['label-0']});"), encoding="utf-8") + assert checker.main() == 0 + + +def test_unrelated_strings_and_logs_are_not_labels(files): + _, triage = files + triage.write_text(workflow("const unrelated = 'needs-changes';\ncore.info('needs-changes');\ncore.info(`mentions 'needs-changes'`);\naddLabels({labels: ['label-0']});"), encoding="utf-8") + assert checker.main() == 0 + + +def test_tracks_arrays_aliases_ternaries_and_mutations(files, capsys): + _, triage = files + triage.write_text( + workflow(""" +const paths = ['docs/']; +const RULES = [['area:i18n', [/^frontend\\//]], ['area:infra', [/^docker\\//]]]; +const areaLabels = RULES.filter(([, rules]) => rules.length).map(([label]) => label); +const sizeLabel = paths.includes('not-a-label') ? 'size/XS' : 'size/M'; +const desired = new Set([...areaLabels, sizeLabel]); +if (paths.length) desired.add('awaiting-review'); +const toAdd = [...desired].filter(n => n !== 'not-a-label'); +toAdd.push('needs approval'); +const alias = toAdd; +github.rest.issues.addLabels({labels: alias}); +"""), + encoding="utf-8", + ) + assert checker.applied_labels(triage) == {"area:i18n", "area:infra", "size/XS", "size/M", "awaiting-review", "needs approval"} + assert checker.main() == 1 + assert "not-a-label" not in capsys.readouterr().err + + +def test_jobs_do_not_share_variable_bindings(files): + _, triage = files + triage.write_text(workflow("const labels = ['label-0'];\naddLabels({labels});") + workflow("const labels = ['label-1'];\naddLabels({labels});").replace("jobs:\n", "", 1).replace(" triage:\n", " second:\n", 1), encoding="utf-8") + assert checker.applied_labels(triage) == {"label-0", "label-1"} + + +def test_escaped_quotes_and_comment_markers_in_labels(files): + _, triage = files + triage.write_text(workflow(r"""addLabels({labels: ["owner's // note", 'needs \'review\'', 'area:\u0069' + '18n']});"""), encoding="utf-8") + with pytest.raises((ValueError, SystemExit), match="computed|extract"): + checker.applied_labels(triage) + + +def test_escaped_label_literals(files): + _, triage = files + triage.write_text(workflow(r"""addLabels({"labels": ["owner's // note", 'needs \'review\'', 'area:\u006918n']});"""), encoding="utf-8") + assert checker.applied_labels(triage) == {"owner's // note", "needs 'review'", "area:i18n"} + + +@pytest.mark.parametrize("script", ["addLabels({labels: computed()});", "addLabels({labels: [`label-${number}`]});"]) +def test_unresolved_or_computed_values_fail_closed(files, script): + _, triage = files + triage.write_text(workflow(script), encoding="utf-8") + with pytest.raises(SystemExit, match="extract|computed"): + checker.main() + + +@pytest.mark.parametrize("missing", [0, 1]) +def test_missing_inputs_fail(files, missing): + files[missing].unlink() + with pytest.raises(SystemExit, match="Missing"): + checker.main() + + +@pytest.mark.parametrize( + "script", + [ + "const labels = ['label-0', ...unknownLabels]; addLabels({labels});", + "const labels = ['label-0', otherLabel]; addLabels({labels});", + "const labels = ['label-0']; const alias = labels.map(n => transform(n)); addLabels({labels: alias});", + ], +) +def test_partial_extraction_cannot_hide_unresolved_labels(files, script): + _, triage = files + triage.write_text(workflow(script), encoding="utf-8") + with pytest.raises(SystemExit, match="extract|computed"): + checker.main() + + +def test_nested_conditional_labels(files): + _, triage = files + triage.write_text(workflow("const label = first ? second ? 'label-0' : 'label-1' : 'label-2'; addLabels({labels: [label]});"), encoding="utf-8") + assert checker.applied_labels(triage) == {"label-0", "label-1", "label-2"} + + +@pytest.mark.parametrize("label", sorted(APPLIED)) +def test_every_real_applied_label_is_enforced(files, capsys, label): + labels, triage = files + manifest = (REPO_ROOT / ".github/labels.yml").read_text(encoding="utf-8") + labels.write_text("\n".join(line for line in manifest.splitlines() if line.strip() not in {f'- name: "{label}"', f"- name: {label}"}), encoding="utf-8") + triage.write_text((REPO_ROOT / ".github/workflows/triage.yml").read_text(encoding="utf-8"), encoding="utf-8") + assert checker.main() == 1 + assert f" - {label}\n" in capsys.readouterr().err diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 3dea93936f..6db8365975 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -23,21 +23,21 @@ For those launches, export the effective `DEER_FLOW_HOME` when collecting a bund and ensure the checkout `.env` does not override it. Tests clear all three runtime path variables and compare storage defaults with the launcher's actual shell blocks. -## Dependency Check Diagnostics +## Dependency and Label Checks -`check_extension.py` checks wheels with Gateway's asset validator. -CI builds and checks all bundled examples in `extension-package-check.yml`. -Tests: `backend/tests/test_extension_check_script.py`. +`check_extension.py` uses Gateway's wheel asset validator; CI checks examples +in `extension-package-check.yml`. Tests: `backend/tests/test_extension_check_script.py`. -`check.py` decodes captured output as UTF-8 with replacement and runs pnpm with -`PYTHONIOENCODING=utf-8:backslashreplace`. Preserve Unicode diagnostics and exit -status in `make check`, independently of the host locale. -Tests: `backend/tests/test_check_script.py`. +`check.py`: decode UTF-8 with replacement; pnpm uses +`PYTHONIOENCODING=utf-8:backslashreplace`. Keep Unicode/exit status in +`make check` in any locale. Tests: `backend/tests/test_check_script.py`. -`doctor.py` and `setup_wizard.py` configure stdout with `errors="replace"` at -the start of `main`, preserving its encoding so legacy terminals and pipes can -finish reports and setup. Guard `reconfigure` for embedded/captured streams. -Offline subprocess coverage: `backend/tests/test_setup_script_encoding.py`. +`doctor.py`/`setup_wizard.py`: at `main` start, keep stdout's encoding +with `errors="replace"` for legacy terminals/pipes. Guard `reconfigure` on +embedded/captured streams. Offline tests: `backend/tests/test_setup_script_encoding.py`. + +`check_label_declarations.py`: stdlib-only guard; reject unsupported +expressions. Tests: `backend/tests/test_label_declarations.py`. ## Manual Claude OAuth Export diff --git a/scripts/check_label_declarations.py b/scripts/check_label_declarations.py new file mode 100644 index 0000000000..9761eb68c1 --- /dev/null +++ b/scripts/check_label_declarations.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Check that every label the triage workflow applies is declared in labels.yml. + +``.github/labels.yml`` is the declarative source of truth for repository labels, +and ``.github/workflows/triage.yml`` applies labels by name at runtime. GitHub +rejects an ``addLabels`` call with **422** when *any* requested label does not +exist, and drops the whole batch -- so a label added to the workflow but +forgotten here makes labelling stop working silently instead of failing loudly. + +This is the static guard for that pair: it extracts the label literals the +triage workflow can apply and asserts each one is declared in labels.yml. + +The reader follows quoted literals through arrays, conditional values, variable +aliases, Sets and add/push mutations in each workflow script. Filter predicates, +log messages, YAML comments and JavaScript comments are not label sources. The +current AREA_RULES first-element map is supported; other map transformations, +computed calls/strings and unresolved values fail closed. This is not a general +JavaScript evaluator. The read-only ``skip-validation`` control is not applied. + +Standard library only -- the check has to stay cheap enough to run on every PR. +Regression coverage: backend/tests/test_label_declarations.py. + +Usage: + python scripts/check_label_declarations.py +""" + +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path +from typing import NamedTuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +LABELS_FILE = REPO_ROOT / ".github" / "labels.yml" +TRIAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "triage.yml" + +# Only workflow JavaScript is inspected; YAML and JavaScript comments are not +# label sources. Regex literals must be tokens too, so their escaped slashes +# cannot be mistaken for the start of a comment. +SCRIPT = re.compile(r"^(?P *)script:\s*[|>][-+]?\s*(?:#.*)?$") +JS_TOKEN = re.compile( + r"(?P//[^\n]*|/\*.*?\*/)" + r"|(?P'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")" + r"|(?P