diff --git a/.agents/pm/history/pm-graph-4glq.jsonl b/.agents/pm/history/pm-graph-4glq.jsonl new file mode 100644 index 0000000..4852f18 --- /dev/null +++ b/.agents/pm/history/pm-graph-4glq.jsonl @@ -0,0 +1,11 @@ +{"ts":"2026-08-24T09:40:05.637Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-graph-4glq"},{"op":"add","path":"/metadata/title","value":"The changelog gate stamps an untagged version with the current date, so its verdict flips every midnight with no commit"},{"op":"add","path":"/metadata/description","value":"pm-changelog synthesises a pending release window for a version that has no matching git tag and stamps it with the current UTC date rather than anything derived from the input. The release workflow tags only after npm publish succeeds, and publish has been failing fleet wide since 2026-08-21, so this package currently carries an untagged version and is on that clock dependent path. The exposure is masked today only by a coincidence, because the package version and today's date are the same calendar day, and it fails on any later day with no commit having changed. Verified on pm-ops where the version date and today's date differ, which makes the comparison decisive: an untagged 2026.8.22 generates heading 2026.8.22 - 2026-08-24 without the flag and 2026.8.22 - 2026-08-22 with it. The remedy needs no new code because pm-changelog 2026.8.17 already ships --date-from-version, and zero of twenty packages passed it. Applied to every invocation of the generator in this package rather than only to the scripts whose names begin with changelog, because the release workflow invokes it directly and generation and check must not disagree. Site audit: package.json sites 4 flagged 4; .github/workflows/release.yml sites 1 flagged 1;"},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["changelog","gates","release"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-24T09:40:05.637Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-24T09:40:05.637Z"},{"op":"add","path":"/metadata/author","value":"harness:claude-code"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"3b4f6ec7727feab0dab89f1c7cdf053a8461bf1f6397eb06882669e7bee33490","item_hash_version":2,"message":"","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"}}}} +{"ts":"2026-08-24T09:40:06.755Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T09:40:06.755Z"},{"op":"add","path":"/metadata/files","value":[{"path":"package.json","scope":"project","note":"every pm-changelog invocation here now derives the no-tag release date from the calendar version"}]}],"before_hash":"3b4f6ec7727feab0dab89f1c7cdf053a8461bf1f6397eb06882669e7bee33490","after_hash":"899dc1f2f874c61949944a20d79f8c75e032d6458a98089fcf512c295c1f8727","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"}}}} +{"ts":"2026-08-24T09:40:07.287Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":".github/workflows/release.yml","scope":"project","note":"every pm-changelog invocation here now derives the no-tag release date from the calendar version"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T09:40:07.287Z"}],"before_hash":"899dc1f2f874c61949944a20d79f8c75e032d6458a98089fcf512c295c1f8727","after_hash":"50094d74d094634496001176181a2f37537a2fc91c8cedab7d1b727d699c5326","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"}}}} +{"ts":"2026-08-24T09:40:07.714Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T09:40:07.714Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run changelog:check","path":"package.json","scope":"project","note":"regenerates the changelog and compares it; without the flag the heading date is stamped from the clock and the comparison fails on any day after generation"}]}],"before_hash":"50094d74d094634496001176181a2f37537a2fc91c8cedab7d1b727d699c5326","after_hash":"44be746cf80968369495815cc4847bb74d4ad5261bc063256ae6af485707f3c0","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"}}}} +{"ts":"2026-08-24T10:03:38.387Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"bash scripts/verify-release-changelog-date.sh","path":"scripts/verify-release-changelog-date.sh","scope":"project","note":"exercises the release workflow path that npm run changelog:check does not: asserts every generator invocation in every tracked file carries the flag, and shows the same probe version yielding its own calendar date with the flag and today's date without it"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T10:03:38.387Z"}],"before_hash":"44be746cf80968369495815cc4847bb74d4ad5261bc063256ae6af485707f3c0","after_hash":"14e5f43fcffe1925fea08435f587fa0f5e91be96dc6ee1f6ef0fbc76e50c82c6","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"}}}} +{"ts":"2026-08-24T10:03:38.802Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/2","value":{"path":"scripts/verify-release-changelog-date.sh","scope":"project","note":"executable proof for the release workflow invocations, which the package.json changelog check never reaches"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T10:03:38.802Z"}],"before_hash":"14e5f43fcffe1925fea08435f587fa0f5e91be96dc6ee1f6ef0fbc76e50c82c6","after_hash":"70d8c80409f12e9891fc56c0bfdb1f1d04e24dc36f1c9763c042589873dbda33","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"}}}} +{"ts":"2026-08-24T10:03:39.234Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T10:03:39.234Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-24T10:03:39.234Z","author":"harness:claude-code","text":"CodeRabbit was right that the linked test only covered the package.json invocation and never reached the direct pm-changelog commands in the release workflow. Added an executable check that does both halves. The static half enumerates every tracked file containing a generator invocation and asserts each file's flagged count is not below its site count, which fails when any single site loses the flag; verified by removing the flag from one of two release.yml sites and watching it report one of two and exit non-zero. The behavioural half runs the generator twice on the same probe version 2026.1.2, deliberately chosen so its calendar date cannot coincide with today, and asserts the heading is 2026.1.2 - 2026-01-02 with the flag and 2026.1.2 - 2026-08-24 without it, so the test states the defect rather than merely asserting the fix."}]}],"before_hash":"70d8c80409f12e9891fc56c0bfdb1f1d04e24dc36f1c9763c042589873dbda33","after_hash":"5d26fcdf32cbdd1db9b1e7d8f45a4ad9bb09cfa65d1efeae532fca9e378f58b1","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"}}}} +{"ts":"2026-08-24T10:05:50.476Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"365e646596cb83e6152b34c6","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-24T10:05:50.476Z","author":"harness:claude-code","text":"CodeRabbit was right that the linked test only covered the package.json invocation and never reached the direct pm-changelog commands in the release workflow. Added an executable check that does both halves. The static half enumerates every tracked file containing a generator invocation and asserts each file's flagged count is not below its site count, which fails when any single site loses the flag; verified by removing the flag from one of two release.yml sites and watching it report one of two and exit non-zero. The behavioural half runs the generator twice on the same probe version 2026.1.2, deliberately chosen so its calendar date cannot coincide with today, and asserts the heading is 2026.1.2 - 2026-01-02 with the flag and 2026.1.2 - 2026-08-24 without it, so the test states the defect rather than merely asserting the fix."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-24T10:05:50.476Z"}],"before_hash":"5d26fcdf32cbdd1db9b1e7d8f45a4ad9bb09cfa65d1efeae532fca9e378f58b1","after_hash":"58131ff3cab390b58093e95d06930c55e247de3550bc6ecaa9f50cfc8f5b0755","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"}}}} +{"ts":"2026-08-27T17:09:22.782Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"74d2f6d55073a15471e92197","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_delete","patch":[{"op":"remove","path":"/metadata/comments/1"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T17:09:22.782Z"}],"before_hash":"58131ff3cab390b58093e95d06930c55e247de3550bc6ecaa9f50cfc8f5b0755","after_hash":"b345318501cb9e102ef0f50a9eaa50170dec0eefece6d5bef07860784d4f7a50","item_hash_version":2,"message":"Remove a comment duplicated by a retried add","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"}}}} +{"ts":"2026-08-27T17:18:39.579Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"74d2f6d55073a15471e92197","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-27T17:18:39.579Z","author":"claude-agent","text":"The verifier this issue added was a file the repository shipped and nothing executed. It was absent from release:check and from every CI step, in thirteen of the fifteen repositories that carry it, so a later edit could have removed --date-from-version from the workflow and both generation and --check would have agreed on a clock-derived date without anything failing. It is now a named script, the last step of release:check, and its own CI step. Reported by CodeRabbit on unbraind/pm-graph#70.\n\nThe verifier itself gained two fixes in the same pass, also from CodeRabbit. Its static section now balances flags per line rather than per file, which closes two hiding places at once: a single line carrying several invocations where only one is flagged, and an unflagged invocation hiding behind a mention of the flag on a line that invokes nothing. Its behavioural section no longer runs the unflagged control under || true, so a control that fails to produce a heading now fails the gate instead of printing a note while the script exits zero. Both were confirmed by executing the old script against the defect and watching it exit zero."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T17:18:39.579Z"}],"before_hash":"b345318501cb9e102ef0f50a9eaa50170dec0eefece6d5bef07860784d4f7a50","after_hash":"9a5d43ef78322d70061943b12648a4778db7dbd1f2c5d3f720774a101ebd2b57","item_hash_version":2,"message":"Record that the verifier became an executed gate","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"}}}} +{"ts":"2026-08-27T17:18:40.005Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"74d2f6d55073a15471e92197","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/2/note","value":"every pm-changelog invocation here now derives the no-tag release date from the calendar version"},{"op":"replace","path":"/metadata/files/2/path","value":"package.json"},{"op":"replace","path":"/metadata/files/0/note","value":"runs the changelog-date verifier as its own step"},{"op":"replace","path":"/metadata/files/0/path","value":".github/workflows/ci.yml"},{"op":"add","path":"/metadata/files/3","value":{"path":"scripts/verify-release-changelog-date.sh","scope":"project","note":"executable proof for the release workflow invocations, which the package.json changelog check never reaches"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T17:18:40.005Z"}],"before_hash":"9a5d43ef78322d70061943b12648a4778db7dbd1f2c5d3f720774a101ebd2b57","after_hash":"62965e6894cc1e4e01c9b8f0aeb7c052b3553f9026c9872f48531f768072127a","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/history/pm-graph-gjpd.jsonl b/.agents/pm/history/pm-graph-gjpd.jsonl new file mode 100644 index 0000000..d1d69eb --- /dev/null +++ b/.agents/pm/history/pm-graph-gjpd.jsonl @@ -0,0 +1,8 @@ +{"ts":"2026-08-29T04:58:36.090Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-graph-gjpd"},{"op":"add","path":"/metadata/title","value":"Unblock PR 70 suffixed CalVer changelog gate"},{"op":"add","path":"/metadata/description","value":"Rebase PR 70 after PR 79, adopt the published suffix-safe pm-changelog release, and execute a same-day suffixed-version regression."},{"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-29T04:58:36.090Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-29T04:58:36.090Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"9726954f68f1b235f9e4b962d9fe21c3b3424525a1458cde41e84e00d410c5b4","item_hash_version":2,"message":""} +{"ts":"2026-08-29T04:58:36.574Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:58:36.574Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/claim_principal","value":"pi-agent"}],"before_hash":"9726954f68f1b235f9e4b962d9fe21c3b3424525a1458cde41e84e00d410c5b4","after_hash":"dcd9a4b38b34d247753444d159b4f69f47454573cd0d69f3d8d1d92eb14bfdb1","item_hash_version":2} +{"ts":"2026-08-29T04:58:37.062Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:58:37.062Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"dcd9a4b38b34d247753444d159b4f69f47454573cd0d69f3d8d1d92eb14bfdb1","after_hash":"eec28beaf45a6f874220bb9ac6a2d483f05c6bdd5b61b0343572a1f2207d7dbd","item_hash_version":2} +{"ts":"2026-08-29T04:58:37.797Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:58:37.797Z"},{"op":"add","path":"/metadata/files","value":[{"path":"package-lock.json","scope":"project"},{"path":"package.json","scope":"project"},{"path":"scripts/verify-release-changelog-date.ts","scope":"project"},{"path":"test/verify-release-changelog-date.test.ts","scope":"project"}]}],"before_hash":"eec28beaf45a6f874220bb9ac6a2d483f05c6bdd5b61b0343572a1f2207d7dbd","after_hash":"5da91d771e93ba2d02b1692d8028cc2ba7264da5d041569b5076237e8f6c957a","item_hash_version":2} +{"ts":"2026-08-29T04:58:38.384Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:58:38.384Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-29T04:58:38.384Z","author":"pi-agent","text":"Updated pm-changelog from 2026.8.17 to published 2026.8.22 and changed the behavioural gate to probe 2026.1.2-1, preserving the suffix while expecting date 2026-01-02."}]}],"before_hash":"5da91d771e93ba2d02b1692d8028cc2ba7264da5d041569b5076237e8f6c957a","after_hash":"0303b972bbcf421e10c9c16842dc044cc4153e205f32f822e507bb5c5e76e5d0","item_hash_version":2} +{"ts":"2026-08-29T04:58:39.086Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:58:39.086Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-29T04:58:39.070Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-29T04:58:39.070Z"},{"op":"add","path":"/metadata/close_reason","value":"Suffixed CalVer release path now executable and covered"}],"before_hash":"0303b972bbcf421e10c9c16842dc044cc4153e205f32f822e507bb5c5e76e5d0","after_hash":"8e522465a1ff24de60806c6d587fc87febff4be1f35181d4bb9bd412c0dd8926","item_hash_version":2} +{"ts":"2026-08-29T04:58:39.701Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:58:39.701Z"}],"before_hash":"8e522465a1ff24de60806c6d587fc87febff4be1f35181d4bb9bd412c0dd8926","after_hash":"54604f9217fef400e847b350cbaa89377110ca4c0d848e75b685eb76222e7225","item_hash_version":2} +{"ts":"2026-08-29T05:19:01.093Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"3b80ec0e6cc69afd5cadb09e","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-29T05:19:01.093Z","author":"pi-agent","text":"Follow-up CodeRabbit round: stripComment now treats backslashes as escapes only outside single quotes; added a regression where a literal trailing backslash precedes the closing quote and a commented date flag."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T05:19:01.093Z"}],"before_hash":"54604f9217fef400e847b350cbaa89377110ca4c0d848e75b685eb76222e7225","after_hash":"7b1ff2e700c1866caa750e5ccaa07668a67343ecb44179684632ee0e019732c6","item_hash_version":2} diff --git a/.agents/pm/issues/pm-graph-4glq.toon b/.agents/pm/issues/pm-graph-4glq.toon new file mode 100644 index 0000000..17606c8 --- /dev/null +++ b/.agents/pm/issues/pm-graph-4glq.toon @@ -0,0 +1,22 @@ +id: pm-graph-4glq +title: "The changelog gate stamps an untagged version with the current date, so its verdict flips every midnight with no commit" +description: "pm-changelog synthesises a pending release window for a version that has no matching git tag and stamps it with the current UTC date rather than anything derived from the input. The release workflow tags only after npm publish succeeds, and publish has been failing fleet wide since 2026-08-21, so this package currently carries an untagged version and is on that clock dependent path. The exposure is masked today only by a coincidence, because the package version and today's date are the same calendar day, and it fails on any later day with no commit having changed. Verified on pm-ops where the version date and today's date differ, which makes the comparison decisive: an untagged 2026.8.22 generates heading 2026.8.22 - 2026-08-24 without the flag and 2026.8.22 - 2026-08-22 with it. The remedy needs no new code because pm-changelog 2026.8.17 already ships --date-from-version, and zero of twenty packages passed it. Applied to every invocation of the generator in this package rather than only to the scripts whose names begin with changelog, because the release workflow invokes it directly and generation and check must not disagree. Site audit: package.json sites 4 flagged 4; .github/workflows/release.yml sites 1 flagged 1;" +type: Issue +status: open +priority: 1 +tags[3]: changelog,gates,release +created_at: "2026-08-24T09:40:05.637Z" +updated_at: "2026-08-27T17:18:40.005Z" +author: "harness:claude-code" +comments[2]{created_at,author,text}: + "2026-08-24T10:03:39.234Z","harness:claude-code","CodeRabbit was right that the linked test only covered the package.json invocation and never reached the direct pm-changelog commands in the release workflow. Added an executable check that does both halves. The static half enumerates every tracked file containing a generator invocation and asserts each file's flagged count is not below its site count, which fails when any single site loses the flag; verified by removing the flag from one of two release.yml sites and watching it report one of two and exit non-zero. The behavioural half runs the generator twice on the same probe version 2026.1.2, deliberately chosen so its calendar date cannot coincide with today, and asserts the heading is 2026.1.2 - 2026-01-02 with the flag and 2026.1.2 - 2026-08-24 without it, so the test states the defect rather than merely asserting the fix." + "2026-08-27T17:18:39.579Z",claude-agent,"The verifier this issue added was a file the repository shipped and nothing executed. It was absent from release:check and from every CI step, in thirteen of the fifteen repositories that carry it, so a later edit could have removed --date-from-version from the workflow and both generation and --check would have agreed on a clock-derived date without anything failing. It is now a named script, the last step of release:check, and its own CI step. Reported by CodeRabbit on unbraind/pm-graph#70.\n\nThe verifier itself gained two fixes in the same pass, also from CodeRabbit. Its static section now balances flags per line rather than per file, which closes two hiding places at once: a single line carrying several invocations where only one is flagged, and an unflagged invocation hiding behind a mention of the flag on a line that invokes nothing. Its behavioural section no longer runs the unflagged control under || true, so a control that fails to produce a heading now fails the gate instead of printing a note while the script exits zero. Both were confirmed by executing the old script against the defect and watching it exit zero." +files[4]{path,scope,note}: + .github/workflows/ci.yml,project,runs the changelog-date verifier as its own step + .github/workflows/release.yml,project,every pm-changelog invocation here now derives the no-tag release date from the calendar version + package.json,project,every pm-changelog invocation here now derives the no-tag release date from the calendar version + scripts/verify-release-changelog-date.sh,project,"executable proof for the release workflow invocations, which the package.json changelog check never reaches" +tests[2]{command,path,scope,note}: + "npm run changelog:check",package.json,project,regenerates the changelog and compares it; without the flag the heading date is stamped from the clock and the comparison fails on any day after generation + bash scripts/verify-release-changelog-date.sh,scripts/verify-release-changelog-date.sh,project,"exercises the release workflow path that npm run changelog:check does not: asserts every generator invocation in every tracked file carries the flag, and shows the same probe version yielding its own calendar date with the flag and today's date without it" +body: "" diff --git a/.agents/pm/issues/pm-graph-gjpd.toon b/.agents/pm/issues/pm-graph-gjpd.toon new file mode 100644 index 0000000..e9e904d --- /dev/null +++ b/.agents/pm/issues/pm-graph-gjpd.toon @@ -0,0 +1,22 @@ +id: pm-graph-gjpd +title: Unblock PR 70 suffixed CalVer changelog gate +description: "Rebase PR 70 after PR 79, adopt the published suffix-safe pm-changelog release, and execute a same-day suffixed-version regression." +type: Issue +status: closed +priority: 2 +tags: [] +created_at: "2026-08-29T04:58:36.090Z" +updated_at: "2026-08-29T05:19:01.093Z" +closed_at: "2026-08-29T04:58:39.070Z" +completed_at: "2026-08-29T04:58:39.070Z" +author: pi-agent +comments[2]{created_at,author,text}: + "2026-08-29T04:58:38.384Z",pi-agent,"Updated pm-changelog from 2026.8.17 to published 2026.8.22 and changed the behavioural gate to probe 2026.1.2-1, preserving the suffix while expecting date 2026-01-02." + "2026-08-29T05:19:01.093Z",pi-agent,"Follow-up CodeRabbit round: stripComment now treats backslashes as escapes only outside single quotes; added a regression where a literal trailing backslash precedes the closing quote and a commented date flag." +files[4]{path,scope}: + package-lock.json,project + package.json,project + scripts/verify-release-changelog-date.ts,project + test/verify-release-changelog-date.test.ts,project +close_reason: Suffixed CalVer release path now executable and covered +body: "" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83c07fe..f7d721c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,9 @@ jobs: - name: Verify every publish invocation is attested run: npm run verify:release-publish-attestation + - name: Verify the changelog date is derived from the version + run: npm run verify:release-changelog-date + - name: Verify Bun can install the package graph run: bun install --no-save diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c402bb7..c8312ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,7 +285,7 @@ jobs: common=( --pm-root .agents/pm --all-release-tags - --release-version-from-package + --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm diff --git a/.gitignore b/.gitignore index 30eeb8c..5ca4171 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ RELEASE_NOTES.md .env.* !.env.example coverage/ + +# SDK workspace-transaction journals are runtime state, not tracked data. +# pm health fails closed on these (tracked_runtime_cache_files), so committing +# one turns the health gate red; the journal has no value once the transaction +# has landed. Deliberately outside any `pm-cli:` fence, which the CLI rewrites. +.agents/pm/transactions/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1efa1..ff91bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Unblock PR 70 suffixed CalVer changelog gate ([pm-graph-gjpd](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-gjpd.toon)) - Close PR 79 scalar escape review findings ([pm-graph-ewxc](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-ewxc.toon)) ### Other diff --git a/package-lock.json b/package-lock.json index e20c4f1..2944d95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@types/node": "^26.2.0", "@unbrained/pm-cli": "2026.8.28", - "pm-changelog": "2026.8.17", + "pm-changelog": "2026.8.22", "pm-ops": "2026.8.17", "typescript": "^7.0.2" }, @@ -1196,9 +1196,9 @@ } }, "node_modules/pm-changelog": { - "version": "2026.8.17", - "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.8.17.tgz", - "integrity": "sha512-wU52DpIWlSRhi0v3xtktgVF+oLte4Z+LWZ0H3VjbrhG+8XOEJrhevOu0YqMyiPQvNyt0SpDXy8OEhyxyaIcVEA==", + "version": "2026.8.22", + "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.8.22.tgz", + "integrity": "sha512-EazkSluZqJLOkKBjADT26IzA8E3teizm6lC3M5uuyKOH/cg09HL3gSdbLfxGqdSIMDsjXPRasw/giRQu/EKbzw==", "dev": true, "license": "MIT", "bin": { @@ -1208,7 +1208,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.16" + "@unbrained/pm-cli": ">=2026.8.20" } }, "node_modules/pm-ops": { diff --git a/package.json b/package.json index 209b8a8..e930871 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,18 @@ "docstring": "node scripts/docstring-gate.ts", "audit:prod": "node --input-type=module -e \"import { spawnSync } from 'node:child_process'; import { devNull } from 'node:os'; const env = { ...process.env, npm_config_userconfig: devNull, NPM_CONFIG_USERCONFIG: devNull }; for (const key of Object.keys(env)) if (key.toLowerCase() === 'npm_config_allow_scripts') delete env[key]; const win = process.platform === 'win32'; const r = spawnSync(win ? 'npm.cmd' : 'npm', ['audit', '--omit=dev', '--ignore-scripts'], { stdio: 'inherit', env, shell: win }); process.exit(r.status ?? 1);\"", "pack:dry-run": "npm pack --dry-run", - "changelog": "pm-changelog --pm-root .agents/pm --mode prepend --output CHANGELOG.md --release-version-from-package --since-previous-tag --until-release-tag --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", - "changelog:full": "pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", - "changelog:check": "pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --check --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", - "release:check": "npm run typecheck && npm run build && npm run docstring && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check && npm run verify:release-publish-attestation", + "changelog": "pm-changelog --pm-root .agents/pm --mode prepend --output CHANGELOG.md --release-version-from-package --date-from-version --since-previous-tag --until-release-tag --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", + "changelog:full": "pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", + "changelog:check": "pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --check --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", + "release:check": "npm run typecheck && npm run build && npm run docstring && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check && npm run verify:release-publish-attestation && npm run verify:release-changelog-date", "prepack": "npm run build", "test": "npm run build && npm run build:test && node --test test/*.test.ts", "prepublishOnly": "npm run release:check", - "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", + "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-graph/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded", "merge:install": "pm merge install", "coverage": "npm run build && npm run build:test && node scripts/coverage-gate.ts", - "verify:release-publish-attestation": "node scripts/verify-release-publish-attestation.ts" + "verify:release-publish-attestation": "node scripts/verify-release-publish-attestation.ts", + "verify:release-changelog-date": "node scripts/verify-release-changelog-date.ts" }, "dependencies": { "neo4j-driver": "^6.2.0" @@ -30,7 +31,7 @@ "devDependencies": { "@types/node": "^26.2.0", "@unbrained/pm-cli": "2026.8.28", - "pm-changelog": "2026.8.17", + "pm-changelog": "2026.8.22", "pm-ops": "2026.8.17", "typescript": "^7.0.2" }, diff --git a/scripts/verify-release-changelog-date.ts b/scripts/verify-release-changelog-date.ts new file mode 100644 index 0000000..a1795fb --- /dev/null +++ b/scripts/verify-release-changelog-date.ts @@ -0,0 +1,415 @@ +/** + * Proves an untagged release's changelog heading comes from the calendar + * version rather than from the clock, and that every generator invocation in + * this package asks for that. + * + * Why this exists separately from `npm run changelog:check`: that script only + * exercises the `package.json` invocation. `.github/workflows/release.yml` + * calls the generator directly. If either side lost `--date-from-version` the + * two would disagree during a release -- one heading derived from the clock, + * the other from the version -- and the release would fail on the divergence + * rather than on the stale date the flag exists to remove. + * + * The analysis is separated from the I/O so the rules below are executed by the + * test suite against fixtures rather than only against this repository, which + * happens to satisfy them. + * + * @packageDocumentation + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Every spelling that tells the generator which version it is rendering. + * + * All three matter. `--release-version` is a declared alias of `--version`, and + * a workflow that names the pending tag explicitly uses one of those two rather + * than `--release-version-from-package`. A scan that recognised only the + * package-derived spelling skipped exactly the release-time invocations it was + * written to protect. */ +export const VERSION_INPUTS = ["--release-version-from-package", "--release-version", "--version"] as const; + +/** The flag under audit: it makes the no-tag heading version-derived. */ +export const DATE_FLAG = "--date-from-version"; + +/** One generator invocation found in a tracked file. */ +export interface Invocation { + /** File the invocation was found in. */ + file: string; + /** The logical command, with continuations joined and arrays expanded. */ + command: string; +} + +/** A tracked file's path and contents. */ +export interface SourceFile { + /** Repository-relative path. */ + file: string; + /** File contents. */ + text: string; +} + +/** + * Collapse shell and YAML line continuations so one logical command is one string. + * + * A backslash at end of line joins the next line; without this every multi-line + * invocation looks like a set of fragments, none of which carries both the + * version input and the date flag. + * + * @param text - Raw file contents. + * @returns The same text with continuations joined. + */ +export function joinContinuations(text: string): string { + return text.replace(/\\\r?\n\s*/g, " "); +} + +/** + * Index bash array assignments so a shared options array can be expanded. + * + * The release workflows declare `common=( ... )` once and pass `"${common[@]}"` + * to each invocation, precisely so the invocations cannot drift. A scan that + * reads only the invocation line therefore sees none of the shared flags. + * + * @param text - File contents with continuations already joined. + * @returns Array name mapped to the flag text it holds. + */ +export function bashArrays(text: string): Map { + const arrays = new Map(); + for (const match of text.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=\(([\s\S]*?)\)/g)) { + arrays.set(match[1], match[2].replace(/\s+/g, " ").trim()); + } + return arrays; +} + +/** + * Expand `"${name[@]}"` references against the file's array declarations. + * + * An unknown name is left untouched rather than erased: silently dropping it + * would turn "this scan does not understand the command" into "this command has + * no flags", which reads as a pass. + * + * @param line - One logical command. + * @param arrays - Array declarations from the same file. + * @returns The command with referenced array contents inlined. + */ +export function expandArrays(line: string, arrays: Map): string { + return line.replace(/"?\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"?/g, (whole, name: string) => + arrays.get(name) ?? whole); +} + +/** + * Drop an unquoted trailing comment from one command. + * + * A `#` inside quotes is an argument -- these invocations pass URLs containing + * one -- while an unquoted `#` starts a comment the shell never runs. Without + * this, a comment is part of the command as far as a substring check is + * concerned, and `... --release-version-from-package # --date-from-version` + * satisfies the very check it is complaining about. + * + * @param command - One logical command. + * @returns The command with any unquoted trailing comment removed. + */ +export function stripComment(command: string): string { + let single = false; + let double = false; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]; + if (character === "\\" && !single) { index += 1; continue; } + if (character === "'" && !double) single = !single; + else if (character === '"' && !single) double = !double; + else if (character === "#" && !single && !double && (index === 0 || /\s/.test(command[index - 1]))) { + return command.slice(0, index); + } + } + return command; +} + +/** + * Find every generator invocation in one file's contents. + * + * An invocation is a logical command that runs the generator -- by binary name, + * or as the built `dist/cli.js` in pm-changelog's own repository -- and carries + * at least one version input. Comment lines are excluded: a file may describe + * these flags without running anything, and holding prose to the rule is a + * false positive that trains people to weaken the gate. + * + * @param source - The file's path and contents. + * @returns The invocations found, in file order. + */ +export function invocationsIn(source: SourceFile): Invocation[] { + const text = joinContinuations(source.text); + const arrays = bashArrays(text); + const found: Invocation[] = []; + for (const raw of text.split("\n")) { + if (/^\s*#/.test(raw)) continue; + if (!/pm-changelog|dist\/cli\.js/.test(raw)) continue; + // Split on shell separators AFTER expanding arrays. One line can hold + // several invocations -- a package.json script chaining two generator calls + // with `&&` is the common case -- and judging the line as a whole lets a + // flagged call cover for an unflagged one beside it. + // + // `&&`, `||` and `;` need no surrounding whitespace to separate commands, + // and `flagged&&unflagged` is valid shell: requiring whitespace left the + // two calls in one segment where the first one's flag covered the second. + // A bare `|` is only treated as a separator when whitespace-delimited, + // because an unspaced pipe is far more likely to be inside an argument -- + // an alternation in a regex, say -- and splitting there would separate a + // version input from its own flag and report a defect that is not present. + for (const raw_segment of expandArrays(raw, arrays).split(/\s*(?:&&|\|\||;)\s*|\s\|\s/)) { + const segment = stripComment(raw_segment); + if (!/pm-changelog|dist\/cli\.js/.test(segment)) continue; + if (!VERSION_INPUTS.some((flag) => segment.includes(flag))) continue; + found.push({ file: source.file, command: segment }); + } + } + return found; +} + +/** The outcome of one verifier run. */ +export interface VerifierResult { + /** Reasons the run failed; empty means it passed. */ + failures: string[]; + /** Lines describing what was checked, for the operator. */ + notes: string[]; +} + +/** + * Audit every generator invocation across the given files. + * + * Judged per invocation rather than by counting: counting matching lines misses + * a line holding several invocations where only one is flagged, and counting + * occurrences file-wide lets an unflagged invocation hide behind a mention of + * the flag on a line that invokes nothing at all. + * + * @param sources - The tracked files to scan. + * @returns Failures and per-file notes. + */ +export function auditInvocations(sources: SourceFile[]): VerifierResult { + const invocations = sources.flatMap(invocationsIn); + const failures: string[] = []; + const counted = new Map(); + for (const invocation of invocations) { + const tally = counted.get(invocation.file) ?? { total: 0, unflagged: 0 }; + tally.total += 1; + if (!invocation.command.includes(DATE_FLAG)) { + tally.unflagged += 1; + failures.push( + `${invocation.file}: a generator invocation carries a version input but not ${DATE_FLAG}: ` + + invocation.command.trim().slice(0, 160), + ); + } + counted.set(invocation.file, tally); + } + if (invocations.length === 0) { + failures.push("no generator invocation was found in any tracked file - the scan is looking in the wrong place"); + } + const notes: string[] = []; + for (const [file, tally] of counted) { + if (tally.unflagged > 0) continue; + notes.push(`ok - ${file}: ${tally.total} generator invocation(s), each carrying ${DATE_FLAG}`); + } + return { failures, notes }; +} + +/** A generator run's first `## ` heading, or why there was none. */ +export interface HeadingResult { + /** Whether the run produced a heading at all. */ + ok: boolean; + /** The heading, or a description of the failure. */ + text: string; +} + +/** + * Compare the flagged and unflagged headings for one probe version. + * + * The unflagged run is the control: it is what proves the flag is doing the + * work rather than the heading happening to be right for another reason. A + * control that failed to run leaves the comparison unmade, so its failure is a + * failure here -- it was previously suppressed with `|| true` and downgraded to + * a note, letting the script exit zero having proved nothing. + * + * @param probe - Probe version, deliberately not today's date. + * @param today - Today's date as `YYYY-MM-DD`. + * @param generate - Runs the generator; `flagged` selects `--date-from-version`. + * @returns Failures and notes for the behavioural half. + */ +export function auditHeadings( + probe: string, + today: string, + generate: (flagged: boolean) => HeadingResult, +): VerifierResult { + const expected = `## ${probe} - ${probe.replace(/^(\d{4})\.(\d{1,2})\.(\d{1,2}).*$/, (_all, y: string, m: string, d: string) => + `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`)}`; + const todayHeading = `## ${probe} - ${today}`; + const failures: string[] = []; + const notes: string[] = []; + + const flagged = generate(true); + if (!flagged.ok) failures.push(`with ${DATE_FLAG}, ${flagged.text}`); + else if (flagged.text !== expected) failures.push(`with ${DATE_FLAG} expected '${expected}', got '${flagged.text}'`); + else notes.push(`ok - with the flag the heading is version-derived: ${flagged.text}`); + + const control = generate(false); + if (!control.ok) failures.push(`without ${DATE_FLAG}, ${control.text}, so the comparison proves nothing`); + else if (control.text === flagged.text) { + failures.push( + `the heading is '${control.text}' with and without ${DATE_FLAG} - the flag is changing nothing, so` + + " nothing here proves the date is version-derived", + ); + } else { + // What must hold is that the flag CHANGES the outcome. Asserting the + // control equals today's date instead would pin the generator's current + // default, so a compatible dependency update that changed it would fail + // this gate for no defect; and today's date is sampled once for two + // subprocess runs, so a run crossing UTC midnight would fail for no defect + // either. Whether the control happens to be the clock is reported, not + // required. + const derivation = control.text === todayHeading ? "clock-derived" : "derived some other way"; + notes.push(`ok - the flag changes the heading: '${flagged.text}' with it, '${control.text}' without (${derivation})`); + } + + return { failures, notes }; +} + +/** + * Resolve the generator this repository should run. + * + * In pm-changelog's own repository the generator is the build output rather + * than a dependency, so resolve in that order instead of assuming node_modules. + * + * @param root - Repository root. + * @returns The executable and any leading arguments. + */ +export function resolveGenerator(root: string): { bin: string; lead: string[] } { + const local = resolve(root, "node_modules/.bin/pm-changelog"); + if (existsSync(local)) return { bin: local, lead: [] }; + const built = resolve(root, "dist/cli.js"); + if (existsSync(built)) return { bin: process.execPath, lead: [built] }; + return { bin: "npx", lead: ["pm-changelog"] }; +} + +/** + * Run the generator once and return its first `## ` heading. + * + * A run that fails is reported as a failed run rather than as an absent + * heading, because the two have different remedies and conflating them is how + * a broken generator reads as a passing gate. + * + * @param bin - Executable to run. + * @param args - Arguments to pass. + * @param cwd - Directory to run in. + * @returns The heading, or why there was none. + */ +export function generateHeading(bin: string, args: string[], cwd: string): HeadingResult { + try { + const out = execFileSync(bin, args, { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }); + const line = out.split("\n").find((candidate) => candidate.startsWith("## ")); + return line === undefined ? { ok: false, text: "the run produced no '## ' heading" } : { ok: true, text: line }; + } catch (error) { + return { ok: false, text: `the run failed: ${(error as Error).message.split("\n")[0]}` }; + } +} + +/** + * Run both halves of the verifier against a real checkout. + * + * @param root - Repository root to verify. + * @param today - Today's date as `YYYY-MM-DD`. + * @returns Failures and notes from both halves. + */ +export function verify(root: string, today: string): VerifierResult { + const tracked = execFileSync( + "git", + ["ls-files", "--", "package.json", ".github/workflows/*.yml", ".github/workflows/*.yaml"], + { cwd: root, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }, + ) + .trimEnd() + .split("\n") + .filter((file) => file.length > 0 && existsSync(resolve(root, file))); + const sources = tracked.map((file) => ({ file, text: readFileSync(resolve(root, file), "utf-8") })); + const statics = auditInvocations(sources); + + // A same-day second release carries a numeric suffix. The dependency must + // preserve that full version while deriving the date from its CalVer base. + const probe = "2026.1.2-1"; + const { bin, lead } = resolveGenerator(root); + // The generator refuses a truncated workspace read rather than silently + // omitting entries, so the unbounded controls the real scripts pass are + // required here too. + const common = [ + ...lead, + "--pm-root", ".agents/pm", + "--stdout", + "--pm-bin", "./node_modules/.bin/pm", + "--pm-arg=--output-budget", "--pm-arg=unbounded", + "--pm-arg=--output-limit", "--pm-arg=unbounded", + "--release-version", probe, + ]; + const behavioural = auditHeadings(probe, today, (flagged) => + generateHeading(bin, flagged ? [...common, DATE_FLAG] : common, root)); + + return { + failures: [...statics.failures, ...behavioural.failures], + notes: [...statics.notes, ...behavioural.notes], + }; +} + +/** + * Report a verifier result on the process streams and set the exit code. + * + * @param result - The result to report. + */ +export function report(result: VerifierResult): void { + for (const note of result.notes) process.stdout.write(`${note}\n`); + for (const failure of result.failures) process.stderr.write(`FAIL: ${failure}\n`); + process.exitCode = result.failures.length === 0 ? 0 : 1; +} + +/** + * Whether this module is the process entry point. + * + * Kept as a named function rather than an inline comparison so the suite can + * execute both answers; a guard nothing exercises is how an entry point stops + * running and nobody notices. + * + * @param argv - The process argv to judge. + * @param moduleUrl - The module's own `import.meta.url`. + * @returns True when argv names this module as the script being run. + */ +export function isMainInvocation(argv: string[], moduleUrl: string): boolean { + const script = argv[1]; + return script !== undefined && moduleUrl === pathToFileURL(resolve(script)).href; +} + +/** + * Verify this repository and report the result. + * + * @param root - Repository root to verify. + * @param today - Today's date as `YYYY-MM-DD`. + */ +export function main(root: string, today: string): void { + report(verify(root, today)); +} + +/** + * Verify and report, but only when this module is the process entry point. + * + * The guard is a function rather than a bare `if` at module scope so the suite + * can execute both answers. A bare `if` leaves its own body unreachable from + * any in-process test, which is how an entry point quietly stops running. + * + * @param argv - The process argv to judge. + * @param moduleUrl - This module's `import.meta.url`. + * @param root - Repository root to verify. + * @param today - Today's date as `YYYY-MM-DD`. + * @returns True when the verifier ran. + */ +export function runIfMain(argv: string[], moduleUrl: string, root: string, today: string): boolean { + if (!isMainInvocation(argv, moduleUrl)) return false; + main(root, today); + return true; +} + +// Run only as main, so the suite can import the rules above and execute them +// against fixtures rather than only against this repository. +runIfMain(process.argv, import.meta.url, resolve(import.meta.dirname, ".."), new Date().toISOString().slice(0, 10)); diff --git a/test/verify-release-changelog-date.test.ts b/test/verify-release-changelog-date.test.ts new file mode 100644 index 0000000..a497d3e --- /dev/null +++ b/test/verify-release-changelog-date.test.ts @@ -0,0 +1,293 @@ +/** + * Executes the changelog-date verifier's rules against fixtures. + * + * The verifier's own repository satisfies its rules, so running it here would + * only prove that today's tree is fine. What these cases prove is that each + * rule still FAILS on the defect it exists to catch -- including the two shapes + * that defeated the previous line-oriented implementation. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + DATE_FLAG, + VERSION_INPUTS, + auditHeadings, + auditInvocations, + bashArrays, + stripComment, + expandArrays, + invocationsIn, + joinContinuations, + generateHeading, + isMainInvocation, + main, + runIfMain, + report, + resolveGenerator, + verify, +} from "../scripts/verify-release-changelog-date.ts"; + +const FLAGGED = `pm-changelog --pm-root .agents/pm --release-version-from-package ${DATE_FLAG}`; + +test("every version-input spelling is recognised, because pm-changelog accepts all three", () => { + for (const input of VERSION_INPUTS) { + const found = invocationsIn({ file: "package.json", text: `pm-changelog ${input} v2026.1.2` }); + assert.equal(found.length, 1, `${input} must be recognised as a version input`); + } +}); + +test("an invocation naming the pending tag is caught, not skipped", () => { + // The regression Greptile found: the scan matched only the package-derived + // spelling, so a workflow passing --version "$RELEASE_TAG" was invisible and + // its missing flag went unreported. + const result = auditInvocations([{ + file: ".github/workflows/release.yml", + text: 'pm-changelog --pm-root .agents/pm --all-release-tags --version "$RELEASE_TAG" --mode replace', + }]); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /carries a version input but not --date-from-version/); +}); + +test("a shared bash options array is expanded into each invocation that uses it", () => { + // Declared once so the invocations cannot drift, with the effect that the + // invocation line itself carries almost no flags. + const text = [ + 'common=(', + ' --pm-root .agents/pm', + ` --version "$RELEASE_TAG"`, + ` ${DATE_FLAG}`, + ')', + './node_modules/.bin/pm-changelog "${common[@]}" --mode replace', + ].join("\n"); + assert.deepEqual(auditInvocations([{ file: ".github/workflows/release.yml", text }]).failures, []); + + const withoutFlag = text.replace(` ${DATE_FLAG}\n`, ""); + assert.equal(auditInvocations([{ file: ".github/workflows/release.yml", text: withoutFlag }]).failures.length, 1); +}); + +test("an unknown array reference is left in place rather than erased", () => { + // Erasing it would turn "this scan does not understand the command" into + // "this command carries no flags", which reads as a pass. + assert.equal(expandArrays('cmd "${missing[@]}"', new Map()), 'cmd "${missing[@]}"'); + assert.equal(expandArrays('cmd "${known[@]}"', new Map([["known", "--a --b"]])), "cmd --a --b"); +}); + +test("bashArrays collapses whitespace so a multi-line declaration is one flag string", () => { + assert.equal(bashArrays("common=(\n --a\n --b\n)").get("common"), "--a --b"); +}); + +test("a backslash continuation is one logical command, not fragments", () => { + const text = `pm-changelog \\\n --release-version-from-package \\\n ${DATE_FLAG}`; + assert.equal(joinContinuations(text).split("\n").length, 1); + assert.deepEqual(auditInvocations([{ file: "package.json", text }]).failures, []); +}); + +test("one line holding two invocations is judged per invocation, spaced or not", () => { + const unflagged = "pm-changelog --pm-root . --release-version-from-package"; + for (const separator of [" && ", "&&", " || ", "||", "; ", ";"]) { + const result = auditInvocations([{ file: "package.json", text: `${FLAGGED}${separator}${unflagged}` }]); + assert.equal( + result.failures.length, + 1, + `the unflagged half must be reported across ${JSON.stringify(separator)}`, + ); + } +}); + +test("an unspaced pipe is treated as part of an argument, not as a separator", () => { + // Splitting there would separate a version input from its own flag and + // report a defect that is not present. + const piped = `pm-changelog --release-version-from-package ${DATE_FLAG} --tag-pattern "^v(2026|2027)"`; + assert.deepEqual(auditInvocations([{ file: "package.json", text: piped }]).failures, []); +}); + +test("a mention of the flag on a non-invoking line cannot cover for an unflagged invocation", () => { + const result = auditInvocations([{ + file: "package.json", + text: `pm-changelog --release-version-from-package\necho "the date comes from ${DATE_FLAG}"`, + }]); + assert.equal(result.failures.length, 1); +}); + +test("a mention inside a comment is not an invocation", () => { + const result = auditInvocations([ + { file: ".github/workflows/release.yml", text: " # pm-changelog --release-version-from-package explains the flag" }, + { file: "package.json", text: FLAGGED }, + ]); + assert.deepEqual(result.failures, []); + assert.equal(result.notes.length, 1, "the comment-only file must not be reported as verified"); +}); + +test("finding no invocation anywhere is a failure, not a silent pass", () => { + const result = auditInvocations([{ file: "package.json", text: "{}" }]); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /looking in the wrong place/); +}); + +test("the generator is resolved from node_modules, then dist, then the registry", () => { + const root = mkdtempSync(join(tmpdir(), "verify-generator-")); + try { + // Nothing to find: fall back to the registry rather than silently doing + // nothing, so the failure names a missing generator. + assert.deepEqual(resolveGenerator(root), { bin: "npx", lead: ["pm-changelog"] }); + + // pm-changelog's own repository builds the generator it verifies with, so + // the built entry point is used when there is no installed dependency. + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "dist/cli.js"), ""); + assert.deepEqual(resolveGenerator(root), { bin: process.execPath, lead: [join(root, "dist/cli.js")] }); + + // An installed dependency wins: it is the version the package actually runs. + mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); + writeFileSync(join(root, "node_modules/.bin/pm-changelog"), ""); + assert.deepEqual(resolveGenerator(root), { bin: join(root, "node_modules/.bin/pm-changelog"), lead: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a shell segment that runs something else is not judged as a generator invocation", () => { + // The release workflow pipes the generator's output through awk and sed; only + // the segment that runs the generator carries the contract this gate audits. + const text = `pm-changelog --release-version-from-package ${DATE_FLAG} --stdout | awk '/^## /' | sed -n '1p'`; + assert.deepEqual(auditInvocations([{ file: ".github/workflows/release.yml", text }]).failures, []); + assert.equal(auditInvocations([{ file: ".github/workflows/release.yml", text }]).notes.length, 1); +}); + +test("the behavioural half derives a suffixed same-day release from its CalVer base", () => { + const suffixed = auditHeadings("2026.1.2-1", "2026-08-27", (flagged) => + ({ ok: true, text: flagged ? "## 2026.1.2-1 - 2026-01-02" : "## 2026.1.2-1 - 2026-08-27" })); + assert.deepEqual(suffixed.failures, []); + assert.match(suffixed.notes[0]!, /2026\.1\.2-1 - 2026-01-02/); + + const good = auditHeadings("2026.1.2", "2026-08-27", (flagged) => + ({ ok: true, text: flagged ? "## 2026.1.2 - 2026-01-02" : "## 2026.1.2 - 2026-08-27" })); + assert.deepEqual(good.failures, []); + assert.equal(good.notes.length, 2); +}); + +test("a control that fails is a failure, not a note", () => { + // Previously `|| true` swallowed this and the script exited 0 having never + // made the comparison that gives it meaning. + const result = auditHeadings("2026.1.2", "2026-08-27", (flagged) => + flagged ? { ok: true, text: "## 2026.1.2 - 2026-01-02" } : { ok: false, text: "the run failed: exit 2" }); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /so the comparison proves nothing/); +}); + +test("a control identical to the flagged run fails, because then the flag discriminates nothing", () => { + const result = auditHeadings("2026.1.2", "2026-08-27", () => ({ ok: true, text: "## 2026.1.2 - 2026-01-02" })); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /the flag is changing nothing/); +}); + +test("a control derived some other way still passes, because the contract is that the flag changes the heading", () => { + // Deliberately NOT today's date. Asserting the control equals today would pin + // the generator's current default -- a compatible dependency update that + // changed it would fail this gate with no defect -- and today's date is + // sampled once for two subprocess runs, so a run crossing UTC midnight would + // fail for no defect either. + const result = auditHeadings("2026.1.2", "2026-08-27", (flagged) => + ({ ok: true, text: flagged ? "## 2026.1.2 - 2026-01-02" : "## 2026.1.2 - 2030-12-31" })); + assert.deepEqual(result.failures, []); + assert.match(result.notes[1], /derived some other way/); +}); + +test("a flagged run that fails, or that derives the wrong date, both fail", () => { + const failed = auditHeadings("2026.1.2", "2026-08-27", (flagged) => + flagged ? { ok: false, text: "the run failed: exit 1" } : { ok: true, text: "## 2026.1.2 - 2026-08-27" }); + assert.equal(failed.failures.length, 1); + + const wrong = auditHeadings("2026.1.2", "2026-08-27", (flagged) => + ({ ok: true, text: flagged ? "## 2026.1.2 - 2020-01-02" : "## 2026.1.2 - 2026-08-27" })); + assert.equal(wrong.failures.length, 1); + assert.match(wrong.failures[0], /expected '## 2026\.1\.2 - 2026-01-02'/); +}); + +test("verify runs both halves against this checkout and reports through the process streams", () => { + const result = verify(resolve(import.meta.dirname, ".."), new Date().toISOString().slice(0, 10)); + assert.deepEqual(result.failures, [], result.failures.join("\n")); + const previous = process.exitCode; + report(result); + assert.equal(process.exitCode, 0); + report({ failures: ["a failure"], notes: [] }); + assert.equal(process.exitCode, 1); + process.exitCode = previous; +}); + +test("a generator that cannot run is reported as a failed run, not as a missing heading", () => { + const failed = generateHeading(process.execPath, ["-e", "process.exit(2)"], import.meta.dirname); + assert.equal(failed.ok, false); + assert.match(failed.text, /^the run failed: /); + + const silent = generateHeading(process.execPath, ["-e", "console.log('no heading here')"], import.meta.dirname); + assert.equal(silent.ok, false); + assert.match(silent.text, /produced no '## ' heading/); + + const good = generateHeading(process.execPath, ["-e", "console.log('## 1.2.3 - 2026-01-02')"], import.meta.dirname); + assert.deepEqual(good, { ok: true, text: "## 1.2.3 - 2026-01-02" }); +}); + +test("the main-invocation guard answers both ways", () => { + const self = fileURLToPath(import.meta.resolve("../scripts/verify-release-changelog-date.ts")); + const url = import.meta.resolve("../scripts/verify-release-changelog-date.ts"); + assert.equal(isMainInvocation(["node", self], url), true); + assert.equal(isMainInvocation(["node", fileURLToPath(import.meta.url)], url), false); + assert.equal(isMainInvocation(["node"], url), false); +}); + +test("runIfMain runs only when argv names this module, and refuses otherwise", () => { + const url = import.meta.resolve("../scripts/verify-release-changelog-date.ts"); + assert.equal(runIfMain(["node", fileURLToPath(import.meta.url)], url, ".", "2026-01-01"), false, + "a process entered through some other script must not run the verifier"); +}); + +test("main verifies this checkout and reports through the process streams", () => { + const savedExit = process.exitCode; + const out = process.stdout.write; + const err = process.stderr.write; + const written: string[] = []; + process.stdout.write = ((chunk: string) => { written.push(chunk); return true; }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string) => { written.push(chunk); return true; }) as typeof process.stderr.write; + let ran = false; + try { + ran = runIfMain( + ["node", fileURLToPath(import.meta.resolve("../scripts/verify-release-changelog-date.ts"))], + import.meta.resolve("../scripts/verify-release-changelog-date.ts"), + resolve(import.meta.dirname, ".."), + new Date().toISOString().slice(0, 10), + ); + } finally { + process.stdout.write = out; + process.stderr.write = err; + } + assert.equal(ran, true, "argv naming this module must run the verifier"); + assert.equal(process.exitCode, 0, written.join("")); + assert.ok(written.some((line) => line.startsWith("ok - ")), "main must report what it checked"); + process.exitCode = savedExit; +}); + +test("an unquoted trailing comment cannot supply the flag the command is missing", () => { + // A substring check cannot tell a comment from an argument, so a comment + // mentioning the flag satisfies the very check it complains about. + const commented = `pm-changelog --release-version-from-package --stdout # ${DATE_FLAG} belongs here`; + const result = auditInvocations([{ file: "package.json", text: commented }]); + assert.equal(result.failures.length, 1, "the comment must not stand in for the flag"); + + // A `#` inside quotes is an argument: these invocations pass URLs with one. + const quoted = `pm-changelog --release-version-from-package ${DATE_FLAG} --item-url-base "https://example.test/#anchor"`; + assert.deepEqual(auditInvocations([{ file: "package.json", text: quoted }]).failures, []); + + const singleQuotedBackslash = `pm-changelog --release-version-from-package --item-url-base 'https://example.test/\\' # ${DATE_FLAG}`; + assert.equal(auditInvocations([{ file: "package.json", text: singleQuotedBackslash }]).failures.length, 1, + "a literal backslash inside single quotes must not escape the closing quote or comment"); + + assert.equal(stripComment('cmd --flag "a # b" # trailing'), 'cmd --flag "a # b" '); + assert.equal(stripComment("cmd --flag 'a # b'"), "cmd --flag 'a # b'"); + assert.equal(stripComment("cmd --flag\\# not-a-comment"), "cmd --flag\\# not-a-comment"); + assert.equal(stripComment("#leading"), ""); +});