diff --git a/AUDIT.md b/AUDIT.md index 9fa88390..43417fe7 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -31,6 +31,8 @@ Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If th Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses on a source-only repo - all N/A. +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. + ## 4. Per-Dimension Checks (Letter and Intent) For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: @@ -152,6 +154,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [repo-config-settings]: ./repo-config/settings.json [reports]: ./reports/ [repos]: ./registry/repos.json +[scope-model]: ./spec/scope-model.md [secrets]: ./spec/secrets.json [spec]: ./spec/ [standup]: ./STANDUP.md diff --git a/spec/audit.py b/spec/audit.py index 829004a2..d6945563 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -84,6 +84,33 @@ def repo_slug(entry): return "/".join(entry["url"].rstrip("/").split("/")[-2:]) +def repo_selectors(entry, defaults): + """The scope-selector set a files.json appliesTo is matched against (see spec/scope-model.md). + + The four namespaces - project types, workflowModel, releaseTrigger, consumerModel - are disjoint, so + a flat token set is unambiguous. workflowModel and releaseTrigger resolve repo -> defaults -> fleet + default (as configure.sh does). consumerModel has no fleet default - validate.py requires it on every + cataloged repo, so a cataloged repo always contributes one. + """ + sel = set(entry.get("types", [])) + sel.add(entry.get("workflowModel") or defaults.get("workflowModel") or "release") + sel.add(entry.get("releaseTrigger") or defaults.get("releaseTrigger") or "two-phase") + # consumerModel has no defaults fallback - the registry schema does not allow defaults.consumerModel, and + # validate.py requires it on every cataloged repo. The guard only shields a malformed non-cataloged entry. + cm = entry.get("consumerModel") + if cm: + sel.add(cm) + return sel + + +def applies(applies_to, sel): + """True if an appliesTo selector applies to a repo's selector set. Disjunctive any-of, with `*` meaning all.""" + if applies_to == "*": + return True + tokens = applies_to if isinstance(applies_to, list) else [applies_to] + return bool(set(tokens) & sel) + + def audit_repo(entry, spec): findings = [] # (kind, text) slug = repo_slug(entry) @@ -230,14 +257,14 @@ def audit_repo(entry, spec): findings.append(("DRIFT", f"dependabot: {eco} ecosystem not declared though {why}; add it for both main and develop per the fleet norm")) # --- File presence on the ground-truth branch --- + # appliesTo is matched against the repo's full selector set (types + workflowModel + releaseTrigger + + # consumerModel), so the release/operational develop ruleset is two data entries, not a code swap. + sel = repo_selectors(entry, spec["registry"].get("defaults", {})) seen_paths = set() for item in spec["files"]["baseline"]: - applies = item.get("appliesTo", "*") - if applies != "*" and not set(applies) & set(types): + if not applies(item.get("appliesTo", "*"), sel): continue path = item["path"] - if path == "repo-config/develop.json" and model == "operational": - path = "repo-config/operational/develop.json" if path in seen_paths: continue seen_paths.add(path) diff --git a/spec/files.json b/spec/files.json index fb9760c3..7d3fd379 100644 --- a/spec/files.json +++ b/spec/files.json @@ -1,6 +1,6 @@ { "$schema": "./files.schema.json", - "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A.", + "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary.", "baseline": [ { "path": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "Git and Commit Rules", "Branching Model", "Release Model", "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, { "path": "CODESTYLE.md", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, @@ -14,7 +14,8 @@ { "path": "cspell.json", "whole": true, "appliesTo": "*" }, { "path": ".gitignore", "appliesTo": "*" }, { "path": "version.json", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, - { "path": "repo-config/develop.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "repo-config/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["release"] }, + { "path": "repo-config/operational/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["operational"] }, { "path": "repo-config/main.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, { "path": "AUDIT.md", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": "spec/secrets.json", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, diff --git a/spec/files.schema.json b/spec/files.schema.json index 7013e1a7..aafc5de1 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -16,11 +16,27 @@ "properties": { "path": { "type": "string" }, "whole": { "type": "boolean" }, - "sections": { "type": "array", "items": { "type": "string" } }, + "sections": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 } + } + } + ] + } + }, "placeholders": { "type": "array", "items": { "type": "string" } }, "reference": { "type": "string" }, "intentRef": { "type": "string" }, - "appliesTo": { "type": ["string", "array"] } + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 } } } } diff --git a/spec/scope-model.md b/spec/scope-model.md new file mode 100644 index 00000000..a0eafe05 --- /dev/null +++ b/spec/scope-model.md @@ -0,0 +1,46 @@ +# Scope Model + +How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not itself carried to the fleet. + +## Two Axes + +A rule has a physical home, and - if it is a repo rule - a reach. + +- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/` - it loads in every session regardless of repo and covers ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ - the write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo - the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. + +## Selectors + +A selector is one token from one of four **disjoint** namespaces. Because the namespaces share no token, a single flat `appliesTo` list is unambiguous. + +| Namespace | Tokens | Source of truth | +| --- | --- | --- | +| project type | `csharp` `nuget` `pypi` `python` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `docs` | [`spec/project-types.json`][project-types] | +| workflow model | `release` `operational` | [`registry/repos.schema.json`][repos-schema] | +| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | +| consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | + +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default - [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. + +## appliesTo Semantics + +`appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). + +- **`*`** means all repos. +- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate - a single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. +- Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. + +## Documenting a Whole-Carried File's Section Scopes + +A file carried `whole` (no `sections` allowlist) still has single-scope sections, and the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. + +- [`CODESTYLE.md`][codestyle]: **General** is all-downstream, **.NET** is `csharp`, **Python** is `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. + + +[audit]: ./audit.py +[codestyle]: ../CODESTYLE.md +[files]: ./files.json +[files-schema]: ./files.schema.json +[project-types]: ./project-types.json +[repos-schema]: ../registry/repos.schema.json +[validate]: ./validate.py diff --git a/spec/validate.py b/spec/validate.py index 6787404e..28b4cf19 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -12,6 +12,13 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent +# Scope-selector vocabularies (see spec/scope-model.md), kept in sync with registry/repos.schema.json +# $defs. The four namespaces - project types plus these three - must stay disjoint, so a flat appliesTo +# token set in spec/files.json is unambiguous. +WORKFLOW_MODELS = ("release", "operational") +RELEASE_TRIGGERS = ("two-phase", "publish-on-merge", "dispatch-only", "none") +CONSUMER_MODELS = ("push", "pull") + def load(rel): return json.loads((ROOT / rel).read_text(encoding="utf-8")) @@ -90,11 +97,16 @@ def check_secret_set(label, entry, need_kind): print(f" - {e}") return 1 - # defaults.workflowModel feeds configure.sh's fallback, so an invalid value here breaks the apply while every - # per-repo entry still validates - check it once. - default_model = repos.get("defaults", {}).get("workflowModel") - if default_model is not None and default_model not in ("release", "operational"): - errors.append(f"defaults.workflowModel '{default_model}' invalid (expected release or operational)") + # defaults.workflowModel/releaseTrigger feed configure.sh's fallback and selector resolution, so an + # invalid value here breaks the apply or scopes wrong while every per-repo entry still validates - check + # them once. + reg_defaults = repos.get("defaults", {}) + default_model = reg_defaults.get("workflowModel") + if default_model is not None and default_model not in WORKFLOW_MODELS: + errors.append(f"defaults.workflowModel '{default_model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") + default_trigger = reg_defaults.get("releaseTrigger") + if default_trigger is not None and default_trigger not in RELEASE_TRIGGERS: + errors.append(f"defaults.releaseTrigger '{default_trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") for i, repo in enumerate(repos["repos"]): if not isinstance(repo, dict): @@ -121,8 +133,20 @@ def check_secret_set(label, entry, need_kind): errors.append(f"{name}: type '{t}' not defined in project-types.json") model = repo.get("workflowModel") - if model is not None and model not in ("release", "operational"): - errors.append(f"{name}: workflowModel '{model}' invalid (expected release or operational)") + if model is not None and model not in WORKFLOW_MODELS: + errors.append(f"{name}: workflowModel '{model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") + + # releaseTrigger is a scope selector (spec/scope-model.md), so an invalid value would silently fail + # to match any releaseTrigger-scoped section rather than error. + trigger = repo.get("releaseTrigger") + if trigger is not None and trigger not in RELEASE_TRIGGERS: + errors.append(f"{name}: releaseTrigger '{trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") + + # consumerModel is a scope selector (spec/scope-model.md), so a cataloged repo must declare it or a + # push/pull-scoped section would fail open (never matched) on that repo. + cm = repo.get("consumerModel") + if cm not in CONSUMER_MODELS: + errors.append(f"{name}: consumerModel '{cm}' invalid or missing (expected {' or '.join(CONSUMER_MODELS)})") eol = repo.get("lineEndings") if eol is not None and eol not in ("lf", "crlf"): @@ -167,6 +191,51 @@ def check_secret_set(label, entry, need_kind): if kind and mech != kind: errors.append(f"{name}: {target} labeled '{mech}' but its mechanism is '{kind}'") + # files.json appliesTo selectors must resolve to a known token, and no project type may collide with a + # reserved selector - a flat token set is only unambiguous while the namespaces stay disjoint. An + # unknown token fails open (it never matches), so a required file/section would silently apply nowhere. + reserved = set(WORKFLOW_MODELS) | set(RELEASE_TRIGGERS) | set(CONSUMER_MODELS) + clash = known_types & reserved + if clash: + errors.append(f"files.json: project type(s) collide with a reserved scope selector: {', '.join(sorted(clash))}") + universe = known_types | reserved + + def check_selector(where, applies_to): + if isinstance(applies_to, list) and not applies_to: + errors.append(f"files.json: {where} appliesTo is an empty list (use \"*\" for all repos, or list selectors) - it would apply nowhere") + return + tokens = [] if applies_to == "*" else (applies_to if isinstance(applies_to, list) else [applies_to]) + for tok in tokens: + # CI runs no JSON-schema validation, so guard the type here rather than crash on an unhashable + # token (e.g. a nested object) reaching the set-membership test below. + if not isinstance(tok, str): + errors.append(f"files.json: {where} appliesTo has a non-string token {tok!r}") + elif tok not in universe: + errors.append(f"files.json: {where} appliesTo '{tok}' is not a known selector") + + # CI runs no JSON-schema validation, so shape-check files.json here rather than crash on a malformed + # entry (a non-object baseline item, a non-array sections, a section that is neither string nor object). + files = load("spec/files.json") + baseline = files.get("baseline", []) + if not isinstance(baseline, list): + errors.append("files.json: 'baseline' must be an array") + baseline = [] + for item in baseline: + if not isinstance(item, dict): + errors.append(f"files.json: baseline entry {item!r} is not an object") + continue + path = item.get("path", "?") + check_selector(path, item.get("appliesTo", "*")) + sections = item.get("sections", []) + if not isinstance(sections, list): + errors.append(f"files.json: {path} sections must be an array") + continue + for elt in sections: + if isinstance(elt, dict): + check_selector(f"{path} section '{elt.get('name', '?')}'", elt.get("appliesTo", "*")) + elif not isinstance(elt, str): + errors.append(f"files.json: {path} section entry {elt!r} must be a string or object") + if errors: print("Spec validation FAILED:") for e in errors: