diff --git a/registry/repos.json b/registry/repos.json index fd7bb89d..023f6d51 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -201,7 +201,8 @@ "name": "ESPHome-Config", "url": "https://github.com/ptr727/ESPHome-Config", "status": "cataloged", - "types": ["source-only"], + "types": ["source-only", "python", "cpp"], + "profiles": { "python": "lint-only", "cpp": "lint-only" }, "groundTruthBranch": "main", "workflowModel": "operational", "lineEndings": "lf", @@ -210,7 +211,7 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime; distinct from that Docker repo."] + "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime. Distinct from that Docker repo.", "First repo with a lint-only python codegen subtree (easystart/python: ruff/mypy config only, no tests, no uv.lock) and lint-only cpp (committed custom-component and template headers). A shared clang-format config for the cpp style is not yet added."] }, { "name": "HomeAssistant-Config", diff --git a/registry/repos.schema.json b/registry/repos.schema.json index 032838f4..bd14c2ff 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -44,6 +44,7 @@ "url": { "type": "string", "format": "uri" }, "status": { "enum": ["cataloged", "backlog"] }, "types": { "type": "array", "items": { "type": "string" } }, + "profiles": { "type": "object", "additionalProperties": { "enum": ["lint-only", "build"] } }, "classificationPending": { "type": "boolean" }, "groundTruthBranch": { "type": "string" }, "workflowModel": { "$ref": "#/$defs/workflowModel" }, diff --git a/spec/audit.py b/spec/audit.py index 258db235..3dfccb55 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -565,6 +565,12 @@ def audit_repo(entry, spec): # --- Secrets (names only) --- secrets = spec["secrets"] + # The codecov coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file) is claimed by a + # type only at build profile: a lint-only language has no tests, so no coverage (spec/type-model.md). + repo_profiles = entry.get("profiles", {}) + if not isinstance(repo_profiles, dict): + repo_profiles = {} + coverage_active = any(secrets.get("typeMechanisms", {}).get(t) == "codecov" and repo_profiles.get(t) != "lint-only" for t in types) stores = {} # No ok404: an empty store returns {"secrets": []}, so a 404/403 (permissions, rename) must # surface as ERROR rather than cascade into false missing-secret DEFECTs. @@ -572,7 +578,7 @@ def audit_repo(entry, spec): data = gh(path) stores[store] = {s["name"] for s in (data or {}).get("secrets", [])} mechanisms = [secrets["targetMechanisms"].get(p.get("target")) for p in entry.get("publish", [])] - mechanisms += [secrets.get("typeMechanisms", {}).get(t) for t in types] + mechanisms += [secrets.get("typeMechanisms", {}).get(t) for t in types if repo_profiles.get(t) != "lint-only"] claimed = [secrets["mechanisms"][m] for m in mechanisms if m and m in secrets["mechanisms"]] required_by_store = {"actions": set(), "dependabot": set()} for store in secrets["baseline"].get("stores", []): @@ -643,6 +649,8 @@ def audit_repo(entry, spec): if not applies(item.get("appliesTo", "*"), sel): continue path = item["path"] + if path == "codecov.yml" and not coverage_active: + continue # coverage feature file: N/A when no type claims codecov at build profile (spec/type-model.md) if path not in wanted_sections: wanted_sections[path] = set() verbatim_secs[path] = set() diff --git a/spec/project-types.json b/spec/project-types.json index 22c97818..9436e55c 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -1,16 +1,17 @@ { "$schema": "./project-types.schema.json", - "note": "Type -> requirements and checks. Each check names a verdict tier (letter = exact form, intent = equivalent outcome) and points at the prose section that owns the rationale. The audit (AUDIT.md) runs the checks for a repo's applicable types plus the cross-cutting dimensions; an absent construct is N/A, not a defect.", + "note": "Type -> requirements and checks. Each check names a verdict tier (letter = exact form, intent = equivalent outcome) and points at the prose section that owns the rationale. The audit (AUDIT.md) runs the checks for a repo's declared types plus the cross-cutting dimensions. An absent construct is N/A, not a defect. How types and profiles are declared, validated against detection, and gated by profile is defined in spec/type-model.md.", "types": { "csharp": { "detect": ["*.csproj", "*.slnx", "*.sln"], + "profiles": ["build"], "intentRefs": ["CODESTYLE.md", "AGENTS.md#line-endings"], "requiredFiles": [".editorconfig", ".gitattributes"], "checks": [ { "id": "csharp.editorconfig.ruleblock", "verdict": "letter", "assert": ".editorconfig carries the shared [*.cs] plus ReSharper rule block.", "intentRef": "CODESTYLE.md" }, { "id": "csharp.analyzers.zerowarnings", "verdict": "intent", "assert": "Analyzer severities are enforced; warnings are not relaxed or suppressed wholesale.", "intentRef": "CODESTYLE.md" }, { "id": "csharp.centralconfig.props", "verdict": "letter", "assert": "Shared MSBuild configuration is centralized at the repo root: Directory.Build.props carries the common analyzer and warning properties (the Zero Warnings set), and Directory.Packages.props enables ManagePackageVersionsCentrally with every dependency version declared once - a csproj carries only project-specific properties and versionless PackageReference items.", "intentRef": "CODESTYLE.md" }, - { "id": "csharp.coverage.codecov", "verdict": "letter", "assert": "The unit-test job collects coverage (dotnet test --collect:\"XPlat Code Coverage\" --results-directory ./coverage) and uploads it to Codecov via codecov/codecov-action, best-effort (fail_ci_if_error: false so a Codecov outage or an absent token never reds the gate); CODECOV_TOKEN is stored in the repo actions secrets and reaches the reusable validator via secrets: inherit. Required for every C# repo with tests.", "intentRef": "WORKFLOW.md" } + { "id": "csharp.coverage.codecov", "verdict": "letter", "assert": "The unit-test job collects coverage (dotnet test --collect:\"XPlat Code Coverage\" --results-directory ./coverage) and uploads it to Codecov via codecov/codecov-action, best-effort (fail_ci_if_error: false so a Codecov outage or an absent token never reds the gate). CODECOV_TOKEN is stored in the repo actions secrets and reaches the reusable validator via secrets: inherit. Required for every C# repo with tests.", "intentRef": "WORKFLOW.md", "minProfile": "build" } ] }, "nuget": { @@ -31,17 +32,26 @@ }, "python": { "detect": ["pyproject.toml", "setup.py"], + "profiles": ["build", "lint-only"], "canonicalPlacement": "pyproject.toml", - "profileNote": "Two structurally-detected profiles share this type (CODESTYLE.md Python 'Two profiles'). PROJECT: the Python has third-party runtime dependencies or is the repo's deliverable - a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --frozen + uv run in CI). SCRIPTS: stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by whether the Python has third-party runtime dependencies, which the audit detects structurally from pyproject.toml (see python.profile.detect) rather than by inspecting imports: [project]+deps/[build-system] + uv.lock -> PROJECT; tool-config-only, no [project]/[build-system], no uv.lock -> SCRIPTS. The profile changes which checks apply: see python.profile.detect and the per-check N/A notes.", + "profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies or as the repo's deliverable - a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --frozen + uv run in CI). The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by whether the Python has third-party runtime dependencies, which the audit detects structurally from pyproject.toml (see python.profile.detect) rather than by inspecting imports. The shape [project]+deps/[build-system] + uv.lock is build, and tool-config-only with no [project]/[build-system] and no uv.lock is lint-only. The declared profile corresponds to this shape, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile).", "checks": [ - { "id": "python.profile.detect", "verdict": "letter", "assert": "The profile is read from pyproject.toml: a [project] table with runtime dependencies (or a [build-system]) is the PROJECT profile; a pyproject carrying only [tool.*] config with no [project]/[build-system] and no uv.lock is the SCRIPTS profile. A SCRIPTS subtree must not carry a uv.lock or project/build metadata (that would misrepresent it as a shippable package); a PROJECT must.", "intentRef": "CODESTYLE.md" }, + { "id": "python.profile.detect", "verdict": "letter", "assert": "The declared profile corresponds to the pyproject.toml shape. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject carrying only [tool.*] config with no [project]/[build-system] and no uv.lock is the lint-only profile (the SCRIPTS shape). A lint-only subtree must not carry a uv.lock or project/build metadata, which would misrepresent it as a shippable package, and a build one must.", "intentRef": "CODESTYLE.md" }, { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present (pyproject.toml [tool.ruff]). Both profiles.", "intentRef": "CODESTYLE.md" }, - { "id": "python.pyright.config", "verdict": "intent", "assert": "PROJECT profile: pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline; third-party strictness is relaxed only where a dependency has no usable types. N/A for the SCRIPTS profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md" }, + { "id": "python.pyright.config", "verdict": "intent", "assert": "Build profile: pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline. Third-party strictness is relaxed only where a dependency has no usable types. N/A for the lint-only profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md", "minProfile": "build" }, { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, - { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker (not banned); required for a Home Assistant integration (platinum strict-typing) and is the SCRIPTS profile's type checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, - { "id": "python.coverage.codecov", "verdict": "letter", "assert": "The test job collects coverage (pytest --cov-report=xml) and uploads it to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false); CODECOV_TOKEN is stored in the repo actions secrets. Required for every Python repo with tests. N/A for the SCRIPTS profile (lint/type-checked only, no pytest); in a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md" }, - { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "PROJECT profile: the committed uv.lock is pinned to LF in both .editorconfig ([uv.lock]) and .gitattributes (uv.lock text eol=lf); uv regenerates it LF on every platform, so a CRLF-default repo otherwise reds editorconfig-checker on every uv lock/sync. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the SCRIPTS profile (no uv.lock by definition).", "intentRef": "AGENTS.md#line-endings" }, - { "id": "python.scripts.uvx", "verdict": "letter", "assert": "SCRIPTS profile only: the tools run via uvx (no project install, no lockfile). A uvx @ pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the PROJECT profile (which pins tool versions via uv.lock + uv sync --frozen instead).", "intentRef": "CODESTYLE.md" } + { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker, not banned. It is required for a Home Assistant integration (platinum strict-typing) and is the lint-only profile's type checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, + { "id": "python.coverage.codecov", "verdict": "letter", "assert": "The test job collects coverage (pytest --cov-report=xml) and uploads it to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). CODECOV_TOKEN is stored in the repo actions secrets. Required for every Python repo with tests. N/A for the lint-only profile (lint/type-checked only, no pytest). In a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md", "minProfile": "build" }, + { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "Build profile: the committed uv.lock is pinned to LF in both .editorconfig ([uv.lock]) and .gitattributes (uv.lock text eol=lf). uv regenerates it LF on every platform, so a CRLF-default repo otherwise reds editorconfig-checker on every uv lock/sync. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the lint-only profile (no uv.lock by definition).", "intentRef": "AGENTS.md#line-endings", "minProfile": "build" }, + { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx @ pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile (which pins tool versions via uv.lock + uv sync --frozen instead).", "intentRef": "CODESTYLE.md" } + ] + }, + "cpp": { + "detect": ["*.cpp", "*.cxx", "*.cc", "*.hpp", "*.hxx", "*.h", "*.ino", "*.c"], + "profiles": ["lint-only"], + "intentRefs": ["CODESTYLE.md"], + "checks": [ + { "id": "cpp.clangformat.shared", "verdict": "intent", "assert": "A clang-format configuration (.clang-format) drives C/C++ formatting and is shared by the editor, the CLI, and CI, feeding the operational lint gate. The scope is style only. Semantic and static analysis are left to the downstream toolchain that compiles the code (an ESPHome build), which has the compile context clang-tidy would need. A repo's .h is treated as C++ by context.", "intentRef": "CODESTYLE.md" } ] }, "console": { diff --git a/spec/project-types.schema.json b/spec/project-types.schema.json index fb88ec98..6e29ab23 100644 --- a/spec/project-types.schema.json +++ b/spec/project-types.schema.json @@ -20,7 +20,8 @@ "verdict": { "enum": ["letter", "intent"] }, "assert": { "type": "string" }, "intentRef": { "type": "string" }, - "workflowRef": { "type": "string" } + "workflowRef": { "type": "string" }, + "minProfile": { "enum": ["lint-only", "build"] } } }, "typeDef": { @@ -28,6 +29,7 @@ "additionalProperties": true, "properties": { "checks": { "type": "array", "items": { "$ref": "#/$defs/check" } }, + "profiles": { "type": "array", "items": { "enum": ["lint-only", "build"] } }, "appliesTo": { "type": ["string", "array"] }, "priority": { "enum": ["high", "normal"] } } diff --git a/spec/section-model.md b/spec/section-model.md index ee0abe53..f4bb9df9 100644 --- a/spec/section-model.md +++ b/spec/section-model.md @@ -40,6 +40,8 @@ A section is one of the following. Fidelity is declared in [files.json][files], | Devcontainer | intent | describes this repo's toolchain and devcontainer, genuinely per-repo | | Repository Layout | intent | describes this repo's directory tree, genuinely per-repo | +**Devcontainer content.** A devcontainer is optional infrastructure, not required by any repo type. An operational (live config) repo is edited and deployed live and typically has none, so its Devcontainer section states that plainly. A repo that keeps one - a code repo's toolchain, or an offline-debugging aid for a config repo - describes it. The section is present in every carried `AGENTS.md` so the development model is always answered, even when the answer is none. + **Not carried (hub-only).** `Repository Onboarding and Conformance` lives in the hub's `AGENTS.md` as hub-audit context (reconciling the registry, the STANDUP cold-start, the conformance matrix) but is not a carried section - a downstream agent never runs those. Its one universal rule, that a repo is done when it passes `AUDIT.md` for its type, is carried in `AUDIT.md` itself. Like the model docs and `STANDUP.md`, it is hub machinery, not fleet law. ## Changing the structure carries review weight diff --git a/spec/type-model.md b/spec/type-model.md new file mode 100644 index 00000000..d5214525 --- /dev/null +++ b/spec/type-model.md @@ -0,0 +1,71 @@ +# Project Type Model + +Companion to [section-model.md][section-model] and [fidelity-model.md][fidelity-model]. Those define how carried *content* is verified. This one defines how a repo's **types** - what it is built from and for - are declared, validated, and checked. It is the ground truth an agent or human consults before adding a type, a profile, or a type check, not a judgment re-derived each session. + +**Rollout status.** This model is being wired in stages. Where a rule below names a check `audit.py` does not yet run, or a schema field not yet defined, this doc is the contract that implementation realizes. + +## Declaration is the source of truth + +A repo's types are **declared** in its [registry/repos.json][repos] entry (`types`), and a language type may also declare a **profile** (below). The audit runs the checks for each declared type plus the cross-cutting dimensions. Declaration - not inference - is authoritative: the registry states what the repo *is*, and [project-types.json][types] holds each type's requirements and checks. + +This mirrors the fleet principle that *the registry is ground truth about reality, not intent* (the `setup.driftnotes.current` check, over the registry `driftNotes` field): a declaration is a claim about the repo that must match what the repo actually contains. + +## Detection validates, it does not classify + +Each type in `project-types.json` carries `detect` patterns (files or markers that evidence the type). Detection is a **validator and a discovery aid**, never an auto-classifier - it checks declarations against reality and surfaces candidates, but it does not decide a repo's types on its own. The two axes give four cases: + +| | detected | not detected | +| --- | --- | --- | +| **declared** | consistent - the checks run | **false declaration** - a finding (e.g. `cpp` declared, no C/C++ files present) | +| **not declared** | **discovery advisory** - declare it or mark it ignored | nothing to do | + +The undeclared-but-detected advisory has three honest resolutions, all explicit intent, never silent: + +- **declare** it, so its checks run; +- **ignore** it - an explicit suppression carrying a reason (the driftNote shape), for a language deliberately not tracked (vendored third-party code, an incidental snippet); +- leave it as a standing advisory until decided. + +A false declaration is always a finding: a claim the repo does not back is drift, the same way a stale driftNote is. + +## Profiles: build vs lint-only + +A language type is present at one of two **depths**, declared as its `profile`: + +- **build** - the language is compiled, tested, and/or packaged in this repo. Its full check set applies (style, type-check, tests, coverage, packaging). +- **lint-only** - the language is present and style-checked here, but not built: there is no build/test/package for it in this repo. Only its lint/style/type-check checks apply. Build, test, coverage, and packaging checks are N/A. + +Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build/test/coverage/package checks set `build`. The audit uses the declared profile to hold the coverage requirement - the CODECOV_TOKEN secret and the codecov.yml file - N/A for a lint-only language, replacing the older per-check "N/A for the SCRIPTS profile" prose. + +The profile is **declared and validated**, not merely detected. `python` already reads its shape structurally from `pyproject.toml` (a uv PROJECT with tests and a lockfile, versus stdlib SCRIPTS tooling). That structural read becomes the profile **validator** - a declared `python` profile that contradicts the pyproject shape is a false declaration. One concept (the declared profile), checked by detection, rather than two ways to classify. + +### Consequence for cross-cutting checks + +A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks - the `CODECOV_TOKEN` secret and the `codecov.yml` file presence - are **profile-aware**: they are N/A for a language whose declared profile has no tests. A lint-only language must never manufacture a coverage finding. + +## Languages + +Language types carry the style and type-check requirements for their language, gated by profile. A language that is only ever linted in the fleet is `lint-only` by nature and defines no build/test/package checks: + +- **cpp** - C/C++ present for style only. The check of record is **clang-format** (a shared config driving the editor, the CLI, and CI, a `parity.lang` arm), feeding the operational lint CI. Deeper semantic and static analysis is intentionally out of scope here - for a codegen or config repo the C++ is scaffolded and completed by its downstream toolchain (an ESPHome compile), which does the compilation-time checking, and clang-tidy would need a compile database the repo does not have. A repo's `.h` is read as C++ by context (Arduino/ESPHome), since the extension alone is ambiguous. + +## Changing the type set carries review weight + +The set of types, their profiles, and each type's checks is governed, like the section and fidelity models. + +- **Adding a type or a check** declares a new requirement for every repo that carries it. Add it to `project-types.json`, to the schema where the shape changes, and to this doc in the same change. +- **Declaring or changing a repo's type or profile** is a claim about the repo. Detection validates it, and a contradiction is a finding to reconcile, not a liberty. +- **A detected-but-undeclared language** is drift to resolve (declare or ignore-with-reason), not silently accepted. + +## Enforcement + +`registry/repos.json` declares each repo's types and profiles. [validate.py][validate] proves the declarations are well-formed against [project-types.schema.json][schema]. [audit.py][audit] runs each declared type's checks at its profile, validates declarations against detection (false declaration, discovery advisory, honored ignores), and holds profile-gated checks N/A off-profile. + + + +[audit]: ./audit.py +[fidelity-model]: ./fidelity-model.md +[repos]: ../registry/repos.json +[schema]: ./project-types.schema.json +[section-model]: ./section-model.md +[types]: ./project-types.json +[validate]: ./validate.py diff --git a/spec/validate.py b/spec/validate.py index 75523c2e..901b9e5a 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -139,6 +139,18 @@ def check_secret_set(label, entry, need_kind): for t in repo_types: if t not in known_types: errors.append(f"{name}: type '{t}' not defined in project-types.json") + # A declared profile must name one of the repo's types and a profile that type allows (spec/type-model.md). + # CI runs no JSON-schema validation, so guard the shape here rather than crash on .items(). + profiles_decl = repo.get("profiles", {}) + if not isinstance(profiles_decl, dict): + errors.append(f"{name}: profiles must be an object mapping a type to its profile") + profiles_decl = {} + for tname, prof in profiles_decl.items(): + allowed = types["types"].get(tname, {}).get("profiles", []) + if tname not in repo_types: + errors.append(f"{name}: profile declared for '{tname}', not one of the repo's types") + elif tname in known_types and prof not in allowed: + errors.append(f"{name}: type '{tname}' profile '{prof}' not in its allowed profiles {allowed or '[]'}") model = repo.get("workflowModel") if model is not None and model not in WORKFLOW_MODELS: