@rmrdeveloper/sideroom-pi 8.0.2 → 8.1.1

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.
package/README.md CHANGED
@@ -6,9 +6,10 @@
6
6
 
7
7
  > Coding agents fail in predictable ways: they guess instead of asking, they
8
8
  > bury progress in chat scroll, they litter your repo with `TODO.md` files,
9
- > and every session writes code in a slightly different style.
9
+ > every session writes code in a slightly different style, and they declare
10
+ > victory while the project checks are still red.
10
11
  >
11
- > **Sideroom fixes all four — without touching your repository.**
12
+ > **Sideroom fixes all of it — without touching your repository.**
12
13
 
13
14
  Sideroom Pi is a global [Pi package](https://pi.dev/docs/latest/packages)
14
15
  that gives the parent agent a *side room* next to your code: a place to ask
@@ -29,6 +30,10 @@ Every long agent session drifts toward the same failure modes:
29
30
  and half-abandoned plans committed next to real code.
30
31
  4. **Style drift.** Each session reinvents conventions: nesting depth, error
31
32
  handling, naming, validation. Review becomes cleanup.
33
+ 5. **Unapplied guidelines.** The contract is read, then ignored: a braceless
34
+ `if`, a swallowed error, `console.log`, and `TODO` land in the diff anyway.
35
+ 6. **Premature completion.** The agent announces it is done while the
36
+ formatter, linter, type checks, or tests were never run.
32
37
 
33
38
  Sideroom answers each one with a small, opinionated surface:
34
39
 
@@ -39,6 +44,8 @@ Sideroom answers each one with a small, opinionated surface:
39
44
  | Repo pollution | Session-branch state — the board and history die with the session, never with a commit |
40
45
  | Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
41
46
  | Vague plans | `sideroom-grill` — an interview that settles the words before the work |
47
+ | Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
48
+ | Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
42
49
 
43
50
  ## What it feels like
44
51
 
@@ -65,6 +72,14 @@ wall of tool calls, it:
65
72
  blocked. The guides cover guard clauses, braced conditionals, fail-fast
66
73
  errors, focused units, and all 19 canonical rules without dumping them into
67
74
  the system prompt.
75
+ - **Cannot sneak sloppy lines past the gate.** Each write and edit is checked
76
+ against the mechanical rules on the added lines only: braceless
77
+ conditionals and swallowed errors block the mutation; vague names, stale
78
+ `TODO`s, commented-out code, and debug output are appended as notes to the
79
+ result. A rule that keeps firing degrades instead of trapping the agent.
80
+ - **Does not call it done in red.** When files changed and the project's own
81
+ check command has not passed, the agent is steered back to run it before
82
+ finishing. If no check command is detectable, the gate stays out of the way.
68
83
 
69
84
  ## What's inside
70
85
 
@@ -119,6 +134,26 @@ Each guide mirrors all 19 rules in the canonical seed with idiomatic examples.
119
134
  The seed, `assets/artifacts/GUIDELINES_TEMPLATE.md`, is never pasted into the
120
135
  system prompt.
121
136
 
137
+ ### Rules — enforcement, not advice
138
+
139
+ The guidelines gate makes the agent read the rules; `sideroom_rules` makes it
140
+ follow them. On every `write`/`edit`, only the added lines are checked against
141
+ the mechanical rules from the canonical seed. Braceless conditionals and
142
+ swallowed errors block the mutation with an actionable reason; banned
143
+ identifiers, stale comments, commented-out code, and debug artifacts are
144
+ appended as notes to the tool result. A per-rule circuit breaker degrades a
145
+ repeatedly firing block to a note so the agent never dead-locks. No files
146
+ written, no config read: the catalog ships with the package.
147
+
148
+ ### Done — done means green
149
+
150
+ `sideroom_done` detects the project's check command — `package.json` scripts
151
+ (`check` → `test` → `lint` → `typecheck` → `types`) with the right
152
+ package manager, `pytest`, `go test ./...`, `cargo test`, or a `make check`
153
+ target — and watches for it to pass. If files changed without a green run, the
154
+ agent is steered once per turn, up to a cap, to run it before finishing. When
155
+ nothing is detectable, the gate does nothing.
156
+
122
157
  ## Quick path
123
158
 
124
159
  1. Install globally from npm. Pi records this in your user settings, so do
@@ -187,6 +222,27 @@ languages use the shared table. Do not route around
187
222
  the gate through Bash or another file-mutation path. Before finishing, review
188
223
  the diff against the loaded guide and run the relevant project checks.
189
224
 
225
+ ### Rules contract
226
+
227
+ - Trigger: every `write`/`edit` call, in every mode.
228
+ - Scope: only lines added by the call. For `edit`, each `oldText → newText`
229
+ pair; for `write`, the new content against the existing file.
230
+ - Blocking rules: braced conditionals (rule 1) and explicit error handling
231
+ (rule 5). Warning rules: clear names (6), comments (19), and debug artifacts.
232
+ - Blocked calls return the rule id, the reason, and the fix. Warnings are
233
+ appended to the tool result.
234
+ - A rule that blocks three times in a run degrades to a warning until five
235
+ clean checks or a new interactive prompt reset it.
236
+
237
+ ### Done contract
238
+
239
+ - Detected command: `package.json` scripts in priority order with the
240
+ lockfile's package manager, then `pytest -q` for Python, `go test ./...`,
241
+ `cargo test`, or `make check`. Detection is cached per working directory.
242
+ - Green means that command ran with a zero exit in this run. A later
243
+ successful `write`/`edit` clears green again.
244
+ - Steering: once per turn, at most twice per run, then it stops.
245
+
190
246
  ### Grill contract
191
247
 
192
248
  Load the skill at the start of a change with a fuzzy plan.
@@ -194,6 +250,12 @@ Single-session scope only. Files are created lazily: nothing exists until
194
250
  the first term or decision crystallises. A session with a sharper glossary
195
251
  and zero ADRs is working as designed.
196
252
 
253
+ ## Documentation
254
+
255
+ Maintainer reference lives in [`docs/`](docs/README.md): the
256
+ [architecture](docs/architecture.md) and one page per
257
+ [extension](docs/extensions/ask.md).
258
+
197
259
  ## License
198
260
 
199
261
  MIT.
@@ -0,0 +1,227 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export interface CheckCommand {
5
+ readonly executable: string;
6
+ readonly args: readonly string[];
7
+ readonly display: string;
8
+ }
9
+
10
+ const SHELL_OPERATOR = {
11
+ and: '&&',
12
+ or: '||',
13
+ pipe: '|',
14
+ sequence: ';',
15
+ background: '&',
16
+ } as const;
17
+
18
+ type ShellOperator = (typeof SHELL_OPERATOR)[keyof typeof SHELL_OPERATOR];
19
+
20
+ interface ShellSegment {
21
+ readonly text: string;
22
+ readonly operatorBefore: ShellOperator | undefined;
23
+ }
24
+
25
+ export const PACKAGE_MANAGER = {
26
+ npm: 'npm',
27
+ pnpm: 'pnpm',
28
+ yarn: 'yarn',
29
+ bun: 'bun',
30
+ } as const;
31
+
32
+ type PackageManager = (typeof PACKAGE_MANAGER)[keyof typeof PACKAGE_MANAGER];
33
+
34
+ const SCRIPT_PRIORITY = [
35
+ 'check',
36
+ 'test',
37
+ 'lint',
38
+ 'typecheck',
39
+ 'types',
40
+ ] as const;
41
+
42
+ export function detectCheckCommand(cwd: string): CheckCommand | undefined {
43
+ const packageCommand = detectPackageCommand(cwd);
44
+ if (packageCommand !== undefined) {
45
+ return packageCommand;
46
+ }
47
+ if (
48
+ existsSync(join(cwd, 'pyproject.toml')) ||
49
+ existsSync(join(cwd, 'setup.py'))
50
+ ) {
51
+ return command('pytest', ['-q']);
52
+ }
53
+ if (existsSync(join(cwd, 'go.mod'))) {
54
+ return command('go', ['test', './...']);
55
+ }
56
+ if (existsSync(join(cwd, 'Cargo.toml'))) {
57
+ return command('cargo', ['test']);
58
+ }
59
+ if (hasMakeCheck(cwd)) {
60
+ return command('make', ['check']);
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ export function commandMatches(text: string, check: CheckCommand): boolean {
66
+ const target = collapseWhitespace(check.display);
67
+ const segments = splitShellCommand(text);
68
+ const hasUnsafeOperator = segments.some(
69
+ ({ operatorBefore }) =>
70
+ operatorBefore !== undefined && operatorBefore !== SHELL_OPERATOR.and,
71
+ );
72
+ if (hasUnsafeOperator) {
73
+ return false;
74
+ }
75
+
76
+ return segments.some(({ text: segmentText }) =>
77
+ simpleCommandMatches(segmentText, target),
78
+ );
79
+ }
80
+
81
+ function simpleCommandMatches(text: string, target: string): boolean {
82
+ const normalized = collapseWhitespace(text.trim());
83
+ if (normalized === target) {
84
+ return true;
85
+ }
86
+ return normalized.startsWith(`${target} `);
87
+ }
88
+
89
+ function splitShellCommand(commandText: string): ShellSegment[] {
90
+ const segments: ShellSegment[] = [];
91
+ let segmentStart = 0;
92
+ let operatorBefore: ShellOperator | undefined;
93
+ let quote = '';
94
+
95
+ for (let index = 0; index < commandText.length; index += 1) {
96
+ const character = commandText[index] ?? '';
97
+ if (quote.length > 0) {
98
+ if (character === '\\' && quote === '"') {
99
+ index += 1;
100
+ continue;
101
+ }
102
+ if (character === quote) {
103
+ quote = '';
104
+ }
105
+ continue;
106
+ }
107
+
108
+ const startsQuote =
109
+ character === "'" || character === '"' || character === '`';
110
+ if (startsQuote) {
111
+ quote = character;
112
+ continue;
113
+ }
114
+
115
+ const operator = shellOperatorAt(commandText, index);
116
+ if (operator === undefined) {
117
+ continue;
118
+ }
119
+ segments.push({
120
+ text: commandText.slice(segmentStart, index),
121
+ operatorBefore,
122
+ });
123
+ operatorBefore = operator;
124
+ index += operator.length - 1;
125
+ segmentStart = index + 1;
126
+ }
127
+
128
+ segments.push({
129
+ text: commandText.slice(segmentStart),
130
+ operatorBefore,
131
+ });
132
+ return segments;
133
+ }
134
+
135
+ function shellOperatorAt(
136
+ commandText: string,
137
+ index: number,
138
+ ): ShellOperator | undefined {
139
+ const twoCharacterOperator = commandText.slice(index, index + 2);
140
+ if (twoCharacterOperator === SHELL_OPERATOR.and) {
141
+ return SHELL_OPERATOR.and;
142
+ }
143
+ if (twoCharacterOperator === SHELL_OPERATOR.or) {
144
+ return SHELL_OPERATOR.or;
145
+ }
146
+
147
+ const character = commandText[index];
148
+ if (character === SHELL_OPERATOR.pipe) {
149
+ return SHELL_OPERATOR.pipe;
150
+ }
151
+ if (character === SHELL_OPERATOR.sequence) {
152
+ return SHELL_OPERATOR.sequence;
153
+ }
154
+ if (character === SHELL_OPERATOR.background) {
155
+ return SHELL_OPERATOR.background;
156
+ }
157
+ return undefined;
158
+ }
159
+
160
+ function detectPackageCommand(cwd: string): CheckCommand | undefined {
161
+ const packagePath = join(cwd, 'package.json');
162
+ if (!existsSync(packagePath)) {
163
+ return undefined;
164
+ }
165
+
166
+ const scripts = readScripts(packagePath);
167
+ const script = SCRIPT_PRIORITY.find(
168
+ (name) => typeof scripts[name] === 'string',
169
+ );
170
+ if (script === undefined) {
171
+ return undefined;
172
+ }
173
+
174
+ const manager = packageManager(cwd);
175
+ if (manager === PACKAGE_MANAGER.yarn) {
176
+ return command(PACKAGE_MANAGER.yarn, [script]);
177
+ }
178
+ return command(manager, ['run', script]);
179
+ }
180
+
181
+ function readScripts(path: string): Record<string, unknown> {
182
+ try {
183
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
184
+ scripts?: Record<string, unknown>;
185
+ };
186
+ return parsed.scripts ?? {};
187
+ } catch (error) {
188
+ throw new Error(`Failed to read scripts from ${path}`, { cause: error });
189
+ }
190
+ }
191
+
192
+ function packageManager(cwd: string): PackageManager {
193
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) {
194
+ return PACKAGE_MANAGER.pnpm;
195
+ }
196
+ if (existsSync(join(cwd, 'yarn.lock'))) {
197
+ return PACKAGE_MANAGER.yarn;
198
+ }
199
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) {
200
+ return PACKAGE_MANAGER.bun;
201
+ }
202
+ return PACKAGE_MANAGER.npm;
203
+ }
204
+
205
+ function hasMakeCheck(cwd: string): boolean {
206
+ const path = join(cwd, 'Makefile');
207
+ if (!existsSync(path)) {
208
+ return false;
209
+ }
210
+ try {
211
+ return /^check:/m.test(readFileSync(path, 'utf8'));
212
+ } catch (error) {
213
+ throw new Error(`Failed to read ${path}`, { cause: error });
214
+ }
215
+ }
216
+
217
+ function command(executable: string, args: readonly string[]): CheckCommand {
218
+ return {
219
+ executable,
220
+ args,
221
+ display: [executable, ...args].join(' '),
222
+ };
223
+ }
224
+
225
+ function collapseWhitespace(value: string): string {
226
+ return value.replaceAll(/\s+/g, ' ');
227
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ isBashToolResult,
5
+ isEditToolResult,
6
+ isToolCallEventType,
7
+ isWriteToolResult,
8
+ } from '@earendil-works/pi-coding-agent';
9
+ import { type CheckCommand, commandMatches } from './detect.ts';
10
+ import {
11
+ DONE_GATE_TYPE,
12
+ type DoneState,
13
+ formatDoneSteer,
14
+ shouldSteerDone,
15
+ } from './model.ts';
16
+
17
+ export type ResolveCommand = (
18
+ ctx: Pick<ExtensionContext, 'cwd'>,
19
+ ) => CheckCommand | undefined;
20
+
21
+ export function registerDoneGuard(
22
+ pi: ExtensionAPI,
23
+ state: DoneState,
24
+ resolveCommand: ResolveCommand,
25
+ ): void {
26
+ const checkRuns = new Set<string>();
27
+
28
+ pi.on('turn_start', () => {
29
+ state.steeredThisTurn = false;
30
+ });
31
+
32
+ pi.on('tool_call', (event, ctx) => {
33
+ if (!isToolCallEventType('bash', event)) {
34
+ return;
35
+ }
36
+ const check = resolveCommand(ctx);
37
+ if (check === undefined) {
38
+ return;
39
+ }
40
+ if (commandMatches(event.input.command, check)) {
41
+ checkRuns.add(event.toolCallId);
42
+ }
43
+ });
44
+
45
+ pi.on('tool_result', (event) => {
46
+ if (isWriteToolResult(event) || isEditToolResult(event)) {
47
+ if (!event.isError) {
48
+ state.mutated = true;
49
+ state.checksGreen = false;
50
+ }
51
+ return;
52
+ }
53
+ if (isBashToolResult(event)) {
54
+ const wasCheck = checkRuns.delete(event.toolCallId);
55
+ if (wasCheck && !event.isError) {
56
+ state.checksGreen = true;
57
+ }
58
+ }
59
+ });
60
+
61
+ pi.on('turn_end', (_event, ctx) => {
62
+ const check = resolveCommand(ctx);
63
+ if (check === undefined || !shouldSteerDone(state, true)) {
64
+ return;
65
+ }
66
+ state.steeredThisTurn = true;
67
+ state.steerCount += 1;
68
+ pi.sendMessage(
69
+ {
70
+ customType: DONE_GATE_TYPE,
71
+ content: formatDoneSteer(check.display),
72
+ display: false,
73
+ },
74
+ { triggerTurn: true, deliverAs: 'steer' },
75
+ );
76
+ });
77
+ }
@@ -0,0 +1,35 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import { type CheckCommand, detectCheckCommand } from './detect.ts';
6
+ import { type ResolveCommand, registerDoneGuard } from './guard.ts';
7
+ import { createDoneState, resetDoneRun } from './model.ts';
8
+
9
+ // Pi loads extensions/*/index.ts through export default.
10
+ export default function registerDone(pi: ExtensionAPI): void {
11
+ const state = createDoneState();
12
+ const cache = new Map<string, CheckCommand | undefined>();
13
+ const resolveCommand: ResolveCommand = (
14
+ ctx: Pick<ExtensionContext, 'cwd'>,
15
+ ) => {
16
+ if (cache.has(ctx.cwd)) {
17
+ return cache.get(ctx.cwd);
18
+ }
19
+ const command = detectCheckCommand(ctx.cwd);
20
+ cache.set(ctx.cwd, command);
21
+ return command;
22
+ };
23
+
24
+ registerDoneGuard(pi, state, resolveCommand);
25
+
26
+ pi.on('input', (event) => {
27
+ if (event.source === 'interactive' || event.source === 'rpc') {
28
+ resetDoneRun(state);
29
+ }
30
+ });
31
+
32
+ pi.on('session_start', () => {
33
+ resetDoneRun(state);
34
+ });
35
+ }
@@ -0,0 +1,43 @@
1
+ export interface DoneState {
2
+ mutated: boolean;
3
+ checksGreen: boolean;
4
+ steeredThisTurn: boolean;
5
+ steerCount: number;
6
+ }
7
+
8
+ export const MAX_DONE_STEERS = 2;
9
+
10
+ export const DONE_GATE_TYPE = 'sideroom-done-gate';
11
+
12
+ export function createDoneState(): DoneState {
13
+ return {
14
+ mutated: false,
15
+ checksGreen: false,
16
+ steeredThisTurn: false,
17
+ steerCount: 0,
18
+ };
19
+ }
20
+
21
+ export function resetDoneRun(state: DoneState): void {
22
+ state.mutated = false;
23
+ state.checksGreen = false;
24
+ state.steeredThisTurn = false;
25
+ state.steerCount = 0;
26
+ }
27
+
28
+ export function shouldSteerDone(
29
+ state: DoneState,
30
+ hasCommand: boolean,
31
+ ): boolean {
32
+ return (
33
+ hasCommand &&
34
+ state.mutated &&
35
+ !state.checksGreen &&
36
+ !state.steeredThisTurn &&
37
+ state.steerCount < MAX_DONE_STEERS
38
+ );
39
+ }
40
+
41
+ export function formatDoneSteer(display: string): string {
42
+ return `Run \`${display}\` and make it pass before finishing.`;
43
+ }
@@ -21,6 +21,7 @@ export interface LanguageGuide {
21
21
  export const LANGUAGE_GUIDES: readonly LanguageGuide[] = [
22
22
  languageGuide('go.md', ['.go']),
23
23
  languageGuide('java.md', ['.java']),
24
+ languageGuide('javascript.md', ['.js', '.jsx', '.mjs', '.cjs']),
24
25
  languageGuide('php-laravel.md', ['.php']),
25
26
  languageGuide('python.md', ['.py']),
26
27
  languageGuide('rust.md', ['.rs']),
@@ -1,3 +1,6 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, normalize, resolve } from 'node:path';
1
4
  import {
2
5
  type ExtensionAPI,
3
6
  isReadToolResult,
@@ -31,7 +34,7 @@ export function registerGuidelineGuard(
31
34
  pi: ExtensionAPI,
32
35
  state: GuidelineReadState,
33
36
  ): void {
34
- pi.on('tool_result', (event) => {
37
+ pi.on('tool_result', (event, ctx) => {
35
38
  if (!isReadToolResult(event) || event.isError) {
36
39
  return;
37
40
  }
@@ -45,7 +48,7 @@ export function registerGuidelineGuard(
45
48
  ) {
46
49
  return;
47
50
  }
48
- recordGuidelineRead(state, input.path);
51
+ recordGuidelineRead(state, input.path, ctx.cwd);
49
52
  });
50
53
 
51
54
  pi.on('tool_call', (event) => {
@@ -71,19 +74,20 @@ export function registerGuidelineGuard(
71
74
  export function recordGuidelineRead(
72
75
  state: GuidelineReadState,
73
76
  path: string | undefined,
77
+ cwd: string = process.cwd(),
74
78
  ): void {
75
79
  if (path === undefined) {
76
80
  return;
77
81
  }
78
82
 
79
- const normalizedPath = normalizePath(path);
80
- if (normalizedPath === normalizePath(GUIDELINE_SKILL_PATH)) {
83
+ const canonicalPath = canonicalizePath(path, cwd);
84
+ if (canonicalPath === canonicalCatalogPath(GUIDELINE_SKILL_PATH)) {
81
85
  state.skillRead = true;
82
86
  return;
83
87
  }
84
88
 
85
89
  for (const guide of LANGUAGE_GUIDES) {
86
- if (normalizedPath === normalizePath(guide.path)) {
90
+ if (canonicalPath === canonicalCatalogPath(guide.path)) {
87
91
  state.languageGuidesRead.add(guide.path);
88
92
  return;
89
93
  }
@@ -116,12 +120,50 @@ export function mutationBlockReason(
116
120
  }
117
121
 
118
122
  function languageGuideFor(path: string): string | undefined {
119
- const normalizedPath = normalizePath(path).toLowerCase();
123
+ const normalizedPath = stripLeadingAt(path)
124
+ .replaceAll('\\', '/')
125
+ .toLowerCase();
120
126
  return LANGUAGE_GUIDES.find(({ extensions }) =>
121
127
  extensions.some((extension) => normalizedPath.endsWith(extension)),
122
128
  )?.path;
123
129
  }
124
130
 
125
- function normalizePath(path: string): string {
126
- return path.replace(/^@/, '').replaceAll('\\', '/');
131
+ const canonicalCatalogPaths = new Map<string, string>();
132
+
133
+ function canonicalCatalogPath(path: string): string {
134
+ const cached = canonicalCatalogPaths.get(path);
135
+ if (cached !== undefined) {
136
+ return cached;
137
+ }
138
+ const canonical = realpathOr(path);
139
+ canonicalCatalogPaths.set(path, canonical);
140
+ return canonical;
141
+ }
142
+
143
+ function canonicalizePath(path: string, cwd: string): string {
144
+ const expanded = expandHome(stripLeadingAt(path));
145
+ const absolute = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
146
+ return realpathOr(normalize(absolute));
147
+ }
148
+
149
+ function realpathOr(path: string): string {
150
+ try {
151
+ return realpathSync.native(path);
152
+ } catch {
153
+ return path;
154
+ }
155
+ }
156
+
157
+ function expandHome(path: string): string {
158
+ if (path === '~') {
159
+ return homedir();
160
+ }
161
+ if (path.startsWith('~/') || path.startsWith('~\\')) {
162
+ return join(homedir(), path.slice(2));
163
+ }
164
+ return path;
165
+ }
166
+
167
+ function stripLeadingAt(path: string): string {
168
+ return path.startsWith('@') ? path.slice(1) : path;
127
169
  }
@@ -19,4 +19,10 @@ export default function registerGuidelines(pi: ExtensionAPI): void {
19
19
  }
20
20
  return { systemPrompt };
21
21
  });
22
+
23
+ // Compaction may summarize the loaded guides out of context mid-run; force
24
+ // a fresh read instead of trusting a now-stale in-memory flag.
25
+ pi.on('session_compact', () => {
26
+ resetGuidelineReadState(readState);
27
+ });
22
28
  }
@@ -0,0 +1,71 @@
1
+ export const LANGUAGE = {
2
+ typescript: 'typescript',
3
+ javascript: 'javascript',
4
+ go: 'go',
5
+ java: 'java',
6
+ php: 'php',
7
+ python: 'python',
8
+ rust: 'rust',
9
+ generic: 'generic',
10
+ } as const;
11
+
12
+ export const SEVERITY = {
13
+ block: 'block',
14
+ warn: 'warn',
15
+ } as const;
16
+
17
+ export type LanguageId = (typeof LANGUAGE)[keyof typeof LANGUAGE];
18
+
19
+ export type RuleId =
20
+ | 'braced-conditionals'
21
+ | 'explicit-error-handling'
22
+ | 'clear-names'
23
+ | 'comments'
24
+ | 'debug-artifacts';
25
+
26
+ export type RuleSeverity = (typeof SEVERITY)[keyof typeof SEVERITY];
27
+
28
+ export const RULE_SEVERITY: Readonly<Record<RuleId, RuleSeverity>> = {
29
+ 'braced-conditionals': SEVERITY.block,
30
+ 'explicit-error-handling': SEVERITY.block,
31
+ 'clear-names': SEVERITY.warn,
32
+ comments: SEVERITY.warn,
33
+ 'debug-artifacts': SEVERITY.warn,
34
+ };
35
+
36
+ export function languageForPath(path: string): LanguageId {
37
+ const normalized = path.replaceAll('\\', '/').toLowerCase();
38
+ const separator = normalized.lastIndexOf('/');
39
+ const fileName = normalized.slice(separator + 1);
40
+ if (fileName.endsWith('.ts') || fileName.endsWith('.tsx')) {
41
+ return LANGUAGE.typescript;
42
+ }
43
+ if (
44
+ fileName.endsWith('.js') ||
45
+ fileName.endsWith('.jsx') ||
46
+ fileName.endsWith('.mjs') ||
47
+ fileName.endsWith('.cjs')
48
+ ) {
49
+ return LANGUAGE.javascript;
50
+ }
51
+ if (fileName.endsWith('.go')) {
52
+ return LANGUAGE.go;
53
+ }
54
+ if (fileName.endsWith('.java')) {
55
+ return LANGUAGE.java;
56
+ }
57
+ if (fileName.endsWith('.php')) {
58
+ return LANGUAGE.php;
59
+ }
60
+ if (fileName.endsWith('.py')) {
61
+ return LANGUAGE.python;
62
+ }
63
+ if (fileName.endsWith('.rs')) {
64
+ return LANGUAGE.rust;
65
+ }
66
+ return LANGUAGE.generic;
67
+ }
68
+
69
+ export function supportsBracedConditionals(language: LanguageId): boolean {
70
+ return language !== LANGUAGE.python && language !== LANGUAGE.generic;
71
+ }