From 40516113e61da77442aa99829a4432ab78aea6a6 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:28:55 +0800 Subject: [PATCH 1/6] feat(validate): guard skills against T3 provider discovery pitfalls T3 Code lists provider-discovered skills. Cursor and Antigravity drop a skill whose frontmatter YAML fails to parse (only Claude repairs it), and T3's composer only forms a skill chip for names matching [a-zA-Z0-9][a-zA-Z0-9:_-]* with a letter. validate.mjs now rejects plain name/description scalars that are invalid YAML (': ', ' #', leading indicator) and names outside the chip pattern, leaving quoted and block scalars alone. Also add docs/session-context/T3-CODE.md, an opt-in advisory routing note for sessions where the t3-code MCP tools are present. Co-Authored-By: Claude Sonnet 5.5 --- docs/session-context/T3-CODE.md | 78 +++++++++++++++++++++++++++++++++ package.json | 2 +- scripts/skill-compat-lib.mjs | 39 +++++++++++++++++ scripts/skill-compat.test.mjs | 34 ++++++++++++++ scripts/validate.mjs | 2 + 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 docs/session-context/T3-CODE.md create mode 100644 scripts/skill-compat-lib.mjs create mode 100644 scripts/skill-compat.test.mjs diff --git a/docs/session-context/T3-CODE.md b/docs/session-context/T3-CODE.md new file mode 100644 index 0000000..feec8cc --- /dev/null +++ b/docs/session-context/T3-CODE.md @@ -0,0 +1,78 @@ +# Optional session context: T3 Code + +This file is an explicit, user-selected routing artifact for sessions that run +inside T3 Code and should keep following mstack's `/meta-mode` workflow. It +complements `SESSION-CONTEXT.md`; the mstack installer never registers it as a +hook, startup command, or implicit instruction. + +## Opt-in contract + +1. Select the `session-context` artifact explicitly with + `--artifact session-context`. This installs the whole `session-context/` + directory, so this file arrives next to `SESSION-CONTEXT.md`. +2. Configure the active harness to read this file using its documented, + user-owned session or project configuration surface. +3. Confirm the harness configuration and review the file before using it in a + sensitive repository. + +If the file is not explicitly selected and configured, do nothing. + +## When this applies + +Apply this only when the `t3-code` MCP server's tools are actually present in +the session. T3 Code injects that server and its own orchestration instructions +into hosted provider sessions, and its tool names may carry a harness prefix such +as `mcp__t3_code__delegate_task`. T3 attaches the server lazily on some +harnesses, so if a tool scan shows nothing, make one bounded direct call to +`orchestrator_capabilities` before concluding the tools are absent. + +If the tools are absent or that call fails, do nothing special and follow +`/meta-mode` normally, including its usual `run-role` and PR-watching paths. + +## Routing inside T3 Code + +These rules refine, and never replace, T3's own injected instructions and the +harness's normal approval and tool policy. + +- Delegation: prefer `delegate_task` over spawning provider CLIs with + `run-role` for child work. Pick the provider and model from + `orchestrator_capabilities`, keep each returned `taskId`, and manage it with + `task_status` or `task_cancel`. For every further round, call `delegate_task` + again with the full brief, prior findings, and unresolved objections and a new + `clientRequestId`; do not continue a child with `t3_thread_send` on its + `childThreadId`. +- Threads: do not use `t3_thread_launch` or `create_threads` for a plain + "subagent" or parallel-work request. They create top-level conversations and + are for when the user explicitly asks for separate threads. +- Pull requests: call `link_pull_request` with the full URL for every PR you + create or work on, including each layer of a stack. When asked to watch or + babysit a PR, call `watch_pull_request` and end the turn instead of polling or + running the Bun PR watcher in `tools/meta-mode/watch-pr`; T3 wakes the thread + on check completion, new comments, or conflicts. Call `unwatch_pull_request` + before handing the work back to the user. + +## When the task is T3 Code itself + +If the repository being changed is T3 Code, its `AGENTS.md` applies and takes +precedence over this file. Its three hazards, because the user's own T3 instance +is often the one running you: + +- Never kill a process by matching a name, path, or worktree string. Kill only a + PID you captured when you started it. +- Never start a server against, or write to, `~/.t3/userdata`. Copy data into + your worktree's own state instead. +- Never set `VITE_HTTP_URL` or `VITE_WS_URL` for development. + +## Security and portability boundary + +This is plain text, not executable configuration. It does not grant tools, +permissions, network access, or persistence, and it does not configure or +authenticate the `t3-code` MCP server; T3 Code owns that. Keep routing advisory: +do not execute commands, modify files, install hooks, or send data without the +harness's normal approval and tool policy. Hooks remain unsupported unless +explicitly configured outside mstack. + +Do not put secrets, credentials, transcript contents, bearer tokens, or +machine-specific paths in this file. Keep the canonical skill tree +Harness-neutral; this artifact is a separate optional boundary for users who +choose session routing. diff --git a/package.json b/package.json index 39b52c3..36df278 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "install-skills": "node scripts/install.mjs", "optimize-context": "node scripts/optimize-context.mjs", "reconcile-context": "node scripts/reconcile-context.mjs", - "test": "node scripts/validate.mjs && node scripts/skill-baseline.mjs --check && node --test scripts/install.test.mjs scripts/install-migration.test.mjs scripts/migration-integration.test.mjs scripts/skill-discovery.test.mjs scripts/harness-targets.test.mjs scripts/remote-discovery.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/model-budget.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs tools/meta-mode/check-plan.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/skill-baseline.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs scripts/harness-policy.test.mjs scripts/harness-project.test.mjs scripts/check-upstream-adaptations.test.mjs scripts/check-playbooks.test.mjs scripts/pack-claude-skills.test.mjs scripts/session-hook.test.mjs" + "test": "node scripts/validate.mjs && node scripts/skill-baseline.mjs --check && node --test scripts/install.test.mjs scripts/install-migration.test.mjs scripts/migration-integration.test.mjs scripts/skill-discovery.test.mjs scripts/harness-targets.test.mjs scripts/remote-discovery.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/model-budget.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs tools/meta-mode/check-plan.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/skill-baseline.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs scripts/harness-policy.test.mjs scripts/harness-project.test.mjs scripts/check-upstream-adaptations.test.mjs scripts/check-playbooks.test.mjs scripts/pack-claude-skills.test.mjs scripts/session-hook.test.mjs scripts/skill-compat.test.mjs" }, "bin": { "mstack": "scripts/install.mjs", diff --git a/scripts/skill-compat-lib.mjs b/scripts/skill-compat-lib.mjs new file mode 100644 index 0000000..69dc632 --- /dev/null +++ b/scripts/skill-compat-lib.mjs @@ -0,0 +1,39 @@ +// T3 Code lists skills discovered by each provider. Cursor's and Antigravity's +// scanners drop a skill whose frontmatter fails YAML parsing, and T3's composer +// only forms a skill chip for names matching the pattern below. Keep these +// checks dependency-free: they flag only plain scalars that are invalid YAML. + +const T3_SKILL_NAME = /^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/; +const PLAIN_SCALAR_FIELDS = ["name", "description"]; +// YAML indicators that cannot start a plain scalar (quote and block forms are handled earlier). +const LEADING_INDICATORS = new Set(["[", "{", "&", "*", "!", "%", "@", "`"]); + +function fieldValue(frontmatter, field) { + return frontmatter.match(new RegExp(`^${field}:[ \\t]*(.*)$`, "m"))?.[1]?.trim(); +} + +function plainScalarProblem(value) { + if (LEADING_INDICATORS.has(value[0])) return `starts with YAML indicator ${value[0]}`; + if (/:(\s|$)/.test(value)) return "contains ': ' (invalid in a plain YAML scalar)"; + if (/\s#/.test(value)) return "contains ' #' (starts a YAML comment)"; + return undefined; +} + +/** + * Returns problems that make T3-hosted providers drop or fail to chip a skill. + * Quoted and block scalars are accepted as written. + */ +export function skillCompatProblems(frontmatter) { + const problems = []; + for (const field of PLAIN_SCALAR_FIELDS) { + const raw = fieldValue(frontmatter, field); + if (!raw || /^["'|>]/.test(raw)) continue; + const problem = plainScalarProblem(raw); + if (problem) problems.push(`${field} ${problem}; quote it`); + } + const name = fieldValue(frontmatter, "name")?.replace(/^(["'])(.*)\1$/, "$2"); + if (name && (!T3_SKILL_NAME.test(name) || !/[a-zA-Z]/.test(name))) { + problems.push(`name ${name} does not match the T3 skill chip pattern ${T3_SKILL_NAME}`); + } + return problems; +} diff --git a/scripts/skill-compat.test.mjs b/scripts/skill-compat.test.mjs new file mode 100644 index 0000000..b9fbcdf --- /dev/null +++ b/scripts/skill-compat.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { skillCompatProblems } from "./skill-compat-lib.mjs"; + +const cases = [ + ["valid plain", "name: meta-mode\ndescription: Route a task through a workflow.", 0], + ["valid plain with colon inside a word", "name: a\ndescription: See http://example.com/x or a:b", 0], + ["valid double-quoted", 'name: tdd\ndescription: "Use when: a bug has a test, # not a comment"', 0], + ["valid single-quoted", "name: tdd\ndescription: 'Use when: a bug'", 0], + ["valid block scalar", "name: tdd\ndescription: >\n Use when: a bug", 0], + ["valid literal block", "name: tdd\ndescription: |-\n Use when: a bug", 0], + ["valid empty value followed by block", "name: tdd\ndescription:\n Use when: a bug", 0], + ["plain with ': '", "name: tdd\ndescription: Use when: a bug", 1, /description contains ': '/], + ["plain with trailing colon", "name: tdd\ndescription: Use for bugs:", 1, /description contains ': '/], + ["plain with ' #'", "name: tdd\ndescription: Use for bugs #urgent", 1, /description contains ' #'/], + ["plain with leading bracket", "name: tdd\ndescription: [draft] Use for bugs", 1, /starts with YAML indicator \[/], + ["plain with leading backtick", "name: tdd\ndescription: `tdd` helper", 1, /starts with YAML indicator `/], + ["plain with leading star", "name: tdd\ndescription: *important* bugs", 1, /starts with YAML indicator \*/], + ["plain name with ': '", "name: a: b\ndescription: ok", 2, /name contains ': '/], + ["name with space", "name: my skill\ndescription: ok", 1, /name my skill does not match/], + ["name with dot", "name: my.skill\ndescription: ok", 1, /does not match/], + ["name starting with hyphen", "name: -skill\ndescription: ok", 1, /does not match/], + ["name without a letter", "name: 123\ndescription: ok", 1, /does not match/], + ["quoted name that violates the pattern", 'name: "my skill"\ndescription: ok', 1, /does not match/], + ["valid name with colon and underscore", "name: plugin:my_skill-2\ndescription: ok", 0], +]; + +for (const [label, frontmatter, count, pattern] of cases) { + test(`skill compat: ${label}`, () => { + const problems = skillCompatProblems(frontmatter); + assert.equal(problems.length, count, problems.join("\n")); + if (pattern) assert.match(problems.join("\n"), pattern); + }); +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 1e5b37b..8faa189 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -3,6 +3,7 @@ import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { validateArtifactOverrides } from "./install-paths.mjs"; import { validatePluginAgents, validatePluginHooks } from "./claude-package-lib.mjs"; +import { skillCompatProblems } from "./skill-compat-lib.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const skillsRoot = join(repoRoot, "skills"); @@ -86,6 +87,7 @@ for (const skill of actual) { const description = frontmatter[1].match(/^description:\s*(.+)$/m)?.[1]?.trim(); if (name !== skill) errors.push(`${skill}: frontmatter name is ${name ?? "missing"}`); if (!description) errors.push(`${skill}: missing description`); + for (const problem of skillCompatProblems(frontmatter[1])) errors.push(`${skill}: ${problem}`); for (const match of content.matchAll(/\[[^\]]+\]\((?!https?:|#)([^)]+)\)/g)) { const linked = resolve(dirname(path), match[1]); if (!existsSync(linked)) errors.push(`${skill}: broken relative link ${match[1]}`); From 17aaa42600e6f76954da9980c5c4d77a82d719a0 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:32:15 +0800 Subject: [PATCH 2/6] fix(validate): check full plain scalars and YAML-only separators Flag conditional indicators (-, ?, : followed by a space or tab), validate continuation lines of multi-line plain scalars, and use [ \t] instead of \s so Unicode spaces such as NBSP are not misread as YAML separators. Replace the nested-mapping fixture with a genuine multiline string and add regression cases including CRLF. Co-Authored-By: Claude Sonnet 5.5 --- scripts/skill-compat-lib.mjs | 55 ++++++++++++++++++++++++++--------- scripts/skill-compat.test.mjs | 25 +++++++++++++++- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/scripts/skill-compat-lib.mjs b/scripts/skill-compat-lib.mjs index 69dc632..500d423 100644 --- a/scripts/skill-compat-lib.mjs +++ b/scripts/skill-compat-lib.mjs @@ -2,20 +2,46 @@ // scanners drop a skill whose frontmatter fails YAML parsing, and T3's composer // only forms a skill chip for names matching the pattern below. Keep these // checks dependency-free: they flag only plain scalars that are invalid YAML. +// +// YAML separation whitespace is space and tab only, so every check below uses +// [ \t] and never \s (which also matches NBSP and other Unicode spaces that are +// ordinary plain-scalar text). const T3_SKILL_NAME = /^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/; const PLAIN_SCALAR_FIELDS = ["name", "description"]; -// YAML indicators that cannot start a plain scalar (quote and block forms are handled earlier). -const LEADING_INDICATORS = new Set(["[", "{", "&", "*", "!", "%", "@", "`"]); +// Indicators that can never start a plain scalar (quote and block forms are handled earlier). +const ALWAYS_INDICATORS = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "%", "@", "`"]); +// `-`, `?` and `:` are indicators only when followed by a separator. +const CONDITIONAL_INDICATORS = /^[-?:]([ \t]|$)/; -function fieldValue(frontmatter, field) { - return frontmatter.match(new RegExp(`^${field}:[ \\t]*(.*)$`, "m"))?.[1]?.trim(); +const trimSeparators = (text) => text.replace(/^[ \t]+|[ \t]+$/g, ""); + +/** Reads a top-level field: its same-line value and any indented continuation lines. */ +function readField(lines, field) { + const start = lines.findIndex((line) => new RegExp(`^${field}:([ \\t]|$)`).test(line)); + if (start === -1) return undefined; + let first = trimSeparators(lines[start].slice(field.length + 1)); + if (first.startsWith("#")) first = ""; // the whole remainder is a comment + const continuation = []; + for (const line of lines.slice(start + 1)) { + if (line.trim() === "") continue; + if (!/^[ \t]/.test(line)) break; + continuation.push(line); + } + return { first, continuation }; } -function plainScalarProblem(value) { - if (LEADING_INDICATORS.has(value[0])) return `starts with YAML indicator ${value[0]}`; - if (/:(\s|$)/.test(value)) return "contains ': ' (invalid in a plain YAML scalar)"; - if (/\s#/.test(value)) return "contains ' #' (starts a YAML comment)"; +function plainScalarProblem(field) { + const lines = [field.first, ...field.continuation].filter((line) => trimSeparators(line) !== ""); + if (lines.length === 0) return undefined; + const head = trimSeparators(lines[0]); + if (ALWAYS_INDICATORS.has(head[0]) || CONDITIONAL_INDICATORS.test(head)) { + return `starts with YAML indicator ${head[0]}`; + } + for (const line of lines) { + if (/:([ \t]|$)/.test(trimSeparators(line))) return "contains ': ' (invalid in a plain YAML scalar)"; + if (/(^|[ \t])#/.test(line)) return "contains ' #' (starts a YAML comment)"; + } return undefined; } @@ -24,14 +50,15 @@ function plainScalarProblem(value) { * Quoted and block scalars are accepted as written. */ export function skillCompatProblems(frontmatter) { + const lines = frontmatter.split(/\r?\n/); const problems = []; - for (const field of PLAIN_SCALAR_FIELDS) { - const raw = fieldValue(frontmatter, field); - if (!raw || /^["'|>]/.test(raw)) continue; - const problem = plainScalarProblem(raw); - if (problem) problems.push(`${field} ${problem}; quote it`); + for (const fieldName of PLAIN_SCALAR_FIELDS) { + const field = readField(lines, fieldName); + if (!field || /^["'|>]/.test(field.first)) continue; + const problem = plainScalarProblem(field); + if (problem) problems.push(`${fieldName} ${problem}; quote it`); } - const name = fieldValue(frontmatter, "name")?.replace(/^(["'])(.*)\1$/, "$2"); + const name = readField(lines, "name")?.first.replace(/^(["'])(.*)\1$/, "$2"); if (name && (!T3_SKILL_NAME.test(name) || !/[a-zA-Z]/.test(name))) { problems.push(`name ${name} does not match the T3 skill chip pattern ${T3_SKILL_NAME}`); } diff --git a/scripts/skill-compat.test.mjs b/scripts/skill-compat.test.mjs index b9fbcdf..30f7518 100644 --- a/scripts/skill-compat.test.mjs +++ b/scripts/skill-compat.test.mjs @@ -9,7 +9,30 @@ const cases = [ ["valid single-quoted", "name: tdd\ndescription: 'Use when: a bug'", 0], ["valid block scalar", "name: tdd\ndescription: >\n Use when: a bug", 0], ["valid literal block", "name: tdd\ndescription: |-\n Use when: a bug", 0], - ["valid empty value followed by block", "name: tdd\ndescription:\n Use when: a bug", 0], + ["valid multiline plain string", "name: tdd\ndescription: Use for bugs\n when a test fails\n or a regression appears", 0], + ["valid multiline plain string starting on the next line", "name: tdd\ndescription:\n Use for bugs\n when a test fails", 0], + ["valid multiline plain string with CRLF", "name: tdd\r\ndescription: Use for bugs\r\n when a test fails\r\n", 0], + ["valid hyphen, question mark and colon without a separator", "name: tdd\ndescription: -fast ?maybe :colon a-b", 0], + ["valid NBSP after a colon", "name: tdd\ndescription: Use when: a bug", 0], + ["valid NBSP before a hash", "name: tdd\ndescription: Use for bugs #urgent", 0], + ["valid ideographic space around a colon and hash", "name: tdd\ndescription: Use when: a bug  #x", 0], + ["valid next field after a multiline value", "name: tdd\ndescription: Use for bugs\n when a test fails\nmetadata:\n a: b", 0], + ["nested mapping as description", "name: tdd\ndescription:\n Use when: a bug", 1, /description contains ': '/], + ["sequence as description", "name: tdd\ndescription:\n - Run tests", 1, /starts with YAML indicator -/], + ["leading hyphen separator", "name: tdd\ndescription: - Run tests", 1, /starts with YAML indicator -/], + ["leading hyphen separator with CRLF", "name: tdd\r\ndescription: - Run tests\r\n", 1, /starts with YAML indicator -/], + ["leading hyphen separator with a tab", "name: tdd\ndescription: -\tRun tests", 1, /starts with YAML indicator -/], + ["leading question mark separator", "name: tdd\ndescription: ? Run tests", 1, /starts with YAML indicator \?/], + ["leading colon separator", "name: tdd\ndescription: : Run tests", 1, /starts with YAML indicator :/], + ["lone leading hyphen", "name: tdd\ndescription: -", 1, /starts with YAML indicator -/], + ["leading flow closer", "name: tdd\ndescription: ] Run tests", 1, /starts with YAML indicator \]/], + ["colon continuation line", "name: tdd\ndescription: Use for bugs\n Use when: a test fails", 1, /description contains ': '/], + ["colon continuation line with CRLF", "name: tdd\r\ndescription: Use for bugs\r\n Use when: a test fails\r\n", 1, /description contains ': '/], + ["trailing colon on a continuation line", "name: tdd\ndescription: Use for bugs\n Use when:\n a test fails", 1, /description contains ': '/], + ["hash continuation line", "name: tdd\ndescription: Use for bugs\n when a test fails #urgent", 1, /description contains ' #'/], + ["comment-only continuation line", "name: tdd\ndescription: Use for bugs\n # note\n when a test fails", 1, /description contains ' #'/], + ["tab separator before a hash", "name: tdd\ndescription: Use for bugs\t#urgent", 1, /description contains ' #'/], + ["tab separator after a colon", "name: tdd\ndescription: Use when:\ta bug", 1, /description contains ': '/], ["plain with ': '", "name: tdd\ndescription: Use when: a bug", 1, /description contains ': '/], ["plain with trailing colon", "name: tdd\ndescription: Use for bugs:", 1, /description contains ': '/], ["plain with ' #'", "name: tdd\ndescription: Use for bugs #urgent", 1, /description contains ' #'/], From aec000ae1ab980ff01e66cb1486f0cc3ad51bd34 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:35:46 +0800 Subject: [PATCH 3/6] fix(validate): reject empty, malformed quoted, and malformed block scalars Report comment-only or empty name/description values, require quoted scalars to terminate with only a comment after the closing quote, and require block scalar headers to carry only indent/chomp indicators. Reword the ' #' problem to say it truncates the value and document that this is deliberately stricter than YAML. Co-Authored-By: Claude Sonnet 5.5 --- scripts/skill-compat-lib.mjs | 79 +++++++++++++++++++++++++++-------- scripts/skill-compat.test.mjs | 28 +++++++++++++ 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/scripts/skill-compat-lib.mjs b/scripts/skill-compat-lib.mjs index 500d423..19ba20c 100644 --- a/scripts/skill-compat-lib.mjs +++ b/scripts/skill-compat-lib.mjs @@ -1,18 +1,28 @@ // T3 Code lists skills discovered by each provider. Cursor's and Antigravity's // scanners drop a skill whose frontmatter fails YAML parsing, and T3's composer // only forms a skill chip for names matching the pattern below. Keep these -// checks dependency-free: they flag only plain scalars that are invalid YAML. +// checks dependency-free: they inspect only the scalar forms a skill description +// or name can take (plain, quoted, block), not YAML in general. // // YAML separation whitespace is space and tab only, so every check below uses // [ \t] and never \s (which also matches NBSP and other Unicode spaces that are // ordinary plain-scalar text). +// +// Deliberately stricter than YAML: ` #` inside a plain scalar is a valid comment, +// not a parse error, but it silently truncates the value. A truncated +// description misroutes the skill, so it is rejected like a parse failure. const T3_SKILL_NAME = /^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/; const PLAIN_SCALAR_FIELDS = ["name", "description"]; -// Indicators that can never start a plain scalar (quote and block forms are handled earlier). +// Indicators that can never start a plain scalar (quote and block forms are handled separately). const ALWAYS_INDICATORS = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "%", "@", "`"]); // `-`, `?` and `:` are indicators only when followed by a separator. const CONDITIONAL_INDICATORS = /^[-?:]([ \t]|$)/; +// Block scalar header: indentation and chomping indicators only, then an optional comment. +const BLOCK_HEADER = /^[|>](?:[1-9][+-]?|[+-][1-9]?)?(?:[ \t]+#.*)?$/; +const COMMENT_TAIL = /^(?:[ \t]+#.*)?[ \t]*$/; +const BLANK_OR_COMMENT_LINE = /^[ \t]*(?:#.*)?$/; +const QUOTE_REMEDY = "quote the value"; const trimSeparators = (text) => text.replace(/^[ \t]+|[ \t]+$/g, ""); @@ -31,36 +41,71 @@ function readField(lines, field) { return { first, continuation }; } -function plainScalarProblem(field) { - const lines = [field.first, ...field.continuation].filter((line) => trimSeparators(line) !== ""); - if (lines.length === 0) return undefined; +/** Parses a quoted scalar that may continue onto indented lines. */ +function inspectQuoted(text) { + const quote = text[0]; + let close = -1; + for (let i = 1; i < text.length && close === -1; i++) { + if (quote === '"' && text[i] === "\\") i++; + else if (text[i] === quote) { + if (quote === "'" && text[i + 1] === "'") i++; + else close = i; + } + } + if (close === -1) return { problem: `has an unterminated ${quote === '"' ? "double" : "single"}-quoted scalar` }; + const [sameLine, ...later] = text.slice(close + 1).split("\n"); + if (!COMMENT_TAIL.test(sameLine) || !later.every((line) => BLANK_OR_COMMENT_LINE.test(line))) { + return { problem: `has content after the closing ${quote}` }; + } + const inner = text.slice(1, close); + return { value: quote === "'" ? inner.replaceAll("''", "'") : inner }; +} + +function inspectPlain(lines) { const head = trimSeparators(lines[0]); if (ALWAYS_INDICATORS.has(head[0]) || CONDITIONAL_INDICATORS.test(head)) { - return `starts with YAML indicator ${head[0]}`; + return { problem: `starts with YAML indicator ${head[0]}; ${QUOTE_REMEDY}` }; } + const value = lines.map(trimSeparators).join(" "); for (const line of lines) { - if (/:([ \t]|$)/.test(trimSeparators(line))) return "contains ': ' (invalid in a plain YAML scalar)"; - if (/(^|[ \t])#/.test(line)) return "contains ' #' (starts a YAML comment)"; + if (/:([ \t]|$)/.test(trimSeparators(line))) { + return { value, problem: `contains ': ' (invalid in a plain YAML scalar); ${QUOTE_REMEDY}` }; + } + // Includes an indented `#` line inside a multiline plain scalar. + if (/(^|[ \t])#/.test(line)) { + return { value, problem: `contains ' #', which starts a YAML comment and truncates the value; ${QUOTE_REMEDY}` }; + } } - return undefined; + return { value }; +} + +/** Returns `{ problem }` and/or the scalar's string `value` when it can be determined. */ +function inspectField(field) { + const lines = [field.first, ...field.continuation].filter((line) => trimSeparators(line) !== ""); + if (lines.length === 0) return { problem: "is empty or comment-only (YAML null)" }; + const head = trimSeparators(lines[0]); + if (/^[|>]/.test(head)) { + return BLOCK_HEADER.test(head) ? {} : { problem: `has an invalid block scalar header ${head}` }; + } + if (/^["']/.test(head)) return inspectQuoted(lines.map(trimSeparators).join("\n")); + return inspectPlain(lines); } /** * Returns problems that make T3-hosted providers drop or fail to chip a skill. - * Quoted and block scalars are accepted as written. + * Well-formed quoted and block scalars are accepted as written. */ export function skillCompatProblems(frontmatter) { const lines = frontmatter.split(/\r?\n/); const problems = []; for (const fieldName of PLAIN_SCALAR_FIELDS) { const field = readField(lines, fieldName); - if (!field || /^["'|>]/.test(field.first)) continue; - const problem = plainScalarProblem(field); - if (problem) problems.push(`${fieldName} ${problem}; quote it`); - } - const name = readField(lines, "name")?.first.replace(/^(["'])(.*)\1$/, "$2"); - if (name && (!T3_SKILL_NAME.test(name) || !/[a-zA-Z]/.test(name))) { - problems.push(`name ${name} does not match the T3 skill chip pattern ${T3_SKILL_NAME}`); + if (!field) continue; + const { problem, value } = inspectField(field); + if (problem) problems.push(`${fieldName} ${problem}`); + if (fieldName === "name" && value && (!T3_SKILL_NAME.test(value) || !/[a-zA-Z]/.test(value))) { + problems.push(`name ${value} does not match the T3 skill chip pattern ${T3_SKILL_NAME}`); + } } return problems; } diff --git a/scripts/skill-compat.test.mjs b/scripts/skill-compat.test.mjs index 30f7518..e21e642 100644 --- a/scripts/skill-compat.test.mjs +++ b/scripts/skill-compat.test.mjs @@ -17,6 +17,34 @@ const cases = [ ["valid NBSP before a hash", "name: tdd\ndescription: Use for bugs #urgent", 0], ["valid ideographic space around a colon and hash", "name: tdd\ndescription: Use when: a bug  #x", 0], ["valid next field after a multiline value", "name: tdd\ndescription: Use for bugs\n when a test fails\nmetadata:\n a: b", 0], + ["valid quoted colon", 'name: tdd\ndescription: "a: b"', 0], + ["valid single-quoted escape", "name: tdd\ndescription: 'it''s'", 0], + ["valid escaped double quote", 'name: tdd\ndescription: "say \\"hi\\": now"', 0], + ["valid quoted with trailing comment", 'name: tdd\ndescription: "a b" # note', 0], + ["valid quoted continuing onto indented lines", 'name: tdd\ndescription: "Use for bugs\n when: a test fails"', 0], + ["valid chomp-only block header", "name: tdd\ndescription: >-\n Use when: a bug", 0], + ["valid indent-only block header", "name: tdd\ndescription: |2\n Use when: a bug", 0], + ["valid indent and chomp block header", "name: tdd\ndescription: >2+\n Use when: a bug", 0], + ["valid block header with a comment", "name: tdd\ndescription: > # folded\n Use when: a bug", 0], + ["valid quoted name with a trailing comment", 'name: "tdd" # skill\ndescription: ok', 0], + ["comment-only description", "name: tdd\ndescription: # comment", 1, /description is empty or comment-only/], + ["comment without a space", "name: tdd\ndescription: #x", 1, /description is empty or comment-only/], + ["empty description", "name: tdd\ndescription:\nmetadata:\n a: b", 1, /description is empty or comment-only/], + ["comment-only name", "name: # skill\ndescription: ok", 1, /name is empty or comment-only/], + ["comment after the key then a value", "name: tdd\ndescription: # note\n Use for bugs", 0], + ["unterminated double quote", 'name: tdd\ndescription: "unterminated', 1, /unterminated double-quoted/], + ["unterminated single quote", "name: tdd\ndescription: 'unterminated", 1, /unterminated single-quoted/], + ["escaped closing double quote", 'name: tdd\ndescription: "abc\\"', 1, /unterminated double-quoted/], + ["unescaped apostrophe in single quotes", "name: tdd\ndescription: 'it's'", 1, /content after the closing '/], + ["content after a closing double quote", 'name: tdd\ndescription: "a" b', 1, /content after the closing "/], + ["hash glued to a closing quote", 'name: tdd\ndescription: "a"#b', 1, /content after the closing "/], + ["content on a later line after a closing quote", 'name: tdd\ndescription: "a"\n b', 1, /content after the closing "/], + ["quoted name unterminated", 'name: "tdd\ndescription: ok', 1, /name has an unterminated/], + ["block header with trailing text", "name: tdd\ndescription: |oops\n body", 1, /invalid block scalar header \|oops/], + ["block header with two indent digits", "name: tdd\ndescription: >12\n body", 1, /invalid block scalar header/], + ["block header with zero indent", "name: tdd\ndescription: |0\n body", 1, /invalid block scalar header/], + ["hash comment message", "name: tdd\ndescription: Use for bugs #urgent", 1, /starts a YAML comment and truncates the value; quote the value/], + ["indented hash line message", "name: tdd\ndescription: Use for bugs\n # note", 1, /starts a YAML comment and truncates the value; quote the value/], ["nested mapping as description", "name: tdd\ndescription:\n Use when: a bug", 1, /description contains ': '/], ["sequence as description", "name: tdd\ndescription:\n - Run tests", 1, /starts with YAML indicator -/], ["leading hyphen separator", "name: tdd\ndescription: - Run tests", 1, /starts with YAML indicator -/], From 1c3550d3c0a83156fc3df6289340f5df92292b65 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:38:53 +0800 Subject: [PATCH 4/6] fix(validate): validate double-quoted escapes and block scalar bodies Reject double-quoted escapes outside the YAML 1.2 set (and \x, \u, \U with the wrong digit count), and check that a block scalar has a non-empty body indented under its key and at least as deep as an explicit indentation indicator. Co-Authored-By: Claude Sonnet 5.5 --- scripts/skill-compat-lib.mjs | 39 +++++++++++++++++++++++++++++------ scripts/skill-compat.test.mjs | 24 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/scripts/skill-compat-lib.mjs b/scripts/skill-compat-lib.mjs index 19ba20c..d2c0196 100644 --- a/scripts/skill-compat-lib.mjs +++ b/scripts/skill-compat-lib.mjs @@ -15,11 +15,15 @@ const T3_SKILL_NAME = /^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/; const PLAIN_SCALAR_FIELDS = ["name", "description"]; // Indicators that can never start a plain scalar (quote and block forms are handled separately). -const ALWAYS_INDICATORS = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "%", "@", "`"]); +// A `|` or `>` is a block header only on the key's own line; on a continuation line it is invalid. +const ALWAYS_INDICATORS = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "%", "@", "`", "|", ">"]); // `-`, `?` and `:` are indicators only when followed by a separator. const CONDITIONAL_INDICATORS = /^[-?:]([ \t]|$)/; // Block scalar header: indentation and chomping indicators only, then an optional comment. const BLOCK_HEADER = /^[|>](?:[1-9][+-]?|[+-][1-9]?)?(?:[ \t]+#.*)?$/; +// YAML 1.2 double-quoted escapes. A backslash before a newline is a line continuation. +const SIMPLE_ESCAPES = '0abtnvfre \t"/\\N_LP\n'; +const HEX_ESCAPES = { x: 2, u: 4, U: 8 }; const COMMENT_TAIL = /^(?:[ \t]+#.*)?[ \t]*$/; const BLANK_OR_COMMENT_LINE = /^[ \t]*(?:#.*)?$/; const QUOTE_REMEDY = "quote the value"; @@ -46,8 +50,18 @@ function inspectQuoted(text) { const quote = text[0]; let close = -1; for (let i = 1; i < text.length && close === -1; i++) { - if (quote === '"' && text[i] === "\\") i++; - else if (text[i] === quote) { + if (quote === '"' && text[i] === "\\") { + const escape = text[i + 1]; + if (escape === undefined) break; // unterminated + const digits = HEX_ESCAPES[escape]; + if (digits !== undefined) { + if (!new RegExp(`^[0-9a-fA-F]{${digits}}$`).test(text.slice(i + 2, i + 2 + digits))) { + return { problem: `has an invalid escape \\${escape} (needs ${digits} hex digits)` }; + } + i += 1 + digits; + } else if (SIMPLE_ESCAPES.includes(escape)) i++; + else return { problem: `has an invalid escape \\${escape}; double-quoted YAML allows only the YAML 1.2 escapes` }; + } else if (text[i] === quote) { if (quote === "'" && text[i + 1] === "'") i++; else close = i; } @@ -61,6 +75,21 @@ function inspectQuoted(text) { return { value: quote === "'" ? inner.replaceAll("''", "'") : inner }; } +/** Checks a block scalar header and that its body is indented under the (indent 0) key. */ +function inspectBlock({ first, continuation }) { + if (!BLOCK_HEADER.test(first)) return { problem: `has an invalid block scalar header ${first}` }; + if (continuation.length === 0) return { problem: "has an empty block scalar body" }; + const indicators = first.match(/^[|>]([1-9+-]*)/)[1]; + const explicit = Number(indicators.match(/[1-9]/)?.[0] ?? 0); + const indents = continuation.map((line) => line.match(/^ */)[0].length); + // Without an indentation indicator YAML takes the first body line's indent. + const required = explicit || indents[0]; + if (indents.some((indent) => indent < Math.max(required, 1))) { + return { problem: `has a block scalar body line indented less than ${Math.max(required, 1)} space(s)` }; + } + return {}; +} + function inspectPlain(lines) { const head = trimSeparators(lines[0]); if (ALWAYS_INDICATORS.has(head[0]) || CONDITIONAL_INDICATORS.test(head)) { @@ -84,9 +113,7 @@ function inspectField(field) { const lines = [field.first, ...field.continuation].filter((line) => trimSeparators(line) !== ""); if (lines.length === 0) return { problem: "is empty or comment-only (YAML null)" }; const head = trimSeparators(lines[0]); - if (/^[|>]/.test(head)) { - return BLOCK_HEADER.test(head) ? {} : { problem: `has an invalid block scalar header ${head}` }; - } + if (/^[|>]/.test(field.first)) return inspectBlock(field); if (/^["']/.test(head)) return inspectQuoted(lines.map(trimSeparators).join("\n")); return inspectPlain(lines); } diff --git a/scripts/skill-compat.test.mjs b/scripts/skill-compat.test.mjs index e21e642..37ea0cd 100644 --- a/scripts/skill-compat.test.mjs +++ b/scripts/skill-compat.test.mjs @@ -27,6 +27,30 @@ const cases = [ ["valid indent and chomp block header", "name: tdd\ndescription: >2+\n Use when: a bug", 0], ["valid block header with a comment", "name: tdd\ndescription: > # folded\n Use when: a bug", 0], ["valid quoted name with a trailing comment", 'name: "tdd" # skill\ndescription: ok', 0], + ["valid escaped quote", 'name: tdd\ndescription: "say \\"hi\\""', 0], + ["valid escaped backslash", 'name: tdd\ndescription: "C:\\\\skills"', 0], + ["valid unicode escape", 'name: tdd\ndescription: "caf\\u00e9"', 0], + ["valid hex and long unicode escapes", 'name: tdd\ndescription: "\\x41 \\U0001F600 \\N\\_\\L\\P\\0\\a\\b\\t\\n\\v\\f\\r\\e\\/ \\ "', 0], + ["valid escaped line continuation", 'name: tdd\ndescription: "Use for \\\n bugs"', 0], + ["invalid escape C:\\skills", 'name: tdd\ndescription: "Use files under C:\\skills"', 1, /invalid escape \\s/], + ["short hex escape", 'name: tdd\ndescription: "a\\x4"', 1, /invalid escape \\x \(needs 2 hex digits\)/], + ["non-hex digits in hex escape", 'name: tdd\ndescription: "a\\x4g"', 1, /invalid escape \\x/], + ["short unicode escape", 'name: tdd\ndescription: "a\\u12"', 1, /invalid escape \\u \(needs 4 hex digits\)/], + ["short long-unicode escape", 'name: tdd\ndescription: "a\\U0001F60"', 1, /invalid escape \\U \(needs 8 hex digits\)/], + ["backslash at the end of the value", 'name: tdd\ndescription: "abc\\', 1, /unterminated double-quoted/], + ["backslash is literal in single quotes", "name: tdd\ndescription: 'C:\\skills'", 0], + ["valid block body", "name: tdd\ndescription: >-\n Use when a bug\n needs a test", 0], + ["valid block body with CRLF", "name: tdd\r\ndescription: >-\r\n Use when a bug\r\n needs a test\r\nmetadata:\r\n a: b", 0], + ["valid explicit indent with deeper body", "name: tdd\ndescription: >2\n Use when a bug\n needs a test", 0], + ["valid blank line inside a block body", "name: tdd\ndescription: |\n Use when a bug\n\n needs a test", 0], + ["block explicit indent with a 1-space line", "name: tdd\ndescription: >2\n Use when a bug\n needs a test", 1, /indented less than 2 space/], + ["block explicit indent with a 1-space line and CRLF", "name: tdd\r\ndescription: >2\r\n needs a test\r\n", 1, /indented less than 2 space/], + ["block body less indented than its first line", "name: tdd\ndescription: >\n Use when a bug\n needs a test", 1, /indented less than 4 space/], + ["block with an empty body", "name: tdd\ndescription: >-\nmetadata:\n a: b", 1, /empty block scalar body/], + ["block with an empty body at the end", "name: tdd\ndescription: |", 1, /empty block scalar body/], + ["block with an empty body and CRLF", "name: tdd\r\ndescription: >-\r\nmetadata:\r\n a: b\r\n", 1, /empty block scalar body/], + ["tab-indented block body", "name: tdd\ndescription: >\n\tUse when a bug", 1, /indented less than 1 space/], + ["block indicator on a continuation line", "name: tdd\ndescription:\n | Use when a bug", 1, /starts with YAML indicator \|/], ["comment-only description", "name: tdd\ndescription: # comment", 1, /description is empty or comment-only/], ["comment without a space", "name: tdd\ndescription: #x", 1, /description is empty or comment-only/], ["empty description", "name: tdd\ndescription:\nmetadata:\n a: b", 1, /description is empty or comment-only/], From de4dec526b4ce0b4ca59188626c6763a83ef6df6 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:41:29 +0800 Subject: [PATCH 5/6] fix(validate): accept block headers on the line after the key When the key's own line holds no value, treat a leading | or > on the next value-bearing line as the block header and validate its body. Co-Authored-By: Claude Sonnet 5.5 --- scripts/skill-compat-lib.mjs | 7 ++++++- scripts/skill-compat.test.mjs | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/skill-compat-lib.mjs b/scripts/skill-compat-lib.mjs index d2c0196..94b7bed 100644 --- a/scripts/skill-compat-lib.mjs +++ b/scripts/skill-compat-lib.mjs @@ -15,7 +15,7 @@ const T3_SKILL_NAME = /^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/; const PLAIN_SCALAR_FIELDS = ["name", "description"]; // Indicators that can never start a plain scalar (quote and block forms are handled separately). -// A `|` or `>` is a block header only on the key's own line; on a continuation line it is invalid. +// A leading `|` or `>` is a block header, handled before the plain-scalar check; otherwise invalid. const ALWAYS_INDICATORS = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "%", "@", "`", "|", ">"]); // `-`, `?` and `:` are indicators only when followed by a separator. const CONDITIONAL_INDICATORS = /^[-?:]([ \t]|$)/; @@ -113,7 +113,12 @@ function inspectField(field) { const lines = [field.first, ...field.continuation].filter((line) => trimSeparators(line) !== ""); if (lines.length === 0) return { problem: "is empty or comment-only (YAML null)" }; const head = trimSeparators(lines[0]); + // A block header is the value itself, either after the key or on the next value-bearing line. + const [next, ...rest] = field.continuation; if (/^[|>]/.test(field.first)) return inspectBlock(field); + if (field.first === "" && /^[|>]/.test(trimSeparators(next ?? ""))) { + return inspectBlock({ first: trimSeparators(next), continuation: rest }); + } if (/^["']/.test(head)) return inspectQuoted(lines.map(trimSeparators).join("\n")); return inspectPlain(lines); } diff --git a/scripts/skill-compat.test.mjs b/scripts/skill-compat.test.mjs index 37ea0cd..828dc1f 100644 --- a/scripts/skill-compat.test.mjs +++ b/scripts/skill-compat.test.mjs @@ -50,7 +50,17 @@ const cases = [ ["block with an empty body at the end", "name: tdd\ndescription: |", 1, /empty block scalar body/], ["block with an empty body and CRLF", "name: tdd\r\ndescription: >-\r\nmetadata:\r\n a: b\r\n", 1, /empty block scalar body/], ["tab-indented block body", "name: tdd\ndescription: >\n\tUse when a bug", 1, /indented less than 1 space/], - ["block indicator on a continuation line", "name: tdd\ndescription:\n | Use when a bug", 1, /starts with YAML indicator \|/], + ["header on the next line with trailing text", "name: tdd\ndescription:\n | Use when a bug", 1, /invalid block scalar header/], + ["valid header on the next line", "name: tdd\ndescription:\n >-\n Use when a bug has a cheap regression test.", 0], + ["valid header on the next line with CRLF", "name: tdd\r\ndescription:\r\n >-\r\n Use when a bug has a cheap regression test.\r\nmetadata:\r\n a: b\r\n", 0], + ["valid header on the next line with a same-indent body", "name: tdd\ndescription:\n |\n Use when: a bug", 0], + ["valid header on the next line after a comment", "name: tdd\ndescription: # note\n >2\n Use when: a bug", 0], + ["header on the next line with a zero-indented body", "name: tdd\ndescription:\n >-\nUse when a bug", 1, /empty block scalar body/], + ["header on the next line with a zero-indented body and CRLF", "name: tdd\r\ndescription:\r\n >-\r\nUse when a bug\r\n", 1, /empty block scalar body/], + ["header on the next line with a shallow explicit indent", "name: tdd\ndescription:\n >2\n Use when a bug", 1, /indented less than 2 space/], + ["valid next-line quoted scalar", 'name: tdd\ndescription:\n "Use when: a bug"', 0], + ["malformed next-line quoted scalar", 'name: tdd\ndescription:\n "Use when: a bug', 1, /unterminated double-quoted/], + ["block indicator inside a plain multiline scalar", "name: tdd\ndescription: Use for bugs\n > when a test fails", 0], ["comment-only description", "name: tdd\ndescription: # comment", 1, /description is empty or comment-only/], ["comment without a space", "name: tdd\ndescription: #x", 1, /description is empty or comment-only/], ["empty description", "name: tdd\ndescription:\nmetadata:\n a: b", 1, /description is empty or comment-only/], From 05b975d9364abc04be7bfe43719819018344572f Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:43:44 +0800 Subject: [PATCH 6/6] fix(validate): treat dedented comments after a block scalar as comments A comment-only line indented below the block body ends the scalar instead of failing as under-indented body text. Comment lines between a key and its value are also ignored. Co-Authored-By: Claude Sonnet 5.5 --- scripts/skill-compat-lib.mjs | 29 +++++++++++++++++------------ scripts/skill-compat.test.mjs | 11 +++++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/scripts/skill-compat-lib.mjs b/scripts/skill-compat-lib.mjs index 94b7bed..9f0123e 100644 --- a/scripts/skill-compat-lib.mjs +++ b/scripts/skill-compat-lib.mjs @@ -28,7 +28,8 @@ const COMMENT_TAIL = /^(?:[ \t]+#.*)?[ \t]*$/; const BLANK_OR_COMMENT_LINE = /^[ \t]*(?:#.*)?$/; const QUOTE_REMEDY = "quote the value"; -const trimSeparators = (text) => text.replace(/^[ \t]+|[ \t]+$/g, ""); +const isCommentLine = (line) => /^[ \t]*#/.test(line); +const trimSeparators =(text) => text.replace(/^[ \t]+|[ \t]+$/g, ""); /** Reads a top-level field: its same-line value and any indented continuation lines. */ function readField(lines, field) { @@ -83,11 +84,13 @@ function inspectBlock({ first, continuation }) { const explicit = Number(indicators.match(/[1-9]/)?.[0] ?? 0); const indents = continuation.map((line) => line.match(/^ */)[0].length); // Without an indentation indicator YAML takes the first body line's indent. - const required = explicit || indents[0]; - if (indents.some((indent) => indent < Math.max(required, 1))) { - return { problem: `has a block scalar body line indented less than ${Math.max(required, 1)} space(s)` }; - } - return {}; + const required = Math.max(explicit || indents[0], 1); + const end = indents.findIndex((indent) => indent < required); + if (end === -1) return {}; + // A line below the body's indent ends the scalar. Only comments may follow it, + // and a comment-only line there is not body text. + if (end > 0 && continuation.slice(end).every(isCommentLine)) return {}; + return { problem: `has a block scalar body line indented less than ${required} space(s)` }; } function inspectPlain(lines) { @@ -109,15 +112,17 @@ function inspectPlain(lines) { } /** Returns `{ problem }` and/or the scalar's string `value` when it can be determined. */ -function inspectField(field) { - const lines = [field.first, ...field.continuation].filter((line) => trimSeparators(line) !== ""); +function inspectField({ first, continuation: rawContinuation }) { + // Comment lines between the key and its value are not part of the value. + const valueStart = rawContinuation.findIndex((line) => !isCommentLine(line)); + const continuation = first === "" ? (valueStart === -1 ? [] : rawContinuation.slice(valueStart)) : rawContinuation; + const lines = [first, ...continuation].filter((line) => trimSeparators(line) !== ""); if (lines.length === 0) return { problem: "is empty or comment-only (YAML null)" }; const head = trimSeparators(lines[0]); // A block header is the value itself, either after the key or on the next value-bearing line. - const [next, ...rest] = field.continuation; - if (/^[|>]/.test(field.first)) return inspectBlock(field); - if (field.first === "" && /^[|>]/.test(trimSeparators(next ?? ""))) { - return inspectBlock({ first: trimSeparators(next), continuation: rest }); + if (/^[|>]/.test(first)) return inspectBlock({ first, continuation }); + if (first === "" && /^[|>]/.test(head)) { + return inspectBlock({ first: head, continuation: continuation.slice(1) }); } if (/^["']/.test(head)) return inspectQuoted(lines.map(trimSeparators).join("\n")); return inspectPlain(lines); diff --git a/scripts/skill-compat.test.mjs b/scripts/skill-compat.test.mjs index 828dc1f..4dd8bbe 100644 --- a/scripts/skill-compat.test.mjs +++ b/scripts/skill-compat.test.mjs @@ -51,6 +51,17 @@ const cases = [ ["block with an empty body and CRLF", "name: tdd\r\ndescription: >-\r\nmetadata:\r\n a: b\r\n", 1, /empty block scalar body/], ["tab-indented block body", "name: tdd\ndescription: >\n\tUse when a bug", 1, /indented less than 1 space/], ["header on the next line with trailing text", "name: tdd\ndescription:\n | Use when a bug", 1, /invalid block scalar header/], + ["dedented comment after a block body", "name: tdd\ndescription: |-\n Use when a bug needs a test.\n # Keep metadata separate.\nmetadata:\n category: testing", 0], + ["dedented comment after a block body with CRLF", "name: tdd\r\ndescription: |-\r\n Use when a bug needs a test.\r\n # Keep metadata separate.\r\nmetadata:\r\n category: testing\r\n", 0], + ["several dedented comments after a block body", "name: tdd\ndescription: >\n Use when a bug.\n # one\n # two\n # three", 0], + ["comment as deep as the body is body text", "name: tdd\ndescription: |\n Use when a bug.\n # literal text\n", 0], + ["dedented non-comment after a block body", "name: tdd\ndescription: |-\n Use when a bug needs a test.\n Keep metadata separate.\nmetadata:\n category: testing", 1, /indented less than 2 space/], + ["dedented non-comment after a block body with CRLF", "name: tdd\r\ndescription: |-\r\n Use when a bug needs a test.\r\n Keep metadata separate.\r\nmetadata:\r\n category: testing\r\n", 1, /indented less than 2 space/], + ["non-comment after a dedented comment", "name: tdd\ndescription: |\n Use when a bug.\n # note\n more text", 1, /indented less than 2 space/], + ["comment dedented below an explicit indent as the first line", "name: tdd\ndescription: >2\n # note\n body", 1, /indented less than 2 space/], + ["comment lines between the key and a next-line header", "name: tdd\ndescription:\n # note\n >-\n Use when a bug", 0], + ["comment lines between the key and a next-line plain value", "name: tdd\ndescription: # note\n # more\n Use for bugs", 0], + ["only comment lines after the key", "name: tdd\ndescription:\n # note\nmetadata:\n a: b", 1, /description is empty or comment-only/], ["valid header on the next line", "name: tdd\ndescription:\n >-\n Use when a bug has a cheap regression test.", 0], ["valid header on the next line with CRLF", "name: tdd\r\ndescription:\r\n >-\r\n Use when a bug has a cheap regression test.\r\nmetadata:\r\n a: b\r\n", 0], ["valid header on the next line with a same-indent body", "name: tdd\ndescription:\n |\n Use when: a bug", 0],