@rmrdeveloper/sideroom-pi 8.14.0 → 9.0.0

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.
Files changed (37) hide show
  1. package/README.md +19 -9
  2. package/assets/artifacts/GUIDELINES_TEMPLATE.md +33 -2
  3. package/assets/visual-context/browser/INSTALL.md +11 -2
  4. package/assets/visual-context/browser/background.js +72 -60
  5. package/assets/visual-context/browser/content.js +45 -7
  6. package/assets/visual-context/browser/destinations.js +40 -0
  7. package/assets/visual-context/browser/manifest.json +1 -1
  8. package/extensions/guidelines/catalog.ts +9 -0
  9. package/extensions/guidelines/execute.ts +26 -0
  10. package/extensions/guidelines/guard.ts +46 -14
  11. package/extensions/guidelines/index.ts +31 -0
  12. package/extensions/guidelines/prompt.ts +2 -2
  13. package/extensions/guidelines/review.ts +1 -1
  14. package/extensions/jev/model.ts +13 -0
  15. package/extensions/visual-context/broker.ts +14 -6
  16. package/extensions/visual-context/compiler-config.ts +103 -0
  17. package/extensions/visual-context/configure.ts +421 -0
  18. package/extensions/visual-context/dialogs.ts +95 -0
  19. package/extensions/visual-context/files.ts +139 -0
  20. package/extensions/visual-context/index.ts +48 -56
  21. package/extensions/visual-context/match.ts +48 -16
  22. package/extensions/visual-context/model.ts +29 -3
  23. package/extensions/visual-context/projects.ts +226 -0
  24. package/extensions/visual-context/session.ts +150 -17
  25. package/extensions/visual-context/setup.ts +146 -0
  26. package/extensions/visual-context/source.ts +203 -100
  27. package/extensions/visual-context/ui.ts +5 -1
  28. package/package.json +5 -5
  29. package/skills/sideroom-guidelines/SKILL.md +8 -5
  30. package/skills/sideroom-guidelines/references/languages/go.md +35 -1
  31. package/skills/sideroom-guidelines/references/languages/java.md +34 -1
  32. package/skills/sideroom-guidelines/references/languages/javascript.md +34 -1
  33. package/skills/sideroom-guidelines/references/languages/php-laravel.md +34 -1
  34. package/skills/sideroom-guidelines/references/languages/python.md +30 -1
  35. package/skills/sideroom-guidelines/references/languages/rust.md +34 -1
  36. package/skills/sideroom-guidelines/references/languages/typescript.md +38 -1
  37. package/skills/sideroom-visual-setup/SKILL.md +49 -0
@@ -1,10 +1,15 @@
1
1
  import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
- import { extname, join, relative, sep } from 'node:path';
3
- import ignore from 'ignore';
2
+ import { extname, isAbsolute, join, relative, sep } from 'node:path';
4
3
  import { projectRelativePath } from '../shared/file-path.ts';
5
4
  import { isMissingFile } from '../shared/read-file.ts';
5
+ import {
6
+ allowedPath,
7
+ createPathPolicy,
8
+ MAX_FILE_BYTES,
9
+ type VisualPathPolicy,
10
+ } from './files.ts';
6
11
  import { InvalidMarkup } from './markup.ts';
7
- import { matchSource, sourceFingerprint } from './match.ts';
12
+ import { matchSource, sourceFingerprint, sourceMatches } from './match.ts';
8
13
  import {
9
14
  MAX_CANDIDATES,
10
15
  type SourceCandidate,
@@ -21,45 +26,42 @@ const SOURCE_EXTENSIONS = new Set([
21
26
  '.jsx',
22
27
  '.tsx',
23
28
  ]);
24
- const SKIP_DIRECTORIES = new Set([
25
- 'node_modules',
26
- 'dist',
27
- 'build',
28
- 'coverage',
29
- 'vendor',
30
- ]);
31
- const IGNORE_FILES = ['.gitignore', '.ignore', '.fdignore'];
32
29
  const MAX_ENTRIES = 4_096;
33
- const MAX_FILE_BYTES = 262_144;
34
30
  const MAX_SOURCE_BYTES = 8_388_608;
35
31
  const MAX_DEPTH = 16;
36
- const SENSITIVE_NAME =
37
- /secret|credential|password|private.?key|(?:^|[._-])env(?:[._-]|$)/iu;
38
32
 
39
33
  export function resolveSelection(
40
34
  cwd: string,
41
35
  selection: VisualSelection,
42
36
  ): SourceResolution {
37
+ if (selection.source) {
38
+ return resolveCompilerLocation({ cwd, selection });
39
+ }
40
+ return scanSource({ cwd, selection, paths: createPathPolicy(cwd) });
41
+ }
42
+
43
+ interface SourceScanInput {
44
+ readonly cwd: string;
45
+ readonly selection: VisualSelection;
46
+ readonly paths: VisualPathPolicy;
47
+ }
48
+
49
+ function scanSource({
50
+ cwd,
51
+ selection,
52
+ paths,
53
+ }: SourceScanInput): SourceResolution {
43
54
  const root = realpathSync(cwd);
44
55
  const candidates: SourceCandidate[] = [];
45
56
  let entriesRead = 0;
46
57
  let sourceBytes = 0;
47
58
  let truncated = false;
48
- function walk({
49
- directory,
50
- patterns,
51
- depth,
52
- }: {
53
- readonly directory: string;
54
- readonly patterns: readonly string[];
55
- readonly depth: number;
56
- }): void {
59
+ let capped = false;
60
+ function walk(directory: string, depth: number): void {
57
61
  if (depth > MAX_DEPTH) {
58
62
  truncated = true;
59
63
  return;
60
64
  }
61
- const localPatterns = [...patterns, ...ignorePatterns(directory, root)];
62
- const matcher = ignore().add(localPatterns);
63
65
  for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
64
66
  (left, right) => left.name.localeCompare(right.name),
65
67
  )) {
@@ -68,30 +70,13 @@ export function resolveSelection(
68
70
  truncated = true;
69
71
  return;
70
72
  }
71
- if (
72
- entry.name.startsWith('.') ||
73
- [...entry.name].some(
74
- (character) =>
75
- character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
76
- ) ||
77
- SENSITIVE_NAME.test(entry.name) ||
78
- entry.isSymbolicLink()
79
- ) {
73
+ const absolutePath = paths.allowedPath(join(directory, entry.name));
74
+ if (!absolutePath) {
80
75
  continue;
81
76
  }
82
- const absolutePath = join(directory, entry.name);
83
77
  const path = relative(root, absolutePath).split(sep).join('/');
84
- if (matcher.ignores(entry.isDirectory() ? `${path}/` : path)) {
85
- continue;
86
- }
87
78
  if (entry.isDirectory()) {
88
- if (!SKIP_DIRECTORIES.has(entry.name)) {
89
- walk({
90
- directory: absolutePath,
91
- patterns: localPatterns,
92
- depth: depth + 1,
93
- });
94
- }
79
+ walk(absolutePath, depth + 1);
95
80
  continue;
96
81
  }
97
82
  if (
@@ -108,7 +93,14 @@ export function resolveSelection(
108
93
  sourceBytes += size;
109
94
  const source = readFileSync(absolutePath, 'utf8');
110
95
  try {
111
- candidates.push(...matchSource({ path, source, selection }));
96
+ for (const candidate of sourceMatches({ path, source, selection })) {
97
+ candidates.push(candidate);
98
+ candidates.sort(compareCandidates);
99
+ if (candidates.length > MAX_CANDIDATES) {
100
+ capped = true;
101
+ candidates.pop();
102
+ }
103
+ }
112
104
  } catch (cause) {
113
105
  if (cause instanceof InvalidMarkup) {
114
106
  truncated = true;
@@ -118,73 +110,184 @@ export function resolveSelection(
118
110
  }
119
111
  }
120
112
  }
121
- walk({ directory: root, patterns: [], depth: 0 });
122
- candidates.sort(
123
- (left, right) =>
124
- right.score - left.score ||
125
- left.path.localeCompare(right.path) ||
126
- left.startLine - right.startLine,
113
+ walk(root, 0);
114
+ return { candidates, truncated, capped };
115
+ }
116
+
117
+ function compareCandidates(
118
+ left: SourceCandidate,
119
+ right: SourceCandidate,
120
+ ): number {
121
+ return (
122
+ right.score - left.score ||
123
+ left.path.localeCompare(right.path) ||
124
+ left.startLine - right.startLine
127
125
  );
128
- return {
129
- candidates: candidates.slice(0, MAX_CANDIDATES),
130
- truncated,
131
- capped: candidates.length > MAX_CANDIDATES,
132
- };
133
126
  }
134
127
 
135
- function ignorePatterns(directory: string, root: string): readonly string[] {
136
- const prefix = relative(root, directory).split(sep).join('/');
137
- const patterns: string[] = [];
138
- for (const file of IGNORE_FILES) {
139
- let source: string;
140
- try {
141
- source = readFileSync(join(directory, file), 'utf8');
142
- } catch (cause) {
143
- if (isMissingFile(cause)) {
144
- continue;
145
- }
146
- throw new Error(`Cannot read ignore file ${file}`, { cause });
128
+ interface CompilerLocationInput {
129
+ readonly cwd: string;
130
+ readonly selection: VisualSelection;
131
+ readonly project?: string;
132
+ }
133
+
134
+ export function resolveCompilerLocation({
135
+ cwd,
136
+ selection,
137
+ project,
138
+ }: CompilerLocationInput): SourceResolution {
139
+ const location = selection.source;
140
+ if (!location) {
141
+ throw new Error('A compiler source location is required.');
142
+ }
143
+ const unresolved = (issue: string): SourceResolution => ({
144
+ candidates: [],
145
+ truncated: false,
146
+ capped: false,
147
+ issue,
148
+ });
149
+ if (!isAbsolute(location.path) && project === undefined) {
150
+ return unresolved(
151
+ 'Associate this page with a project in F7 before resolving a relative compiler path.',
152
+ );
153
+ }
154
+ const projectRoot = project === undefined ? cwd : allowedPath(cwd, project);
155
+ if (!projectRoot || !statSync(projectRoot).isDirectory()) {
156
+ return unresolved('The associated project is missing or excluded.');
157
+ }
158
+ const absolute = allowedPath(projectRoot, location.path);
159
+ if (
160
+ !absolute ||
161
+ !allowedPath(cwd, absolute) ||
162
+ !statSync(absolute).isFile() ||
163
+ statSync(absolute).size > MAX_FILE_BYTES
164
+ ) {
165
+ return unresolved(
166
+ 'The compiler path is missing, excluded, outside the project, or too large.',
167
+ );
168
+ }
169
+ const path = relative(realpathSync(cwd), absolute).split(sep).join('/');
170
+ if (
171
+ ![...SOURCE_EXTENSIONS, '.js', '.mjs', '.ts', '.mts'].includes(
172
+ extname(path).toLowerCase(),
173
+ )
174
+ ) {
175
+ return unresolved('The compiler path has an unsupported source format.');
176
+ }
177
+ try {
178
+ const candidates = matchSource({
179
+ path,
180
+ source: readFileSync(absolute, 'utf8'),
181
+ selection,
182
+ location,
183
+ });
184
+ if (candidates.length !== 1) {
185
+ return unresolved(
186
+ 'The compiler location does not match the selected tag in current local markup. Restart or refresh the development page.',
187
+ );
147
188
  }
148
- for (const line of source.split(/\r?\n/u)) {
149
- if (!line || line.startsWith('#')) {
150
- continue;
151
- }
152
- const negated = line.startsWith('!');
153
- const pattern = negated ? line.slice(1) : line;
154
- const nested = prefix
155
- ? `${prefix}/${pattern.startsWith('/') ? pattern.slice(1) : pattern.includes('/') ? pattern : `**/${pattern}`}`
156
- : pattern;
157
- patterns.push(`${negated ? '!' : ''}${nested}`);
189
+ return { candidates, truncated: false, capped: false };
190
+ } catch (cause) {
191
+ if (cause instanceof InvalidMarkup) {
192
+ return unresolved(
193
+ 'The compiler source could not be parsed; its location was not accepted.',
194
+ );
158
195
  }
196
+ throw new Error(`Cannot verify compiler source ${path}`, { cause });
159
197
  }
160
- return patterns;
198
+ }
199
+
200
+ export function resolveProjectSelection({
201
+ cwd,
202
+ selection,
203
+ project,
204
+ }: CompilerLocationInput): SourceResolution {
205
+ if (selection.source) {
206
+ return resolveCompilerLocation({ cwd, selection, project });
207
+ }
208
+ if (project === undefined) {
209
+ return resolveSelection(cwd, selection);
210
+ }
211
+ const workspaceRoot = realpathSync(cwd);
212
+ const paths = createPathPolicy(workspaceRoot);
213
+ const root = paths.allowedPath(project);
214
+ if (!root || !statSync(root).isDirectory()) {
215
+ return {
216
+ candidates: [],
217
+ truncated: false,
218
+ capped: false,
219
+ issue: 'The associated project is missing or excluded.',
220
+ };
221
+ }
222
+ const resolution = scanSource({ cwd: root, selection, paths });
223
+ return {
224
+ ...resolution,
225
+ candidates: resolution.candidates
226
+ .filter((candidate) => paths.allowedPath(join(root, candidate.path)))
227
+ .map((candidate) => ({
228
+ ...candidate,
229
+ path: relative(workspaceRoot, join(root, candidate.path))
230
+ .split(sep)
231
+ .join('/'),
232
+ })),
233
+ };
161
234
  }
162
235
 
163
236
  export function candidateIsCurrent(
164
237
  cwd: string,
165
238
  candidate: SourceCandidate,
166
239
  ): boolean {
167
- try {
168
- const absolutePath = realpathSync(join(cwd, candidate.path));
169
- if (
170
- projectRelativePath(cwd, absolutePath) === undefined ||
171
- statSync(absolutePath).size > MAX_FILE_BYTES
172
- ) {
173
- return false;
174
- }
175
- const source = readFileSync(absolutePath, 'utf8');
176
- return (
177
- sourceFingerprint(source) === candidate.fingerprint &&
178
- candidate.endLine <= source.split('\n').length
179
- );
180
- } catch (cause) {
181
- if (isGone(cause)) {
182
- return false;
240
+ return currentCandidates(cwd, [candidate]).length === 1;
241
+ }
242
+
243
+ // Each call owns its file versions; no validation survives into another prompt.
244
+ export function currentCandidates(
245
+ cwd: string,
246
+ candidates: readonly SourceCandidate[],
247
+ ): readonly SourceCandidate[] {
248
+ let paths: VisualPathPolicy | undefined;
249
+ const versions = new Map<
250
+ string,
251
+ { readonly fingerprint: string; readonly lines: number } | undefined
252
+ >();
253
+ return candidates.filter((candidate) => {
254
+ try {
255
+ paths ??= createPathPolicy(cwd);
256
+ const absolutePath = paths.allowedPath(candidate.path);
257
+ if (!absolutePath) {
258
+ return false;
259
+ }
260
+ if (!versions.has(absolutePath)) {
261
+ let version:
262
+ | { readonly fingerprint: string; readonly lines: number }
263
+ | undefined;
264
+ if (
265
+ projectRelativePath(cwd, absolutePath) !== undefined &&
266
+ statSync(absolutePath).size <= MAX_FILE_BYTES
267
+ ) {
268
+ const source = readFileSync(absolutePath, 'utf8');
269
+ version = {
270
+ fingerprint: sourceFingerprint(source),
271
+ lines: source.split('\n').length,
272
+ };
273
+ }
274
+ versions.set(absolutePath, version);
275
+ }
276
+ const version = versions.get(absolutePath);
277
+ return (
278
+ version !== undefined &&
279
+ version.fingerprint === candidate.fingerprint &&
280
+ candidate.endLine <= version.lines
281
+ );
282
+ } catch (cause) {
283
+ if (isGone(cause)) {
284
+ return false;
285
+ }
286
+ throw new Error(`Cannot verify selected source ${candidate.path}`, {
287
+ cause,
288
+ });
183
289
  }
184
- throw new Error(`Cannot verify selected source ${candidate.path}`, {
185
- cause,
186
- });
187
- }
290
+ });
188
291
  }
189
292
 
190
293
  // A path that turned into a file on the way reports ENOTDIR, which means the
@@ -4,6 +4,7 @@ import {
4
4
  candidateLabel,
5
5
  elementLabel,
6
6
  type PendingSelection,
7
+ SOURCE_METHOD,
7
8
  VISUAL_WIDGET,
8
9
  } from './model.ts';
9
10
 
@@ -13,7 +14,10 @@ export const BROWSER_EXTENSION_DIRECTORY = fileURLToPath(
13
14
 
14
15
  function pendingLabel({ resolution, chosen }: PendingSelection): string {
15
16
  if (chosen) {
16
- return `${candidateLabel(chosen)} (selected candidate)`;
17
+ return `${candidateLabel(chosen)}${chosen.startColumn ? `:${String(chosen.startColumn)}` : ''} (${chosen.method === SOURCE_METHOD.compiler ? 'compiler location checked' : 'selected candidate'})`;
18
+ }
19
+ if (resolution.issue) {
20
+ return resolution.issue;
17
21
  }
18
22
  const truncated = resolution.truncated ? ' · search truncated' : '';
19
23
  const capped = resolution.capped ? ' · more candidates' : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.14.0",
3
+ "version": "9.0.0",
4
4
  "description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
5
5
  "type": "module",
6
6
  "files": [
@@ -81,8 +81,8 @@
81
81
  "video": "https://raw.githubusercontent.com/RMRdeveloper/sideroom-pi/main/media/preview.mp4"
82
82
  },
83
83
  "peerDependencies": {
84
- "@earendil-works/pi-coding-agent": "*",
85
- "@earendil-works/pi-tui": "*",
84
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
85
+ "@earendil-works/pi-tui": ">=1.2.0",
86
86
  "typebox": "*"
87
87
  },
88
88
  "devDependencies": {
@@ -90,8 +90,8 @@
90
90
  "@changesets/cli": "^3.0.2",
91
91
  "@commitlint/cli": "^21.2.2",
92
92
  "@commitlint/config-conventional": "^21.2.2",
93
- "@earendil-works/pi-coding-agent": "0.87.1",
94
- "@earendil-works/pi-tui": "0.87.1",
93
+ "@earendil-works/pi-coding-agent": "1.2.0",
94
+ "@earendil-works/pi-tui": "1.2.0",
95
95
  "@types/node": "^22.20.1",
96
96
  "@types/ws": "^8.18.2",
97
97
  "fflate": "0.8.3",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sideroom-guidelines
3
- description: "Follow the mandatory coding workflow before any write or edit, including bug fixes, features, refactors, tests, configuration, and documentation edits. Read the complete skill first; also read the exact language guide for Java, PHP/Laravel, JavaScript/JSX, TypeScript/TSX, Python, Go, or Rust targets. Discussion or read-only investigation alone does not require loading it."
3
+ description: "Follow the mandatory coding workflow before any write or edit, including bug fixes, features, refactors, tests, configuration, and documentation edits. Load the complete skill and exact language guide through sideroom_guidelines or full read calls for Java, PHP/Laravel, JavaScript/JSX, TypeScript/TSX, Python, Go, or Rust targets. Discussion or read-only investigation alone does not require loading it."
4
4
  license: MIT
5
5
  metadata:
6
6
  author: RMRdeveloper
@@ -11,12 +11,14 @@ metadata:
11
11
 
12
12
  ## Activation Contract
13
13
 
14
- Load this whole skill with `read`, without `offset` or `limit`, before the first `write` or `edit` in each agent run. Use the absolute skill path shown in Available Skills. For each supported target language, also fully load its exact guide from `references/languages/`.
14
+ Before `write` or `edit`, call `sideroom_guidelines` with the target file's `path`. It returns this whole skill and the matching complete language guide, or only this skill for unsupported languages. Reuse loaded guides during the same agent run; load any additional target language before editing it. Reload after compaction or a new run.
15
+
16
+ Alternatively, load this whole skill with `read`, without `offset` or `limit`, using the absolute packaged path shown in Available Skills. For each supported target language, also fully load its exact guide from `references/languages/`. Both routes satisfy the same gate only on successful, complete delivery.
15
17
 
16
18
  ## Hard Rules
17
19
 
18
20
  - Apply the loaded guide to changed code in the context of the full user requirements and established project contracts. Architectural rules require demonstrated evidence; KISS, DRY, and YAGNI never justify omitting requested behavior, required error handling, compatibility, or meaningful tests.
19
- - Do not write or edit until exact full-file reads of the required packaged paths succeed; partial reads and same-named files do not count.
21
+ - Do not write or edit until the required packaged documents are fully loaded through `sideroom_guidelines` or exact full-file `read` calls; failed or partial loads and same-named files do not count.
20
22
  - Do not bypass the gate with Bash, a subprocess, or another mutation tool.
21
23
  - Do not load language guides unrelated to files being changed.
22
24
  - Fail fast, keep control flow flat, and validate once at the boundary.
@@ -36,8 +38,8 @@ Load this whole skill with `read`, without `offset` or `limit`, before the first
36
38
 
37
39
  ## Execution Steps
38
40
 
39
- 1. Read this complete file from its packaged path before any mutation.
40
- 2. Fully read the matching packaged language guide, if the target has a supported extension.
41
+ 1. Load this complete skill through `sideroom_guidelines` with the target path, or a full `read` of its packaged path, before any mutation.
42
+ 2. Ensure the matching complete language guide is loaded for supported extensions. The tool includes it automatically; with `read`, load it separately.
41
43
  3. Implement a focused change that satisfies all current requirements and preserves required existing behavior.
42
44
  4. Review the changes introduced by this run against the loaded guide; fix demonstrated violations caused by the change without refactoring unrelated code.
43
45
  5. Run the relevant formatter, linter, type checks, and tests before finishing.
@@ -51,6 +53,7 @@ Load this whole skill with `read`, without `offset` or `limit`, before the first
51
53
  | Explicit error, fail now | Fallbacks that hide the real failure |
52
54
  | One responsibility per unit | Validate + transform + persist + notify together |
53
55
  | Extract for real duplication, a present responsibility, or testability | Abstract only for hypothetical future reuse |
56
+ | Decomposition by intent (rule 20): name a coherent operation so its caller reads the flow without implementation details, even with one caller | Split only to shorten functions, or separate coupled steps into shared mutable state, excessive parameters, or functions that must be read together |
54
57
  | Simplest design that satisfies the full requirements | Omit necessary structure to minimize code |
55
58
  | Implement current requirements and established contracts completely | Add capabilities only for hypothetical future cases |
56
59
  | Compose small focused units | Deep inheritance for unrelated behavior |
@@ -25,6 +25,7 @@ Rules every Go change must follow.
25
25
  | 17 | [Dependency direction](#17-dependency-direction) | architectural |
26
26
  | 18 | [Testability](#18-testability-as-a-design-constraint) | architectural |
27
27
  | 19 | [Comments](#19-comments) | architectural |
28
+ | 20 | [Decomposition by intent](#20-decomposition-by-intent) | structural |
28
29
 
29
30
  ---
30
31
 
@@ -370,8 +371,41 @@ counter++
370
371
  ticker := time.NewTicker(5 * time.Second)
371
372
  ```
372
373
 
374
+ ### 20. Decomposition by intent
375
+
376
+ RULE: Extract a block into a named function when it represents a coherent operation and its name and contract let the caller understand the flow without reading its implementation. Keep orchestration separate from implementation details. Do not split solely to reduce line count; keep tightly coupled steps together when separating them would introduce shared mutable state, excessive parameters, or require reading both functions to understand either.
377
+ WHY: Good decomposition reduces how much a reader must understand at once. Function length is a review signal; extraction must improve comprehension rather than merely move code elsewhere.
378
+
379
+ ```go
380
+ // bad
381
+ func createPackingSlip(items []PackingItem, destination string) PackingSlip {
382
+ unitCount := 0
383
+ weightGrams := 0
384
+ for _, item := range items {
385
+ unitCount += item.Quantity
386
+ weightGrams += item.Quantity * item.UnitWeightGrams
387
+ }
388
+ return PackingSlip{Destination: destination, Totals: PackingTotals{UnitCount: unitCount, WeightGrams: weightGrams}}
389
+ }
390
+
391
+ // good
392
+ func createPackingSlip(items []PackingItem, destination string) PackingSlip {
393
+ return PackingSlip{Destination: destination, Totals: calculatePackingTotals(items)}
394
+ }
395
+
396
+ func calculatePackingTotals(items []PackingItem) PackingTotals {
397
+ unitCount := 0
398
+ weightGrams := 0
399
+ for _, item := range items {
400
+ unitCount += item.Quantity
401
+ weightGrams += item.Quantity * item.UnitWeightGrams
402
+ }
403
+ return PackingTotals{UnitCount: unitCount, WeightGrams: weightGrams}
404
+ }
405
+ ```
406
+
373
407
  ---
374
408
 
375
409
  **When in doubt:** fail fast, keep it flat, keep it small.
376
410
 
377
- **Enforcement note:** rules 1–2 are mechanical and should be backed by Go formatting or linting. Rules 3–10 are structural checks. Rules 11–19 require review judgment.
411
+ **Enforcement note:** rules 1–2 are mechanical and should be backed by Go formatting or linting. Rules 3–10 and 20 are structural; rules 11–19 are architectural. Rule 20 requires semantic review, not a line-count limit or mechanical length detector. Rules 11–20 require review judgment.
@@ -25,6 +25,7 @@ Rules every Java change must follow.
25
25
  | 17 | [Dependency direction](#17-dependency-direction) | architectural |
26
26
  | 18 | [Testability](#18-testability-as-a-design-constraint) | architectural |
27
27
  | 19 | [Comments](#19-comments) | architectural |
28
+ | 20 | [Decomposition by intent](#20-decomposition-by-intent) | structural |
28
29
 
29
30
  ---
30
31
 
@@ -250,8 +251,40 @@ counter.increment();
250
251
  scheduler.scheduleAtFixedRate(this::checkPaymentStatus, 0, 5, SECONDS);
251
252
  ```
252
253
 
254
+ ### 20. Decomposition by intent
255
+
256
+ RULE: Extract a block into a named function when it represents a coherent operation and its name and contract let the caller understand the flow without reading its implementation. Keep orchestration separate from implementation details. Do not split solely to reduce line count; keep tightly coupled steps together when separating them would introduce shared mutable state, excessive parameters, or require reading both functions to understand either.
257
+ WHY: Good decomposition reduces how much a reader must understand at once. Function length is a review signal; extraction must improve comprehension rather than merely move code elsewhere.
258
+
259
+ ```java
260
+ // bad
261
+ PackingSlip createPackingSlip(List<PackingItem> items, String destination) {
262
+ int unitCount = 0;
263
+ long weightGrams = 0;
264
+ for (PackingItem item : items) {
265
+ unitCount += item.quantity();
266
+ weightGrams += item.quantity() * item.unitWeightGrams();
267
+ }
268
+ return new PackingSlip(destination, new PackingTotals(unitCount, weightGrams));
269
+ }
270
+ // good
271
+ PackingSlip createPackingSlip(List<PackingItem> items, String destination) {
272
+ return new PackingSlip(destination, calculatePackingTotals(items));
273
+ }
274
+
275
+ private PackingTotals calculatePackingTotals(List<PackingItem> items) {
276
+ int unitCount = 0;
277
+ long weightGrams = 0;
278
+ for (PackingItem item : items) {
279
+ unitCount += item.quantity();
280
+ weightGrams += item.quantity() * item.unitWeightGrams();
281
+ }
282
+ return new PackingTotals(unitCount, weightGrams);
283
+ }
284
+ ```
285
+
253
286
  ---
254
287
 
255
288
  **When in doubt:** fail fast, keep it flat, keep it small.
256
289
 
257
- **Enforcement note:** rules 1–2 are mechanical and should be backed by Java formatting or linting. Rules 3–10 are structural checks. Rules 11–19 require review judgment.
290
+ **Enforcement note:** rules 1–2 are mechanical and should be backed by Java formatting or linting. Rules 3–10 and 20 are structural; rules 11–19 are architectural. Rule 20 requires semantic review, not a line-count limit or mechanical length detector. Rules 11–20 require review judgment.
@@ -25,6 +25,7 @@ Rules every JavaScript change must follow.
25
25
  | 17 | [Dependency direction](#17-dependency-direction) | architectural |
26
26
  | 18 | [Testability](#18-testability-as-a-design-constraint) | architectural |
27
27
  | 19 | [Comments](#19-comments) | architectural |
28
+ | 20 | [Decomposition by intent](#20-decomposition-by-intent) | structural |
28
29
 
29
30
  ---
30
31
 
@@ -377,11 +378,43 @@ counter++;
377
378
  setInterval(checkPaymentStatus, 5_000);
378
379
  ```
379
380
 
381
+ ### 20. Decomposition by intent
382
+
383
+ RULE: Extract a block into a named function when it represents a coherent operation and its name and contract let the caller understand the flow without reading its implementation. Keep orchestration separate from implementation details. Do not split solely to reduce line count; keep tightly coupled steps together when separating them would introduce shared mutable state, excessive parameters, or require reading both functions to understand either.
384
+ WHY: Good decomposition reduces how much a reader must understand at once. Function length is a review signal; extraction must improve comprehension rather than merely move code elsewhere.
385
+
386
+ ```js
387
+ // bad
388
+ function createPackingSlip(items, destination) {
389
+ let unitCount = 0;
390
+ let weightGrams = 0;
391
+ for (const item of items) {
392
+ unitCount += item.quantity;
393
+ weightGrams += item.quantity * item.unitWeightGrams;
394
+ }
395
+ return { destination, totals: { unitCount, weightGrams } };
396
+ }
397
+ // good
398
+ function createPackingSlip(items, destination) {
399
+ return { destination, totals: calculatePackingTotals(items) };
400
+ }
401
+
402
+ function calculatePackingTotals(items) {
403
+ let unitCount = 0;
404
+ let weightGrams = 0;
405
+ for (const item of items) {
406
+ unitCount += item.quantity;
407
+ weightGrams += item.quantity * item.unitWeightGrams;
408
+ }
409
+ return { unitCount, weightGrams };
410
+ }
411
+ ```
412
+
380
413
  ---
381
414
 
382
415
  **When in doubt:** fail fast, keep it flat, keep it small.
383
416
 
384
- **Enforcement note:** rules 1–2 are mechanical and should be backed by a JavaScript formatter/linter. Rules 3–10 are structural checks. Rules 11–19 require review judgment.
417
+ **Enforcement note:** rules 1–2 are mechanical and should be backed by a JavaScript formatter/linter. Rules 3–10 and 20 are structural; rules 11–19 are architectural. Rule 20 requires semantic review, not a line-count limit or mechanical length detector. Rules 11–20 require review judgment.
385
418
 
386
419
  ## Language-specific conventions
387
420
 
@@ -25,6 +25,7 @@ Rules every PHP or Laravel change must follow.
25
25
  | 17 | [Dependency direction](#17-dependency-direction) | architectural |
26
26
  | 18 | [Testability](#18-testability-as-a-design-constraint) | architectural |
27
27
  | 19 | [Comments](#19-comments) | architectural |
28
+ | 20 | [Decomposition by intent](#20-decomposition-by-intent) | structural |
28
29
 
29
30
  ---
30
31
 
@@ -241,8 +242,40 @@ RULE: Comment only non-obvious intent — a trade-off, a constraint, a hazard na
241
242
  Schedule::call($checkPaymentStatus)->everyFiveSeconds();
242
243
  ```
243
244
 
245
+ ### 20. Decomposition by intent
246
+
247
+ RULE: Extract a block into a named function when it represents a coherent operation and its name and contract let the caller understand the flow without reading its implementation. Keep orchestration separate from implementation details. Do not split solely to reduce line count; keep tightly coupled steps together when separating them would introduce shared mutable state, excessive parameters, or require reading both functions to understand either.
248
+ WHY: Good decomposition reduces how much a reader must understand at once. Function length is a review signal; extraction must improve comprehension rather than merely move code elsewhere.
249
+
250
+ ```php
251
+ // bad
252
+ function createPackingSlip(array $items, string $destination): PackingSlip {
253
+ $unitCount = 0;
254
+ $weightGrams = 0;
255
+ foreach ($items as $item) {
256
+ $unitCount += $item->quantity;
257
+ $weightGrams += $item->quantity * $item->unitWeightGrams;
258
+ }
259
+ return new PackingSlip($destination, new PackingTotals($unitCount, $weightGrams));
260
+ }
261
+ // good
262
+ function createPackingSlip(array $items, string $destination): PackingSlip {
263
+ return new PackingSlip($destination, calculatePackingTotals($items));
264
+ }
265
+
266
+ function calculatePackingTotals(array $items): PackingTotals {
267
+ $unitCount = 0;
268
+ $weightGrams = 0;
269
+ foreach ($items as $item) {
270
+ $unitCount += $item->quantity;
271
+ $weightGrams += $item->quantity * $item->unitWeightGrams;
272
+ }
273
+ return new PackingTotals($unitCount, $weightGrams);
274
+ }
275
+ ```
276
+
244
277
  ---
245
278
 
246
279
  **When in doubt:** fail fast, keep it flat, keep it small.
247
280
 
248
- **Enforcement note:** rules 1–2 are mechanical and should be backed by PHP/Laravel formatting or linting. Rules 3–10 are structural checks. Rules 11–19 require review judgment.
281
+ **Enforcement note:** rules 1–2 are mechanical and should be backed by PHP/Laravel formatting or linting. Rules 3–10 and 20 are structural; rules 11–19 are architectural. Rule 20 requires semantic review, not a line-count limit or mechanical length detector. Rules 11–20 require review judgment.