Skip to content
Merged
3 changes: 3 additions & 0 deletions AUDIT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -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
Expand Down
35 changes: 31 additions & 4 deletions spec/audit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand DownExpand Up@@ -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)
Expand Down
5 changes: 3 additions & 2 deletions spec/files.json
Original file line numberDiff line numberDiff line change
@@ -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": "*" },
Expand All@@ -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": "*" },
Expand Down
20 changes: 18 additions & 2 deletions spec/files.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }
}
}
}
Expand Down
46 changes: 46 additions & 0 deletions spec/scope-model.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- Repo -->
[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
83 changes: 76 additions & 7 deletions spec/validate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"))
Expand DownExpand Up@@ -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):
Expand All@@ -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"):
Expand DownExpand Up@@ -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
Comment thread
ptr727 marked this conversation as resolved.

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:
Expand Down