From ff15a57804d373f04ef1d9e7b75a6ef99c3c5931 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:25:15 -0700 Subject: [PATCH 1/2] nightly: fix tracker stripping, export spawn errors, release version gap - validate: #305 stopped stripping an AI tracker that sits directly before bold markers, an em/en dash, or an ellipsis, reporting url-missing (e.g. **https://x.com/p?utm_source=chatgpt.com** -> **https://x.com/p**). - rewrite-eval-opencode: an `opencode export` spawn failure threw "Cannot read properties of undefined (reading 'trim')" and hid the real error; route it through requireCommand like the other calls. - detector CI: since #306 releases trigger only on package.json changes, so a release PR that forgets package.json merged green and never released. Run verify-release-versions.js on every PR. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/detector-test.yml | 4 ++++ CHANGELOG.md | 2 +- detector/validate.js | 5 ++++- detector/validate.test.js | 3 +++ .../skills/avoid-ai-writing/detector/validate.js | 5 ++++- scripts/rewrite-eval-opencode.js | 2 +- skills/avoid-ai-writing/detector/validate.js | 5 ++++- skills/preservation-verifier/scripts/validate.js | 5 ++++- 8 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/detector-test.yml b/.github/workflows/detector-test.yml index 13684ab..5898ece 100644 --- a/.github/workflows/detector-test.yml +++ b/.github/workflows/detector-test.yml @@ -48,6 +48,10 @@ jobs: with: node-version: "20" - run: npm test + # Release runs trigger only on package.json changes, so a release PR that + # bumps CHANGELOG.md without package.json would otherwise merge green and + # never release. Enforce the agreement on every PR instead. + - run: node scripts/verify-release-versions.js # Scores this repo's own docs with this repo's own detector. A document # that drifts past its budget in scripts/self-scan.js fails here. PROOF.md # explains what the numbers mean and what they do not claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index a846f4e..3f6c1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project are documented here. ### Fixed -- Preserve non-tracking query parameters when removing AI-referrer parameters from URLs during rewrite validation (#210). +- Preserve non-tracking query parameters when removing AI-referrer parameters from URLs during rewrite validation (#210). Removing a tracker that sits directly before bold markers, a dash, or an ellipsis no longer reports the URL as altered. - Replace four superlinear Markdown scans reachable through the detector API with bounded or forward-only parsing. Validate corpus cache IDs, stage and retry cache replacements, isolate CLI-test files in private temporary directories, and require push-triggered releases to prove the package version changed. ## [3.35.0] — 2026-09-13 diff --git a/detector/validate.js b/detector/validate.js index 62f9af3..7d99588 100644 --- a/detector/validate.js +++ b/detector/validate.js @@ -87,7 +87,10 @@ const AIDetectorValidate = (() => { // Bare-URL extraction includes adjacent sentence punctuation. Treat it as // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { - const punctuation = query.match(/[.,;:!?]+$/)?.[0] || ''; + // The extractor also keeps emphasis markers, and a dash or ellipsis plus + // whatever prose follows it without a space; neither can end an exact + // tracker value. + const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { diff --git a/detector/validate.test.js b/detector/validate.test.js index 848d352..b821401 100644 --- a/detector/validate.test.js +++ b/detector/validate.test.js @@ -193,6 +193,9 @@ test('stripping a terminal AI tracker preserves adjacent sentence punctuation', ['https://example.com/post?referrer=grok.com,', 'https://example.com/post,'], ['https://example.com/post?utm_source=chatgpt.com?', 'https://example.com/post?'], ['https://example.com/post?utm_source=chatgpt.com!?', 'https://example.com/post!?'], + ['**https://example.com/post?utm_source=chatgpt.com**', '**https://example.com/post**'], + ['https://example.com/post?utm_source=chatgpt.com—it', 'https://example.com/post—it'], + ['https://example.com/post?a=1&utm_source=chatgpt.com…', 'https://example.com/post?a=1…'], ]; for (const [beforeUrl, afterUrl] of cases) { diff --git a/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js b/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js index 62f9af3..7d99588 100644 --- a/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js +++ b/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js @@ -87,7 +87,10 @@ const AIDetectorValidate = (() => { // Bare-URL extraction includes adjacent sentence punctuation. Treat it as // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { - const punctuation = query.match(/[.,;:!?]+$/)?.[0] || ''; + // The extractor also keeps emphasis markers, and a dash or ellipsis plus + // whatever prose follows it without a space; neither can end an exact + // tracker value. + const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { diff --git a/scripts/rewrite-eval-opencode.js b/scripts/rewrite-eval-opencode.js index 6801f02..2120a6a 100644 --- a/scripts/rewrite-eval-opencode.js +++ b/scripts/rewrite-eval-opencode.js @@ -574,7 +574,7 @@ function runTask(context, task) { const sessions = [...new Set(events.map((event) => event.sessionID).filter(Boolean))]; assert.equal(sessions.length, 1, `${task.id}: expected one OpenCode session ID`); const exported = command(config.opencode_path, ['export', sessions[0], '--pure'], { env: taskEnv, cwd: taskDir, timeout: config.timeout_ms }); - assert.equal(exported.status, 0, `${task.id}: opencode export failed: ${exported.stderr.trim()}`); + requireCommand(exported, `${task.id}: opencode export failed`); writeExclusive(path.join(taskDir, 'session-export.json'), exported.stdout); const receipt = JSON.parse(exported.stdout); assert.equal(receipt.info?.version, config.opencode_version, `${task.id}: session export OpenCode version differs`); diff --git a/skills/avoid-ai-writing/detector/validate.js b/skills/avoid-ai-writing/detector/validate.js index 62f9af3..7d99588 100644 --- a/skills/avoid-ai-writing/detector/validate.js +++ b/skills/avoid-ai-writing/detector/validate.js @@ -87,7 +87,10 @@ const AIDetectorValidate = (() => { // Bare-URL extraction includes adjacent sentence punctuation. Treat it as // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { - const punctuation = query.match(/[.,;:!?]+$/)?.[0] || ''; + // The extractor also keeps emphasis markers, and a dash or ellipsis plus + // whatever prose follows it without a space; neither can end an exact + // tracker value. + const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { diff --git a/skills/preservation-verifier/scripts/validate.js b/skills/preservation-verifier/scripts/validate.js index 62f9af3..7d99588 100644 --- a/skills/preservation-verifier/scripts/validate.js +++ b/skills/preservation-verifier/scripts/validate.js @@ -87,7 +87,10 @@ const AIDetectorValidate = (() => { // Bare-URL extraction includes adjacent sentence punctuation. Treat it as // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { - const punctuation = query.match(/[.,;:!?]+$/)?.[0] || ''; + // The extractor also keeps emphasis markers, and a dash or ellipsis plus + // whatever prose follows it without a space; neither can end an exact + // tracker value. + const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { From a955b3f3386841aa51821f3d8bba21ad848e9bf1 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:48:45 -0700 Subject: [PATCH 2/2] Preserve URL query suffixes and validate all release versions Address nightly review findings with query-separator regressions, independent skill/plugin version drift controls, and the export-error changelog entry. --- CHANGELOG.md | 1 + detector/validate.js | 6 +- detector/validate.test.js | 15 +++ .../avoid-ai-writing/detector/validate.js | 6 +- scripts/verify-release-versions.js | 92 +++++++++++++-- scripts/verify-release-versions.test.js | 111 +++++++++++++++++- skills/avoid-ai-writing/detector/validate.js | 6 +- .../preservation-verifier/scripts/validate.js | 6 +- 8 files changed, 221 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e04be..5cd9a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project are documented here. ### Fixed +- Report the underlying OpenCode export launch error instead of a secondary `stderr.trim()` exception during rewrite evaluation. - Preserve non-tracking query parameters when removing AI-referrer parameters from URLs during rewrite validation (#210). Removing a tracker that sits directly before bold markers, a dash, or an ellipsis no longer reports the URL as altered. - Replace four superlinear Markdown scans reachable through the detector API with bounded or forward-only parsing. Validate corpus cache IDs, stage and retry cache replacements, isolate CLI-test files in private temporary directories, and require push-triggered releases to prove the package version changed. - Replace the preservation validator's fenced-code regex with a line scanner that tracks the opening fence marker and run length, so a fence closes only on the same marker at equal or greater length per CommonMark. A `~~~` line inside a ``` block (the normal way to document Markdown fences) is content, and a three-backtick line inside a four-backtick fence no longer closes it. The same scanner replaces the marker-agnostic matcher in `scripts/self-scan.js` (#236). diff --git a/detector/validate.js b/detector/validate.js index aea3ce1..d845d88 100644 --- a/detector/validate.js +++ b/detector/validate.js @@ -144,9 +144,9 @@ const AIDetectorValidate = (() => { // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { // The extractor also keeps emphasis markers, and a dash or ellipsis plus - // whatever prose follows it without a space; neither can end an exact - // tracker value. - const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; + // prose follows it without a space. Query separators or escapes in that suffix + // keep it inside the URL, so functional fields cannot become prose. + const punctuation = query.match(/(?:[–—…][^&=%]*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { diff --git a/detector/validate.test.js b/detector/validate.test.js index b0532e9..64da96b 100644 --- a/detector/validate.test.js +++ b/detector/validate.test.js @@ -250,6 +250,21 @@ test('stripping a terminal AI tracker preserves adjacent sentence punctuation', } }); +test('dash and ellipsis suffixes cannot move query data into prose', () => { + for (const marker of ['–', '—', '…']) { + for (const tail of ['foo&keep=1', 'foo=1', 'foo&keep', 'foo%26keep%3D1']) { + const before = `See https://example.com/post?utm_source=chatgpt.com${marker}${tail}`; + const after = `See https://example.com/post${marker}${tail}`; + const r = validate(before, after, { skipResidual: true }); + assert.ok(codes(r).includes('url-missing'), `${marker}${tail}: ${formatResult(r)}`); + } + } + for (const query of ['?ref=home—it', '?utm_source=chatgpt.com.au—x']) { + const r = validate(`See https://example.com/post${query}`, 'See https://example.com/post', { skipResidual: true }); + assert.ok(codes(r).includes('url-missing'), formatResult(r)); + } +}); + test('removing a terminal question mark from a URL is still an error', () => { const before = 'See https://example.com/post? for details.'; const after = 'See https://example.com/post for details.'; diff --git a/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js b/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js index aea3ce1..d845d88 100644 --- a/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js +++ b/plugins/avoid-ai-writing/skills/avoid-ai-writing/detector/validate.js @@ -144,9 +144,9 @@ const AIDetectorValidate = (() => { // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { // The extractor also keeps emphasis markers, and a dash or ellipsis plus - // whatever prose follows it without a space; neither can end an exact - // tracker value. - const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; + // prose follows it without a space. Query separators or escapes in that suffix + // keep it inside the URL, so functional fields cannot become prose. + const punctuation = query.match(/(?:[–—…][^&=%]*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { diff --git a/scripts/verify-release-versions.js b/scripts/verify-release-versions.js index 506c697..4578ee3 100755 --- a/scripts/verify-release-versions.js +++ b/scripts/verify-release-versions.js @@ -11,6 +11,21 @@ const CHANGELOG_HEADING_RE = new RegExp( ); const CHANGELOG_SECTION_RE = /^##\s+(.+)$/; const UNRELEASED_HEADING_RE = /^## \[Unreleased\](?:\s|$)/; +const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +const VERSIONED_FILES = [ + { path: 'SKILL.md', label: 'SKILL.md', kind: 'skill' }, + { + path: path.join('plugins', 'avoid-ai-writing', '.claude-plugin', 'plugin.json'), + label: 'Claude plugin manifest', + kind: 'json', + }, + { + path: path.join('.codex-plugin', 'plugin.json'), + label: 'OpenAI plugin manifest', + kind: 'json', + }, +]; function readChangelogVersion(changelogText) { for (const line of changelogText.split(/\r?\n/)) { @@ -21,20 +36,48 @@ function readChangelogVersion(changelogText) { return null; } -function readPackageVersion(packageJsonText) { +function readJsonVersion(jsonText, label) { let parsed; try { - parsed = JSON.parse(packageJsonText); + parsed = JSON.parse(jsonText); } catch { - return { error: 'package.json is not valid JSON' }; + return { error: `${label} is not valid JSON` }; } const version = parsed && parsed.version; if (typeof version !== 'string' || version.length === 0) { - return { error: 'package.json is missing a string "version" field' }; + return { error: `${label} is missing a string "version" field` }; + } + if (!SEMVER_RE.test(version)) { + return { error: `${label} version (${version}) is not a numeric X.Y.Z semver` }; } + return { version }; +} + +function readPackageVersion(packageJsonText) { // Releases use numeric X.Y.Z tags only; prereleases intentionally fail closed. + return readJsonVersion(packageJsonText, 'package.json'); +} + +function readSkillVersion(skillText) { + const frontmatter = skillText.match(SKILL_FRONTMATTER_RE); + if (!frontmatter) { + return { error: 'SKILL.md is missing valid YAML frontmatter' }; + } + + const versionLines = frontmatter[1] + .split(/\r?\n/) + .filter((line) => /^version\s*:/.test(line)); + if (versionLines.length !== 1) { + return { error: 'SKILL.md frontmatter must contain exactly one top-level version field' }; + } + + const match = versionLines[0].match(/^version\s*:\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))\s*(?:#.*)?$/); + const version = match && (match[1] ?? match[2] ?? match[3]); + if (!version) { + return { error: 'SKILL.md is missing a string "version" field' }; + } if (!SEMVER_RE.test(version)) { - return { error: `package.json version (${version}) is not a numeric X.Y.Z semver` }; + return { error: `SKILL.md version (${version}) is not a numeric X.Y.Z semver` }; } return { version }; } @@ -72,8 +115,8 @@ function verifyVersionChanged(previousPackageJsonText, currentVersion) { } /** - * @param {string} root Repository root containing CHANGELOG.md and package.json - * @returns {{ ok: true, changelogVersion: string, packageVersion: string } | { ok: false, message: string }} + * @param {string} root Repository root containing the release version files + * @returns {{ ok: true, changelogVersion: string, packageVersion: string, skillVersion: string, claudePluginVersion: string, openaiPluginVersion: string } | { ok: false, message: string }} */ function verifyReleaseVersions(root) { const changelogPath = path.join(root, 'CHANGELOG.md'); @@ -121,7 +164,37 @@ function verifyReleaseVersions(root) { }; } - return { ok: true, changelogVersion, packageVersion: pkg.version }; + const versions = {}; + for (const file of VERSIONED_FILES) { + let text; + try { + text = fs.readFileSync(path.join(root, file.path), 'utf8'); + } catch { + return { ok: false, message: `Could not read ${file.label}` }; + } + const parsed = file.kind === 'skill' + ? readSkillVersion(text) + : readJsonVersion(text, file.label); + if (parsed.error) return { ok: false, message: parsed.error }; + if (parsed.version !== pkg.version) { + return { + ok: false, + message: + `${file.label} (${parsed.version}) != package.json and CHANGELOG.md (${pkg.version}) — ` + + 'fix the drift before publishing or tagging.', + }; + } + versions[file.label] = parsed.version; + } + + return { + ok: true, + changelogVersion, + packageVersion: pkg.version, + skillVersion: versions['SKILL.md'], + claudePluginVersion: versions['Claude plugin manifest'], + openaiPluginVersion: versions['OpenAI plugin manifest'], + }; } function formatGithubError(message) { @@ -186,7 +259,7 @@ function main(argv) { } process.stdout.write( - `package.json and CHANGELOG.md agree on ${result.changelogVersion}\n`, + `Release version files agree on ${result.changelogVersion}\n`, ); if (githubOutput) { fs.appendFileSync(githubOutput, `version=${result.changelogVersion}\n`, 'utf8'); @@ -199,6 +272,7 @@ module.exports = { SEMVER_RE, readChangelogVersion, readPackageVersion, + readSkillVersion, verifyVersionChanged, verifyReleaseVersions, }; diff --git a/scripts/verify-release-versions.test.js b/scripts/verify-release-versions.test.js index 7436ce0..e59ad05 100644 --- a/scripts/verify-release-versions.test.js +++ b/scripts/verify-release-versions.test.js @@ -26,12 +26,46 @@ function fixtureRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), 'avoid-ai-writing-release-versions-')); } -function writeFixture(root, { changelog, packageVersion, packageJson }) { +function writeFixture(root, { + changelog, + packageVersion, + packageJson, + skillVersion, + skillText, + claudePluginVersion, + claudePluginJson, + openaiPluginVersion, + openaiPluginJson, +}) { + const defaultVersion = packageVersion ?? packageJson?.version ?? '1.0.0'; fs.writeFileSync( path.join(root, 'CHANGELOG.md'), changelog, 'utf8', ); + fs.writeFileSync( + path.join(root, 'SKILL.md'), + skillText === undefined ? `---\nname: fixture\nversion: ${skillVersion ?? defaultVersion}\n---\n` : skillText, + 'utf8', + ); + const claudeManifest = path.join(root, 'plugins', 'avoid-ai-writing', '.claude-plugin', 'plugin.json'); + const openaiManifest = path.join(root, '.codex-plugin', 'plugin.json'); + fs.mkdirSync(path.dirname(claudeManifest), { recursive: true }); + fs.mkdirSync(path.dirname(openaiManifest), { recursive: true }); + fs.writeFileSync( + claudeManifest, + claudePluginJson === undefined + ? JSON.stringify({ name: 'fixture', version: claudePluginVersion ?? defaultVersion }, null, 2) + '\n' + : claudePluginJson, + 'utf8', + ); + fs.writeFileSync( + openaiManifest, + openaiPluginJson === undefined + ? JSON.stringify({ name: 'fixture', version: openaiPluginVersion ?? defaultVersion }, null, 2) + '\n' + : openaiPluginJson, + 'utf8', + ); fs.writeFileSync( path.join(root, 'package.json'), JSON.stringify(packageJson || { name: 'fixture', version: packageVersion }, null, 2) + '\n', @@ -82,6 +116,9 @@ t('verifyReleaseVersions accepts matching package.json and changelog versions', assert.strictEqual(result.ok, true); assert.strictEqual(result.changelogVersion, '1.2.3'); assert.strictEqual(result.packageVersion, '1.2.3'); + assert.strictEqual(result.skillVersion, '1.2.3'); + assert.strictEqual(result.claudePluginVersion, '1.2.3'); + assert.strictEqual(result.openaiPluginVersion, '1.2.3'); }); t('verifyVersionChanged accepts a new version and rejects unchanged recovery pushes', () => { @@ -127,7 +164,79 @@ t('verifyReleaseVersions accepts an Unreleased-only documentation edit above the ok: true, changelogVersion: '3.34.0', packageVersion: '3.34.0', + skillVersion: '3.34.0', + claudePluginVersion: '3.34.0', + openaiPluginVersion: '3.34.0', + }); +}); + +t('verifyReleaseVersions rejects each stale skill or plugin version', () => { + const cases = [ + [{ skillVersion: '1.2.2' }, /SKILL\.md \(1\.2\.2\)/], + [{ claudePluginVersion: '1.2.2' }, /Claude plugin manifest \(1\.2\.2\)/], + [{ openaiPluginVersion: '1.2.2' }, /OpenAI plugin manifest \(1\.2\.2\)/], + ]; + for (const [override, expected] of cases) { + const root = fixtureRoot(); + writeFixture(root, { + changelog: '## [1.2.3]\n', + packageVersion: '1.2.3', + ...override, + }); + const result = verifyReleaseVersions(root); + assert.strictEqual(result.ok, false); + assert.match(result.message, expected); + } +}); + +t('verifyReleaseVersions rejects missing versioned skill and plugin files', () => { + const paths = [ + ['SKILL.md', /Could not read SKILL\.md/], + [path.join('plugins', 'avoid-ai-writing', '.claude-plugin', 'plugin.json'), /Could not read Claude plugin manifest/], + [path.join('.codex-plugin', 'plugin.json'), /Could not read OpenAI plugin manifest/], + ]; + for (const [missing, expected] of paths) { + const root = fixtureRoot(); + writeFixture(root, { changelog: '## [1.2.3]\n', packageVersion: '1.2.3' }); + fs.rmSync(path.join(root, missing)); + const result = verifyReleaseVersions(root); + assert.strictEqual(result.ok, false); + assert.match(result.message, expected); + } +}); + +t('verifyReleaseVersions rejects malformed skill and plugin version sources', () => { + const cases = [ + [{ skillText: '# no frontmatter\n' }, /SKILL\.md is missing valid YAML frontmatter/], + [{ skillText: '---\nname: fixture\n---\n' }, /exactly one top-level version field/], + [{ skillText: '---\nversion: 1.2.3\nversion: 1.2.3\n---\n' }, /exactly one top-level version field/], + [{ skillText: '---\nversion: 1.2\n---\n' }, /SKILL\.md version \(1\.2\).*numeric X\.Y\.Z/], + [{ claudePluginJson: '{bad json' }, /Claude plugin manifest is not valid JSON/], + [{ claudePluginJson: '{"name":"fixture"}' }, /Claude plugin manifest is missing a string "version" field/], + [{ openaiPluginJson: '{bad json' }, /OpenAI plugin manifest is not valid JSON/], + [{ openaiPluginJson: '{"version":"v1.2.3"}' }, /OpenAI plugin manifest version \(v1\.2\.3\).*numeric X\.Y\.Z/], + ]; + for (const [override, expected] of cases) { + const root = fixtureRoot(); + writeFixture(root, { + changelog: '## [1.2.3]\n', + packageVersion: '1.2.3', + ...override, + }); + const result = verifyReleaseVersions(root); + assert.strictEqual(result.ok, false); + assert.match(result.message, expected); + } +}); + +t('verifyReleaseVersions accepts a quoted skill version in CRLF frontmatter', () => { + const root = fixtureRoot(); + writeFixture(root, { + changelog: '## [1.2.3]\r\n', + packageVersion: '1.2.3', + skillText: '---\r\nname: fixture\r\nversion: "1.2.3"\r\n---\r\n', }); + assert.strictEqual(verifyReleaseVersions(root).ok, true); }); t('verifyReleaseVersions accepts CRLF changelog headings', () => { diff --git a/skills/avoid-ai-writing/detector/validate.js b/skills/avoid-ai-writing/detector/validate.js index aea3ce1..d845d88 100644 --- a/skills/avoid-ai-writing/detector/validate.js +++ b/skills/avoid-ai-writing/detector/validate.js @@ -144,9 +144,9 @@ const AIDetectorValidate = (() => { // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { // The extractor also keeps emphasis markers, and a dash or ellipsis plus - // whatever prose follows it without a space; neither can end an exact - // tracker value. - const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; + // prose follows it without a space. Query separators or escapes in that suffix + // keep it inside the URL, so functional fields cannot become prose. + const punctuation = query.match(/(?:[–—…][^&=%]*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) { diff --git a/skills/preservation-verifier/scripts/validate.js b/skills/preservation-verifier/scripts/validate.js index aea3ce1..d845d88 100644 --- a/skills/preservation-verifier/scripts/validate.js +++ b/skills/preservation-verifier/scripts/validate.js @@ -144,9 +144,9 @@ const AIDetectorValidate = (() => { // prose only when removing it exposes an exact tracker in the final field. if (queryEnd === u.length) { // The extractor also keeps emphasis markers, and a dash or ellipsis plus - // whatever prose follows it without a space; neither can end an exact - // tracker value. - const punctuation = query.match(/(?:[–—…].*|[.,;:!?*_~|]+)$/)?.[0] || ''; + // prose follows it without a space. Query separators or escapes in that suffix + // keep it inside the URL, so functional fields cannot become prose. + const punctuation = query.match(/(?:[–—…][^&=%]*|[.,;:!?*_~|]+)$/)?.[0] || ''; const withoutPunctuation = query.slice(0, query.length - punctuation.length); const finalParam = withoutPunctuation.slice(withoutPunctuation.lastIndexOf('&') + 1); if (punctuation && AI_URL_PARAM.test(finalParam)) {