diff --git a/AUDIT.md b/AUDIT.md index 80d7638d..08fd385f 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -16,6 +16,14 @@ This audit is not occasional. Run it whenever you **create, adopt, or materially - **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports//audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. - **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. +**Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. + +```shell +python3 scripts/host_gate.py --repo # run from a hub checkout, floors from spec/host-tools.json +``` + +Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. + ## 1. Scope and Ground-Truth Branch Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence. A stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. @@ -84,7 +92,7 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: # Rules sort on each rule's whole content, matching the key normalize_ruleset in audit.py sorts by. # Sorting on .type alone leaves two rules of one type in input order, so a reordered pair would read as drift. # canon sorts keys at every depth before serializing, because the committed payload is written key-sorted and the API returns its own order, so a bare tojson gives the same rule two different sort keys. - # It recurses rather than calling walk/1, which arrived in jq 1.6, and no declared floor puts a host above that. + # It recurses rather than calling walk/1, which the declared floor does make available, because the recursion costs nothing and compiles below the floor as well. # A host on jq 1.5 would not degrade on walk, it would fail to compile the filter and report drift on every ruleset it never compared, which is what repo-config/configure.sh defines its own recursion to avoid. canon='def canon: . as $in | if type == "object" then reduce (keys_unsorted|sort)[] as $k ({}; . + { ($k): ($in[$k]|canon) }) elif type == "array" then map(canon) else . end;' norm="$canon"'{name,target,enforcement,conditions,rules} | .rules|=sort_by(canon|tojson)' @@ -174,6 +182,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [governance-branching-model]: ./GOVERNANCE.md#branching-model [governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling [governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[host-setup]: ./docs/host-setup.md [project-types]: ./spec/project-types.json [readme-sections]: ./spec/readme-sections.json [readme-structure]: ./spec/readme-structure.md diff --git a/RESYNC.md b/RESYNC.md index 46cccbe4..4b89a107 100644 --- a/RESYNC.md +++ b/RESYNC.md @@ -14,6 +14,8 @@ Three states look similar from inside a repository and take different procedures **The distinction is measured, not assumed.** `spec/audit.py ` reports an absent carried file as a `LETTER` and a present-but-stale one as a `DRIFT`, so the finding kinds themselves say which procedure the repository is owed. A run that is mostly letters is a repository that needs [`STANDUP.md`][standup]. +**A few letters are not that, and the ordinary cause is the baseline having grown.** When the manifest gains an entry, every repository in the fleet reports that one file absent on its next audit, all at once and through no fault of its own. That is a file to carry inside a resync, at the point in section 3 where its class belongs, rather than evidence the repository was never stood up. Read the letters before routing on their count: a handful naming files the hub added recently is a resync, and a baseline's worth naming the instruction set is a standup. + ## 1. Reach the Hub, and Verify the Host **Fetch a hub checkout of your own immediately before reading it.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a resync driven from a stale hub converges a repository onto a revision that is already history. Read `main`, the promoted and gated state, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Work only in that checkout rather than in one that another task is using. diff --git a/STANDUP.md b/STANDUP.md index 28e2c0ae..efaca8ed 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -22,6 +22,22 @@ if [ "$(git config --global --get gpg.format)" = ssh ]; then ssh-add -L; else gp `--global` rather than the effective config, because the effective value depends on where the command runs: inside any existing repository a repo-local override wins, so a bare `git config --get user.email` there reports that repository's identity and hides the host setting this step exists to check. The two scopes together are what make the result sound, since this block proves the host is right and the block below proves nothing shadows it. +**Verify the host's tools in the same step, since identity is only half of what a standup needs from a machine.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer, which is how both host defects this fleet has hit arrived. + +```shell +python3 scripts/host_gate.py # from a hub checkout, against the fleet floors in spec/host-tools.json +``` + +**No `--repo` here, and that is the one place in these procedures where it is omitted deliberately.** The flag points the gate at a repo's own `host-tools.json` so its floors are layered over the fleet ones, and at this step there is no repo to point it at: the target does not exist yet, since this section runs before the `git init` in section 0B, and the file itself arrives with the baseline in section 2. So this run checks the fleet floors, which is all that is knowable now. + +**Re-run it with `--repo` once section 2 has carried the file**, because a bare run does not read the target's declaration at all, so any floor that repo adds goes unapplied and the run cannot tell you it was skipped: + +```shell +python3 scripts/host_gate.py --repo # after section 2, so the repo's own floors count +``` + +A finding at either point is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. + The agent check branches rather than listing both forms, because they are alternatives and running the wrong one fails on a correctly configured host: an SSH host need not have `gpg` installed at all. Signing is **SSH or GPG**, so judge the format and its agent together rather than requiring `ssh`: what matters is that the configured format has a matching agent holding the key, which is the check [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] prescribes. Any of these wrong or absent is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. After `git init` and before the first commit, confirm the repo added no override of its own. This one needs a repository, since `--local` fails outside one. Read it here and run it in section 0B, which places it between the init and the first commit, so nothing here is a prompt to init early: @@ -104,7 +120,7 @@ Capture the source, verify the capture **against the source**, and hold the veri ## 2. Carry the Baseline Files -Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one. +Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, `host-tools.json`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one. **`version.json` is a file to carry and a floor to choose.** [`WORKFLOW.md`][workflow] D3.3 makes its `version` field the repo's own major.minor floor, with NBGV appending the git height as the patch, so the number carried in with the file is a claim about a release history the new repo does not have. Set it deliberately, at standup, before the first release: @@ -115,6 +131,10 @@ Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **This decision is effectively one-way, which is why it belongs here.** Once a repo publishes against a floor, lowering it regresses the released version order, so a floor that was never chosen is kept rather than corrected. Inherited floors are the observed failure, not a hypothetical one: four operational config repos run on a floor none of them picked and have released against it. +**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look. + +**The carried copy drops the `$schema` pointer, and that is not an oversight to correct.** The schemas are hub-only and no selector carries one, so a relative pointer copied downstream resolves to a path that repo does not have, and a schema-aware editor then reports the file invalid for a reason the repo cannot fix. This is settled fleet practice rather than a new rule: the hub's `spec/secrets.json` carries `./secrets.schema.json` and every downstream copy of that file omits the key. Copy the structure and leave the pointer behind. + **Repo-specific content has a declared destination, not a judgment call.** The baseline is what a repo *carries*. Anything the repo knows that the fleet does not needs somewhere to live, and improvising a location per repo is what the destinations in [`spec/section-model.md`][section-model] exist to prevent. Four topical docs take it, chosen by what the content **is**: - [`CODESTYLE.md`][codestyle]: the repo's language and formatting conventions beyond the carried rules. @@ -187,7 +207,9 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [governance]: ./GOVERNANCE.md [governance-git-and-commit-rules]: ./GOVERNANCE.md#git-and-commit-rules [governance-repository-boundaries-and-write-safety]: ./GOVERNANCE.md#repository-boundaries-and-write-safety +[host-gate]: ./scripts/host_gate.py [host-setup]: ./docs/host-setup.md +[host-tools]: ./spec/host-tools.json [matrix]: ./reports/conformance-matrix.md [project-types]: ./spec/project-types.json [readme-structure]: ./spec/readme-structure.md diff --git a/docs/host-setup.md b/docs/host-setup.md index 96109f08..dea57859 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -19,6 +19,7 @@ This section is the **contract**: which tools a host needs and which repo proced | `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | | `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | | Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | +| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target | | `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none | | `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none | @@ -241,6 +242,7 @@ A host that fails any row is not ready for the procedure that row names, and the [agent-safety]: ../host-setup/agent-safety/README.md +[audit]: ../AUDIT.md [devcontainer]: ./devcontainer.md [governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules [host-gate]: ../scripts/host_gate.py diff --git a/host-tools.json b/host-tools.json new file mode 100644 index 00000000..6e8177f9 --- /dev/null +++ b/host-tools.json @@ -0,0 +1,5 @@ +{ + "$schema": "./spec/host-tools-local.schema.json", + "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look. A repository copying this file leaves the $schema pointer behind, because the schemas are hub-only and no selector carries one, so a relative pointer resolves to a path that repository does not have and a schema-aware editor reports the file invalid for a reason nobody there can fix. The carried spec/secrets.json already works that way, holding the pointer here and omitting it in every downstream copy.", + "tools": [] +} diff --git a/repo-config/configure.sh b/repo-config/configure.sh index d0716da7..d029a1cc 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -239,7 +239,7 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol # Dropping that would turn array order into false drift. # A scalar array sorts directly, and required_status_checks sorts by context, its identifying field. local ptypes norm - # The walk/1 builtin arrived in jq 1.6, so it is defined here rather than called. + # The walk/1 builtin arrived in jq 1.6, which the declared floor makes available, and it is defined here rather than called so the filter also compiles below the floor. # A host on jq 1.5 would otherwise not degrade, it would fail to compile the filter. # The check_ruleset function would then report drift on every parameterized rule it never actually compared. # That is the inverse of the false clean this comparison was written to close, so the portable definition is worth its length. diff --git a/scripts/host_gate.py b/scripts/host_gate.py index 7535ef79..cb0d5dde 100755 --- a/scripts/host_gate.py +++ b/scripts/host_gate.py @@ -293,7 +293,10 @@ def check(tools: list[dict]) -> list[str]: elif compare(found, floor) < 0: # The remedy rides on the finding rather than beside it, since a separate line would count as a second issue. src = (tool.get('source') or {}).get('linux' if sys.platform.startswith('linux') else 'macos' if sys.platform == 'darwin' else 'windows') - issues.append(f'{name} {version} is below the {floor_text} floor - {tool["why"]}' + # The head line stays the scannable fact and the rationale follows it, rather than being inlined into it. + # An entry's why runs to a paragraph, so inlining made the one line a reader scans in CI output unreadable, and the longest entry is not the one that needs it least. + issues.append(f'{name} {version} is below the {floor_text} floor' + + f'\nWHY: {tool["why"]}' + (f'\nINSTALL FROM: {src}' if src else '')) else: NOTES.append(f'{name} {version} meets the {floor_text} floor') @@ -333,10 +336,11 @@ def main(argv: list[str] | None = None) -> int: status = 'FAIL' if issues else 'ok' print(f'[{status:4}] host-tools {len(issues)} issue(s) over {len(tools)} declared tool(s)') for i in issues: - # A finding may carry a continuation line, which is indented under it rather than counted beside it. - head, _, tail = i.partition('\n') + # A finding may carry continuation lines, indented under it rather than counted beside it. + # Every line is split rather than only the first, since partitioning once leaves later newlines inside one printed line and loses the indent on all but the first. + head, *rest = i.split('\n') print(f' {head}') - if tail: + for tail in rest: print(f' {tail}') if not a.quiet: # After the findings and outside the count, since a note is not one. diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index bcc1ddc3..dd364787 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -384,6 +384,119 @@ def test_a_missing_file_is_a_diagnostic_rather_than_a_raise(self): self.assertIsInstance(host_gate.read_declaration(Path('/definitely/not/here.json'), 'declaration'), str) +class TestLocalOverlay(unittest.TestCase): + """This repo's own root host-tools.json, and the schema it points at. + + The overlay borrowed the fleet schema once, and that schema forbids both shapes an overlay is for: + `minItems: 1` rejects the empty list a repo with nothing to add carries, and the per-entry + `required` list rejects the partial entry that lets a repo raise one floor without restating a + tool. Neither is caught by running the gate, because CI runs no JSON-schema validation at all and + the pointer's only reader is a schema-aware editor, so the file showed as invalid where it is + edited and nowhere else. + """ + + def setUp(self): + self.root = host_gate.SPEC.parent.parent + self.overlay = json.loads((self.root / 'host-tools.json').read_text(encoding='utf-8')) + self.local_schema = json.loads((self.root / 'spec' / 'host-tools-local.schema.json').read_text(encoding='utf-8')) + self.fleet_schema = json.loads((self.root / 'spec' / 'host-tools.schema.json').read_text(encoding='utf-8')) + + def test_the_overlay_points_at_the_overlay_schema(self): + self.assertEqual(self.overlay['$schema'], './spec/host-tools-local.schema.json') + + def test_the_two_schemas_agree_on_every_field_they_share(self): + """The "only two axes differ" claim, enforced field by field rather than asserted in prose. + + The overlay schema was written by hand from the fleet one and quietly relaxed two more things: + `minimum` lost its version pattern and `source` lost its named platforms and its closed + property set. A schema-aware editor then accepted a floor the gate rejects and a source key + that reaches no platform, while the tests next door said only two axes differed. + + Comparing each shared field is what keeps the duplication honest, since the alternative is a + cross-file `$ref` that editors resolve inconsistently. The two intended differences are + excluded by name, so adding a third relaxation fails here rather than widening the claim. + """ + fleet = self.fleet_schema['$defs']['tool']['properties'] + overlay = self.local_schema['$defs']['override']['properties'] + self.assertEqual(set(fleet), set(overlay), 'the two schemas describe different field sets') + # `description` is editor help rather than a constraint, so it is not compared. + # `required.default` is excluded because the two files mean different things by an omitted `required`, which this test found rather than assumed. + # In the fleet declaration every entry is an addition, so omitting it means true. + # In an overlay, merge() applies only the fields an entry carries, so omitting it means inherit the hub's value, and declaring a default of true would tell an editor the opposite. + ignore = {'description'} + for field in sorted(overlay): + drop = ignore | ({'default'} if field == 'required' else set()) + want = {k: v for k, v in fleet[field].items() if k not in drop} + got = {k: v for k, v in overlay[field].items() if k not in drop} + self.assertEqual(got, want, f'the overlay constrains {field!r} differently from the fleet schema') + self.assertNotIn('default', overlay['required'], + 'an omitted `required` in an overlay inherits rather than defaulting to true') + + def test_the_two_schemas_differ_where_the_overlay_needs_them_to(self): + """Asserted as a difference rather than as two absolute values. + + A change that tightened the overlay schema back to the fleet one would satisfy a check written + against either file alone, since each would still be internally consistent. + """ + self.assertEqual(self.fleet_schema['properties']['tools']['minItems'], 1) + self.assertEqual(self.local_schema['properties']['tools']['minItems'], 0) + self.assertEqual(self.fleet_schema['$defs']['tool']['required'], + ['name', 'probes', 'pattern', 'minimum', 'why']) + self.assertEqual(self.local_schema['$defs']['override']['required'], ['name']) + + def test_the_shipped_overlay_satisfies_its_own_schema(self): + """The two constraints that moved, checked directly rather than through a validator. + + jsonschema is not installed and CI validates no schema, so a dependency-free check is the only + one that runs anywhere. + """ + tools = self.overlay['tools'] + self.assertIsInstance(tools, list) + self.assertGreaterEqual(len(tools), self.local_schema['properties']['tools']['minItems']) + allowed = set(self.local_schema['$defs']['override']['properties']) + for entry in tools: + self.assertIn('name', entry, 'an overlay entry names the tool it overrides') + self.assertFalse(set(entry) - allowed, f'unknown overlay field(s) in {entry!r}') + + def accepts(self, schema, doc): + """Whether a document satisfies the two constraints the overlay schema deliberately relaxes. + + Only `tools.minItems` and the per-entry `required` list are read, which is the whole of what + separates these two schemas. It is not a JSON-schema validator and does not pretend to be one: + jsonschema is not installed and CI validates no schema, so a dependency-free check of the two + axes under discussion is what can actually run. + """ + tools = doc['tools'] + if len(tools) < schema['properties']['tools']['minItems']: + return False + entry_def = schema['$defs']['tool' if 'tool' in schema['$defs'] else 'override'] + return all(not set(entry_def['required']) - set(e) for e in tools) + + def test_the_overlay_schema_is_strictly_the_more_permissive_of_the_two(self): + """The justification for the split, stated about the schemas rather than about today's data. + + This replaced a check that the shipped overlay violates the fleet schema. That coupled a + schema-design invariant to one data instance: an overlay that legitimately grew a full tool + entry would satisfy the fleet schema and fail the test, while the split stayed just as + justified. What justifies it is that the overlay schema accepts everything the fleet schema + does and more, so the assertion is made in both directions over constructed documents. + """ + full = {'name': 'jq', 'probes': [['jq', '--version']], 'pattern': 'jq-(.*)', + 'minimum': '1.7', 'why': 'because'} + empty_list = {'tools': []} + partial_entry = {'tools': [{'name': 'jq', 'minimum': '1.7'}]} + full_entry = {'tools': [full]} + + # Everything the fleet schema accepts, the overlay schema accepts too. + self.assertTrue(self.accepts(self.fleet_schema, full_entry)) + self.assertTrue(self.accepts(self.local_schema, full_entry)) + + # And the overlay accepts two shapes the fleet schema rejects, which is why it exists. + for doc, what in ((empty_list, 'an empty tools list'), (partial_entry, 'a partial entry')): + self.assertFalse(self.accepts(self.fleet_schema, doc), f'the fleet schema now accepts {what}') + self.assertTrue(self.accepts(self.local_schema, doc), f'the overlay schema rejects {what}') + + class TestShippedDeclaration(unittest.TestCase): """The file this repo actually ships, read rather than assumed.""" @@ -414,7 +527,7 @@ def test_the_declared_floors_are_the_ones_with_a_stated_reason(self): one in the data, which is what caught the python3 floor being added without this line. """ floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None} - self.assertEqual(floors, {'gh', 'git-restore-mtime', 'python3'}) + self.assertEqual(floors, {'gh', 'git-restore-mtime', 'jq', 'python3'}) def test_the_contract_table_carries_every_declared_floor(self): """docs/host-setup.md restates the floors, so the doc goes stale the moment the data moves. @@ -466,15 +579,21 @@ def norm(text): f'{cells[floor_col]!r}') def test_a_target_floor_says_so_rather_than_implying_a_defect(self): - """The python3 floor is a target, so its `why` has to distinguish itself from a measured one. + """A target floor's `why` has to distinguish itself from a measured one. A reader who takes a target floor for a measured one goes looking for a defect report that does not exist, which is the failure the two-kinds wording was written to prevent. + The set is asserted rather than one entry, so a third target floor added without the wording + fails here instead of reading as measured. Both members are named, since a check that only + counted them would pass on the wrong pair. """ - python3 = next(t for t in self.data['tools'] if t['name'] == 'python3') - self.assertEqual(python3['minimum'], '3.13') - self.assertIn('target', python3['why']) - self.assertIn('unverified rather than known broken', python3['why']) + targets = {'jq': '1.7', 'python3': '3.13'} + by_name = {t['name']: t for t in self.data['tools']} + for name, floor in targets.items(): + entry = by_name[name] + self.assertEqual(entry['minimum'], floor) + self.assertIn('target', entry['why']) + self.assertIn('unverified rather than known broken', entry['why']) if __name__ == '__main__': diff --git a/spec/files.json b/spec/files.json index 33660612..04933a04 100644 --- a/spec/files.json +++ b/spec/files.json @@ -17,6 +17,7 @@ { "path": ".markdownlint-cli2.jsonc", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, { "path": "cspell.json", "fidelity": "intent", "whole": true, "appliesTo": "*" }, { "path": ".gitignore", "appliesTo": "*" }, + { "path": "host-tools.json", "appliesTo": "*" }, { "path": "version.json", "fidelity": "intent", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, { "path": "repo-config/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["release"] }, { "path": "repo-config/operational/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["operational"] }, diff --git a/spec/host-tools-local.schema.json b/spec/host-tools-local.schema.json new file mode 100644 index 00000000..c3107883 --- /dev/null +++ b/spec/host-tools-local.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools-local.schema.json", + "title": "Repository host tool overlay", + "description": "A repository's own root host-tools.json, which scripts/host_gate.py layers over the fleet declaration in spec/host-tools.json. This is a different format from that file and needs its own schema rather than borrowing it, in three ways. An empty tools list is the ordinary case, because a repository with nothing to add still carries the file, where an empty fleet declaration would mean no host contract at all. An entry may be partial, carrying a name plus only the fields it changes, which is what lets a repository raise one floor without restating a whole tool, where the fleet declaration requires every field of every entry. Only name is required here, since an entry that names no tool cannot be matched against one. And an omitted required field means inherit rather than true: scripts/host_gate.py applies only the fields an entry carries, so a default declared here would tell a reader the opposite of what the merge does, which is why this file states none where the fleet declaration states true. Every other constraint matches the fleet schema field for field, and a test asserts that rather than trusting this sentence, because this file was hand-written from that one and drifted on two fields before the test existed. The gate rather than this schema is the authority on what an overlay may do: layering is tighten-only, so an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, and a rejected relaxation is reported rather than dropped. A schema cannot express that, because it compares nothing against the fleet declaration.", + "type": "object", + "required": ["tools"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "tools": { + "type": "array", + "minItems": 0, + "items": { "$ref": "#/$defs/override" } + } + }, + "$defs": { + "override": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "probes": { + "type": "array", + "minItems": 1, + "items": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + }, + "pattern": { "type": "string", "minLength": 1 }, + "minimum": { "type": ["string", "null"], "pattern": "^\\d+(\\.\\d+)*$" }, + "why": { "type": "string", "minLength": 1 }, + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } + } + } + } + } +} diff --git a/spec/host-tools.json b/spec/host-tools.json index 874b00b3..22b5638d 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -44,6 +44,19 @@ "windows": "Not applicable, since the tool is used by a Linux deploy path." } }, + { + "name": "jq", + "required": true, + "probes": [["jq", "--version"]], + "pattern": "jq-(\\d+(?:\\.\\d+)*)", + "minimum": "1.7", + "why": "Needed by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, so a host without it cannot apply, check, or audit a repository's branch configuration. The floor is a target rather than a measured breakage one version below it, and it says so rather than implying a defect nobody found. It is anchored to what the fleet's own platform provides: current Debian stable packages 1.7.1, so a distribution install satisfies the floor and no host needs a manual build to meet it, while an older release sits below and is the case the floor is for. That anchor is what keeps it actionable, since a floor set to the newest release instead would fail a host running every documented filter correctly. Two lower versions matter to a reader and neither is the floor. 1.6 is where walk/1 arrived, so the floor sits above it and walk is available, and the filters nonetheless define their own recursion, which costs nothing and keeps them compiling below the floor. On jq 1.5 walk/1 does not degrade, it fails to compile the filter, so a diff built on it reports drift on every ruleset it never compared, which is the inverse of the false clean the comparison exists to close. The neighbouring claim that keys_unsorted needs 1.6 is disproved rather than assumed, measured on jq-1.5-1-a5b5cbe where keys_unsorted evaluates and walk(.) answers a compile error, so the two builtins are not in the same position and the analogy between them is the whole of what carried that claim. A host below the floor is therefore unverified rather than known broken, since every filter here is written to a feature set older than the floor requires. A separate implementation is worth knowing about and is not this entry's subject: gh --jq is gojq built into the CLI rather than this binary, and it emits object keys sorted, which is why the section 6 filter canonicalizes key order itself rather than depending on whichever implementation produced its input.", + "source": { + "linux": "The distribution package where it meets the floor, which on current Debian stable it does at 1.7.1, so the floor is chosen to be satisfiable without a manual build. An older release sits below it, and there the upstream release at https://github.com/jqlang/jq is the source. Preferring the current upstream release everywhere keeps the fleet on one version in practice, which is what the other two platforms already do.", + "macos": "Homebrew, which tracks upstream releases.", + "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." + } + }, { "name": "python3", "required": true,