Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions registry/repos.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"description": "Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails.",
"status": "cataloged",
"types": ["source-only", "docs", "python"],
"profiles": { "python": "lint-only" },
"groundTruthBranch": "main",
"hasDevelop": true,
"publish": [{ "target": "github-release", "mechanism": "none" }],
Expand Down Expand Up @@ -198,7 +197,7 @@
"url": "https://github.com/ptr727/ESPHome-Config",
"status": "cataloged",
"types": ["source-only", "python", "cpp"],
"profiles": { "python": "build", "cpp": "lint-only" },
"profiles": { "cpp": "lint-only" },
"groundTruthBranch": "main",
"workflowModel": "operational",
"lineEndings": "lf",
Expand Down
2 changes: 1 addition & 1 deletion registry/repos.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"$comment": "Length alone, with no pattern: the blank-value rule is spec/validate.py's, which is the gate, and stating it here needed `\\S`, whose set differs between Python and ECMA-262. That divergence had an editor refusing a value the gate accepts, the direction an advisory schema must never be wrong in."
},
"types": { "type": "array", "items": { "type": "string" } },
"profiles": { "type": "object", "additionalProperties": { "enum": ["lint-only", "build"] } },
"profiles": { "type": "object", "propertyNames": { "not": { "const": "python" } }, "additionalProperties": { "enum": ["lint-only", "build"] } },
"classificationPending": { "type": "boolean" },
"groundTruthBranch": { "$ref": "#/$defs/groundTruthBranch" },
"workflowModel": { "$ref": "#/$defs/workflowModel" },
Expand Down
5 changes: 2 additions & 3 deletions spec/project-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,10 @@
},
"python": {
"detect": ["*.py"],
"profiles": ["build", "lint-only"],
"canonicalPlacement": "pyproject.toml",
"profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). A profile belongs to a declared directory, not to the repository, so one repository may hold both. The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies, or Python that is a deliverable. Its uv form is a PEP 621 uv project: pyproject [project]+deps+[build-system], a committed uv.lock, and uv sync --all-groups --frozen + uv run in CI. Its pip form is a pyproject.toml beside a requirements*.txt with no uv.lock, installed with pip. The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts, e.g. a Python tooling subtree of a csharp app. It runs with uvx, with no uv.lock and no uv project, and its pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by trait, whether the Python has third-party runtime dependencies or is a deliverable. The audit detects that trait structurally from pyproject.toml, uv.lock, and requirements*.txt (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], no uv.lock, and no requirements*.txt 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). The profile decides only how the tools are installed, never what is owed. Every Python directory owes lint, format, a type check, a tests/ suite, and coverage. A repository declares each in the hub validator's python-directories input, which the registry's pythonDirectories mirrors (python.directories.declared). A uv workspace is declared once, by its root. A repository declaring none has its root gated only where it tracks a root pyproject.toml, as WORKFLOW.md D1.2 states. Tracked .py files other than the catalog's carried hub_fetch_run.py hook helper or its former name hub-fetch-run.py, not a config file, are what make a repository Python.",
"profileNote": "A declared directory's profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). A profile belongs to a declared directory, not to the repository, so one repository may hold both. The registry therefore declares no python profile, and spec/validate.py refuses one. The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies, or Python that is a deliverable. Its uv form is a PEP 621 uv project: pyproject [project]+deps+[build-system], a committed uv.lock, and uv sync --all-groups --frozen + uv run in CI. Its pip form is a pyproject.toml beside a requirements*.txt with no uv.lock, installed with pip. The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts, e.g. a Python tooling subtree of a csharp app. It runs with uvx, with no uv.lock and no uv project, and its pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by trait, whether the Python has third-party runtime dependencies or is a deliverable. The audit detects that trait structurally from pyproject.toml, uv.lock, and requirements*.txt (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], no uv.lock, and no requirements*.txt is lint-only. That shape is the directory's profile, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile). The profile decides only how the tools are installed, never what is owed. Every Python directory owes lint, format, a type check, a tests/ suite, and coverage. A repository declares each in the hub validator's python-directories input, which the registry's pythonDirectories mirrors (python.directories.declared). A uv workspace is declared once, by its root. A repository declaring none has its root gated only where it tracks a root pyproject.toml, as WORKFLOW.md D1.2 states. Tracked .py files other than the catalog's carried hub_fetch_run.py hook helper or its former name hub-fetch-run.py, not a config file, are what make a repository Python.",
"checks": [
{ "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.toml beside a requirements*.txt is the build profile too, installed with pip, whether or not it carries a [project] table. A pyproject carrying only [tool.*] config with no [project]/[build-system], no uv.lock, and no requirements*.txt 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 carry one of those or a requirements*.txt. A lint-only subtree carries no requirements*.txt either. A virtual uv workspace root, whose root pyproject.toml carries [tool.uv.workspace] with no [project] or [build-system] and commits a uv.lock, is the build shape, its [project] tables living in the workspace members under subtrees.", "intentRef": "CODESTYLE.md" },
{ "id": "python.profile.detect", "verdict": "letter", "assert": "Each declared directory's pyproject.toml shape states its profile. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject.toml beside a requirements*.txt is the build profile too, installed with pip, whether or not it carries a [project] table. A pyproject carrying only [tool.*] config with no [project]/[build-system], no uv.lock, and no requirements*.txt is the lint-only profile (the SCRIPTS shape). A committed uv.lock or any [project] table makes a directory build too, whatever else its pyproject.toml holds. A virtual uv workspace root, whose root pyproject.toml carries [tool.uv.workspace] with no [project] or [build-system] and commits a uv.lock, is the build shape, its [project] tables living in the workspace members under subtrees.", "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": "Build profile: first-party code (src or the integration package) is type-checked in CI by pyright strict, by mypy, or by both. Each CI checker runs strict (pyright strict mode, mypy's strict flags), 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 / mypy.ini / .mypy.ini, or a setup.cfg [mypy] section, 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" },
Expand Down
4 changes: 2 additions & 2 deletions spec/type-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ 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 or packaged in this repo. Its lint/style/type-check checks apply, and build and packaging checks are N/A. Test and coverage checks are N/A too, except for Python, whose lint-only directories still owe a suite run under coverage.

Python's profile belongs to each declared Python directory rather than to the repository, per [CODESTYLE.md][codestyle] "Python", so one repository may hold both depths.
Python's profile belongs to each declared Python directory rather than to the repository, per [CODESTYLE.md][codestyle] "Python", so one repository may hold both depths. The registry therefore declares no `python` profile. The registry's `pythonDirectories` lists the directories, and each one's `pyproject.toml` shape states its profile.

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 and package checks set `build`, and so does a coverage check for a language other than Python. The coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file, or its .codecov.yml alternative) is N/A for a lint-only language other than Python, and otherwise follows tests: the audit reads the repo's tree for tests and holds the requirement N/A where it finds none.

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.
A registry profile is **declared and validated**, not merely detected. Python is the exception, because its structural read of each directory's `pyproject.toml` already is the profile. A repository-wide value could not describe a repository holding both depths. It would also be a second classification of a shape that already states one. So [validate.py][validate] refuses a `python` entry in `profiles`.

### Consequence for cross-cutting checks

Expand Down
44 changes: 28 additions & 16 deletions spec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,33 @@ def description_errors_for_repo(repo, name):
return description_errors(name, repo["description"])


def profile_errors(name, profiles_decl, repo_types, type_defs):
"""Errors for a cataloged entry's `profiles`, each naming one of the repo's types and a profile it allows.

Python is refused by name rather than by its empty allowed list, per spec/type-model.md. Its profile belongs to
each declared directory and is read from that directory's pyproject.toml shape, so a registry value can only
restate or contradict it. CI runs no JSON-schema validation, so the shape is guarded here rather than crashing on
.items().
"""
if not isinstance(profiles_decl, dict):
return [f"{name}: profiles must be an object mapping a type to its profile"]
errors = []
for tname, prof in profiles_decl.items():
allowed = type_defs.get(tname, {}).get("profiles", [])
if tname == "python":
errors.append(
f"{name}: profiles declares 'python', but a Python profile belongs to each pythonDirectories "
"entry and is read from its pyproject.toml shape, so remove it"
)
elif tname not in repo_types:
errors.append(f"{name}: profile declared for '{tname}', not one of the repo's types")
elif tname in type_defs and prof not in allowed:
errors.append(
f"{name}: type '{tname}' profile '{prof}' not in its allowed profiles {allowed or '[]'}"
)
return errors


def description_errors(name, desc):
"""Shape errors for a registry entry's optional `description` (GOVERNANCE.md "Repository Details").

Expand Down Expand Up @@ -1279,22 +1306,7 @@ 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 '[]'}"
)
errors.extend(profile_errors(name, repo.get("profiles", {}), repo_types, types["types"]))

# The releaseTrigger field is a scope selector, per spec/scope-model.md, so an invalid value would silently fail to match any releaseTrigger-scoped section rather than error.
trigger = repo.get("releaseTrigger")
Expand Down
80 changes: 80 additions & 0 deletions tests/test_spec_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,74 @@ def test_over_the_cap_is_rejected(self) -> None:
)


class ProfileErrorsCase(unittest.TestCase):
"""registry/repos.json's `profiles`, which Python never appears in (spec/type-model.md "Profiles: build vs lint-only")."""

TYPE_DEFS: ClassVar[dict[str, dict[str, object]]] = {
"python": {"checks": []},
"widget": {"profiles": ["build", "lint-only"]},
"gizmo": {"checks": []},
}

def test_a_python_profile_is_refused_with_the_per_directory_reason(self) -> None:
for prof in ("build", "lint-only"):
with self.subTest(prof=prof):
errors = validate.profile_errors(
"Fixture", {"python": prof}, ["python"], self.TYPE_DEFS
)
self.assertEqual(len(errors), 1)
self.assertIn("Fixture: profiles declares 'python'", errors[0])
self.assertIn("pythonDirectories", errors[0])
self.assertIn("pyproject.toml shape", errors[0])

def test_a_python_profile_is_refused_even_beside_a_valid_one(self) -> None:
errors = validate.profile_errors(
"Fixture",
{"python": "build", "widget": "lint-only"},
["python", "widget"],
self.TYPE_DEFS,
)
self.assertEqual(len(errors), 1)
self.assertIn("profiles declares 'python'", errors[0])

def test_another_type_keeps_its_profile(self) -> None:
self.assertEqual(
validate.profile_errors("Fixture", {"widget": "lint-only"}, ["widget"], self.TYPE_DEFS),
[],
)

def test_a_type_defining_no_profiles_is_still_refused_generically(self) -> None:
self.assertEqual(
validate.profile_errors("Fixture", {"gizmo": "build"}, ["gizmo"], self.TYPE_DEFS),
["Fixture: type 'gizmo' profile 'build' not in its allowed profiles []"],
)

def test_a_profile_for_an_undeclared_type_is_refused(self) -> None:
self.assertEqual(
validate.profile_errors("Fixture", {"widget": "build"}, ["gizmo"], self.TYPE_DEFS),
["Fixture: profile declared for 'widget', not one of the repo's types"],
)

def test_a_profiles_value_that_is_not_an_object_is_refused(self) -> None:
self.assertEqual(
validate.profile_errors("Fixture", ["python"], ["python"], self.TYPE_DEFS),
["Fixture: profiles must be an object mapping a type to its profile"],
)

def test_the_shipped_registry_draws_no_profile_error(self) -> None:
type_defs = validate.load("spec/project-types.json")["types"]
self.assertNotIn("profiles", type_defs["python"])
for repo in validate.load("registry/repos.json")["repos"]:
with self.subTest(repo=repo["name"]):
self.assertNotIn("python", repo.get("profiles", {}))
self.assertEqual(
validate.profile_errors(
repo["name"], repo.get("profiles", {}), repo.get("types", []), type_defs
),
[],
)


class TreeSourceRootCase(unittest.TestCase):
"""A tree source naming the repository root is refused however it is spelled."""

Expand Down Expand Up @@ -1226,6 +1294,18 @@ def test_a_mixed_types_list_still_checks_its_string_elements(self) -> None:
self.assertIn("Fixture: type 'pythn' not defined in project-types.json", output)
self.assertNotIn("not one of the repo's types", output)

def test_a_declared_python_profile_is_refused_by_the_loop(self) -> None:
output = self.run_against(
self.entry(
status="cataloged",
classificationPending=None,
hasDevelop=True,
types=["python"],
profiles={"python": "build"},
)
)
self.assertIn("Fixture: profiles declares 'python'", output)

def test_a_declared_null_types_names_the_missing_types_remedy(self) -> None:
entry = self.entry(status="cataloged", classificationPending=None)
entry["types"] = None
Expand Down
Loading