jhste-skills 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,63 +70,6 @@ function addSharedScannerCandidates(file, text, findings, thresholds) {
70
70
  }
71
71
  }
72
72
 
73
- function hasUseClientDirective(text) {
74
- return /^\s*(?:"use client"|'use client')\s*;?/u.test(text);
75
- }
76
-
77
- function isScriptPipeline(file) {
78
- return /(^|\/)scripts\/(data|ops|import|imports|backfill|repair|migrate|migration)\//.test(file.rel)
79
- && /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(file.rel);
80
- }
81
-
82
- function matchedResponsibilityHints(text, hintGroups) {
83
- return hintGroups
84
- .filter((group) => group.patterns.some((pattern) => pattern.test(text)))
85
- .map((group) => group.label);
86
- }
87
-
88
- function scanMixedResponsibilities(file, text, findings) {
89
- if (hasUseClientDirective(text)) {
90
- const hints = matchedResponsibilityHints(text, [
91
- { label: 'browser storage', patterns: [/\b(localStorage|sessionStorage)\b/] },
92
- { label: 'network/API', patterns: [/\bfetch\s*\(/, /\baxios\./, /\buse(Query|Mutation)\s*\(/] },
93
- { label: 'toast/notification', patterns: [/\btoast\b/, /\bnotify\b/] },
94
- { label: 'modal/dialog state', patterns: [/\b(Dialog|Modal|Sheet)\b/, /\bopen[A-Z]\w*\b/, /\bis[A-Z]\w*Open\b/] },
95
- { label: 'route navigation', patterns: [/\buseRouter\s*\(/, /\brouter\.(push|replace|refresh)\b/] },
96
- { label: 'heavy mapping', patterns: [/\.(map|filter|reduce)\s*\(/] },
97
- ]);
98
- if (hints.length >= 3) {
99
- candidate(findings.responsibilityBudget, 'mixed client responsibility candidate', file, 1, `client module mixes ${hints.slice(0, 4).join(', ')}; review hook/adapter/presentation split`, 'warning');
100
- }
101
- }
102
-
103
- const routeLike = /(^|\/)(api|routes?|controllers?|pages\/api)\//i.test(file.rel) || /route\.(ts|js)$/.test(file.rel);
104
- if (routeLike) {
105
- const hints = matchedResponsibilityHints(text, [
106
- { label: 'auth/session', patterns: [/\b(auth|session|permission|currentUser|getUser)\b/i] },
107
- { label: 'validation', patterns: [/\b(z\.object|safeParse|parseAsync|validate|schema)\b/] },
108
- { label: 'database', patterns: [/\b(prisma|pool\.query|client\.query|SELECT|INSERT|UPDATE|DELETE|db\.)\b/i] },
109
- { label: 'response formatting', patterns: [/\b(Response\.json|NextResponse\.json|res\.json)\b/] },
110
- ]);
111
- if (hints.length >= 3) {
112
- candidate(findings.responsibilityBudget, 'mixed route responsibility candidate', file, 1, `route/controller mixes ${hints.join(', ')}; review route/service/repository/response split`, 'warning');
113
- }
114
- }
115
-
116
- if (isScriptPipeline(file)) {
117
- const hints = matchedResponsibilityHints(text, [
118
- { label: 'CLI parsing', patterns: [/\b(process\.argv|argparse|ArgumentParser|commander)\b/] },
119
- { label: 'file IO', patterns: [/\b(readFile|writeFile|open\(|Path\(|fs\.)\b/] },
120
- { label: 'data transform', patterns: [/\.(map|filter|reduce)\s*\(/, /\bjson\.loads\b/i, /\bJSON\.parse\b/] },
121
- { label: 'persistence/network', patterns: [/\b(fetch|pool\.query|client\.query|INSERT|UPDATE|DELETE|requests\.)\b/i] },
122
- { label: 'reporting', patterns: [/\b(console\.|print\(|logger\.)\b/] },
123
- ]);
124
- if (hints.length >= 4) {
125
- candidate(findings.responsibilityBudget, 'mixed script responsibility candidate', file, 1, `script mixes ${hints.join(', ')}; review CLI/loader/transform/persist/report seams`, 'warning');
126
- }
127
- }
128
- }
129
-
130
73
  function newFindingsBag() {
131
74
  return {
132
75
  largeFiles: [],
@@ -157,7 +100,6 @@ export function scanFiles(files, thresholds) {
157
100
  try {
158
101
  const text = fs.readFileSync(file.full, 'utf8');
159
102
  addSharedScannerCandidates(file, text, findings, thresholds);
160
- scanMixedResponsibilities(file, text, findings);
161
103
  } catch (error) {
162
104
  const message = error instanceof Error ? error.message : String(error);
163
105
  candidate(findings.scanWarnings, 'scan warning', file, 1, `file could not be scanned and was omitted from rule candidates: ${message}`, 'warning');
@@ -18,6 +18,9 @@ export const FINDING_METADATA = {
18
18
  'responsibility.route.budget': { family: 'responsibility_budget', pack: 'core', scanner: 'scanResponsibilityBudget' },
19
19
  'responsibility.script.budget': { family: 'responsibility_budget', pack: 'core', scanner: 'scanResponsibilityBudget' },
20
20
  'responsibility.python_orchestrator.budget': { family: 'responsibility_budget', pack: 'core', scanner: 'scanResponsibilityBudget' },
21
+ 'responsibility.client.mixed': { family: 'responsibility_budget', pack: 'core', scanner: 'scanResponsibilityBudget' },
22
+ 'responsibility.route.mixed': { family: 'responsibility_budget', pack: 'core', scanner: 'scanResponsibilityBudget' },
23
+ 'responsibility.script.mixed': { family: 'responsibility_budget', pack: 'core', scanner: 'scanResponsibilityBudget' },
21
24
  'srp.function.length': { family: 'single_responsibility_advisory', pack: 'core', scanner: 'scanSingleResponsibility' },
22
25
  'srp.function.mixed_responsibility': { family: 'single_responsibility_advisory', pack: 'core', scanner: 'scanSingleResponsibility' },
23
26
  'srp.module.mixed_exports': { family: 'single_responsibility_advisory', pack: 'core', scanner: 'scanSingleResponsibility' },
@@ -66,7 +66,7 @@ function guidanceForFinding(item) {
66
66
  if (family === 'single_responsibility_advisory') {
67
67
  return {
68
68
  means: 'This class, module, or function may have more than one reason to change. The finding is heuristic and should be reviewed, not treated as proof.',
69
- next: 'Name the one main responsibility, then move only independently changing work behind a real seam; avoid extracting shallow pass-through wrappers.',
69
+ next: 'Name the one main responsibility, then move only independently changing work behind a real seam; keep always-cochanging contract pieces together and avoid shallow pass-through wrappers.',
70
70
  };
71
71
  }
72
72
  if (family === 'external_input_validation') {
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import {
3
3
  hasUseClientDirective,
4
+ isRouteLikePath,
5
+ isScriptPipelinePath,
4
6
  isSourceCodePath,
5
7
  lineAt,
6
8
  violation,
@@ -119,6 +121,42 @@ export function scanFileSizeAdvisory(relPath, text, settings) {
119
121
  return [];
120
122
  }
121
123
 
124
+ function matchedResponsibilityHints(text, hintGroups) {
125
+ return hintGroups
126
+ .filter((group) => group.patterns.some((pattern) => pattern.test(text)))
127
+ .map((group) => group.label);
128
+ }
129
+
130
+ function mixedClientResponsibilityHints(text) {
131
+ return matchedResponsibilityHints(text, [
132
+ { label: 'browser storage', patterns: [/\b(localStorage|sessionStorage)\b/] },
133
+ { label: 'network/API', patterns: [/\bfetch\s*\(/, /\baxios\./, /\buse(Query|Mutation)\s*\(/] },
134
+ { label: 'toast/notification', patterns: [/\btoast\b/, /\bnotify\b/] },
135
+ { label: 'modal/dialog state', patterns: [/\b(Dialog|Modal|Sheet)\b/, /\bopen[A-Z]\w*\b/, /\bis[A-Z]\w*Open\b/] },
136
+ { label: 'route navigation', patterns: [/\buseRouter\s*\(/, /\brouter\.(push|replace|refresh)\b/] },
137
+ { label: 'heavy mapping', patterns: [/\.(map|filter|reduce)\s*\(/] },
138
+ ]);
139
+ }
140
+
141
+ function mixedRouteResponsibilityHints(text) {
142
+ return matchedResponsibilityHints(text, [
143
+ { label: 'auth/session', patterns: [/\b(auth|session|permission|currentUser|getUser)\b/i] },
144
+ { label: 'validation', patterns: [/\b(z\.object|safeParse|parseAsync|validate|schema)\b/] },
145
+ { label: 'database', patterns: [/\b(prisma|pool\.query|client\.query|SELECT|INSERT|UPDATE|DELETE|db\.)\b/i] },
146
+ { label: 'response formatting', patterns: [/\b(Response\.json|NextResponse\.json|res\.json)\b/] },
147
+ ]);
148
+ }
149
+
150
+ function mixedScriptResponsibilityHints(text) {
151
+ return matchedResponsibilityHints(text, [
152
+ { label: 'CLI parsing', patterns: [/\b(process\.argv|argparse|ArgumentParser|commander)\b/] },
153
+ { label: 'file IO', patterns: [/\b(readFile|writeFile|open\(|Path\(|fs\.)\b/] },
154
+ { label: 'data transform', patterns: [/\.(map|filter|reduce)\s*\(/, /\bjson\.loads\b/i, /\bJSON\.parse\b/] },
155
+ { label: 'persistence/network', patterns: [/\b(fetch|pool\.query|client\.query|INSERT|UPDATE|DELETE|requests\.)\b/i] },
156
+ { label: 'reporting', patterns: [/\b(console\.|print\(|logger\.)\b/] },
157
+ ]);
158
+ }
159
+
122
160
  export function scanResponsibilityBudget(relPath, text, settings) {
123
161
  const ext = path.extname(relPath).toLowerCase();
124
162
  if (!['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py'].includes(ext)) return [];
@@ -131,17 +169,35 @@ export function scanResponsibilityBudget(relPath, text, settings) {
131
169
  if (hasUseClientDirective(text) && lineCount > settings.client_module_review_lines) {
132
170
  out.push(violation({ ruleId: 'responsibility.client.budget', severity: 'warning', relPath, symbol: 'use-client', message: `${lineCount} lines in client module; review hook/adapter/presentation split.`, confidence: 'medium' }));
133
171
  }
134
- const routeLike = /(^|\/)(api|routes?|controllers?|pages\/api)\//i.test(relPath) || /route\.(ts|js)$/.test(relPath);
172
+ const routeLike = isRouteLikePath(relPath);
135
173
  if (routeLike && lineCount >= settings.route_review_lines) {
136
174
  out.push(violation({ ruleId: 'responsibility.route.budget', severity: 'warning', relPath, symbol: 'route', message: `${lineCount} lines in route/controller-like file; review auth/validation/service/response seams.`, confidence: 'medium' }));
137
175
  }
138
- const scriptPipeline = /(^|\/)scripts\/(data|ops|import|imports|backfill|repair|migrate|migration)\//.test(relPath);
176
+ const scriptPipeline = isScriptPipelinePath(relPath);
139
177
  if (scriptPipeline && lineCount >= settings.import_ops_script_review_lines) {
140
178
  out.push(violation({ ruleId: 'responsibility.script.budget', severity: 'warning', relPath, symbol: 'script-pipeline', message: `${lineCount} lines in import/ops-style script; review CLI/loader/transform/persist/report seams.`, confidence: 'medium' }));
141
179
  }
142
180
  if (ext === '.py' && /(^|\/)(main|.*orchestrator|.*runner|stage_runner)\.py$/.test(relPath) && lineCount >= settings.python_orchestrator_review_lines) {
143
181
  out.push(violation({ ruleId: 'responsibility.python_orchestrator.budget', severity: 'warning', relPath, symbol: 'python-orchestrator', message: `${lineCount} lines in Python orchestrator/runner; review policy/IO/runtime/notification/result seams.`, confidence: 'medium' }));
144
182
  }
183
+ if (hasUseClientDirective(text)) {
184
+ const hints = mixedClientResponsibilityHints(text);
185
+ if (hints.length >= 3) {
186
+ out.push(violation({ ruleId: 'responsibility.client.mixed', severity: 'warning', relPath, symbol: 'use-client-mixed', message: `client module mixes ${hints.slice(0, 4).join(', ')}; review hook/adapter/presentation split.`, confidence: 'low' }));
187
+ }
188
+ }
189
+ if (routeLike) {
190
+ const hints = mixedRouteResponsibilityHints(text);
191
+ if (hints.length >= 3) {
192
+ out.push(violation({ ruleId: 'responsibility.route.mixed', severity: 'warning', relPath, symbol: 'route-mixed', message: `route/controller mixes ${hints.join(', ')}; review route/service/repository/response split.`, confidence: 'low' }));
193
+ }
194
+ }
195
+ if (scriptPipeline) {
196
+ const hints = mixedScriptResponsibilityHints(text);
197
+ if (hints.length >= 4) {
198
+ out.push(violation({ ruleId: 'responsibility.script.mixed', severity: 'warning', relPath, symbol: 'script-pipeline-mixed', message: `script mixes ${hints.join(', ')}; review CLI/loader/transform/persist/report seams.`, confidence: 'low' }));
199
+ }
200
+ }
145
201
  return out;
146
202
  }
147
203
 
@@ -12,11 +12,14 @@ const RESPONSIBILITY_HINTS = [
12
12
  { label: 'validation', patterns: [/\b(validate|validator|safeParse|parseAsync|schema|assert|parseEnv|errors\.push)\b/i] },
13
13
  { label: 'filesystem IO', patterns: [/\b(fs\.|readFile|writeFile|mkdir|rmSync|cpSync|readdirSync)\b/i] },
14
14
  { label: 'process/git/network IO', patterns: [/\b(spawnSync|execFileSync|fetch\(|axios\.|git\(|git\s+-C)\b/i] },
15
- { label: 'persistence', patterns: [/\b(prisma|pool\.query|client\.query|db\.|INSERT\s+INTO|UPDATE\s+\w|DELETE\s+FROM|SELECT\s+.+\s+FROM)\b/i] },
15
+ { label: 'persistence', patterns: [/\b(prisma|pool\.query|client\.query|db\.|INSERT\s+INTO|UPDATE\s+\w|DELETE\s+FROM|SELECT\s+.+\s+FROM)\b/i, /\b\w*(Repository|Repo|Store)\.(find|findMany|save|create|insert|update|delete|upsert|query|persist)\w*\s*\(/i] },
16
16
  { label: 'rendering/reporting', patterns: [/\b(console\.|Response\.json|NextResponse\.json|res\.json|render|markdown|tableRows|print|logger\.)\b/i] },
17
17
  { label: 'prompting', patterns: [/\b(ask\(|readline|question\(|prompt)\b/i] },
18
18
  { label: 'data transformation', patterns: [/\btransform|serialize|deserialize\b/i] },
19
19
  { label: 'time/crypto policy', patterns: [/\b(Date\.now|new Date\(|crypto\.|randomUUID|createHash)\b/i] },
20
+ { label: 'notification/email side effect', patterns: [/\b\w*(Email|Mail|Notification|Notifier)\w*\.(send|deliver|notify|enqueue|publish)\w*\s*\(/i, /\b(sendEmail|sendMail|sendWelcome|notifyUser)\w*\s*\(/i] },
21
+ { label: 'billing/payment side effect', patterns: [/\b\w*(Payment|Billing|Invoice|Subscription|Stripe)\w*\.(create|charge|capture|refund|update|cancel)\w*\s*\(/i] },
22
+ { label: 'event/queue side effect', patterns: [/\b(eventBus|queue|publisher|producer)\.(publish|send|enqueue|emit)\w*\s*\(/i] },
20
23
  ];
21
24
 
22
25
  const EXPORT_NAME_FAMILIES = [
@@ -56,6 +59,45 @@ function functionDeclarations(text) {
56
59
  return out;
57
60
  }
58
61
 
62
+ const CONTROL_FLOW_NAMES = new Set(['if', 'for', 'while', 'switch', 'catch', 'function']);
63
+
64
+ function braceDepthBetween(text, start, end) {
65
+ let depth = 0;
66
+ for (let index = start; index < end; index += 1) {
67
+ if (text[index] === '{') depth += 1;
68
+ if (text[index] === '}') depth -= 1;
69
+ }
70
+ return depth;
71
+ }
72
+
73
+ function classMethodDeclarations(text) {
74
+ const classPattern = /(?:^|\n)[ \t]*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)[^{]*\{/gu;
75
+ const methodPattern = /(?:^|\n)([ \t]*(?:(?:public|private|protected|static|override|abstract|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{;\n]+)?\s*\{)/gu;
76
+ const out = [];
77
+ for (const classMatch of text.matchAll(classPattern)) {
78
+ const className = classMatch[1] || 'AnonymousClass';
79
+ const classStart = (classMatch.index || 0) + (classMatch[0].startsWith('\n') ? 1 : 0);
80
+ const classOpen = text.indexOf('{', classStart);
81
+ if (classOpen === -1) continue;
82
+ const classEnd = matchingBraceIndex(text, classOpen);
83
+ if (classEnd === -1) continue;
84
+ const classBodyStart = classOpen + 1;
85
+ const classBody = text.slice(classBodyStart, classEnd);
86
+ for (const methodMatch of classBody.matchAll(methodPattern)) {
87
+ const methodName = methodMatch[2] || 'anonymous';
88
+ if (CONTROL_FLOW_NAMES.has(methodName)) continue;
89
+ const relativeStart = (methodMatch.index || 0) + (methodMatch[0].startsWith('\n') ? 1 : 0);
90
+ const start = classBodyStart + relativeStart;
91
+ if (braceDepthBetween(text, classOpen, start) !== 1) continue;
92
+ const openBrace = classBodyStart + (methodMatch.index || 0) + methodMatch[0].lastIndexOf('{');
93
+ const end = matchingBraceIndex(text, openBrace);
94
+ if (end === -1 || end > classEnd) continue;
95
+ out.push({ name: `${className}.${methodName}`, start, end, body: text.slice(start, end + 1) });
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
59
101
  function matchingBraceIndex(text, openBrace) {
60
102
  let depth = 0;
61
103
  let quote = '';
@@ -111,7 +153,8 @@ function pythonFunctionDeclarations(text) {
111
153
  }
112
154
 
113
155
  function declarationsForPath(relPath, text) {
114
- return relPath.endsWith('.py') ? pythonFunctionDeclarations(text) : functionDeclarations(text);
156
+ if (relPath.endsWith('.py')) return pythonFunctionDeclarations(text);
157
+ return [...functionDeclarations(text), ...classMethodDeclarations(text)].sort((left, right) => left.start - right.start);
115
158
  }
116
159
 
117
160
  function exportedCallableNames(text) {
@@ -54,3 +54,9 @@ Record actual command output in release notes before publishing a release.
54
54
  Release gates include dependency-free syntax checking, a first-run `install -> deep-scan -> tune --yes -> guard` smoke flow, `npm pack --dry-run` contents checks, and packed-tarball bin execution in a fresh temp consumer. These gates are not part of commit-time hooks.
55
55
 
56
56
  - Verify `sync`/`update` migrates older managed vendored renames without leaving duplicate directories, including `diagnose` → `diagnosing-bugs`.
57
+
58
+ ## npm trusted publishing
59
+
60
+ Package publishing is handled by `.github/workflows/publish-npm.yml` on `v*.*.*` tags using GitHub Actions OIDC trusted publishing. The workflow grants `id-token: write`, runs `npm test`, and publishes with provenance, so it does not require a long-lived npm token, local `npm adduser`, or OAuth login in the release shell.
61
+
62
+ Before cutting a tag, confirm the npm package's trusted publisher settings match this repository and workflow file exactly: GitHub owner/repo `jhste102lab/jhste-skills`, workflow `publish-npm.yml`, and no environment unless one is added to the workflow.
package/docs/RULES.md CHANGED
@@ -89,6 +89,8 @@ The rule should be used with `advisory`, `changed-files`, or `baseline-new-only`
89
89
 
90
90
  `single_responsibility_advisory` is a heuristic review signal for changed classes, modules, and functions. It looks for long functions, functions that appear to mix several concern categories, and modules whose exports look like unrelated helper families. Treat findings as prompts to name one main responsibility and one reason to change; do not extract pass-through wrappers just to silence the warning. The rule remains advisory by default, but repositories can opt into changed-file blocking by setting the rule mode and `guard.fail_on: warning`.
91
91
 
92
+ SRP is not a "one function per file" or "smallest possible file" rule. A split is useful when the separated code has an independent reason to change, an understandable file-level responsibility, and a natural behavior or side-effect seam to test. If type definitions, select aliases, mappers, constants, or tiny wrappers always change together and force readers to chase several files to understand one contract, prefer a cohesive contract module instead. The built-in scanner only emits low-confidence static candidates; co-change history, call graphs, and reader navigation cost still require human review.
93
+
92
94
  ## Restricted profile format
93
95
 
94
96
  `.jhste/profile.yaml` uses a documented restricted YAML-like contract rather than arbitrary YAML. Supported operational sections are `mode`, `packs.<id>.mode`, `rules.<id>.mode`, file-size/responsibility/single-responsibility threshold keys, `baseline.enabled`, `baseline.path`, `guard.default_scope`, `guard.default_format`, `guard.fail_on`, `guard.exit_codes`, and `commands` with `cmd`/`args` or legacy `run`. Unknown pack ids, rule ids, guard keys, baseline keys, command keys, duplicate operational sections, and invalid numeric thresholds are config failures with exit `3`. Documentation-only metadata sections such as `recommendations`, `adapters`, `deep_scan`, `workflow`, and `strict` do not affect guard runtime settings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jhste-skills",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Installable engineering guardrails and workflow skills for AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "LICENSE"
34
34
  ],
35
35
  "scripts": {
36
- "test": "npm run syntax:check && npm run docs:check && npm run vendor:check && npm run public-safety:check && npm run public-safety-fixtures:test && npm run profile-fixtures:test && npm run guard-fixtures:test && npm run single-responsibility-fixtures:test && npm run smoke:test && npm run release:gates",
36
+ "test": "npm run syntax:check && npm run docs:check && npm run vendor:check && npm run public-safety:check && npm run public-safety-fixtures:test && npm run profile-fixtures:test && npm run guard-fixtures:test && npm run responsibility-budget-fixtures:test && npm run single-responsibility-fixtures:test && npm run smoke:test && npm run release:gates",
37
37
  "syntax:check": "node scripts/syntax-check.mjs",
38
38
  "docs:check": "node scripts/docs-check.mjs",
39
39
  "vendor:check": "node scripts/vendor-check.mjs",
@@ -41,6 +41,7 @@
41
41
  "public-safety-fixtures:test": "node scripts/public-safety-fixtures-test.mjs",
42
42
  "profile-fixtures:test": "node scripts/profile-fixtures-test.mjs",
43
43
  "guard-fixtures:test": "node scripts/guard-fixtures-test.mjs",
44
+ "responsibility-budget-fixtures:test": "node scripts/responsibility-budget-fixtures-test.mjs",
44
45
  "single-responsibility-fixtures:test": "node scripts/single-responsibility-fixtures-test.mjs",
45
46
  "smoke:test": "node scripts/smoke-test.mjs",
46
47
  "release:gates": "node scripts/release-gates-test.mjs"
@@ -35,6 +35,9 @@ implementation:
35
35
  - responsibility.route.budget
36
36
  - responsibility.script.budget
37
37
  - responsibility.python_orchestrator.budget
38
+ - responsibility.client.mixed
39
+ - responsibility.route.mixed
40
+ - responsibility.script.mixed
38
41
  recommendation:
39
42
  changed_files: true
40
43
  baseline_supported: true
@@ -31,5 +31,5 @@ recommendation:
31
31
  baseline_supported: true
32
32
  default_enforcement: review_only
33
33
  public_safe_examples:
34
- bad: Keep adding parsing, validation, filesystem IO, prompting, transformation, persistence, and reporting to one function or shared utility bucket.
35
- good: Name the changed module or function's one responsibility, keep cohesive implementation inside deep modules, and extract only when a real seam improves locality.
34
+ bad: Keep adding parsing, validation, filesystem IO, prompting, transformation, persistence, and reporting to one function or split every type, select alias, mapper, and tiny wrapper into separate files that always change together.
35
+ good: Name the changed module or function's one responsibility and one independent reason to change; keep cohesive contracts together inside deep modules, and extract only when a real tested seam improves locality.
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import {
4
+ fail,
5
+ guardJson,
6
+ hasRule,
7
+ makeRepo,
8
+ write,
9
+ } from './guard-fixtures/helpers.mjs';
10
+
11
+ {
12
+ const repo = makeRepo('responsibility-mixed');
13
+ write(path.join(repo, 'src/components/EventPanel.tsx'), `"use client";
14
+ export function EventPanel() {
15
+ const cached = localStorage.getItem('event');
16
+ fetch('/api/events');
17
+ toast.success(cached);
18
+ return <Dialog open={true} />;
19
+ }
20
+ `);
21
+ write(path.join(repo, 'src/app/api/events/route.ts'), `const schema = { safeParse(value) { return { success: true, data: value }; } };
22
+ export async function POST(request) {
23
+ const user = await requireUser();
24
+ const body = schema.safeParse(await request.json());
25
+ const rows = await prisma.event.findMany({ where: { userId: user.id } });
26
+ return Response.json({ body, rows });
27
+ }
28
+ `);
29
+ write(path.join(repo, 'scripts/import/events.ts'), `import fs from 'node:fs';
30
+ export async function main() {
31
+ const file = process.argv[2];
32
+ const rows = JSON.parse(fs.readFileSync(file, 'utf8')).map((row) => row.id);
33
+ await fetch('https://example.test/events');
34
+ console.log(rows.length);
35
+ }
36
+ `);
37
+ const result = guardJson(repo);
38
+ if (!hasRule(result, 'responsibility.client.mixed', 'EventPanel.tsx')) fail('mixed client responsibility was not reported by guard');
39
+ if (!hasRule(result, 'responsibility.route.mixed', 'events/route.ts')) fail('mixed route responsibility was not reported by guard');
40
+ if (!hasRule(result, 'responsibility.script.mixed', 'scripts/import/events.ts')) fail('mixed script responsibility was not reported by guard');
41
+ for (const ruleId of ['responsibility.client.mixed', 'responsibility.route.mixed', 'responsibility.script.mixed']) {
42
+ const item = result.violations.find((violation) => violation.rule_id === ruleId);
43
+ if (item.confidence !== 'low' || item.severity !== 'warning') fail(`${ruleId} should be low-confidence warning`);
44
+ }
45
+ }
46
+
47
+ console.log('responsibility-budget-fixtures-test passed: mixed responsibility candidates verified.');
@@ -61,6 +61,23 @@ export function importRows(argv) {
61
61
  if (item.confidence !== 'low' || item.severity !== 'warning') fail('mixed function SRP candidate should be low-confidence warning');
62
62
  }
63
63
 
64
+ {
65
+ const repo = makeRepo('class-method-mixed');
66
+ write(path.join(repo, 'src/UserService.ts'), `export class UserService {
67
+ async register(input) {
68
+ const user = await userRepository.save(input);
69
+ await emailService.sendWelcome(user.email);
70
+ await paymentClient.createCustomer(user.id);
71
+ return user;
72
+ }
73
+ }
74
+ `);
75
+ const result = guardJson(repo);
76
+ const item = result.violations.find((violation) => violation.rule_id === 'srp.function.mixed_responsibility' && violation.symbol === 'UserService.register');
77
+ if (!item) fail('mixed class method SRP candidate was not reported');
78
+ if (item.confidence !== 'low' || item.severity !== 'warning') fail('mixed class method SRP candidate should be low-confidence warning');
79
+ }
80
+
64
81
  {
65
82
  const repo = makeRepo('module-exports');
66
83
  write(path.join(repo, 'src/shared.ts'), `export function parseArgs() { return {}; }
@@ -9,6 +9,8 @@ When reviewing a proposed change, ask:
9
9
  - Does this module hide complexity or merely pass it through?
10
10
  - Are repo-local docs using a more specific term or rule?
11
11
  - Where do caller contracts, mutation safety, or hot-path performance assumptions belong so they stay visible instead of leaking across seams?
12
+ - Do the proposed files change independently, or do they always move together as one cohesive contract?
13
+ - Would the split reduce the number of files touched for a typical change, or merely make readers chase more files?
12
14
 
13
15
  ## Bad / better / why skeletons
14
16
 
@@ -34,6 +36,12 @@ Change adjacent code only when it sits on the changed execution path and leaving
34
36
  - Better: each module owns one responsibility such as argument parsing, repository discovery, filesystem operations, prompting, or rendering; keep a compatibility facade only when it contains no policy and prevents churn.
35
37
  - Why: maintainers can name one reason to change the module, while callers do not learn unrelated helpers.
36
38
 
39
+ ### Over-fragmented contract split
40
+
41
+ - Bad: a cohesive feature contract is split into separate `*-types`, `*-select`, `*-mapper`, `*-aliases`, and tiny wrapper files that are hard to understand alone and usually change in the same commit.
42
+ - Better: keep type, select/shape alias, mapper, and small constants together when they describe one caller-facing contract; split only the independently changing query, policy, side effect, or behavior seam.
43
+ - Why: SRP is about independent reasons to change, not maximizing file count; good splits reduce change surface and test scope without increasing reader navigation cost.
44
+
37
45
  ### Function responsibility split
38
46
 
39
47
  - Bad: one function parses input, validates it, reads files, transforms data, writes output, prints a report, and decides exit behavior.