From e9ca379345f6fc7bbdb1f8a2b7c9bb07e6ee5a47 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Mon, 15 Jun 2026 17:16:22 -0500 Subject: [PATCH 1/2] feat(labels): add gate-label audit and seed referenced-but-unseeded labels A reusable workflow that references a label never added to the seed set fails on repos that lack it. Add a gate-label audit that statically collects every label declared as a *-label/*-labels workflow-input default and fails on any that is not seeded; unreferenced seed labels are informational only. The audit surfaced ci:full, auto-triaged, and doc-orphan as referenced-but-unseeded -- now seeded. Move the seed array into scripts/labels.mjs so the audit can import it without running the setup-repo CLI. Refs #83 Co-Authored-By: Claude Opus 4.8 (1M context) --- .changelog/unreleased/83-gate-label-audit.md | 12 ++++ scripts/gate-labels.mjs | 71 ++++++++++++++++++++ scripts/gate-labels.test.mjs | 68 +++++++++++++++++++ scripts/labels.mjs | 66 ++++++++++++++++++ scripts/setup-repo.mjs | 53 ++------------- 5 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 .changelog/unreleased/83-gate-label-audit.md create mode 100644 scripts/gate-labels.mjs create mode 100644 scripts/gate-labels.test.mjs create mode 100644 scripts/labels.mjs diff --git a/.changelog/unreleased/83-gate-label-audit.md b/.changelog/unreleased/83-gate-label-audit.md new file mode 100644 index 0000000..63263a1 --- /dev/null +++ b/.changelog/unreleased/83-gate-label-audit.md @@ -0,0 +1,12 @@ +### Added + +- Gate-label audit (`scripts/gate-labels.mjs` + `gate-labels.test.mjs`): fails if a + label declared as a `*-label`/`*-labels` workflow-input default is missing from + the seed set; reports unreferenced seed labels as informational only. (#83) +- Seed labels `ci:full`, `auto-triaged`, and `doc-orphan`, which reusable + workflows already reference but were never provisioned. (#83) + +### Changed + +- Moved the canonical label seed array out of `setup-repo.mjs` into + `scripts/labels.mjs` so the audit can import it without executing the CLI. (#83) diff --git a/scripts/gate-labels.mjs b/scripts/gate-labels.mjs new file mode 100644 index 0000000..0877c59 --- /dev/null +++ b/scripts/gate-labels.mjs @@ -0,0 +1,71 @@ +// gate-labels.mjs — audit that every GitHub label a reusable workflow applies or +// gates on is present in the canonical seed set (labels.mjs). +// +// Why: workflows declare their label dependency through `*-label` / `*-labels` +// inputs (e.g. repo-required-gate's `force-full-ci-label: ci:full`, +// changelog-fragment's `opt-out-label: no-changelog`). If such a label is never +// seeded, `gh ... --label X` and label-gated jobs fail on that repo. The audit +// FAILS on a referenced-but-unseeded label; it only REPORTS unreferenced seed +// labels (many are valid manual/triage labels with no gate that parses them). +// Source: forensic analysis of session 019eccc1 (F1) + owner refinement 4. + +// Inputs whose name ends in -label(s) but whose value is NOT a label name. +const IGNORE_INPUTS = new Set(['sync-labels']); // labeler on/off toggle, not a label + +const LABEL_INPUT_RE = /^(\s*)([a-z0-9][a-z0-9-]*-labels?):\s*$/i; +const DEFAULT_RE = /^\s*default:\s*(.+?)\s*$/; + +/** + * Extract label names declared as defaults of `*-label` / `*-labels` workflow + * inputs. Singular inputs yield one name; plural inputs are comma-split. + * Skips the ignore set, booleans, empty defaults, and `${{ }}` expressions. + * + * @param {string} workflowText Raw YAML of a workflow file. + * @returns {string[]} referenced label names (may contain duplicates) + */ +export function extractLabelInputDefaults(workflowText) { + const lines = String(workflowText || '').split(/\r?\n/); + const found = []; + + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(LABEL_INPUT_RE); + if (!m) continue; + const [, indent, inputName] = m; + if (IGNORE_INPUTS.has(inputName)) continue; + const plural = inputName.endsWith('-labels'); + + for (let j = i + 1; j < lines.length; j++) { + const line = lines[j]; + if (line.trim() === '') continue; + const lineIndent = (line.match(/^(\s*)/)[1] || '').length; + if (lineIndent <= indent.length) break; // left this input's block + + const d = line.match(DEFAULT_RE); + if (!d) continue; + const val = d[1].replace(/^["']|["']$/g, '').trim(); + if (!val || val === 'true' || val === 'false' || val.startsWith('${{')) break; + const names = plural ? val.split(',').map((s) => s.trim()).filter(Boolean) : [val]; + found.push(...names); + break; + } + } + + return found; +} + +/** + * Compare the set of gate-referenced labels against the seed set. + * + * @param {Iterable} referenced Labels referenced by gates. + * @param {Iterable} seed Seeded label names. + * @returns {{missing: string[], unreferenced: string[]}} + * missing = referenced but NOT seeded (a real gap — fail). + * unreferenced = seeded but not gate-referenced (informational only). + */ +export function auditGateLabels(referenced, seed) { + const seedSet = seed instanceof Set ? seed : new Set(seed); + const refSet = referenced instanceof Set ? referenced : new Set(referenced); + const missing = [...refSet].filter((name) => !seedSet.has(name)).sort(); + const unreferenced = [...seedSet].filter((name) => !refSet.has(name)).sort(); + return { missing, unreferenced }; +} diff --git a/scripts/gate-labels.test.mjs b/scripts/gate-labels.test.mjs new file mode 100644 index 0000000..8b3ee27 --- /dev/null +++ b/scripts/gate-labels.test.mjs @@ -0,0 +1,68 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { LABEL_NAMES } from './labels.mjs'; +import { auditGateLabels, extractLabelInputDefaults } from './gate-labels.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const workflowsDir = join(repoRoot, '.github', 'workflows'); + +function referencedGateLabels() { + const names = new Set(); + for (const file of readdirSync(workflowsDir)) { + if (!/\.ya?ml$/.test(file)) continue; + const text = readFileSync(join(workflowsDir, file), 'utf8'); + for (const name of extractLabelInputDefaults(text)) names.add(name); + } + return names; +} + +describe('gate-label audit (live workflows vs. seed set)', () => { + const referenced = referencedGateLabels(); + + it('statically collects the labels workflows depend on', () => { + // Sanity: the scanner finds the labels we know gates apply/gate on. + for (const name of ['ci:full', 'no-changelog', 'anomaly', 'doc-orphan', 'auto-triaged']) { + expect(referenced.has(name)).toBe(true); + } + }); + + it('seeds every gate-referenced label (referenced-but-unseeded is a hard failure)', () => { + const { missing } = auditGateLabels(referenced, LABEL_NAMES); + expect(missing).toEqual([]); + }); + + it('treats unreferenced seed labels as informational, not a failure', () => { + const { unreferenced } = auditGateLabels(referenced, LABEL_NAMES); + expect(Array.isArray(unreferenced)).toBe(true); + if (unreferenced.length > 0) { + // eslint-disable-next-line no-console + console.info( + `[gate-label audit] ${unreferenced.length} seed labels are not gate-referenced (informational): ${unreferenced.join(', ')}`, + ); + } + }); +}); + +describe('extractLabelInputDefaults', () => { + it('reads a singular *-label input default', () => { + const yml = ['inputs:', ' opt-out-label:', ' type: string', ' default: "no-changelog"'].join('\n'); + expect(extractLabelInputDefaults(yml)).toEqual(['no-changelog']); + }); + + it('splits a plural *-labels input default on commas', () => { + const yml = [' exempt-pr-labels:', ' default: "pinned,wip,blocked"'].join('\n'); + expect(extractLabelInputDefaults(yml)).toEqual(['pinned', 'wip', 'blocked']); + }); + + it('ignores the sync-labels toggle, booleans, and ${{ }} expressions', () => { + const yml = [ + ' sync-labels:', + ' default: false', + ' some-label:', + ' default: ${{ inputs.x }}', + ].join('\n'); + expect(extractLabelInputDefaults(yml)).toEqual([]); + }); +}); diff --git a/scripts/labels.mjs b/scripts/labels.mjs new file mode 100644 index 0000000..74724c7 --- /dev/null +++ b/scripts/labels.mjs @@ -0,0 +1,66 @@ +// labels.mjs — canonical ArchonVII label seed set. +// +// Applied by setup-repo.mjs and audited by gate-labels.test.mjs. Any label that a +// reusable workflow or script *applies* or *gates on* (see gate-labels.mjs) MUST +// appear here, or `gh ... --label X` and label-gated workflows fail on repos that +// were never seeded with that label. The gate-label audit enforces this. +// +// Source of the gate-required entries: the `*-label` input defaults in +// .github/workflows/*.yml (see gate-labels.mjs `extractLabelInputDefaults`). + +export const LABELS = [ + // Type + { name: 'bug', color: 'D93F0B', description: 'Something is broken' }, + { name: 'enhancement', color: 'A2EEEF', description: 'New feature or capability' }, + { name: 'documentation', color: '0075CA', description: 'Docs gap, error, or improvement' }, + { name: 'chore', color: 'CFD3D7', description: 'Tech debt, refactor, cleanup' }, + { name: 'refactor', color: 'CFD3D7', description: 'Code restructure without behavior change' }, + { name: 'tests', color: '5319E7', description: 'Test-only changes' }, + { name: 'performance', color: 'FBCA04', description: 'Performance improvement' }, + { name: 'dependencies', color: '0366D6', description: 'Dependency bump or change' }, + { name: 'security', color: 'B60205', description: 'Security-relevant change' }, + { name: 'breaking', color: 'B60205', description: 'Breaking change (API, schema, behavior)' }, + + // Severity (used by anomaly-to-issue workflow) + { name: 'severity:low', color: '0E8A16', description: 'Anomaly severity: low' }, + { name: 'severity:medium', color: 'FBCA04', description: 'Anomaly severity: medium' }, + { name: 'severity:high', color: 'D93F0B', description: 'Anomaly severity: high' }, + { name: 'severity:critical', color: 'B60205', description: 'Anomaly severity: critical' }, + + // Priority + { name: 'priority:p0', color: 'B60205', description: 'Drop everything' }, + { name: 'priority:p1', color: 'D93F0B', description: 'Do soon' }, + { name: 'priority:p2', color: 'FBCA04', description: 'Normal' }, + { name: 'priority:p3', color: 'C5DEF5', description: 'Nice to have' }, + + // Effort (from the `open` skill — drives triage and PRD breakdown) + { name: 'effort:s', color: 'C2E0C6', description: '< 1 hour' }, + { name: 'effort:m', color: 'BFE5BF', description: '~ half day' }, + { name: 'effort:l', color: 'FBCA04', description: '1–2 days' }, + { name: 'effort:xl', color: 'D93F0B', description: 'Multi-day' }, + + // Status + { name: 'wip', color: 'FEF2C0', description: 'Work in progress; not ready for review' }, + { name: 'blocked', color: 'E11D21', description: 'Blocked on external dependency' }, + { name: 'stale', color: 'CFD3D7', description: 'Auto-applied by stale workflow' }, + { name: 'pinned', color: '5319E7', description: 'Exempt from stale/lock workflows' }, + { name: 'roadmap', color: '5319E7', description: 'Long-running roadmap tracking issue' }, + + // Workflow / release + { name: 'no-changelog', color: 'EDEDED', description: 'Skip the CHANGELOG fragment requirement' }, + { name: 'anomaly', color: 'B60205', description: 'Auto-promoted from .anomalies/ file on merge' }, + { name: 'ignore-for-release', color: 'EDEDED', description: 'Exclude from auto-generated release notes' }, + { name: 'auto-merge', color: '0E8A16', description: 'Eligible for auto-merge once CI is green' }, + // Gate-required labels surfaced by the gate-label audit (referenced-but-unseeded). + // Colors are cosmetic; the provenance is the referencing workflow named below. + { name: 'ci:full', color: '5319E7', description: 'Force the full required-gate CI lanes regardless of changed paths' }, // force-full-ci-label default in repo-required-gate.yml + { name: 'auto-triaged', color: 'C5DEF5', description: 'Applied by the anomaly auto-triage workflow' }, // auto-triage-label default in anomaly-triage.yml + { name: 'doc-orphan', color: 'FBCA04', description: 'Durable doc missing from its index (doc-orphan detector)' }, // base-label default in doc-orphan-detector.yml + + // PRD / breakdown (from the `open` skill) + { name: 'prd', color: '5319E7', description: 'Parent PRD issue — broken into tracer-bullet sub-issues' }, + { name: 'tracer-bullet', color: 'BFD4F2', description: 'Thin vertical slice cutting through all layers' }, + { name: 'needs-triage', color: 'FEF2C0', description: 'Not yet triaged into a bucket' }, +]; + +export const LABEL_NAMES = new Set(LABELS.map((label) => label.name)); diff --git a/scripts/setup-repo.mjs b/scripts/setup-repo.mjs index ee6f0a2..9610742 100644 --- a/scripts/setup-repo.mjs +++ b/scripts/setup-repo.mjs @@ -24,6 +24,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { LABELS } from './labels.mjs'; const exec = promisify(execFile); @@ -51,55 +52,9 @@ const NO_PROTECTION = flag('--no-protection'); // --- Standard label set ---------------------------------------------------- -const LABELS = [ - // Type - { name: 'bug', color: 'D93F0B', description: 'Something is broken' }, - { name: 'enhancement', color: 'A2EEEF', description: 'New feature or capability' }, - { name: 'documentation', color: '0075CA', description: 'Docs gap, error, or improvement' }, - { name: 'chore', color: 'CFD3D7', description: 'Tech debt, refactor, cleanup' }, - { name: 'refactor', color: 'CFD3D7', description: 'Code restructure without behavior change' }, - { name: 'tests', color: '5319E7', description: 'Test-only changes' }, - { name: 'performance', color: 'FBCA04', description: 'Performance improvement' }, - { name: 'dependencies', color: '0366D6', description: 'Dependency bump or change' }, - { name: 'security', color: 'B60205', description: 'Security-relevant change' }, - { name: 'breaking', color: 'B60205', description: 'Breaking change (API, schema, behavior)' }, - - // Severity (used by anomaly-to-issue workflow) - { name: 'severity:low', color: '0E8A16', description: 'Anomaly severity: low' }, - { name: 'severity:medium', color: 'FBCA04', description: 'Anomaly severity: medium' }, - { name: 'severity:high', color: 'D93F0B', description: 'Anomaly severity: high' }, - { name: 'severity:critical', color: 'B60205', description: 'Anomaly severity: critical' }, - - // Priority - { name: 'priority:p0', color: 'B60205', description: 'Drop everything' }, - { name: 'priority:p1', color: 'D93F0B', description: 'Do soon' }, - { name: 'priority:p2', color: 'FBCA04', description: 'Normal' }, - { name: 'priority:p3', color: 'C5DEF5', description: 'Nice to have' }, - - // Effort (from the `open` skill — drives triage and PRD breakdown) - { name: 'effort:s', color: 'C2E0C6', description: '< 1 hour' }, - { name: 'effort:m', color: 'BFE5BF', description: '~ half day' }, - { name: 'effort:l', color: 'FBCA04', description: '1–2 days' }, - { name: 'effort:xl', color: 'D93F0B', description: 'Multi-day' }, - - // Status - { name: 'wip', color: 'FEF2C0', description: 'Work in progress; not ready for review' }, - { name: 'blocked', color: 'E11D21', description: 'Blocked on external dependency' }, - { name: 'stale', color: 'CFD3D7', description: 'Auto-applied by stale workflow' }, - { name: 'pinned', color: '5319E7', description: 'Exempt from stale/lock workflows' }, - { name: 'roadmap', color: '5319E7', description: 'Long-running roadmap tracking issue' }, - - // Workflow / release - { name: 'no-changelog', color: 'EDEDED', description: 'Skip the CHANGELOG fragment requirement' }, - { name: 'anomaly', color: 'B60205', description: 'Auto-promoted from .anomalies/ file on merge' }, - { name: 'ignore-for-release', color: 'EDEDED', description: 'Exclude from auto-generated release notes' }, - { name: 'auto-merge', color: '0E8A16', description: 'Eligible for auto-merge once CI is green' }, - - // PRD / breakdown (from the `open` skill) - { name: 'prd', color: '5319E7', description: 'Parent PRD issue — broken into tracer-bullet sub-issues' }, - { name: 'tracer-bullet', color: 'BFD4F2', description: 'Thin vertical slice cutting through all layers' }, - { name: 'needs-triage', color: 'FEF2C0', description: 'Not yet triaged into a bucket' }, -]; +// The canonical label seed set now lives in labels.mjs (imported above) so the +// gate-label audit (gate-labels.test.mjs) can verify it against the labels that +// reusable workflows actually apply/gate on, without importing this CLI script. // --- gh wrapper ------------------------------------------------------------ From a6ac15f027b2f94311b8a3a45a5af8e0e1fca1b6 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Mon, 15 Jun 2026 19:34:29 -0500 Subject: [PATCH 2/2] test(gate-labels): document inline label inputs are out of scope LABEL_INPUT_RE matches block-style *-label inputs only. Add a comment making the limitation explicit and a test locking that an inline single-line declaration yields no labels, so a future maintainer adding an inline gate label is not lulled into false audit confidence. Refs #83 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/gate-labels.mjs | 5 +++++ scripts/gate-labels.test.mjs | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/scripts/gate-labels.mjs b/scripts/gate-labels.mjs index 0877c59..08844fc 100644 --- a/scripts/gate-labels.mjs +++ b/scripts/gate-labels.mjs @@ -12,6 +12,11 @@ // Inputs whose name ends in -label(s) but whose value is NOT a label name. const IGNORE_INPUTS = new Set(['sync-labels']); // labeler on/off toggle, not a label +// Matches BLOCK-style inputs only: the `*-label(s):` key on its own line, with the +// value on a following `default:` line. Inline/flow-style on one line (e.g. +// `force-full-ci-label: ci:full`) is intentionally NOT scanned — every live workflow +// input is block-style (locked by gate-labels.test.mjs). Extend this if a future +// workflow ever declares a gate label inline, or the audit could miss it. const LABEL_INPUT_RE = /^(\s*)([a-z0-9][a-z0-9-]*-labels?):\s*$/i; const DEFAULT_RE = /^\s*default:\s*(.+?)\s*$/; diff --git a/scripts/gate-labels.test.mjs b/scripts/gate-labels.test.mjs index 8b3ee27..1489878 100644 --- a/scripts/gate-labels.test.mjs +++ b/scripts/gate-labels.test.mjs @@ -65,4 +65,10 @@ describe('extractLabelInputDefaults', () => { ].join('\n'); expect(extractLabelInputDefaults(yml)).toEqual([]); }); + + it('intentionally ignores inline (flow-style) label inputs (block-style only)', () => { + // Documents the LABEL_INPUT_RE scope: a single-line `key: value` is not scanned. + // All live workflow label inputs are block-style; revisit if that ever changes. + expect(extractLabelInputDefaults(' force-full-ci-label: ci:full')).toEqual([]); + }); });