From 505fa356abf6f247b388af4ee44abe04e7469a5b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 23:35:50 +0200 Subject: [PATCH] fix(agents): validate extensionless imports and version comparators --- docs/development/agent-workflow.md | 3 +- tools/skills/validate-agent-guidance.mjs | 40 ++++++++++++------- tools/skills/validate-agent-guidance.test.mjs | 28 ++++++++++++- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 5682c0c85..887fe102e 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -62,11 +62,12 @@ HTML video, audio, source and track `src` assets and video posters use the same existence checks as images. Entity decoding uses full HTML text/attribute rules, including references whose semicolon may be omitted. Inline guidance imports are rejected after punctuation as well as whitespace. +Extensionless inline candidates are also imports when they resolve to repository files. Declared scoped dependencies, scope wildcards and matching TypeScript path aliases are recognized as package/alias mentions. Traversal and document-file imports are rejected before those exemptions. TypeScript configuration is parsed as JSONC. Declared packages also permit safe subpaths; exact aliases stay exact. -Declared package mentions may include a version or dist-tag qualifier. +Declared package mentions may include a version (including semver comparators) or dist-tag qualifier. Qualifier handling includes unscoped names; terminal sentence punctuation is removed before matching a declared package, as are straight/curly apostrophe possessives. Markdown destinations decode HTML entities before URI parsing, matching rendered links. diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index 1929170b2..7345e885a 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { readFile, realpath } from 'node:fs/promises'; +import { readFile, realpath, stat } from 'node:fs/promises'; import { dirname, extname, @@ -117,7 +117,7 @@ async function packageMentions(rootDir) { let token = raw.replace(/[?!.,;:)"'\]}]+$/u, ''); token = token.replace(/['’]s$/iu, ''); token = token.replace( - /^([^/@]+(?:\/[^/@]+)?)@(?:[~^]?\d[\w.+-]*|[a-z][\w-]*)$/iu, + /^([^/@]+(?:\/[^/@]+)?)@(?:(?:[~^]|[<>]=?|=)?\d[\w.+-]*|[a-z][\w-]*)$/iu, '$1' ); if ( @@ -170,18 +170,30 @@ export async function validateAgentGuidance({ rootDir }) { const unfenced = guidanceProse(markdown); const imports = guidanceStandaloneImports(markdown); const prose = unfenced.replace(/`[^`\n]+`/gu, ''); - const inlineImports = [ - ...prose.matchAll(/(?:^|[^\p{L}\p{N}_@])@([^\s]+)/gu), - ] - .map((match) => match[1]) - .filter( - (token) => - !isPackageMention(token) && - (/[./\\]/u.test(token) || - /^(?:LICENSE|Makefile|Dockerfile|AGENTS|CLAUDE)(?:$|[.,;)])/u.test( - token - )) - ); + const inlineImports = []; + for (const match of prose.matchAll( + /(?:^|[^\p{L}\p{N}_@])@([^\s]+)/gu + )) { + const token = match[1]; + if (isPackageMention(token)) continue; + if ( + /[./\\]/u.test(token) || + /^(?:LICENSE|Makefile|Dockerfile|AGENTS|CLAUDE)(?:$|[.,;)])/u.test( + token + ) + ) { + inlineImports.push(token); + continue; + } + const candidate = token.replace(/[?!.,;:)"'\]}]+$/u, ''); + try { + if ((await stat(resolve(rootDir, candidate))).isFile()) + inlineImports.push(token); + } catch (error) { + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') + throw error; + } + } if ( inlineImports.some((token) => token !== 'AGENTS.md') || (source === 'AGENTS.md' && inlineImports.length) || diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 621dc80e9..3632d1926 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -812,7 +812,15 @@ for (const filename of [ }); } -for (const version of ['22.1.6', '^22.1.6', 'next']) { +for (const version of [ + '22.1.6', + '^22.1.6', + 'next', + '>=22.0.0', + '<=22.0.0', + '>22.0.0', + '<22.0.0', +]) { test(`declared package can include version ${version}`, async (t) => { assert.deepEqual( await diagnostics(t, { @@ -1005,3 +1013,21 @@ for (const suffix of ["'s", '’s']) { ); }); } + +for (const suffix of ['', '.']) { + test(`existing extensionless inline import is rejected: ${suffix}`, async (t) => { + const result = await diagnostics(t, { + 'CLAUDE.md': '@AGENTS.md\n\nRead @INSTRUCTIONS' + suffix, + INSTRUCTIONS: 'Additional guidance', + }); + assert.ok( + result.some((message) => message.includes('additional or inline')) + ); + }); +} +test('ordinary unknown handle is not a file import', async (t) => { + assert.deepEqual( + await diagnostics(t, { 'AGENTS.md': 'Ask @maintainer' }), + [] + ); +});