-
-
Notifications
You must be signed in to change notification settings - Fork 277
Expand file tree
/
Copy pathmodel.ts
More file actions
451 lines (419 loc) · 14 KB
/
Copy pathmodel.ts
File metadata and controls
451 lines (419 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
// Derived, fail-open check selector for `pnpm check:affected --base <ref>`.
//
// The model turns a set of changed paths into a plan of local checks with
// stable, machine-readable reasoning. It is intentionally source-of-truth
// derived rather than a hand-maintained path-to-check registry (issue #1181):
//
// - Vitest owns affected-test discovery through its native `related`
// command and static module graph; this model only decides when that
// existing tool applies;
// - the lint/typecheck/layering/fallow gates are always-on for their input
// categories, so they are never silently skipped (issue constraint);
// - a small explicit build-ownership layer covers Swift, Android helpers,
// the macOS helper, MCP metadata, and the public package surface — the
// only paths whose owning build the sources of truth cannot derive.
//
// Anything the model cannot confidently classify fails open to the full check
// set: unknown paths, workflow/tooling, the selector's own sources, and
// ambiguous files under an owned root that only resolve to `format` (e.g. a
// non-.ts fixture whose owning suite cannot be derived). Existing GitHub CI
// remains authoritative; this only optimizes local/agent feedback.
export type CheckId =
| 'format'
| 'lint'
| 'typecheck'
| 'test-app-typecheck'
| 'layering'
| 'fallow'
| 'mcp-metadata'
| 'build'
| 'package'
| 'vitest-related'
| 'unit'
| 'coverage'
| 'provider-integration'
| 'integration-node'
| 'integration-progress'
| 'swift-runner'
| 'android-helpers'
| 'macos-helper'
| 'web-smoke'
| 'replay-compat';
// The complete local check universe. A fail-open plan selects all of these;
// keep it in sync with the catalog in checks.ts (asserted by the self-test).
export const ALL_CHECKS: readonly CheckId[] = [
'format',
'lint',
'typecheck',
'test-app-typecheck',
'layering',
'fallow',
'mcp-metadata',
'build',
'package',
'vitest-related',
'unit',
'coverage',
'provider-integration',
'integration-node',
'integration-progress',
'swift-runner',
'android-helpers',
'macos-helper',
'web-smoke',
'replay-compat',
];
export type SelectionReason = {
check: CheckId;
path: string;
rule: string;
detail: string;
};
export type FailOpenReason = {
path: string;
rule: 'workflow-tooling' | 'selector-owning' | 'unknown-path' | 'ambiguous-path';
detail: string;
};
export type CheckPlan = {
failOpen: boolean;
checks: CheckId[];
reasons: SelectionReason[];
failOpenReasons: FailOpenReason[];
docsOnlyPaths: string[];
};
export type SelectInput = {
changedFiles: readonly string[];
// Public package entry source files, derived from package.json `exports`.
packageEntryFiles?: readonly string[];
};
// --- Path classification helpers -------------------------------------------
const ROOT_TOOLING = new Set([
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'tsconfig.json',
'tsconfig.lib.json',
'tsdown.config.ts',
'vitest.config.ts',
'.oxlintrc.json',
'.oxfmtrc.json',
'.npmrc',
]);
// Prose that specifies this selector's own behavior — the Testing Matrix these
// ownership rules mirror. It is docs by path, but editing it can invalidate the
// derivation below, and the selector cannot tell whether it did. Keep this in
// sync when the matrix moves; the docs short-circuit would otherwise treat it as
// inert Markdown.
const SELECTOR_OWNING_DOCS = new Set(['docs/agents/testing.md']);
function isSelectorOwning(file: string): boolean {
return (
SELECTOR_OWNING_DOCS.has(file) ||
(file.startsWith('scripts/check-affected/') && !file.endsWith('.md'))
);
}
function isWorkflowTooling(file: string): boolean {
// A workspace package manifest or tsconfig rewires module resolution for
// every consumer, so it fails open like root tooling does.
const packageTooling = /^packages\/[^/]+\/(?:package\.json|tsconfig\.json)$/.test(file);
return (
file.startsWith('.github/') ||
file.startsWith('scripts/') ||
packageTooling ||
ROOT_TOOLING.has(file)
);
}
function isDocs(file: string): boolean {
// skills/ Markdown is agent guidance prose with no owning suite (the
// SkillGym harness was removed), so it classifies as docs like the rest.
return (
file.startsWith('docs/') ||
file.startsWith('website/') ||
file === 'README.md' ||
file === 'LICENSE' ||
file.endsWith('.md')
);
}
function isTestPath(file: string): boolean {
return /\.test\.ts$/.test(file) || /(?:^|\/)__tests__\//.test(file);
}
// --- Ownership rules --------------------------------------------------------
// Each rule inspects one changed file and returns the reasons it contributes.
// Splitting the selection into small, independent rules keeps every function
// simple and makes the derivation self-documenting.
type FileFacts = {
file: string;
isTs: boolean;
underSrc: boolean;
underTest: boolean;
isSrcProd: boolean;
};
type OwnershipRule = (facts: FileFacts, input: SelectInput) => SelectionReason[];
function reason(check: CheckId, file: string, rule: string, detail: string): SelectionReason {
return { check, path: file, rule, detail };
}
const formatGate: OwnershipRule = ({ file, underSrc, underTest }) =>
underSrc || underTest
? [reason('format', file, 'gate:format', 'oxfmt covers src/ and test/')]
: [];
const staticTsGates: OwnershipRule = ({ file, isTs, underSrc, underTest }) =>
isTs && (underSrc || underTest)
? [
reason('lint', file, 'gate:lint', 'oxlint covers the source tree'),
reason('typecheck', file, 'gate:typecheck', 'tsc includes src/ and test/'),
reason('fallow', file, 'gate:fallow', 'fallow audits changed TypeScript for dead code'),
]
: [];
const srcProdGate: OwnershipRule = ({ file, isSrcProd }) => {
if (!isSrcProd) return [];
const selections = [
reason('layering', file, 'gate:layering', 'layering guard reads production src/ modules'),
reason('build', file, 'src-prod', 'production source is compiled by the build'),
];
if (file.startsWith('src/platforms/')) {
selections.push(
reason(
'provider-integration',
file,
'platform-src',
'platform source shapes device/provider wire behavior',
),
reason(
'coverage',
file,
'platform-src',
'Testing Matrix requires coverage for platform/device-response changes',
),
);
}
return selections;
};
function isNodeIntegrationPath(file: string): boolean {
return (
file.startsWith('test/integration/') &&
!file.slice('test/integration/'.length).includes('/') &&
file.endsWith('.ts')
);
}
const vitestRelatedOwnership: OwnershipRule = ({ file, isTs, underSrc, underTest }) =>
isTs && (underSrc || underTest) && !isNodeIntegrationPath(file)
? [
reason(
'vitest-related',
file,
'vitest:related',
'Vitest resolves affected tests through its static module graph',
),
]
: [];
// Workspace package source (#1490 W0): bundled into the published artifact,
// type-checked in the root graph, covered by Vitest's module graph, and
// guarded by layering R11. Fallow scans it too — the W0 ignorePatterns entry
// claimed its resolver could not follow workspace specifiers, which stopped
// being true (it resolves @agent-device/* through each exports map), so an
// extraction into packages/ no longer takes its own dead code out of scope.
const workspacePackageOwnership: OwnershipRule = ({ file, isTs }) => {
if (!isTs || !/^packages\/[^/]+\/src\//.test(file)) return [];
const selections = [
reason('format', file, 'gate:format', 'oxfmt covers packages/'),
reason('lint', file, 'gate:lint', 'oxlint covers packages/'),
reason('typecheck', file, 'gate:typecheck', 'tsc includes packages/'),
reason('fallow', file, 'gate:fallow', 'fallow audits changed TypeScript for dead code'),
reason('layering', file, 'package-src', 'layering R11 guards workspace package boundaries'),
reason(
'vitest-related',
file,
'vitest:related',
'Vitest resolves affected tests through its static module graph',
),
];
if (!isTestPath(file)) {
selections.push(
reason('build', file, 'package-src', 'package source is bundled into the published artifact'),
);
}
return selections;
};
const nodeIntegrationOwnership: OwnershipRule = ({ file }) =>
isNodeIntegrationPath(file)
? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')]
: [];
const testAppOwnership: OwnershipRule = ({ file }) => {
if (!file.startsWith('examples/test-app/')) return [];
if (!/\.(?:[cm]?[jt]sx?|json)$/.test(file)) return [];
return [
reason('format', file, 'gate:format', 'oxfmt covers the Expo test app'),
reason('lint', file, 'gate:lint', 'oxlint covers the Expo test app'),
reason(
'test-app-typecheck',
file,
'own:test-app',
'the Expo test app has an isolated TypeScript dependency graph',
),
];
};
// The frozen replay-compat corpus (#1417). `.ad` fixture data would otherwise
// fail open on its extension: its only consumer is the unit-lane corpus test.
// Any corpus change — script or manifest — also runs the history-backed
// provenance verifier, which is the only gate that can prove an entry's blob
// really came from the release tag it names.
const replayCompatOwnership: OwnershipRule = ({ file }) => {
if (!file.startsWith('test/replay-compat/')) return [];
const selections = [
reason(
'replay-compat',
file,
'own:replay-compat-provenance',
'corpus provenance is re-derived from released git blobs',
),
];
if (file.endsWith('.ad')) {
selections.push(
reason(
'unit',
file,
'own:replay-compat',
'frozen replay-compat corpus is asserted by the unit-lane corpus test',
),
);
}
return selections;
};
const BUILD_OWNERSHIP: ReadonlyArray<{
check: CheckId;
rule: string;
detail: string;
owns: (file: string) => boolean;
}> = [
{
check: 'swift-runner',
rule: 'own:swift',
detail: 'Swift runner sources require the XCUITest build',
owns: (file) => file.startsWith('apple/runner/') || file.endsWith('.swift'),
},
{
check: 'android-helpers',
rule: 'own:android-helpers',
detail: 'Android helper packages have their own build',
owns: (file) =>
file.startsWith('android/snapshot-helper/') || file.startsWith('android/ime-helper/'),
},
{
check: 'macos-helper',
rule: 'own:macos-helper',
detail: 'macOS helper is a separate Swift package build',
owns: (file) => file.startsWith('apple/macos-helper/'),
},
{
check: 'mcp-metadata',
rule: 'own:mcp',
detail: 'MCP registry metadata must stay in sync',
owns: (file) => file === 'server.json' || file === 'smithery.yaml',
},
];
const buildOwnership: OwnershipRule = ({ file }, input) => {
const selections = BUILD_OWNERSHIP.filter((entry) => entry.owns(file)).map((entry) =>
reason(entry.check, file, entry.rule, entry.detail),
);
if ((input.packageEntryFiles ?? []).includes(file)) {
selections.push(
reason('build', file, 'own:public-surface', 'public package entry affects declarations'),
reason(
'package',
file,
'own:public-surface',
'a public entry must still resolve from a clean install',
),
);
}
return selections;
};
const OWNERSHIP_RULES: readonly OwnershipRule[] = [
formatGate,
staticTsGates,
srcProdGate,
vitestRelatedOwnership,
workspacePackageOwnership,
nodeIntegrationOwnership,
testAppOwnership,
replayCompatOwnership,
buildOwnership,
];
function fileFacts(file: string): FileFacts {
const isTs = file.endsWith('.ts') && !file.endsWith('.d.ts');
const underSrc = file.startsWith('src/');
return {
file,
isTs,
underSrc,
underTest: file.startsWith('test/'),
isSrcProd: underSrc && isTs && !isTestPath(file),
};
}
function failOpenFor(file: string): FailOpenReason | null {
if (isSelectorOwning(file)) {
return {
path: file,
rule: 'selector-owning',
detail: 'change to the affected-check selector cannot be trusted to select itself',
};
}
if (isWorkflowTooling(file)) {
return {
path: file,
rule: 'workflow-tooling',
detail: 'workflow/tooling change can alter any gate',
};
}
return null;
}
// --- Selection --------------------------------------------------------------
export function selectChecks(input: SelectInput): CheckPlan {
const reasons: SelectionReason[] = [];
const failOpenReasons: FailOpenReason[] = [];
const docsOnlyPaths: string[] = [];
for (const file of input.changedFiles) {
const failOpen = failOpenFor(file);
if (failOpen) {
failOpenReasons.push(failOpen);
continue;
}
if (isDocs(file)) {
docsOnlyPaths.push(file);
continue;
}
const facts = fileFacts(file);
const selections = OWNERSHIP_RULES.flatMap((rule) => rule(facts, input));
if (selections.length === 0) {
failOpenReasons.push({
path: file,
rule: 'unknown-path',
detail: 'path has no derivable owner; run the full set to stay safe',
});
continue;
}
// `format` is an always-on gate, not evidence of test/build ownership. A
// file we can only route to formatting (e.g. a non-.ts fixture under
// test/) has no derivable suite owner, so treat it as ambiguous and fail
// open rather than silently narrowing to just `format`.
if (!selections.some((selection) => selection.check !== 'format')) {
failOpenReasons.push({
path: file,
rule: 'ambiguous-path',
detail: 'only formatting is derivable; no test/build owner, so run the full set',
});
continue;
}
reasons.push(...selections);
}
if (failOpenReasons.length > 0) {
return { failOpen: true, checks: [...ALL_CHECKS], reasons, failOpenReasons, docsOnlyPaths };
}
const selected = new Set(reasons.map((entry) => entry.check));
return {
failOpen: false,
checks: ALL_CHECKS.filter((check) => selected.has(check)),
reasons,
failOpenReasons,
docsOnlyPaths,
};
}