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"])
  })
})
