diff --git a/.agents/pm/history/pm-github-bi6l.jsonl b/.agents/pm/history/pm-github-bi6l.jsonl new file mode 100644 index 0000000..008180d --- /dev/null +++ b/.agents/pm/history/pm-github-bi6l.jsonl @@ -0,0 +1,9 @@ +{"ts":"2026-09-05T18:45:11.664Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-bi6l"},{"op":"add","path":"/metadata/title","value":"Match the release-date control heading as a grammar and escape the probe version"},{"op":"add","path":"/metadata/description","value":"Excluding only a trailing digit still admitted other versions and a date position holding a non-date, and the probe was interpolated into the pattern unescaped so its dots matched any character."},{"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-09-05T18:45:11.664Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-09-05T18:45:11.664Z"},{"op":"add","path":"/metadata/author","value":"claude-orchestrator"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"3abcf4827b9e77f9385274645df1df187ee800a3e16c66dead3156bf84975649","item_hash_version":3,"message":"","event_class":"substantive","record_hash_version":1,"record_hash":"3c49f36fb1781a2d7b6d4566f735bc99551914169e454a551e8ce175681f86da"} +{"ts":"2026-09-05T18:45:12.125Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:45:12.125Z"},{"op":"add","path":"/metadata/assignee","value":"claude-orchestrator"},{"op":"add","path":"/metadata/claim_principal","value":"claude-orchestrator"}],"before_hash":"3abcf4827b9e77f9385274645df1df187ee800a3e16c66dead3156bf84975649","after_hash":"0bc0bfe8beabf656dbb851b3047b2b13baa837291d054074d5dec4f634c3eed2","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"c033dafc13e6448e81dd5e137354e134605ef9c509e223dd211f61d97cdb8651"} +{"ts":"2026-09-05T18:45:12.166Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:45:12.166Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"0bc0bfe8beabf656dbb851b3047b2b13baa837291d054074d5dec4f634c3eed2","after_hash":"1fdc3bf20bb3c745e8a4275591787692f989735f51aca7273e17480218a0ad66","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"a360ee338bc6ea2e3108afd1276afaea38888ab2092276b7f4b6bb5eb5cb081e"} +{"ts":"2026-09-05T18:45:12.608Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:45:12.608Z"},{"op":"add","path":"/metadata/files","value":[{"path":"scripts/verify-release-changelog-date.sh","scope":"project"}]}],"before_hash":"1fdc3bf20bb3c745e8a4275591787692f989735f51aca7273e17480218a0ad66","after_hash":"c420a7bde3984cfaa6cbf4fbbead5a0f4ed4b3942024bacd66227e6b6fb57011","item_hash_version":3,"event_class":"maintenance","record_hash_version":1,"record_hash":"615eeb411b8bf8182b72493cf1758e09f439c9c962fe719f9a8564b71d80ee46"} +{"ts":"2026-09-05T18:45:13.071Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:45:13.071Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-09-05T18:45:13.071Z","author":"claude-orchestrator","text":"Greptile raised a second P2 on the sibling pm-linear pull request against the boundary that was merged here earlier today, and it applies identically. Excluding only a digit immediately after the probe still accepted a heading for version 2026.1.2.3 and for 2026.1.2-rc1, which are other versions, and 2026.1.2 followed by a dash and garbage, which is a date position holding something that is not a date and is exactly the shape a broken date implementation would emit. The suffix is now matched as a grammar covering the bare version, an optional numeric duplicate-section suffix and an optional ISO date. Separately the probe was interpolated into the pattern unescaped, so its dots matched any character; it is now escaped before use."}]}],"before_hash":"c420a7bde3984cfaa6cbf4fbbead5a0f4ed4b3942024bacd66227e6b6fb57011","after_hash":"64dfd6a5e151aad5d2c0ef7bed999d7de06fa50256c40a4bde71efec66cd2f10","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"9810bc5f63b699a914c9a55d65664fc0b96fdb1667e99c416e113371543d31ac"} +{"ts":"2026-09-05T18:45:13.537Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:45:13.537Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-09-05T18:45:13.525Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-09-05T18:45:13.525Z"},{"op":"add","path":"/metadata/resolution","value":"Bounded the control by a grammar of recognised heading forms and escaped the probe version before interpolation."},{"op":"add","path":"/metadata/expected_result","value":"Other versions, prerelease suffixes and a non-date in the date position are all rejected; bare, suffixed, dated and suffixed-and-dated headings are accepted."},{"op":"add","path":"/metadata/actual_result","value":"All nine cases verified; release:check exits 0."},{"op":"add","path":"/metadata/close_reason","value":"Bounded the control by a grammar of recognised heading forms and escaped the probe version before interpolation."}],"before_hash":"64dfd6a5e151aad5d2c0ef7bed999d7de06fa50256c40a4bde71efec66cd2f10","after_hash":"d51d56dc660767a0172f959869cf0f09c0f6f6d898fc2d471b99edb30c4eb257","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"84d92ed584303d9dc1feb32a9c80d484770150ed512c7d824661e420e889ba2e"} +{"ts":"2026-09-05T18:55:35.554Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-09-05T18:55:35.554Z","author":"claude-orchestrator","text":"Greptile raised two further P2s on this pull request and both were right. First, matching the optional date by its digit widths accepts an impossible date such as a month of 13 and a day of 40, and a stale date from any past day, which are precisely the malformed date implementations this control exists to reject. The date is now bound to the actual clock date, because the only date an unflagged run may legitimately carry is today's: deriving the date from the clock is the defect the flag removes. Second, the grammar had no committed tests, and a normal run only ever sees the one heading this checkout's generator emits, so every accept and reject boundary was unexercised. The script now has a self-test mode that runs the matcher against a fixed fourteen case matrix and exits non-zero on disagreement, and the release workflow test invokes it so the pattern stays single sourced rather than being restated in the test and drifting. The test also asserts that specific matrix rows actually ran, so a self-test that silently checked nothing cannot pass. Confirmed non-vacuous by restoring the shape-only date match, which fails the test."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:55:35.554Z"}],"before_hash":"d51d56dc660767a0172f959869cf0f09c0f6f6d898fc2d471b99edb30c4eb257","after_hash":"19d640bf57ebfc634bffadc18f34fd706227f1a78fd7624a400414f678ece6bf","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"da754530c3e386299c5a925029d7381d2484ef16e09d9ba2704d91c3bb36a3e6"} +{"ts":"2026-09-05T18:55:36.196Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"update","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"test/release-workflow.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:55:36.196Z"}],"before_hash":"19d640bf57ebfc634bffadc18f34fd706227f1a78fd7624a400414f678ece6bf","after_hash":"0ac1cf997e73e5e31a37a08ff5d51a4c1f4eb35599d03e6cbbc3986d3940e3c0","item_hash_version":3,"event_class":"maintenance","record_hash_version":1,"record_hash":"bceb0403622c61a8c908a356108e4341d0cddb733610f7c3f73c32e2a7f4aa7f"} +{"ts":"2026-09-05T18:55:36.700Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"d8dbc6c3b8a673148b5c3e78","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.261","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-05T18:55:36.700Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"the changelog-date control's heading grammar accepts and rejects the matrix it claims to","scope":"project","provenance":{"author":"claude-orchestrator","created_at":"2026-09-05T18:55:36.672Z","source_kind":"local_mutation","source_ref":"fix/match-the-date-control-heading-as-a-grammar"}}]}],"before_hash":"0ac1cf997e73e5e31a37a08ff5d51a4c1f4eb35599d03e6cbbc3986d3940e3c0","after_hash":"1d58221a7941b97108ba9a0bc9fd295493521840b1ccc07c31133c75a9479784","item_hash_version":3,"event_class":"maintenance","record_hash_version":1,"record_hash":"e82e273f5a8c11a8da8605bd761e7ab3e2e5d3a25664b2c6d3cc05436c7ac7cf"} diff --git a/.agents/pm/issues/pm-github-bi6l.toon b/.agents/pm/issues/pm-github-bi6l.toon new file mode 100644 index 0000000..afd9d8f --- /dev/null +++ b/.agents/pm/issues/pm-github-bi6l.toon @@ -0,0 +1,26 @@ +id: pm-github-bi6l +title: Match the release-date control heading as a grammar and escape the probe version +description: "Excluding only a trailing digit still admitted other versions and a date position holding a non-date, and the probe was interpolated into the pattern unescaped so its dots matched any character." +type: Issue +status: closed +priority: 2 +tags: [] +created_at: "2026-09-05T18:45:11.664Z" +updated_at: "2026-09-05T18:55:36.700Z" +closed_at: "2026-09-05T18:45:13.525Z" +completed_at: "2026-09-05T18:45:13.525Z" +claim_principal: claude-orchestrator +author: claude-orchestrator +resolution: Bounded the control by a grammar of recognised heading forms and escaped the probe version before interpolation. +expected_result: "Other versions, prerelease suffixes and a non-date in the date position are all rejected; bare, suffixed, dated and suffixed-and-dated headings are accepted." +actual_result: "All nine cases verified; release:check exits 0." +comments[2]{created_at,author,text}: + "2026-09-05T18:45:13.071Z",claude-orchestrator,"Greptile raised a second P2 on the sibling pm-linear pull request against the boundary that was merged here earlier today, and it applies identically. Excluding only a digit immediately after the probe still accepted a heading for version 2026.1.2.3 and for 2026.1.2-rc1, which are other versions, and 2026.1.2 followed by a dash and garbage, which is a date position holding something that is not a date and is exactly the shape a broken date implementation would emit. The suffix is now matched as a grammar covering the bare version, an optional numeric duplicate-section suffix and an optional ISO date. Separately the probe was interpolated into the pattern unescaped, so its dots matched any character; it is now escaped before use." + "2026-09-05T18:55:35.554Z",claude-orchestrator,"Greptile raised two further P2s on this pull request and both were right. First, matching the optional date by its digit widths accepts an impossible date such as a month of 13 and a day of 40, and a stale date from any past day, which are precisely the malformed date implementations this control exists to reject. The date is now bound to the actual clock date, because the only date an unflagged run may legitimately carry is today's: deriving the date from the clock is the defect the flag removes. Second, the grammar had no committed tests, and a normal run only ever sees the one heading this checkout's generator emits, so every accept and reject boundary was unexercised. The script now has a self-test mode that runs the matcher against a fixed fourteen case matrix and exits non-zero on disagreement, and the release workflow test invokes it so the pattern stays single sourced rather than being restated in the test and drifting. The test also asserts that specific matrix rows actually ran, so a self-test that silently checked nothing cannot pass. Confirmed non-vacuous by restoring the shape-only date match, which fails the test." +files[2]{path,scope}: + scripts/verify-release-changelog-date.sh,project + test/release-workflow.test.ts,project +tests[1]{command,scope,provenance{author,created_at,source_kind,source_ref}}: + the changelog-date control's heading grammar accepts and rejects the matrix it claims to,project,claude-orchestrator,"2026-09-05T18:55:36.672Z",local_mutation,fix/match-the-date-control-heading-as-a-grammar +close_reason: Bounded the control by a grammar of recognised heading forms and escaped the probe version before interpolation. +body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 201e514..1c5d94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Match the release-date control heading as a grammar and escape the probe version ([pm-github-bi6l](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-bi6l.toon)) + ### Other - Pin the pm toolchain and assert the changelog-date flag by difference ([pm-github-c5i4](https://github.com/unbraind/pm-github/blob/main/.agents/pm/chores/pm-github-c5i4.toon)) diff --git a/scripts/verify-release-changelog-date.sh b/scripts/verify-release-changelog-date.sh index 05896d9..21780b5 100755 --- a/scripts/verify-release-changelog-date.sh +++ b/scripts/verify-release-changelog-date.sh @@ -38,7 +38,56 @@ done < <(git ls-files -- package.json '.github/workflows/*.yml' '.github/workflo # version-derived heading cannot coincide and the assertion discriminates. probe=2026.1.2 expected="## ${probe} - 2026-01-02" -today_heading="## ${probe} - $(date -u +%Y-%m-%d)" +today="$(date -u +%Y-%m-%d)" +today_heading="## ${probe} - ${today}" + +# The heading forms a CORRECT generator can produce for the probe version, used +# to bound the unflagged control. The date half is bound to the actual clock +# date, not to the `YYYY-MM-DD` shape: a shape-only match accepts an impossible +# date such as `2026-13-40` and a stale one such as last week's, and those are +# exactly the malformed date implementations this gate exists to catch. The only +# date an unflagged run may legitimately carry is today's, because deriving the +# date from the clock IS the defect the flag removes. +# +# The version is escaped before interpolation; unescaped, its dots match any +# character and `## 2026X1Y2` would be accepted as a heading for 2026.1.2. +probe_re=$(printf '%s' "$probe" | sed 's/[].[^$*\/]/\\&/g') +control_is_recognised() { + printf '%s' "$1" | grep -qE "^## ${probe_re}(-[0-9]+)?( - ${today})?$" +} + +# `--self-test` exercises the matcher against a fixed matrix and exits non-zero +# on any disagreement. Normal execution only ever sees the one heading THIS +# checkout's generator happens to emit, so without this the accept/reject +# boundaries are unexercised and can regress silently. +if [ "${1:-}" = "--self-test" ]; then + self_status=0 + while IFS='|' read -r want heading; do + [ -z "$want" ] && continue + if control_is_recognised "$heading"; then got=accept; else got=reject; fi + if [ "$got" = "$want" ]; then + echo "ok - $want $heading" + else + echo "FAIL: expected $want, got $got for '$heading'" >&2; self_status=1 + fi + done <-2` when a -# section for that version already exists, which is legitimate output that an -# allow-list of the bare and clock forms would reject. The trailing `[^0-9]` -# guard stops `2026.1.2` matching a heading for `2026.1.20`. +# The bound is a GRAMMAR of the heading forms a correct generator produces for +# this probe version, not an enumerated list of spellings and not a delimiter +# class. Enumerating is too brittle -- the generator also emits a disambiguated +# `## -2` when a section for that version already exists. But merely +# excluding a trailing digit is far too permissive: `## .3` and +# `## -rc1` are OTHER versions, and `## - garbage` is a date +# position holding something that is not a date, which is exactly the shape a +# broken date implementation would emit. So: the bare version, an optional +# `-` duplicate suffix, an optional ` - ` date, nothing else. if [ -z "$without" ]; then echo "FAIL: without --date-from-version produced no heading, so the comparison proves nothing" >&2; status=1 elif [ "$without" = "$with" ]; then echo "FAIL: without --date-from-version the heading is already '$without', identical to the flagged run" >&2; status=1 -elif ! printf '%s' "$without" | grep -qE "^## ${probe}([^0-9].*)?$"; then - echo "FAIL: without --date-from-version expected a heading for ${probe} in a non-version-derived form, got '$without' - the control cannot vouch for a heading that is not even for the probe version" >&2; status=1 +elif ! control_is_recognised "$without"; then + echo "FAIL: without --date-from-version expected a heading for ${probe} in a recognised form ('## ${probe}', optionally a '-' duplicate suffix, optionally ' - ${today}'), got '$without' - the control cannot vouch for a heading form it does not recognise" >&2; status=1 elif [ "$without" = "$today_heading" ]; then echo "ok - without the flag the heading is clock-derived: $without (this is the defect the flag removes)" else diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index 201d02f..7c174d4 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import test from "node:test"; @@ -486,3 +487,39 @@ test("publication is proven possible before anything is mutated", () => { assert.doesNotMatch(step, /-o\s+\/tmp\/[^\s"]+/); assert.match(step, /-o "\$\{response\}"/); }); + +/** + * The changelog-date verifier's heading grammar, exercised through the script's + * own `--self-test` mode. + * + * Normal execution only ever sees the single heading this checkout's generator + * happens to emit, so the accept/reject boundaries the grammar exists to enforce + * are never exercised by a real run and can regress silently. `--self-test` runs + * the matcher against a fixed matrix and exits non-zero on any disagreement, + * which keeps the pattern single-sourced: this test asserts the script's own + * verdict rather than re-declaring the regular expression and drifting from it. + * + * The matrix covers what a review of this control has actually caught: another + * version, a prerelease suffix, an impossible date, a stale date, a non-date in + * the date position, a heading matched only because an unescaped probe's dots + * were treated as wildcards, and the empty heading. + */ +test("the changelog-date control's heading grammar accepts and rejects the matrix it claims to", () => { + const script = resolve(import.meta.dirname, "../scripts/verify-release-changelog-date.sh"); + const run = spawnSync("bash", [script, "--self-test"], { encoding: "utf-8" }); + assert.equal( + run.status, + 0, + `the heading grammar disagreed with its own matrix:\n${run.stdout}\n${run.stderr}`, + ); + // A self-test that silently checked nothing would also exit 0, so require the + // matrix to have actually run. + assert.ok( + run.stdout.includes("ok - reject ## 2026.1.2 - 2026-13-40"), + "the impossible-date case must be exercised, not skipped", + ); + assert.ok( + run.stdout.includes("ok - accept ## 2026.1.2"), + "the bare-version case must be exercised, not skipped", + ); +});