style-profile
Tier 2. No titan dependencies. zod v4 is a peer; handlebars is a regular dependency.
npm install @titan-design/style-profile zodThe problem it solves
A team's coding standards usually live in two places that drift apart: prose that people and agents read, and linter configs that machines enforce. This package holds the standard once, as a profile: a set of style rules, each with a convention, a confidence, and optional per-tool extensions. It then exports that profile to every place the standard has to show up: eslint.config.js, ruff.toml, .editorconfig, a markdown style guide, Claude rules, Claude hooks, and a Claude skill.
Confidence decides severity. A rule at or above severityThresholds.error (default 0.85) becomes an error, at or above warn (0.60) a warning, and at or above info (0.40) a hint. Anything lower is left out of every artifact.
When to reach for it
- You have a profile (hand-written, or produced by a style analyzer) and need the linter configs and agent instructions that enforce it.
- You are assembling your own ESLint config and want single rules derived from a profile:
buildNamingConventionRule,buildImportOrderRule,buildFunctionLengthRule,buildFileNamingRule,buildJsdocRules.
It does not read source code. Producing a profile from a codebase is the style analyzer's job, and running the generated linters is the style checker's (both later ports from codewatch).
Public API
| Export | What it does |
|---|---|
ProfileSchema, StyleRuleSchema, ExampleSchema, StabilitySchema, FixabilitySchema | zod schemas; Profile, StyleRule, Stability, Fixability, SeverityThresholds, Severity, ProfileCategory are their types |
SCHEMA_VERSION, DEFAULT_SEVERITY_THRESHOLDS, PROFILE_CATEGORIES | constants |
readProfile(path), writeProfile(path, profile) | parse through the schema on the way in and out |
validateProfile(data) | zod safeParse result |
migrateProfile(profile), registerMigration({ from, to, migrate }) | walk registered migrations up to SCHEMA_VERSION |
exportProfile(profile, format), SUPPORTED_FORMATS, ExportFormat | one entry point for every format; returns GeneratedFile[] ({ path, content }) |
generateEslintExport, generateRuffExport, generateEditorConfigExport, generateMarkdownExport, generateClaudeRules, generateHooksConfig, generateSkillFiles | the per-format generators behind exportProfile |
severityForConfidence(confidence, thresholds?) | the one confidence ladder: error, warn, info or off |
toEslintLevel(severity), EslintSeverity | the ESLint level for a tier; info becomes warn, since ESLint has no info level |
toEslintSeverity, severityRank, build*Rule, buildJsdocRules | single ESLint rules derived from a profile; they emit error or warn, never info |
extractAllRules, getTopRules, getRulesByCategory, getRulesForCategory, detectLanguages | flatten and group a profile's rules |
Example
Verified against 0.1.0. A profile in, an ESLint config and a ruff config out:
import { ProfileSchema, exportProfile } from "@titan-design/style-profile";
const rule = (convention, confidence, extensions) => ({ convention, confidence, extensions });
const profile = ProfileSchema.parse({
schemaVersion: "1.0.0",
author: "ada",
generated: "2026-09-18T00:00:00.000Z",
sources: ["src/"],
naming: { variables: rule("camelCase", 0.93) },
structure: {
importOrder: rule(["builtin", "external", "relative"], 0.9),
functionMaxLines: rule(30, 0.7),
},
documentation: {},
errorHandling: { noThrowLiteral: rule(true, 0.88, { eslint: { rule: "no-throw-literal" } }) },
formatting: { lineLength: rule(100, 0.8, { ruff: { codes: ["E501"] } }) },
patterns: {},
idioms: { detected: [] },
antiPatterns: { acknowledged: [] },
overrides: [],
});
const [eslint] = exportProfile(profile, "eslint");
const [ruff] = exportProfile(profile, "ruff");eslint.path is eslint.config.js:
// Generated by codewatch for ada
// Date: 2026-09-18T00:00:00.000Z
// Do not edit manually - regenerate with: codewatch export --format eslint
import perfectionist from "eslint-plugin-perfectionist";
export default {
"rules": {
"no-throw-literal": "error",
"perfectionist/sort-imports": [
"error",
{
"type": "natural",
"groups": [
"builtin",
"external",
"relative"
]
}
]
}
};ruff.path is ruff.toml:
# Generated by codewatch for ada
# Date: 2026-09-18T00:00:00.000Z
# Do not edit manually - regenerate with: codewatch export --format ruff
line-length = 100
[lint]
select = ["C90", "E", "I", "N"]
fixable = ["ALL"]
[lint.mccabe]
max-complexity = 30
[lint.isort]
section-order = ["builtin", "external", "relative"]What it deliberately does not do
It is a port of codewatch's @codewatch/profile and keeps the original's output exactly, including the quirks listed below, except for the hooks export, which now follows Claude Code's settings schema. It does not analyze code, run linters, or write files: every exporter returns { path, content } and the caller decides where it goes.
Gotchas
generateEslintExportonly emits rules that carry anextensions.eslintentry, plusperfectionist/sort-importsfromstructure.importOrder. The naming, file-name, function-length and JSDoc builders are not wired into it; call them yourself.- The generated
eslint.config.jsdefault-exports a single object and imports plugins without registering them underplugins. Treat it as a rules list to merge into a real flat config, not a config ESLint can load on its own. - The ruff exporter maps
structure.functionMaxLines(a line count) to mccabemax-complexity(a cyclomatic bound), and it strips digits from extension codes, soE501selects the wholeEfamily. generateHooksConfigignores the profile. It always returns onePostToolUsehook onWrite|Editthat readstool_input.file_pathfrom the hook's stdin JSON withjqand runscodewatch check --fixon that file, sojqandcodewatchmust be onPATH.exportProfilewrites it to.claude/codewatch-hooks.json, a fragment rather than a whole settings file. Merge it into.claude/settings.jsonby appending its entries:jq -s '.[0].hooks.PostToolUse += .[1].hooks.PostToolUse | .[0]' .claude/settings.json .claude/codewatch-hooks.json.- The migration registry is module-global.
registerMigrationaffects every caller in the process, andreadProfiledoes not migrate. CallmigrateProfilebefore parsing an older profile. - The skill exporter reads its Handlebars templates from the package's
templates/directory at call time, so bundling this package into a single file breaksgenerateSkillFilesunlesstemplates/travels with it.
Where it came from
Ported in TP-131 from codewatch packages/profile, with the Handlebars templates from codewatch skills/code-style-personal/templates. Generated artifacts were diffed byte-for-byte against the original on the codewatch export fixture and a branch-coverage profile. Legacy identifiers are kept as they were: the generated headers and hook command still say codewatch (the hooks export alone was rewritten in TP-1221), and profiles may carry the https://json.schemastore.org/code-style-profile.json $schema URL.