New orchestrated scanner output-style-scanner.mjs — first new family since SKL. Three findings, each pinned to a CONFIRMED V-row of the steering-model plan + re-verified against code.claude.com/docs/en/output-styles: - CA-OST-001 (medium, V10): user/project custom style missing keep-coding-instructions:true (default false) → silently strips built-in software-engineering instructions when active. Scoped to user/project. - CA-OST-002 (low, V11): plugin style with force-for-plugin:true overrides the user's selected outputStyle. Verifiseringsplikt correction — the plan bullet said "project/user style," but force-for-plugin is plugin-styles-only per the docs, so the check keys on source==='plugin'. - CA-OST-003 (medium): settings outputStyle matching no built-in (Default/Explanatory/Learning/Proactive, case-insensitive) nor discovered custom style → dead config. Byte-stability — a scanner addition, not a field addition. Growing the scanners array + scanners_ok cannot be hidden by a field strip, but re-seeding frozen v5.0.0 (the SKL precedent) would now bake in B2's hotspot triple + claudeMd drift. So, per the B2 lesson, frozen v5.0.0 snapshots are PRESERVED and the OST entry is stripped at compare time via new tests/helpers/strip-added-scanner.mjs (wired into json/raw-backcompat + the Step 5/6 humanizer tests); only SC-5 default-output is regenerated (additive OST entry, diff reviewed). OST is fixture-gated (no output styles on marketplace-medium / hermetic HOME → silent). Wiring: orchestrator; humanizer (OST→Configuration mistake) + humanizer-data OST family (title-coupled); scoring (OST→Settings, keeps 10 areas). Suite 1012→1023 (+11). Badges: scanners 14, tests 1023, TRANSLATIONS families 15. Lore swept: README, CLAUDE.md, scanner-internals, humanizer.md. self-audit A/A. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
8.1 KiB
JavaScript
179 lines
8.1 KiB
JavaScript
/**
|
|
* OST Scanner — Output-style validation (v5.6 C)
|
|
*
|
|
* Output styles are live (the standalone `/output-style` command was removed in
|
|
* v2.1.91; styles are now managed via `/config`). They are the most surprising
|
|
* steering surface because they rewrite the system prompt:
|
|
*
|
|
* CA-OST-001 A custom (user/project) output style that does NOT set
|
|
* `keep-coding-instructions: true` → when active, Claude Code
|
|
* REMOVES its built-in software-engineering instructions (how to
|
|
* scope changes, write comments, verify work) and keeps only the
|
|
* style's text. `keep-coding-instructions` defaults to false, so
|
|
* this is the headline footgun. Severity medium.
|
|
*
|
|
* CA-OST-002 A PLUGIN output style with `force-for-plugin: true` → Claude
|
|
* Code auto-applies it whenever the plugin is enabled, OVERRIDING
|
|
* the user's selected `outputStyle`. If several enabled plugins set
|
|
* it, the first loaded wins. Severity low (awareness). Note:
|
|
* `force-for-plugin` is plugin-styles-only per the docs, so this
|
|
* keys on `source === 'plugin'` — a user/project style cannot
|
|
* trigger the override (it would simply be ignored).
|
|
*
|
|
* CA-OST-003 A settings `outputStyle` value that matches no built-in and no
|
|
* discovered custom style → dead config: Claude Code falls back to
|
|
* the default style, so the configured behavior is silently not
|
|
* applied. Severity medium.
|
|
*
|
|
* Every claim traces to a CONFIRMED row of docs/v5.5-steering-model-plan.md
|
|
* (V9/V10/V11/V12), verified against code.claude.com/docs/en/output-styles and
|
|
* .../plugins-reference. The scanner is fixture-gated: with no output styles and
|
|
* no `outputStyle` setting it emits nothing (keeps the SC-5 snapshot byte-stable).
|
|
*
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
import { finding, scannerResult } from './lib/output.mjs';
|
|
import { SEVERITY } from './lib/severity.mjs';
|
|
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
|
import { parseFrontmatter, parseJson } from './lib/yaml-parser.mjs';
|
|
|
|
const SCANNER = 'OST';
|
|
|
|
// Built-in output styles, verified against code.claude.com/docs/en/output-styles.
|
|
// Compared case-insensitively so OST-003 never false-flags a valid built-in.
|
|
const BUILTIN_STYLES = ['default', 'explanatory', 'learning', 'proactive'];
|
|
|
|
/**
|
|
* Read + parse the frontmatter of each enumerated output style once.
|
|
* @param {Array<object>} styles - readActiveConfig().outputStyles entries
|
|
*/
|
|
async function withFrontmatter(styles) {
|
|
const out = [];
|
|
for (const s of styles) {
|
|
let frontmatter = null;
|
|
try {
|
|
frontmatter = parseFrontmatter(await readFile(s.path, 'utf-8')).frontmatter;
|
|
} catch { /* unreadable → treat as no frontmatter */ }
|
|
out.push({ ...s, frontmatter });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective `outputStyle` setting from the cascade (user → project →
|
|
* local; later scope wins). Returns null when unset everywhere.
|
|
* @param {object} activeConfig
|
|
* @returns {Promise<{value:string, scope:string, path:string} | null>}
|
|
*/
|
|
async function resolveOutputStyleSetting(activeConfig) {
|
|
const cascade = (activeConfig.settings && activeConfig.settings.cascade) || [];
|
|
let resolved = null;
|
|
for (const entry of cascade) {
|
|
if (!entry.exists || !entry.path) continue;
|
|
let json = null;
|
|
try { json = parseJson(await readFile(entry.path, 'utf-8')); } catch { continue; }
|
|
if (json && typeof json.outputStyle === 'string' && json.outputStyle.trim()) {
|
|
resolved = { value: json.outputStyle.trim(), scope: entry.scope, path: entry.path };
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Main scanner entry point.
|
|
* @param {string} targetPath - repo root to scan
|
|
* @param {object} _discovery - unused (OST reads the active config cascade itself)
|
|
*/
|
|
export async function scan(targetPath, _discovery) {
|
|
const start = Date.now();
|
|
const findings = [];
|
|
|
|
const activeConfig = await readActiveConfig(targetPath);
|
|
const styles = await withFrontmatter(activeConfig.outputStyles || []);
|
|
|
|
// CA-OST-001 — user/project custom style missing keep-coding-instructions:true.
|
|
for (const s of styles) {
|
|
if (s.source !== 'project' && s.source !== 'user') continue;
|
|
const kci = s.frontmatter ? s.frontmatter.keep_coding_instructions : undefined;
|
|
if (kci === true) continue;
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
severity: SEVERITY.medium,
|
|
title: 'Custom output style removes built-in coding instructions',
|
|
description:
|
|
`The ${s.source} output style "${s.name}" does not set ` +
|
|
'`keep-coding-instructions: true`. While this style is active, Claude Code ' +
|
|
'drops its built-in software-engineering instructions — how to scope changes, ' +
|
|
'write comments, and verify work — and keeps only this style\'s text. The ' +
|
|
'frontmatter flag defaults to false, so the strip is easy to miss.',
|
|
file: s.path,
|
|
evidence:
|
|
`output_style="${s.name}"; source=${s.source}; ` +
|
|
`keep-coding-instructions=${kci === undefined ? 'unset (default false)' : String(kci)}`,
|
|
recommendation:
|
|
'To keep Claude Code\'s software-engineering behavior while applying this style, ' +
|
|
'add `keep-coding-instructions: true` to the frontmatter. If the strip is ' +
|
|
'intentional (a non-coding persona), no change is needed.',
|
|
category: 'output-styles',
|
|
}));
|
|
}
|
|
|
|
// CA-OST-002 — plugin output style with force-for-plugin:true (overrides user choice).
|
|
for (const s of styles) {
|
|
if (s.source !== 'plugin') continue;
|
|
const ffp = s.frontmatter ? s.frontmatter.force_for_plugin : undefined;
|
|
if (ffp !== true) continue;
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
severity: SEVERITY.low,
|
|
title: 'Plugin output style overrides your selected output style',
|
|
description:
|
|
`The plugin "${s.pluginName}" ships an output style "${s.name}" with ` +
|
|
'`force-for-plugin: true`, so Claude Code applies it automatically whenever the ' +
|
|
'plugin is enabled — overriding whatever `outputStyle` you selected. When more ' +
|
|
'than one enabled plugin does this, the first one loaded wins.',
|
|
file: s.path,
|
|
evidence: `output_style="${s.name}"; source=plugin:${s.pluginName}; force-for-plugin=true`,
|
|
recommendation:
|
|
'If you did not expect this style, disable the plugin or remove ' +
|
|
'`force-for-plugin: true` from its output style. This is awareness only — the ' +
|
|
'plugin is behaving as designed.',
|
|
category: 'output-styles',
|
|
}));
|
|
}
|
|
|
|
// CA-OST-003 — settings outputStyle resolving to a non-existent style (dead config).
|
|
const resolved = await resolveOutputStyleSetting(activeConfig);
|
|
if (resolved) {
|
|
const known = new Set([
|
|
...BUILTIN_STYLES,
|
|
...styles.map(s => String(s.name).toLowerCase()),
|
|
]);
|
|
if (!known.has(resolved.value.toLowerCase())) {
|
|
const customNames = styles.map(s => s.name);
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
severity: SEVERITY.medium,
|
|
title: 'Configured output style does not exist',
|
|
description:
|
|
`Your ${resolved.scope} settings set \`outputStyle: "${resolved.value}"\`, but no ` +
|
|
'built-in or discovered custom style has that name. Claude Code falls back to the ' +
|
|
'default style, so the output behavior you configured is silently never applied.',
|
|
file: resolved.path,
|
|
evidence:
|
|
`outputStyle="${resolved.value}"; scope=${resolved.scope}; ` +
|
|
`builtins=[${BUILTIN_STYLES.join(', ')}]; ` +
|
|
`known_custom=[${customNames.join(', ')}]`,
|
|
recommendation:
|
|
'Fix the value to match an existing style (built-ins: Default, Explanatory, ' +
|
|
'Learning, Proactive), create the missing style under `.claude/output-styles/`, ' +
|
|
'or remove the `outputStyle` setting.',
|
|
category: 'output-styles',
|
|
}));
|
|
}
|
|
}
|
|
|
|
return scannerResult(SCANNER, 'ok', findings, styles.length, Date.now() - start);
|
|
}
|