@orkestrel/scaffold 0.0.63 → 0.0.65

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 (57) hide show
  1. package/README.md +29 -104
  2. package/dist/bin/main.js +95 -27
  3. package/dist/bin/main.js.map +1 -1
  4. package/dist/host/AGENTS.md +2 -2
  5. package/dist/host/CLAUDE.md +6 -0
  6. package/dist/host/agents/orchestration.md +23 -15
  7. package/dist/host/agents/skills/enterprise-bootstrap/SKILL.md +184 -177
  8. package/dist/host/agents/skills/enterprise-bootstrap/references/bootstrap-reference.md +314 -91
  9. package/dist/host/agents/skills/enterprise-bootstrap/references/color-modes.md +241 -0
  10. package/dist/host/agents/skills/enterprise-bootstrap/references/components.md +83 -36
  11. package/dist/host/agents/skills/enterprise-bootstrap/references/frontend-design.md +297 -98
  12. package/dist/host/agents/skills/enterprise-bootstrap/references/inputs.md +25 -14
  13. package/dist/host/agents/skills/enterprise-bootstrap/references/inspection.md +216 -128
  14. package/dist/host/agents/skills/enterprise-bootstrap/references/responsive-layout.md +187 -0
  15. package/dist/host/agents/skills/enterprise-bootstrap/references/utilities.md +109 -20
  16. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +5 -5
  17. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  18. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +15 -15
  19. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +43 -17
  20. package/dist/host/agents/skills/orkestrel-publish/references/window.md +41 -16
  21. package/dist/host/claude/agents/orkestrel.md +56 -56
  22. package/dist/host/claude/agents/reviewer.md +13 -0
  23. package/dist/host/claude/rules/architecture.md +51 -45
  24. package/dist/host/claude/rules/documentation.md +18 -1
  25. package/dist/host/claude/rules/portability.md +2 -0
  26. package/dist/host/claude/rules/quality.md +1 -1
  27. package/dist/host/claude/rules/tests.md +12 -11
  28. package/dist/host/claude/rules/typescript.md +5 -0
  29. package/dist/host/claude/rules/workspace.md +25 -20
  30. package/dist/host/claude/rules/writing.md +4 -0
  31. package/dist/host/claude/settings.json +1 -1
  32. package/dist/host/claude/skills/enterprise-bootstrap/SKILL.md +10 -9
  33. package/dist/host/codex/agents/orkestrel.toml +3 -3
  34. package/dist/host/codex/agents/reviewer.toml +4 -2
  35. package/dist/host/configs/helpers.ts +311 -2
  36. package/dist/host/configs/policy.ts +1100 -51
  37. package/dist/host/dotfiles/oxlintrc.json +72 -1
  38. package/dist/host/guides/guide.md +749 -222
  39. package/dist/host/guides/scaffold.md +529 -394
  40. package/dist/host/manifest.json +53 -40
  41. package/dist/host/scripts/ollama.sh +322 -13
  42. package/dist/host/tests/config.test.ts +1200 -16
  43. package/dist/host/tests/policy.test.ts +157 -173
  44. package/dist/host/tests/setupPolicy.ts +522 -1007
  45. package/dist/src/core/index.cjs +402 -287
  46. package/dist/src/core/index.cjs.map +1 -1
  47. package/dist/src/core/index.d.cts +160 -128
  48. package/dist/src/core/index.d.ts +160 -128
  49. package/dist/src/core/index.js +400 -286
  50. package/dist/src/core/index.js.map +1 -1
  51. package/dist/src/server/index.cjs +28 -21
  52. package/dist/src/server/index.cjs.map +1 -1
  53. package/dist/src/server/index.d.cts +38 -33
  54. package/dist/src/server/index.d.ts +38 -33
  55. package/dist/src/server/index.js +28 -21
  56. package/dist/src/server/index.js.map +1 -1
  57. package/package.json +18 -19
@@ -1,18 +1,18 @@
1
- /** The syntax-node fields supplied to every policy visitor. */
1
+ /** Describes the syntax-node fields supplied to every policy visitor. */
2
2
  export interface PolicyNode {
3
3
  readonly type: string
4
4
  readonly range: [number, number]
5
5
  }
6
6
 
7
- /** The expression fields inspected by the policy rules. */
7
+ /** Describes the expression fields the policy rules inspect. */
8
8
  export interface PolicyExpression extends PolicyNode {
9
9
  readonly parent?: PolicyExpression | null
10
10
  readonly name?: unknown
11
11
  readonly value?: unknown
12
12
  readonly key?: PolicyExpression
13
- readonly id?: unknown
13
+ readonly id?: PolicyExpression | null
14
14
  readonly method?: boolean
15
- readonly kind?: 'get' | 'init' | 'set'
15
+ readonly kind?: 'const' | 'get' | 'init' | 'let' | 'set' | 'var'
16
16
  readonly expression?: boolean
17
17
  readonly object?: PolicyExpression
18
18
  readonly property?: PolicyExpression
@@ -20,59 +20,484 @@ export interface PolicyExpression extends PolicyNode {
20
20
  readonly callee?: PolicyExpression
21
21
  readonly argument?: PolicyExpression | null
22
22
  readonly arguments?: readonly PolicyExpression[]
23
- readonly body?: PolicyExpression
23
+ readonly body?: PolicyExpression | readonly PolicyExpression[]
24
24
  readonly quasis?: readonly PolicyExpression[]
25
25
  readonly expressions?: readonly PolicyExpression[]
26
26
  readonly accessibility?: 'private' | 'protected' | 'public' | null
27
+ readonly declaration?: PolicyExpression
28
+ readonly declarations?: readonly PolicyExpression[]
29
+ readonly init?: PolicyExpression | null
30
+ readonly source?: PolicyExpression
31
+ readonly specifiers?: readonly PolicyExpression[]
32
+ readonly imported?: PolicyExpression
27
33
  }
28
34
 
29
- /** One diagnostic emitted by a policy rule. */
30
- export interface PolicyDiagnostic {
35
+ /** Pairs one declared module function with the name a prefix rule reads. */
36
+ export interface PolicyBinding {
31
37
  readonly node: PolicyExpression
38
+ readonly name: string | undefined
39
+ }
40
+
41
+ /** Describes one comment the comment rules read out of a linted file. */
42
+ export interface PolicyComment extends PolicyNode {
43
+ readonly type: 'Block' | 'Line' | 'Shebang'
44
+ readonly value: string
45
+ }
46
+
47
+ /** Lists the Oxlint source-text operations the comment rules read. */
48
+ export interface PolicySourceCode {
49
+ readonly text: string
50
+ getAllComments(): readonly PolicyComment[]
51
+ }
52
+
53
+ /** Pairs one doc block with the declared name its first sentence must not repeat. */
54
+ export interface PolicyDoc {
55
+ readonly comment: PolicyComment
56
+ readonly name: string | undefined
57
+ }
58
+
59
+ /** Describes one banned term, the prose it matches, and the replacement its row names. */
60
+ export interface PolicyTerm {
61
+ readonly term: string
62
+ readonly pattern: RegExp
63
+ readonly replacement: string
64
+ }
65
+
66
+ /** Pairs one banned-term match with the offset it starts at. */
67
+ export interface PolicyHit {
68
+ readonly term: PolicyTerm
69
+ readonly index: number
70
+ }
71
+
72
+ /** Describes one diagnostic a policy rule emits. */
73
+ export interface PolicyDiagnostic {
74
+ readonly node: PolicyNode
32
75
  readonly messageId: string
33
76
  readonly data?: Readonly<Record<string, string>>
34
77
  }
35
78
 
36
- /** The Oxlint context operations used by the policy rules. */
79
+ /** Lists the Oxlint context operations the policy rules use. */
37
80
  export interface PolicyContext {
81
+ readonly filename: string
82
+ /** Names the directory Oxlint resolves `filename` against. */
83
+ readonly cwd: string
84
+ readonly sourceCode: PolicySourceCode
38
85
  report(diagnostic: PolicyDiagnostic): void
39
86
  }
40
87
 
41
- /** The rule documentation fields supplied to Oxlint. */
88
+ /** Describes the rule documentation fields supplied to Oxlint. */
42
89
  export interface PolicyDocs {
43
90
  readonly [key: string]: unknown
44
91
  readonly description: string
45
92
  }
46
93
 
47
- /** The rule metadata fields supplied to Oxlint. */
94
+ /** Describes the rule metadata fields supplied to Oxlint. */
48
95
  export interface PolicyMeta {
49
96
  readonly type: 'problem'
50
97
  readonly docs: PolicyDocs
51
98
  readonly messages: Readonly<Record<string, string>>
52
99
  }
53
100
 
54
- /** The Oxlint visitor entries used by the policy rules. */
101
+ /** Lists the Oxlint visitor entries the policy rules use. */
55
102
  export interface PolicyVisitor {
56
103
  readonly [key: string]: ((node: PolicyNode) => void) | undefined
104
+ readonly Program?: (node: PolicyNode) => void
57
105
  readonly CallExpression?: (node: PolicyNode) => void
106
+ readonly ClassDeclaration?: (node: PolicyNode) => void
58
107
  readonly FunctionDeclaration?: (node: PolicyNode) => void
59
108
  readonly FunctionExpression?: (node: PolicyNode) => void
60
109
  readonly ArrowFunctionExpression?: (node: PolicyNode) => void
110
+ readonly ImportDeclaration?: (node: PolicyNode) => void
111
+ readonly MemberExpression?: (node: PolicyNode) => void
61
112
  readonly MethodDefinition?: (node: PolicyNode) => void
62
113
  readonly PropertyDefinition?: (node: PolicyNode) => void
63
114
  readonly AccessorProperty?: (node: PolicyNode) => void
64
115
  readonly TSAbstractMethodDefinition?: (node: PolicyNode) => void
65
116
  readonly TSAbstractPropertyDefinition?: (node: PolicyNode) => void
66
117
  readonly TSAbstractAccessorProperty?: (node: PolicyNode) => void
118
+ readonly TSDeclareFunction?: (node: PolicyNode) => void
119
+ readonly TSEnumDeclaration?: (node: PolicyNode) => void
120
+ readonly TSInterfaceDeclaration?: (node: PolicyNode) => void
121
+ readonly TSModuleDeclaration?: (node: PolicyNode) => void
122
+ readonly TSTypeAliasDeclaration?: (node: PolicyNode) => void
123
+ readonly VariableDeclaration?: (node: PolicyNode) => void
67
124
  }
68
125
 
69
- /** The complete behavior exposed by one policy rule. */
126
+ /** Describes the complete behavior one policy rule exposes. */
70
127
  export interface PolicyRuleInterface {
71
128
  readonly meta: PolicyMeta
72
129
  create(context: PolicyContext): PolicyVisitor
73
130
  }
74
131
 
75
- /** Whether a policy expression is runtime function syntax. */
132
+ /** Lists every centralized module the architecture kind table names. */
133
+ export const CENTRAL_SOURCE_FILES: readonly string[] = Object.freeze([
134
+ 'cloners.ts',
135
+ 'combinators.ts',
136
+ 'compilers.ts',
137
+ 'constants.ts',
138
+ 'contracts.ts',
139
+ 'errors.ts',
140
+ 'factories.ts',
141
+ 'handlers.ts',
142
+ 'helpers.ts',
143
+ 'index.ts',
144
+ 'inferers.ts',
145
+ 'middlewares.ts',
146
+ 'parsers.ts',
147
+ 'relations.ts',
148
+ 'routes.ts',
149
+ 'schemas.ts',
150
+ 'seeders.ts',
151
+ 'shapers.ts',
152
+ 'templates.ts',
153
+ 'types.ts',
154
+ 'validators.ts',
155
+ ])
156
+
157
+ /** Lists the exhaustive centralized-file set that permits module functions. */
158
+ export const FUNCTION_SOURCE_FILES: readonly string[] = Object.freeze([
159
+ 'cloners.ts',
160
+ 'combinators.ts',
161
+ 'compilers.ts',
162
+ 'errors.ts',
163
+ 'factories.ts',
164
+ 'handlers.ts',
165
+ 'helpers.ts',
166
+ 'inferers.ts',
167
+ 'middlewares.ts',
168
+ 'parsers.ts',
169
+ 'relations.ts',
170
+ 'schemas.ts',
171
+ 'seeders.ts',
172
+ 'shapers.ts',
173
+ 'validators.ts',
174
+ ])
175
+
176
+ /** Lists the centralized files that permit module data by declaration syntax. */
177
+ export const DATA_SOURCE_FILES: readonly string[] = Object.freeze([
178
+ 'combinators.ts',
179
+ 'constants.ts',
180
+ 'contracts.ts',
181
+ 'relations.ts',
182
+ 'routes.ts',
183
+ 'schemas.ts',
184
+ 'shapers.ts',
185
+ 'templates.ts',
186
+ 'validators.ts',
187
+ ])
188
+
189
+ /**
190
+ * Lists the files excluded from the module-data rule because their namespace values hold helper
191
+ * behavior. This exclusion also permits unrelated module data such as `export const RETRIES = 3`.
192
+ */
193
+ export const DATA_EXEMPT_FILES: readonly string[] = Object.freeze(['helpers.ts'])
194
+
195
+ /** Lists the fleet-registered folders whose direct modules each contain one named function. */
196
+ export const FUNCTION_DOMAIN_FOLDERS: readonly string[] = Object.freeze([
197
+ 'app/browser/composables',
198
+ 'src/server/execution',
199
+ ])
200
+
201
+ /** Lists the registered function-domain names no source file may take as its stem. */
202
+ export const FUNCTION_DOMAIN_NAMES: readonly string[] = Object.freeze(
203
+ FUNCTION_DOMAIN_FOLDERS.map((folder) => folder.slice(folder.lastIndexOf('/') + 1)),
204
+ )
205
+
206
+ /** Lists every ambient declaration suffix the placement and line-ending rules leave uninspected. */
207
+ export const POLICY_AMBIENT_SUFFIXES: readonly string[] = Object.freeze([
208
+ '.d.cts',
209
+ '.d.mts',
210
+ '.d.ts',
211
+ ])
212
+
213
+ /** Lists the TypeScript source extensions whose declaration syntax the placement rules read. */
214
+ export const POLICY_SOURCE_EXTENSIONS: readonly string[] = Object.freeze([
215
+ 'cts',
216
+ 'mts',
217
+ 'ts',
218
+ 'tsx',
219
+ ])
220
+
221
+ /**
222
+ * Matches the lint populations the placement rules run over, as the Oxlint configuration declares
223
+ * them.
224
+ */
225
+ export const POLICY_PLACEMENT_GLOBS: readonly string[] = Object.freeze([
226
+ `app/**/*.{${POLICY_SOURCE_EXTENSIONS.join(',')}}`,
227
+ `src/**/*.{${POLICY_SOURCE_EXTENSIONS.join(',')}}`,
228
+ ])
229
+
230
+ /**
231
+ * Matches the lint population the line-ending rule runs over, as the Oxlint configuration declares
232
+ * it.
233
+ */
234
+ export const POLICY_ENDING_GLOBS: readonly string[] = Object.freeze([
235
+ 'app/**/*.ts',
236
+ 'configs/**/*.ts',
237
+ 'src/**/*.ts',
238
+ ])
239
+
240
+ /**
241
+ * Matches the file name shape an implementation file takes, holding the class that matches its
242
+ * stem.
243
+ */
244
+ export const POLICY_CLASS_PATTERN = /^[A-Z][A-Za-z0-9]*\.ts$/u
245
+
246
+ /** Matches the name shape every constants.ts declaration takes. */
247
+ export const POLICY_CONSTANT_PATTERN = /^[A-Z][A-Z0-9_]*$/u
248
+
249
+ /** Matches the file name shape a direct module of a registered function domain takes. */
250
+ export const POLICY_DOMAIN_PATTERN = /^[a-z][A-Za-z0-9]*\.ts$/u
251
+
252
+ /** Matches a first word that reads as a third-person verb. */
253
+ export const POLICY_VOICE_PATTERN = /^[A-Z][a-z]*s$/u
254
+
255
+ /** Matches the boundary a description paragraph's first sentence ends at. */
256
+ export const POLICY_SENTENCE_PATTERN = /\.\s|\.$/u
257
+
258
+ /** Matches the continuation marker a doc block repeats on each line after its opening. */
259
+ export const POLICY_MARKER_PATTERN = /^\s*\*\s?/u
260
+
261
+ /** Matches one line break in any host's form. */
262
+ export const POLICY_BREAK_PATTERN = /\r\n|\r|\n/u
263
+
264
+ /** Matches a fenced code block, opening run through closing run. */
265
+ export const POLICY_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[^\n]*$/gmu
266
+
267
+ /** Matches an inline code span, including one a line break runs through. */
268
+ export const POLICY_SPAN_PATTERN = /(`+)(?!`)[\s\S]*?[^`]\1(?!`)/gu
269
+
270
+ /** Matches a link or inherited-documentation tag, whose target is a symbol rather than prose. */
271
+ export const POLICY_TAG_PATTERN = /\{@(?:linkcode|linkplain|link|inheritDoc)\b[^}]*\}/giu
272
+
273
+ /** Matches a URL, whose segments are an address rather than prose. */
274
+ export const POLICY_URL_PATTERN = /https?:\/\/\S+/gu
275
+
276
+ /**
277
+ * Lists the words ending in `s` that open a sentence without being a third-person verb.
278
+ *
279
+ * @remarks
280
+ * The voice rule reads a first word rather than a parsed verb, so a demonstrative, a pronoun, an
281
+ * adverb, and a singular noun ending in `s` each need naming here to stay refused.
282
+ */
283
+ export const POLICY_VOICE_STOPWORDS: readonly string[] = Object.freeze([
284
+ 'Access',
285
+ 'Across',
286
+ 'Address',
287
+ 'Alias',
288
+ 'Always',
289
+ 'Analysis',
290
+ 'Assess',
291
+ 'Basis',
292
+ 'Bias',
293
+ 'Bus',
294
+ 'Business',
295
+ 'Canvas',
296
+ 'Chaos',
297
+ 'Class',
298
+ 'Compress',
299
+ 'Cross',
300
+ 'Discuss',
301
+ 'Dismiss',
302
+ 'Express',
303
+ 'Focus',
304
+ 'Gas',
305
+ 'Guess',
306
+ 'Harness',
307
+ 'Its',
308
+ 'Lens',
309
+ 'Miss',
310
+ 'Numerous',
311
+ 'Pass',
312
+ 'Perhaps',
313
+ 'Plus',
314
+ 'Press',
315
+ 'Previous',
316
+ 'Process',
317
+ 'Progress',
318
+ 'Series',
319
+ 'Sometimes',
320
+ 'Status',
321
+ 'Success',
322
+ 'This',
323
+ 'Thus',
324
+ 'Unless',
325
+ 'Various',
326
+ 'Was',
327
+ 'Whereas',
328
+ 'Witness',
329
+ 'Yes',
330
+ ])
331
+
332
+ /**
333
+ * Lists every substitution-table row whose ban is unconditional, beside its replacement.
334
+ *
335
+ * @remarks
336
+ * Each pattern is case-insensitive, word-bounded, and global, and carries the inflections its row
337
+ * reaches.
338
+ */
339
+ export const POLICY_BANNED_TERMS: readonly PolicyTerm[] = Object.freeze([
340
+ { term: 'should', pattern: /\bshould\b/giu, replacement: 'must, can, might, or the imperative' },
341
+ { term: 'simply', pattern: /\bsimply\b/giu, replacement: 'delete' },
342
+ { term: 'easy', pattern: /\beas(?:y|ier|iest|ily)\b/giu, replacement: 'delete' },
343
+ { term: 'just', pattern: /\bjust\b/giu, replacement: 'delete' },
344
+ { term: 'currently', pattern: /\bcurrently\b/giu, replacement: 'delete, or give the date' },
345
+ { term: 'utilize', pattern: /\butiliz(?:e|es|ed|ing|ation)\b/giu, replacement: 'use' },
346
+ { term: 'leverage', pattern: /\bleverag(?:e|es|ed|ing)\b/giu, replacement: 'use' },
347
+ { term: 'via', pattern: /\bvia\b/giu, replacement: 'through, by using' },
348
+ { term: 'in order to', pattern: /\bin order to\b/giu, replacement: 'to' },
349
+ { term: 'e.g.', pattern: /\be\.g\./giu, replacement: 'for example' },
350
+ { term: 'i.e.', pattern: /\bi\.e\./giu, replacement: 'that is' },
351
+ { term: 'etc.', pattern: /\betc\./giu, replacement: 'bound the list, or recast the sentence' },
352
+ { term: 'performant', pattern: /\bperformant\b/giu, replacement: 'the measured property' },
353
+ { term: 'robust', pattern: /\brobust(?:ly|ness)?\b/giu, replacement: 'the measured property' },
354
+ { term: 'allows you to', pattern: /\ballows you to\b/giu, replacement: 'lets you' },
355
+ { term: 'and/or', pattern: /\band\/or\b/giu, replacement: 'and, or, or both' },
356
+ { term: 'please', pattern: /\bplease\b/giu, replacement: 'delete' },
357
+ { term: 'sanity check', pattern: /\bsanity[ -]check/giu, replacement: 'quick check' },
358
+ { term: 'dummy', pattern: /\bdumm(?:y|ies)\b/giu, replacement: 'placeholder' },
359
+ { term: 'blacklist', pattern: /\bblacklist(?:s|ed|ing)?\b/giu, replacement: 'denylist' },
360
+ { term: 'whitelist', pattern: /\bwhitelist(?:s|ed|ing)?\b/giu, replacement: 'allowlist' },
361
+ { term: 'slave', pattern: /\bslave\b/giu, replacement: 'replica' },
362
+ ])
363
+
364
+ /**
365
+ * Lists every substitution-table row a reader rules by sense, which no pattern matches.
366
+ *
367
+ * @remarks
368
+ * Each row carries a permitted sense: a date value, a version value, a causal clause, and the name
369
+ * a replication topology takes. The currency check proves each row is registered here.
370
+ */
371
+ export const POLICY_JUDGED_TERMS: readonly string[] = Object.freeze([
372
+ 'now',
373
+ 'new',
374
+ 'latest',
375
+ 'once',
376
+ 'since',
377
+ 'master',
378
+ ])
379
+
380
+ /** Returns the file name a policy rule keys on, read from either host separator. */
381
+ export function pathToPolicyFile(filename: string): string {
382
+ const normalized = filename.replaceAll('\\', '/')
383
+ return normalized.slice(normalized.lastIndexOf('/') + 1)
384
+ }
385
+
386
+ /** Returns the folder path a policy rule keys on, read from either host separator. */
387
+ export function pathToPolicyFolder(filename: string): string {
388
+ const normalized = filename.replaceAll('\\', '/')
389
+ const boundary = normalized.lastIndexOf('/')
390
+ return boundary === -1 ? '' : normalized.slice(0, boundary)
391
+ }
392
+
393
+ /**
394
+ * Returns the workspace-relative path when the file sits under the directory the linter resolved it
395
+ * against, else the path as given. The linter resolves each file against its own directory, the
396
+ * workspace root under the `lint` scripts and its package directory under `RuleTester`, so a file
397
+ * outside that directory keeps its path and matches no registered domain folder, because a
398
+ * registered folder is workspace-relative. For a workspace at the filesystem root the prefix is the
399
+ * separator alone. A drive-letter case difference between the two arguments is not folded.
400
+ */
401
+ export function pathToPolicyRelative(filename: string, cwd: string): string {
402
+ const normalizedFile = filename.replaceAll('\\', '/')
403
+ const normalizedCwd = cwd.replaceAll('\\', '/').replace(/\/+$/u, '')
404
+ const prefix = `${normalizedCwd}/`
405
+ return normalizedFile.startsWith(prefix) ? normalizedFile.slice(prefix.length) : normalizedFile
406
+ }
407
+
408
+ /** Returns the extensionless stem of one policy file name. */
409
+ export function fileToPolicyStem(file: string): string {
410
+ const boundary = file.lastIndexOf('.')
411
+ return boundary <= 0 ? file : file.slice(0, boundary)
412
+ }
413
+
414
+ /** Reports whether a path names an ambient declaration file, which no policy rule inspects. */
415
+ export function isPolicyAmbient(filename: string): boolean {
416
+ const file = pathToPolicyFile(filename)
417
+ return POLICY_AMBIENT_SUFFIXES.some((suffix) => file.endsWith(suffix))
418
+ }
419
+
420
+ /**
421
+ * Reports whether a path is a direct module of a fleet-registered function domain.
422
+ *
423
+ * The registered folder is a workspace-relative path, compared by equality after the linter's own
424
+ * directory is stripped from the given path.
425
+ */
426
+ export function isPolicyDomain(filename: string, cwd: string): boolean {
427
+ const relative = pathToPolicyRelative(filename, cwd)
428
+ const file = pathToPolicyFile(relative)
429
+ const folder = pathToPolicyFolder(relative)
430
+ return (
431
+ FUNCTION_DOMAIN_FOLDERS.some((registered) => folder === registered) &&
432
+ POLICY_DOMAIN_PATTERN.test(file) &&
433
+ file !== 'index.ts' &&
434
+ file !== 'main.ts' &&
435
+ !CENTRAL_SOURCE_FILES.includes(file)
436
+ )
437
+ }
438
+
439
+ /** Returns the identifier name a node carries, or `undefined` for any other syntax. */
440
+ export function identifierToPolicyName(
441
+ node: PolicyExpression | null | undefined,
442
+ ): string | undefined {
443
+ if (node === undefined || node === null || node.type !== 'Identifier') return undefined
444
+ return typeof node.name === 'string' ? node.name : undefined
445
+ }
446
+
447
+ /** Returns the literal text a node carries, through a single-quasi template literal. */
448
+ export function expressionToPolicyText(
449
+ node: PolicyExpression | null | undefined,
450
+ ): string | undefined {
451
+ if (node === undefined || node === null) return undefined
452
+ if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : undefined
453
+ if (
454
+ node.type !== 'TemplateLiteral' ||
455
+ node.quasis?.length !== 1 ||
456
+ node.expressions?.length !== 0
457
+ ) {
458
+ return undefined
459
+ }
460
+ const value = node.quasis[0]?.value
461
+ if (typeof value !== 'object' || value === null) return undefined
462
+ const cooked: unknown = Object.getOwnPropertyDescriptor(value, 'cooked')?.value
463
+ const raw: unknown = Object.getOwnPropertyDescriptor(value, 'raw')?.value
464
+ return typeof cooked === 'string' ? cooked : typeof raw === 'string' ? raw : undefined
465
+ }
466
+
467
+ /** Returns the single body expression a node holds, excluding a statement list. */
468
+ export function expressionToPolicyBody(node: PolicyExpression): PolicyExpression | undefined {
469
+ const body = node.body
470
+ if (body === undefined) return undefined
471
+ return 'type' in body ? body : undefined
472
+ }
473
+
474
+ /** Returns the top-level statements a program holds. */
475
+ export function programToPolicyStatements(node: PolicyExpression): readonly PolicyExpression[] {
476
+ const body = node.body
477
+ if (body === undefined || 'type' in body) return []
478
+ return body
479
+ }
480
+
481
+ /** Returns the declaration a top-level statement holds, through either export form. */
482
+ export function statementToPolicyDeclaration(node: PolicyExpression): PolicyExpression | undefined {
483
+ if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') {
484
+ return node.declaration
485
+ }
486
+ return node
487
+ }
488
+
489
+ /** Reports whether a statement sits at module scope, through either export form. */
490
+ export function isPolicyTop(node: PolicyExpression): boolean {
491
+ const parent = node.parent
492
+ if (parent === undefined || parent === null) return false
493
+ if (parent.type === 'Program') return true
494
+ return (
495
+ (parent.type === 'ExportNamedDeclaration' || parent.type === 'ExportDefaultDeclaration') &&
496
+ parent.parent?.type === 'Program'
497
+ )
498
+ }
499
+
500
+ /** Reports whether a policy expression is runtime function syntax. */
76
501
  export function isPolicyFunction(node: PolicyExpression): boolean {
77
502
  return (
78
503
  node.type === 'FunctionDeclaration' ||
@@ -81,12 +506,17 @@ export function isPolicyFunction(node: PolicyExpression): boolean {
81
506
  )
82
507
  }
83
508
 
84
- /** Whether a policy function is anonymous. */
509
+ /** Reports whether a node declares a module function, including a signature without a body. */
510
+ export function isPolicyDeclaredFunction(node: PolicyExpression): boolean {
511
+ return node.type === 'FunctionDeclaration' || node.type === 'TSDeclareFunction'
512
+ }
513
+
514
+ /** Reports whether a policy function is anonymous. */
85
515
  export function isPolicyAnonymous(node: PolicyExpression): boolean {
86
516
  return node.type === 'ArrowFunctionExpression' || node.id === null
87
517
  }
88
518
 
89
- /** Return the outermost parenthesized expression holding a policy function. */
519
+ /** Returns the outermost parenthesized expression holding a policy function. */
90
520
  export function functionToPolicyPosition(node: PolicyExpression): PolicyExpression {
91
521
  let position = node
92
522
  while (position.parent?.type === 'ParenthesizedExpression') {
@@ -95,7 +525,7 @@ export function functionToPolicyPosition(node: PolicyExpression): PolicyExpressi
95
525
  return position
96
526
  }
97
527
 
98
- /** Whether a policy function is an anonymous callback passed directly as an argument. */
528
+ /** Reports whether a policy function is an anonymous callback passed directly as an argument. */
99
529
  export function isPolicyCallback(node: PolicyExpression): boolean {
100
530
  if (!isPolicyAnonymous(node)) return false
101
531
  const position = functionToPolicyPosition(node)
@@ -106,7 +536,7 @@ export function isPolicyCallback(node: PolicyExpression): boolean {
106
536
  )
107
537
  }
108
538
 
109
- /** Whether a policy function is an anonymous function returned directly as a result. */
539
+ /** Reports whether a policy function is an anonymous function returned directly as a result. */
110
540
  export function isPolicyResult(node: PolicyExpression): boolean {
111
541
  if (!isPolicyAnonymous(node)) return false
112
542
  const position = functionToPolicyPosition(node)
@@ -117,7 +547,7 @@ export function isPolicyResult(node: PolicyExpression): boolean {
117
547
  )
118
548
  }
119
549
 
120
- /** Whether an Oxlint function expression represents method syntax. */
550
+ /** Reports whether an Oxlint function expression represents method syntax. */
121
551
  export function isPolicyMethod(node: PolicyExpression): boolean {
122
552
  const parent = node.parent
123
553
  return (
@@ -129,7 +559,10 @@ export function isPolicyMethod(node: PolicyExpression): boolean {
129
559
  )
130
560
  }
131
561
 
132
- /** Whether a policy function sits inside another function before any class-expression boundary. */
562
+ /**
563
+ * Reports whether a policy function sits inside another function before any class-expression
564
+ * boundary.
565
+ */
133
566
  export function hasPolicyFunctionAncestor(node: PolicyExpression): boolean {
134
567
  let parent = node.parent
135
568
  let method = false
@@ -145,15 +578,16 @@ export function hasPolicyFunctionAncestor(node: PolicyExpression): boolean {
145
578
  return method
146
579
  }
147
580
 
148
- /** Whether an arrow is the policy plugin's sanctioned visitor-table delegation. */
581
+ /** Reports whether an arrow is the policy plugin's sanctioned visitor-table delegation. */
149
582
  export function isPolicyVisitor(node: PolicyExpression): boolean {
583
+ const body = expressionToPolicyBody(node)
150
584
  if (
151
585
  node.type !== 'ArrowFunctionExpression' ||
152
586
  node.expression !== true ||
153
- node.body?.type !== 'CallExpression' ||
154
- node.body.callee?.type !== 'Identifier' ||
155
- typeof node.body.callee.name !== 'string' ||
156
- !node.body.callee.name.startsWith('report')
587
+ body?.type !== 'CallExpression' ||
588
+ body.callee?.type !== 'Identifier' ||
589
+ typeof body.callee.name !== 'string' ||
590
+ !body.callee.name.startsWith('report')
157
591
  ) {
158
592
  return false
159
593
  }
@@ -180,7 +614,237 @@ export function isPolicyVisitor(node: PolicyExpression): boolean {
180
614
  )
181
615
  }
182
616
 
183
- /** Report function syntax nested inside another function body. */
617
+ /**
618
+ * Returns the module-scope statement that owns a policy function, or `undefined` when none does.
619
+ *
620
+ * A class declaration or class expression on the way up ends the search, because the placement law
621
+ * reads module regions rather than class members.
622
+ */
623
+ export function functionToPolicyRegion(node: PolicyExpression): PolicyExpression | undefined {
624
+ let current = node
625
+ let parent = current.parent
626
+ while (parent !== undefined && parent !== null) {
627
+ if (parent.type === 'ClassDeclaration' || parent.type === 'ClassExpression') return undefined
628
+ if (parent.type === 'Program') return statementToPolicyDeclaration(current)
629
+ current = parent
630
+ parent = parent.parent
631
+ }
632
+ return undefined
633
+ }
634
+
635
+ /** Lists every module function a top-level statement declares, paired with its declared name. */
636
+ export function statementToPolicyBindings(node: PolicyExpression): readonly PolicyBinding[] {
637
+ if (isPolicyDeclaredFunction(node)) {
638
+ return [{ node, name: identifierToPolicyName(node.id) }]
639
+ }
640
+ if (node.type !== 'VariableDeclaration') return []
641
+ const bindings: PolicyBinding[] = []
642
+ for (const declarator of node.declarations ?? []) {
643
+ const init = declarator.init
644
+ if (init === undefined || init === null || !isPolicyFunction(init)) continue
645
+ const name = identifierToPolicyName(declarator.id)
646
+ if (name === undefined) continue
647
+ bindings.push({ node: declarator, name })
648
+ }
649
+ return bindings
650
+ }
651
+
652
+ /** Reports whether a call trims a whole payload before splitting it on a line feed. */
653
+ export function isPolicySplit(node: PolicyExpression): boolean {
654
+ if (node.type !== 'CallExpression' || node.arguments?.length !== 1) return false
655
+ const split = node.callee
656
+ if (split?.type !== 'MemberExpression' || split.computed === true) return false
657
+ if (identifierToPolicyName(split.property) !== 'split') return false
658
+ if (expressionToPolicyText(node.arguments[0]) !== '\n') return false
659
+ const trim = split.object
660
+ if (trim?.type !== 'CallExpression' || trim.arguments?.length !== 0) return false
661
+ const read = trim.callee
662
+ return (
663
+ read?.type === 'MemberExpression' &&
664
+ read.computed !== true &&
665
+ identifierToPolicyName(read.property) === 'trim'
666
+ )
667
+ }
668
+
669
+ /** Reports whether an expression reads the host line ending from a binding named os. */
670
+ export function isPolicyTerminator(node: PolicyExpression): boolean {
671
+ return (
672
+ node.type === 'MemberExpression' &&
673
+ node.computed !== true &&
674
+ identifierToPolicyName(node.property) === 'EOL' &&
675
+ identifierToPolicyName(node.object) === 'os'
676
+ )
677
+ }
678
+
679
+ /** Reports whether an import declaration takes the EOL member from the host module. */
680
+ export function importsPolicyTerminator(node: PolicyExpression): boolean {
681
+ const specifier = expressionToPolicyText(node.source)
682
+ if (specifier !== 'node:os' && specifier !== 'os') return false
683
+ return (node.specifiers ?? []).some(
684
+ (element) =>
685
+ element.type === 'ImportSpecifier' && identifierToPolicyName(element.imported) === 'EOL',
686
+ )
687
+ }
688
+
689
+ /**
690
+ * Blanks every character of a matched region, holding its length and its line breaks.
691
+ *
692
+ * @param text - The matched region to blank.
693
+ * @returns The region with each character outside a line break replaced by a space.
694
+ */
695
+ export function blankPolicyText(text: string): string {
696
+ return text.replace(/[^\n]/gu, ' ')
697
+ }
698
+
699
+ /**
700
+ * Blanks the regions of a text whose content is code, an address, or a symbol rather than prose.
701
+ *
702
+ * @remarks
703
+ * A fenced block, an inline code span a line break runs through, a link tag, and a URL each carry
704
+ * tokens a reader is meant to copy rather than read, so a banned term inside one is not prose. Each
705
+ * region is blanked in place, so every offset the caller reports stays the offset in the original
706
+ * text.
707
+ *
708
+ * @param text - The prose to strip, with any continuation marker already removed.
709
+ * @returns The same text with every code, tag, and address region replaced by spaces.
710
+ */
711
+ export function stripPolicyCode(text: string): string {
712
+ const fenced = text.replace(POLICY_FENCE_PATTERN, blankPolicyText)
713
+ const spanned = fenced.replace(POLICY_SPAN_PATTERN, blankPolicyText)
714
+ const tagged = spanned.replace(POLICY_TAG_PATTERN, blankPolicyText)
715
+ return tagged.replace(POLICY_URL_PATTERN, blankPolicyText)
716
+ }
717
+
718
+ /**
719
+ * Reads every banned term a stripped text carries, in offset order.
720
+ *
721
+ * @param text - The prose to read, already stripped of its code regions.
722
+ * @returns One hit per match, each naming its row and the offset the match starts at.
723
+ */
724
+ export function textToPolicyHits(text: string): readonly PolicyHit[] {
725
+ const hits: PolicyHit[] = []
726
+ for (const term of POLICY_BANNED_TERMS) {
727
+ for (const match of text.matchAll(term.pattern)) hits.push({ term, index: match.index })
728
+ }
729
+ return hits.sort((left, right) => left.index - right.index)
730
+ }
731
+
732
+ /**
733
+ * Reads one doc block's description paragraph, which ends at its first block tag.
734
+ *
735
+ * @param comment - The doc block to read.
736
+ * @returns The description with continuation markers removed and whitespace collapsed.
737
+ */
738
+ export function commentToPolicyParagraph(comment: PolicyComment): string {
739
+ const description: string[] = []
740
+ for (const line of comment.value.split(POLICY_BREAK_PATTERN)) {
741
+ const text = line.replace(POLICY_MARKER_PATTERN, '')
742
+ if (text.trimStart().startsWith('@')) break
743
+ description.push(text)
744
+ }
745
+ return description.join(' ').replace(/\s+/gu, ' ').trim()
746
+ }
747
+
748
+ /**
749
+ * Reads the opening word of a description paragraph, punctuation removed.
750
+ *
751
+ * @param paragraph - The collapsed description paragraph.
752
+ * @returns The paragraph's first word reduced to its letters, empty where it has none.
753
+ */
754
+ export function paragraphToPolicyOpener(paragraph: string): string {
755
+ const first = paragraph.match(/^\S+/u)?.[0] ?? ''
756
+ return first.replace(/[^A-Za-z]/gu, '')
757
+ }
758
+
759
+ /**
760
+ * Reports whether an opening word reads as a third-person verb.
761
+ *
762
+ * @param word - The opening word to judge.
763
+ * @returns True if the word ends in `s` and names no registered non-verb; false otherwise.
764
+ */
765
+ export function isPolicyVoiced(word: string): boolean {
766
+ return POLICY_VOICE_PATTERN.test(word) && !POLICY_VOICE_STOPWORDS.includes(word)
767
+ }
768
+
769
+ /**
770
+ * Pairs every exported top-level statement with the doc block written directly above it.
771
+ *
772
+ * @remarks
773
+ * A statement takes the last comment that closes before it, and takes it only where that comment is
774
+ * a doc block and nothing but whitespace separates the two. A blank line between them is still
775
+ * whitespace, so the pairing survives one.
776
+ *
777
+ * @param node - The program node whose top-level statements are read.
778
+ * @param sourceCode - The source-text reader supplying the comments and the text between them.
779
+ * @returns One entry per documented export, each naming its block and its declared symbol.
780
+ */
781
+ export function programToPolicyDocs(
782
+ node: PolicyExpression,
783
+ sourceCode: PolicySourceCode,
784
+ ): readonly PolicyDoc[] {
785
+ const comments = sourceCode.getAllComments()
786
+ const docs: PolicyDoc[] = []
787
+ for (const statement of programToPolicyStatements(node)) {
788
+ if (!statement.type.startsWith('Export')) continue
789
+ const start = statement.range[0]
790
+ let previous: PolicyComment | undefined
791
+ for (const comment of comments) {
792
+ if (comment.range[1] <= start) previous = comment
793
+ }
794
+ if (previous === undefined || previous.type !== 'Block') continue
795
+ if (!previous.value.startsWith('*')) continue
796
+ if (sourceCode.text.slice(previous.range[1], start).trim() !== '') continue
797
+ const declaration = statement.declaration
798
+ docs.push({
799
+ comment: previous,
800
+ name:
801
+ identifierToPolicyName(declaration?.id) ??
802
+ identifierToPolicyName(declaration?.declarations?.[0]?.id),
803
+ })
804
+ }
805
+ return docs
806
+ }
807
+
808
+ /** Reports a doc block whose first sentence is not a third-person summary of its own symbol. */
809
+ export function reportVoice(context: PolicyContext, doc: PolicyDoc): void {
810
+ const paragraph = commentToPolicyParagraph(doc.comment)
811
+ if (!isPolicyVoiced(paragraphToPolicyOpener(paragraph))) {
812
+ context.report({ node: doc.comment, messageId: 'voice' })
813
+ }
814
+ const name = doc.name
815
+ if (name === undefined) return
816
+ const sentence = paragraph.split(POLICY_SENTENCE_PATTERN)[0] ?? ''
817
+ const repeat = new RegExp(`(?<![\\w$])${name.replaceAll('$', '\\$')}(?![\\w$])`, 'u')
818
+ if (repeat.test(sentence)) {
819
+ context.report({ node: doc.comment, messageId: 'name', data: { name } })
820
+ }
821
+ }
822
+
823
+ /** Reports every voice failure among the doc blocks one program's exports carry. */
824
+ export function reportDocs(context: PolicyContext, node: PolicyExpression): void {
825
+ for (const doc of programToPolicyDocs(node, context.sourceCode)) reportVoice(context, doc)
826
+ }
827
+
828
+ /** Reports every banned term one comment's prose carries. */
829
+ export function reportTerm(context: PolicyContext, comment: PolicyComment): void {
830
+ const lines = comment.value
831
+ .split(POLICY_BREAK_PATTERN)
832
+ .map((line) => line.replace(POLICY_MARKER_PATTERN, ''))
833
+ for (const hit of textToPolicyHits(stripPolicyCode(lines.join('\n')))) {
834
+ context.report({
835
+ node: comment,
836
+ messageId: 'term',
837
+ data: { term: hit.term.term, replacement: hit.term.replacement },
838
+ })
839
+ }
840
+ }
841
+
842
+ /** Reports every banned term the comments of one linted file carry. */
843
+ export function reportComments(context: PolicyContext): void {
844
+ for (const comment of context.sourceCode.getAllComments()) reportTerm(context, comment)
845
+ }
846
+
847
+ /** Reports function syntax nested inside another function body. */
184
848
  export function reportNested(context: PolicyContext, node: PolicyExpression): void {
185
849
  if (
186
850
  !hasPolicyFunctionAncestor(node) ||
@@ -194,7 +858,7 @@ export function reportNested(context: PolicyContext, node: PolicyExpression): vo
194
858
  context.report({ node, messageId: 'nested' })
195
859
  }
196
860
 
197
- /** Report banned calls on the named Vitest and Jest framework objects. */
861
+ /** Reports banned calls on the named Vitest and Jest framework objects. */
198
862
  export function reportMocking(context: PolicyContext, node: PolicyExpression): void {
199
863
  const callee = node.callee
200
864
  if (
@@ -213,26 +877,9 @@ export function reportMocking(context: PolicyContext, node: PolicyExpression): v
213
877
  }
214
878
 
215
879
  const property = callee.property
216
- let member: string | undefined
217
- if (callee.computed) {
218
- if (property.type === 'Literal' && typeof property.value === 'string') {
219
- member = property.value
220
- } else if (
221
- property.type === 'TemplateLiteral' &&
222
- property.quasis?.length === 1 &&
223
- property.expressions?.length === 0
224
- ) {
225
- const quasi = property.quasis[0]
226
- const value = quasi?.value
227
- if (typeof value === 'object' && value !== null) {
228
- const cooked: unknown = Object.getOwnPropertyDescriptor(value, 'cooked')?.value
229
- const raw: unknown = Object.getOwnPropertyDescriptor(value, 'raw')?.value
230
- member = typeof cooked === 'string' ? cooked : typeof raw === 'string' ? raw : undefined
231
- }
232
- }
233
- } else if (property.type === 'Identifier' && typeof property.name === 'string') {
234
- member = property.name
235
- }
880
+ const member = callee.computed
881
+ ? expressionToPolicyText(property)
882
+ : identifierToPolicyName(property)
236
883
 
237
884
  switch (member) {
238
885
  case 'mock':
@@ -255,7 +902,7 @@ export function reportMocking(context: PolicyContext, node: PolicyExpression): v
255
902
  }
256
903
  }
257
904
 
258
- /** Report TypeScript privacy keywords on class members. */
905
+ /** Reports TypeScript privacy keywords on class members. */
259
906
  export function reportPrivacy(context: PolicyContext, node: PolicyExpression): void {
260
907
  if (node.accessibility === 'private' || node.accessibility === 'protected') {
261
908
  context.report({
@@ -266,7 +913,145 @@ export function reportPrivacy(context: PolicyContext, node: PolicyExpression): v
266
913
  }
267
914
  }
268
915
 
269
- /** Ban function declarations and assignments inside another function body. */
916
+ /** Reports a centralized declaration that carries no export. */
917
+ export function reportHidden(context: PolicyContext, node: PolicyExpression): void {
918
+ if (isPolicyAmbient(context.filename)) return
919
+ if (!CENTRAL_SOURCE_FILES.includes(pathToPolicyFile(context.filename))) return
920
+ if (node.parent?.type !== 'Program') return
921
+ context.report({ node, messageId: 'hidden' })
922
+ }
923
+
924
+ /** Reports a type declaration outside types.ts. */
925
+ export function reportType(context: PolicyContext, node: PolicyExpression): void {
926
+ if (isPolicyAmbient(context.filename) || !isPolicyTop(node)) return
927
+ if (pathToPolicyFile(context.filename) === 'types.ts') return
928
+ context.report({ node, messageId: 'type' })
929
+ }
930
+
931
+ /** Reports a class whose file neither names it nor collects errors. */
932
+ export function reportClass(context: PolicyContext, node: PolicyExpression): void {
933
+ if (isPolicyAmbient(context.filename) || !isPolicyTop(node)) return
934
+ const file = pathToPolicyFile(context.filename)
935
+ if (file === 'errors.ts') return
936
+ if (
937
+ POLICY_CLASS_PATTERN.test(file) &&
938
+ identifierToPolicyName(node.id) === fileToPolicyStem(file)
939
+ ) {
940
+ return
941
+ }
942
+ context.report({ node, messageId: 'class' })
943
+ }
944
+
945
+ /** Reports module data outside a data-kind file. */
946
+ export function reportData(context: PolicyContext, node: PolicyExpression): void {
947
+ if (isPolicyAmbient(context.filename) || !isPolicyTop(node)) return
948
+ const file = pathToPolicyFile(context.filename)
949
+ if (DATA_SOURCE_FILES.includes(file) || DATA_EXEMPT_FILES.includes(file)) return
950
+ for (const declarator of node.declarations ?? []) {
951
+ const init = declarator.init
952
+ if (init !== undefined && init !== null && isPolicyFunction(init)) continue
953
+ context.report({ node: declarator, messageId: 'data' })
954
+ }
955
+ }
956
+
957
+ /** Reports module function syntax outside a function-kind file. */
958
+ export function reportFunction(context: PolicyContext, node: PolicyExpression): void {
959
+ if (isPolicyAmbient(context.filename)) return
960
+ if (FUNCTION_SOURCE_FILES.includes(pathToPolicyFile(context.filename))) return
961
+ if (isPolicyDomain(context.filename, context.cwd)) return
962
+ const region = functionToPolicyRegion(node)
963
+ if (region === undefined) return
964
+ if (isPolicyDeclaredFunction(region)) {
965
+ if (region === node) context.report({ node, messageId: 'function' })
966
+ return
967
+ }
968
+ if (region.type !== 'VariableDeclaration') return
969
+ if (isPolicyCallback(node) || isPolicyResult(node)) return
970
+ context.report({ node, messageId: 'function' })
971
+ }
972
+
973
+ /** Reports a constants.ts declaration that is mutable, misnamed, or a bare collection. */
974
+ export function reportConstant(context: PolicyContext, node: PolicyExpression): void {
975
+ if (isPolicyAmbient(context.filename) || !isPolicyTop(node)) return
976
+ if (pathToPolicyFile(context.filename) !== 'constants.ts') return
977
+ if (node.kind !== 'const') context.report({ node, messageId: 'mutable' })
978
+ for (const declarator of node.declarations ?? []) {
979
+ const name = identifierToPolicyName(declarator.id)
980
+ if (name === undefined || !POLICY_CONSTANT_PATTERN.test(name)) {
981
+ context.report({ node: declarator, messageId: 'naming' })
982
+ }
983
+ const init = declarator.init
984
+ if (init?.type === 'ArrayExpression' || init?.type === 'ObjectExpression') {
985
+ context.report({ node: declarator, messageId: 'collection' })
986
+ }
987
+ }
988
+ }
989
+
990
+ /** Reports a parsers.ts function whose name lacks the parse prefix. */
991
+ export function reportParser(context: PolicyContext, node: PolicyExpression): void {
992
+ if (isPolicyAmbient(context.filename) || !isPolicyTop(node)) return
993
+ if (pathToPolicyFile(context.filename) !== 'parsers.ts') return
994
+ for (const binding of statementToPolicyBindings(node)) {
995
+ if (binding.name === undefined || !binding.name.startsWith('parse')) {
996
+ context.report({ node: binding.node, messageId: 'parser' })
997
+ }
998
+ }
999
+ }
1000
+
1001
+ /** Reports a factories.ts function whose name lacks the create prefix. */
1002
+ export function reportFactory(context: PolicyContext, node: PolicyExpression): void {
1003
+ if (isPolicyAmbient(context.filename) || !isPolicyTop(node)) return
1004
+ if (pathToPolicyFile(context.filename) !== 'factories.ts') return
1005
+ for (const binding of statementToPolicyBindings(node)) {
1006
+ if (binding.name === undefined || !binding.name.startsWith('create')) {
1007
+ context.report({ node: binding.node, messageId: 'factory' })
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ /** Reports a registered function domain taken as a file, or a malformed module inside one. */
1013
+ export function reportDomain(context: PolicyContext, node: PolicyExpression): void {
1014
+ if (isPolicyAmbient(context.filename)) return
1015
+ const file = pathToPolicyFile(context.filename)
1016
+ if (FUNCTION_DOMAIN_NAMES.includes(fileToPolicyStem(file))) {
1017
+ context.report({ node, messageId: 'file' })
1018
+ }
1019
+ if (!isPolicyDomain(context.filename, context.cwd)) return
1020
+ const expected = fileToPolicyStem(file)
1021
+ let implementations = 0
1022
+ let malformed = 0
1023
+ for (const statement of programToPolicyStatements(node)) {
1024
+ if (statement.type === 'ImportDeclaration') continue
1025
+ const declaration = statementToPolicyDeclaration(statement)
1026
+ if (declaration === undefined || !isPolicyDeclaredFunction(declaration)) {
1027
+ malformed += 1
1028
+ continue
1029
+ }
1030
+ if (declaration.type === 'FunctionDeclaration') implementations += 1
1031
+ if (
1032
+ statement.type !== 'ExportNamedDeclaration' ||
1033
+ identifierToPolicyName(declaration.id) !== expected
1034
+ ) {
1035
+ malformed += 1
1036
+ }
1037
+ }
1038
+ if (implementations !== 1 || malformed > 0) context.report({ node, messageId: 'module' })
1039
+ }
1040
+
1041
+ /** Reports source that reads the host line ending or splits arrived text before trimming it. */
1042
+ export function reportEnding(context: PolicyContext, node: PolicyExpression): void {
1043
+ if (isPolicyAmbient(context.filename)) return
1044
+ if (isPolicySplit(node)) context.report({ node, messageId: 'split' })
1045
+ if (isPolicyTerminator(node)) context.report({ node, messageId: 'terminator' })
1046
+ }
1047
+
1048
+ /** Reports an import that takes the host line ending from the operating-system module. */
1049
+ export function reportEndingImport(context: PolicyContext, node: PolicyExpression): void {
1050
+ if (isPolicyAmbient(context.filename)) return
1051
+ if (importsPolicyTerminator(node)) context.report({ node, messageId: 'terminator' })
1052
+ }
1053
+
1054
+ /** Bans function declarations and assignments inside another function body. */
270
1055
  export const NESTED_RULE: PolicyRuleInterface = {
271
1056
  meta: {
272
1057
  type: 'problem',
@@ -287,7 +1072,7 @@ export const NESTED_RULE: PolicyRuleInterface = {
287
1072
  },
288
1073
  }
289
1074
 
290
- /** Ban framework mocking, spying, fake clocks, and global or environment stubs. */
1075
+ /** Bans framework mocking, spying, fake clocks, and global or environment stubs. */
291
1076
  export const MOCKING_RULE: PolicyRuleInterface = {
292
1077
  meta: {
293
1078
  type: 'problem',
@@ -310,7 +1095,7 @@ export const MOCKING_RULE: PolicyRuleInterface = {
310
1095
  },
311
1096
  }
312
1097
 
313
- /** Ban compile-time-only TypeScript privacy keywords on class members. */
1098
+ /** Bans compile-time-only TypeScript privacy keywords on class members. */
314
1099
  export const PRIVACY_RULE: PolicyRuleInterface = {
315
1100
  meta: {
316
1101
  type: 'problem',
@@ -334,12 +1119,276 @@ export const PRIVACY_RULE: PolicyRuleInterface = {
334
1119
  },
335
1120
  }
336
1121
 
337
- /** The workspace Oxlint plugin. */
1122
+ /** Bans a centralized declaration that no export reaches. */
1123
+ export const HIDDEN_RULE: PolicyRuleInterface = {
1124
+ meta: {
1125
+ type: 'problem',
1126
+ docs: {
1127
+ description: 'Disallow an unexported declaration in a centralized module.',
1128
+ },
1129
+ messages: {
1130
+ hidden:
1131
+ 'Export this declaration or fold it into its caller; a centralized module hides nothing.',
1132
+ },
1133
+ },
1134
+ create(context) {
1135
+ return {
1136
+ ClassDeclaration: (node) => reportHidden(context, node),
1137
+ FunctionDeclaration: (node) => reportHidden(context, node),
1138
+ TSDeclareFunction: (node) => reportHidden(context, node),
1139
+ TSEnumDeclaration: (node) => reportHidden(context, node),
1140
+ TSInterfaceDeclaration: (node) => reportHidden(context, node),
1141
+ TSModuleDeclaration: (node) => reportHidden(context, node),
1142
+ TSTypeAliasDeclaration: (node) => reportHidden(context, node),
1143
+ VariableDeclaration: (node) => reportHidden(context, node),
1144
+ }
1145
+ },
1146
+ }
1147
+
1148
+ /** Bans a type declaration outside its module's types.ts. */
1149
+ export const TYPE_RULE: PolicyRuleInterface = {
1150
+ meta: {
1151
+ type: 'problem',
1152
+ docs: {
1153
+ description: 'Disallow an interface, type alias, enum, or namespace outside types.ts.',
1154
+ },
1155
+ messages: {
1156
+ type: 'Move this declaration to the module types.ts file, which holds every reusable type.',
1157
+ },
1158
+ },
1159
+ create(context) {
1160
+ return {
1161
+ TSEnumDeclaration: (node) => reportType(context, node),
1162
+ TSInterfaceDeclaration: (node) => reportType(context, node),
1163
+ TSModuleDeclaration: (node) => reportType(context, node),
1164
+ TSTypeAliasDeclaration: (node) => reportType(context, node),
1165
+ }
1166
+ },
1167
+ }
1168
+
1169
+ /** Bans a class outside the implementation file that names it. */
1170
+ export const CLASS_RULE: PolicyRuleInterface = {
1171
+ meta: {
1172
+ type: 'problem',
1173
+ docs: {
1174
+ description: 'Disallow a class outside errors.ts or the file named for it.',
1175
+ },
1176
+ messages: {
1177
+ class: 'Move this class to a PascalCase implementation file named for it, or to errors.ts.',
1178
+ },
1179
+ },
1180
+ create(context) {
1181
+ return {
1182
+ ClassDeclaration: (node) => reportClass(context, node),
1183
+ }
1184
+ },
1185
+ }
1186
+
1187
+ /** Bans module data outside a data-kind file. */
1188
+ export const DATA_RULE: PolicyRuleInterface = {
1189
+ meta: {
1190
+ type: 'problem',
1191
+ docs: {
1192
+ description: 'Disallow a module-scope value declaration outside a data-kind file.',
1193
+ },
1194
+ messages: {
1195
+ data: 'Move this module data to constants.ts or another data-kind file.',
1196
+ },
1197
+ },
1198
+ create(context) {
1199
+ return {
1200
+ VariableDeclaration: (node) => reportData(context, node),
1201
+ }
1202
+ },
1203
+ }
1204
+
1205
+ /** Bans module function syntax outside a function-kind file. */
1206
+ export const FUNCTION_RULE: PolicyRuleInterface = {
1207
+ meta: {
1208
+ type: 'problem',
1209
+ docs: {
1210
+ description:
1211
+ 'Disallow module-scope function syntax outside a function-kind file or a registered function domain.',
1212
+ },
1213
+ messages: {
1214
+ function:
1215
+ 'Move this function to a function-kind file or a registered function-domain module; only a directly passed callback and a directly returned function may sit in module data.',
1216
+ },
1217
+ },
1218
+ create(context) {
1219
+ return {
1220
+ ArrowFunctionExpression: (node) => reportFunction(context, node),
1221
+ FunctionDeclaration: (node) => reportFunction(context, node),
1222
+ FunctionExpression: (node) => reportFunction(context, node),
1223
+ TSDeclareFunction: (node) => reportFunction(context, node),
1224
+ }
1225
+ },
1226
+ }
1227
+
1228
+ /** Bans a constants.ts declaration that is mutable, misnamed, or a bare collection. */
1229
+ export const CONSTANT_RULE: PolicyRuleInterface = {
1230
+ meta: {
1231
+ type: 'problem',
1232
+ docs: {
1233
+ description:
1234
+ 'Disallow a non-const, non-UPPER_SNAKE_CASE, or bare-collection declaration in constants.ts.',
1235
+ },
1236
+ messages: {
1237
+ mutable: 'Declare every constants.ts binding with const.',
1238
+ naming: 'Name every constants.ts declaration in UPPER_SNAKE_CASE.',
1239
+ collection: 'Freeze this collection through a call; constants.ts holds no bare literal.',
1240
+ },
1241
+ },
1242
+ create(context) {
1243
+ return {
1244
+ VariableDeclaration: (node) => reportConstant(context, node),
1245
+ }
1246
+ },
1247
+ }
1248
+
1249
+ /** Bans a parsers.ts function whose name lacks the parse prefix. */
1250
+ export const PARSER_RULE: PolicyRuleInterface = {
1251
+ meta: {
1252
+ type: 'problem',
1253
+ docs: {
1254
+ description: 'Disallow a parsers.ts function whose name does not start with parse.',
1255
+ },
1256
+ messages: {
1257
+ parser:
1258
+ 'Name this parsers.ts function with the parse prefix, or move it to its own kind file.',
1259
+ },
1260
+ },
1261
+ create(context) {
1262
+ return {
1263
+ FunctionDeclaration: (node) => reportParser(context, node),
1264
+ TSDeclareFunction: (node) => reportParser(context, node),
1265
+ VariableDeclaration: (node) => reportParser(context, node),
1266
+ }
1267
+ },
1268
+ }
1269
+
1270
+ /** Bans a factories.ts function whose name lacks the create prefix. */
1271
+ export const FACTORY_RULE: PolicyRuleInterface = {
1272
+ meta: {
1273
+ type: 'problem',
1274
+ docs: {
1275
+ description: 'Disallow a factories.ts function whose name does not start with create.',
1276
+ },
1277
+ messages: {
1278
+ factory:
1279
+ 'Name this factories.ts function with the create prefix, or move it to its own kind file.',
1280
+ },
1281
+ },
1282
+ create(context) {
1283
+ return {
1284
+ FunctionDeclaration: (node) => reportFactory(context, node),
1285
+ TSDeclareFunction: (node) => reportFactory(context, node),
1286
+ VariableDeclaration: (node) => reportFactory(context, node),
1287
+ }
1288
+ },
1289
+ }
1290
+
1291
+ /** Bans a malformed module in a registered function domain, and a file named for one. */
1292
+ export const DOMAIN_RULE: PolicyRuleInterface = {
1293
+ meta: {
1294
+ type: 'problem',
1295
+ docs: {
1296
+ description:
1297
+ 'Disallow a registered function domain taken as a source file, and a domain module that is not one exported function named for its file.',
1298
+ },
1299
+ messages: {
1300
+ file: 'Make this registered function domain a folder rather than a source file.',
1301
+ module:
1302
+ 'Give this registered function module imports and one exported function named for its file.',
1303
+ },
1304
+ },
1305
+ create(context) {
1306
+ return {
1307
+ Program: (node) => reportDomain(context, node),
1308
+ }
1309
+ },
1310
+ }
1311
+
1312
+ /** Bans host-specific line-ending handling. */
1313
+ export const ENDING_RULE: PolicyRuleInterface = {
1314
+ meta: {
1315
+ type: 'problem',
1316
+ docs: {
1317
+ description:
1318
+ 'Disallow reading the host line ending and trimming an arrived payload before splitting it.',
1319
+ },
1320
+ messages: {
1321
+ split: 'Split arrived text on the line-ending pattern, then trim each line.',
1322
+ terminator: 'Emit a line feed rather than the host line ending.',
1323
+ },
1324
+ },
1325
+ create(context) {
1326
+ return {
1327
+ CallExpression: (node) => reportEnding(context, node),
1328
+ MemberExpression: (node) => reportEnding(context, node),
1329
+ ImportDeclaration: (node) => reportEndingImport(context, node),
1330
+ }
1331
+ },
1332
+ }
1333
+
1334
+ /** Bans a doc block above an export whose first sentence is not a third-person summary. */
1335
+ export const VOICE_RULE: PolicyRuleInterface = {
1336
+ meta: {
1337
+ type: 'problem',
1338
+ docs: {
1339
+ description:
1340
+ 'Disallow a description paragraph that opens on a word other than a third-person verb, and one that names the symbol it documents.',
1341
+ },
1342
+ messages: {
1343
+ voice:
1344
+ 'Open this description with a third-person verb ending in s, such as Creates, Returns, or Checks whether.',
1345
+ name: 'State what the symbol does without naming {{name}} in the first sentence.',
1346
+ },
1347
+ },
1348
+ create(context) {
1349
+ return {
1350
+ Program: (node) => reportDocs(context, node),
1351
+ }
1352
+ },
1353
+ }
1354
+
1355
+ /** Bans a comment carrying a term the substitution table bans unconditionally. */
1356
+ export const TERM_RULE: PolicyRuleInterface = {
1357
+ meta: {
1358
+ type: 'problem',
1359
+ docs: {
1360
+ description:
1361
+ 'Disallow an unconditionally banned substitution-table term in comment prose, outside code spans, fenced blocks, link tags, and URLs.',
1362
+ },
1363
+ messages: {
1364
+ term: 'Replace {{term}} in this comment: {{replacement}}.',
1365
+ },
1366
+ },
1367
+ create(context) {
1368
+ return {
1369
+ Program: () => reportComments(context),
1370
+ }
1371
+ },
1372
+ }
1373
+
1374
+ /** Declares the workspace Oxlint plugin. */
338
1375
  export default {
339
1376
  meta: { name: 'policy' },
340
1377
  rules: {
341
1378
  'no-mocking': MOCKING_RULE,
342
1379
  'no-keyword-privacy': PRIVACY_RULE,
343
1380
  'no-nested-functions': NESTED_RULE,
1381
+ 'no-hidden-declaration': HIDDEN_RULE,
1382
+ 'no-misplaced-type': TYPE_RULE,
1383
+ 'no-misplaced-class': CLASS_RULE,
1384
+ 'no-misplaced-data': DATA_RULE,
1385
+ 'no-misplaced-function': FUNCTION_RULE,
1386
+ 'no-malformed-constant': CONSTANT_RULE,
1387
+ 'no-misnamed-parser': PARSER_RULE,
1388
+ 'no-misnamed-factory': FACTORY_RULE,
1389
+ 'no-malformed-domain': DOMAIN_RULE,
1390
+ 'no-host-line-endings': ENDING_RULE,
1391
+ 'no-malformed-summary': VOICE_RULE,
1392
+ 'no-banned-term': TERM_RULE,
344
1393
  },
345
1394
  }