Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/detector-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
conorbronsdon marked this conversation as resolved.
# 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.
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ 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).
- 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).

Expand Down
5 changes: 4 additions & 1 deletion detector/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,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
// 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)) {
Expand Down
18 changes: 18 additions & 0 deletions detector/validate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,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) {
Expand All @@ -247,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.';
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/rewrite-eval-opencode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Comment thread
conorbronsdon marked this conversation as resolved.
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`);
Expand Down
92 changes: 83 additions & 9 deletions scripts/verify-release-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/)) {
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
Expand All @@ -199,6 +272,7 @@ module.exports = {
SEMVER_RE,
readChangelogVersion,
readPackageVersion,
readSkillVersion,
verifyVersionChanged,
verifyReleaseVersions,
};
Expand Down
111 changes: 110 additions & 1 deletion scripts/verify-release-versions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
5 changes: 4 additions & 1 deletion skills/avoid-ai-writing/detector/validate.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion skills/preservation-verifier/scripts/validate.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading