Files
2026-07-13 12:20:01 +08:00

1229 lines
55 KiB
TypeScript

import { readdirSync, readFileSync, statSync, type Dirent } from "fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { parseFrontmatter } from "../src/utils/frontmatter"
/**
* Convention-enforcement tests for skill content, encoding the repo-root
* AGENTS.md rules "File References in Skills" and "Platform-Specific
* Variables in Skills", plus the Anthropic skill-spec frontmatter limits.
*
* Skill directories are enumerated dynamically under plugins/<plugin>/skills/
* so new skills (and new plugins) are auto-covered.
*
* Rule groups:
*
* 1. SELF-CONTAINMENT (AGENTS.md "File References in Skills"): a skill must
* not reference files outside its own directory tree. Detection policy —
* designed to avoid false positives on documentation examples:
* - Fenced code blocks are stripped before scanning. Teaching samples,
* message templates, and anti-pattern demos live in fences (e.g.,
* screenshot markdown like `![Before](url-1)`), and fences are where
* skills quote code they are ABOUT rather than files they USE. This
* fence policy is Rule-1-specific — Rule 2 deliberately differs (see
* below): self-containment looks for ESCAPING references, which in
* fences are overwhelmingly quoted anti-patterns, while reference
* integrity looks for LOCAL paths, which in fences are overwhelmingly
* real script invocations.
* - Markdown link/image targets are treated as real references. A target
* is a violation when it is absolute (`/...`, `~/...`) or when it
* resolves outside the skill directory under BOTH file-relative and
* skill-root-relative anchoring (a `../scripts/x` link from
* `references/` resolves inside the skill and is allowed).
* - Prose/backtick mentions of installed-plugin paths
* (`~/.claude/plugins/...`) are flagged — AGENTS.md names this as a
* broken pattern. Files whose PURPOSE is reading that machine state
* are exempted below with written reasons.
* - Bare absolute paths in prose are NOT flagged: skills legitimately
* discuss `/tmp` scratch conventions and quote `/Users/...` as
* anti-pattern examples (ce-plan, ce-compound, ce-ideate).
*
* 2. REFERENCE INTEGRITY (AGENTS.md "File References in Skills"): every
* skill-local path mentioned in a skill's markdown must exist on disk
* INSIDE the skill directory. Candidates are (a) relative markdown link
* targets outside fences, (b) `references/...`, `scripts/...`, or
* `assets/...` path tokens inside backtick code spans — whether the span
* IS the path (`scripts/run.sh`) or embeds it in a command
* (`bash scripts/run.sh ARG`, `python3 scripts/foo.py <arg>`), so a
* deleted or renamed script is caught even when mentioned only as an
* executable command — (c) the same path tokens inside FENCED code
* blocks, and (d) `@`-include targets in prose (`@./references/x.md` —
* inlined at load time, so a deleted target breaks the skill at load).
* For (b) and (c), tokens are unwrapped before the shape gate:
* surrounding quotes and a platform-variable prefix (`${VAR}/`,
* `${VAR:-default}/`, `$VAR/`) are stripped, so the repo's documented
* cross-platform invocation style
* (`bash "${CLAUDE_SKILL_DIR:-.}/scripts/x.sh"` — AGENTS.md
* "Platform-Specific Variables in Skills") yields `scripts/x.sh` as a
* candidate; the variable prefix means "skill-root-relative" by that
* convention, so skill-root anchoring is correct for the remainder.
* Deliberately NOT candidates (per a mechanical audit of every
* references/-, scripts/-, assets/-mentioning line across all skills):
* bare path tokens in prose with no backticks, link, or `@` prefix —
* unmarked prose is where hypothetical teaching examples live, and the
* only real instances (advisory `references/yaml-schema.md` pointers in
* HTML comments inside ce-compound's and ce-compound-refresh's asset
* templates) sit in templates that are instantiated OUTSIDE the skill,
* where the path is context for a future reader, not a runtime
* dependency — and directory-only mentions (`references/` with no
* filename), which name no file to check.
* (c) is where this rule's fence policy deliberately diverges
* from Rule 1: fenced bash blocks are where skills put their REAL bundled
* script invocations (for example, `bash scripts/session-history/extract-metadata.py`),
* so stripping fences here would let a deleted or renamed script pass CI
* while the skill fails at runtime. Markdown-link syntax inside fences is
* still NOT a candidate — fenced `[text](references/x.md)` is teaching
* material, and its parentheses/brackets fail the bare-token shape — and
* backticks within fenced lines act as token separators so inline-code
* path mentions in fenced pseudocode tokenize cleanly. A candidate passes
* when it resolves
* inside the skill directory (anchored at the skill root or at the
* containing file's directory) AND exists there. Resolution is contained
* BEFORE any existence check: a `..` candidate that escapes the skill is
* a violation even when the target exists in a sibling skill — existence
* elsewhere is exactly what makes it a cross-skill reference. Targets
* containing template placeholder characters (`<`, `>`, `*`, `{`, `$`)
* are skipped.
*
* 3. FRONTMATTER LIMITS (Anthropic skill spec — hard constraints, with no
* exemption lists because exceeding them breaks real behavior):
* - frontmatter description <= 1024 chars (some harnesses reject longer
* descriptions — also enforced in tests/frontmatter.test.ts).
* - frontmatter name <= 64 chars.
* Deliberately NOT gated here: the 500-line SKILL.md body guidance and any
* byte cap on skill bodies. The line guidance is advice, not a constraint —
* several skills in this plugin are large by design, and gating guidance
* would tax every deliberately-large skill with exemption-list ceremony.
* The "8KB Codex body cap" that circulates in ecosystem lint tooling turned
* out to be folklore: Codex's real limit is an ~8,000-character budget on
* the injected skills metadata LIST, while full SKILL.md bodies are read
* from disk on demand (see the note in tests/real-plugin-conversion.test.ts).
*
* 4. PLATFORM-VARIABLE FALLBACK (AGENTS.md "Platform-Specific Variables in
* Skills"): skill markdown using harness variables (${CLAUDE_*},
* ${CODEX_*}) must degrade gracefully. Mechanically graceful forms pass
* outright: a shell default (`${VAR:-...}`) or an existence guard
* (`[ -n "$VAR" ]` / `[ -z "$VAR" ]` / `[ -f "${VAR}/path" ]`). EXCEPTION:
* a skill-directory var (CLAUDE_SKILL_DIR / CLAUDE_PLUGIN_ROOT, see
* SKILL_DIR_VARS) used to locate a bundled file is NOT made graceful by a
* `${VAR:-.}` default — that default resolves to the project CWD and
* silently misses the bundled script on non-Claude targets (issue #943);
* it must use an existence guard or be acknowledged. Any other use must be
* explicitly acknowledged in PLATFORM_VAR_ACKNOWLEDGED with a written
* reason naming the prose fallback (the AGENTS.md pre-resolution pattern).
* An earlier
* revision tried to verify the fallback PROSE itself, via a fallback
* keyword list and a line window — that graded English with regexes and
* failed legitimate rewordings, so the editorial judgment now lives in
* the registry: exact matching, immune to prose rewording, vetted by a
* reviewer when the entry is added, kept honest by a stale-entry check.
* Credential-style env vars (e.g., GEMINI_API_KEY) are out of scope —
* they are API requirements, not harness variables.
*
* Each rule's scanning helpers are unit-tested with in-file fixtures at the
* bottom of this file (mutation-resistance layer, matching the house style
* of tests/skill-shell-safety.test.ts).
*/
const REPO_ROOT = process.cwd()
const SKILLS_ROOT = path.join(REPO_ROOT, "skills")
const AGENTS_MD_REF = `AGENTS.md (repo root)`
// ---------------------------------------------------------------------------
// Exemptions. Shrinking these lists is welcome; growing them requires written
// justification in the entry comment — they must not become silent junk
// drawers. Keys are repo-root-relative paths so same-named skills in
// different plugins cannot collide.
// ---------------------------------------------------------------------------
// Rule 1: files allowed to mention installed-plugin paths (~/.claude/plugins/...).
const INSTALLED_PLUGIN_PATH_EXEMPTIONS = new Map<string, string>([])
const DESCRIPTION_CHAR_BUDGET = 1024
const NAME_CHAR_BUDGET = 64
const EXPECTED_USER_INVOKED_SKILLS = new Set([
"ce-dogfood",
"ce-polish",
"ce-product-pulse",
"ce-promote",
"ce-setup",
"ce-sweep",
"ce-test-xcode",
"lfg",
])
const REQUIRED_MODEL_INVOKED_CALLEES = new Set([
"ce-brainstorm",
"ce-code-review",
"ce-commit",
"ce-commit-push-pr",
"ce-compound",
"ce-compound-refresh",
"ce-debug",
"ce-doc-review",
"ce-ideate",
"ce-optimize",
"ce-plan",
"ce-proof",
"ce-resolve-pr-feedback",
"ce-riffrec-feedback-analysis",
"ce-simplify-code",
"ce-strategy",
"ce-test-browser",
"ce-work",
"ce-worktree",
])
// ---------------------------------------------------------------------------
// Skill enumeration
// ---------------------------------------------------------------------------
type SkillDir = {
/** repo-root-relative skill dir, e.g. skills/ce-plan */
relPath: string
absPath: string
}
function listSkillDirs(): SkillDir[] {
const out: SkillDir[] = []
let skillEntries: Dirent[]
try {
skillEntries = readdirSync(SKILLS_ROOT, { withFileTypes: true })
} catch {
return out
}
for (const entry of skillEntries) {
if (!entry.isDirectory()) continue
const absPath = path.join(SKILLS_ROOT, entry.name)
out.push({ relPath: path.relative(REPO_ROOT, absPath), absPath })
}
return out
}
function listMarkdownFiles(dir: string): string[] {
const out: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
out.push(...listMarkdownFiles(full))
continue
}
if (entry.name.endsWith(".md")) out.push(full)
}
return out
}
// ---------------------------------------------------------------------------
// Scanning helpers (pure; unit-tested at the bottom of this file)
// ---------------------------------------------------------------------------
type Located = { lineNumber: number; value: string }
/**
* Partitions markdown around fenced code blocks (``` / ~~~, any fence length
* >= 3): `prose` is the input with fences blanked (line numbers preserved),
* `fencedLines` are the in-fence lines with their line numbers. Outer fences
* longer than three backticks (e.g., ````markdown teaching blocks) correctly
* swallow shorter inner fences. Rule 1 scans only `prose`; Rule 2 scans both
* sides — sharing one fence state machine keeps the two policies from
* drifting on fence-parsing edge cases.
*/
function partitionFencedCodeBlocks(markdown: string): { prose: string; fencedLines: Located[] } {
const lines = markdown.split("\n")
let fence: { char: string; len: number } | null = null
const fencedLines: Located[] = []
const prose = lines.map((line, i) => {
const match = line.match(/^\s*(`{3,}|~{3,})/)
if (match) {
const char = match[1][0]
const len = match[1].length
if (!fence) fence = { char, len }
else if (fence.char === char && len >= fence.len) fence = null
return ""
}
if (fence) {
fencedLines.push({ lineNumber: i + 1, value: line })
return ""
}
return line
})
return { prose: prose.join("\n"), fencedLines }
}
/** Blanks out fenced code blocks while preserving line numbers. */
function stripFencedCodeBlocks(markdown: string): string {
return partitionFencedCodeBlocks(markdown).prose
}
function lineNumberAt(text: string, index: number): number {
return text.slice(0, index).split("\n").length
}
/** Extracts markdown link and image targets: [text](target), ![alt](target "title"). */
function extractMarkdownLinkTargets(markdown: string): Located[] {
const out: Located[] = []
const regex = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
let match: RegExpExecArray | null
while ((match = regex.exec(markdown)) !== null) {
out.push({ lineNumber: lineNumberAt(markdown, match.index), value: match[1] })
}
return out
}
/**
* The skill-local path-token shape (references/, scripts/, assets/). The
* character class excludes template placeholder characters, so
* placeholder-bearing path tokens (`scripts/<name>`) are skipped by
* construction while placeholder ARGUMENTS after a clean path token do not
* suppress it.
*/
const LOCAL_PATH_TOKEN = /^(references|scripts|assets)\/[A-Za-z0-9._/-]+$/
/**
* Strips the shell wrapping a whitespace token may carry around a skill-local
* path: surrounding quotes and a platform-variable prefix (`${VAR}/`,
* `${VAR:-default}/`, or `$VAR/`). The repo's documented cross-platform
* invocation style (AGENTS.md "Platform-Specific Variables in Skills") writes
* bundled-script calls as `bash "${CLAUDE_SKILL_DIR:-.}/scripts/x.sh"`, so
* the quote/`$`/`{` characters of the wrapping must not hide the
* `scripts/x.sh` remainder from the existence check. Unwrapping happens
* BEFORE any placeholder gate sees the token: a real invocation's remainder
* is placeholder-free, while a templated remainder (`.../scripts/<name>`)
* still fails the token shape by construction.
*/
function stripShellWrapping(token: string): string {
return token
.replace(/^["']/, "")
.replace(/["']$/, "")
.replace(/^\$(?:\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}|[A-Za-z_][A-Za-z0-9_]*)\//, "")
}
/** Whitespace-delimited tokens of `text` whose unwrapped form matches the skill-local path shape. */
function localPathTokensIn(text: string): string[] {
return text
.split(/\s+/)
.map(stripShellWrapping)
.filter((token) => LOCAL_PATH_TOKEN.test(token))
}
/**
* Extracts skill-local path tokens from backtick code spans. The span may BE
* the path (`scripts/run.sh`) or embed it in a command (`bash scripts/run.sh
* ARG`, `python3 scripts/foo.py <arg>`): each whitespace-delimited token
* matching the path shape is extracted, so a deleted or renamed script is
* caught even when mentioned only as an executable command.
*/
function extractLocalPathCodeSpans(markdown: string): Located[] {
const out: Located[] = []
const spanRegex = /`([^`\n]+)`/g
let match: RegExpExecArray | null
while ((match = spanRegex.exec(markdown)) !== null) {
const lineNumber = lineNumberAt(markdown, match.index)
for (const token of localPathTokensIn(match[1])) out.push({ lineNumber, value: token })
}
return out
}
/**
* Extracts `@`-include targets from prose (the at-include syntax that inlines
* a file at load time, e.g. `@./references/persona-catalog.md` in
* ce-code-review's "Included References"). These are load-bearing — a deleted
* target breaks the skill at load — yet invisible to the other extractors:
* no backticks, no markdown-link syntax, and the `@`/`./` prefix fails the
* bare token shape. Placeholder-bearing targets are excluded by the token
* character class, as everywhere else.
*/
function extractAtIncludeTargets(markdown: string): Located[] {
const out: Located[] = []
const regex = /(?:^|\s)@(\.\/)?((?:references|scripts|assets)\/[A-Za-z0-9._/-]+)/g
let match: RegExpExecArray | null
while ((match = regex.exec(markdown)) !== null) {
// match.index may sit on the leading whitespace; number the line of the @.
out.push({
lineNumber: lineNumberAt(markdown, match.index + match[0].indexOf("@")),
value: match[2],
})
}
return out
}
/**
* Extracts skill-local path tokens from INSIDE fenced code blocks (the Rule 2
* fence policy — see the header). Backticks within a fenced line are treated
* as token separators so inline-code path mentions in fenced pseudocode
* (`see \`references/x.md\``) tokenize cleanly; markdown-link syntax never
* yields a candidate because its brackets/parentheses fail the token shape.
*/
function extractFencedLocalPathTokens(markdown: string): Located[] {
const out: Located[] = []
for (const { lineNumber, value } of partitionFencedCodeBlocks(markdown).fencedLines) {
for (const token of localPathTokensIn(value.replace(/`/g, " "))) {
out.push({ lineNumber, value: token })
}
}
return out
}
function isExternalLinkTarget(target: string): boolean {
return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")
}
function isTemplatePlaceholderPath(target: string): boolean {
return /[<>*{}$]/.test(target)
}
/** Strips a #fragment suffix from a link target. */
function withoutFragment(target: string): string {
const hash = target.indexOf("#")
return hash === -1 ? target : target.slice(0, hash)
}
/**
* True when a reference target escapes the skill directory. Absolute and
* home-anchored targets always escape. Relative targets escape only when
* they resolve outside the skill root from BOTH the containing file's
* directory (`fileDirWithinSkill`, posix-relative to the skill root, "" for
* SKILL.md itself) and the skill root.
*/
function escapesSkillDir(target: string, fileDirWithinSkill: string): boolean {
if (target.startsWith("/") || target.startsWith("~")) return true
const fromFile = path.posix.normalize(path.posix.join(fileDirWithinSkill, target))
const fromRoot = path.posix.normalize(target)
const escapes = (p: string) => p === ".." || p.startsWith("../")
return escapes(fromFile) && escapes(fromRoot)
}
const INSTALLED_PLUGIN_PATH_REGEX = /(~|\$HOME)\/\.claude\/plugins\//
type SelfContainmentViolation = { lineNumber: number; detail: string }
/** Rule 1 scanner for one markdown file's content. */
function findSelfContainmentViolations(
markdown: string,
fileDirWithinSkill: string,
): SelfContainmentViolation[] {
const stripped = stripFencedCodeBlocks(markdown)
const out: SelfContainmentViolation[] = []
for (const { lineNumber, value } of extractMarkdownLinkTargets(stripped)) {
if (isExternalLinkTarget(value) || isTemplatePlaceholderPath(value)) continue
if (escapesSkillDir(withoutFragment(value), fileDirWithinSkill)) {
out.push({ lineNumber, detail: `link target escapes the skill directory: ${value}` })
}
}
const lines = stripped.split("\n")
for (let i = 0; i < lines.length; i++) {
if (INSTALLED_PLUGIN_PATH_REGEX.test(lines[i])) {
out.push({
lineNumber: i + 1,
detail: `installed-plugin path (~/.claude/plugins/...): ${lines[i].trim().slice(0, 120)}`,
})
}
}
return out
}
/** Rule 2 candidate extractor: skill-local paths that must exist on disk. */
function extractLocalReferenceCandidates(markdown: string): Located[] {
const stripped = stripFencedCodeBlocks(markdown)
const out: Located[] = []
for (const { lineNumber, value } of extractMarkdownLinkTargets(stripped)) {
if (isExternalLinkTarget(value) || isTemplatePlaceholderPath(value)) continue
const target = withoutFragment(value)
if (target === "" || target.startsWith("/") || target.startsWith("~")) continue
out.push({ lineNumber, value: target })
}
for (const span of extractLocalPathCodeSpans(stripped)) {
if (isTemplatePlaceholderPath(span.value)) continue
out.push(span)
}
for (const target of extractAtIncludeTargets(stripped)) {
out.push(target)
}
for (const token of extractFencedLocalPathTokens(markdown)) {
if (isTemplatePlaceholderPath(token.value)) continue
out.push(token)
}
return out
}
/**
* Resolves a Rule 2 candidate against a base directory and returns the
* result only when it stays inside the skill directory; otherwise null.
* Containment is checked BEFORE any disk access so a `..` candidate that
* lands on a real file in a sibling skill is rejected rather than treated
* as existing — that is precisely the cross-skill reference Rule 2 exists
* to catch.
*/
function resolveInsideSkill(skillAbsPath: string, baseDir: string, target: string): string | null {
const resolved = path.resolve(baseDir, target)
if (resolved === skillAbsPath || resolved.startsWith(skillAbsPath + path.sep)) return resolved
return null
}
// Rule 4 helpers ------------------------------------------------------------
const PLATFORM_VAR_REGEX = /\$\{?((?:CLAUDE|CODEX)_[A-Z][A-Z0-9_]*)/g
type PlatformVarOccurrence = { lineNumber: number; variable: string; graceful: boolean }
// Shell test operators that count as guarding a platform variable: `-n`/`-z`
// (set / unset) and `-f`/`-e`/`-d` (path existence). Defined once so the three
// guard matchers below stay in lockstep when the set changes.
const GUARD_TEST = `\\[\\s*-[nzefd]\\s+`
const PLATFORM_VAR_CAPTURE = `\\$\\{?((?:CLAUDE|CODEX)_[A-Z][A-Z0-9_]*)\\b`
/**
* Skill-directory variables — used to locate a bundled file. For these, a
* `${VAR:-default}` shell default is NOT a graceful fallback: the default
* (e.g. `.`) resolves to the project CWD on non-Claude targets and silently
* misses the bundled script (issue #943). They must use an existence guard
* (so the off-Claude branch is explicit) or carry an acknowledged reason —
* a plain `${VAR:-.}` bundled-script path must not pass the convention.
*/
const SKILL_DIR_VARS = new Set(["CLAUDE_SKILL_DIR", "CLAUDE_PLUGIN_ROOT"])
/**
* True when the occurrence on this line is gracefully handled *by the line
* itself*: a `${VAR:-...}` shell default (for ordinary vars only — skill-dir
* vars are excluded, see SKILL_DIR_VARS), or a `[ -n/-z/-f/-e/-d "...$VAR..." ]`
* test (existence/non-empty guard).
*/
function isGracefulPlatformVarUse(line: string, variable: string): boolean {
if (!SKILL_DIR_VARS.has(variable) && new RegExp(`\\$\\{${variable}:-`).test(line)) return true
return new RegExp(`${GUARD_TEST}"[^"]*\\$\\{?${variable}\\b[^"]*"\\s*\\]`).test(line)
}
/** Platform variables the markdown guards with a `[ -n/-z/-f/-e/-d "...$VAR..." ]` test anywhere. */
function fileGuardedVars(markdown: string): Set<string> {
const guarded = new Set<string>()
const guardForm = new RegExp(`${GUARD_TEST}"[^"]*${PLATFORM_VAR_CAPTURE}[^"]*"\\s*\\]`, "g")
let match: RegExpExecArray | null
while ((match = guardForm.exec(markdown)) !== null) guarded.add(match[1])
return guarded
}
/** Matches the opening of a guard block, e.g. `if [ -f "${CLAUDE_SKILL_DIR}/scripts/x" ]; then`. */
const GUARD_BLOCK_OPEN = new RegExp(`\\bif\\b.*${GUARD_TEST}"[^"]*${PLATFORM_VAR_CAPTURE}`, "g")
/** Collects every platform variable a line opens an `if [ -X "$VAR..." ]` guard for. */
function guardsOpenedOnLine(line: string): Set<string> {
const opened = new Set<string>()
let match: RegExpExecArray | null
GUARD_BLOCK_OPEN.lastIndex = 0
while ((match = GUARD_BLOCK_OPEN.exec(line)) !== null) opened.add(match[1])
return opened
}
/**
* Gracefulness is context-sensitive (issue #943). Fence parsing reuses
* `partitionFencedCodeBlocks` so it follows the same markdown model as the
* rest of this file (4-backtick outer fences, `~~~`, etc.):
*
* - Inside a fenced code block (an *executable* context), a use is graceful
* only if the line itself guards the variable, or the line sits inside an
* open `if [ -f "${VAR}/..." ]; then ... fi` guard block. Block-scoped on
* purpose: a second, *unguarded* script invocation in the same file is still
* reported, not masked by an unrelated guard. A self-contained single-line
* guard (`if ...; then bash X; fi`) covers only its own line — the `fi`
* closes the block so later lines are not treated as guarded.
* - Outside code fences (a *prose* mention, e.g. "resolves via `${VAR}`"), a
* use is graceful if the line itself is graceful or the file guards the
* variable somewhere — so explanatory prose in a skill that does guard the
* variable does not generate noise.
*/
function findPlatformVarOccurrences(markdown: string): PlatformVarOccurrence[] {
const out: PlatformVarOccurrence[] = []
const { prose, fencedLines } = partitionFencedCodeBlocks(markdown)
const fileGuarded = fileGuardedVars(markdown)
// Prose occurrences (fenced lines are blanked in `prose`).
const proseLines = prose.split("\n")
for (let i = 0; i < proseLines.length; i++) {
const line = proseLines[i]
if (!line) continue
let match: RegExpExecArray | null
PLATFORM_VAR_REGEX.lastIndex = 0
while ((match = PLATFORM_VAR_REGEX.exec(line)) !== null) {
const variable = match[1]
out.push({
lineNumber: i + 1,
variable,
graceful: isGracefulPlatformVarUse(line, variable) || fileGuarded.has(variable),
})
}
}
// Fenced (executable) occurrences, with block-scoped guard tracking.
let activeGuard = new Set<string>()
let prevLineNumber = -2
for (const { lineNumber, value: line } of fencedLines) {
if (lineNumber !== prevLineNumber + 1) activeGuard = new Set() // new fenced block
prevLineNumber = lineNumber
const openedHere = guardsOpenedOnLine(line)
const lineGuard = new Set([...activeGuard, ...openedHere])
let match: RegExpExecArray | null
PLATFORM_VAR_REGEX.lastIndex = 0
while ((match = PLATFORM_VAR_REGEX.exec(line)) !== null) {
const variable = match[1]
out.push({
lineNumber,
variable,
graceful: isGracefulPlatformVarUse(line, variable) || lineGuard.has(variable),
})
}
// A `fi` on this line closes the block (covers single-line guards); otherwise
// any guard opened here stays active for subsequent lines in this block.
if (/\bfi\b/.test(line)) activeGuard = new Set()
else for (const variable of openedHere) activeGuard.add(variable)
}
out.sort((a, b) => a.lineNumber - b.lineNumber)
return out
}
/**
* Acknowledged non-graceful platform-variable uses, keyed
* `<repo-relative file>#<VARIABLE>` -> written reason naming the prose
* fallback that lives in the file.
*
* Whether prose constitutes a real fallback is an editorial judgment. An
* earlier revision automated it with a fallback-keyword list and a line
* window; that failed legitimate rewordings ("if the variable is empty")
* and could be satisfied by coincidental keywords. The judgment now happens
* once, at review time, when an entry is added here — and the stale-entry
* test below forces removal when the underlying use disappears.
*/
const PLATFORM_VAR_ACKNOWLEDGED = new Map<string, string>()
/** Rule 4 scanner for one markdown file: every non-graceful occurrence. */
function findPlatformVarViolations(markdown: string): PlatformVarOccurrence[] {
return findPlatformVarOccurrences(markdown).filter((o) => !o.graceful)
}
// ---------------------------------------------------------------------------
// Repo scans
// ---------------------------------------------------------------------------
const skillDirs = listSkillDirs()
describe("skill self-containment (AGENTS.md 'File References in Skills')", () => {
for (const skill of skillDirs) {
test(`${skill.relPath} has no file references escaping the skill directory`, () => {
const offenders: string[] = []
for (const filePath of listMarkdownFiles(skill.absPath)) {
const fileRel = path.relative(REPO_ROOT, filePath)
if (INSTALLED_PLUGIN_PATH_EXEMPTIONS.has(fileRel)) continue
const fileDirWithinSkill = path
.relative(skill.absPath, path.dirname(filePath))
.split(path.sep)
.join("/")
const content = readFileSync(filePath, "utf8")
for (const violation of findSelfContainmentViolations(content, fileDirWithinSkill)) {
offenders.push(` ${fileRel}:${violation.lineNumber}${violation.detail}`)
}
}
expect(
offenders,
`Skills must be self-contained: no \`../\` traversal out of the skill, no absolute paths, no installed-plugin (~/.claude/plugins/...) paths — see ${AGENTS_MD_REF} "File References in Skills". Duplicate shared files into each skill or reference the other skill semantically ("load the ce-X skill"). If the file's purpose is reading installed-plugin machine state, add it to INSTALLED_PLUGIN_PATH_EXEMPTIONS in tests/skill-conventions.test.ts with a written reason.\nOffending references:\n${offenders.join("\n")}`,
).toEqual([])
})
}
})
describe("skill reference integrity (AGENTS.md 'File References in Skills')", () => {
for (const skill of skillDirs) {
test(`${skill.relPath} only mentions skill-local paths that exist on disk`, () => {
const missing: string[] = []
for (const filePath of listMarkdownFiles(skill.absPath)) {
const fileRel = path.relative(REPO_ROOT, filePath)
const content = readFileSync(filePath, "utf8")
for (const { lineNumber, value } of extractLocalReferenceCandidates(content)) {
const containedCandidates = [
resolveInsideSkill(skill.absPath, skill.absPath, value),
resolveInsideSkill(skill.absPath, path.dirname(filePath), value),
].filter((p): p is string => p !== null)
if (containedCandidates.length === 0) {
missing.push(` ${fileRel}:${lineNumber}${value} (resolves outside the skill directory)`)
continue
}
const exists = (p: string) => {
try {
statSync(p)
return true
} catch {
return false
}
}
if (!containedCandidates.some(exists)) {
missing.push(` ${fileRel}:${lineNumber}${value}`)
}
}
}
expect(
missing,
`Every references/, scripts/, or assets/ path (and relative markdown link) mentioned in a skill must exist inside that skill's directory — see ${AGENTS_MD_REF} "File References in Skills". A path that exists only in ANOTHER skill is a cross-skill reference: duplicate the file into this skill or replace the file-level pointer with semantic wording ("the <name> reference in the ce-X skill" without a path).\nMissing paths:\n${missing.join("\n")}`,
).toEqual([])
})
}
})
describe("skill frontmatter limits (Anthropic skill spec)", () => {
test("skill invocation classification matches the intended model/user split", () => {
const actualUserInvoked = new Set<string>()
const actualModelInvoked = new Set<string>()
for (const skill of skillDirs) {
const skillMdPath = path.join(skill.absPath, "SKILL.md")
const raw = readFileSync(skillMdPath, "utf8")
const { data } = parseFrontmatter(raw, skillMdPath)
const name = typeof data.name === "string" ? data.name : path.basename(skill.absPath)
if (data["disable-model-invocation"] === true) {
actualUserInvoked.add(name)
} else {
actualModelInvoked.add(name)
}
}
const missingUserInvoked = [...EXPECTED_USER_INVOKED_SKILLS].filter((name) => !actualUserInvoked.has(name))
const unexpectedUserInvoked = [...actualUserInvoked].filter((name) => !EXPECTED_USER_INVOKED_SKILLS.has(name))
const disabledRequiredCallees = [...REQUIRED_MODEL_INVOKED_CALLEES].filter((name) => !actualModelInvoked.has(name))
expect(
missingUserInvoked,
`These skills should be user-invoked only via disable-model-invocation: true:\n${missingUserInvoked.join("\n")}`,
).toEqual([])
expect(
unexpectedUserInvoked,
`Unexpected user-invoked skills. If intentional, update EXPECTED_USER_INVOKED_SKILLS and verify no model-routed caller depends on them:\n${unexpectedUserInvoked.join("\n")}`,
).toEqual([])
expect(
disabledRequiredCallees,
`These skills must remain model-invoked because pipelines or sibling skills call them:\n${disabledRequiredCallees.join("\n")}`,
).toEqual([])
})
for (const skill of skillDirs) {
const skillMdPath = path.join(skill.absPath, "SKILL.md")
const raw = readFileSync(skillMdPath, "utf8")
const { data } = parseFrontmatter(raw, skillMdPath)
test(`${skill.relPath} frontmatter description is at most ${DESCRIPTION_CHAR_BUDGET} characters (Anthropic skill spec)`, () => {
const description = typeof data.description === "string" ? data.description : ""
expect(description, `${skill.relPath}/SKILL.md must declare a frontmatter description`).not.toBe("")
expect(
description.length,
`${skill.relPath}/SKILL.md description is ${description.length} characters; the Anthropic skill spec caps descriptions at ${DESCRIPTION_CHAR_BUDGET} and some harnesses reject longer ones.`,
).toBeLessThanOrEqual(DESCRIPTION_CHAR_BUDGET)
})
test(`${skill.relPath} frontmatter name is at most ${NAME_CHAR_BUDGET} characters (Anthropic skill spec)`, () => {
const name = typeof data.name === "string" ? data.name : ""
expect(name, `${skill.relPath}/SKILL.md must declare a frontmatter name`).not.toBe("")
expect(
name.length,
`${skill.relPath}/SKILL.md name is ${name.length} characters; the Anthropic skill spec caps names at ${NAME_CHAR_BUDGET}.`,
).toBeLessThanOrEqual(NAME_CHAR_BUDGET)
})
}
test("installed-plugin path exemption list only names existing files", () => {
const stale = [...INSTALLED_PLUGIN_PATH_EXEMPTIONS.keys()].filter((rel) => {
try {
statSync(path.join(REPO_ROOT, rel))
return false
} catch {
return true
}
})
expect(stale, `Remove stale INSTALLED_PLUGIN_PATH_EXEMPTIONS entries:\n${stale.join("\n")}`).toEqual([])
})
})
describe("platform-variable fallback (AGENTS.md 'Platform-Specific Variables in Skills')", () => {
for (const skill of skillDirs) {
test(`${skill.relPath} platform variables degrade gracefully or are acknowledged`, () => {
const offenders: string[] = []
for (const filePath of listMarkdownFiles(skill.absPath)) {
const fileRel = path.relative(REPO_ROOT, filePath)
const content = readFileSync(filePath, "utf8")
for (const { lineNumber, variable } of findPlatformVarViolations(content)) {
if (PLATFORM_VAR_ACKNOWLEDGED.has(`${fileRel}#${variable}`)) continue
offenders.push(` ${fileRel}:${lineNumber}\${${variable}} with no fallback`)
}
}
expect(
offenders,
`Platform variables (\${CLAUDE_*}, \${CODEX_*}) must not be assumed to resolve — see ${AGENTS_MD_REF} "Platform-Specific Variables in Skills". An ordinary var may use a shell default (\${VAR:-...}); a skill-directory var (CLAUDE_SKILL_DIR / CLAUDE_PLUGIN_ROOT) used to locate a bundled file must NOT — its default silently misses the script off-Claude (#943) — so guard it with an existence test inside a code block ([ -f "\${VAR}/path" ]). If the fallback genuinely lives in prose (a platform var resolved at runtime with a documented fallback, or a core-script skill pinned via allowed-tools), write that prose first, then acknowledge the use in PLATFORM_VAR_ACKNOWLEDGED in tests/skill-conventions.test.ts with a reason naming the fallback. Note: \`!\` load-time pre-resolution is banned outright (tests/skill-shell-safety.test.ts) and is never a valid fallback here.\nOffending occurrences:\n${offenders.join("\n")}`,
).toEqual([])
})
}
test("acknowledged platform-variable entries match current non-graceful uses", () => {
const current = new Set<string>()
for (const skill of skillDirs) {
for (const filePath of listMarkdownFiles(skill.absPath)) {
const fileRel = path.relative(REPO_ROOT, filePath)
for (const { variable } of findPlatformVarViolations(readFileSync(filePath, "utf8"))) {
current.add(`${fileRel}#${variable}`)
}
}
}
const stale = [...PLATFORM_VAR_ACKNOWLEDGED.keys()].filter((key) => !current.has(key))
expect(
stale,
`PLATFORM_VAR_ACKNOWLEDGED entries must correspond to a current non-graceful use — remove stale entries from tests/skill-conventions.test.ts:\n${stale.join("\n")}`,
).toEqual([])
})
})
// ---------------------------------------------------------------------------
// Synthetic fixtures: prove violations are caught and valid content passes
// (mutation-resistance layer).
// ---------------------------------------------------------------------------
describe("stripFencedCodeBlocks", () => {
test("blanks fenced content while preserving line numbers", () => {
const sample = "before\n```bash\n[link](../escape.md)\n```\nafter"
const stripped = stripFencedCodeBlocks(sample)
expect(stripped.split("\n").length).toBe(5)
expect(stripped).not.toContain("../escape.md")
expect(stripped).toContain("after")
})
test("an outer four-backtick fence swallows inner three-backtick fences", () => {
const sample = "````markdown\n```bash\nrm -rf /\n```\nstill fenced [x](../out.md)\n````\nfree"
const stripped = stripFencedCodeBlocks(sample)
expect(stripped).not.toContain("../out.md")
expect(stripped).toContain("free")
})
test("leaves prose outside fences untouched", () => {
const sample = "see `references/foo.md` for details"
expect(stripFencedCodeBlocks(sample)).toBe(sample)
})
})
describe("extractMarkdownLinkTargets", () => {
test("captures link and image targets with line numbers", () => {
const sample = "intro\n[doc](references/a.md) and ![img](assets/b.png)\n"
expect(extractMarkdownLinkTargets(sample)).toEqual([
{ lineNumber: 2, value: "references/a.md" },
{ lineNumber: 2, value: "assets/b.png" },
])
})
test("captures titles-stripped targets", () => {
expect(extractMarkdownLinkTargets('[d](references/a.md "Title")')).toEqual([
{ lineNumber: 1, value: "references/a.md" },
])
})
})
describe("extractLocalPathCodeSpans", () => {
test("matches skill-local references/, scripts/, assets/ spans", () => {
const sample = "Read `references/walkthrough.md`, run `scripts/get-pr-comments`, embed `assets/logo.png`."
expect(extractLocalPathCodeSpans(sample).map((s) => s.value)).toEqual([
"references/walkthrough.md",
"scripts/get-pr-comments",
"assets/logo.png",
])
})
test("extracts path tokens embedded in command spans", () => {
const sample =
"Run `bash scripts/run.sh ARG`, then `python3 scripts/extract.py --out report.json`."
expect(extractLocalPathCodeSpans(sample).map((s) => s.value)).toEqual([
"scripts/run.sh",
"scripts/extract.py",
])
})
test("placeholder arguments do not suppress a clean path token", () => {
expect(extractLocalPathCodeSpans("run `python3 scripts/validate.py <output-path>`").map((s) => s.value)).toEqual([
"scripts/validate.py",
])
})
test("unwraps quoted and variable-prefixed path tokens (skill-root-relative by convention)", () => {
expect(
extractLocalPathCodeSpans(
'invoke `bash "${CLAUDE_SKILL_DIR:-.}/scripts/worktree-manager.sh" create <branch>`',
).map((s) => s.value),
).toEqual(["scripts/worktree-manager.sh"])
expect(
extractLocalPathCodeSpans("run `bash $CLAUDE_SKILL_DIR/scripts/setup.sh`").map((s) => s.value),
).toEqual(["scripts/setup.sh"])
expect(
extractLocalPathCodeSpans('see `cat "references/guide.md"`').map((s) => s.value),
).toEqual(["references/guide.md"])
})
test("skips placeholder-bearing path tokens", () => {
expect(extractLocalPathCodeSpans("invoked via `bash scripts/<name>`")).toEqual([])
expect(extractLocalPathCodeSpans("read `references/${topic}.md`")).toEqual([])
})
test("skips variable-prefixed tokens whose remainder is a placeholder", () => {
expect(extractLocalPathCodeSpans('run `bash "${CLAUDE_SKILL_DIR:-.}/scripts/<name>"`')).toEqual([])
expect(extractLocalPathCodeSpans("read `$CLAUDE_PLUGIN_ROOT/references/${topic}.md`")).toEqual([])
})
test("extracts multiple path tokens from one span", () => {
expect(
extractLocalPathCodeSpans("run `cp references/template.md assets/copy.md`").map((s) => s.value),
).toEqual(["references/template.md", "assets/copy.md"])
})
test("ignores bare filenames, home paths, and absolute paths", () => {
const sample =
"see `walkthrough.md`, store in `~/coding-tutor-tutorials/x.md`, use `/tmp/scratch`, ls `the scripts/ dir`"
expect(extractLocalPathCodeSpans(sample)).toEqual([])
})
})
describe("extractAtIncludeTargets", () => {
test("extracts prose at-include targets with and without ./", () => {
const sample = "### Persona Catalog\n\n@./references/persona-catalog.md\n\n@references/other.md\n"
expect(extractAtIncludeTargets(sample)).toEqual([
{ lineNumber: 3, value: "references/persona-catalog.md" },
{ lineNumber: 5, value: "references/other.md" },
])
})
test("skips placeholder-bearing targets and non-path at-tokens", () => {
expect(extractAtIncludeTargets("@./references/<topic>.md")).toEqual([])
expect(extractAtIncludeTargets("@message = Message.find(params[:id])")).toEqual([])
expect(extractAtIncludeTargets("email user@scripts.example.com")).toEqual([])
})
})
describe("extractFencedLocalPathTokens", () => {
test("extracts a real script invocation from a fenced bash block", () => {
const sample = "intro\n```bash\nbash scripts/clean-gone\n```\nafter"
expect(extractFencedLocalPathTokens(sample)).toEqual([
{ lineNumber: 3, value: "scripts/clean-gone" },
])
})
test("treats backticks inside fenced lines as token separators", () => {
const sample = "```\n(see `references/codex-delegation-workflow.md`)\n```"
expect(extractFencedLocalPathTokens(sample).map((t) => t.value)).toEqual([
"references/codex-delegation-workflow.md",
])
})
test("unwraps quoted variable-prefixed invocations (the documented cross-platform style)", () => {
const sample = [
"```bash",
'bash "${CLAUDE_SKILL_DIR:-.}/scripts/worktree-manager.sh" create <branch-name> [from-branch]',
'bash "${CLAUDE_SKILL_DIR}/scripts/upstream-version.sh"',
"```",
].join("\n")
expect(extractFencedLocalPathTokens(sample).map((t) => t.value)).toEqual([
"scripts/worktree-manager.sh",
"scripts/upstream-version.sh",
])
})
test("skips placeholder-bearing tokens and markdown-link syntax", () => {
const sample = [
"```bash",
"bash scripts/<name>",
'bash "${CLAUDE_SKILL_DIR:-.}/scripts/<generated-name>"',
"```",
"```markdown",
"[guide](references/guide.md)",
"![Before](url-1)",
"```",
].join("\n")
expect(extractFencedLocalPathTokens(sample)).toEqual([])
})
test("ignores path tokens outside fences (those belong to the code-span extractor)", () => {
expect(extractFencedLocalPathTokens("run `scripts/run.sh` in prose")).toEqual([])
})
})
describe("escapesSkillDir", () => {
test("flags ../ traversal out of the skill from SKILL.md", () => {
expect(escapesSkillDir("../other-skill/references/schema.yaml", "")).toBe(true)
})
test("flags absolute and home-anchored targets", () => {
expect(escapesSkillDir("/home/user/plugins/skills/other/file.md", "references")).toBe(true)
expect(escapesSkillDir("~/.claude/plugins/cache/m/p/1.0.0/skills/other/file.md", "")).toBe(true)
})
test("allows ../ that stays inside the skill (references/ -> scripts/)", () => {
expect(escapesSkillDir("../scripts/get-pr-comments", "references")).toBe(false)
})
test("allows skill-root-relative paths", () => {
expect(escapesSkillDir("references/foo.md", "")).toBe(false)
expect(escapesSkillDir("references/foo.md", "references")).toBe(false)
})
})
describe("findSelfContainmentViolations", () => {
test("catches an escaping markdown link", () => {
const violations = findSelfContainmentViolations("line\n[steal](../sibling-skill/SKILL.md)\n", "")
expect(violations.length).toBe(1)
expect(violations[0].lineNumber).toBe(2)
})
test("catches installed-plugin path mentions in prose", () => {
const violations = findSelfContainmentViolations(
"Read `~/.claude/plugins/cache/marketplace/plugin/1.0.0/skills/other/file.md` for context.",
"",
)
expect(violations.length).toBe(1)
})
test("ignores documentation examples inside fenced code blocks", () => {
const sample = "```\n[bad](../outside.md)\ncat ~/.claude/plugins/installed_plugins.json\n```\n"
expect(findSelfContainmentViolations(sample, "")).toEqual([])
})
test("ignores external URLs, anchors, and placeholder templates", () => {
const sample =
"[a](https://example.com) [b](#section) [c](references/<name>.md) [d](mailto:x@y.z)"
expect(findSelfContainmentViolations(sample, "")).toEqual([])
})
test("passes clean skill-local references", () => {
const sample = "Read `references/foo.md` and [the guide](references/guide.md#usage)."
expect(findSelfContainmentViolations(sample, "")).toEqual([])
})
})
describe("extractLocalReferenceCandidates", () => {
test("collects relative link targets and span path tokens (bare and command-embedded), stripping fragments", () => {
const sample =
"[guide](references/guide.md#setup)\nRead `references/foo.md` and run `bash scripts/run.sh <ARG>`."
expect(extractLocalReferenceCandidates(sample).map((c) => c.value)).toEqual([
"references/guide.md",
"references/foo.md",
"scripts/run.sh",
])
})
test("skips fenced templates, placeholders, externals, and absolute targets", () => {
const sample =
"```\n![Before](url-1)\n```\n[t](references/<topic>.md) [u](https://x.dev) [v](/abs/file.md)"
expect(extractLocalReferenceCandidates(sample)).toEqual([])
})
test("includes fenced script invocations: an existing script passes, a missing one is caught", () => {
// Extraction is what makes a fenced mention visible to the existence
// check; resolution + statSync against the real skill then demonstrates
// the pass/caught split the repo scan enforces.
const sample = "```bash\nbash scripts/session-history/extract-metadata.py\nbash scripts/deleted-tool.sh\n```"
expect(extractLocalReferenceCandidates(sample).map((c) => c.value)).toEqual([
"scripts/session-history/extract-metadata.py",
"scripts/deleted-tool.sh",
])
const skillRoot = path.join(SKILLS_ROOT, "ce-compound")
const existing = resolveInsideSkill(skillRoot, skillRoot, "scripts/session-history/extract-metadata.py")
const missing = resolveInsideSkill(skillRoot, skillRoot, "scripts/deleted-tool.sh")
expect(existing).not.toBeNull()
expect(statSync(existing!).isFile()).toBe(true)
expect(missing).not.toBeNull()
expect(() => statSync(missing!)).toThrow()
})
test("skips fenced placeholder-bearing path tokens", () => {
expect(extractLocalReferenceCandidates("```bash\nbash scripts/<generated-name>\n```")).toEqual([])
})
test("collects prose at-include targets (load-time inlining)", () => {
const sample = "### Subagent Template\n\n@./references/subagent-template.md\n"
expect(extractLocalReferenceCandidates(sample).map((c) => c.value)).toEqual([
"references/subagent-template.md",
])
})
test("variable-prefixed invocations are extracted and anchor at the skill root", () => {
// The documented cross-platform style: the variable prefix means
// "skill-root-relative" (AGENTS.md "Platform-Specific Variables in
// Skills"), so the unwrapped remainder must resolve at the skill root —
// demonstrated against a real bundled script (ce-compound's).
const sample =
'```bash\nbash "${CLAUDE_SKILL_DIR:-.}/scripts/validate-frontmatter.py"\n```'
expect(extractLocalReferenceCandidates(sample).map((c) => c.value)).toEqual([
"scripts/validate-frontmatter.py",
])
const skillRoot = path.join(SKILLS_ROOT, "ce-compound")
const resolved = resolveInsideSkill(skillRoot, skillRoot, "scripts/validate-frontmatter.py")
expect(resolved).not.toBeNull()
expect(statSync(resolved!).isFile()).toBe(true)
})
})
describe("resolveInsideSkill", () => {
const skillRoot = path.join(
SKILLS_ROOT,
"ce-resolve-pr-feedback",
)
test("rejects ../ traversal to a sibling skill even when the target exists there", () => {
const sibling = "../ce-plan/references/plan-handoff.md"
// The target genuinely exists in the sibling skill — existence outside
// the skill must not satisfy Rule 2; it is the cross-skill reference.
expect(statSync(path.resolve(skillRoot, sibling)).isFile()).toBe(true)
expect(resolveInsideSkill(skillRoot, skillRoot, sibling)).toBeNull()
})
test("rejects obfuscated in-prefix traversal (references/../../other-skill/...)", () => {
expect(
resolveInsideSkill(skillRoot, skillRoot, "references/../../ce-plan/references/plan-handoff.md"),
).toBeNull()
})
test("accepts in-skill ../ traversal (references/ -> scripts/)", () => {
expect(
resolveInsideSkill(skillRoot, path.join(skillRoot, "references"), "../scripts/get-pr-comments"),
).toBe(path.join(skillRoot, "scripts", "get-pr-comments"))
})
test("accepts skill-root-relative paths", () => {
expect(resolveInsideSkill(skillRoot, skillRoot, "references/targeted-mode.md")).toBe(
path.join(skillRoot, "references", "targeted-mode.md"),
)
})
})
describe("findPlatformVarOccurrences / isGracefulPlatformVarUse", () => {
test("flags a bare ${CLAUDE_PLUGIN_ROOT} as non-graceful", () => {
const occurrences = findPlatformVarOccurrences("python3 ${CLAUDE_PLUGIN_ROOT}/scripts/x.py")
expect(occurrences).toEqual([
{ lineNumber: 1, variable: "CLAUDE_PLUGIN_ROOT", graceful: false },
])
})
test("treats ${VAR:-default} as graceful for ordinary platform vars", () => {
const occurrences = findPlatformVarOccurrences('echo "${CODEX_SANDBOX:-0}"')
expect(occurrences).toEqual([{ lineNumber: 1, variable: "CODEX_SANDBOX", graceful: true }])
})
test("does NOT treat ${CLAUDE_SKILL_DIR:-.} as graceful (issue #943 regression)", () => {
// A skill-dir var with a `:-` shell default silently misses the bundled
// script off-Claude — it must use an existence guard, not `:-`.
const occurrences = findPlatformVarOccurrences('bash "${CLAUDE_SKILL_DIR:-.}/scripts/x.sh"')
expect(occurrences).toEqual([{ lineNumber: 1, variable: "CLAUDE_SKILL_DIR", graceful: false }])
})
test("treats [ -n \"$VAR\" ] existence guards as graceful", () => {
const occurrences = findPlatformVarOccurrences('if [ -n "$CODEX_SANDBOX" ] || [ -n "$CODEX_SESSION_ID" ]; then')
expect(occurrences.map((o) => o.graceful)).toEqual([true, true])
})
test("ignores credential-style env vars like GEMINI_API_KEY", () => {
expect(findPlatformVarOccurrences("export GEMINI_API_KEY=$GEMINI_API_KEY")).toEqual([])
})
})
describe("findPlatformVarViolations", () => {
test("reports every non-graceful occurrence (acknowledgment happens via the registry, not the scanner)", () => {
// A `!` pre-resolution line embedding a platform var is exactly the shape
// this scanner must flag: the `${CLAUDE_PLUGIN_ROOT}` occurrence is
// reported regardless of the surrounding `!` (which is itself banned by
// tests/skill-shell-safety.test.ts). The prose fallback below is not judged
// by the scanner — a genuine runtime use would be acknowledged in
// PLATFORM_VAR_ACKNOWLEDGED with a reason.
const preResolutionWithPlatformVar = [
'**Plugin version (pre-resolved):** !`jq -r .version "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json"`',
"",
"If the line above resolved to a semantic version (e.g., `2.42.0`), use it.",
"Otherwise (empty, a literal command string, or an error), use the versionless fallback.",
"Do not attempt to resolve the version at runtime.",
].join("\n")
expect(findPlatformVarViolations(preResolutionWithPlatformVar)).toEqual([
{ lineNumber: 1, variable: "CLAUDE_PLUGIN_ROOT", graceful: false },
])
})
test("reports occurrences inside fenced code blocks too", () => {
const sample = [
"```bash",
"python3 ${CLAUDE_PLUGIN_ROOT}/scripts/x.py",
"```",
].join("\n")
expect(findPlatformVarViolations(sample).map((v) => v.variable)).toEqual(["CLAUDE_PLUGIN_ROOT"])
})
test("graceful uses are not reported", () => {
expect(findPlatformVarViolations('echo "${CODEX_SANDBOX:-0}"')).toEqual([])
})
test("a ${CLAUDE_SKILL_DIR:-.} bundled-script path IS reported (issue #943)", () => {
expect(
findPlatformVarViolations('bash "${CLAUDE_SKILL_DIR:-.}/scripts/x.sh"').map((v) => v.variable),
).toEqual(["CLAUDE_SKILL_DIR"])
})
test("an existence-guarded bundled-script block reports no violations (issue #943)", () => {
// The guard line opens the block; the guarded call on the next line is
// graceful because it sits inside the open `if [ -f ... ]; then ... fi`.
const sample = [
'```bash',
'if [ -f "${CLAUDE_SKILL_DIR}/scripts/x.sh" ]; then',
' bash "${CLAUDE_SKILL_DIR}/scripts/x.sh" create feat/login',
'else',
' echo "unavailable on this platform"',
'fi',
'```',
].join("\n")
expect(findPlatformVarViolations(sample)).toEqual([])
})
test("a guarded script does NOT mask a second unguarded script of the same var (block-scoped)", () => {
// x.py is guarded; y.py — after `fi`, outside any guard — must still be
// reported, or the convention would miss issue #943's bug for y.py.
const sample = [
'```bash',
'if [ -f "${CLAUDE_SKILL_DIR}/scripts/x.py" ]; then',
' python3 "${CLAUDE_SKILL_DIR}/scripts/x.py"',
'fi',
'python3 "${CLAUDE_SKILL_DIR}/scripts/y.py"',
'```',
].join("\n")
const violations = findPlatformVarViolations(sample)
expect(violations.map((v) => v.lineNumber)).toEqual([5])
})
test("a single-line guard does not leak gracefulness to later lines in the same fence", () => {
// The guard and call are one line; the `fi` closes the block, so the
// following unguarded y.py must still be reported (regression: an earlier
// version cleared the guard only on a line that was exactly `fi`).
const sample = [
'```bash',
'if [ -f "${CLAUDE_SKILL_DIR}/scripts/x.py" ]; then python3 "${CLAUDE_SKILL_DIR}/scripts/x.py"; fi',
'python3 "${CLAUDE_SKILL_DIR}/scripts/y.py"',
'```',
].join("\n")
expect(findPlatformVarViolations(sample).map((v) => v.lineNumber)).toEqual([3])
})
test("prose mentions of a guarded var are not reported (no acknowledgment noise)", () => {
// The executable use is guarded in a fence; the later prose sentence
// mentioning the same var must not be flagged.
const sample = [
'```bash',
'if [ -f "${CLAUDE_SKILL_DIR}/scripts/x.sh" ]; then bash "${CLAUDE_SKILL_DIR}/scripts/x.sh"; fi',
'```',
'',
'On Claude Code `${CLAUDE_SKILL_DIR}` resolves to the skill directory.',
].join("\n")
expect(findPlatformVarViolations(sample)).toEqual([])
})
test("a bare ${VAR} use with no existence guard anywhere is still reported", () => {
const sample = [
'```bash',
'python3 "${CLAUDE_SKILL_DIR}/scripts/x.py" out.md',
'```',
].join("\n")
expect(findPlatformVarViolations(sample).map((v) => v.variable)).toEqual(["CLAUDE_SKILL_DIR"])
})
})