@orkestrel/scaffold 0.0.80 → 0.0.82

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.
Files changed (36) hide show
  1. package/dist/bin/main.js +167 -13
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/AGENTS.md +5 -3
  4. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +2 -2
  5. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +41 -38
  6. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +3 -3
  7. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +3 -3
  8. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +5 -5
  9. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +3 -3
  10. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +9 -9
  11. package/dist/host/claude/agents/orkestrel.md +52 -52
  12. package/dist/host/claude/rules/application.md +20 -6
  13. package/dist/host/claude/rules/architecture.md +2 -2
  14. package/dist/host/claude/rules/browser.md +9 -0
  15. package/dist/host/claude/rules/documentation.md +2 -1
  16. package/dist/host/claude/rules/styles.md +35 -12
  17. package/dist/host/claude/rules/tests.md +15 -10
  18. package/dist/host/claude/rules/workspace.md +128 -98
  19. package/dist/host/claude/skills/orkestrel-journey/SKILL.md +1 -1
  20. package/dist/host/configs/helpers.ts +157 -9
  21. package/dist/host/configs/policy.ts +64 -61
  22. package/dist/host/dotfiles/oxlintrc.json +132 -16
  23. package/dist/host/dotfiles/prettierignore +1 -1
  24. package/dist/host/guides/README.md +9 -5
  25. package/dist/host/guides/scaffold.md +475 -115
  26. package/dist/host/manifest.json +27 -27
  27. package/dist/host/tests/config.test.ts +996 -39
  28. package/dist/host/tests/policy.test.ts +11 -0
  29. package/dist/host/tests/setupPolicy.ts +396 -22
  30. package/dist/src/core/index.cjs +1263 -161
  31. package/dist/src/core/index.cjs.map +1 -1
  32. package/dist/src/core/index.d.cts +297 -35
  33. package/dist/src/core/index.d.ts +297 -35
  34. package/dist/src/core/index.js +1243 -162
  35. package/dist/src/core/index.js.map +1 -1
  36. package/package.json +3 -3
@@ -1,12 +1,12 @@
1
1
  // P1: Every checked population must exist and be non-empty; absence fails instead of passing vacuously.
2
2
  // P2: Required items are checked strictly; extra items are ignored before their shape is read.
3
3
 
4
+ import type { UserConfig } from 'vite'
4
5
  import { spawnSync } from 'node:child_process'
5
6
  import {
6
7
  existsSync,
7
8
  globSync,
8
9
  lstatSync,
9
- mkdtempSync,
10
10
  mkdirSync,
11
11
  readdirSync,
12
12
  readFileSync,
@@ -16,7 +16,7 @@ import {
16
16
  } from 'node:fs'
17
17
  import { createRequire } from 'node:module'
18
18
  import { tmpdir } from 'node:os'
19
- import { dirname, join, resolve } from 'node:path'
19
+ import { dirname, join, relative, resolve } from 'node:path'
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url'
21
21
  import { build, createServer, loadConfigFromFile } from 'vite'
22
22
  import { RuleTester } from 'oxlint/plugins-dev'
@@ -53,8 +53,19 @@ import policyPlugin, {
53
53
  textToPolicyHits,
54
54
  } from '../configs/policy.js'
55
55
  import configuration, { resolveWorkspacePath } from '../vite.config.js'
56
+ import * as rootConfiguration from '../vite.config.js'
56
57
  import tsconfig from '../tsconfig.json' with { type: 'json' }
57
58
  import {
59
+ collectSheets,
60
+ collectFrameworks,
61
+ readConfigRecord,
62
+ readConfigScript,
63
+ collectFaceWrappers,
64
+ inspectSheetConfiguration,
65
+ readSheetPrelude,
66
+ SHEET_POLICY_BARREL_PATTERN,
67
+ SHEET_POLICY_ORDER_PATTERN,
68
+ readImportDiagnostics,
58
69
  createPolicyScratch,
59
70
  inspectPolicyConfiguration,
60
71
  inspectPolicyWiring,
@@ -64,6 +75,357 @@ import { describe, expect, it } from 'vitest'
64
75
 
65
76
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
66
77
 
78
+ describe('selected faces', () => {
79
+ it('collects each selected sheet proof from the effective project root and rejects a wrong root', async () => {
80
+ for (const face of collectSheets(root)) {
81
+ const proofs = resolve(root, 'tests/src', face)
82
+ if (!existsSync(proofs)) continue
83
+ const wrapper = resolve(root, `configs/src/vite.${face}.config.ts`)
84
+ const loaded = await loadConfigFromFile(
85
+ { command: 'serve', mode: 'test' },
86
+ wrapper,
87
+ root,
88
+ 'silent',
89
+ )
90
+ if (loaded === null) throw new Error(`Unloaded wrapper ${wrapper}`)
91
+ const project = loaded.config
92
+ const include = project.test?.include
93
+ if (include === undefined) throw new Error(`Missing sheet include ${face}`)
94
+ // Vitest roots a file project at its wrapper directory unless test.root overrides it.
95
+ const directory = resolve(
96
+ root,
97
+ project.test?.root ?? dirname(wrapper),
98
+ project.test?.dir || '.',
99
+ )
100
+ const collected = globSync(include, { cwd: directory }).map((path) =>
101
+ resolve(directory, path),
102
+ )
103
+ const expected = [resolve(proofs, 'index.test.ts')]
104
+ if (face === 'styles' && existsSync(resolve(proofs, 'themes/index.test.ts')))
105
+ expected.push(resolve(proofs, 'themes/index.test.ts'))
106
+ expect(collected).toEqual(expect.arrayContaining(expected))
107
+ const control = globSync(include, { cwd: dirname(wrapper) }).map((path) =>
108
+ resolve(dirname(wrapper), path),
109
+ )
110
+ expect(control).toEqual([])
111
+ expect(() => expect(control).toEqual(expect.arrayContaining(expected))).toThrow('expected')
112
+ }
113
+ })
114
+
115
+ it('enumerates markers independently of wrappers and detects a deleted wrapper', () => {
116
+ const fixture = createPolicyScratch({ prefix: 'propagation-faces-' })
117
+ const scratch = fixture.path
118
+ try {
119
+ expect(collectSheets(scratch)).toEqual([])
120
+ expect(collectFrameworks(scratch)).toEqual([])
121
+ for (const path of [
122
+ 'src/paper/index.scss',
123
+ 'src/paper/sheet.ts',
124
+ 'src/incomplete/index.scss',
125
+ 'src/vue/index.ts',
126
+ 'app/vue/index.ts',
127
+ ]) {
128
+ mkdirSync(dirname(resolve(scratch, path)), { recursive: true })
129
+ writeFileSync(resolve(scratch, path), '')
130
+ }
131
+ expect(collectSheets(scratch)).toEqual(['paper'])
132
+ expect(collectFrameworks(scratch)).toEqual(['src/vue', 'app/vue'])
133
+ for (const path of [
134
+ 'configs/src/vite.paper.config.ts',
135
+ 'configs/src/vite.vue.config.ts',
136
+ 'configs/app/vite.vue.config.ts',
137
+ ]) {
138
+ mkdirSync(dirname(resolve(scratch, path)), { recursive: true })
139
+ writeFileSync(resolve(scratch, path), '')
140
+ }
141
+ expect(collectFaceWrappers(scratch)).toContain('configs/src/vite.paper.config.ts')
142
+ rmSync(resolve(scratch, 'configs/src/vite.paper.config.ts'))
143
+ expect(() => collectFaceWrappers(scratch)).toThrow(
144
+ 'Missing wrapper configs/src/vite.paper.config.ts',
145
+ )
146
+ expect(collectSheets(scratch)).toContain('paper')
147
+ } finally {
148
+ fixture.destroy()
149
+ }
150
+ })
151
+
152
+ it('loads each selected sheet and framework wrapper and checks its packaging and browser project', async () => {
153
+ const manifest = readConfigRecord(
154
+ JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')),
155
+ )
156
+ const scripts = readConfigRecord(manifest.scripts)
157
+ const sheets = collectSheets(root)
158
+ const frameworks = collectFrameworks(root)
159
+ const wrappers = collectFaceWrappers(root)
160
+ const readings: Array<{
161
+ readonly axis: string
162
+ readonly face: string
163
+ readonly wrapper: string
164
+ readonly project: UserConfig
165
+ }> = []
166
+ for (const sheet of sheets) expect(wrappers).toContain(`configs/src/vite.${sheet}.config.ts`)
167
+ for (const face of frameworks)
168
+ expect(wrappers).toContain(`configs/${face.replace('/', '/vite.')}.config.ts`)
169
+ for (const wrapper of wrappers) {
170
+ const loaded = await loadConfigFromFile(
171
+ { command: 'build', mode: 'test' },
172
+ resolve(root, wrapper),
173
+ root,
174
+ 'silent',
175
+ )
176
+ if (loaded === null) throw new Error(`Unloaded wrapper ${wrapper}`)
177
+ const match = /^configs\/(src|app)\/vite\.([^.]+)\.config\.ts$/u.exec(wrapper)
178
+ const axis = match?.[1]
179
+ const face = match?.[2]
180
+ if (axis === undefined || face === undefined) throw new Error(`Invalid wrapper ${wrapper}`)
181
+ const project = loaded.config
182
+ expect(project.test?.include).toContain(`tests/${axis}/${face}/**/*.test.ts`)
183
+ expect(project.test?.browser).toMatchObject({
184
+ enabled: true,
185
+ instances: expect.arrayContaining([expect.objectContaining({ browser: 'chromium' })]),
186
+ })
187
+ expect(project.optimizeDeps?.include).toEqual(
188
+ expect.arrayContaining(['@orkestrel/test', '@orkestrel/test/browser']),
189
+ )
190
+ expect(
191
+ Object.getOwnPropertyDescriptor(tsconfig.compilerOptions.paths, `@${axis}/${face}`)?.value,
192
+ ).toEqual([`./${axis}/${face}/index.ts`])
193
+ readings.push({ axis, face, wrapper, project })
194
+ }
195
+ for (const { face, wrapper, project } of readings.filter((reading) =>
196
+ sheets.includes(reading.face),
197
+ )) {
198
+ expect(readFileSync(resolve(root, wrapper), 'utf8')).toContain('sheetProject(')
199
+ expect(inspectSheetConfiguration(project)).toEqual([])
200
+ const exports = readConfigRecord(manifest.exports)
201
+ expect(exports[`./${face}`]).toBe(`./dist/src/${face}/index.css`)
202
+ expect(exports[`./${face}/scss`]).toBe(`./src/${face}/index.scss`)
203
+ expect(manifest.files).toContain(`src/${face}/**/*.scss`)
204
+ expect(manifest.sideEffects).toEqual(expect.arrayContaining(['**/*.css', '**/*.scss']))
205
+ expect(readConfigScript(scripts, `test:src:${face}`)).toContain(
206
+ `npm run build:src:${face} &&`,
207
+ )
208
+ expect(readConfigScript(scripts, 'build:src')).toContain(`npm run build:src:${face}`)
209
+ }
210
+ for (const { project } of readings.filter((reading) =>
211
+ frameworks.includes(`${reading.axis}/${reading.face}`),
212
+ )) {
213
+ expect(project.optimizeDeps?.include).toContain('vue')
214
+ expect(project.test?.setupFiles).toEqual(
215
+ expect.arrayContaining(['./tests/setup.ts', './tests/setupBrowser.ts']),
216
+ )
217
+ }
218
+ for (const { wrapper } of readings.filter(
219
+ (reading) => reading.axis === 'src' && frameworks.includes(`${reading.axis}/${reading.face}`),
220
+ )) {
221
+ const text = readFileSync(resolve(root, wrapper), 'utf8')
222
+ expect(text).toContain('rewriteCoreSpecifier')
223
+ expect(text).toContain('rewriteBrowserSpecifier')
224
+ }
225
+ if (!existsSync(resolve(root, 'src/styles/themes/index.scss'))) return
226
+ const loaded = await loadConfigFromFile(
227
+ { command: 'build', mode: 'test' },
228
+ resolve(root, 'configs/src/vite.themes.config.ts'),
229
+ root,
230
+ 'silent',
231
+ )
232
+ if (loaded === null) throw new Error('Unloaded themes wrapper')
233
+ expect(loaded.config.build?.outDir).toBe('dist/src/styles/themes')
234
+ const barrel = readFileSync(resolve(root, 'src/styles/themes/index.scss'), 'utf8')
235
+ expect(readSheetPrelude(barrel)).toMatch(SHEET_POLICY_BARREL_PATTERN)
236
+ expect(
237
+ readSheetPrelude(readFileSync(resolve(root, 'src/styles/_tokens.scss'), 'utf8')),
238
+ ).toMatch(SHEET_POLICY_ORDER_PATTERN)
239
+ const target = sheets.includes('styles') ? 'styles' : 'themes'
240
+ const command = sheets.includes('styles')
241
+ ? 'vite build --config configs/src/vite.styles.config.ts && vite build --config configs/src/vite.themes.config.ts'
242
+ : 'vite build --config configs/src/vite.themes.config.ts'
243
+ expect(readConfigScript(scripts, `build:src:${target}`)).toBe(command)
244
+ expect(readConfigScript(scripts, 'build:src')).toContain(`npm run build:src:${target}`)
245
+ })
246
+
247
+ it('detects missing sheet setup and reversed themes order in configuration data', () => {
248
+ const project = {
249
+ test: {
250
+ setupFiles: ['./tests/setup.ts', './tests/setupBrowser.ts', './tests/setupStyles.ts'],
251
+ isolate: false,
252
+ },
253
+ }
254
+ const script =
255
+ 'vite build --config configs/src/vite.styles.config.ts && vite build --config configs/src/vite.themes.config.ts'
256
+ expect(inspectSheetConfiguration(project, script)).toEqual([])
257
+ expect(
258
+ inspectSheetConfiguration(
259
+ { test: { ...project.test, setupFiles: ['./tests/setup.ts', './tests/setupBrowser.ts'] } },
260
+ script,
261
+ ),
262
+ ).toContain('./tests/setupStyles.ts')
263
+ expect(
264
+ inspectSheetConfiguration(project, script.split(' && ').reverse().join(' && ')),
265
+ ).toContain('themes order')
266
+ })
267
+
268
+ it('resolves showcase and journey modes for every occupied application', async () => {
269
+ const applications = [
270
+ 'browser',
271
+ ...collectFrameworks(root)
272
+ .filter((face) => face.startsWith('app/'))
273
+ .map((face) => face.slice(4)),
274
+ ]
275
+ const manifest = readConfigRecord(
276
+ JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')),
277
+ )
278
+ const scripts = readConfigRecord(manifest.scripts)
279
+ const factories = readConfigRecord(rootConfiguration)
280
+ const showcases = ['configs/app/vite.showcase.config.ts'].filter(
281
+ (path) => existsSync(resolve(root, path)) || existsSync(resolve(root, 'showcase')),
282
+ )
283
+ for (const showcase of showcases) {
284
+ const wrapper = readFileSync(resolve(root, 'configs/app/vite.showcase.config.ts'), 'utf8')
285
+ expect(wrapper).toContain('defineConfig(({ mode }) => appShowcase(mode))')
286
+ expect(wrapper).not.toContain('dist/showcase')
287
+ expect(readFileSync(resolve(root, '.prettierignore'), 'utf8').split(/\r\n|\n/u)).toContain(
288
+ 'showcase/',
289
+ )
290
+ for (const application of applications) {
291
+ const script = 'build:showcase' + (application === 'browser' ? '' : ':' + application)
292
+ expect(readConfigScript(scripts, script)).toContain('configs/app/vite.showcase.config.ts')
293
+ for (const chain of manifest.private === true
294
+ ? []
295
+ : [readConfigScript(scripts, 'prepublishOnly')]) {
296
+ expect(chain).toContain(`npm run ${script}`)
297
+ expect(chain.indexOf('npm run build &&')).toBeLessThan(chain.indexOf(`npm run ${script}`))
298
+ }
299
+ const loaded = await loadConfigFromFile(
300
+ { command: 'build', mode: application },
301
+ resolve(root, showcase),
302
+ root,
303
+ 'silent',
304
+ )
305
+ if (loaded === null) throw new Error('Unloaded showcase wrapper')
306
+ const output = loaded.config.build?.outDir
307
+ if (output === undefined) throw new Error('Missing showcase output')
308
+ expect(relative(root, output)).toBe('showcase')
309
+ expect(() =>
310
+ expect(relative(root, resolve(root, 'dist/showcase'))).toBe('showcase'),
311
+ ).toThrow('expected')
312
+ expect(loaded.config.build?.emptyOutDir).toBe(false)
313
+ }
314
+ expect(scripts.show).toBeUndefined()
315
+ }
316
+ if (!existsSync(resolve(root, 'configs/app/vite.journey.config.ts'))) return
317
+ for (const application of applications) {
318
+ const loaded = await loadConfigFromFile(
319
+ { command: 'serve', mode: application },
320
+ resolve(root, 'configs/app/vite.journey.config.ts'),
321
+ root,
322
+ 'silent',
323
+ )
324
+ if (
325
+ loaded === null ||
326
+ !Array.isArray(loaded.config.test?.projects) ||
327
+ loaded.config.test.projects.length === 0
328
+ )
329
+ throw new Error('Missing journey projects')
330
+ const variants = new Set<string>()
331
+ const declared = new Set<string>()
332
+ for (const factory of loaded.config.test.projects) {
333
+ if (typeof factory !== 'function') throw new Error('Missing journey factory')
334
+ const project = readConfigRecord(
335
+ await Reflect.apply(factory, undefined, [{ command: 'serve', mode: application }]),
336
+ )
337
+ const test = readConfigRecord(project.test)
338
+ expect(test.include).toEqual([`tests/app/${application}/integration.test.ts`])
339
+ expect(test.exclude).toEqual([])
340
+ const provide = readConfigRecord(test.provide)
341
+ if (typeof provide.variant !== 'string' || !Array.isArray(provide.variants))
342
+ throw new Error('Missing journey variant values')
343
+ variants.add(provide.variant)
344
+ for (const value of provide.variants) {
345
+ const variant = readConfigRecord(value)
346
+ if (typeof variant.name !== 'string') throw new Error('Unnamed journey variant')
347
+ declared.add(variant.name)
348
+ }
349
+ const selected = provide.variants
350
+ .map(readConfigRecord)
351
+ .find((variant) => variant.name === provide.variant)
352
+ if (selected === undefined) throw new Error('Missing selected variant')
353
+ expect(readConfigRecord(test.browser).viewport).toEqual({
354
+ width: selected.width,
355
+ height: selected.height,
356
+ })
357
+ expect(typeof provide.capture).toBe('boolean')
358
+ expect(readConfigRecord(project.optimizeDeps).include).toEqual(
359
+ expect.arrayContaining(['@orkestrel/test', '@orkestrel/test/browser']),
360
+ )
361
+ }
362
+ expect(variants).toEqual(declared)
363
+ }
364
+ const factory = factories.appJourney
365
+ if (typeof factory !== 'function') throw new Error('Missing application journey')
366
+ expect(() =>
367
+ Reflect.apply(factory, undefined, [
368
+ { name: 'desktop', width: 1280, height: 800 },
369
+ [],
370
+ 'absent',
371
+ ]),
372
+ ).toThrow('not declared')
373
+ })
374
+
375
+ it('collects both browser setup proofs and optimizes every selected browser factory', () => {
376
+ const factories = readConfigRecord(rootConfiguration)
377
+ const manifest = readConfigRecord(
378
+ JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')),
379
+ )
380
+ const dependencies = {
381
+ ...readConfigRecord(manifest.dependencies ?? {}),
382
+ ...readConfigRecord(manifest.devDependencies ?? {}),
383
+ }
384
+ for (const name of [
385
+ 'srcBrowser',
386
+ 'appBrowser',
387
+ 'srcVue',
388
+ 'appVue',
389
+ 'setupBrowser',
390
+ 'sheetProject',
391
+ 'integration',
392
+ ]) {
393
+ const factory = factories[name]
394
+ if (typeof factory !== 'function') continue
395
+ const project = readConfigRecord(
396
+ Reflect.apply(factory, undefined, name === 'sheetProject' ? ['src:styles'] : []),
397
+ )
398
+ const test = readConfigRecord(project.test)
399
+ if (readConfigRecord(test.browser ?? {}).enabled !== true) continue
400
+ const include = readConfigRecord(project.optimizeDeps).include
401
+ expect(include).toEqual(
402
+ expect.arrayContaining([
403
+ '@orkestrel/test',
404
+ '@orkestrel/test/browser',
405
+ ...(dependencies['@orkestrel/contract'] === undefined ? [] : ['@orkestrel/contract']),
406
+ ]),
407
+ )
408
+ }
409
+ for (const proof of ['tests/setupBrowser.test.ts', 'tests/setupStyles.test.ts'].filter((path) =>
410
+ existsSync(resolve(root, path)),
411
+ )) {
412
+ const factory = factories.setupBrowser
413
+ if (typeof factory !== 'function') throw new Error('Missing browser setup project')
414
+ expect(
415
+ readConfigRecord(readConfigRecord(Reflect.apply(factory, undefined, [])).test).include,
416
+ ).toContain(proof)
417
+ expect(
418
+ readConfigRecord(readConfigRecord(Reflect.apply(factory, undefined, [])).test).include,
419
+ ).toEqual(expect.arrayContaining(['tests/setupBrowser.test.ts', 'tests/setupStyles.test.ts']))
420
+ }
421
+ if (typeof factories.setup !== 'function') return
422
+ expect(
423
+ readConfigRecord(readConfigRecord(Reflect.apply(factories.setup, undefined, [])).test)
424
+ .exclude,
425
+ ).toEqual(expect.arrayContaining(['tests/setupBrowser.test.ts', 'tests/setupStyles.test.ts']))
426
+ })
427
+ })
428
+
67
429
  // The `src` axis a declaration roll-up reads, derived from a recognized source environment that is
68
430
  // a physical directory rather than from a `src` entry of any shape. A regular file, an empty
69
431
  // directory, a directory holding no recognized environment, a symbolic link, and an entry this host
@@ -113,8 +475,15 @@ describe('root configuration', () => {
113
475
  if (existsSync(resolve(root, path))) required.set(`@${axis}/${environment}`, path)
114
476
  }
115
477
  }
116
- if (required.size === 0) throw new Error('The workspace selects no alias target')
478
+ for (const face of collectSheets(root)) required.set(`@src/${face}`, `src/${face}/index.ts`)
479
+ for (const face of collectFrameworks(root)) required.set(`@${face}`, `${face}/index.ts`)
117
480
  const declared = new Map(Object.entries(tsconfig.compilerOptions.paths))
481
+ expect(required.size > 0 || existsSync(resolve(root, 'src/styles/themes/sheet.ts'))).toBe(true)
482
+ if (required.size === 0) {
483
+ if (declared.size !== 0 || Object.keys(aliases).length !== 0)
484
+ throw new Error('The themes-only workspace declares an unexpected alias')
485
+ return
486
+ }
118
487
  const absent = new Map<string, readonly string[]>()
119
488
  expect(() => {
120
489
  for (const key of required.keys()) {
@@ -138,7 +507,7 @@ describe('root configuration', () => {
138
507
  string,
139
508
  {
140
509
  readonly benchmark?: readonly string[]
141
- readonly include: string
510
+ readonly include: string | readonly string[]
142
511
  readonly parallel?: boolean
143
512
  readonly pool?: string
144
513
  readonly setup: readonly string[]
@@ -203,21 +572,35 @@ describe('root configuration', () => {
203
572
  'integration',
204
573
  ]) {
205
574
  if (!existsSync(resolve(root, `tests/${label}.test.ts`))) continue
575
+ const setup = ['./tests/setup.ts']
576
+ if (label === 'conformance') setup.push('./tests/setupServer.ts')
577
+ if (
578
+ label === 'integration' &&
579
+ (collectSheets(root).length > 0 ||
580
+ existsSync(resolve(root, 'src/styles/themes/index.scss')))
581
+ )
582
+ setup.push('./tests/setupBrowser.ts', './tests/setupStyles.ts')
206
583
  expected.set(label, {
207
584
  include: `tests/${label}.test.ts`,
208
- setup: ['./tests/setup.ts'],
585
+ setup,
209
586
  })
210
587
  }
211
- // The setup project is selected by any proof named `setup*.test.ts` directly under
212
- // `tests`, so it is the one derived project whose include is a pattern rather than
213
- // the proof's own path. Reading it from the same glob the generator reads keeps a
214
- // registered project inside this gate instead of beside it.
215
- if (globSync('tests/setup*.test.ts', { cwd: root }).length > 0) {
588
+ const browserProofs = ['tests/setupBrowser.test.ts', 'tests/setupStyles.test.ts']
589
+ const setupProofs = globSync('tests/setup*.test.ts', { cwd: root })
590
+ .map((path) => path.replaceAll('\\', '/'))
591
+ .filter((path) => /^tests\/setup[^/]*\.test\.ts$/u.test(path))
592
+ if (setupProofs.some((path) => !browserProofs.includes(path))) {
216
593
  expected.set('setup', {
217
594
  include: 'tests/setup*.test.ts',
218
595
  setup: ['./tests/setup.ts'],
219
596
  })
220
597
  }
598
+ if (setupProofs.some((path) => browserProofs.includes(path))) {
599
+ expected.set('setup:browser', {
600
+ include: browserProofs,
601
+ setup: ['./tests/setup.ts', './tests/setupBrowser.ts'],
602
+ })
603
+ }
221
604
  // The live-service project covers a directory rather than one proof, so its
222
605
  // readiness module is the fact that selects it. A suite beneath
223
606
  // `tests/service` with no setup module is a project nothing configures.
@@ -269,7 +652,7 @@ describe('root configuration', () => {
269
652
  string,
270
653
  {
271
654
  readonly benchmark?: readonly string[]
272
- readonly include: string
655
+ readonly include: string | readonly string[]
273
656
  readonly parallel?: boolean
274
657
  readonly pool?: string
275
658
  readonly setup: readonly string[]
@@ -323,9 +706,14 @@ describe('root configuration', () => {
323
706
  const effective = include.filter(
324
707
  (path) => typeof path === 'string' && (!Array.isArray(exclude) || !exclude.includes(path)),
325
708
  )
326
- if (effective.length !== 1 || typeof effective[0] !== 'string') {
327
- throw new Error(`${label} does not resolve to one effective include`)
709
+ if (
710
+ effective.length !== (label === 'setup:browser' ? 2 : 1) ||
711
+ typeof effective[0] !== 'string'
712
+ ) {
713
+ throw new Error(`${label} does not resolve to its required includes`)
328
714
  }
715
+ for (const required of label === 'setup' ? browserProofs : [])
716
+ expect(exclude).toContain(required)
329
717
  if (label === 'probe') {
330
718
  const benchmark: unknown = Object.getOwnPropertyDescriptor(test, 'benchmark')?.value
331
719
  const parallel: unknown = Object.getOwnPropertyDescriptor(test, 'fileParallelism')?.value
@@ -354,13 +742,28 @@ describe('root configuration', () => {
354
742
  })
355
743
  continue
356
744
  }
357
- configured.set(label, { include: effective[0], setup: [...new Set(setup)] })
745
+ configured.set(label, {
746
+ include: label === 'setup:browser' ? effective : effective[0],
747
+ setup: [...new Set(setup)],
748
+ })
358
749
  }
359
750
 
360
751
  // Required projects come from present source and test paths. Each factory is selected by name
361
752
  // before its result is read. Extra factories are ignored without validating their result shape.
362
753
  expect(extraLoaded).toBe(false)
363
754
  for (const [label, project] of expected) expect(configured.get(label)).toStrictEqual(project)
755
+ for (const label of ['setup', 'setup:browser']) {
756
+ const name = label === 'setup' ? 'setup' : 'setupBrowser'
757
+ expect(projects.some((entry) => typeof entry === 'function' && entry.name === name)).toBe(
758
+ expected.has(label),
759
+ )
760
+ if (!expected.has(label)) continue
761
+ const missingSetup = new Map(configured)
762
+ missingSetup.delete(label)
763
+ expect(() => expect(missingSetup.get(label)).toStrictEqual(expected.get(label))).toThrow(
764
+ 'expected',
765
+ )
766
+ }
364
767
 
365
768
  const missing = new Map(configured)
366
769
  expect(missing.delete('probe')).toBe(true)
@@ -378,7 +781,7 @@ describe('root configuration', () => {
378
781
  }).toThrow(/strictly equal/u)
379
782
  })
380
783
 
381
- it('returns the invocation mode and no other invocation field from every registered project factory', () => {
784
+ it('loads file projects and returns only the invocation mode from registered project factories', async () => {
382
785
  const projects = configuration.test?.projects
383
786
  if (!Array.isArray(projects)) throw new Error('The root configuration carries no projects')
384
787
  if (projects.length === 0) throw new Error('The root configuration registers no project')
@@ -402,7 +805,32 @@ describe('root configuration', () => {
402
805
  const spreading = Object.defineProperty((record: object) => ({ ...record, test }), 'name', {
403
806
  value: 'spreading',
404
807
  })
405
- const entries: readonly unknown[] = [...projects, ignoring, spreading, { test }]
808
+ const wrappers = projects.filter((entry) => typeof entry === 'string')
809
+ const sheets = collectSheets(root)
810
+ const expectedWrappers = sheets.map((face) => `./configs/src/vite.${face}.config.ts`)
811
+ if (!sheets.includes('styles') && existsSync(resolve(root, 'src/styles/themes/index.scss')))
812
+ expectedWrappers.push('./configs/src/vite.themes.config.ts')
813
+ expect([...wrappers].sort()).toEqual(expectedWrappers.sort())
814
+ for (const wrapper of wrappers) {
815
+ const loaded = await loadConfigFromFile(
816
+ { command: 'serve', mode: invocation.mode },
817
+ resolve(root, wrapper),
818
+ root,
819
+ 'silent',
820
+ )
821
+ if (loaded === null) throw new Error(`Unloaded file project ${wrapper}`)
822
+ const face = /^\.\/configs\/src\/vite\.([^.]+)\.config\.ts$/u.exec(wrapper)?.[1]
823
+ if (face === undefined) throw new Error(`Invalid file project ${wrapper}`)
824
+ expect(loaded.config.test?.name).toMatchObject({ label: `src:${face}` })
825
+ expect(loaded.config.test?.include).toEqual([
826
+ `tests/src/${face === 'themes' ? 'styles/themes' : face}/**/*.test.ts`,
827
+ ])
828
+ expect(() =>
829
+ expect({ test: {} }).toMatchObject({ test: { name: { label: `src:${face}` } } }),
830
+ ).toThrow('expected')
831
+ }
832
+ const factories = projects.filter((entry) => typeof entry !== 'string')
833
+ const entries: readonly unknown[] = [...factories, ignoring, spreading, { test }]
406
834
  const readings = entries.map((entry) => {
407
835
  if (typeof entry !== 'function') {
408
836
  return { name: undefined, callable: false, mode: undefined, leaked: [] }
@@ -421,13 +849,13 @@ describe('root configuration', () => {
421
849
  }
422
850
  })
423
851
  const forwarded = { callable: true, mode: 'sentinel-mode', leaked: [] }
424
- expect(readings.slice(0, projects.length)).toStrictEqual(
425
- projects.map((entry) => ({
852
+ expect(readings.slice(0, factories.length)).toStrictEqual(
853
+ factories.map((entry) => ({
426
854
  name: typeof entry === 'function' ? entry.name : undefined,
427
855
  ...forwarded,
428
856
  })),
429
857
  )
430
- expect(readings.slice(projects.length)).toStrictEqual([
858
+ expect(readings.slice(factories.length)).toStrictEqual([
431
859
  { name: 'ignoring', callable: true, mode: undefined, leaked: [] },
432
860
  {
433
861
  name: 'spreading',
@@ -437,7 +865,7 @@ describe('root configuration', () => {
437
865
  },
438
866
  { name: undefined, callable: false, mode: undefined, leaked: [] },
439
867
  ])
440
- for (const reading of readings.slice(projects.length)) {
868
+ for (const reading of readings.slice(factories.length)) {
441
869
  expect({ ...reading, name: undefined }).not.toStrictEqual({ name: undefined, ...forwarded })
442
870
  }
443
871
 
@@ -462,6 +890,9 @@ describe('root configuration', () => {
462
890
  }
463
891
  }
464
892
  }
893
+ for (const face of collectFrameworks(root)) {
894
+ required.push(`configs/${face.replace('/', '/tsconfig.')}.json`)
895
+ }
465
896
  if (existsSync(resolve(root, 'src/bin'))) {
466
897
  required.push('configs/src/tsconfig.bin.json', 'configs/src/vite.bin.config.ts')
467
898
  }
@@ -558,7 +989,7 @@ describe('root configuration', () => {
558
989
  environment === 'bin'
559
990
  ? 'dist/bin'
560
991
  : environment === 'showcase'
561
- ? 'dist/showcase'
992
+ ? 'showcase'
562
993
  : `dist/${axis}/${environment}`
563
994
  if (resolve(root, output) !== resolve(root, expected)) {
564
995
  throw new Error(`${wrapper} resolves to the wrong output`)
@@ -567,7 +998,7 @@ describe('root configuration', () => {
567
998
  }
568
999
 
569
1000
  const tsconfigMatch =
570
- /^configs\/(src|app)\/tsconfig\.(core|browser|server|bin)\.json$/u.exec(wrapper)
1001
+ /^configs\/(src|app)\/tsconfig\.(core|browser|server|bin|vue)\.json$/u.exec(wrapper)
571
1002
  if (tsconfigMatch === null) {
572
1003
  throw new Error(`${wrapper} is not a required target wrapper`)
573
1004
  }
@@ -591,19 +1022,22 @@ describe('root configuration', () => {
591
1022
  const expectedLib =
592
1023
  environment === 'core'
593
1024
  ? ['ESNext', 'WebWorker']
594
- : environment === 'browser'
1025
+ : environment === 'browser' || environment === 'vue'
595
1026
  ? ['ESNext', 'DOM', 'DOM.Iterable']
596
1027
  : ['ESNext']
597
1028
  const expectedTypes =
598
1029
  environment === 'core'
599
1030
  ? []
600
- : environment === 'browser'
601
- ? axis === 'app'
1031
+ : environment === 'browser' || environment === 'vue'
1032
+ ? axis === 'app' && environment === 'vue'
602
1033
  ? ['vite/client', 'vue']
603
1034
  : ['vite/client']
604
1035
  : ['node']
605
1036
  expect(lib).toStrictEqual(expectedLib)
606
1037
  expect(types).toStrictEqual(expectedTypes)
1038
+ for (const control of axis === 'app' && environment === 'vue' ? [['vite/client']] : []) {
1039
+ expect(() => expect(control).toStrictEqual(expectedTypes)).toThrow('expected')
1040
+ }
607
1041
  }
608
1042
  for (const tests of journeys) {
609
1043
  const names: string[] = []
@@ -827,7 +1261,8 @@ describe('root configuration', () => {
827
1261
  if (typeof stage !== 'function') {
828
1262
  throw new Error('The server helper module exports no stageInventory function')
829
1263
  }
830
- const workspace = mkdtempSync(join(root, 'host-inventory-'))
1264
+ const scratch = createPolicyScratch({ prefix: 'host-inventory-' })
1265
+ const workspace = scratch.path
831
1266
  try {
832
1267
  const generated = join(workspace, 'host.json')
833
1268
  Reflect.apply(stage, undefined, [root, generated])
@@ -877,7 +1312,7 @@ describe('root configuration', () => {
877
1312
  }
878
1313
  console.info(`host-inventory: entries=${generatedIndex.size}`)
879
1314
  } finally {
880
- rmSync(workspace, { recursive: true, force: true })
1315
+ scratch.destroy()
881
1316
  }
882
1317
  } finally {
883
1318
  await server.close()
@@ -1005,7 +1440,7 @@ describe('policy plugin', () => {
1005
1440
  code: 'function createProjector() { return () => 1 }',
1006
1441
  },
1007
1442
  {
1008
- name: 'accepts the sanctioned policy visitor delegation',
1443
+ name: 'accepts callbacks in a returned object literal',
1009
1444
  code: [
1010
1445
  'function reportNode(context, node) { context.report({ node }) }',
1011
1446
  'const RULE = {',
@@ -1023,6 +1458,45 @@ describe('policy plugin', () => {
1023
1458
  name: 'accepts class accessors inside a factory',
1024
1459
  code: 'function createAccessor() { class Accessor { get value() { return 1 } set value(value) { consume(value) } } return Accessor }',
1025
1460
  },
1461
+ {
1462
+ name: 'accepts the event-map option in a function body',
1463
+ code: `function configure() {
1464
+ createSomething({
1465
+ on: {
1466
+ thing: function that() {
1467
+ console.log('that thing')
1468
+ },
1469
+ other: () => {
1470
+ console.log('this other')
1471
+ },
1472
+ },
1473
+ })
1474
+ }`,
1475
+ },
1476
+ {
1477
+ name: 'accepts an array element in a call argument',
1478
+ code: 'function configure() { return create([() => 1]) }',
1479
+ },
1480
+ {
1481
+ name: 'accepts methods and accessors in admitted object positions',
1482
+ code: 'function configure() { create({ on: { thing() { return 1 } } }); new Factory({ get value() { return 1 }, set value(value) { consume(value) } }); return { read() { return 1 } } }',
1483
+ },
1484
+ {
1485
+ name: 'accepts an object member returned from a function',
1486
+ code: 'function configure() { return { read: function readValue() { return 1 } } }',
1487
+ },
1488
+ {
1489
+ name: 'accepts an object member in a parenthesized arrow body',
1490
+ code: 'const configure = () => ({ read: () => 1 })',
1491
+ },
1492
+ {
1493
+ name: 'accepts a parenthesized member in a constructor argument',
1494
+ code: 'function configure() { return new Factory({ read: (() => 1) }) }',
1495
+ },
1496
+ {
1497
+ name: 'accepts a named function expression argument',
1498
+ code: 'function projectValue() { return read(function readValue() { return 1 }) }',
1499
+ },
1026
1500
  ],
1027
1501
  invalid: [
1028
1502
  {
@@ -1054,11 +1528,6 @@ describe('policy plugin', () => {
1054
1528
  code: 'function projectValue() { const readValue = () => 1; return readValue() }',
1055
1529
  errors: [{ messageId: 'nested' }],
1056
1530
  },
1057
- {
1058
- name: 'rejects a named function expression argument',
1059
- code: 'function projectValue() { return read(function readValue() { return 1 }) }',
1060
- errors: [{ messageId: 'nested' }],
1061
- },
1062
1531
  {
1063
1532
  name: 'rejects a callback parameter default function',
1064
1533
  code: 'function projectValue() { return values.map((value = () => 1) => value()) }',
@@ -1074,6 +1543,48 @@ describe('policy plugin', () => {
1074
1543
  code: 'class Project { read() { const value = () => 1; return value() } }',
1075
1544
  errors: [{ messageId: 'nested' }],
1076
1545
  },
1546
+ {
1547
+ name: 'rejects an object literal bound locally before being passed',
1548
+ code: 'function configure() { const options = { on: { thing: () => 1 } }; return create(options) }',
1549
+ errors: [{ messageId: 'nested' }],
1550
+ },
1551
+ {
1552
+ name: 'rejects an object method bound locally before being passed',
1553
+ code: 'function configure() { const options = { on: { thing() { return 1 } } }; return create(options) }',
1554
+ errors: [{ messageId: 'nested' }],
1555
+ },
1556
+ {
1557
+ name: 'rejects object accessors bound locally before being passed',
1558
+ code: 'function configure() { const options = { get value() { return 1 }, set value(value) { consume(value) } }; return create(options) }',
1559
+ errors: [{ messageId: 'nested' }, { messageId: 'nested' }],
1560
+ },
1561
+ {
1562
+ name: 'rejects an object member reached through a spread',
1563
+ code: 'function configure() { return create({ ...{ thing: () => 1 } }) }',
1564
+ errors: [{ messageId: 'nested' }],
1565
+ },
1566
+ {
1567
+ name: 'rejects a computed property value',
1568
+ code: 'function configure() { return create({ [key]: () => 1 }) }',
1569
+ errors: [{ messageId: 'nested' }],
1570
+ },
1571
+ {
1572
+ name: 'rejects a local binding inside a getter in an argument',
1573
+ code: 'function configure() { return create({ get value() { const read = () => 1; return read() } }) }',
1574
+ errors: [{ messageId: 'nested' }],
1575
+ },
1576
+ {
1577
+ name: 'rejects only the local binding inside an admitted member',
1578
+ code: [
1579
+ 'function configure() {',
1580
+ 'create({ on: { thing: () => {',
1581
+ 'const read = () => 1;',
1582
+ 'return read()',
1583
+ '} } })',
1584
+ '}',
1585
+ ].join('\n'),
1586
+ errors: [{ messageId: 'nested', line: 3, column: 13 }],
1587
+ },
1077
1588
  ],
1078
1589
  })
1079
1590
 
@@ -1874,6 +2385,302 @@ describe('policy plugin', () => {
1874
2385
  expect(CENTRAL_SOURCE_FILES).toContain('handlers.ts')
1875
2386
  })
1876
2387
 
2388
+ it('matches every isolated import pattern with refused and admitted fixtures', () => {
2389
+ const fixture = createPolicyScratch({ prefix: 'propagation-patterns-' })
2390
+ const scratch = fixture.path
2391
+ try {
2392
+ const configured = readConfigRecord(
2393
+ JSON.parse(readFileSync(resolve(root, '.oxlintrc.json'), 'utf8')),
2394
+ )
2395
+ if (!Array.isArray(configured.overrides)) throw new Error('Missing overrides')
2396
+ const overrides: object[] = []
2397
+ const expected = new Map<string, boolean>()
2398
+ for (const [blockIndex, value] of configured.overrides.entries()) {
2399
+ const block = readConfigRecord(value)
2400
+ const rule = readConfigRecord(block.rules)['no-restricted-imports']
2401
+ if (!Array.isArray(rule)) continue
2402
+ const patterns = readConfigRecord(rule[1]).patterns
2403
+ if (!Array.isArray(patterns)) throw new Error('Missing restriction patterns')
2404
+ for (const [patternIndex, patternValue] of patterns.entries()) {
2405
+ const pattern = readConfigRecord(patternValue)
2406
+ const message = pattern.message
2407
+ if (typeof message !== 'string') throw new Error('Missing pattern message')
2408
+ const sources: ReadonlyArray<readonly [string, boolean]> = message.includes('URL schemes')
2409
+ ? [
2410
+ ['https://host/x', true],
2411
+ ['data:text/plain,x', true],
2412
+ ['node:fs', false],
2413
+ ['n:entry', true],
2414
+ ['no:entry', true],
2415
+ ['nod:entry', true],
2416
+ ['nodea:entry', true],
2417
+ ['1bad:entry', false],
2418
+ ['./data:entry', false],
2419
+ ]
2420
+ : message.includes('noncanonical dot')
2421
+ ? [
2422
+ ['../core/../server/index.js', true],
2423
+ ['../core/./index.js', true],
2424
+ ['../core/index.js', false],
2425
+ ['./index.js', false],
2426
+ ['../../core/index.js', false],
2427
+ ['.../../index.js', true],
2428
+ ['.name/../index.js', true],
2429
+ ]
2430
+ : message.includes('sibling sheet')
2431
+ ? [
2432
+ ['@src/print', true],
2433
+ ['../print/index.js', true],
2434
+ ['@src/core', false],
2435
+ ['@src/browser', false],
2436
+ ['@src/vue', false],
2437
+ ['@app/core', false],
2438
+ ['@app/browser', false],
2439
+ ['@src/corex', true],
2440
+ ['@src/', true],
2441
+ ['../src/print/index.js', true],
2442
+ ['../browser?x', true],
2443
+ ['../print.ext/index.js', false],
2444
+ ...(Array.isArray(block.files) &&
2445
+ block.files.some(
2446
+ (file: unknown) => typeof file === 'string' && file.startsWith('app/vue/'),
2447
+ )
2448
+ ? [['@app/print', true] as const]
2449
+ : []),
2450
+ ]
2451
+ : [
2452
+ [
2453
+ message.includes('absolute paths')
2454
+ ? '/machine/module.js'
2455
+ : message.includes('normalized traversal')
2456
+ ? './../core/index.js'
2457
+ : message.includes('forward slashes')
2458
+ ? '..\\core\\index.js'
2459
+ : message.includes('compiler API')
2460
+ ? 'typescript'
2461
+ : message.includes('private app')
2462
+ ? '@app/core'
2463
+ : message.includes('stylesheets')
2464
+ ? './index.css'
2465
+ : message.includes('host-independent') ||
2466
+ message.includes('Node or server')
2467
+ ? 'node:fs'
2468
+ : message.includes('Vue extension')
2469
+ ? '@src/vue'
2470
+ : '@src/browser',
2471
+ true,
2472
+ ],
2473
+ ]
2474
+ const owner =
2475
+ Array.isArray(block.files) && typeof block.files[0] === 'string'
2476
+ ? block.files[0].split('/**')[0]
2477
+ : undefined
2478
+ if (owner === undefined) throw new Error('Missing pattern owner')
2479
+ const paths: string[] = []
2480
+ for (const [index, [source, refusal]] of [
2481
+ ...sources,
2482
+ ['./index.js', false] as const,
2483
+ ].entries()) {
2484
+ const path = `${owner}/pattern${blockIndex}_${patternIndex}_${index}.ts`
2485
+ mkdirSync(dirname(resolve(scratch, path)), { recursive: true })
2486
+ writeFileSync(
2487
+ resolve(scratch, path),
2488
+ 'import * as boundary from ' + JSON.stringify(source) + '\nvoid boundary\ndebugger\n',
2489
+ )
2490
+ expected.set(path, refusal)
2491
+ paths.push(path)
2492
+ }
2493
+ overrides.push({
2494
+ files: paths,
2495
+ rules: { 'no-restricted-imports': ['error', { patterns: [pattern] }] },
2496
+ })
2497
+ }
2498
+ }
2499
+ writeFileSync(
2500
+ resolve(scratch, '.oxlintrc.json'),
2501
+ JSON.stringify({ rules: { 'no-debugger': 'error' }, overrides }),
2502
+ )
2503
+ const codes = readImportDiagnostics(scratch, [...expected.keys()])
2504
+ const readings = [...expected].map(([path, refusal]) => ({
2505
+ path,
2506
+ expected: refusal,
2507
+ actual: codes.has('eslint(no-restricted-imports) ' + path),
2508
+ }))
2509
+ expect(readings.filter((reading) => reading.expected !== reading.actual)).toEqual([])
2510
+ writeFileSync(
2511
+ resolve(scratch, '.oxlintrc.json'),
2512
+ JSON.stringify({ rules: { 'no-debugger': 'error', 'no-restricted-imports': 'off' } }),
2513
+ )
2514
+ const disabled = readImportDiagnostics(scratch, [...expected.keys()])
2515
+ expect(() =>
2516
+ expect(
2517
+ [...expected].filter(
2518
+ ([path, refusal]) => disabled.has('eslint(no-restricted-imports) ' + path) !== refusal,
2519
+ ),
2520
+ ).toEqual([]),
2521
+ ).toThrow('deeply equal')
2522
+ } finally {
2523
+ fixture.destroy()
2524
+ }
2525
+ })
2526
+
2527
+ it('fences environment and root imports through the real linter and fails with the restriction disabled', () => {
2528
+ const fixture = createPolicyScratch({ prefix: 'propagation-imports-' })
2529
+ const scratch = fixture.path
2530
+ try {
2531
+ mkdirSync(resolve(scratch, 'configs'))
2532
+ writeFileSync(
2533
+ resolve(scratch, 'configs/policy.ts'),
2534
+ readFileSync(resolve(root, 'configs/policy.ts')),
2535
+ )
2536
+ const text = readFileSync(resolve(root, '.oxlintrc.json'), 'utf8')
2537
+ writeFileSync(resolve(scratch, '.oxlintrc.json'), text)
2538
+ const expected = new Map<string, boolean>()
2539
+ for (const owner of [
2540
+ 'src/vue',
2541
+ 'app/vue',
2542
+ 'src/browser',
2543
+ 'app/browser',
2544
+ 'src/core',
2545
+ 'app/core',
2546
+ 'src/server',
2547
+ 'app/server',
2548
+ 'src/bin',
2549
+ ]) {
2550
+ const refused = owner.endsWith('/vue')
2551
+ ? [
2552
+ 'node:fs',
2553
+ '@src/server',
2554
+ '../server/index.js',
2555
+ '@src/print',
2556
+ '../print/index.js',
2557
+ ...(owner.startsWith('src/')
2558
+ ? ['@app/core', '../../app/core/index.js']
2559
+ : ['@app/server', '@app/print']),
2560
+ ]
2561
+ : [
2562
+ 'vue',
2563
+ 'vue/runtime-dom',
2564
+ '@vue/runtime-core',
2565
+ '@vitejs/plugin-vue',
2566
+ '@src/vue',
2567
+ '@orkestrel/sample/vue',
2568
+ '@app/vue',
2569
+ '../vue/index.js',
2570
+ '../../src/vue/index.js',
2571
+ '../../app/vue/index.js',
2572
+ ]
2573
+ const admitted = owner.endsWith('/vue')
2574
+ ? [
2575
+ '@src/core',
2576
+ '@src/browser',
2577
+ '../core/index.js',
2578
+ '../browser/index.js',
2579
+ 'vue',
2580
+ '@vue/runtime-core',
2581
+ ...(owner.startsWith('app/')
2582
+ ? ['@app/core', '@app/browser', '@src/vue', '../../src/vue/index.js']
2583
+ : []),
2584
+ ]
2585
+ : ['@src/core']
2586
+ for (const [sources, refusal] of [
2587
+ [
2588
+ [
2589
+ ...refused,
2590
+ 'https://host/x',
2591
+ 'data:text/plain,x',
2592
+ '../core/../server/index.js',
2593
+ '../core/./index.js',
2594
+ ],
2595
+ true,
2596
+ ],
2597
+ [
2598
+ [
2599
+ ...admitted,
2600
+ './index.js',
2601
+ '../core/index.js',
2602
+ ...(owner.endsWith('/server') || owner === 'src/bin' ? ['node:fs'] : []),
2603
+ ],
2604
+ false,
2605
+ ],
2606
+ ] as const) {
2607
+ for (const [index, source] of sources.entries()) {
2608
+ const file = owner + '/' + (refusal ? 'refused' : 'admitted') + index + '.ts'
2609
+ mkdirSync(dirname(resolve(scratch, file)), { recursive: true })
2610
+ writeFileSync(
2611
+ resolve(scratch, file),
2612
+ 'import * as boundary from ' + JSON.stringify(source) + '\nvoid boundary\ndebugger\n',
2613
+ )
2614
+ expected.set(file, refusal)
2615
+ }
2616
+ }
2617
+ }
2618
+ for (const [file, source, refusal] of [
2619
+ ['probe.config.ts', 'typescript', true],
2620
+ ['allowed.config.ts', 'node:fs', false],
2621
+ ['src/core/compiler.ts', 'typescript', true],
2622
+ ['tests/compiler.ts', 'typescript', true],
2623
+ ['configs/compiler.ts', 'typescript', true],
2624
+ ['scripts/compiler.ts', 'typescript', true],
2625
+ ] as const) {
2626
+ mkdirSync(dirname(resolve(scratch, file)), { recursive: true })
2627
+ writeFileSync(
2628
+ resolve(scratch, file),
2629
+ 'import * as boundary from ' + JSON.stringify(source) + '\nvoid boundary\ndebugger\n',
2630
+ )
2631
+ expected.set(file, refusal)
2632
+ }
2633
+ const reports: Array<ReadonlySet<string>> = []
2634
+ for (const disabled of [false, true]) {
2635
+ if (disabled) {
2636
+ const parsed: unknown = JSON.parse(text)
2637
+ if (typeof parsed !== 'object' || parsed === null)
2638
+ throw new Error('Missing lint configuration')
2639
+ const overrides: unknown = Object.getOwnPropertyDescriptor(parsed, 'overrides')?.value
2640
+ if (!Array.isArray(overrides)) throw new Error('Missing lint overrides')
2641
+ for (const block of overrides) {
2642
+ if (typeof block !== 'object' || block === null) continue
2643
+ const files: unknown = Object.getOwnPropertyDescriptor(block, 'files')?.value
2644
+ if (
2645
+ !Array.isArray(files) ||
2646
+ !files.some(
2647
+ (file: unknown) => typeof file === 'string' && file.startsWith('src/vue/'),
2648
+ )
2649
+ )
2650
+ continue
2651
+ const rules: unknown = Object.getOwnPropertyDescriptor(block, 'rules')?.value
2652
+ if (typeof rules !== 'object' || rules === null) throw new Error('Missing Vue rules')
2653
+ Reflect.set(rules, 'no-restricted-imports', 'off')
2654
+ }
2655
+ writeFileSync(resolve(scratch, '.oxlintrc.json'), JSON.stringify(parsed))
2656
+ }
2657
+ const codes = readImportDiagnostics(scratch, [...expected.keys()])
2658
+ reports.push(codes)
2659
+ }
2660
+ const [enabled, disabled] = reports
2661
+ if (enabled === undefined || disabled === undefined)
2662
+ throw new Error('Missing boundary readings')
2663
+ const readings = [...expected].map(([path, refusal]) => ({
2664
+ path,
2665
+ expected: refusal,
2666
+ actual: enabled.has('eslint(no-restricted-imports) ' + path),
2667
+ }))
2668
+ expect(readings.filter((reading) => reading.actual !== reading.expected)).toEqual([])
2669
+ const control = [...expected].map(([path, refusal]) => ({
2670
+ path,
2671
+ expected: refusal,
2672
+ actual: disabled.has('eslint(no-restricted-imports) ' + path),
2673
+ }))
2674
+ expect(() =>
2675
+ expect(control.filter((reading) => reading.actual !== reading.expected)).toEqual([]),
2676
+ ).toThrow('deeply equal')
2677
+ expect(control).toContainEqual({ path: 'src/vue/refused3.ts', expected: true, actual: false })
2678
+ expect(control).toContainEqual({ path: 'src/vue/refused8.ts', expected: true, actual: false })
2679
+ } finally {
2680
+ fixture.destroy()
2681
+ }
2682
+ })
2683
+
1877
2684
  it('enables every plugin rule over the population its law names', () => {
1878
2685
  const parsed: unknown = JSON.parse(readFileSync(resolve(root, '.oxlintrc.json'), 'utf8'))
1879
2686
  const rules: unknown = Object.getOwnPropertyDescriptor(policyPlugin, 'rules')?.value
@@ -2063,6 +2870,129 @@ describe('policy plugin', () => {
2063
2870
  })
2064
2871
 
2065
2872
  describe('configuration helpers', () => {
2873
+ it('resolves declared application modes and refuses undeclared modes', () => {
2874
+ const applications = { browser: true, vue: true }
2875
+ for (const mode of [undefined, 'development', 'production', 'test', 'browser']) {
2876
+ expect(configHelpers.resolveApplication(mode, applications)).toBe('browser')
2877
+ }
2878
+ expect(configHelpers.resolveApplication('vue', applications)).toBe('vue')
2879
+ expect(() => configHelpers.resolveApplication('vue', { browser: true })).toThrow(
2880
+ 'The application mode "vue" is not declared.',
2881
+ )
2882
+ expect(() => configHelpers.resolveApplication('preview', applications)).toThrow(
2883
+ 'The application mode "preview" is not declared.',
2884
+ )
2885
+ expect(() => configHelpers.resolveApplication('toString', applications)).toThrow(
2886
+ 'The application mode "toString" is not declared.',
2887
+ )
2888
+ })
2889
+
2890
+ it('hashes page bytes against SHA-256 vectors without its stamp line', () => {
2891
+ expect(configHelpers.computeStamp('')).toBe(
2892
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
2893
+ )
2894
+ expect(configHelpers.computeStamp('abc')).toBe(
2895
+ 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
2896
+ )
2897
+ expect(configHelpers.computeStamp('<meta name="build-id" content="old" />\nabc')).toBe(
2898
+ configHelpers.computeStamp('abc'),
2899
+ )
2900
+ expect(configHelpers.computeStamp('<meta name="build-id" content="old" />\r\nabc')).toBe(
2901
+ configHelpers.computeStamp('abc'),
2902
+ )
2903
+ expect(configHelpers.computeStamp('abd')).not.toBe(configHelpers.computeStamp('abc'))
2904
+ })
2905
+
2906
+ it('stamps the final page once and refuses repeated or malformed stamp lines', () => {
2907
+ const html = '<html>\n\t<head>\n\t</head>\n</html>\n'
2908
+ const stamped = configHelpers.stampPage(html)
2909
+ expect(stamped.match(/name="build-id"/gu)).toHaveLength(1)
2910
+ expect(stamped).toContain(`content="${configHelpers.computeStamp(stamped)}"`)
2911
+ expect(configHelpers.computeStamp(stamped)).toBe(configHelpers.computeStamp(html))
2912
+ expect(configHelpers.stampPage(stamped)).toBe(stamped)
2913
+ const fixture = createPolicyScratch({ prefix: 'propagation-stamp-' })
2914
+ const scratch = fixture.path
2915
+ try {
2916
+ const path = resolve(scratch, 'page.html')
2917
+ writeFileSync(path, stamped)
2918
+ expect(configHelpers.stampPage(readFileSync(path, 'utf8'))).toBe(stamped)
2919
+ writeFileSync(path, '<meta name="build-id" content="old" />\n' + stamped)
2920
+ expect(() => configHelpers.stampPage(readFileSync(path, 'utf8'))).toThrow(
2921
+ 'at most one well-formed build stamp line',
2922
+ )
2923
+ } finally {
2924
+ fixture.destroy()
2925
+ }
2926
+ expect(() =>
2927
+ configHelpers.stampPage('<meta name="build-id" content="old" />\n' + stamped),
2928
+ ).toThrow('A showcase page must carry at most one well-formed build stamp line.')
2929
+ expect(() => configHelpers.stampPage('<html><head></head></html>')).toThrow(
2930
+ 'A showcase page must close its head on its own line.',
2931
+ )
2932
+ expect(() => configHelpers.stampPage('<meta name="build-id" content="old">\n' + html)).toThrow(
2933
+ 'A showcase page must carry at most one well-formed build stamp line.',
2934
+ )
2935
+ })
2936
+
2937
+ it('resolves externals from peers, refused packages, and published siblings', () => {
2938
+ const options = {
2939
+ peers: ['sample-peer'],
2940
+ refused: ['vue', '@vue/'],
2941
+ siblings: ['C:/workspace/src/browser/index.ts'],
2942
+ }
2943
+ for (const id of [
2944
+ 'node:fs',
2945
+ '@orkestrel/contract',
2946
+ 'sample-peer',
2947
+ 'sample-peer/subpath',
2948
+ 'C:\\workspace\\src\\browser\\index.ts',
2949
+ ]) {
2950
+ expect(configHelpers.resolveExternal(id, options)).toBe(true)
2951
+ }
2952
+ for (const id of [
2953
+ '@src/browser',
2954
+ '@src/core',
2955
+ '@app/vue',
2956
+ 'sample-peerish',
2957
+ './local.js',
2958
+ 'vue-tools',
2959
+ ]) {
2960
+ expect(configHelpers.resolveExternal(id, options)).toBe(false)
2961
+ }
2962
+ for (const id of ['vue', 'vue/runtime-dom']) {
2963
+ expect(() => configHelpers.resolveExternal(id, options)).toThrow(
2964
+ `The import ${id} is refused`,
2965
+ )
2966
+ expect(() => configHelpers.resolveExternal(id, options)).toThrow('peerDependencies')
2967
+ }
2968
+ expect(configHelpers.resolveExternal('vue', { ...options, peers: ['vue'] })).toBe(true)
2969
+ expect(configHelpers.resolveExternal('vue/runtime-dom', { ...options, peers: ['vue'] })).toBe(
2970
+ true,
2971
+ )
2972
+ expect(() =>
2973
+ configHelpers.resolveExternal('@vue/runtime-core', {
2974
+ ...options,
2975
+ peers: ['vue', '@vue/runtime-core'],
2976
+ }),
2977
+ ).toThrow('import from vue instead of the @vue/ implementation scope')
2978
+ })
2979
+
2980
+ it('classifies Vue faces as browser owners and browser targets', () => {
2981
+ for (const owner of ['src/vue', 'app/vue']) {
2982
+ expect(configHelpers.environmentPathError(owner, 'src/server/index.ts')).toBeDefined()
2983
+ expect(configHelpers.environmentSourceError(owner, 'node:fs')).toBeDefined()
2984
+ expect(configHelpers.environmentSourceError(owner, '@src/server')).toBeDefined()
2985
+ expect(configHelpers.environmentPathError(owner, 'src/browser/index.ts')).toBeUndefined()
2986
+ expect(configHelpers.environmentSourceError(owner, '@src/browser')).toBeUndefined()
2987
+ }
2988
+ for (const owner of ['src/core', 'app/core', 'src/server', 'app/server']) {
2989
+ expect(configHelpers.environmentPathError(owner, 'src/vue/index.ts')).toBeDefined()
2990
+ expect(configHelpers.environmentPathError(owner, 'app/vue/index.ts')).toBeDefined()
2991
+ expect(configHelpers.environmentSourceError(owner, '@src/vue')).toBeDefined()
2992
+ expect(configHelpers.environmentSourceError(owner, '@app/vue')).toBeDefined()
2993
+ }
2994
+ })
2995
+
2066
2996
  it('exposes every helper this proof requires', () => {
2067
2997
  const required = [
2068
2998
  'ENVIRONMENT_MODULE_BYTES',
@@ -2250,7 +3180,8 @@ describe('configuration helpers', () => {
2250
3180
  > = ['src/core', 'src/browser', 'src/server', 'app/core', 'app/browser', 'app/server']
2251
3181
  const owner = environments.find((environment) => existsSync(resolve(root, environment)))
2252
3182
  if (owner === undefined) throw new Error('The workspace carries no environment plugin target')
2253
- const workspace = mkdtempSync(join(resolve(root, owner), 'config-build-'))
3183
+ const scratch = createPolicyScratch({ parent: resolve(root, owner), prefix: 'config-build-' })
3184
+ const workspace = scratch.path
2254
3185
  try {
2255
3186
  const source = resolve(workspace, 'index.ts')
2256
3187
  writeFileSync(source, 'export const control = true\n', 'utf8')
@@ -2291,7 +3222,7 @@ describe('configuration helpers', () => {
2291
3222
  )
2292
3223
  await expect(Reflect.apply(hook, context, ['@src/core', source])).resolves.toBeNull()
2293
3224
  } finally {
2294
- rmSync(workspace, { recursive: true, force: true })
3225
+ scratch.destroy()
2295
3226
  }
2296
3227
  })
2297
3228
 
@@ -2413,6 +3344,16 @@ describe('configuration helpers', () => {
2413
3344
  expect(configHelpers.rewriteCoreSpecifier("from '@src/core'")).toBe(`from '${name}'`)
2414
3345
  expect(configHelpers.rewriteCoreSpecifier("from '../../core/index.js'")).toBe(`from '${name}'`)
2415
3346
  expect(configHelpers.rewriteCoreSpecifier("from './sibling.js'")).toBe("from './sibling.js'")
3347
+ expect(configHelpers.rewriteBrowserSpecifier("from '@src/browser'")).toBe(
3348
+ `from '${name}/browser'`,
3349
+ )
3350
+ expect(configHelpers.rewriteBrowserSpecifier("from '../../browser/index.js'")).toBe(
3351
+ `from '${name}/browser'`,
3352
+ )
3353
+ expect(configHelpers.rewriteBrowserSpecifier("from '../browser/index.ts'")).toBe(
3354
+ `from '${name}/browser'`,
3355
+ )
3356
+ expect(configHelpers.rewriteBrowserSpecifier("from './sibling.js'")).toBe("from './sibling.js'")
2416
3357
 
2417
3358
  // The mechanism the roll-up proof's skip reads: an absent package rejects resolution.
2418
3359
  expect(() => createRequire(import.meta.url).resolve('@absent/declaration-extractor')).toThrow(
@@ -2459,7 +3400,7 @@ describe('configuration helpers', () => {
2459
3400
  // The roll-up loads the declaration extractor, which only a workspace publishing source from
2460
3401
  // `src` installs. Where `require.resolve` rejects that package, this proof does not apply.
2461
3402
  it.skipIf(extractorPath === undefined)(
2462
- 'rolls one face into a single declaration and rewrites its core specifier',
3403
+ 'rolls one face into a single declaration and rewrites its core and browser specifiers',
2463
3404
  async () => {
2464
3405
  const scratch = createPolicyScratch({ prefix: 'orkestrel-config-rollup-' })
2465
3406
  // The hook's temporary declaration emit is proven removed from a host temporary
@@ -2494,7 +3435,10 @@ describe('configuration helpers', () => {
2494
3435
  noEmit: false,
2495
3436
  rootDir: './source',
2496
3437
  outDir: './emit',
2497
- paths: { '@src/core': ['./source/core/index.ts'] },
3438
+ paths: {
3439
+ '@src/core': ['./source/core/index.ts'],
3440
+ '@src/browser': ['./source/browser/index.ts'],
3441
+ },
2498
3442
  },
2499
3443
  include: ['./source/server/**/*.ts'],
2500
3444
  }),
@@ -2503,9 +3447,13 @@ describe('configuration helpers', () => {
2503
3447
  'source/core/index.ts',
2504
3448
  'export interface FixtureLabel {\n\treadonly label: string\n}\n',
2505
3449
  )
3450
+ scratch.write(
3451
+ 'source/browser/index.ts',
3452
+ 'export interface FixtureView {\n\treadonly visible: boolean\n}\n',
3453
+ )
2506
3454
  scratch.write(
2507
3455
  'source/server/index.ts',
2508
- "import type { FixtureLabel } from '@src/core'\n\nexport interface FixtureRecord {\n\treadonly label: FixtureLabel\n\treadonly count: number\n}\n",
3456
+ "import type { FixtureLabel } from '@src/core'\nimport type { FixtureView } from '@src/browser'\n\nexport interface FixtureRecord {\n\treadonly label: FixtureLabel\n\treadonly view: FixtureView\n\treadonly count: number\n}\n",
2509
3457
  )
2510
3458
 
2511
3459
  const name = configHelpers.packageManifestName(configHelpers.WORKSPACE_ROOT)
@@ -2525,7 +3473,13 @@ describe('configuration helpers', () => {
2525
3473
  plugins: [
2526
3474
  configHelpers.declarationRollup(
2527
3475
  face.rewrite
2528
- ? { project, rewrite: configHelpers.rewriteCoreSpecifier }
3476
+ ? {
3477
+ project,
3478
+ rewrite: (text) =>
3479
+ configHelpers.rewriteBrowserSpecifier(
3480
+ configHelpers.rewriteCoreSpecifier(text),
3481
+ ),
3482
+ }
2529
3483
  : { project },
2530
3484
  ),
2531
3485
  ],
@@ -2572,10 +3526,13 @@ describe('configuration helpers', () => {
2572
3526
  expect(rolled).toContain('FixtureRecord')
2573
3527
  expect(rolled).toContain(`from '${name}'`)
2574
3528
  expect(rolled).not.toContain('@src/core')
3529
+ expect(rolled).toContain(`from '${name}/browser'`)
3530
+ expect(rolled).not.toContain('@src/browser')
2575
3531
 
2576
3532
  // The control: the same face without a rewrite ships the specifier the extractor kept.
2577
3533
  const control = readFileSync(join(workspace, 'kept', 'index.d.ts'), 'utf8')
2578
3534
  expect(control).toContain('@src/core')
3535
+ expect(control).toContain('@src/browser')
2579
3536
  expect(control).not.toContain(`from '${name}'`)
2580
3537
  } finally {
2581
3538
  for (const [name, value] of inherited) {