fix(agents): parse JSONC and constrain package exemptions

This commit is contained in:
4gray committed 2026-09-20 21:23:30 +02:00
1 parent 231ec0a513
commit 73e98bd00b
4 files changed
+71 -13

No files matched your search

+4 -3
View File
@@ -53,12 +53,13 @@ as Markdown links, including decoded attributes and fragment validation.
Image references check file existence without interpreting image fragments as
Markdown headings; document links keep anchor checks even when sharing a target.
Inline guidance imports are rejected after punctuation as well as whitespace.
Scopes declared by package dependencies or TypeScript path aliases are recognized
as package/alias mentions rather than additional file imports.
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.
The import scan includes visible HTML text and excludes code and non-rendered containers.
Image source sets use `parse-srcset` to check each candidate URL. Root-relative
literals never suppress source-relative definition checks.
The Nx test hash includes `marked`, `parse5`, `github-slugger` and `parse-srcset`
The Nx test hash includes `marked`, `parse5`, `github-slugger`, `parse-srcset` and `typescript`
so dependency changes invalidate parser coverage.
It cannot prove semantic equivalence; review changed contracts as well.
+2 -1
View File
@@ -16,7 +16,8 @@
"marked",
"parse5",
"github-slugger",
"parse-srcset"
"parse-srcset",
"typescript"
]
}
],
+35 -9
View File
@@ -1,3 +1,4 @@
import ts from 'typescript';
import { readFile, realpath } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -67,10 +68,20 @@ async function validateReference(
}
}
async function packageScopes(rootDir) {
async function packageMentions(rootDir) {
async function readJson(path) {
try {
return JSON.parse(await readFile(resolve(rootDir, path), 'utf8'));
const text = await readFile(resolve(rootDir, path), 'utf8');
if (path !== 'tsconfig.base.json') return JSON.parse(text);
const parsed = ts.parseConfigFileTextToJson(path, text);
if (parsed.error)
throw new Error(
ts.flattenDiagnosticMessageText(
parsed.error.messageText,
'\n'
)
);
return parsed.config;
} catch (error) {
if (error.code === 'ENOENT') return {};
throw error;
@@ -85,17 +96,32 @@ async function packageScopes(rootDir) {
...Object.keys(manifest.peerDependencies ?? {}),
...Object.keys(config.compilerOptions?.paths ?? {}),
];
return new Set(
names
.filter((name) => /^@[^/]+\//u.test(name))
.map((name) => name.slice(1, name.indexOf('/')))
);
const declared = names
.filter((name) => /^@[^/]+\//u.test(name))
.map((name) => name.slice(1));
const scopes = new Set(declared.map((name) => name.split('/')[0]));
return (raw) => {
const token = raw.replace(/[.,;:)"'\]}]+$/u, '');
if (
token.split(/[\/\\]/u).some((part) => part === '.' || part === '..')
)
return false;
if (/\.(?:md|mdx|txt|json|ya?ml|html?)$/iu.test(token)) return false;
if (token.endsWith('/*') && scopes.has(token.slice(0, -2))) return true;
return declared.some((name) => {
const star = name.indexOf('*');
return star < 0
? token === name
: token.startsWith(name.slice(0, star)) &&
token.endsWith(name.slice(star + 1));
});
};
}
export async function validateAgentGuidance({ rootDir }) {
rootDir = await realpath(rootDir);
const diagnostics = [];
const scopes = await packageScopes(rootDir);
const isPackageMention = await packageMentions(rootDir);
for (const source of SURFACES) {
let markdown;
try {
@@ -129,7 +155,7 @@ export async function validateAgentGuidance({ rootDir }) {
.map((match) => match[1])
.filter(
(token) =>
!scopes.has(token.split('/')[0]) &&
!isPackageMention(token) &&
(/[./\\]/u.test(token) ||
/^(?:LICENSE|Makefile|Dockerfile|AGENTS|CLAUDE)(?:$|[.,;)])/u.test(
token
@@ -722,3 +722,33 @@ test('declared scoped dependencies and aliases are not inline imports', async (t
[]
);
});
test('TypeScript scope discovery supports JSONC', async (t) => {
assert.deepEqual(
await diagnostics(t, {
'tsconfig.base.json':
'{ // comment\n "compilerOptions": { "paths": { "@custom/*": ["libs/*"], }, }, }',
'AGENTS.md': 'Use @custom/services.',
}),
[]
);
});
for (const target of [
'angular/../docs/example.md',
'angular/missing.md',
'angular/undeclared',
]) {
test(`declared scopes do not exempt undeclared imports: ${target}`, async (t) => {
assert.match(
(
await diagnostics(t, {
'package.json': JSON.stringify({
dependencies: { '@angular/core': '*' },
}),
'CLAUDE.md': '@AGENTS.md\n\nRead @' + target,
})
).join('\n'),
/imports are not allowed/
);
});
}