@orkestrel/scaffold 0.0.30 → 0.0.32

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.
@@ -95,9 +95,22 @@ export const DATA_SOURCE_FILES: readonly string[] = Object.freeze([
95
95
  'validators.ts',
96
96
  ])
97
97
 
98
+ /**
99
+ * Files excluded from the module-data rule because their namespace values hold helper behavior.
100
+ * This exclusion also permits unrelated module data such as `export const RETRIES = 3`.
101
+ */
102
+ export const DATA_EXEMPT_FILES: readonly string[] = Object.freeze(['helpers.ts'])
103
+
98
104
  /** Fleet-registered folders whose direct modules each contain one named function. */
99
105
  export const FUNCTION_DOMAIN_FOLDERS: readonly string[] = Object.freeze(['app/browser/composables'])
100
106
 
107
+ /** Every ambient declaration suffix the source glob collects and the parsed population excludes. */
108
+ export const POLICY_AMBIENT_SUFFIXES: readonly string[] = Object.freeze([
109
+ '.d.cts',
110
+ '.d.mts',
111
+ '.d.ts',
112
+ ])
113
+
101
114
  /** TypeScript source extensions whose declaration syntax the sweep reads. */
102
115
  export const POLICY_SOURCE_EXTENSIONS: readonly string[] = Object.freeze([
103
116
  'cts',
@@ -106,9 +119,32 @@ export const POLICY_SOURCE_EXTENSIONS: readonly string[] = Object.freeze([
106
119
  'tsx',
107
120
  ])
108
121
 
109
- /** The complete TypeScript source population inspected under either workspace axis. */
122
+ /** Every extension through which a mirrored test can name a module. */
123
+ export const POLICY_MODULE_EXTENSIONS: readonly string[] = Object.freeze([
124
+ 'cts',
125
+ 'mts',
126
+ 'ts',
127
+ 'tsx',
128
+ 'vue',
129
+ 'scss',
130
+ 'css',
131
+ ])
132
+
133
+ /** Module extensions whose extensionless stem can resolve a leading-underscore partial. */
134
+ export const POLICY_PARTIAL_EXTENSIONS: readonly string[] = Object.freeze(['scss', 'css'])
135
+
136
+ /** The reserved stem prefix whose mirrored module can resolve inside the tests axis. */
137
+ export const POLICY_TESTS_MODULE_PREFIX = 'setup'
138
+
139
+ /** The complete parsed TypeScript source population under either workspace axis. */
110
140
  export const POLICY_SOURCE_GLOB = `{app,src}/**/*.{${POLICY_SOURCE_EXTENSIONS.join(',')}}`
111
141
 
142
+ /** The complete module population available to mirrored tests under either workspace axis. */
143
+ export const POLICY_MODULE_GLOB = `{app,src}/**/*.{${POLICY_MODULE_EXTENSIONS.join(',')}}`
144
+
145
+ /** The tests-axis setup module population available to mirrored tests. */
146
+ export const POLICY_TESTS_MODULE_GLOB = `tests/**/${POLICY_TESTS_MODULE_PREFIX}*.ts`
147
+
112
148
  /** The mirrored module-test population inspected under either workspace axis. */
113
149
  export const POLICY_TEST_GLOB = 'tests/{app,src}/**/*.test.ts'
114
150
 
@@ -165,6 +201,23 @@ export function isFunctionDomainPath(path: string): boolean {
165
201
  )
166
202
  }
167
203
 
204
+ /**
205
+ * Read the function expression an expression holds directly, through any parentheses.
206
+ *
207
+ * @param expression - The expression to unwrap.
208
+ * @returns The arrow or function expression it holds directly, or `undefined` for anything else.
209
+ */
210
+ export function expressionToPolicyFunction(
211
+ expression: ts.Expression | undefined,
212
+ ): ts.ArrowFunction | ts.FunctionExpression | undefined {
213
+ let current = expression
214
+ while (current !== undefined && ts.isParenthesizedExpression(current)) {
215
+ current = current.expression
216
+ }
217
+ if (current === undefined) return undefined
218
+ return ts.isArrowFunction(current) || ts.isFunctionExpression(current) ? current : undefined
219
+ }
220
+
168
221
  /**
169
222
  * Whether a variable initializer is directly a function expression.
170
223
  *
@@ -172,29 +225,91 @@ export function isFunctionDomainPath(path: string): boolean {
172
225
  * @returns `true` for a direct arrow or function expression.
173
226
  */
174
227
  export function isPolicyFunctionInitializer(initializer: ts.Expression | undefined): boolean {
175
- let expression = initializer
176
- while (expression !== undefined && ts.isParenthesizedExpression(expression)) {
177
- expression = expression.expression
178
- }
179
- return (
180
- expression !== undefined &&
181
- (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression))
182
- )
228
+ return expressionToPolicyFunction(initializer) !== undefined
183
229
  }
184
230
 
185
231
  /**
186
- * Whether an expression contains module-level function syntax.
232
+ * Whether a module region holds a module policy function.
233
+ *
234
+ * A module region is syntax outside every permitted function. A permitted function's parameters and
235
+ * the arguments of a call sitting inside one are read under this same rule, because the law grants
236
+ * those positions nothing extra.
187
237
  *
188
- * @param node - The initializer subtree to inspect.
189
- * @returns `true` when the subtree contains an arrow or function expression.
238
+ * @param node - The module region to inspect.
239
+ * @returns `true` when the region holds an arrow or function expression the law does not permit.
240
+ * An arrow or function expression passed directly as a call or `new` argument is exempt itself, and
241
+ * reports only for what its parameters and body nest. A class expression is never function syntax.
190
242
  */
191
- export function hasPolicyFunctionExpression(node: ts.Node | undefined): boolean {
243
+ export function hasModulePolicyFunction(node: ts.Node | undefined): boolean {
192
244
  if (node === undefined) return false
193
245
  if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return true
194
246
  if (ts.isClassExpression(node)) return false
247
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
248
+ if (hasModulePolicyFunction(node.expression)) return true
249
+ if (node.arguments !== undefined) {
250
+ for (const argument of node.arguments) {
251
+ const permitted = expressionToPolicyFunction(argument)
252
+ if (permitted === undefined) {
253
+ if (hasModulePolicyFunction(argument)) return true
254
+ } else if (nestsPolicyFunction(permitted)) return true
255
+ }
256
+ }
257
+ return false
258
+ }
195
259
  let found = false
196
260
  ts.forEachChild(node, (child) => {
197
- if (!found && hasPolicyFunctionExpression(child)) found = true
261
+ if (!found && hasModulePolicyFunction(child)) found = true
262
+ })
263
+ return found
264
+ }
265
+
266
+ /**
267
+ * Whether a permitted function nests a policy function the law does not permit.
268
+ *
269
+ * The function is exempt in its own right. Its parameters read as a module region, and its body
270
+ * reads as the region inside a permitted function, where a nested function keeps permission only by
271
+ * qualifying independently as a direct callback or result.
272
+ *
273
+ * @param node - The permitted callback or returned function to inspect.
274
+ * @returns `true` when its parameters or body nest function syntax the law does not permit.
275
+ */
276
+ export function nestsPolicyFunction(node: ts.ArrowFunction | ts.FunctionExpression): boolean {
277
+ for (const parameter of node.parameters) {
278
+ if (hasModulePolicyFunction(parameter)) return true
279
+ }
280
+ if (!ts.isBlock(node.body)) {
281
+ const returned = expressionToPolicyFunction(node.body)
282
+ if (returned !== undefined) return nestsPolicyFunction(returned)
283
+ }
284
+ return hasNestedPolicyFunction(node.body)
285
+ }
286
+
287
+ /**
288
+ * Whether a region inside a permitted function holds a nested policy function.
289
+ *
290
+ * A direct return keeps its permission through control flow. Every other nested function reports
291
+ * unless it independently qualifies as a direct callback. Class-expression members stay outside
292
+ * the instrument's reach.
293
+ *
294
+ * @param node - The region inside a permitted function to inspect.
295
+ * @returns `true` when the region holds function syntax the law does not permit.
296
+ */
297
+ export function hasNestedPolicyFunction(node: ts.Node): boolean {
298
+ if (ts.isFunctionDeclaration(node)) return true
299
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return true
300
+ if (ts.isClassExpression(node)) return false
301
+ if (ts.isReturnStatement(node)) {
302
+ const returned = expressionToPolicyFunction(node.expression)
303
+ return returned === undefined
304
+ ? hasModulePolicyFunction(node.expression)
305
+ : nestsPolicyFunction(returned)
306
+ }
307
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
308
+ return hasModulePolicyFunction(node)
309
+ }
310
+ let found = false
311
+ ts.forEachChild(node, (child) => {
312
+ if (!found && hasNestedPolicyFunction(child)) found = true
198
313
  })
199
314
  return found
200
315
  }
@@ -302,8 +417,8 @@ export function inspectPolicyVariables(
302
417
  const violations: PolicyViolation[] = []
303
418
  for (const declaration of statement.declarationList.declarations) {
304
419
  const directFunction = isPolicyFunctionInitializer(declaration.initializer)
305
- const containsFunction = hasPolicyFunctionExpression(declaration.initializer)
306
- if (!directFunction && !DATA_SOURCE_FILES.includes(file)) {
420
+ const containsFunction = hasModulePolicyFunction(declaration.initializer)
421
+ if (!directFunction && !DATA_SOURCE_FILES.includes(file) && !DATA_EXEMPT_FILES.includes(file)) {
307
422
  violations.push(
308
423
  createPolicyViolation('data', path, 'module data sits in a data-kind file', declaration),
309
424
  )
@@ -488,7 +603,7 @@ export function inspectPolicySource(source: PolicySource): readonly PolicyViolat
488
603
  }
489
604
 
490
605
  /**
491
- * Inspect an explicit source population through the same per-file route as the workspace sweep.
606
+ * Inspect an explicit parsed population through the same per-file route as the workspace sweep.
492
607
  *
493
608
  * @param sources - The TypeScript files to inspect.
494
609
  * @returns Every syntactic placement violation in source order.
@@ -500,51 +615,77 @@ export function inspectPolicySources(sources: readonly PolicySource[]): readonly
500
615
  }
501
616
 
502
617
  /**
503
- * Read the complete TypeScript source population beneath one workspace.
618
+ * Read the parsed TypeScript source population beneath one workspace.
504
619
  *
505
620
  * @param root - The workspace root to read.
506
- * @returns Every TypeScript source under the src and app axes, sorted by path.
621
+ * @returns Every non-ambient TypeScript source under the src and app axes, sorted by path.
507
622
  */
508
623
  export function readPolicySources(root: string): readonly PolicySource[] {
509
624
  return globSync(POLICY_SOURCE_GLOB, { cwd: root })
625
+ .map(normalizePolicyPath)
626
+ .filter((path) => !POLICY_AMBIENT_SUFFIXES.some((suffix) => basename(path).endsWith(suffix)))
510
627
  .sort()
511
628
  .map((path) => ({
512
- path: normalizePolicyPath(path),
629
+ path,
513
630
  content: readFileSync(join(root, path), 'utf8'),
514
631
  }))
515
632
  }
516
633
 
517
634
  /**
518
- * Derive the required source module for one mirrored module test.
635
+ * Derive the extensionless module stem for one mirrored module test.
519
636
  *
520
637
  * @param path - The workspace-relative test path.
521
- * @returns The required TypeScript source path, or `undefined` for a reserved scope test.
638
+ * @returns The extensionless module stem, or `undefined` for a reserved scope test.
522
639
  */
523
- export function testToPolicySource(path: string): string | undefined {
640
+ export function testToPolicyStem(path: string): string | undefined {
524
641
  const normalized = normalizePolicyPath(path)
525
642
  if (basename(normalized) === 'integration.test.ts') return undefined
526
643
  if (!normalized.startsWith('tests/') || !normalized.endsWith('.test.ts')) return undefined
527
- return `${normalized.slice('tests/'.length, -'.test.ts'.length)}.ts`
644
+ return normalized.slice('tests/'.length, -'.test.ts'.length)
645
+ }
646
+
647
+ /**
648
+ * The candidate set is every module name a registered language resolves for a stem.
649
+ *
650
+ * @param stem - The extensionless workspace-relative module stem.
651
+ * @returns Direct modules, partial modules, then a matching tests-axis setup module.
652
+ */
653
+ export function stemToPolicyCandidates(stem: string): readonly string[] {
654
+ const normalized = normalizePolicyPath(stem)
655
+ const directory = dirname(normalized).replaceAll('\\', '/')
656
+ const name = basename(normalized)
657
+ const candidates = POLICY_MODULE_EXTENSIONS.map((extension) => `${normalized}.${extension}`)
658
+ for (const extension of POLICY_PARTIAL_EXTENSIONS) {
659
+ candidates.push(`${directory}/_${name}.${extension}`)
660
+ }
661
+ if (name.startsWith(POLICY_TESTS_MODULE_PREFIX)) candidates.push(`tests/${normalized}.ts`)
662
+ return candidates
528
663
  }
529
664
 
530
665
  /**
531
- * Inspect mirrored test paths against an explicit source-path population.
666
+ * Inspect mirrored test paths against an explicit module-path population.
532
667
  *
533
668
  * @param tests - The module-test paths to inspect.
534
- * @param sources - The existing TypeScript source paths.
669
+ * @param modules - The existing module paths in every registered language.
535
670
  * @returns Every missing mirror violation in test-path order.
536
671
  */
537
672
  export function inspectPolicyMirrorPaths(
538
673
  tests: readonly string[],
539
- sources: ReadonlySet<string>,
674
+ modules: ReadonlySet<string>,
540
675
  ): readonly PolicyViolation[] {
541
676
  const violations: PolicyViolation[] = []
542
677
  for (const test of tests) {
543
678
  const path = normalizePolicyPath(test)
544
- const source = testToPolicySource(path)
545
- if (source !== undefined && !sources.has(source)) {
679
+ const stem = testToPolicyStem(path)
680
+ if (stem === undefined) continue
681
+ const candidates = stemToPolicyCandidates(stem)
682
+ if (!candidates.some((candidate) => modules.has(candidate))) {
546
683
  violations.push(
547
- createPolicyViolation('mirror', path, `module test requires matching source ${source}`),
684
+ createPolicyViolation(
685
+ 'mirror',
686
+ path,
687
+ `module test requires one matching module: ${candidates.join(', ')}`,
688
+ ),
548
689
  )
549
690
  }
550
691
  }
@@ -559,10 +700,11 @@ export function inspectPolicyMirrorPaths(
559
700
  */
560
701
  export function inspectPolicyMirrors(root: string): readonly PolicyViolation[] {
561
702
  const tests = globSync(POLICY_TEST_GLOB, { cwd: root }).sort().map(normalizePolicyPath)
562
- const sources = new Set(
563
- globSync('{app,src}/**/*.ts', { cwd: root }).sort().map(normalizePolicyPath),
564
- )
565
- return inspectPolicyMirrorPaths(tests, sources)
703
+ const modules = new Set([
704
+ ...globSync(POLICY_MODULE_GLOB, { cwd: root }).sort().map(normalizePolicyPath),
705
+ ...globSync(POLICY_TESTS_MODULE_GLOB, { cwd: root }).sort().map(normalizePolicyPath),
706
+ ])
707
+ return inspectPolicyMirrorPaths(tests, modules)
566
708
  }
567
709
 
568
710
  /**
@@ -698,6 +840,127 @@ export const POLICY_CONTROLS: readonly PolicyControl[] = Object.freeze([
698
840
  },
699
841
  ],
700
842
  },
843
+ {
844
+ label: 'rejects a module whose stem only prefixes the test stem',
845
+ membership: 'module paths whose exact extensionless stem differs from the test stem',
846
+ rule: 'mirror',
847
+ files: [
848
+ { path: 'app/browser/WidgetPanel.vue', content: '<template></template>\n' },
849
+ { path: 'tests/app/browser/Widget.test.ts', content: '' },
850
+ ],
851
+ },
852
+ {
853
+ label: 'rejects a partial whose stem differs from the test stem',
854
+ membership: 'partial module paths whose exact underscore-free stem differs from the test stem',
855
+ rule: 'mirror',
856
+ files: [
857
+ { path: 'app/browser/styles/_token.scss', content: '' },
858
+ { path: 'tests/app/browser/styles/tokens.test.ts', content: '' },
859
+ ],
860
+ },
861
+ {
862
+ label: 'rejects a test with no module candidate',
863
+ membership: 'module tests with no matching module path in any registered form',
864
+ rule: 'mirror',
865
+ files: [{ path: 'tests/app/browser/Widget.test.ts', content: '' }],
866
+ },
867
+ {
868
+ label: 'rejects a non-setup module inside tests',
869
+ membership: 'tests-axis modules whose stem does not start with setup',
870
+ rule: 'mirror',
871
+ files: [
872
+ { path: 'tests/app/core/widget.ts', content: '' },
873
+ { path: 'tests/app/core/widget.test.ts', content: '' },
874
+ ],
875
+ },
876
+ {
877
+ label: 'rejects a type in a non-ambient env module',
878
+ membership: 'non-ambient TypeScript modules whose filename is not types.ts',
879
+ rule: 'type',
880
+ files: [{ path: 'app/browser/env.ts', content: 'export interface EnvironmentInterface {}\n' }],
881
+ },
882
+ {
883
+ label: 'rejects a property-held arrow in constants.ts',
884
+ membership: 'module-level function syntax not passed directly as an argument',
885
+ rule: 'function',
886
+ files: [
887
+ {
888
+ path: 'app/edge/constants.ts',
889
+ content: 'export const HANDLERS = Object.freeze({ run: () => undefined })\n',
890
+ },
891
+ ],
892
+ },
893
+ {
894
+ label: 'rejects a callback parameter default function',
895
+ membership: 'function expressions in callback parameter defaults',
896
+ rule: 'function',
897
+ files: [
898
+ {
899
+ path: 'app/edge/constants.ts',
900
+ content: 'export const VALUES = Object.freeze(C.map((c = () => 1) => c))\n',
901
+ },
902
+ ],
903
+ },
904
+ {
905
+ label: 'rejects a destructured callback parameter default function',
906
+ membership: 'function expressions in destructured callback parameter defaults',
907
+ rule: 'function',
908
+ files: [
909
+ {
910
+ path: 'app/edge/constants.ts',
911
+ content: 'export const VALUES = Object.freeze(C.map(({ f = () => 1 }) => f))\n',
912
+ },
913
+ ],
914
+ },
915
+ {
916
+ label: 'rejects an assignment inside callback control flow',
917
+ membership: 'function assignments inside callback control-flow branches',
918
+ rule: 'function',
919
+ files: [
920
+ {
921
+ path: 'app/edge/constants.ts',
922
+ content:
923
+ 'export const VALUES = Object.freeze(C.map((c) => { if (c) { const f = () => 1; return f() } return 2 }))\n',
924
+ },
925
+ ],
926
+ },
927
+ {
928
+ label: 'rejects an assignment inside a direct callback',
929
+ membership: 'function assignments inside the body of a callback passed directly as an argument',
930
+ rule: 'function',
931
+ files: [
932
+ {
933
+ path: 'app/edge/constants.ts',
934
+ content:
935
+ 'export const VALUES = Object.freeze(C.map((c) => { const f = () => c; return f() }))\n',
936
+ },
937
+ ],
938
+ },
939
+ {
940
+ label: 'rejects a declaration inside a direct callback',
941
+ membership:
942
+ 'function declarations inside the body of a callback passed directly as an argument',
943
+ rule: 'function',
944
+ files: [
945
+ {
946
+ path: 'app/edge/constants.ts',
947
+ content:
948
+ 'export const LABELS = Object.freeze(COLUMNS.map((column) => { function format() { return column.label } return format() }))\n',
949
+ },
950
+ ],
951
+ },
952
+ {
953
+ label: 'rejects an assignment two direct callbacks down',
954
+ membership: 'function assignments inside a callback the outer callback passes directly',
955
+ rule: 'function',
956
+ files: [
957
+ {
958
+ path: 'app/edge/constants.ts',
959
+ content:
960
+ 'export const VALUES = Object.freeze(C.map((c) => wrap((d) => { const g = () => d; return g() })))\n',
961
+ },
962
+ ],
963
+ },
701
964
  ])
702
965
 
703
966
  /** A differently shaped workspace with app, browser, and worker environments but no core. */