fix(agents): validate extensionless imports and version comparators

This commit is contained in:
4gray committed 2026-09-20 23:35:50 +02:00
1 parent cb42c74f43
commit 505fa356ab
3 files changed
+55 -16

No files matched your search

+2 -1
View File
@@ -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.
+26 -14
View File
@@ -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) ||
+27 -1
View File
@@ -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' }),
[]
);
});