Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **`ResourceMetadataSchema` is now `strict()`.** Shipped with first-class HTML support (#112): the resource-metadata schema rejects unknown top-level fields instead of silently accepting them, so a typo or stale field in code that constructs `ResourceMetadata` now fails at parse time rather than passing through. Move any extra data into a recognized field or drop it.
- **Resource ids now carry a file-extension suffix.** `generateIdFromPath` appends `-<ext>` to every resource id (e.g. `guide.md` → `guide-md`, `guide.html` → `guide-html`, `README.md` → `readme-md`). This makes a markdown file and a same-stem HTML file distinct resources instead of colliding — the prerequisite for first-class HTML resources sharing a directory with their markdown source. Resource ids are internal, path-derived identifiers (never hand-authored in config or frontmatter), but anything that referenced an id by its old bare form must use the suffixed form — most visibly `vat rag query --resource-id` filters and re-indexed chunk ids (re-index to regenerate).
- **Navigational directory links are now valid; `LINK_TARGETS_DIRECTORY` narrows to typed single-file slots ([#126](https://github.com/jdutton/vibe-agent-toolkit/issues/126)).** A markdown or HTML link whose target resolves to an existing directory (`[docs](docs/)`, `<a href="dir/">`) is now a valid navigational reference — existence-checked like any other local link, and a resolved directory passes. The previous behavior in `vat resources validate` (a misnamed `LINK_BROKEN_FILE` with message *"Link target is a directory"*) and in the skill-bundling link walk (an `error`-severity `LINK_TARGETS_DIRECTORY` for any directory-shaped prose link) is gone. `LINK_TARGETS_DIRECTORY` now fires only when a **typed single-file slot** — currently a packaging `files:` source entry — resolves to a directory; the contract demanded a file. A new `local_directory` value joins the `LinkType` enum and `classifyLink` returns it for local hrefs ending in `/`; this is a diagnostic hint, never load-bearing — `docs/` (slash, classified as `local_directory`) and `docs` (no slash, shape-ambiguous `local_file`) are validated identically. The directory rule is explicitly filesystem-only: external trailing-slash URLs (`https://example.com/docs/`) are never judged by it. GitHub-style index resolution (`docs/` → `docs/README.md`) is documented as deliberately out of scope — it is unnecessary once a directory is a valid target. **Migration:** `validation.allow` entries targeting `LINK_TARGETS_DIRECTORY` for navigational links (e.g. `[ToC](docs/sub/)`) no longer match any emitted issue and will surface as `ALLOW_UNUSED` warnings; remove them. `allow` entries against `files:` source paths still apply.
- **`vat resources validate` gains per-code severity configuration, and external-URL findings no longer fail the build by default.** Resource findings now use the same configurable severity framework as `vat skills`: each is a documented code (e.g. `LINK_BROKEN_FILE`, `EXTERNAL_URL_DEAD`) with a default severity, overridable per project under `resources.validation.severity` / `resources.validation.allow`. External-URL findings now default to `warning` and no longer flip the exit code (fixing a bug where they always failed the command); set their severity to `error` to restore failing. Severity now also accepts an `info` level. The never-implemented `resources.validation.checkLinks`/`checkAnchors`/`allowExternal` keys are removed.
- **`validation.severity` / `validation.allow` keys are validated against real codes.** A mistyped code key (e.g. `LNIK_OUTSIDE_PROJECT`) is now a config-load error instead of a silent no-op.
- **Corpus seed entries now require `bucket`, `confidence`, and `maturity` metadata fields.** `PluginEntrySchema` in `vat corpus scan`'s seed loader gains three required enum fields: `bucket: 'official' | 'community'`, `confidence: 'first-party' | 'curated' | 'listed'`, and `maturity: 'production' | 'experimental' | 'example'`. The bundled `corpus/seed.yaml` is updated; downstream callers running custom seeds must add the fields to every entry. `bucket` is the load-bearing discriminator (`official` entries report named findings; `community` entries are aggregate-only in follow-up work). The other two are descriptive metadata used by triage tooling.
Expand Down
8 changes: 5 additions & 3 deletions docs/validation-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ Static-analysis codes that fire anywhere markdown is analyzed — `vat resources
### `LINK_TARGETS_DIRECTORY`

- **Default:** `error`
- **What:** Markdown link resolves to a directory rather than a file.
- **Why it matters:** Directory links are ambiguous — agents and renderers cannot load a directory as content. The author almost certainly intended a specific file inside it.
- **Fix:** Point the link at a specific file (e.g. `README.md` inside the directory) instead of the directory itself.
- **What:** A **typed single-file slot** (e.g. a packaging `files:` source entry) resolves to a directory.
- **Why it matters:** A typed single-file slot is contractually one file. A directory cannot satisfy that contract — packagers cannot copy a directory into a `dest` path declared as a file.
- **Fix:** Point the slot at a specific file inside the directory, or remove the entry.
- **Not in scope:** Navigational markdown/HTML links (`[docs](docs/)`, `<a href="dir/">`). A directory is a valid navigational target: it is existence-checked like any other local link, and a resolved directory passes. The trailing slash is a diagnostic hint only — never a correctness gate. This rule is also filesystem-only: an external URL whose path component looks like a directory (`https://example.com/docs/`) is never judged by this code.
- **Not implemented (decision record, #126):** GitHub-style index resolution (`docs/` → `docs/README.md`) is intentionally not implemented. It is unnecessary once a directory is a valid navigational target.

### `LINK_TO_NAVIGATION_FILE`

Expand Down
4 changes: 2 additions & 2 deletions packages/agent-schema/src/validation-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export const CODE_REGISTRY = {
),
LINK_TARGETS_DIRECTORY: entry(
'error',
'Markdown link resolves to a directory rather than a file.',
'Point the link at a specific file (e.g. README.md inside the directory) instead of the directory itself.',
'A typed single-file slot (e.g. a packaging files: source entry) resolves to a directory.',
'Point the slot at a specific file inside the directory, or remove the entry. Navigational links to directories are valid and do not emit this code.',
'link_targets_directory',
),
LINK_TO_NAVIGATION_FILE: entry(
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-skills/src/skill-packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ export async function packageSkill(
addBundledAssetsToOutputRegistry(outputResources, bundledAssets, pathMap, registry, collidedAssets);
// Include excluded resources (with source paths) for pattern-based rule matching
for (const excl of excludedReferences) {
if (excl.excludeReason === 'directory-target' || excl.excludeReason === 'outside-project') {
if (excl.excludeReason === 'outside-project') {
continue;
}
const exclResource = (registry as WalkableRegistry).getResource(safePath.resolve(excl.path));
Expand All @@ -457,7 +457,7 @@ export async function packageSkill(
const bundledResourceIds = new Set(bundledResources.map(r => r.id));
const excludedIds = [...new Set(
excludedReferences
.filter(r => r.excludeReason !== 'directory-target' && r.excludeReason !== 'outside-project')
.filter(r => r.excludeReason !== 'outside-project')
.map(r => {
const res = (registry as WalkableRegistry).getResource(safePath.resolve(r.path));
return res?.id;
Expand Down
31 changes: 24 additions & 7 deletions packages/agent-skills/src/validators/packaging-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* - vat skills audit --user (report issues, exit 0 always)
*/

import { existsSync } from 'node:fs';
import { existsSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename, dirname } from 'node:path';

Expand Down Expand Up @@ -42,7 +42,6 @@ import {
import { walkerExclusionsToIssues } from './walker-to-issues.js';

/** Exclude reason constants to avoid duplicate string literals */
const EXCLUDE_REASON_DIRECTORY = 'directory-target' as const;
const EXCLUDE_REASON_OUTSIDE_PROJECT = 'outside-project' as const;
const DETAIL_REASON_DEPTH: ExcludedReferenceDetail['reason'] = 'depth-exceeded';

Expand Down Expand Up @@ -128,10 +127,15 @@ export interface PackagingValidationResult {
}

/**
* Validate files config entries for duplicate dest values.
* Validate files config entries:
* - duplicate dest values (DUPLICATE_FILES_DEST)
* - source paths that resolve to a directory (LINK_TARGETS_DIRECTORY) — a
* `files:` entry is a typed single-file slot; a directory cannot satisfy
* the contract. See #126.
*/
function validateFilesConfig(
files: Array<{ source: string; dest: string }> | undefined,
skillDir: string,
): ValidationIssue[] {
if (!files?.length) return [];

Expand All @@ -148,6 +152,20 @@ function validateFilesConfig(
});
}
destSet.add(normalized);

const sourceAbsolute = safePath.resolve(skillDir, entry.source);
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- entry.source from validated config
if (existsSync(sourceAbsolute) && statSync(sourceAbsolute).isDirectory()) {
issues.push(createRegistryIssue(
'LINK_TARGETS_DIRECTORY',
`files: source '${entry.source}' resolves to a directory; a files: entry is a single-file slot.`,
toForwardSlash(entry.source),
));
}
} catch {
// stat failure: leave it to other validation paths to report.
}
}

return issues;
Expand Down Expand Up @@ -269,7 +287,7 @@ export async function validateSkillForPackaging(
}

// Validate files config
rawIssues.push(...validateFilesConfig(packagingConfig?.files));
rawIssues.push(...validateFilesConfig(packagingConfig?.files, dirname(skillPath)));

// Compat capability detection: collect observations from SKILL.md and
// surface each as a CAPABILITY_* issue. Observations are also returned
Expand Down Expand Up @@ -649,11 +667,11 @@ function getResolvedMarkdownLinks(
* Reasons that are reported as validation errors instead of excluded references.
* These are filtered out of the excluded references list.
*/
const VALIDATION_ERROR_REASONS: ReadonlySet<string> = new Set([EXCLUDE_REASON_DIRECTORY, EXCLUDE_REASON_OUTSIDE_PROJECT]);
const VALIDATION_ERROR_REASONS: ReadonlySet<string> = new Set([EXCLUDE_REASON_OUTSIDE_PROJECT]);

/**
* Deduplicate excluded references by path, preserving detail from first occurrence.
* Filters out entries reported as validation errors (directory-target, outside-project).
* Filters out entries reported as validation errors (outside-project).
*/
function deduplicateExcludedReferences(
excludedReferences: LinkResolution[],
Expand Down Expand Up @@ -692,7 +710,6 @@ function mapExcludeReason(
case 'skill-definition': return 'skill-definition';
case 'gitignored': return 'gitignored';
case 'depth-exceeded':
case EXCLUDE_REASON_DIRECTORY:
case EXCLUDE_REASON_OUTSIDE_PROJECT:
case 'missing-target':
case undefined:
Expand Down
1 change: 0 additions & 1 deletion packages/agent-skills/src/validators/walker-to-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const REASON_TO_CODE: Record<NonNullable<LinkResolution['excludeReason']>, Issue
'outside-project': 'LINK_OUTSIDE_PROJECT',
gitignored: 'LINK_TO_GITIGNORED_FILE',
'skill-definition': 'LINK_TO_SKILL_DEFINITION',
'directory-target': 'LINK_TARGETS_DIRECTORY',
'navigation-file': 'LINK_TO_NAVIGATION_FILE',
'missing-target': 'LINK_MISSING_TARGET',
'pattern-matched': null,
Expand Down
9 changes: 6 additions & 3 deletions packages/agent-skills/src/walk-link-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface LinkResolution {
/** Whether the file will be bundled */
bundled: boolean;
/** Reason it was excluded (only set when bundled is false) */
excludeReason?: 'depth-exceeded' | 'pattern-matched' | 'directory-target' | 'outside-project' | 'navigation-file' | 'skill-definition' | 'gitignored' | 'missing-target' | undefined;
excludeReason?: 'depth-exceeded' | 'pattern-matched' | 'outside-project' | 'navigation-file' | 'skill-definition' | 'gitignored' | 'missing-target' | undefined;
/** The rule that matched (only set for pattern-matched exclusions) */
matchedRule?: ExcludeRule | undefined;
/** Link text from the source markdown */
Expand Down Expand Up @@ -183,11 +183,14 @@ function checkExclusions(
excludeMatchers: ExcludeMatcher[],
excludedReferences: LinkResolution[],
): boolean {
// Check if target is a directory
// Directory targets are valid navigational link targets (#126). A directory
// cannot be bundled as a file, so the walker silently skips it — no
// exclusion record, no surfaced issue. LINK_TARGETS_DIRECTORY is reserved
// for typed single-file slots (e.g. packaging `files:` source entries)
// where the contract demands a file.
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path from parsed markdown
if (existsSync(targetPath) && statSync(targetPath).isDirectory()) {
excludedReferences.push(makeExclusion(targetPath, 'directory-target', link));
return true;
}
} catch {
Expand Down
11 changes: 5 additions & 6 deletions packages/agent-skills/test/skill-packager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,17 +652,18 @@ describe('packageSkill - stripPrefix edge cases', () => {
});

// ============================================================================
// Excluded resource filtering (directory-target, outside-project)
// Directory link handling (#126 — directories are valid navigational targets)
// ============================================================================

describe('packageSkill - excluded resource filtering', () => {
it('should skip directory-target exclusions from output registry', async () => {
describe('packageSkill - directory link handling', () => {
it('packages a skill with a navigational directory link; the directory is not bundled', async () => {
const tmp = getTempDir();
const sub = safePath.join(tmp, 'docs');
await mkdir(sub, { recursive: true });
await writeFile(safePath.join(sub, 'page.md'), '# Page');

// Link to a directory — should be excluded with 'directory-target' reason
// `[docs](./docs/)` is a navigational link; the walker silently skips
// the directory itself but still follows `./docs/page.md`.
const sp = await writeSkillMd(
tmp,
UNIT_SKILL_NAME,
Expand All @@ -671,9 +672,7 @@ describe('packageSkill - excluded resource filtering', () => {

const result = await packWithOutput(sp);

// page.md should be bundled, but directory link should be excluded
expect(existsSync(safePath.join(result.outputPath, 'resources', 'page.md'))).toBe(true);
// The skill should still package without errors
expect(result.files.root).toBe('SKILL.md');
});
});
Expand Down
52 changes: 26 additions & 26 deletions packages/agent-skills/test/validators/packaging-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,29 +661,6 @@ describe('validateSkillForPackaging - Severity / allow config (framework)', () =
expect(result.activeErrors.map(e => e.code)).not.toContain('OUTSIDE_PROJECT_BOUNDARY');
});

it('allows LINK_TARGETS_DIRECTORY per-path', async () => {
const tempDir = getTempDir();
const conceptsDir = safePath.join(tempDir, 'docs/sub');
fs.mkdirSync(conceptsDir, { recursive: true });
fs.writeFileSync(safePath.join(conceptsDir, 'README.md'), '# Sub');

const skillContent = createSkillContent(
{ name: TEST_SKILL_NAME, description: VALID_DESCRIPTION },
'\n# Test\n\nSee [Sub](./docs/sub/) for details.',
);
const { skillPath } = createTransitiveSkillStructure(tempDir, {}, skillContent);

// location is 'docs/sub' (relative to project root, no trailing slash)
const result = await validateSkillForPackaging(skillPath, {
validation: {
allow: {
LINK_TARGETS_DIRECTORY: [{ paths: ['docs/sub'], reason: 'ToC target' }],
},
},
});

expect(result.activeErrors).toHaveLength(0);
});
});

describe('validateSkillForPackaging - Metadata reporting', () => {
Expand Down Expand Up @@ -877,7 +854,11 @@ describe('validateSkillForPackaging - Link collection integration', () => {
expect(result.metadata.excludedReferences).toEqual([]);
});

it('should error when skill links to a directory', async () => {
it('accepts a navigational directory link without emitting LINK_TARGETS_DIRECTORY (#126)', async () => {
// `[Concepts](./concepts/)` is a navigational link to an existing
// directory. Per #126, this is valid — LINK_TARGETS_DIRECTORY is
// reserved for typed single-file slots (e.g. packaging `files:`
// source entries), not navigational markdown links.
const tempDir = getTempDir();
const conceptsDir = safePath.join(tempDir, 'concepts');
fs.mkdirSync(conceptsDir, { recursive: true });
Expand All @@ -891,10 +872,29 @@ describe('validateSkillForPackaging - Link collection integration', () => {

const result = await validateSkillForPackaging(skillPath);

expect(result.status).toBe('error');
expect(result.activeErrors.map(e => e.code)).not.toContain('LINK_TARGETS_DIRECTORY');
});

it('emits LINK_TARGETS_DIRECTORY when a files: source entry resolves to a directory (#126)', async () => {
// A `files:` source is a typed single-file slot — it cannot be
// satisfied by a directory, so LINK_TARGETS_DIRECTORY fires here.
const tempDir = getTempDir();
const scriptsDir = safePath.join(tempDir, 'scripts');
fs.mkdirSync(scriptsDir, { recursive: true });

const skillContent = createSkillContent(
{ name: TEST_SKILL_NAME, description: VALID_DESCRIPTION },
'\n# Test\n',
);
const { skillPath } = createTransitiveSkillStructure(tempDir, {}, skillContent);

const result = await validateSkillForPackaging(skillPath, {
files: [{ source: 'scripts', dest: 'scripts/tool.mjs' }],
});

const dirError = result.activeErrors.find(e => e.code === 'LINK_TARGETS_DIRECTORY');
expect(dirError).toBeDefined();
expect(dirError?.message).toContain('concepts');
expect(dirError?.message).toContain('scripts');
});

it('should report directFileCount <= fileCount when links are excluded by depth', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ const resolution = (reason: LinkResolution['excludeReason'], path: string): Link

describe('walkerExclusionsToIssues', () => {
it('maps each reason to the expected issue code', () => {
// Directory targets are silently skipped by the walker (#126) and so do
// not appear in excludedReferences — there is no `directory-target`
// reason to map.
const input: LinkResolution[] = [
resolution('depth-exceeded', '/root/a.md'),
resolution('outside-project', '/other/b.md'),
resolution('gitignored', '/root/dist/c.md'),
resolution('skill-definition', '/root/other/SKILL.md'),
resolution('directory-target', '/root/dir'),
resolution('navigation-file', '/root/README.md'),
resolution('missing-target', '/root/nope.md'),
resolution('pattern-matched', '/root/docs/x.md'),
Expand All @@ -29,7 +31,6 @@ describe('walkerExclusionsToIssues', () => {
'LINK_OUTSIDE_PROJECT',
'LINK_TO_GITIGNORED_FILE',
'LINK_TO_SKILL_DEFINITION',
'LINK_TARGETS_DIRECTORY',
'LINK_TO_NAVIGATION_FILE',
'LINK_MISSING_TARGET',
// pattern-matched emits no issue
Expand Down
7 changes: 7 additions & 0 deletions packages/resources/src/link-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ function extractLinkText(node: Link | LinkReference): string {
* classifyLink('#heading') // 'anchor'
* classifyLink('./file.md') // 'local_file'
* classifyLink('./file.md#anchor') // 'local_file'
* classifyLink('docs/') // 'local_directory'
* ```
*/
export function classifyLink(href: string): LinkType {
Expand All @@ -211,6 +212,12 @@ export function classifyLink(href: string): LinkType {
if (href.includes(':')) {
return 'unknown';
}
// Trailing slash signals directory intent. Diagnostic hint only — validation
// treats local_directory and local_file identically. Anchored hrefs are
// handled below (this branch only matches when there is no '#').
if (href.endsWith('/')) {
return 'local_directory';
}
// Links with anchors are still local file links
if (href.includes('#')) {
return 'local_file';
Expand Down
Loading
Loading