diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 526f61b07..f6986ae53 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -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. diff --git a/tools/skills/project.json b/tools/skills/project.json index ea6576258..d752a2798 100644 --- a/tools/skills/project.json +++ b/tools/skills/project.json @@ -16,7 +16,8 @@ "marked", "parse5", "github-slugger", - "parse-srcset" + "parse-srcset", + "typescript" ] } ], diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index 16c63dad4..bf0cd2c45 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -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 diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 6dfeea6c0..65aa86f3e 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -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/ + ); + }); +}