proteum 2.5.1 → 2.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/README.md +12 -9
- package/agents/project/AGENTS.md +10 -8
- package/agents/project/CODING_STYLE.md +1 -1
- package/agents/project/diagnostics.md +2 -2
- package/agents/project/root/AGENTS.md +10 -8
- package/agents/project/tests/AGENTS.md +1 -1
- package/cli/commands/configure.ts +5 -5
- package/cli/commands/dev.ts +3 -3
- package/cli/commands/verify.ts +117 -4
- package/cli/compiler/artifacts/controllerHelper.ts +66 -0
- package/cli/compiler/artifacts/controllers.ts +3 -0
- package/cli/compiler/artifacts/services.ts +14 -8
- package/cli/compiler/common/generatedRouteModules.ts +270 -53
- package/cli/presentation/commands.ts +14 -3
- package/cli/presentation/help.ts +1 -1
- package/cli/runtime/commands.ts +6 -0
- package/cli/scaffold/templates.ts +11 -3
- package/cli/utils/agents.ts +271 -2
- package/cli/verification/changed.ts +460 -0
- package/client/app/index.ts +1 -1
- package/client/services/router/index.tsx +1 -1
- package/common/applicationConfig.ts +177 -0
- package/common/applicationConfigLoader.ts +33 -1
- package/common/dev/contractsDoctor.ts +16 -0
- package/config.ts +5 -1
- package/docs/migration-2.5.md +54 -11
- package/eslint.js +42 -8
- package/package.json +1 -1
- package/tests/agents-utils.test.cjs +72 -0
- package/tests/cli-mcp-command.test.cjs +14 -0
- package/tests/contracts-doctor.test.cjs +98 -0
- package/tests/definition-contracts.test.cjs +129 -0
- package/tests/eslint-rules.test.cjs +100 -0
- package/tests/scaffold-templates.test.cjs +27 -2
- package/tests/verify-changed.test.cjs +200 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import cp from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { type TVerificationCheckScope, type TVerificationConfig, type TVerificationSuiteConfig } from '../../common/applicationConfig';
|
|
6
|
+
import { loadVerificationConfig } from '../../common/applicationConfigLoader';
|
|
7
|
+
|
|
8
|
+
type TChangedFileMode = 'all' | 'base' | 'staged';
|
|
9
|
+
|
|
10
|
+
export type TChangedVerificationCheck = {
|
|
11
|
+
command: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
id: string;
|
|
14
|
+
matchedFiles: string[];
|
|
15
|
+
reasons: string[];
|
|
16
|
+
scope: TVerificationCheckScope;
|
|
17
|
+
source: 'builtin' | 'config';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TChangedVerificationSkippedCheck = {
|
|
21
|
+
id: string;
|
|
22
|
+
matchedFiles: string[];
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TChangedVerificationExecution = {
|
|
27
|
+
checkId: string;
|
|
28
|
+
command: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
exitCode: number | null;
|
|
32
|
+
signal: NodeJS.Signals | null;
|
|
33
|
+
status: 'failed' | 'passed';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TChangedVerificationPlan = {
|
|
37
|
+
changedFiles: string[];
|
|
38
|
+
configFilepath?: string;
|
|
39
|
+
configRoot: string;
|
|
40
|
+
docsOnly: boolean;
|
|
41
|
+
gitRoot: string;
|
|
42
|
+
selectedChecks: TChangedVerificationCheck[];
|
|
43
|
+
skippedChecks: TChangedVerificationSkippedCheck[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type TChangedVerificationResult = TChangedVerificationPlan & {
|
|
47
|
+
dryRun: boolean;
|
|
48
|
+
executions: TChangedVerificationExecution[];
|
|
49
|
+
result: {
|
|
50
|
+
failedChecks: number;
|
|
51
|
+
ok: boolean;
|
|
52
|
+
selectedChecks: number;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type TBuildChangedVerificationPlanOptions = {
|
|
57
|
+
changedFiles?: string[];
|
|
58
|
+
configSearchDir?: string;
|
|
59
|
+
cwd: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type TRunChangedVerificationOptions = TBuildChangedVerificationPlanOptions & {
|
|
63
|
+
base?: string;
|
|
64
|
+
dryRun?: boolean;
|
|
65
|
+
onPlan?: (plan: TChangedVerificationPlan) => void;
|
|
66
|
+
staged?: boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const sourceExtensions = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']);
|
|
70
|
+
const docsOnlyExtensions = new Set(['.feature', '.md', '.mdx', '.rst', '.txt']);
|
|
71
|
+
const defaultDocsOnlyReason = 'docs-only changes do not require targeted tests unless a project rule matched.';
|
|
72
|
+
|
|
73
|
+
const dedupe = <TValue>(values: TValue[]) => [...new Set(values)];
|
|
74
|
+
const normalizePath = (value: string) => value.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
75
|
+
const quoteShellValue = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
76
|
+
const quoteFileList = (files: string[]) => files.map(quoteShellValue).join(' ');
|
|
77
|
+
|
|
78
|
+
const runGit = (cwd: string, args: string[]) => {
|
|
79
|
+
const result = cp.spawnSync('git', args, {
|
|
80
|
+
cwd,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (result.status !== 0) return [];
|
|
85
|
+
|
|
86
|
+
return result.stdout
|
|
87
|
+
.split(/\r?\n/)
|
|
88
|
+
.map((line) => line.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const resolveGitRoot = (cwd: string) => {
|
|
93
|
+
const [root] = runGit(cwd, ['rev-parse', '--show-toplevel']);
|
|
94
|
+
return root ? path.resolve(root) : path.resolve(cwd);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const fileExistsInRoot = (root: string, relativeFilepath: string) => fs.existsSync(path.join(root, relativeFilepath));
|
|
98
|
+
|
|
99
|
+
export const discoverChangedFiles = ({
|
|
100
|
+
base,
|
|
101
|
+
cwd,
|
|
102
|
+
staged,
|
|
103
|
+
}: {
|
|
104
|
+
base?: string;
|
|
105
|
+
cwd: string;
|
|
106
|
+
staged?: boolean;
|
|
107
|
+
}) => {
|
|
108
|
+
const gitRoot = resolveGitRoot(cwd);
|
|
109
|
+
const mode: TChangedFileMode = base ? 'base' : staged ? 'staged' : 'all';
|
|
110
|
+
const changedFiles =
|
|
111
|
+
mode === 'base'
|
|
112
|
+
? runGit(gitRoot, ['diff', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`])
|
|
113
|
+
: mode === 'staged'
|
|
114
|
+
? runGit(gitRoot, ['diff', '--name-only', '--cached', '--diff-filter=ACMR'])
|
|
115
|
+
: [
|
|
116
|
+
...runGit(gitRoot, ['diff', '--name-only', '--diff-filter=ACMR']),
|
|
117
|
+
...runGit(gitRoot, ['diff', '--name-only', '--cached', '--diff-filter=ACMR']),
|
|
118
|
+
...runGit(gitRoot, ['ls-files', '--others', '--exclude-standard']),
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
return dedupe(changedFiles.map(normalizePath)).filter((filepath) => fileExistsInRoot(gitRoot, filepath));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const isTestFile = (filepath: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(filepath);
|
|
125
|
+
const isRelatedSourceFile = (filepath: string) => {
|
|
126
|
+
if (isTestFile(filepath)) return false;
|
|
127
|
+
if (!sourceExtensions.has(path.extname(filepath))) return false;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
filepath.startsWith('apps/') ||
|
|
131
|
+
filepath.startsWith('client/') ||
|
|
132
|
+
filepath.startsWith('cli/') ||
|
|
133
|
+
filepath.startsWith('common/') ||
|
|
134
|
+
filepath.startsWith('packages/') ||
|
|
135
|
+
filepath.startsWith('server/') ||
|
|
136
|
+
filepath.includes('/src/')
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const isDocsOnlyFile = (filepath: string) =>
|
|
141
|
+
filepath === 'AGENTS.md' ||
|
|
142
|
+
filepath === 'README.md' ||
|
|
143
|
+
filepath.startsWith('docs/') ||
|
|
144
|
+
filepath.startsWith('agents/') ||
|
|
145
|
+
docsOnlyExtensions.has(path.extname(filepath));
|
|
146
|
+
|
|
147
|
+
const normalizeGlob = (glob: string) => normalizePath(glob.trim());
|
|
148
|
+
|
|
149
|
+
const escapeRegExp = (value: string) => value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
|
|
150
|
+
|
|
151
|
+
const globToRegExp = (glob: string) => {
|
|
152
|
+
const normalized = normalizeGlob(glob);
|
|
153
|
+
let pattern = '';
|
|
154
|
+
|
|
155
|
+
for (let index = 0; index < normalized.length; ) {
|
|
156
|
+
if (normalized.slice(index, index + 3) === '**/') {
|
|
157
|
+
pattern += '(?:[^/]+/)*';
|
|
158
|
+
index += 3;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (normalized.slice(index, index + 2) === '**') {
|
|
163
|
+
pattern += '.*';
|
|
164
|
+
index += 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const char = normalized[index];
|
|
169
|
+
pattern += char === '*' ? '[^/]*' : escapeRegExp(char);
|
|
170
|
+
index += 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return new RegExp(`^${pattern}$`);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const matchesGlob = (filepath: string, glob: string) => globToRegExp(glob).test(normalizePath(filepath));
|
|
177
|
+
const matchFiles = (files: string[], globs: readonly string[]) =>
|
|
178
|
+
files.filter((filepath) => globs.some((glob) => matchesGlob(filepath, glob)));
|
|
179
|
+
|
|
180
|
+
const getSuiteCommand = (suite: TVerificationSuiteConfig) => (typeof suite === 'string' ? suite : suite.command);
|
|
181
|
+
const getSuiteCwd = (suite: TVerificationSuiteConfig, configRoot: string) => {
|
|
182
|
+
if (typeof suite === 'string' || !suite.cwd) return configRoot;
|
|
183
|
+
return path.isAbsolute(suite.cwd) ? suite.cwd : path.resolve(configRoot, suite.cwd);
|
|
184
|
+
};
|
|
185
|
+
const getSuiteScope = (suite: TVerificationSuiteConfig, fallback: TVerificationCheckScope = 'targeted') =>
|
|
186
|
+
typeof suite === 'string' || !suite.scope ? fallback : suite.scope;
|
|
187
|
+
|
|
188
|
+
const expandCommand = (command: string, files: string[]) => command.replace(/\{files\}/g, quoteFileList(files));
|
|
189
|
+
|
|
190
|
+
const hasFilesPlaceholder = (command: string) => command.includes('{files}');
|
|
191
|
+
|
|
192
|
+
const addCheck = ({
|
|
193
|
+
check,
|
|
194
|
+
selectedChecks,
|
|
195
|
+
}: {
|
|
196
|
+
check: TChangedVerificationCheck;
|
|
197
|
+
selectedChecks: TChangedVerificationCheck[];
|
|
198
|
+
}) => {
|
|
199
|
+
const existing = selectedChecks.find((entry) => entry.command === check.command && entry.cwd === check.cwd);
|
|
200
|
+
if (!existing) {
|
|
201
|
+
selectedChecks.push(check);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
existing.matchedFiles = dedupe([...existing.matchedFiles, ...check.matchedFiles]);
|
|
206
|
+
existing.reasons = dedupe([...existing.reasons, ...check.reasons]);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const createSuiteCheck = ({
|
|
210
|
+
configRoot,
|
|
211
|
+
files,
|
|
212
|
+
id,
|
|
213
|
+
reason,
|
|
214
|
+
scope,
|
|
215
|
+
source,
|
|
216
|
+
suite,
|
|
217
|
+
}: {
|
|
218
|
+
configRoot: string;
|
|
219
|
+
files: string[];
|
|
220
|
+
id: string;
|
|
221
|
+
reason: string;
|
|
222
|
+
scope?: TVerificationCheckScope;
|
|
223
|
+
source: 'builtin' | 'config';
|
|
224
|
+
suite: TVerificationSuiteConfig;
|
|
225
|
+
}): TChangedVerificationCheck | undefined => {
|
|
226
|
+
const command = getSuiteCommand(suite);
|
|
227
|
+
if (hasFilesPlaceholder(command) && files.length === 0) return undefined;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
command: expandCommand(command, files),
|
|
231
|
+
cwd: getSuiteCwd(suite, configRoot),
|
|
232
|
+
id,
|
|
233
|
+
matchedFiles: files,
|
|
234
|
+
reasons: [reason],
|
|
235
|
+
scope: scope || getSuiteScope(suite),
|
|
236
|
+
source,
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const addConfigSuiteCheck = ({
|
|
241
|
+
config,
|
|
242
|
+
configRoot,
|
|
243
|
+
files,
|
|
244
|
+
id,
|
|
245
|
+
reason,
|
|
246
|
+
run,
|
|
247
|
+
scope,
|
|
248
|
+
selectedChecks,
|
|
249
|
+
skippedChecks,
|
|
250
|
+
}: {
|
|
251
|
+
config: TVerificationConfig;
|
|
252
|
+
configRoot: string;
|
|
253
|
+
files: string[];
|
|
254
|
+
id: string;
|
|
255
|
+
reason: string;
|
|
256
|
+
run: string;
|
|
257
|
+
scope?: TVerificationCheckScope;
|
|
258
|
+
selectedChecks: TChangedVerificationCheck[];
|
|
259
|
+
skippedChecks: TChangedVerificationSkippedCheck[];
|
|
260
|
+
}) => {
|
|
261
|
+
const suite = config.suites?.[run];
|
|
262
|
+
if (!suite) {
|
|
263
|
+
skippedChecks.push({ id: `${id}:${run}`, matchedFiles: files, reason: `Unknown verification suite "${run}".` });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const check = createSuiteCheck({
|
|
268
|
+
configRoot,
|
|
269
|
+
files,
|
|
270
|
+
id: `${id}:${run}`,
|
|
271
|
+
reason,
|
|
272
|
+
scope,
|
|
273
|
+
source: 'config',
|
|
274
|
+
suite,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (!check) {
|
|
278
|
+
skippedChecks.push({ id: `${id}:${run}`, matchedFiles: files, reason: `Suite "${run}" requires matched files.` });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
addCheck({ check, selectedChecks });
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const getDocsOnlyReason = (config: TVerificationConfig) => {
|
|
286
|
+
if (config.docsOnly === false) return undefined;
|
|
287
|
+
if (typeof config.docsOnly === 'object' && config.docsOnly.reason) return config.docsOnly.reason;
|
|
288
|
+
return defaultDocsOnlyReason;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const buildChangedVerificationPlan = ({
|
|
292
|
+
changedFiles,
|
|
293
|
+
configSearchDir,
|
|
294
|
+
cwd,
|
|
295
|
+
}: TBuildChangedVerificationPlanOptions): TChangedVerificationPlan => {
|
|
296
|
+
const gitRoot = resolveGitRoot(cwd);
|
|
297
|
+
const files = dedupe((changedFiles || discoverChangedFiles({ cwd })).map(normalizePath)).filter((filepath) =>
|
|
298
|
+
fileExistsInRoot(gitRoot, filepath),
|
|
299
|
+
);
|
|
300
|
+
const { config, filepath: configFilepath, root: configRoot } = loadVerificationConfig(configSearchDir || cwd);
|
|
301
|
+
const selectedChecks: TChangedVerificationCheck[] = [];
|
|
302
|
+
const skippedChecks: TChangedVerificationSkippedCheck[] = [];
|
|
303
|
+
const docsOnly = files.length > 0 && files.every(isDocsOnlyFile);
|
|
304
|
+
|
|
305
|
+
for (const entry of config.always || []) {
|
|
306
|
+
const suite = config.suites?.[entry] || entry;
|
|
307
|
+
const check = createSuiteCheck({
|
|
308
|
+
configRoot,
|
|
309
|
+
files,
|
|
310
|
+
id: `always:${entry}`,
|
|
311
|
+
reason: 'Configured always-run verification.',
|
|
312
|
+
scope: 'static',
|
|
313
|
+
source: 'config',
|
|
314
|
+
suite,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (check) addCheck({ check, selectedChecks });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (docsOnly) {
|
|
321
|
+
const reason = getDocsOnlyReason(config);
|
|
322
|
+
if (reason) skippedChecks.push({ id: 'builtin:docs-only', matchedFiles: files, reason });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const changedTestFiles = files.filter(isTestFile);
|
|
326
|
+
if (changedTestFiles.length > 0) {
|
|
327
|
+
const check = createSuiteCheck({
|
|
328
|
+
configRoot: gitRoot,
|
|
329
|
+
files: changedTestFiles,
|
|
330
|
+
id: 'builtin:changed-tests',
|
|
331
|
+
reason: 'Changed test files should run directly.',
|
|
332
|
+
scope: 'targeted',
|
|
333
|
+
source: 'builtin',
|
|
334
|
+
suite: 'npx vitest run {files}',
|
|
335
|
+
});
|
|
336
|
+
if (check) addCheck({ check, selectedChecks });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const changedSourceFiles = docsOnly ? [] : files.filter(isRelatedSourceFile);
|
|
340
|
+
if (changedSourceFiles.length > 0) {
|
|
341
|
+
const check = createSuiteCheck({
|
|
342
|
+
configRoot: gitRoot,
|
|
343
|
+
files: changedSourceFiles,
|
|
344
|
+
id: 'builtin:related-tests',
|
|
345
|
+
reason: 'Changed source files should run related tests.',
|
|
346
|
+
scope: 'targeted',
|
|
347
|
+
source: 'builtin',
|
|
348
|
+
suite: 'npx vitest related {files}',
|
|
349
|
+
});
|
|
350
|
+
if (check) addCheck({ check, selectedChecks });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const rule of config.rules || []) {
|
|
354
|
+
const matchedFiles = matchFiles(files, rule.match);
|
|
355
|
+
if (matchedFiles.length === 0) continue;
|
|
356
|
+
|
|
357
|
+
for (const run of rule.run) {
|
|
358
|
+
addConfigSuiteCheck({
|
|
359
|
+
config,
|
|
360
|
+
configRoot,
|
|
361
|
+
files: matchedFiles,
|
|
362
|
+
id: rule.id,
|
|
363
|
+
reason: rule.reason,
|
|
364
|
+
run,
|
|
365
|
+
scope: rule.scope,
|
|
366
|
+
selectedChecks,
|
|
367
|
+
skippedChecks,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
changedFiles: files,
|
|
374
|
+
...(configFilepath ? { configFilepath } : {}),
|
|
375
|
+
configRoot,
|
|
376
|
+
docsOnly,
|
|
377
|
+
gitRoot,
|
|
378
|
+
selectedChecks,
|
|
379
|
+
skippedChecks,
|
|
380
|
+
};
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const runShellCommand = (check: TChangedVerificationCheck) =>
|
|
384
|
+
new Promise<TChangedVerificationExecution>((resolve) => {
|
|
385
|
+
const startedAt = Date.now();
|
|
386
|
+
const child = cp.spawn(check.command, [], {
|
|
387
|
+
cwd: check.cwd,
|
|
388
|
+
shell: true,
|
|
389
|
+
stdio: 'inherit',
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
child.on('error', () => {
|
|
393
|
+
resolve({
|
|
394
|
+
checkId: check.id,
|
|
395
|
+
command: check.command,
|
|
396
|
+
cwd: check.cwd,
|
|
397
|
+
durationMs: Date.now() - startedAt,
|
|
398
|
+
exitCode: 1,
|
|
399
|
+
signal: null,
|
|
400
|
+
status: 'failed',
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
child.on('exit', (exitCode, signal) => {
|
|
404
|
+
resolve({
|
|
405
|
+
checkId: check.id,
|
|
406
|
+
command: check.command,
|
|
407
|
+
cwd: check.cwd,
|
|
408
|
+
durationMs: Date.now() - startedAt,
|
|
409
|
+
exitCode,
|
|
410
|
+
signal,
|
|
411
|
+
status: exitCode === 0 && signal === null ? 'passed' : 'failed',
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
export const runChangedVerification = async ({
|
|
417
|
+
base,
|
|
418
|
+
changedFiles,
|
|
419
|
+
configSearchDir,
|
|
420
|
+
cwd,
|
|
421
|
+
dryRun = false,
|
|
422
|
+
onPlan,
|
|
423
|
+
staged = false,
|
|
424
|
+
}: TRunChangedVerificationOptions): Promise<TChangedVerificationResult> => {
|
|
425
|
+
const files = changedFiles || discoverChangedFiles({ base, cwd, staged });
|
|
426
|
+
const plan = buildChangedVerificationPlan({ changedFiles: files, configSearchDir, cwd });
|
|
427
|
+
const executions: TChangedVerificationExecution[] = [];
|
|
428
|
+
|
|
429
|
+
if (!dryRun) {
|
|
430
|
+
onPlan?.(plan);
|
|
431
|
+
for (const check of plan.selectedChecks) executions.push(await runShellCommand(check));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const failedChecks = executions.filter((execution) => execution.status === 'failed').length;
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
...plan,
|
|
438
|
+
dryRun,
|
|
439
|
+
executions,
|
|
440
|
+
result: {
|
|
441
|
+
failedChecks,
|
|
442
|
+
ok: failedChecks === 0,
|
|
443
|
+
selectedChecks: plan.selectedChecks.length,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export const renderChangedVerificationPlan = (plan: TChangedVerificationPlan) =>
|
|
449
|
+
[
|
|
450
|
+
'Changed Verification Plan',
|
|
451
|
+
`- config=${plan.configFilepath || 'none'}`,
|
|
452
|
+
`- changedFiles=${plan.changedFiles.length}`,
|
|
453
|
+
`- selectedChecks=${plan.selectedChecks.length}`,
|
|
454
|
+
`- skippedChecks=${plan.skippedChecks.length}`,
|
|
455
|
+
...plan.selectedChecks.map(
|
|
456
|
+
(check) =>
|
|
457
|
+
`- [${check.scope}] ${check.id} cwd=${path.relative(plan.gitRoot, check.cwd) || '.'} command=${check.command} reason=${check.reasons.join('; ')}`,
|
|
458
|
+
),
|
|
459
|
+
...plan.skippedChecks.map((check) => `- [skipped] ${check.id} reason=${check.reason}`),
|
|
460
|
+
].join('\n');
|
package/client/app/index.ts
CHANGED
|
@@ -121,7 +121,7 @@ export default abstract class Application {
|
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
public handleError(error: unknown, fallbackMessage?:
|
|
124
|
+
public handleError(error: unknown, fallbackMessage?: any): string | void {
|
|
125
125
|
const normalizedError = normalizeClientError(error, fallbackMessage);
|
|
126
126
|
console.error(normalizedError);
|
|
127
127
|
|
|
@@ -194,7 +194,7 @@ export default class ClientRouter<
|
|
|
194
194
|
} = {};
|
|
195
195
|
|
|
196
196
|
public async registerRoutes() {
|
|
197
|
-
const loaders = appRoutes as TRoutesLoaders;
|
|
197
|
+
const loaders = appRoutes as unknown as TRoutesLoaders;
|
|
198
198
|
let currentRoute: TUnresolvedRoute | undefined;
|
|
199
199
|
debug && console.log(LogPrefix, `Indexing routes and finding the current route from ssr data:`, this.context);
|
|
200
200
|
|
|
@@ -32,6 +32,38 @@ export type TApplicationSetupConfig = {
|
|
|
32
32
|
connect?: TConnectedProjectsConfig;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
export type TVerificationCheckScope = 'targeted' | 'area' | 'full' | 'static';
|
|
36
|
+
|
|
37
|
+
export type TVerificationSuiteConfig =
|
|
38
|
+
| string
|
|
39
|
+
| {
|
|
40
|
+
command: string;
|
|
41
|
+
cwd?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
scope?: TVerificationCheckScope;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type TVerificationRuleConfig = {
|
|
47
|
+
id: string;
|
|
48
|
+
match: readonly string[];
|
|
49
|
+
reason: string;
|
|
50
|
+
run: readonly string[];
|
|
51
|
+
scope?: TVerificationCheckScope;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type TVerificationDocsOnlyConfig =
|
|
55
|
+
| boolean
|
|
56
|
+
| {
|
|
57
|
+
reason?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type TVerificationConfig = {
|
|
61
|
+
always?: readonly string[];
|
|
62
|
+
docsOnly?: TVerificationDocsOnlyConfig;
|
|
63
|
+
rules?: readonly TVerificationRuleConfig[];
|
|
64
|
+
suites?: Record<string, TVerificationSuiteConfig>;
|
|
65
|
+
};
|
|
66
|
+
|
|
35
67
|
const isRecord = (value: unknown): value is TObjectRecord =>
|
|
36
68
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
37
69
|
|
|
@@ -87,6 +119,137 @@ const readStringRecord = ({
|
|
|
87
119
|
return output;
|
|
88
120
|
};
|
|
89
121
|
|
|
122
|
+
const readStringArray = ({
|
|
123
|
+
filepath,
|
|
124
|
+
path,
|
|
125
|
+
value,
|
|
126
|
+
}: {
|
|
127
|
+
filepath: string;
|
|
128
|
+
path: string;
|
|
129
|
+
value: unknown;
|
|
130
|
+
}) => {
|
|
131
|
+
if (!Array.isArray(value)) throw new Error(`Invalid ${path} in ${filepath}. Expected an array of strings.`);
|
|
132
|
+
|
|
133
|
+
const output = value.map((entry, index) => {
|
|
134
|
+
if (typeof entry !== 'string' || entry.trim() === '')
|
|
135
|
+
throw new Error(`Invalid ${path}.${index} in ${filepath}. Expected a non-empty string.`);
|
|
136
|
+
|
|
137
|
+
return entry.trim();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return [...new Set(output)];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const verificationScopes = new Set<TVerificationCheckScope>(['targeted', 'area', 'full', 'static']);
|
|
144
|
+
|
|
145
|
+
const readVerificationScope = ({
|
|
146
|
+
filepath,
|
|
147
|
+
path,
|
|
148
|
+
value,
|
|
149
|
+
}: {
|
|
150
|
+
filepath: string;
|
|
151
|
+
path: string;
|
|
152
|
+
value: unknown;
|
|
153
|
+
}) => {
|
|
154
|
+
if (value === undefined) return undefined;
|
|
155
|
+
if (typeof value !== 'string' || !verificationScopes.has(value as TVerificationCheckScope)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Invalid ${path} in ${filepath}. Expected one of ${Array.from(verificationScopes).join(', ')}.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return value as TVerificationCheckScope;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const normalizeVerificationSuiteConfig = ({
|
|
165
|
+
filepath,
|
|
166
|
+
key,
|
|
167
|
+
value,
|
|
168
|
+
}: {
|
|
169
|
+
filepath: string;
|
|
170
|
+
key: string;
|
|
171
|
+
value: unknown;
|
|
172
|
+
}): TVerificationSuiteConfig => {
|
|
173
|
+
if (typeof value === 'string') return readRequiredString({ filepath, path: `suites.${key}`, value });
|
|
174
|
+
if (!isRecord(value)) throw new Error(`Invalid suites.${key} in ${filepath}. Expected a command string or object.`);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
command: readRequiredString({ filepath, path: `suites.${key}.command`, value: value.command }),
|
|
178
|
+
cwd: readOptionalString({ filepath, path: `suites.${key}.cwd`, value: value.cwd }),
|
|
179
|
+
description: readOptionalString({ filepath, path: `suites.${key}.description`, value: value.description }),
|
|
180
|
+
scope: readVerificationScope({ filepath, path: `suites.${key}.scope`, value: value.scope }),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const normalizeVerificationSuitesConfig = ({
|
|
185
|
+
filepath,
|
|
186
|
+
value,
|
|
187
|
+
}: {
|
|
188
|
+
filepath: string;
|
|
189
|
+
value: unknown;
|
|
190
|
+
}): Record<string, TVerificationSuiteConfig> => {
|
|
191
|
+
if (value === undefined) return {};
|
|
192
|
+
if (!isRecord(value)) throw new Error(`Invalid suites in ${filepath}. Expected an object.`);
|
|
193
|
+
|
|
194
|
+
const output: Record<string, TVerificationSuiteConfig> = {};
|
|
195
|
+
|
|
196
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
197
|
+
if (!key.trim()) throw new Error(`Invalid suites key in ${filepath}. Expected a non-empty string.`);
|
|
198
|
+
output[key] = normalizeVerificationSuiteConfig({ filepath, key, value: entry });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return output;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const normalizeVerificationRuleConfig = ({
|
|
205
|
+
filepath,
|
|
206
|
+
index,
|
|
207
|
+
value,
|
|
208
|
+
}: {
|
|
209
|
+
filepath: string;
|
|
210
|
+
index: number;
|
|
211
|
+
value: unknown;
|
|
212
|
+
}): TVerificationRuleConfig => {
|
|
213
|
+
if (!isRecord(value)) throw new Error(`Invalid rules.${index} in ${filepath}. Expected an object.`);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: readRequiredString({ filepath, path: `rules.${index}.id`, value: value.id }),
|
|
217
|
+
match: readStringArray({ filepath, path: `rules.${index}.match`, value: value.match }),
|
|
218
|
+
reason: readRequiredString({ filepath, path: `rules.${index}.reason`, value: value.reason }),
|
|
219
|
+
run: readStringArray({ filepath, path: `rules.${index}.run`, value: value.run }),
|
|
220
|
+
scope: readVerificationScope({ filepath, path: `rules.${index}.scope`, value: value.scope }),
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const normalizeVerificationRulesConfig = ({
|
|
225
|
+
filepath,
|
|
226
|
+
value,
|
|
227
|
+
}: {
|
|
228
|
+
filepath: string;
|
|
229
|
+
value: unknown;
|
|
230
|
+
}): TVerificationRuleConfig[] => {
|
|
231
|
+
if (value === undefined) return [];
|
|
232
|
+
if (!Array.isArray(value)) throw new Error(`Invalid rules in ${filepath}. Expected an array.`);
|
|
233
|
+
|
|
234
|
+
return value.map((entry, index) => normalizeVerificationRuleConfig({ filepath, index, value: entry }));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const normalizeVerificationDocsOnlyConfig = ({
|
|
238
|
+
filepath,
|
|
239
|
+
value,
|
|
240
|
+
}: {
|
|
241
|
+
filepath: string;
|
|
242
|
+
value: unknown;
|
|
243
|
+
}): TVerificationDocsOnlyConfig => {
|
|
244
|
+
if (value === undefined) return true;
|
|
245
|
+
if (typeof value === 'boolean') return value;
|
|
246
|
+
if (!isRecord(value)) throw new Error(`Invalid docsOnly in ${filepath}. Expected a boolean or object.`);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
reason: readOptionalString({ filepath, path: 'docsOnly.reason', value: value.reason }),
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
90
253
|
const readSocialConfig = ({
|
|
91
254
|
filepath,
|
|
92
255
|
value,
|
|
@@ -160,6 +323,18 @@ export const normalizeApplicationSetupConfig = (
|
|
|
160
323
|
};
|
|
161
324
|
};
|
|
162
325
|
|
|
326
|
+
export const normalizeVerificationConfig = (value: unknown, filepath = 'proteum.verify.config.ts'): TVerificationConfig => {
|
|
327
|
+
if (value === undefined) return { always: [], docsOnly: true, rules: [], suites: {} };
|
|
328
|
+
if (!isRecord(value)) throw new Error(`Invalid verification config in ${filepath}. Expected an object export.`);
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
always: value.always === undefined ? [] : readStringArray({ filepath, path: 'always', value: value.always }),
|
|
332
|
+
docsOnly: normalizeVerificationDocsOnlyConfig({ filepath, value: value.docsOnly }),
|
|
333
|
+
rules: normalizeVerificationRulesConfig({ filepath, value: value.rules }),
|
|
334
|
+
suites: normalizeVerificationSuitesConfig({ filepath, value: value.suites }),
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
|
|
163
338
|
class ApplicationConfigHelpers {
|
|
164
339
|
public static identity<const TIdentity extends TApplicationIdentityConfig>(config: TIdentity) {
|
|
165
340
|
return config;
|
|
@@ -170,4 +345,6 @@ class ApplicationConfigHelpers {
|
|
|
170
345
|
}
|
|
171
346
|
}
|
|
172
347
|
|
|
348
|
+
export const defineVerificationConfig = <const TVerification extends TVerificationConfig>(config: TVerification) => config;
|
|
349
|
+
|
|
173
350
|
export const Application = ApplicationConfigHelpers;
|