@rmrdeveloper/sideroom-pi 8.7.0 → 8.9.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.
- package/README.md +93 -10
- package/extensions/explain/guard.ts +8 -4
- package/extensions/explain/model.ts +57 -8
- package/extensions/guidelines/index.ts +12 -0
- package/extensions/guidelines/prompt.ts +1 -2
- package/extensions/guidelines/review.ts +70 -0
- package/extensions/jev/client.ts +106 -0
- package/extensions/jev/guard.ts +256 -0
- package/extensions/jev/index.ts +88 -0
- package/extensions/jev/key.ts +95 -0
- package/extensions/jev/model.ts +259 -0
- package/extensions/jev/ui.ts +281 -0
- package/extensions/monorepo-skills/flags.ts +9 -0
- package/extensions/monorepo-skills/index.ts +36 -0
- package/extensions/monorepo-skills/prompt.ts +14 -0
- package/extensions/monorepo-skills/scan.ts +225 -0
- package/extensions/persona/catalog.ts +8 -8
- package/extensions/persona/guard.ts +20 -16
- package/extensions/persona/index.ts +11 -9
- package/extensions/persona/model.ts +18 -2
- package/extensions/persona/prompt.ts +9 -0
- package/extensions/rules/guard.ts +1 -16
- package/extensions/shared/read-file.ts +18 -0
- package/extensions/todo/guards.ts +2 -0
- package/package.json +4 -1
- package/skills/sideroom-persona/SKILL.md +15 -8
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isEditToolResult,
|
|
5
|
+
isToolCallEventType,
|
|
6
|
+
isWriteToolResult,
|
|
7
|
+
type ToolCallEvent,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { resolveFileToolPath } from '../shared/file-path.ts';
|
|
10
|
+
import { readFileIfExists } from '../shared/read-file.ts';
|
|
11
|
+
import {
|
|
12
|
+
askJev,
|
|
13
|
+
JEV_FAILURE,
|
|
14
|
+
type JevFailure,
|
|
15
|
+
type JevTransport,
|
|
16
|
+
} from './client.ts';
|
|
17
|
+
import {
|
|
18
|
+
type AddedLine,
|
|
19
|
+
addedLinesForEdit,
|
|
20
|
+
addedLinesForWrite,
|
|
21
|
+
buildFileState,
|
|
22
|
+
findingKey,
|
|
23
|
+
formatFindings,
|
|
24
|
+
type JevFinding,
|
|
25
|
+
stateFitsBudget,
|
|
26
|
+
} from './model.ts';
|
|
27
|
+
|
|
28
|
+
export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
|
|
29
|
+
|
|
30
|
+
const MUTATION_KIND = {
|
|
31
|
+
write: 'write',
|
|
32
|
+
edit: 'edit',
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export const JEV_STATUS = {
|
|
36
|
+
ready: 'ready',
|
|
37
|
+
noKey: 'noKey',
|
|
38
|
+
quota: 'quota',
|
|
39
|
+
auth: 'auth',
|
|
40
|
+
unavailable: 'unavailable',
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type JevStatus = (typeof JEV_STATUS)[keyof typeof JEV_STATUS];
|
|
44
|
+
|
|
45
|
+
interface PendingMutation {
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly kind: string;
|
|
48
|
+
readonly lines: readonly AddedLine[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface JevGuardState {
|
|
52
|
+
halted: JevStatus | undefined;
|
|
53
|
+
transientFailures: number;
|
|
54
|
+
lastModel: string | undefined;
|
|
55
|
+
calls: number;
|
|
56
|
+
readonly pending: Map<string, PendingMutation>;
|
|
57
|
+
readonly notedThisTurn: Set<string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JevGuardDependencies {
|
|
61
|
+
readonly apiKey: () => string | undefined;
|
|
62
|
+
readonly transport: JevTransport;
|
|
63
|
+
readonly report: (status: JevStatus, ctx: ExtensionContext) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createJevGuardState(): JevGuardState {
|
|
67
|
+
return {
|
|
68
|
+
halted: undefined,
|
|
69
|
+
transientFailures: 0,
|
|
70
|
+
lastModel: undefined,
|
|
71
|
+
calls: 0,
|
|
72
|
+
pending: new Map(),
|
|
73
|
+
notedThisTurn: new Set(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The F10 screen calls this after the user stores a key or asks to retry.
|
|
78
|
+
export function resetJevGuard(state: JevGuardState): void {
|
|
79
|
+
state.halted = undefined;
|
|
80
|
+
state.transientFailures = 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Usage belongs to the active session: a new session starts counting from zero.
|
|
84
|
+
export function resetJevUsage(state: JevGuardState): void {
|
|
85
|
+
state.calls = 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function guardStatus(state: JevGuardState): JevStatus {
|
|
89
|
+
if (state.halted !== undefined) {
|
|
90
|
+
return state.halted;
|
|
91
|
+
}
|
|
92
|
+
return state.transientFailures > 0
|
|
93
|
+
? JEV_STATUS.unavailable
|
|
94
|
+
: JEV_STATUS.ready;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function shouldCallJev(state: JevGuardState): boolean {
|
|
98
|
+
return (
|
|
99
|
+
state.halted === undefined &&
|
|
100
|
+
state.transientFailures < JEV_TRANSIENT_FAILURE_LIMIT
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function registerJevGuard(
|
|
105
|
+
pi: ExtensionAPI,
|
|
106
|
+
state: JevGuardState,
|
|
107
|
+
dependencies: JevGuardDependencies,
|
|
108
|
+
): void {
|
|
109
|
+
pi.on('input', (event) => {
|
|
110
|
+
if (event.source !== 'interactive' && event.source !== 'rpc') {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
state.notedThisTurn.clear();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The added lines are read before the mutation lands, because after a write
|
|
117
|
+
// the previous content is gone. The body is read after, from disk.
|
|
118
|
+
pi.on('tool_call', (event, ctx) => {
|
|
119
|
+
const mutation = extractMutation(event, ctx.cwd);
|
|
120
|
+
if (mutation === undefined) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
state.pending.set(event.toolCallId, mutation);
|
|
124
|
+
return undefined;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
128
|
+
const pending = state.pending.get(event.toolCallId);
|
|
129
|
+
state.pending.delete(event.toolCallId);
|
|
130
|
+
if (pending === undefined || event.isError) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (!isWriteToolResult(event) && !isEditToolResult(event)) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const apiKey = dependencies.apiKey();
|
|
138
|
+
if (apiKey === undefined) {
|
|
139
|
+
dependencies.report(JEV_STATUS.noKey, ctx);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (!shouldCallJev(state)) {
|
|
143
|
+
dependencies.report(guardStatus(state), ctx);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const body = readMutationBody(ctx.cwd, pending.path);
|
|
148
|
+
if (body === undefined || !stateFitsBudget(body)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
state.calls += 1;
|
|
153
|
+
const attempt = await askJev(
|
|
154
|
+
dependencies.transport,
|
|
155
|
+
apiKey,
|
|
156
|
+
buildFileState(pending.path, body, pending.kind, pending.lines),
|
|
157
|
+
);
|
|
158
|
+
if (attempt.failure !== undefined) {
|
|
159
|
+
registerFailure(state, attempt.failure);
|
|
160
|
+
dependencies.report(guardStatus(state), ctx);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
registerSuccess(state);
|
|
165
|
+
if (attempt.model !== undefined) {
|
|
166
|
+
state.lastModel = attempt.model;
|
|
167
|
+
}
|
|
168
|
+
dependencies.report(JEV_STATUS.ready, ctx);
|
|
169
|
+
|
|
170
|
+
const findings = attempt.findings ?? [];
|
|
171
|
+
const fresh = findings.filter((finding) =>
|
|
172
|
+
isUnnoted(state, pending.path, finding),
|
|
173
|
+
);
|
|
174
|
+
if (fresh.length === 0) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
markNoted(state, pending.path, fresh);
|
|
178
|
+
return {
|
|
179
|
+
content: [
|
|
180
|
+
...event.content,
|
|
181
|
+
{ type: 'text' as const, text: formatFindings(pending.path, fresh) },
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isUnnoted(
|
|
188
|
+
state: JevGuardState,
|
|
189
|
+
path: string,
|
|
190
|
+
finding: JevFinding,
|
|
191
|
+
): boolean {
|
|
192
|
+
return !state.notedThisTurn.has(findingKey(path, finding.ruleId));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function markNoted(
|
|
196
|
+
state: JevGuardState,
|
|
197
|
+
path: string,
|
|
198
|
+
findings: readonly JevFinding[],
|
|
199
|
+
): void {
|
|
200
|
+
for (const finding of findings) {
|
|
201
|
+
state.notedThisTurn.add(findingKey(path, finding.ruleId));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function registerFailure(state: JevGuardState, failure: JevFailure): void {
|
|
206
|
+
if (failure === JEV_FAILURE.quota) {
|
|
207
|
+
state.halted = JEV_STATUS.quota;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (failure === JEV_FAILURE.auth) {
|
|
211
|
+
state.halted = JEV_STATUS.auth;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
state.transientFailures += 1;
|
|
215
|
+
if (state.transientFailures >= JEV_TRANSIENT_FAILURE_LIMIT) {
|
|
216
|
+
state.halted = JEV_STATUS.unavailable;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function registerSuccess(state: JevGuardState): void {
|
|
221
|
+
state.halted = undefined;
|
|
222
|
+
state.transientFailures = 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function extractMutation(
|
|
226
|
+
event: ToolCallEvent,
|
|
227
|
+
cwd: string,
|
|
228
|
+
): PendingMutation | undefined {
|
|
229
|
+
if (isToolCallEventType('write', event)) {
|
|
230
|
+
const absolutePath = resolveFileToolPath(cwd, event.input.path);
|
|
231
|
+
return {
|
|
232
|
+
path: event.input.path,
|
|
233
|
+
kind: MUTATION_KIND.write,
|
|
234
|
+
lines: addedLinesForWrite(
|
|
235
|
+
absolutePath === undefined ? undefined : readFileIfExists(absolutePath),
|
|
236
|
+
event.input.content,
|
|
237
|
+
),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (isToolCallEventType('edit', event)) {
|
|
241
|
+
return {
|
|
242
|
+
path: event.input.path,
|
|
243
|
+
kind: MUTATION_KIND.edit,
|
|
244
|
+
lines: addedLinesForEdit(event.input.edits),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function readMutationBody(cwd: string, path: string): string | undefined {
|
|
251
|
+
const absolutePath = resolveFileToolPath(cwd, path);
|
|
252
|
+
if (absolutePath === undefined) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
return readFileIfExists(absolutePath);
|
|
256
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { createFetchTransport } from './client.ts';
|
|
7
|
+
import {
|
|
8
|
+
createJevGuardState,
|
|
9
|
+
guardStatus,
|
|
10
|
+
JEV_STATUS,
|
|
11
|
+
type JevStatus,
|
|
12
|
+
registerJevGuard,
|
|
13
|
+
resetJevGuard,
|
|
14
|
+
resetJevUsage,
|
|
15
|
+
} from './guard.ts';
|
|
16
|
+
import {
|
|
17
|
+
clearStoredKey,
|
|
18
|
+
configPathFor,
|
|
19
|
+
resolveApiKey,
|
|
20
|
+
storeApiKey,
|
|
21
|
+
} from './key.ts';
|
|
22
|
+
import {
|
|
23
|
+
createJevKeyController,
|
|
24
|
+
JEV_SHORTCUT,
|
|
25
|
+
JEV_STATUS_KEY,
|
|
26
|
+
statusLabel,
|
|
27
|
+
} from './ui.ts';
|
|
28
|
+
|
|
29
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
30
|
+
export default function registerJev(pi: ExtensionAPI): void {
|
|
31
|
+
const configPath = configPathFor(getAgentDir());
|
|
32
|
+
const guardState = createJevGuardState();
|
|
33
|
+
|
|
34
|
+
// The key is read on each use instead of cached at load time, so a key stored
|
|
35
|
+
// during a running session takes effect without a restart.
|
|
36
|
+
const readKey = () => resolveApiKey(process.env, configPath);
|
|
37
|
+
|
|
38
|
+
const currentStatus = (): JevStatus => {
|
|
39
|
+
if (readKey() === undefined) {
|
|
40
|
+
return JEV_STATUS.noKey;
|
|
41
|
+
}
|
|
42
|
+
return guardStatus(guardState);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const publish = (status: JevStatus, ctx: ExtensionContext): void => {
|
|
46
|
+
ctx.ui.setStatus(
|
|
47
|
+
JEV_STATUS_KEY,
|
|
48
|
+
statusLabel(status, guardState.lastModel, guardState.calls),
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const keyController = createJevKeyController({
|
|
53
|
+
store: (apiKey) => {
|
|
54
|
+
storeApiKey(configPath, apiKey);
|
|
55
|
+
},
|
|
56
|
+
clear: () => {
|
|
57
|
+
clearStoredKey(configPath);
|
|
58
|
+
},
|
|
59
|
+
retry: () => {
|
|
60
|
+
resetJevGuard(guardState);
|
|
61
|
+
},
|
|
62
|
+
origin: () => readKey()?.origin,
|
|
63
|
+
usage: () => guardState.calls,
|
|
64
|
+
refresh: (ctx) => {
|
|
65
|
+
publish(currentStatus(), ctx);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
registerJevGuard(pi, guardState, {
|
|
70
|
+
apiKey: () => readKey()?.key,
|
|
71
|
+
transport: createFetchTransport(),
|
|
72
|
+
report: (status, ctx) => {
|
|
73
|
+
publish(status, ctx);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
pi.on('session_start', (_event, ctx) => {
|
|
78
|
+
resetJevUsage(guardState);
|
|
79
|
+
publish(currentStatus(), ctx);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.registerShortcut(JEV_SHORTCUT, {
|
|
83
|
+
description: 'Set or clear the Jev API key',
|
|
84
|
+
handler: (ctx) => {
|
|
85
|
+
keyController.open(ctx);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const JEV_KEY_ENV_VAR = 'TYPESAFE_API_KEY';
|
|
5
|
+
export const SIDEROOM_CONFIG_FILE = 'sideroom.json';
|
|
6
|
+
|
|
7
|
+
const JEV_API_KEY_FIELD = 'jevApiKey';
|
|
8
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
9
|
+
const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
|
10
|
+
|
|
11
|
+
export interface JevKeySource {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly origin: 'environment' | 'config';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function configPathFor(agentDirectory: string): string {
|
|
17
|
+
return join(agentDirectory, SIDEROOM_CONFIG_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The environment wins so containers and CI work with no file at all. A missing
|
|
21
|
+
// or malformed config means "no key"; anything else about the file is a real
|
|
22
|
+
// problem and surfaces instead of hiding.
|
|
23
|
+
export function resolveApiKey(
|
|
24
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
25
|
+
configPath: string,
|
|
26
|
+
): JevKeySource | undefined {
|
|
27
|
+
const fromEnvironment = environment[JEV_KEY_ENV_VAR]?.trim();
|
|
28
|
+
if (fromEnvironment !== undefined && fromEnvironment.length > 0) {
|
|
29
|
+
return { key: fromEnvironment, origin: 'environment' };
|
|
30
|
+
}
|
|
31
|
+
const stored = readStoredKey(configPath);
|
|
32
|
+
if (stored === undefined) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return { key: stored, origin: 'config' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readStoredKey(configPath: string): string | undefined {
|
|
39
|
+
let raw: string;
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(configPath, 'utf8');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isMissingFile(error)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parsed = parseConfig(raw);
|
|
50
|
+
if (parsed === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const stored = parsed[JEV_API_KEY_FIELD];
|
|
54
|
+
if (typeof stored !== 'string') {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const trimmed = stored.trim();
|
|
58
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function storeApiKey(configPath: string, apiKey: string): void {
|
|
62
|
+
mkdirSync(dirname(configPath), {
|
|
63
|
+
recursive: true,
|
|
64
|
+
mode: OWNER_ONLY_DIRECTORY_MODE,
|
|
65
|
+
});
|
|
66
|
+
const payload = `${JSON.stringify({ [JEV_API_KEY_FIELD]: apiKey }, null, 2)}\n`;
|
|
67
|
+
writeFileSync(configPath, payload, {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
mode: OWNER_ONLY_FILE_MODE,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearStoredKey(configPath: string): void {
|
|
74
|
+
rmSync(configPath, { force: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseConfig(raw: string): Record<string, unknown> | undefined {
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof SyntaxError) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return parsed as Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isMissingFile(error: unknown): boolean {
|
|
94
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
95
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { languageForPath } from '../rules/catalog.ts';
|
|
2
|
+
import { type AddedLine, addedLines } from '../rules/model.ts';
|
|
3
|
+
|
|
4
|
+
export type { AddedLine } from '../rules/model.ts';
|
|
5
|
+
|
|
6
|
+
export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
7
|
+
export const JEV_MODEL = 'jev-latest';
|
|
8
|
+
|
|
9
|
+
// A noul answer carries a probability and no confidence, so this cutoff is the
|
|
10
|
+
// only brake. Below it Jev is saying it has nothing to go on, which would be
|
|
11
|
+
// noise on every edit.
|
|
12
|
+
export const VIOLATION_PROBABILITY = 0.8;
|
|
13
|
+
|
|
14
|
+
// The state must stay well under the two documented budgets (64k tokens for the
|
|
15
|
+
// whole request, 32k for the state plus the longest question). A file that does
|
|
16
|
+
// not fit is skipped, never truncated: half a file reads as a distorted picture
|
|
17
|
+
// and Jev answers it with the same confidence.
|
|
18
|
+
export const MAX_STATE_CHARACTERS = 60_000;
|
|
19
|
+
|
|
20
|
+
const NOUL_TYPE = 'noul';
|
|
21
|
+
|
|
22
|
+
export interface JevNoulQuestion {
|
|
23
|
+
readonly type: typeof NOUL_TYPE;
|
|
24
|
+
readonly instructions: string;
|
|
25
|
+
readonly criteria: { readonly true: string; readonly false: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JevRule {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly question: JevNoulQuestion;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The six guide rules that a single file can answer and that no mechanical
|
|
34
|
+
// check covers. Rules 11, 12, 15, 16, 17 and 18 need the module's neighbours.
|
|
35
|
+
export const JEV_RULES: readonly JevRule[] = [
|
|
36
|
+
{
|
|
37
|
+
id: 'guard-clauses',
|
|
38
|
+
question: {
|
|
39
|
+
type: NOUL_TYPE,
|
|
40
|
+
instructions:
|
|
41
|
+
'In `change.addedLines`, is the control flow nested so the main path is buried, instead of validating and exiting early?',
|
|
42
|
+
criteria: {
|
|
43
|
+
true: 'Conditionals nest two or more levels deep, or the main path sits inside a condition that could have returned early',
|
|
44
|
+
false:
|
|
45
|
+
'Each condition is handled with an early return or a flat sequence',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'fail-fast',
|
|
51
|
+
question: {
|
|
52
|
+
type: NOUL_TYPE,
|
|
53
|
+
instructions:
|
|
54
|
+
'In `change.addedLines`, is an invalid or impossible state papered over with a silent fallback instead of failing immediately?',
|
|
55
|
+
criteria: {
|
|
56
|
+
true: 'A default value or a silent coercion hides a broken dependency or a state that should never happen',
|
|
57
|
+
false:
|
|
58
|
+
'An impossible or invalid case throws with a clear message, or no such case appears',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'command-query',
|
|
64
|
+
question: {
|
|
65
|
+
type: NOUL_TYPE,
|
|
66
|
+
instructions:
|
|
67
|
+
'In `change.addedLines`, does a function both change state and return a value, or does something that reads like a query have a side effect?',
|
|
68
|
+
criteria: {
|
|
69
|
+
true: 'A function that returns data also mutates state, or a getter mutates',
|
|
70
|
+
false: 'Functions either change state or return data, never both',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'null-handling',
|
|
76
|
+
question: {
|
|
77
|
+
type: NOUL_TYPE,
|
|
78
|
+
instructions:
|
|
79
|
+
'In `change.addedLines`, is the same empty value reused to mean several different states, or are `null` and `undefined` mixed for one meaning?',
|
|
80
|
+
criteria: {
|
|
81
|
+
true: 'One empty value carries more than one business meaning, or null and undefined stand for the same thing in different places',
|
|
82
|
+
false:
|
|
83
|
+
'One convention is used and each distinct state has its own value',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'immutability',
|
|
89
|
+
question: {
|
|
90
|
+
type: NOUL_TYPE,
|
|
91
|
+
instructions:
|
|
92
|
+
'In `change.addedLines`, is an object or array received as a parameter modified in place?',
|
|
93
|
+
criteria: {
|
|
94
|
+
true: 'A parameter is pushed, assigned or deleted into, or a transform writes to state outside itself',
|
|
95
|
+
false: 'New values are returned instead of changing the inputs',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'validate-once',
|
|
101
|
+
question: {
|
|
102
|
+
type: NOUL_TYPE,
|
|
103
|
+
instructions:
|
|
104
|
+
'In `change.addedLines`, is the same required, type or range check repeated after a boundary already owns that input?',
|
|
105
|
+
criteria: {
|
|
106
|
+
true: 'A rule already validated at the boundary is checked again deeper in the code',
|
|
107
|
+
false: 'Validation happens once, at the boundary that owns the input',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
export interface JevFileState {
|
|
114
|
+
readonly file: {
|
|
115
|
+
readonly path: string;
|
|
116
|
+
readonly language: string;
|
|
117
|
+
readonly body: string;
|
|
118
|
+
};
|
|
119
|
+
readonly change: {
|
|
120
|
+
readonly kind: string;
|
|
121
|
+
readonly addedLines: readonly string[];
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface JevRequestBody {
|
|
126
|
+
readonly model: string;
|
|
127
|
+
readonly state: JevFileState;
|
|
128
|
+
readonly questions: Readonly<Record<string, JevNoulQuestion>>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface JevNoulAnswer {
|
|
132
|
+
readonly noul: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface JevResponseEnvelope {
|
|
136
|
+
readonly model: string | undefined;
|
|
137
|
+
readonly answers: Readonly<Record<string, JevNoulAnswer>> | undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface JevFinding {
|
|
141
|
+
readonly ruleId: string;
|
|
142
|
+
readonly probability: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildFileState(
|
|
146
|
+
path: string,
|
|
147
|
+
body: string,
|
|
148
|
+
kind: string,
|
|
149
|
+
lines: readonly AddedLine[],
|
|
150
|
+
): JevFileState {
|
|
151
|
+
return {
|
|
152
|
+
file: { path, language: languageForPath(path), body },
|
|
153
|
+
change: { kind, addedLines: lines.map((line) => line.text) },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function addedLinesForWrite(
|
|
158
|
+
previous: string | undefined,
|
|
159
|
+
next: string,
|
|
160
|
+
): AddedLine[] {
|
|
161
|
+
return addedLines(previous, next);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function addedLinesForEdit(
|
|
165
|
+
edits: readonly { readonly oldText: string; readonly newText: string }[],
|
|
166
|
+
): AddedLine[] {
|
|
167
|
+
return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function stateFitsBudget(body: string): boolean {
|
|
171
|
+
return body.length <= MAX_STATE_CHARACTERS;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function buildRequestBody(file: JevFileState): JevRequestBody {
|
|
175
|
+
const questions: Record<string, JevNoulQuestion> = {};
|
|
176
|
+
for (const rule of JEV_RULES) {
|
|
177
|
+
questions[rule.id] = rule.question;
|
|
178
|
+
}
|
|
179
|
+
return { model: JEV_MODEL, state: file, questions };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function parseEnvelope(text: string): JevResponseEnvelope | undefined {
|
|
183
|
+
const payload = parseJsonObject(text);
|
|
184
|
+
if (payload === undefined) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const model = typeof payload.model === 'string' ? payload.model : undefined;
|
|
188
|
+
return { model, answers: readAnswers(payload.answers) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function readFindings(
|
|
192
|
+
envelope: JevResponseEnvelope,
|
|
193
|
+
): readonly JevFinding[] {
|
|
194
|
+
const answers = envelope.answers;
|
|
195
|
+
if (answers === undefined) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const findings: JevFinding[] = [];
|
|
199
|
+
for (const rule of JEV_RULES) {
|
|
200
|
+
const answer = answers[rule.id];
|
|
201
|
+
if (answer === undefined || answer.noul < VIOLATION_PROBABILITY) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
findings.push({ ruleId: rule.id, probability: answer.noul });
|
|
205
|
+
}
|
|
206
|
+
return findings;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function findingKey(path: string, ruleId: string): string {
|
|
210
|
+
return `${path}#${ruleId}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatFindings(
|
|
214
|
+
path: string,
|
|
215
|
+
findings: readonly JevFinding[],
|
|
216
|
+
): string {
|
|
217
|
+
return [
|
|
218
|
+
`Sideroom Jev review flagged ${path}:`,
|
|
219
|
+
...findings.map(
|
|
220
|
+
(finding) =>
|
|
221
|
+
`- [${finding.ruleId}] Jev scored ${finding.probability.toFixed(2)} probability that the added lines break this rule. Verify against the loaded guide before changing anything.`,
|
|
222
|
+
),
|
|
223
|
+
].join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
227
|
+
let parsed: unknown;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(text);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof SyntaxError) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
return parsed as Record<string, unknown>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readAnswers(
|
|
243
|
+
value: unknown,
|
|
244
|
+
): Record<string, JevNoulAnswer> | undefined {
|
|
245
|
+
if (typeof value !== 'object' || value === null) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const answers: Record<string, JevNoulAnswer> = {};
|
|
249
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
250
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const { noul } = entry as { noul?: unknown };
|
|
254
|
+
if (typeof noul === 'number') {
|
|
255
|
+
answers[id] = { noul };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return answers;
|
|
259
|
+
}
|