diff --git a/.agents/pm/history/pm-github-4ga9.jsonl b/.agents/pm/history/pm-github-4ga9.jsonl new file mode 100644 index 0000000..3baf4e3 --- /dev/null +++ b/.agents/pm/history/pm-github-4ga9.jsonl @@ -0,0 +1,11 @@ +{"ts":"2026-08-15T09:59:58.137Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-4ga9"},{"op":"add","path":"/metadata/title","value":"A github command can silently lose its preflight credential gate when the override scope drifts from the mutating command set"},{"op":"add","path":"/metadata/description","value":"registerPreflight is scoped to a fixed six-entry commands array (PR #36). That array and isMutatingGithubCommand are maintained by hand with nothing binding them: the legacy gh-issues alias already showed how easily a hand-maintained command list drifts from the real mutating set. If a command is added or reclassified as mutating on one side only, the override stops running for it (the runtime matches by exact normalized path) and the command executes with no early credential warning — silently, because the runtime swallows preflight throws and the authoritative handler gate only fires once the command actually runs and fails. A test must bind the scope to the classifier: every scoped path has to be one isMutatingGithubCommand treats as mutating (the apply-gated github export and github project sync under their apply configuration), and every declared-but-omitted path has to be read-only, so the two lists cannot drift apart again."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-15T09:59:58.137Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-15T09:59:58.137Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"7f5185e842ae8ae19e58b3fed488319676e1aa0aceafe3f5c8ad83bd3e0d85dc","message":""} +{"ts":"2026-08-15T10:01:20.613Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:01:20.613Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"7f5185e842ae8ae19e58b3fed488319676e1aa0aceafe3f5c8ad83bd3e0d85dc","after_hash":"f76e0a8eba61f45d0d31d0b3eea94c172deec5ae3aef5ba8d36c03ba3ba31b7d"} +{"ts":"2026-08-15T10:01:21.491Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:01:21.491Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-15T10:01:21.491Z","author":"pi-agent","text":"Manifest-vs-scope audit (the check CodeRabbit asked for on pm-linear #67). Commands pm-github declares: github sync, gh-issues import (legacy command alias), github validate, github project list, github project fields, github project import, github project sync (registerCommand), plus github import (registerImporter) and github export (registerExporter). Preflight scope after PR #36: github sync, github export, github import, gh-issues import, github project import, github project sync. Diff: every scoped path is a declared command; the three omitted commands (github validate, github project list, github project fields) are read-only diagnostics and were never gated — isMutatingGithubCommand returned false for them under the old global registration too, so no gate was lost. Notably the legacy alias gh-issues import was already in scope (the gap CodeRabbit caught in pm-linear/pm-jira does not exist here). Remaining defect: nothing binds the two lists, which this item fixes with a drift test."}]}],"before_hash":"f76e0a8eba61f45d0d31d0b3eea94c172deec5ae3aef5ba8d36c03ba3ba31b7d","after_hash":"9e96ef853cc07c8b776d96316fa2d26ac778b41b77f9936a64463fc1b23ad406"} +{"ts":"2026-08-15T10:02:02.393Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-15T10:02:02.393Z","author":"pi-agent","text":"Manifest-vs-scope audit (the check CodeRabbit asked for on pm-linear #67). Commands pm-github declares: github sync, gh-issues import (legacy command alias), github validate, github project list, github project fields, github project import, github project sync (registerCommand), plus github import (registerImporter) and github export (registerExporter). Preflight scope after PR #36: github sync, github export, github import, gh-issues import, github project import, github project sync. Diff: every scoped path is a declared command; the three omitted commands (github validate, github project list, github project fields) are read-only diagnostics and were never gated — isMutatingGithubCommand returned false for them under the old global registration too, so no gate was lost. Notably the legacy alias gh-issues import was already in scope (the gap CodeRabbit caught in pm-linear/pm-jira does not exist here). Remaining defect: nothing binds the two lists, which this item fixes with a drift test."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:02:02.393Z"}],"before_hash":"9e96ef853cc07c8b776d96316fa2d26ac778b41b77f9936a64463fc1b23ad406","after_hash":"60bbfde97381940e7963a985a88516c21892836b3361e5b881e4aa0e94a39b1b"} +{"ts":"2026-08-15T10:02:14.940Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_delete","patch":[{"op":"remove","path":"/metadata/comments/1"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:02:14.940Z"}],"before_hash":"60bbfde97381940e7963a985a88516c21892836b3361e5b881e4aa0e94a39b1b","after_hash":"a7ee8e6f4afaa7197e4b4502fc3de44eefdbe5fed286b97efec001f8d51b2766"} +{"ts":"2026-08-15T10:02:15.603Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:02:15.603Z"},{"op":"add","path":"/metadata/files","value":[{"path":"test/smoke.test.ts","scope":"project"}]}],"before_hash":"a7ee8e6f4afaa7197e4b4502fc3de44eefdbe5fed286b97efec001f8d51b2766","after_hash":"1670cf19621454a56749e0459440bffec71bc595ac9947fcd384493fd62cef8c"} +{"ts":"2026-08-15T10:03:11.022Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:03:11.022Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm test","path":"test/smoke.test.ts","scope":"project","note":"Asserts the preflight override stays scoped to exactly pm-github's owned mutating command paths and binds the scope to isMutatingGithubCommand: every scoped path must be classified mutating (github export and github project sync under their apply configuration) and every declared-but-omitted path must be read-only. Test title: preflight override is scoped to pm-github's owned command paths"}]}],"before_hash":"1670cf19621454a56749e0459440bffec71bc595ac9947fcd384493fd62cef8c","after_hash":"55d2b03b36fa212c3ad633676ff87047e8b3df14b4c4983cc912c5ca9803bc2d"} +{"ts":"2026-08-15T10:15:01.046Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"160291269a4da19f0e3cdf5b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:15:01.046Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-15T10:15:00.998Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-15T10:15:00.998Z"},{"op":"add","path":"/metadata/close_reason","value":"Scope bound to the classifier in test/smoke.test.ts: every scoped preflight path must be one isMutatingGithubCommand treats as mutating (github export and github project sync under their apply configuration), and every declared-but-omitted path (github validate, github project list, github project fields) must be read-only, so the scope list and pm-github's declared command set cannot drift apart again. Manifest-vs-scope audit found no lost gate; gh-issues import was already in scope. Verified: npm test 251/251 pass, coverage 92.04/81.34/91.49 (thresholds 88/79/89), release:check exit 0, npx pm health --strict-exit ok:true."}],"before_hash":"55d2b03b36fa212c3ad633676ff87047e8b3df14b4c4983cc912c5ca9803bc2d","after_hash":"c0838d42424bf04a19c658a6de6a5303f0b058964f7c458a18621f14e9e0dfa6"} +{"ts":"2026-08-15T11:12:30.498Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"faf49faa4cb656dc3626f46b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"remove","path":"/metadata/close_reason"},{"op":"remove","path":"/metadata/completed_at"},{"op":"remove","path":"/metadata/closed_at"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T11:12:30.498Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"c0838d42424bf04a19c658a6de6a5303f0b058964f7c458a18621f14e9e0dfa6","after_hash":"718f3dfba735d7db7bf147f44de32820fc9f7db426e2fee3012efa2c1cba53e5","item_hash_version":2,"message":"Reopened: PR #42 review (Greptile 3789172153 P2, CodeRabbit 3789173850 Major) correctly flags that DECLARED_READ_ONLY_COMMANDS is another hand-maintained list — the drift guard cannot detect the drift it was written for. Reworking the test to derive the declared command set from the real activation registry."} +{"ts":"2026-08-15T11:12:55.204Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.3","agent_model_source":"environment","agent_instance":"faf49faa4cb656dc3626f46b","agent_provenance":{"model":{"value":"glm-5.3","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T11:12:55.204Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/claim_principal","value":"pi-agent"}],"before_hash":"718f3dfba735d7db7bf147f44de32820fc9f7db426e2fee3012efa2c1cba53e5","after_hash":"d990301af05c9f559285be757d43072dcb333dee426166e8954209fbb05ca67d","item_hash_version":2} +{"ts":"2026-08-15T11:37:53.946Z","author":"pi-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2a535af1e474c92d3fda7e13","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T11:37:53.946Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-15T11:37:53.931Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-15T11:37:53.931Z"},{"op":"add","path":"/metadata/resolution","value":"The smoke test now derives the declared command set from real activation registrations (registerCommand, registerImporter, registerExporter) instead of a hand-maintained literal, asserts the dispatch handler paths equal that derived set, partitions it with the same isMutatingGithubCommand predicate production uses, and asserts the mutating and read-only classes reconstruct the declared set exactly. A declared path that escapes both classes therefore fails the test."},{"op":"add","path":"/metadata/expected_result","value":"Declaring a new mutating github command without adding it to the preflight override scope fails the test, naming that command, rather than leaving every assertion green."},{"op":"add","path":"/metadata/actual_result","value":"Verified by declaring a scratch mutating command github scratch drift through registerCommand and adding it to isMutatingGithubCommand without touching the override scope: the suite failed 1 of 251 with preflight override scope must equal the mutating class of the declared command set exactly, and the diff named github scratch drift. The scratch command was then removed and the suite is 251 of 251 green."},{"op":"add","path":"/metadata/close_reason","value":"The smoke test now derives the declared command set from real activation registrations (registerCommand, registerImporter, registerExporter) instead of a hand-maintained literal, asserts the dispatch handler paths equal that derived set, partitions it with the same isMutatingGithubCommand predicate production uses, and asserts the mutating and read-only classes reconstruct the declared set exactly. A declared path that escapes both classes therefore fails the test."}],"before_hash":"d990301af05c9f559285be757d43072dcb333dee426166e8954209fbb05ca67d","after_hash":"f24768279b7db8c10eaf74463125f0852378128aad087826b4bb35086a5a5621","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/issues/pm-github-4ga9.toon b/.agents/pm/issues/pm-github-4ga9.toon new file mode 100644 index 0000000..4ff3b22 --- /dev/null +++ b/.agents/pm/issues/pm-github-4ga9.toon @@ -0,0 +1,24 @@ +id: pm-github-4ga9 +title: A github command can silently lose its preflight credential gate when the override scope drifts from the mutating command set +description: "registerPreflight is scoped to a fixed six-entry commands array (PR #36). That array and isMutatingGithubCommand are maintained by hand with nothing binding them: the legacy gh-issues alias already showed how easily a hand-maintained command list drifts from the real mutating set. If a command is added or reclassified as mutating on one side only, the override stops running for it (the runtime matches by exact normalized path) and the command executes with no early credential warning — silently, because the runtime swallows preflight throws and the authoritative handler gate only fires once the command actually runs and fails. A test must bind the scope to the classifier: every scoped path has to be one isMutatingGithubCommand treats as mutating (the apply-gated github export and github project sync under their apply configuration), and every declared-but-omitted path has to be read-only, so the two lists cannot drift apart again." +type: Issue +status: closed +priority: 2 +tags: [] +created_at: "2026-08-15T09:59:58.137Z" +updated_at: "2026-08-15T11:37:53.946Z" +closed_at: "2026-08-15T11:37:53.931Z" +completed_at: "2026-08-15T11:37:53.931Z" +claim_principal: pi-agent +author: pi-agent +resolution: "The smoke test now derives the declared command set from real activation registrations (registerCommand, registerImporter, registerExporter) instead of a hand-maintained literal, asserts the dispatch handler paths equal that derived set, partitions it with the same isMutatingGithubCommand predicate production uses, and asserts the mutating and read-only classes reconstruct the declared set exactly. A declared path that escapes both classes therefore fails the test." +expected_result: "Declaring a new mutating github command without adding it to the preflight override scope fails the test, naming that command, rather than leaving every assertion green." +actual_result: "Verified by declaring a scratch mutating command github scratch drift through registerCommand and adding it to isMutatingGithubCommand without touching the override scope: the suite failed 1 of 251 with preflight override scope must equal the mutating class of the declared command set exactly, and the diff named github scratch drift. The scratch command was then removed and the suite is 251 of 251 green." +comments[1]{created_at,author,text}: + "2026-08-15T10:01:21.491Z",pi-agent,"Manifest-vs-scope audit (the check CodeRabbit asked for on pm-linear #67). Commands pm-github declares: github sync, gh-issues import (legacy command alias), github validate, github project list, github project fields, github project import, github project sync (registerCommand), plus github import (registerImporter) and github export (registerExporter). Preflight scope after PR #36: github sync, github export, github import, gh-issues import, github project import, github project sync. Diff: every scoped path is a declared command; the three omitted commands (github validate, github project list, github project fields) are read-only diagnostics and were never gated — isMutatingGithubCommand returned false for them under the old global registration too, so no gate was lost. Notably the legacy alias gh-issues import was already in scope (the gap CodeRabbit caught in pm-linear/pm-jira does not exist here). Remaining defect: nothing binds the two lists, which this item fixes with a drift test." +files[1]{path,scope}: + test/smoke.test.ts,project +tests[1]{command,path,scope,note}: + npm test,test/smoke.test.ts,project,"Asserts the preflight override stays scoped to exactly pm-github's owned mutating command paths and binds the scope to isMutatingGithubCommand: every scoped path must be classified mutating (github export and github project sync under their apply configuration) and every declared-but-omitted path must be read-only. Test title: preflight override is scoped to pm-github's owned command paths" +close_reason: "The smoke test now derives the declared command set from real activation registrations (registerCommand, registerImporter, registerExporter) instead of a hand-maintained literal, asserts the dispatch handler paths equal that derived set, partitions it with the same isMutatingGithubCommand predicate production uses, and asserts the mutating and read-only classes reconstruct the declared set exactly. A declared path that escapes both classes therefore fails the test." +body: "" diff --git a/.gitattributes b/.gitattributes index 4755b62..fe696ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -27,6 +27,4 @@ ".agents/pm/history/*.jsonl" merge=pm-history ".agents/pm/settings.json" merge=pm-json ".agents/pm/**/*.json" merge=pm-json -".agents/pm/extensions/**" -merge -".agents/pm/extensions/.managed-extensions.json" merge=pm-json # pm-cli:merge-drivers:end diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a13a7..03b86ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- A github command can silently lose its preflight credential gate when the override scope drifts from the mutating command set ([pm-github-4ga9](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-4ga9.toon)) + ## 2026.8.15 - 2026-08-15 ### Fixed diff --git a/test/smoke.test.ts b/test/smoke.test.ts index 6b9d1d5..9399670 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -23,6 +23,7 @@ import extension, { exportWillApply, formatRateLimit, isDraftPr, + isMutatingGithubCommand, listOwnerProjectsV2Nodes, indexByProvenance, searchDocumentToItem, @@ -139,7 +140,7 @@ test("extension registers at least one capability", async () => { ); }); -test("preflight override is scoped to pm-github's owned command paths", async () => { +test("preflight override scope equals the mutating class of the declared command set", async () => { // The override MUST register as a scoped object (commands + run), not a bare // function: a global (unscoped) override collides pairwise with every other // installed package's preflight override (pm health reports @@ -158,18 +159,120 @@ test("preflight override is scoped to pm-github's owned command paths", async () "function", "scoped preflight override must expose a run function", ); + + // --------------------------------------------------------------------- + // Derive the declared command set from the REAL registration above — never + // from a hand-maintained literal. Every path declared through + // registerCommand, registerImporter and registerExporter is collected: + // registerImporter/registerExporter wrap their handlers into " + // import"/" export" command paths, exactly how the runtime names + // them. This derived set is the single source of truth for the rest of the + // test, so a newly registered command shows up here whether or not anyone + // remembered to classify it (PR #42 review: Greptile 3789172153, CodeRabbit + // 3789173850 — a guard that enumerates a literal cannot detect the drift it + // was written for). + // --------------------------------------------------------------------- + const registrations = ext.activation.registrations; + const declaredPaths = [...new Set([ + ...registrations.commands.map((c) => c.command), + ...registrations.importers.map((i) => `${i.importer} import`), + ...registrations.exporters.map((e) => `${e.exporter} export`), + ])].sort(); + assert.ok(declaredPaths.length > 0, "activation should declare command paths"); + + // Cross-check the registration metadata against the dispatch registry: the + // paths that actually execute (commands.handlers — which is where importer + // and exporter handlers land too) must equal the declared set. If a future + // registration surface stopped landing in one of the two, the derived set + // would be silently incomplete and every assertion below would narrow with + // it — so the two derivations are required to agree. + const dispatchedPaths = [...new Set(ext.activation.commands.handlers.map((h) => h.command))].sort(); assert.deepEqual( - override.commands, - [ - "github sync", - "github export", - "github import", - "gh-issues import", - "github project import", - "github project sync", - ], - "preflight override must be scoped to exactly pm-github's owned mutating command paths", + dispatchedPaths, + declaredPaths, + "dispatch handler paths must equal the registration-derived declared command set", + ); + + // --------------------------------------------------------------------- + // Partition the declared set with the SAME predicate production uses — the + // preflight run() itself consults isMutatingGithubCommand — never a second + // copy of the knowledge. The predicate is monotone in its option flags: + // dry-run only ever disables a mutation branch, and apply/no-dry-run/push + // only ever enable one, so a single maximally-mutating probe is exhaustive: + // if the predicate can return true for a command under ANY option object, + // it returns true under this one. "Can mutate GitHub at all" is therefore + // derived from the classifier, not listed by hand. + // --------------------------------------------------------------------- + const MAXIMALLY_MUTATING_OPTIONS: Record = { + apply: true, + "no-dry-run": true, + push: true, + }; + const canMutate = (command: string) => + isMutatingGithubCommand(command, MAXIMALLY_MUTATING_OPTIONS); + const mutatingPaths = declaredPaths.filter(canMutate); + const readOnlyPaths = declaredPaths.filter((command) => !canMutate(command)); + + // Total partition: every declared path lands in exactly one class and the + // two classes reconstruct the declared set EXACTLY. This is the assertion + // that keeps the guard honest — a declared path that escapes both classes + // (an unclassified new command, a normalization mismatch between the + // registration and classification surfaces) fails here BY NAME instead of + // passing unnoticed while it silently loses its credential gate. + assert.deepEqual( + [...mutatingPaths, ...readOnlyPaths].sort(), + declaredPaths, + "the mutating and read-only classes must reconstruct the declared command set exactly", + ); + for (const command of mutatingPaths) { + assert.ok( + !readOnlyPaths.includes(command), + `${command} landed in both the mutating and read-only classes`, + ); + } + assert.ok( + mutatingPaths.length > 0 && readOnlyPaths.length > 0, + "partition should classify commands on both sides (all-one-sided means the probe is broken)", + ); + + // The scope must EQUAL the derived mutating class — set equality in both + // directions, each failing with a command-naming message: + // 1. a declared path the classifier treats as mutating but the scope + // omits loses its early credential gate (the runtime matches by exact + // normalized path and simply never runs the override for it); + // 2. a scope entry that is not a declared pm-github path, or that the + // classifier treats as read-only, is dead weight or a misclassification. + const scopedPaths = [...(override.commands ?? [])].sort(); + assert.deepEqual( + scopedPaths, + mutatingPaths, + "preflight override scope must equal the mutating class of the declared command set exactly", ); + for (const command of mutatingPaths) { + assert.ok( + scopedPaths.includes(command), + `${command} is declared and isMutatingGithubCommand treats it as mutating, ` + + "but it is missing from the preflight override's commands — it would execute " + + "with no early credential warning", + ); + } + for (const command of scopedPaths) { + assert.ok( + declaredPaths.includes(command), + `${command} is in the preflight override scope but is not a command path pm-github ` + + "declares (registerCommand/registerImporter/registerExporter)", + ); + assert.ok( + canMutate(command), + `${command} is in the preflight override scope but isMutatingGithubCommand treats it as read-only`, + ); + } + for (const command of readOnlyPaths) { + assert.ok( + !scopedPaths.includes(command), + `${command} is read-only (isMutatingGithubCommand) and must not claim a preflight scope entry`, + ); + } }); test("parseNextLink extracts the rel=\"next\" page URL", () => {