@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.
- package/README.md +19 -9
- package/assets/artifacts/GUIDELINES_TEMPLATE.md +33 -2
- package/assets/visual-context/browser/INSTALL.md +11 -2
- package/assets/visual-context/browser/background.js +72 -60
- package/assets/visual-context/browser/content.js +45 -7
- package/assets/visual-context/browser/destinations.js +40 -0
- package/assets/visual-context/browser/manifest.json +1 -1
- package/extensions/guidelines/catalog.ts +9 -0
- package/extensions/guidelines/execute.ts +26 -0
- package/extensions/guidelines/guard.ts +46 -14
- package/extensions/guidelines/index.ts +31 -0
- package/extensions/guidelines/prompt.ts +2 -2
- package/extensions/guidelines/review.ts +1 -1
- package/extensions/jev/model.ts +13 -0
- package/extensions/visual-context/broker.ts +14 -6
- package/extensions/visual-context/compiler-config.ts +103 -0
- package/extensions/visual-context/configure.ts +421 -0
- package/extensions/visual-context/dialogs.ts +95 -0
- package/extensions/visual-context/files.ts +139 -0
- package/extensions/visual-context/index.ts +48 -56
- package/extensions/visual-context/match.ts +48 -16
- package/extensions/visual-context/model.ts +29 -3
- package/extensions/visual-context/projects.ts +226 -0
- package/extensions/visual-context/session.ts +150 -17
- package/extensions/visual-context/setup.ts +146 -0
- package/extensions/visual-context/source.ts +203 -100
- package/extensions/visual-context/ui.ts +5 -1
- package/package.json +5 -5
- package/skills/sideroom-guidelines/SKILL.md +8 -5
- package/skills/sideroom-guidelines/references/languages/go.md +35 -1
- package/skills/sideroom-guidelines/references/languages/java.md +34 -1
- package/skills/sideroom-guidelines/references/languages/javascript.md +34 -1
- package/skills/sideroom-guidelines/references/languages/php-laravel.md +34 -1
- package/skills/sideroom-guidelines/references/languages/python.md +30 -1
- package/skills/sideroom-guidelines/references/languages/rust.md +34 -1
- package/skills/sideroom-guidelines/references/languages/typescript.md +38 -1
- package/skills/sideroom-visual-setup/SKILL.md +49 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import {
|
|
3
|
+
COMPILER_IDENTIFIER,
|
|
4
|
+
CORE_PACKAGE,
|
|
5
|
+
type ConfigKind,
|
|
6
|
+
compilerIntegration,
|
|
7
|
+
INSPECTOR_PACKAGE,
|
|
8
|
+
LOADER_PACKAGE,
|
|
9
|
+
METADATA_LOADER,
|
|
10
|
+
} from './compiler-config.ts';
|
|
11
|
+
|
|
12
|
+
type Expression = ReturnType<typeof import('@babel/parser').parseExpression>;
|
|
13
|
+
type ConfigObject = Extract<Expression, { type: 'ObjectExpression' }>;
|
|
14
|
+
type ConfigArray = Extract<Expression, { type: 'ArrayExpression' }>;
|
|
15
|
+
type Document = ReturnType<typeof parse>;
|
|
16
|
+
interface ConfigurationExport {
|
|
17
|
+
readonly statement: Document['program']['body'][number];
|
|
18
|
+
readonly expression: unknown;
|
|
19
|
+
}
|
|
20
|
+
interface Replacement {
|
|
21
|
+
readonly start: number;
|
|
22
|
+
readonly end: number;
|
|
23
|
+
readonly text: string;
|
|
24
|
+
}
|
|
25
|
+
interface ConfigureSourceInput {
|
|
26
|
+
readonly source: string;
|
|
27
|
+
readonly kind: ConfigKind;
|
|
28
|
+
readonly commonjs?: boolean;
|
|
29
|
+
readonly nextMajor?: number;
|
|
30
|
+
readonly typescript?: boolean;
|
|
31
|
+
}
|
|
32
|
+
interface NestedConfigurationInput {
|
|
33
|
+
readonly object: ConfigObject;
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly member: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function keyName(
|
|
39
|
+
property: ConfigObject['properties'][number],
|
|
40
|
+
): string | undefined {
|
|
41
|
+
if (property.type === 'SpreadElement' || property.computed) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (property.key.type === 'Identifier') {
|
|
45
|
+
return property.key.name;
|
|
46
|
+
}
|
|
47
|
+
if (property.key.type === 'StringLiteral') {
|
|
48
|
+
return property.key.value;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function referencesIdentifier(expression: unknown, name: string): boolean {
|
|
54
|
+
if (!expression || typeof expression !== 'object') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(expression)) {
|
|
58
|
+
return expression.some((child) => referencesIdentifier(child, name));
|
|
59
|
+
}
|
|
60
|
+
const node = expression as Record<string, unknown>;
|
|
61
|
+
if (node.type === 'Identifier') {
|
|
62
|
+
return node.name === name;
|
|
63
|
+
}
|
|
64
|
+
return Object.values(node).some((child) => referencesIdentifier(child, name));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function staticObject(expression: unknown, document: Document): ConfigObject {
|
|
68
|
+
if (
|
|
69
|
+
!expression ||
|
|
70
|
+
typeof expression !== 'object' ||
|
|
71
|
+
!('type' in expression)
|
|
72
|
+
) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'The exported configuration must resolve to one static object.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const node = expression as Expression;
|
|
78
|
+
if (node.type === 'ObjectExpression') {
|
|
79
|
+
return node;
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
node.type === 'TSAsExpression' ||
|
|
83
|
+
node.type === 'TSSatisfiesExpression' ||
|
|
84
|
+
node.type === 'ParenthesizedExpression'
|
|
85
|
+
) {
|
|
86
|
+
return staticObject(node.expression, document);
|
|
87
|
+
}
|
|
88
|
+
if (node.type === 'Identifier') {
|
|
89
|
+
for (const statement of document.program.body) {
|
|
90
|
+
if (statement.type !== 'VariableDeclaration') {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const declaration = statement.declarations.find(
|
|
94
|
+
(item) => item.id.type === 'Identifier' && item.id.name === node.name,
|
|
95
|
+
);
|
|
96
|
+
if (declaration?.init?.type === 'Identifier') {
|
|
97
|
+
throw new Error('Configuration aliases need a manual integration.');
|
|
98
|
+
}
|
|
99
|
+
if (declaration?.init) {
|
|
100
|
+
const otherInitializers = document.program.body.flatMap((entry) => {
|
|
101
|
+
const statement =
|
|
102
|
+
entry.type === 'ExportNamedDeclaration' ? entry.declaration : entry;
|
|
103
|
+
return statement?.type === 'VariableDeclaration'
|
|
104
|
+
? statement.declarations.flatMap((item) =>
|
|
105
|
+
item === declaration ? [] : [item.init],
|
|
106
|
+
)
|
|
107
|
+
: [];
|
|
108
|
+
});
|
|
109
|
+
if (
|
|
110
|
+
otherInitializers.some((initializer) =>
|
|
111
|
+
referencesIdentifier(initializer, node.name),
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'Configuration references in other initializers need a manual integration.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return staticObject(declaration.init, document);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
node.type === 'CallExpression' &&
|
|
124
|
+
node.callee.type === 'Identifier' &&
|
|
125
|
+
['defineConfig', 'defineNuxtConfig'].includes(node.callee.name) &&
|
|
126
|
+
node.arguments.length === 1
|
|
127
|
+
) {
|
|
128
|
+
return staticObject(node.arguments[0], document);
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
node.type === 'ArrowFunctionExpression' ||
|
|
132
|
+
node.type === 'FunctionExpression'
|
|
133
|
+
) {
|
|
134
|
+
if (node.body.type !== 'BlockStatement') {
|
|
135
|
+
return staticObject(node.body, document);
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
node.body.body.length === 1 &&
|
|
139
|
+
node.body.body[0]?.type === 'ReturnStatement'
|
|
140
|
+
) {
|
|
141
|
+
return staticObject(node.body.body[0].argument, document);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
'Custom configuration logic needs a manual integration; no files were changed.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function exportedObject(document: Document): ConfigObject {
|
|
150
|
+
const exports = document.program.body.flatMap<ConfigurationExport>(
|
|
151
|
+
(statement) => {
|
|
152
|
+
if (statement.type === 'ExportDefaultDeclaration') {
|
|
153
|
+
return [{ statement, expression: statement.declaration }];
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
statement.type !== 'ExpressionStatement' ||
|
|
157
|
+
statement.expression.type !== 'AssignmentExpression'
|
|
158
|
+
) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
const { left, right, operator } = statement.expression;
|
|
162
|
+
if (
|
|
163
|
+
operator === '=' &&
|
|
164
|
+
left.type === 'MemberExpression' &&
|
|
165
|
+
!left.computed &&
|
|
166
|
+
left.object.type === 'Identifier' &&
|
|
167
|
+
left.object.name === 'module' &&
|
|
168
|
+
left.property.type === 'Identifier' &&
|
|
169
|
+
left.property.name === 'exports'
|
|
170
|
+
) {
|
|
171
|
+
return [{ statement, expression: right }];
|
|
172
|
+
}
|
|
173
|
+
return [];
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
const exported = exports[0];
|
|
177
|
+
if (!exported) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
'No supported configuration export was found; no files were changed.',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (exports.length !== 1) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
'Multiple configuration exports need a manual integration.',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
document.program.body.some(
|
|
189
|
+
(statement) =>
|
|
190
|
+
statement !== exported.statement &&
|
|
191
|
+
![
|
|
192
|
+
'ImportDeclaration',
|
|
193
|
+
'VariableDeclaration',
|
|
194
|
+
'FunctionDeclaration',
|
|
195
|
+
'ClassDeclaration',
|
|
196
|
+
'ExportNamedDeclaration',
|
|
197
|
+
'ExportAllDeclaration',
|
|
198
|
+
'TSInterfaceDeclaration',
|
|
199
|
+
'TSTypeAliasDeclaration',
|
|
200
|
+
'EmptyStatement',
|
|
201
|
+
].includes(statement.type),
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
'Configuration mutations or executable statements need a manual integration.',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return staticObject(exported.expression, document);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function propertyValue(
|
|
212
|
+
object: ConfigObject,
|
|
213
|
+
name: string,
|
|
214
|
+
): Expression | undefined {
|
|
215
|
+
if (
|
|
216
|
+
object.properties.some(
|
|
217
|
+
(property) =>
|
|
218
|
+
property.type === 'SpreadElement' ||
|
|
219
|
+
('computed' in property && property.computed),
|
|
220
|
+
)
|
|
221
|
+
) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
'Configuration spreads or computed keys need a manual integration.',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const properties = object.properties.filter(
|
|
227
|
+
(property) => keyName(property) === name,
|
|
228
|
+
);
|
|
229
|
+
if (properties.length > 1) {
|
|
230
|
+
throw new Error(`Duplicate configuration key: ${name}`);
|
|
231
|
+
}
|
|
232
|
+
const property = properties[0];
|
|
233
|
+
if (!property) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (property.type !== 'ObjectProperty') {
|
|
237
|
+
throw new Error(`Custom ${name} configuration needs a manual integration.`);
|
|
238
|
+
}
|
|
239
|
+
return property.value as Expression;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function configureSource({
|
|
243
|
+
source,
|
|
244
|
+
kind,
|
|
245
|
+
commonjs = false,
|
|
246
|
+
nextMajor,
|
|
247
|
+
typescript = false,
|
|
248
|
+
}: ConfigureSourceInput): { readonly source: string } {
|
|
249
|
+
const document = parse(source, {
|
|
250
|
+
sourceType: 'unambiguous',
|
|
251
|
+
plugins: ['typescript'],
|
|
252
|
+
attachComment: false,
|
|
253
|
+
});
|
|
254
|
+
const existing =
|
|
255
|
+
document.program.body.some(
|
|
256
|
+
(statement) =>
|
|
257
|
+
statement.type === 'ImportDeclaration' &&
|
|
258
|
+
[INSPECTOR_PACKAGE, CORE_PACKAGE, LOADER_PACKAGE].includes(
|
|
259
|
+
statement.source.value,
|
|
260
|
+
),
|
|
261
|
+
) ||
|
|
262
|
+
[INSPECTOR_PACKAGE, CORE_PACKAGE, LOADER_PACKAGE].some(
|
|
263
|
+
(name) =>
|
|
264
|
+
source.includes(`require('${name}')`) ||
|
|
265
|
+
source.includes(`require("${name}")`),
|
|
266
|
+
);
|
|
267
|
+
if (existing) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
'An inspector integration already exists. Preserve it and verify native-element markers and development-only behavior before binding the page.',
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
new RegExp(
|
|
274
|
+
`\\b${COMPILER_IDENTIFIER}\\b|\\bsideroom(?:Transform|ReadFile|Require|CreateRequire)\\b`,
|
|
275
|
+
'u',
|
|
276
|
+
).test(source)
|
|
277
|
+
) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
'A Sideroom compiler integration identifier is already in use.',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const root = exportedObject(document);
|
|
283
|
+
const imports = document.program.body.filter(
|
|
284
|
+
(statement) => statement.type === 'ImportDeclaration',
|
|
285
|
+
);
|
|
286
|
+
const useRequire =
|
|
287
|
+
commonjs || (!imports.length && source.includes('module.exports'));
|
|
288
|
+
const replacements: Replacement[] = [];
|
|
289
|
+
function append(container: ConfigObject | ConfigArray, member: string): void {
|
|
290
|
+
if (
|
|
291
|
+
container.start === null ||
|
|
292
|
+
container.start === undefined ||
|
|
293
|
+
container.end === null ||
|
|
294
|
+
container.end === undefined
|
|
295
|
+
) {
|
|
296
|
+
throw new Error('Configuration locations are unavailable.');
|
|
297
|
+
}
|
|
298
|
+
const members =
|
|
299
|
+
container.type === 'ObjectExpression'
|
|
300
|
+
? container.properties
|
|
301
|
+
: container.elements;
|
|
302
|
+
const last = members.at(-1);
|
|
303
|
+
const tail = source.slice(
|
|
304
|
+
last?.end ?? container.start + 1,
|
|
305
|
+
container.end - 1,
|
|
306
|
+
);
|
|
307
|
+
const insertion = container.end - 1;
|
|
308
|
+
const previous = replacements.find(
|
|
309
|
+
(replacement) => replacement.start === insertion,
|
|
310
|
+
);
|
|
311
|
+
if (previous) {
|
|
312
|
+
replacements.splice(replacements.indexOf(previous), 1, {
|
|
313
|
+
...previous,
|
|
314
|
+
text: `${previous.text}${member},\n`,
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const punctuation = tail.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu, '');
|
|
319
|
+
const comma = members.length > 0 && !/^\s*,/u.test(punctuation) ? ',' : '';
|
|
320
|
+
replacements.push({
|
|
321
|
+
start: container.end - 1,
|
|
322
|
+
end: container.end - 1,
|
|
323
|
+
text: `${comma}\n${member},\n`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function plugins(object: ConfigObject): void {
|
|
327
|
+
const call = `${COMPILER_IDENTIFIER}()`;
|
|
328
|
+
const value = propertyValue(object, 'plugins');
|
|
329
|
+
if (!value) {
|
|
330
|
+
append(object, `plugins: [${call}]`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (value.type !== 'ArrayExpression') {
|
|
334
|
+
throw new Error(
|
|
335
|
+
'The plugins configuration must be an array for automatic integration.',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (value.start === null || value.start === undefined) {
|
|
339
|
+
throw new Error('The plugin array location is unavailable.');
|
|
340
|
+
}
|
|
341
|
+
// Inspect original markup before framework plugins erase its source positions.
|
|
342
|
+
replacements.push({
|
|
343
|
+
start: value.start + 1,
|
|
344
|
+
end: value.start + 1,
|
|
345
|
+
text: `${call},\n`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function nested({ object, key, member }: NestedConfigurationInput): void {
|
|
349
|
+
const value = propertyValue(object, key);
|
|
350
|
+
if (!value) {
|
|
351
|
+
append(object, `${key}: { ${member} }`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (value.type !== 'ObjectExpression') {
|
|
355
|
+
throw new Error(`The ${key} configuration must be a static object.`);
|
|
356
|
+
}
|
|
357
|
+
append(value, member);
|
|
358
|
+
}
|
|
359
|
+
if (kind === 'astro' || kind === 'nuxt') {
|
|
360
|
+
const vite = propertyValue(root, 'vite');
|
|
361
|
+
if (!vite) {
|
|
362
|
+
nested({
|
|
363
|
+
object: root,
|
|
364
|
+
key: 'vite',
|
|
365
|
+
member: `plugins: [${COMPILER_IDENTIFIER}()]`,
|
|
366
|
+
});
|
|
367
|
+
} else if (vite.type === 'ObjectExpression') {
|
|
368
|
+
plugins(vite);
|
|
369
|
+
} else {
|
|
370
|
+
throw new Error('The vite configuration must be a static object.');
|
|
371
|
+
}
|
|
372
|
+
} else if (kind === 'next') {
|
|
373
|
+
if (nextMajor === undefined || nextMajor < 16) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
'Automatic Next.js setup currently requires Next 16 or later; older versions need their documented bundler-specific integration.',
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (propertyValue(root, 'webpack') || propertyValue(root, 'turbopack')) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
'Existing Next.js bundler configuration needs a manual integration.',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
append(
|
|
384
|
+
root,
|
|
385
|
+
`webpack(${typescript ? 'config: { plugins: unknown[] }, { dev }: { dev: boolean }' : 'config, { dev }'}) {\nif (dev) {\nconfig.plugins.unshift(${COMPILER_IDENTIFIER}());\n}\nreturn config;\n}`,
|
|
386
|
+
);
|
|
387
|
+
append(
|
|
388
|
+
root,
|
|
389
|
+
`turbopack: { rules: process.env.NODE_ENV === 'development' ? { '**/*.{jsx,tsx,js,ts,mjs,mts}': { loaders: [{ loader: ${useRequire ? 'require' : 'sideroomRequire'}.resolve('${METADATA_LOADER}'), options: { pathType: 'absolute' } }] } } : {} }`,
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
plugins(root);
|
|
393
|
+
}
|
|
394
|
+
const position =
|
|
395
|
+
imports.at(-1)?.end ??
|
|
396
|
+
document.program.directives.at(-1)?.end ??
|
|
397
|
+
document.program.interpreter?.end ??
|
|
398
|
+
0;
|
|
399
|
+
const declaration = compilerIntegration({
|
|
400
|
+
kind,
|
|
401
|
+
commonjs: useRequire,
|
|
402
|
+
typescript,
|
|
403
|
+
});
|
|
404
|
+
replacements.push({
|
|
405
|
+
start: position,
|
|
406
|
+
end: position,
|
|
407
|
+
text: `\n${declaration}\n`,
|
|
408
|
+
});
|
|
409
|
+
let configured = source;
|
|
410
|
+
for (const replacement of replacements.sort(
|
|
411
|
+
(left, right) => right.start - left.start,
|
|
412
|
+
)) {
|
|
413
|
+
configured =
|
|
414
|
+
configured.slice(0, replacement.start) +
|
|
415
|
+
replacement.text +
|
|
416
|
+
configured.slice(replacement.end);
|
|
417
|
+
}
|
|
418
|
+
// Reject a broken patch before the parent agent receives a configuration edit.
|
|
419
|
+
parse(configured, { sourceType: 'unambiguous', plugins: ['typescript'] });
|
|
420
|
+
return { source: configured };
|
|
421
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import {
|
|
3
|
+
candidateLabel,
|
|
4
|
+
cleanLabel,
|
|
5
|
+
elementLabel,
|
|
6
|
+
type PendingSelection,
|
|
7
|
+
type SourceCandidate,
|
|
8
|
+
} from './model.ts';
|
|
9
|
+
import type { discoverProjects, VisualProject } from './projects.ts';
|
|
10
|
+
|
|
11
|
+
type DialogUI = Pick<ExtensionContext['ui'], 'select' | 'notify'>;
|
|
12
|
+
export type VisualAction =
|
|
13
|
+
| { readonly action: 'pair' }
|
|
14
|
+
| { readonly action: 'stop' }
|
|
15
|
+
| { readonly action: 'clear' }
|
|
16
|
+
| { readonly action: 'review'; readonly entry: PendingSelection };
|
|
17
|
+
export type SourceAction =
|
|
18
|
+
| { readonly action: 'remove' | 'associate' }
|
|
19
|
+
| { readonly action: 'choose'; readonly candidate: SourceCandidate };
|
|
20
|
+
interface VisualDialogInput {
|
|
21
|
+
readonly ui: DialogUI;
|
|
22
|
+
readonly pending: readonly PendingSelection[];
|
|
23
|
+
readonly connected: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function chooseVisualAction({
|
|
27
|
+
ui,
|
|
28
|
+
pending,
|
|
29
|
+
connected,
|
|
30
|
+
}: VisualDialogInput): Promise<VisualAction | undefined> {
|
|
31
|
+
const options: readonly (VisualAction & { readonly label: string })[] = [
|
|
32
|
+
{ label: 'Pair browser', action: 'pair' },
|
|
33
|
+
...pending.map((entry): VisualAction & { readonly label: string } => ({
|
|
34
|
+
label: `Review ${elementLabel(entry.selection)} [${entry.selection.id}]`,
|
|
35
|
+
action: 'review',
|
|
36
|
+
entry,
|
|
37
|
+
})),
|
|
38
|
+
{ label: 'Remove all pending elements', action: 'clear' },
|
|
39
|
+
...(connected
|
|
40
|
+
? [{ label: 'Disconnect browser', action: 'stop' as const }]
|
|
41
|
+
: []),
|
|
42
|
+
];
|
|
43
|
+
const choice = await ui.select(
|
|
44
|
+
'Browser context',
|
|
45
|
+
options.map((option) => option.label),
|
|
46
|
+
);
|
|
47
|
+
return options.find((option) => option.label === choice);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function chooseSourceAction(
|
|
51
|
+
ui: DialogUI,
|
|
52
|
+
entry: PendingSelection,
|
|
53
|
+
): Promise<SourceAction | undefined> {
|
|
54
|
+
const options: readonly (SourceAction & { readonly label: string })[] = [
|
|
55
|
+
...entry.resolution.candidates.map(
|
|
56
|
+
(candidate): SourceAction & { readonly label: string } => ({
|
|
57
|
+
label: `${candidateLabel(candidate)} · ${candidate.reasons.join(', ')}`,
|
|
58
|
+
action: 'choose',
|
|
59
|
+
candidate,
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
{ label: 'Associate this page with a project', action: 'associate' },
|
|
63
|
+
{ label: 'Remove this element', action: 'remove' },
|
|
64
|
+
];
|
|
65
|
+
const choice = await ui.select(
|
|
66
|
+
entry.selection.source
|
|
67
|
+
? 'Review compiler location'
|
|
68
|
+
: 'Choose a source candidate — origin is inferred',
|
|
69
|
+
options.map((option) => option.label),
|
|
70
|
+
);
|
|
71
|
+
return options.find((option) => option.label === choice);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function chooseProject(
|
|
75
|
+
ui: DialogUI,
|
|
76
|
+
discovery: ReturnType<typeof discoverProjects>,
|
|
77
|
+
): Promise<VisualProject | undefined> {
|
|
78
|
+
if (discovery.projects.length === 0) {
|
|
79
|
+
ui.notify(
|
|
80
|
+
cleanLabel(`No projects were found. ${discovery.issues.join(' ')}`, 512),
|
|
81
|
+
'warning',
|
|
82
|
+
);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const labels = discovery.projects.map(
|
|
86
|
+
(project) => `${project.path} — ${project.name}`,
|
|
87
|
+
);
|
|
88
|
+
const choice = await ui.select(
|
|
89
|
+
discovery.truncated
|
|
90
|
+
? 'Choose project — discovery reached a limit'
|
|
91
|
+
: 'Choose project for this page origin',
|
|
92
|
+
labels,
|
|
93
|
+
);
|
|
94
|
+
return discovery.projects[labels.indexOf(choice ?? '')];
|
|
95
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
import { isMissingFile } from '../shared/read-file.ts';
|
|
5
|
+
|
|
6
|
+
export const SKIP_DIRECTORIES = new Set([
|
|
7
|
+
'node_modules',
|
|
8
|
+
'dist',
|
|
9
|
+
'build',
|
|
10
|
+
'coverage',
|
|
11
|
+
'vendor',
|
|
12
|
+
]);
|
|
13
|
+
export const MAX_FILE_BYTES = 262_144;
|
|
14
|
+
export const SENSITIVE_NAME =
|
|
15
|
+
/secret|credential|password|private.?key|(?:^|[._-])env(?:[._-]|$)/iu;
|
|
16
|
+
const IGNORE_FILES = ['.gitignore', '.ignore', '.fdignore'];
|
|
17
|
+
|
|
18
|
+
export function ignorePatterns(
|
|
19
|
+
directory: string,
|
|
20
|
+
root: string,
|
|
21
|
+
): readonly string[] {
|
|
22
|
+
const prefix = relative(root, directory).split(sep).join('/');
|
|
23
|
+
const patterns: string[] = [];
|
|
24
|
+
for (const file of IGNORE_FILES) {
|
|
25
|
+
let source: string;
|
|
26
|
+
try {
|
|
27
|
+
source = readFileSync(join(directory, file), 'utf8');
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
if (isMissingFile(cause)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Cannot read ignore file ${file}`, { cause });
|
|
33
|
+
}
|
|
34
|
+
for (const line of source.split(/\r?\n/u)) {
|
|
35
|
+
if (!line || line.startsWith('#')) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const negated = line.startsWith('!');
|
|
39
|
+
const pattern = negated ? line.slice(1) : line;
|
|
40
|
+
let nested = pattern;
|
|
41
|
+
if (prefix && pattern.startsWith('/')) {
|
|
42
|
+
nested = `${prefix}/${pattern.slice(1)}`;
|
|
43
|
+
} else if (prefix && pattern.replace(/\/$/u, '').includes('/')) {
|
|
44
|
+
nested = `${prefix}/${pattern}`;
|
|
45
|
+
} else if (prefix) {
|
|
46
|
+
nested = `${prefix}/**/${pattern}`;
|
|
47
|
+
}
|
|
48
|
+
patterns.push(`${negated ? '!' : ''}${nested}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return patterns;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function excludedName(name: string): boolean {
|
|
55
|
+
return (
|
|
56
|
+
name.startsWith('.') ||
|
|
57
|
+
SENSITIVE_NAME.test(name) ||
|
|
58
|
+
[...name].some(
|
|
59
|
+
(character) =>
|
|
60
|
+
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
61
|
+
) ||
|
|
62
|
+
SKIP_DIRECTORIES.has(name)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface VisualPathPolicy {
|
|
67
|
+
readonly allowedPath: (path: string) => string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Reuse ignore rules only within one synchronous operation. File metadata is
|
|
71
|
+
// always read again: a remembered path must not authorize a replacement symlink.
|
|
72
|
+
export function createPathPolicy(cwd: string): VisualPathPolicy {
|
|
73
|
+
const root = realpathSync(cwd);
|
|
74
|
+
const policies = new Map<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
readonly patterns: readonly string[];
|
|
78
|
+
readonly matcher: ReturnType<typeof ignore>;
|
|
79
|
+
}
|
|
80
|
+
>();
|
|
81
|
+
return { allowedPath: resolvePath };
|
|
82
|
+
|
|
83
|
+
function resolvePath(path: string): string | undefined {
|
|
84
|
+
const absolute = resolve(root, path);
|
|
85
|
+
const local = relative(root, absolute);
|
|
86
|
+
if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
let directory = root;
|
|
90
|
+
let patterns: readonly string[] = [];
|
|
91
|
+
const parts = local.split(sep).filter(Boolean);
|
|
92
|
+
for (const [index, name] of parts.entries()) {
|
|
93
|
+
if (excludedName(name)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
let policy = policies.get(directory);
|
|
97
|
+
if (!policy) {
|
|
98
|
+
const inherited = [...patterns, ...ignorePatterns(directory, root)];
|
|
99
|
+
policy = { patterns: inherited, matcher: ignore().add(inherited) };
|
|
100
|
+
policies.set(directory, policy);
|
|
101
|
+
}
|
|
102
|
+
patterns = policy.patterns;
|
|
103
|
+
const next = join(directory, name);
|
|
104
|
+
let entry: ReturnType<typeof lstatSync>;
|
|
105
|
+
try {
|
|
106
|
+
entry = lstatSync(next);
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
if (
|
|
109
|
+
isMissingFile(cause) ||
|
|
110
|
+
(cause instanceof Error &&
|
|
111
|
+
'code' in cause &&
|
|
112
|
+
cause.code === 'ENOTDIR')
|
|
113
|
+
) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Cannot inspect visual path ${local}`, { cause });
|
|
117
|
+
}
|
|
118
|
+
const childPath = relative(root, next).split(sep).join('/');
|
|
119
|
+
if (
|
|
120
|
+
entry.isSymbolicLink() ||
|
|
121
|
+
policy.matcher.ignores(
|
|
122
|
+
entry.isDirectory() ? `${childPath}/` : childPath,
|
|
123
|
+
)
|
|
124
|
+
) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (index < parts.length - 1 && !entry.isDirectory()) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
directory = next;
|
|
131
|
+
}
|
|
132
|
+
return absolute;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Browser paths are untrusted, including paths reported by compiler plugins.
|
|
137
|
+
export function allowedPath(cwd: string, path: string): string | undefined {
|
|
138
|
+
return createPathPolicy(cwd).allowedPath(path);
|
|
139
|
+
}
|