@orkestrel/scaffold 0.0.62 → 0.0.64

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 (52) hide show
  1. package/README.md +18 -103
  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/references/bootstrap-reference.md +16 -16
  8. package/dist/host/agents/skills/enterprise-bootstrap/references/frontend-design.md +6 -6
  9. package/dist/host/agents/skills/enterprise-bootstrap/references/utilities.md +4 -4
  10. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +5 -5
  11. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  12. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +15 -15
  13. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +43 -17
  14. package/dist/host/agents/skills/orkestrel-publish/references/window.md +41 -16
  15. package/dist/host/claude/agents/orkestrel.md +56 -56
  16. package/dist/host/claude/agents/reviewer.md +13 -0
  17. package/dist/host/claude/rules/architecture.md +51 -45
  18. package/dist/host/claude/rules/documentation.md +18 -1
  19. package/dist/host/claude/rules/portability.md +2 -0
  20. package/dist/host/claude/rules/quality.md +1 -1
  21. package/dist/host/claude/rules/tests.md +12 -11
  22. package/dist/host/claude/rules/typescript.md +5 -0
  23. package/dist/host/claude/rules/workspace.md +23 -18
  24. package/dist/host/claude/rules/writing.md +4 -0
  25. package/dist/host/codex/agents/orkestrel.toml +3 -3
  26. package/dist/host/codex/agents/reviewer.toml +4 -2
  27. package/dist/host/configs/helpers.ts +311 -2
  28. package/dist/host/configs/policy.ts +1100 -51
  29. package/dist/host/dotfiles/oxlintrc.json +72 -1
  30. package/dist/host/guides/guide.md +749 -222
  31. package/dist/host/guides/scaffold.md +472 -378
  32. package/dist/host/manifest.json +34 -33
  33. package/dist/host/scripts/codex.sh +0 -0
  34. package/dist/host/scripts/cursor.sh +0 -0
  35. package/dist/host/scripts/deps.sh +0 -0
  36. package/dist/host/scripts/ollama.sh +0 -0
  37. package/dist/host/tests/config.test.ts +1200 -16
  38. package/dist/host/tests/policy.test.ts +157 -173
  39. package/dist/host/tests/setupPolicy.ts +522 -1007
  40. package/dist/src/core/index.cjs +373 -279
  41. package/dist/src/core/index.cjs.map +1 -1
  42. package/dist/src/core/index.d.cts +130 -120
  43. package/dist/src/core/index.d.ts +130 -120
  44. package/dist/src/core/index.js +373 -278
  45. package/dist/src/core/index.js.map +1 -1
  46. package/dist/src/server/index.cjs +28 -21
  47. package/dist/src/server/index.cjs.map +1 -1
  48. package/dist/src/server/index.d.cts +38 -33
  49. package/dist/src/server/index.d.ts +38 -33
  50. package/dist/src/server/index.js +28 -21
  51. package/dist/src/server/index.js.map +1 -1
  52. package/package.json +17 -18
@@ -7,25 +7,71 @@ import {
7
7
  globSync,
8
8
  mkdtempSync,
9
9
  mkdirSync,
10
+ readdirSync,
10
11
  readFileSync,
11
12
  realpathSync,
12
13
  rmSync,
13
14
  writeFileSync,
14
15
  } from 'node:fs'
15
16
  import { createRequire } from 'node:module'
17
+ import { tmpdir } from 'node:os'
16
18
  import { dirname, join, resolve } from 'node:path'
17
19
  import { fileURLToPath, pathToFileURL } from 'node:url'
18
20
  import { build, createServer, loadConfigFromFile } from 'vite'
19
21
  import { RuleTester } from 'oxlint/plugins-dev'
20
22
  import * as configHelpers from '../configs/helpers.js'
21
- import { MOCKING_RULE, NESTED_RULE, PRIVACY_RULE } from '../configs/policy.js'
23
+ import policyPlugin, {
24
+ CENTRAL_SOURCE_FILES,
25
+ CLASS_RULE,
26
+ CONSTANT_RULE,
27
+ DATA_RULE,
28
+ DATA_SOURCE_FILES,
29
+ DOMAIN_RULE,
30
+ ENDING_RULE,
31
+ FACTORY_RULE,
32
+ FUNCTION_RULE,
33
+ FUNCTION_SOURCE_FILES,
34
+ HIDDEN_RULE,
35
+ MOCKING_RULE,
36
+ NESTED_RULE,
37
+ PARSER_RULE,
38
+ POLICY_BANNED_TERMS,
39
+ POLICY_ENDING_GLOBS,
40
+ POLICY_JUDGED_TERMS,
41
+ POLICY_PLACEMENT_GLOBS,
42
+ POLICY_VOICE_STOPWORDS,
43
+ PRIVACY_RULE,
44
+ TERM_RULE,
45
+ TYPE_RULE,
46
+ VOICE_RULE,
47
+ blankPolicyText,
48
+ commentToPolicyParagraph,
49
+ isPolicyVoiced,
50
+ paragraphToPolicyOpener,
51
+ stripPolicyCode,
52
+ textToPolicyHits,
53
+ } from '../configs/policy.js'
22
54
  import configuration, { resolveWorkspacePath } from '../vite.config.js'
23
55
  import tsconfig from '../tsconfig.json' with { type: 'json' }
24
- import { createPolicyScratch, inspectPolicyConfiguration } from './setupPolicy.js'
56
+ import {
57
+ createPolicyScratch,
58
+ inspectPolicyConfiguration,
59
+ inspectPolicyWiring,
60
+ normalizePolicyFilename,
61
+ } from './setupPolicy.js'
25
62
  import { describe, expect, it } from 'vitest'
26
63
 
27
64
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
28
65
 
66
+ // A declaration roll-up loads the extractor package, which only a workspace publishing source from
67
+ // `src` installs. The resolution below is the mechanism its proof is conditioned on.
68
+ let extractorPath: string | undefined
69
+ try {
70
+ extractorPath = createRequire(import.meta.url).resolve('@microsoft/api-extractor')
71
+ } catch {
72
+ extractorPath = undefined
73
+ }
74
+
29
75
  describe('root configuration', () => {
30
76
  it('resolves every declared alias to its real entry', () => {
31
77
  const aliases = configuration.resolve?.alias
@@ -886,19 +932,844 @@ describe('policy plugin', () => {
886
932
  ],
887
933
  })
888
934
 
935
+ tester.run('no-hidden-declaration', HIDDEN_RULE, {
936
+ valid: [
937
+ {
938
+ name: 'accepts an exported centralized declaration',
939
+ filename: 'src/worker/helpers.ts',
940
+ code: 'export function buildValue(): void {}',
941
+ },
942
+ {
943
+ name: 'accepts a hidden declaration outside a centralized file',
944
+ filename: 'src/worker/Widget.ts',
945
+ code: 'function buildValue(): void {}',
946
+ },
947
+ ],
948
+ invalid: [
949
+ {
950
+ name: 'rejects a hidden helper [membership: declarations in a centralized file without an export]',
951
+ filename: 'src/worker/helpers.ts',
952
+ code: 'function buildValue(): void {}',
953
+ errors: [{ messageId: 'hidden' }],
954
+ },
955
+ {
956
+ name: 'rejects a hidden constant [membership: declarations in a centralized file without an export]',
957
+ filename: 'src/worker/constants.ts',
958
+ code: 'const COUNT = 1',
959
+ errors: [{ messageId: 'hidden' }],
960
+ },
961
+ ],
962
+ })
963
+
964
+ tester.run('no-misplaced-type', TYPE_RULE, {
965
+ valid: [
966
+ {
967
+ name: 'accepts an interface in types.ts',
968
+ filename: 'src/mobile/types.ts',
969
+ code: 'export interface ValueInterface { readonly id: string }',
970
+ },
971
+ {
972
+ name: 'accepts an interface in an ambient declaration file',
973
+ filename: 'app/browser/env.d.ts',
974
+ code: 'export interface EnvironmentInterface { readonly mode: string }',
975
+ },
976
+ {
977
+ name: 'accepts an interface in an ambient module declaration file',
978
+ filename: 'app/browser/env.d.mts',
979
+ code: 'export interface EnvironmentInterface { readonly mode: string }',
980
+ },
981
+ {
982
+ name: 'accepts an interface in an ambient CommonJS declaration file',
983
+ filename: 'app/browser/env.d.cts',
984
+ code: 'export interface EnvironmentInterface { readonly mode: string }',
985
+ },
986
+ ],
987
+ invalid: [
988
+ {
989
+ name: 'rejects an interface beside helpers [membership: top-level type declarations outside types.ts]',
990
+ filename: 'src/mobile/helpers.ts',
991
+ code: 'export interface ValueInterface { readonly id: string }',
992
+ errors: [{ messageId: 'type' }],
993
+ },
994
+ {
995
+ name: 'rejects a type alias in an environment module [membership: top-level type declarations outside types.ts]',
996
+ filename: 'app/browser/env.ts',
997
+ code: "export type Mode = 'dark' | 'light'",
998
+ errors: [{ messageId: 'type' }],
999
+ },
1000
+ ],
1001
+ })
1002
+
1003
+ tester.run('no-misplaced-class', CLASS_RULE, {
1004
+ valid: [
1005
+ {
1006
+ name: 'accepts a class in the file named for it',
1007
+ filename: 'app/desktop/Widget.ts',
1008
+ code: 'export class Widget {}',
1009
+ },
1010
+ {
1011
+ name: 'accepts an error class in errors.ts',
1012
+ filename: 'app/desktop/errors.ts',
1013
+ code: 'export class WidgetError extends Error {}',
1014
+ },
1015
+ ],
1016
+ invalid: [
1017
+ {
1018
+ name: 'rejects a class that differs from its file [membership: classes outside errors.ts whose name differs from the filename]',
1019
+ filename: 'app/desktop/Widget.ts',
1020
+ code: 'export class Other {}',
1021
+ errors: [{ messageId: 'class' }],
1022
+ },
1023
+ {
1024
+ name: 'rejects a class in a camelCase file [membership: classes outside errors.ts whose name differs from the filename]',
1025
+ filename: 'app/desktop/helpers.ts',
1026
+ code: 'export class Widget {}',
1027
+ errors: [{ messageId: 'class' }],
1028
+ },
1029
+ ],
1030
+ })
1031
+
1032
+ tester.run('no-misplaced-data', DATA_RULE, {
1033
+ valid: [
1034
+ {
1035
+ name: 'accepts module data in constants.ts',
1036
+ filename: 'app/edge/constants.ts',
1037
+ code: "export const STATUS = 'ready'",
1038
+ },
1039
+ {
1040
+ name: 'accepts a helper namespace in helpers.ts',
1041
+ filename: 'app/edge/helpers.ts',
1042
+ code: 'export const formatters = Object.freeze({ money: build(fmt) })',
1043
+ },
1044
+ ],
1045
+ invalid: [
1046
+ {
1047
+ name: 'rejects data beside handlers [membership: module data whose file is absent from the data register]',
1048
+ filename: 'app/edge/handlers.ts',
1049
+ code: "export const STATUS = 'ready'",
1050
+ errors: [{ messageId: 'data' }],
1051
+ },
1052
+ {
1053
+ name: 'rejects data in an implementation file [membership: module data whose file is absent from the data register]',
1054
+ filename: 'app/edge/Widget.ts',
1055
+ code: 'export const LIMIT = 4',
1056
+ errors: [{ messageId: 'data' }],
1057
+ },
1058
+ ],
1059
+ })
1060
+
1061
+ tester.run('no-misplaced-function', FUNCTION_RULE, {
1062
+ valid: [
1063
+ {
1064
+ name: 'accepts a function in a function-kind file',
1065
+ filename: 'src/worker/helpers.ts',
1066
+ code: 'export function buildValue(): void {}',
1067
+ },
1068
+ {
1069
+ name: 'accepts a module in a registered function domain',
1070
+ filename: 'app/browser/composables/useTheme.ts',
1071
+ code: 'export function useTheme(): void {}',
1072
+ },
1073
+ {
1074
+ name: 'accepts a callback passed directly as an argument',
1075
+ filename: 'app/edge/constants.ts',
1076
+ code: 'export const LABELS = Object.freeze(COLUMNS.map((column) => column.label))',
1077
+ },
1078
+ {
1079
+ name: 'accepts a function returned directly through a concise body',
1080
+ filename: 'app/edge/constants.ts',
1081
+ code: 'export const WRAPPED = wrap(() => () => 1)',
1082
+ },
1083
+ {
1084
+ name: 'accepts functions returned directly through callback control flow',
1085
+ filename: 'app/edge/constants.ts',
1086
+ code: 'export const VALUES = Object.freeze(C.map((c) => { if (c) return () => 1; return () => 2 }))',
1087
+ },
1088
+ {
1089
+ name: 'accepts a function returned by a return statement inside a callback',
1090
+ filename: 'app/edge/constants.ts',
1091
+ code: 'export const LABELS = Object.freeze(COLUMNS.map((column) => { return () => column.label }))',
1092
+ },
1093
+ {
1094
+ name: 'accepts a method of a top-level class',
1095
+ filename: 'app/edge/Widget.ts',
1096
+ code: 'export class Widget { read() { return 1 } }',
1097
+ },
1098
+ ],
1099
+ invalid: [
1100
+ {
1101
+ name: 'rejects a function module in an unregistered folder [membership: module function syntax whose file is absent from the function register]',
1102
+ filename: 'src/worker/jobs/runTask.ts',
1103
+ code: 'export function runTask(): void {}',
1104
+ errors: [{ messageId: 'function' }],
1105
+ },
1106
+ {
1107
+ name: 'rejects a property-held arrow in a route table [membership: module function syntax whose file is absent from the function register]',
1108
+ filename: 'src/worker/routes.ts',
1109
+ code: 'export const ROUTES = Object.freeze([{ handler: () => undefined }])',
1110
+ errors: [{ messageId: 'function' }],
1111
+ },
1112
+ {
1113
+ name: 'rejects a callback parameter default function [membership: module function syntax that is neither a direct callback nor a direct result]',
1114
+ filename: 'app/edge/constants.ts',
1115
+ code: 'export const VALUES = Object.freeze(C.map((c = () => 1) => c))',
1116
+ errors: [{ messageId: 'function' }],
1117
+ },
1118
+ {
1119
+ name: 'rejects an assignment inside a direct callback [membership: module function syntax that is neither a direct callback nor a direct result]',
1120
+ filename: 'app/edge/constants.ts',
1121
+ code: 'export const VALUES = Object.freeze(C.map((c) => { const f = () => c; return f() }))',
1122
+ errors: [{ messageId: 'function' }],
1123
+ },
1124
+ {
1125
+ name: 'rejects a destructured callback parameter default function [membership: module function syntax that is neither a direct callback nor a direct result]',
1126
+ filename: 'app/edge/constants.ts',
1127
+ code: 'export const VALUES = Object.freeze(C.map(({ f = () => 1 }) => f))',
1128
+ errors: [{ messageId: 'function' }],
1129
+ },
1130
+ {
1131
+ name: 'rejects an assignment inside callback control flow [membership: module function syntax that is neither a direct callback nor a direct result]',
1132
+ filename: 'app/edge/constants.ts',
1133
+ code: 'export const VALUES = Object.freeze(C.map((c) => { if (c) { const f = () => 1; return f() } return 2 }))',
1134
+ errors: [{ messageId: 'function' }],
1135
+ },
1136
+ {
1137
+ name: 'rejects a declaration inside a direct callback [membership: module function syntax that is neither a direct callback nor a direct result]',
1138
+ filename: 'app/edge/constants.ts',
1139
+ code: 'export const LABELS = Object.freeze(COLUMNS.map((column) => { function format() { return column.label } return format() }))',
1140
+ errors: [{ messageId: 'function' }],
1141
+ },
1142
+ {
1143
+ name: 'rejects an assignment two direct callbacks down [membership: module function syntax that is neither a direct callback nor a direct result]',
1144
+ filename: 'app/edge/constants.ts',
1145
+ code: 'export const VALUES = Object.freeze(C.map((c) => wrap((d) => { const g = () => d; return g() })))',
1146
+ errors: [{ messageId: 'function' }],
1147
+ },
1148
+ {
1149
+ name: 'rejects a function in a nested folder whose suffix matches a registered domain [membership: module function syntax whose file is absent from the function register]',
1150
+ filename: 'src/server/execution/nested/src/server/execution/thing.ts',
1151
+ code: 'export function run(): void {}',
1152
+ errors: [{ messageId: 'function' }],
1153
+ },
1154
+ ],
1155
+ })
1156
+
1157
+ tester.run('no-malformed-constant', CONSTANT_RULE, {
1158
+ valid: [
1159
+ {
1160
+ name: 'accepts a frozen upper-case constant',
1161
+ filename: 'src/worker/constants.ts',
1162
+ code: "export const LABELS = Object.freeze(['ready'])",
1163
+ },
1164
+ {
1165
+ name: 'accepts a lower-case binding outside constants.ts',
1166
+ filename: 'src/worker/helpers.ts',
1167
+ code: 'export const count = 1',
1168
+ },
1169
+ ],
1170
+ invalid: [
1171
+ {
1172
+ name: 'rejects a mutable constant [membership: variable statements in constants.ts that are not const]',
1173
+ filename: 'src/worker/constants.ts',
1174
+ code: 'export let COUNT = 1',
1175
+ errors: [{ messageId: 'mutable' }],
1176
+ },
1177
+ {
1178
+ name: 'rejects a lower-case constant [membership: declarations in constants.ts outside UPPER_SNAKE_CASE]',
1179
+ filename: 'src/worker/constants.ts',
1180
+ code: 'export const count = 1',
1181
+ errors: [{ messageId: 'naming' }],
1182
+ },
1183
+ {
1184
+ name: 'rejects a bare collection constant [membership: declarations in constants.ts with a direct array or object literal]',
1185
+ filename: 'src/worker/constants.ts',
1186
+ code: 'export const VALUES = []',
1187
+ errors: [{ messageId: 'collection' }],
1188
+ },
1189
+ ],
1190
+ })
1191
+
1192
+ tester.run('no-misnamed-parser', PARSER_RULE, {
1193
+ valid: [
1194
+ {
1195
+ name: 'accepts a parse-prefixed coercer',
1196
+ filename: 'app/edge/parsers.ts',
1197
+ code: 'export function parseValue(): void {}',
1198
+ },
1199
+ {
1200
+ name: 'accepts an unprefixed function outside parsers.ts',
1201
+ filename: 'app/edge/helpers.ts',
1202
+ code: 'export function coerceValue(): void {}',
1203
+ },
1204
+ ],
1205
+ invalid: [
1206
+ {
1207
+ name: 'rejects an unprefixed coercer [membership: parsers.ts functions whose name does not start with parse]',
1208
+ filename: 'app/edge/parsers.ts',
1209
+ code: 'export function coerceValue(): void {}',
1210
+ errors: [{ messageId: 'parser' }],
1211
+ },
1212
+ {
1213
+ name: 'rejects an unprefixed assigned coercer [membership: parsers.ts functions whose name does not start with parse]',
1214
+ filename: 'app/edge/parsers.ts',
1215
+ code: 'export const coerceValue = () => undefined',
1216
+ errors: [{ messageId: 'parser' }],
1217
+ },
1218
+ ],
1219
+ })
1220
+
1221
+ tester.run('no-misnamed-factory', FACTORY_RULE, {
1222
+ valid: [
1223
+ {
1224
+ name: 'accepts a create-prefixed factory',
1225
+ filename: 'app/edge/factories.ts',
1226
+ code: 'export const createValue = () => undefined',
1227
+ },
1228
+ {
1229
+ name: 'accepts an unprefixed function outside factories.ts',
1230
+ filename: 'app/edge/helpers.ts',
1231
+ code: 'export function buildValue(): void {}',
1232
+ },
1233
+ ],
1234
+ invalid: [
1235
+ {
1236
+ name: 'rejects an unprefixed factory [membership: factories.ts functions whose name does not start with create]',
1237
+ filename: 'app/edge/factories.ts',
1238
+ code: 'export function buildValue(): void {}',
1239
+ errors: [{ messageId: 'factory' }],
1240
+ },
1241
+ {
1242
+ name: 'rejects an unprefixed assigned factory [membership: factories.ts functions whose name does not start with create]',
1243
+ filename: 'app/edge/factories.ts',
1244
+ code: 'export const buildValue = () => undefined',
1245
+ errors: [{ messageId: 'factory' }],
1246
+ },
1247
+ ],
1248
+ })
1249
+
1250
+ tester.run('no-malformed-domain', DOMAIN_RULE, {
1251
+ valid: [
1252
+ {
1253
+ name: 'accepts a registered function module carrying imports and one named export',
1254
+ filename: 'app/browser/composables/useTheme.ts',
1255
+ code: ["import { ref } from 'vue'", 'export function useTheme(): void { void ref }'].join(
1256
+ '\n',
1257
+ ),
1258
+ },
1259
+ {
1260
+ name: 'accepts a module outside every registered domain',
1261
+ filename: 'app/edge/helpers.ts',
1262
+ code: 'export function buildValue(): void {}',
1263
+ },
1264
+ {
1265
+ name: 'accepts a nested folder whose suffix matches a registered domain',
1266
+ filename: 'src/server/execution/nested/src/server/execution/thing.ts',
1267
+ code: 'export const VALUE = 1',
1268
+ },
1269
+ ],
1270
+ invalid: [
1271
+ {
1272
+ name: 'rejects a module whose function differs from its file [membership: direct camelCase modules in a registered function-domain folder]',
1273
+ filename: 'app/browser/composables/useTheme.ts',
1274
+ code: 'export function useMode(): void {}',
1275
+ errors: [{ messageId: 'module' }],
1276
+ },
1277
+ {
1278
+ name: 'rejects a hidden domain function [membership: direct camelCase modules in a registered function-domain folder]',
1279
+ filename: 'app/browser/composables/useTheme.ts',
1280
+ code: 'function useTheme(): void {}',
1281
+ errors: [{ messageId: 'module' }],
1282
+ },
1283
+ {
1284
+ name: 'rejects a file named for a registered domain [membership: source files whose stem is a registered function-domain name]',
1285
+ filename: 'app/edge/composables.ts',
1286
+ code: 'export function buildValue(): void {}',
1287
+ errors: [{ messageId: 'file' }],
1288
+ },
1289
+ ],
1290
+ })
1291
+
1292
+ tester.run('no-host-line-endings', ENDING_RULE, {
1293
+ valid: [
1294
+ {
1295
+ name: 'accepts a split on the line-ending pattern',
1296
+ filename: 'src/worker/helpers.ts',
1297
+ code: 'export const lines = text.trim().split(/\\r\\n|\\n/u)',
1298
+ },
1299
+ {
1300
+ name: 'accepts a locally declared line-ending constant',
1301
+ filename: 'src/worker/constants.ts',
1302
+ code: "export const EOL = '\\n'",
1303
+ },
1304
+ {
1305
+ name: 'accepts a namespace import that reads no line ending',
1306
+ filename: 'configs/helpers.ts',
1307
+ code: ["import * as os from 'node:os'", 'export const root = os.tmpdir()'].join('\n'),
1308
+ },
1309
+ ],
1310
+ invalid: [
1311
+ {
1312
+ name: 'rejects a payload trimmed before it is split [membership: split calls carrying a line-feed literal]',
1313
+ filename: 'src/worker/helpers.ts',
1314
+ code: "export const lines = text.trim().split('\\n')",
1315
+ errors: [{ messageId: 'split' }],
1316
+ },
1317
+ {
1318
+ name: 'rejects a templated line-feed split [membership: split calls carrying a line-feed literal]',
1319
+ filename: 'src/worker/helpers.ts',
1320
+ code: 'export const lines = text.trim().split(`\\n`)',
1321
+ errors: [{ messageId: 'split' }],
1322
+ },
1323
+ {
1324
+ name: 'rejects a read of the host line ending [membership: EOL reads on a binding named os]',
1325
+ filename: 'configs/helpers.ts',
1326
+ code: ["import * as os from 'node:os'", 'export const end = os.EOL'].join('\n'),
1327
+ errors: [{ messageId: 'terminator' }],
1328
+ },
1329
+ {
1330
+ name: 'rejects an EOL import from the host module [membership: named EOL specifiers imported from node:os]',
1331
+ filename: 'configs/helpers.ts',
1332
+ code: ["import { EOL } from 'node:os'", 'export const end = EOL'].join('\n'),
1333
+ errors: [{ messageId: 'terminator' }],
1334
+ },
1335
+ ],
1336
+ })
1337
+
1338
+ tester.run('no-malformed-summary', VOICE_RULE, {
1339
+ valid: [
1340
+ {
1341
+ name: 'accepts a third-person opener',
1342
+ code: ['/** Creates a control. */', 'export const CONTROL = 1'].join('\n'),
1343
+ },
1344
+ {
1345
+ name: 'accepts a whether clause',
1346
+ code: ['/** Checks whether the reader is ready. */', 'export const READY = true'].join(
1347
+ '\n',
1348
+ ),
1349
+ },
1350
+ {
1351
+ name: 'accepts a two-letter third-person opener',
1352
+ code: ['/** Is the value a reader receives. */', 'export const CONTROL = 1'].join('\n'),
1353
+ },
1354
+ {
1355
+ name: 'accepts a reporting opener',
1356
+ code: ['/** Reports whether the reader is ready. */', 'export const READY = true'].join(
1357
+ '\n',
1358
+ ),
1359
+ },
1360
+ {
1361
+ name: 'accepts an anonymous default export',
1362
+ code: ['/** Declares the fixture plugin. */', 'export default { meta: 1 }'].join('\n'),
1363
+ },
1364
+ {
1365
+ name: 'accepts a doc block on a declaration no export reaches',
1366
+ code: ['/** The opener, a noun phrase. */', 'const CONTROL = 1', 'void CONTROL'].join('\n'),
1367
+ },
1368
+ {
1369
+ name: 'accepts a doc block on a class member',
1370
+ code: [
1371
+ '/** Holds one value. */',
1372
+ 'export class Holder {',
1373
+ '\t/** The member value, a noun phrase. */',
1374
+ '\tvalue = 1',
1375
+ '}',
1376
+ ].join('\n'),
1377
+ },
1378
+ {
1379
+ name: 'accepts a single-star block comment before an export',
1380
+ code: ['/* The opener, a noun phrase. */', 'export const CONTROL = 1'].join('\n'),
1381
+ },
1382
+ {
1383
+ name: 'accepts a word from the stop set after the opener',
1384
+ code: ['/** Reports whether this process passes. */', 'export const READY = true'].join(
1385
+ '\n',
1386
+ ),
1387
+ },
1388
+ {
1389
+ name: 'accepts a block tag naming the symbol after the description [membership: text before the first block tag]',
1390
+ code: [
1391
+ '/**',
1392
+ ' * Creates a control',
1393
+ ' *',
1394
+ ' * @param value - The value readControl reads.',
1395
+ ' */',
1396
+ 'export function readControl(value: number): number {',
1397
+ '\treturn value',
1398
+ '}',
1399
+ ].join('\n'),
1400
+ },
1401
+ { name: 'accepts an export carrying no doc block', code: 'export const CONTROL = 1' },
1402
+ ],
1403
+ invalid: [
1404
+ {
1405
+ name: 'rejects a noun-phrase opener [membership: doc blocks directly above a top-level export]',
1406
+ code: ['/** The opener, a noun phrase. */', 'export const CONTROL = 1'].join('\n'),
1407
+ errors: [{ messageId: 'voice' }],
1408
+ },
1409
+ {
1410
+ name: 'rejects an imperative opener [membership: doc blocks directly above a top-level export]',
1411
+ code: ['/** Create a control. */', 'export function createControl(): void {}'].join('\n'),
1412
+ errors: [{ messageId: 'voice' }],
1413
+ },
1414
+ {
1415
+ name: 'rejects an opener from the stop set [membership: opening words ending in s that name no verb]',
1416
+ code: ['/** This holds one value. */', 'export const CONTROL = 1'].join('\n'),
1417
+ errors: [{ messageId: 'voice' }],
1418
+ },
1419
+ {
1420
+ name: 'rejects a first sentence naming its own class [membership: the declared identifier inside the first sentence]',
1421
+ code: ['/** Returns the value ControlTwo carries. */', 'export class ControlTwo {}'].join(
1422
+ '\n',
1423
+ ),
1424
+ errors: [{ messageId: 'name', data: { name: 'ControlTwo' } }],
1425
+ },
1426
+ {
1427
+ name: 'rejects a first sentence naming its own constant [membership: the declared identifier inside the first sentence]',
1428
+ code: ['/** Returns the value CONTROL carries. */', 'export const CONTROL = 1'].join('\n'),
1429
+ errors: [{ messageId: 'name', data: { name: 'CONTROL' } }],
1430
+ },
1431
+ {
1432
+ name: 'rejects an empty doc block [membership: description paragraphs, the empty one included]',
1433
+ code: ['/** */', 'export const CONTROL = 1'].join('\n'),
1434
+ errors: [{ messageId: 'voice' }],
1435
+ },
1436
+ {
1437
+ name: 'rejects a doc block a blank line separates from its export [membership: doc blocks whitespace alone separates from a top-level export]',
1438
+ code: ['/** The opener, a noun phrase. */', '', 'export const CONTROL = 1'].join('\n'),
1439
+ errors: [{ messageId: 'voice' }],
1440
+ },
1441
+ ],
1442
+ })
1443
+
1444
+ tester.run('no-banned-term', TERM_RULE, {
1445
+ valid: [
1446
+ { name: 'accepts a term inside a code span', code: '// A `should` token names one row.' },
1447
+ {
1448
+ name: 'accepts a term inside a fenced example',
1449
+ code: [
1450
+ '/**',
1451
+ ' * Reads one value.',
1452
+ ' *',
1453
+ ' * @example',
1454
+ ' * ```text',
1455
+ ' * should stay',
1456
+ ' * ```',
1457
+ ' */',
1458
+ 'export const CONTROL = 1',
1459
+ ].join('\n'),
1460
+ },
1461
+ {
1462
+ name: 'accepts a term inside a link target',
1463
+ code: '/** Reports the state. {@link Example.should} */',
1464
+ },
1465
+ {
1466
+ name: 'accepts a term inside an address',
1467
+ code: '// Read https://example.test/should/row for the shape.',
1468
+ },
1469
+ {
1470
+ name: 'accepts a term inside a code span a line break runs through',
1471
+ code: ['/**', ' * A span `that', ' * spans lines with should` here.', ' */'].join('\n'),
1472
+ },
1473
+ { name: 'accepts a judged term', code: '// The new value is read once now.' },
1474
+ {
1475
+ name: 'accepts a longer word carrying a term',
1476
+ code: '// A justice viable pleased reader.',
1477
+ },
1478
+ { name: 'accepts prose carrying no term', code: '// Reads the value a reader receives.' },
1479
+ ],
1480
+ invalid: [
1481
+ {
1482
+ name: 'rejects should [membership: comment prose outside code, tags, and addresses]',
1483
+ code: '// A reader should meet this row.',
1484
+ errors: [
1485
+ {
1486
+ messageId: 'term',
1487
+ data: { term: 'should', replacement: 'must, can, might, or the imperative' },
1488
+ },
1489
+ ],
1490
+ },
1491
+ {
1492
+ name: 'rejects should in a block comment [membership: block and line comments alike]',
1493
+ code: '/** Reports the state a reader should meet. */',
1494
+ errors: [
1495
+ {
1496
+ messageId: 'term',
1497
+ data: { term: 'should', replacement: 'must, can, might, or the imperative' },
1498
+ },
1499
+ ],
1500
+ },
1501
+ {
1502
+ name: 'rejects simply [membership: comment prose outside code, tags, and addresses]',
1503
+ code: '// The reader simply reads.',
1504
+ errors: [{ messageId: 'term', data: { term: 'simply', replacement: 'delete' } }],
1505
+ },
1506
+ {
1507
+ name: 'rejects easy [membership: comment prose outside code, tags, and addresses]',
1508
+ code: '// The path is easy.',
1509
+ errors: [{ messageId: 'term', data: { term: 'easy', replacement: 'delete' } }],
1510
+ },
1511
+ {
1512
+ name: 'rejects easiest [membership: the inflections one row reaches]',
1513
+ code: '// The path is easiest.',
1514
+ errors: [{ messageId: 'term', data: { term: 'easy', replacement: 'delete' } }],
1515
+ },
1516
+ {
1517
+ name: 'rejects just [membership: comment prose outside code, tags, and addresses]',
1518
+ code: '// The reader just reads.',
1519
+ errors: [{ messageId: 'term', data: { term: 'just', replacement: 'delete' } }],
1520
+ },
1521
+ {
1522
+ name: 'rejects currently [membership: comment prose outside code, tags, and addresses]',
1523
+ code: '// The reader currently reads.',
1524
+ errors: [
1525
+ {
1526
+ messageId: 'term',
1527
+ data: { term: 'currently', replacement: 'delete, or give the date' },
1528
+ },
1529
+ ],
1530
+ },
1531
+ {
1532
+ name: 'rejects utilizes [membership: the inflections one row reaches]',
1533
+ code: '// The reader utilizes the path.',
1534
+ errors: [{ messageId: 'term', data: { term: 'utilize', replacement: 'use' } }],
1535
+ },
1536
+ {
1537
+ name: 'rejects utilizing [membership: the inflections one row reaches]',
1538
+ code: '// The reader is utilizing the path.',
1539
+ errors: [{ messageId: 'term', data: { term: 'utilize', replacement: 'use' } }],
1540
+ },
1541
+ {
1542
+ name: 'rejects leverages [membership: the inflections one row reaches]',
1543
+ code: '// The reader leverages the path.',
1544
+ errors: [{ messageId: 'term', data: { term: 'leverage', replacement: 'use' } }],
1545
+ },
1546
+ {
1547
+ name: 'rejects leveraged [membership: the inflections one row reaches]',
1548
+ code: '// The reader leveraged the path.',
1549
+ errors: [{ messageId: 'term', data: { term: 'leverage', replacement: 'use' } }],
1550
+ },
1551
+ {
1552
+ name: 'rejects via [membership: comment prose outside code, tags, and addresses]',
1553
+ code: '// The reader arrives via the path.',
1554
+ errors: [{ messageId: 'term', data: { term: 'via', replacement: 'through, by using' } }],
1555
+ },
1556
+ {
1557
+ name: 'rejects in order to [membership: comment prose outside code, tags, and addresses]',
1558
+ code: '// The reader reads in order to learn.',
1559
+ errors: [{ messageId: 'term', data: { term: 'in order to', replacement: 'to' } }],
1560
+ },
1561
+ {
1562
+ name: 'rejects the abbreviated for example [membership: dotted rows read with their dots]',
1563
+ code: '// The reader reads one path, e.g. the front page.',
1564
+ errors: [{ messageId: 'term', data: { term: 'e.g.', replacement: 'for example' } }],
1565
+ },
1566
+ {
1567
+ name: 'rejects the abbreviated that is [membership: dotted rows read with their dots]',
1568
+ code: '// The reader reads one path, i.e. the front page.',
1569
+ errors: [{ messageId: 'term', data: { term: 'i.e.', replacement: 'that is' } }],
1570
+ },
1571
+ {
1572
+ name: 'rejects the abbreviated list ending [membership: dotted rows read with their dots]',
1573
+ code: '// The reader reads paths, files, etc.',
1574
+ errors: [
1575
+ {
1576
+ messageId: 'term',
1577
+ data: { term: 'etc.', replacement: 'bound the list, or recast the sentence' },
1578
+ },
1579
+ ],
1580
+ },
1581
+ {
1582
+ name: 'rejects performant [membership: comment prose outside code, tags, and addresses]',
1583
+ code: '// The path is performant.',
1584
+ errors: [
1585
+ { messageId: 'term', data: { term: 'performant', replacement: 'the measured property' } },
1586
+ ],
1587
+ },
1588
+ {
1589
+ name: 'rejects robust [membership: comment prose outside code, tags, and addresses]',
1590
+ code: '// The path is robust.',
1591
+ errors: [
1592
+ { messageId: 'term', data: { term: 'robust', replacement: 'the measured property' } },
1593
+ ],
1594
+ },
1595
+ {
1596
+ name: 'rejects robustness [membership: the inflections one row reaches]',
1597
+ code: '// The path has robustness.',
1598
+ errors: [
1599
+ { messageId: 'term', data: { term: 'robust', replacement: 'the measured property' } },
1600
+ ],
1601
+ },
1602
+ {
1603
+ name: 'rejects allows you to [membership: comment prose outside code, tags, and addresses]',
1604
+ code: '// The path allows you to read.',
1605
+ errors: [{ messageId: 'term', data: { term: 'allows you to', replacement: 'lets you' } }],
1606
+ },
1607
+ {
1608
+ name: 'rejects the conjunction pair [membership: comment prose outside code, tags, and addresses]',
1609
+ code: '// The reader reads a path and/or a file.',
1610
+ errors: [{ messageId: 'term', data: { term: 'and/or', replacement: 'and, or, or both' } }],
1611
+ },
1612
+ {
1613
+ name: 'rejects please [membership: comment prose outside code, tags, and addresses]',
1614
+ code: '// Read the path, please.',
1615
+ errors: [{ messageId: 'term', data: { term: 'please', replacement: 'delete' } }],
1616
+ },
1617
+ {
1618
+ name: 'rejects the hyphenated quick check [membership: rows written with a space or a hyphen]',
1619
+ code: '// Run a sanity-check over the path.',
1620
+ errors: [{ messageId: 'term', data: { term: 'sanity check', replacement: 'quick check' } }],
1621
+ },
1622
+ {
1623
+ name: 'rejects dummy [membership: comment prose outside code, tags, and addresses]',
1624
+ code: '// The path names a dummy value.',
1625
+ errors: [{ messageId: 'term', data: { term: 'dummy', replacement: 'placeholder' } }],
1626
+ },
1627
+ {
1628
+ name: 'rejects dummies [membership: the inflections one row reaches]',
1629
+ code: '// The path names two dummies.',
1630
+ errors: [{ messageId: 'term', data: { term: 'dummy', replacement: 'placeholder' } }],
1631
+ },
1632
+ {
1633
+ name: 'rejects the refused list name [membership: comment prose outside code, tags, and addresses]',
1634
+ code: '// The path reads a blacklist.',
1635
+ errors: [{ messageId: 'term', data: { term: 'blacklist', replacement: 'denylist' } }],
1636
+ },
1637
+ {
1638
+ name: 'rejects the refused permit name [membership: comment prose outside code, tags, and addresses]',
1639
+ code: '// The path reads a whitelist.',
1640
+ errors: [{ messageId: 'term', data: { term: 'whitelist', replacement: 'allowlist' } }],
1641
+ },
1642
+ {
1643
+ name: 'rejects the refused replica name [membership: comment prose outside code, tags, and addresses]',
1644
+ code: '// The path names a slave copy.',
1645
+ errors: [{ messageId: 'term', data: { term: 'slave', replacement: 'replica' } }],
1646
+ },
1647
+ ],
1648
+ })
1649
+
1650
+ it('blanks a matched region without moving a line break', () => {
1651
+ expect(blankPolicyText('abc')).toBe(' ')
1652
+ expect(blankPolicyText('ab\ncd')).toBe(' \n ')
1653
+ })
1654
+
1655
+ it('blanks every code, tag, and address region while holding each later offset', () => {
1656
+ const text = [
1657
+ 'A `should` span.',
1658
+ '```text',
1659
+ 'should stay',
1660
+ '```',
1661
+ 'A {@link Example.should} tag.',
1662
+ 'Read https://example.test/should/row here.',
1663
+ 'A reader should meet this row.',
1664
+ ].join('\n')
1665
+ const stripped = stripPolicyCode(text)
1666
+ expect(stripped).toHaveLength(text.length)
1667
+ expect(stripped.split('\n')).toHaveLength(text.split('\n').length)
1668
+ const hits = textToPolicyHits(stripped)
1669
+ expect(hits.map((hit) => hit.term.term)).toEqual(['should'])
1670
+ expect(text.slice(hits[0]?.index ?? -1, (hits[0]?.index ?? 0) + 6)).toBe('should')
1671
+ })
1672
+
1673
+ it('reads every banned-term hit in offset order with the row it matched', () => {
1674
+ const hits = textToPolicyHits('The reader utilizes a path and arrives via a file.')
1675
+ expect(hits.map((hit) => hit.term.term)).toEqual(['utilize', 'via'])
1676
+ expect(hits.map((hit) => hit.term.replacement)).toEqual(['use', 'through, by using'])
1677
+ expect(hits[0]?.index).toBeLessThan(hits[1]?.index ?? 0)
1678
+ expect(textToPolicyHits('The reader reads one path.')).toEqual([])
1679
+ })
1680
+
1681
+ it('reads a description paragraph up to its first block tag', () => {
1682
+ expect(
1683
+ commentToPolicyParagraph({
1684
+ type: 'Block',
1685
+ value: '*\n * Creates a control.\n *\n * @param value - Reports the state.\n ',
1686
+ range: [0, 0],
1687
+ }),
1688
+ ).toBe('Creates a control.')
1689
+ expect(
1690
+ commentToPolicyParagraph({ type: 'Block', value: '* Creates a control. ', range: [0, 0] }),
1691
+ ).toBe('Creates a control.')
1692
+ expect(commentToPolicyParagraph({ type: 'Block', value: '* ', range: [0, 0] })).toBe('')
1693
+ })
1694
+
1695
+ it('reads the opening word of a paragraph as its letters alone', () => {
1696
+ expect(paragraphToPolicyOpener('Creates a control.')).toBe('Creates')
1697
+ expect(paragraphToPolicyOpener('"Creates" a control.')).toBe('Creates')
1698
+ expect(paragraphToPolicyOpener('')).toBe('')
1699
+ })
1700
+
1701
+ it('admits a third-person opener and refuses a registered non-verb', () => {
1702
+ expect(isPolicyVoiced('Creates')).toBe(true)
1703
+ expect(isPolicyVoiced('Is')).toBe(true)
1704
+ expect(isPolicyVoiced('This')).toBe(false)
1705
+ expect(isPolicyVoiced('Status')).toBe(false)
1706
+ expect(isPolicyVoiced('Create')).toBe(false)
1707
+ expect(isPolicyVoiced('')).toBe(false)
1708
+ expect(POLICY_VOICE_STOPWORDS.every((word) => /^[A-Z][a-z]*s$/u.test(word))).toBe(true)
1709
+ })
1710
+
1711
+ it('keeps the matched and judged term sets disjoint and frozen', () => {
1712
+ const matched = POLICY_BANNED_TERMS.map((entry) => entry.term)
1713
+ expect(matched.filter((term) => POLICY_JUDGED_TERMS.includes(term))).toEqual([])
1714
+ expect(new Set(matched).size).toBe(matched.length)
1715
+ expect(Object.isFrozen(POLICY_BANNED_TERMS)).toBe(true)
1716
+ expect(Object.isFrozen(POLICY_JUDGED_TERMS)).toBe(true)
1717
+ expect(Object.isFrozen(POLICY_VOICE_STOPWORDS)).toBe(true)
1718
+ // Every judged row reaches this file as prose, so a pattern that matched one would red the
1719
+ // workspace sweep on the rule file that names it.
1720
+ for (const term of POLICY_JUDGED_TERMS) {
1721
+ expect(textToPolicyHits(`The reader reads ${term} here.`)).toEqual([])
1722
+ }
1723
+ })
1724
+
1725
+ it('registers handlers as a function kind and routes as a data kind', () => {
1726
+ expect(FUNCTION_SOURCE_FILES).toContain('handlers.ts')
1727
+ expect(FUNCTION_SOURCE_FILES).not.toContain('routes.ts')
1728
+ expect(DATA_SOURCE_FILES).toContain('routes.ts')
1729
+ expect(CENTRAL_SOURCE_FILES).toContain('handlers.ts')
1730
+ })
1731
+
1732
+ it('enables every plugin rule over the population its law names', () => {
1733
+ const parsed: unknown = JSON.parse(readFileSync(resolve(root, '.oxlintrc.json'), 'utf8'))
1734
+ const rules: unknown = Object.getOwnPropertyDescriptor(policyPlugin, 'rules')?.value
1735
+ if (typeof rules !== 'object' || rules === null) {
1736
+ throw new Error('The policy plugin declares no rules')
1737
+ }
1738
+ const declared = Object.getOwnPropertyNames(rules)
1739
+ expect(declared).toContain('no-misplaced-function')
1740
+ expect(declared).toContain('no-host-line-endings')
1741
+ expect(
1742
+ inspectPolicyWiring(
1743
+ parsed,
1744
+ declared.map((name) => `policy/${name}`),
1745
+ [POLICY_PLACEMENT_GLOBS, POLICY_ENDING_GLOBS],
1746
+ ),
1747
+ ).toEqual([])
1748
+ })
1749
+
889
1750
  it('loads every configured policy rule through the real binary', () => {
890
1751
  const scratch = createPolicyScratch({ prefix: 'orkestrel-config-policy-' })
891
1752
  try {
1753
+ // Compare diagnostics from the real directory while the child keeps the scratch cwd.
1754
+ const comparisonRoot = realpathSync.native(scratch.path)
892
1755
  scratch.write('.oxlintrc.json', readFileSync(resolve(root, '.oxlintrc.json'), 'utf8'))
893
1756
  scratch.write('configs/policy.ts', readFileSync(resolve(root, 'configs/policy.ts'), 'utf8'))
894
1757
  scratch.write(
895
1758
  'src/violations/fixture.ts',
896
1759
  [
1760
+ '// A reader should meet this term.',
897
1761
  "vi.mock('./x')",
898
1762
  'class PrivateMember { private value = 1 }',
899
1763
  'class ParameterMember { constructor(readonly value: string) {} }',
900
1764
  'class PublicMember { public value = 1 }',
901
1765
  'function OuterFunction() { const nested = () => undefined; return nested() }',
1766
+ "import * as os from 'node:os'",
1767
+ "import { EOL } from 'node:os'",
1768
+ 'export interface ValueInterface { readonly id: string }',
1769
+ '/** The opener, a noun phrase. */',
1770
+ "export const STATUS = 'ready'",
1771
+ "export const lines = text.trim().split('\\n')",
1772
+ 'export const ending = os.EOL + EOL',
902
1773
  'void PrivateMember',
903
1774
  'void ParameterMember',
904
1775
  'void PublicMember',
@@ -906,13 +1777,36 @@ describe('policy plugin', () => {
906
1777
  ].join('\n'),
907
1778
  )
908
1779
  scratch.write(
909
- 'src/clean/fixture.ts',
1780
+ 'src/violations/helpers.ts',
1781
+ ['function buildValue(): void {}', 'void buildValue'].join('\n'),
1782
+ )
1783
+ scratch.write('src/violations/parsers.ts', 'export function coerceValue(): void {}\n')
1784
+ scratch.write('src/violations/factories.ts', 'export function buildValue(): void {}\n')
1785
+ scratch.write('src/violations/constants.ts', 'export const values = []\n')
1786
+ scratch.write('src/violations/composables.ts', "export const READY = 'yes'\n")
1787
+ scratch.write('app/browser/composables/useTheme.ts', 'export function useMode(): void {}\n')
1788
+ // The line-ending population reaches src, app, and configs alone, so this module
1789
+ // outside them carries the same defect and must draw no diagnostic. The `debugger`
1790
+ // statement is the arrival control the root `no-debugger` rule reports, which proves
1791
+ // the file entered the run the following absence assertion reads.
1792
+ scratch.write(
1793
+ 'scripts/read.ts',
1794
+ [
1795
+ 'export function readLines(text: string): readonly string[] {',
1796
+ '\tdebugger',
1797
+ "\treturn text.trim().split('\\n')",
1798
+ '}',
1799
+ ].join('\n'),
1800
+ )
1801
+ scratch.write(
1802
+ 'src/clean/CleanMember.ts',
910
1803
  [
911
- 'class CleanMember {',
1804
+ '// Reads the value a caller receives.',
1805
+ '/** Holds one runtime-private value. */',
1806
+ 'export class CleanMember {',
912
1807
  '\t#value = 1',
913
1808
  '\tvalue(): number { return this.#value }',
914
1809
  '}',
915
- 'void CleanMember',
916
1810
  ].join('\n'),
917
1811
  )
918
1812
 
@@ -941,7 +1835,7 @@ describe('policy plugin', () => {
941
1835
  const config = resolve(scratch.path, '.oxlintrc.json')
942
1836
  const violations = spawnSync(
943
1837
  process.execPath,
944
- [binary, '--config', config, '--format', 'json', 'src/violations'],
1838
+ [binary, '--config', config, '--format', 'json', 'src/violations', 'app', 'scripts'],
945
1839
  { cwd: scratch.path, encoding: 'utf8', timeout: 15_000 },
946
1840
  )
947
1841
  const clean = spawnSync(
@@ -964,10 +1858,11 @@ describe('policy plugin', () => {
964
1858
  throw new Error('Oxlint returned a malformed diagnostic')
965
1859
  }
966
1860
  const code: unknown = Object.getOwnPropertyDescriptor(diagnostic, 'code')?.value
967
- if (typeof code !== 'string') {
968
- throw new Error('Oxlint returned a diagnostic without a rule id')
1861
+ const filename: unknown = Object.getOwnPropertyDescriptor(diagnostic, 'filename')?.value
1862
+ if (typeof code !== 'string' || typeof filename !== 'string') {
1863
+ throw new Error('Oxlint returned a diagnostic without a rule id and a file')
969
1864
  }
970
- codes.push(code)
1865
+ codes.push(`${code} ${normalizePolicyFilename(comparisonRoot, filename)}`)
971
1866
  }
972
1867
  reports.push(codes)
973
1868
  }
@@ -978,15 +1873,42 @@ describe('policy plugin', () => {
978
1873
  throw new Error('Oxlint returned no fixture reports')
979
1874
  }
980
1875
  expect(violations.status).toBe(1)
981
- for (const rule of [
982
- 'policy(no-mocking)',
983
- 'policy(no-keyword-privacy)',
984
- 'policy(no-nested-functions)',
985
- 'typescript(parameter-properties)',
986
- 'typescript(explicit-member-accessibility)',
1876
+ for (const { code, filename } of [
1877
+ { code: 'policy(no-mocking)', filename: 'src/violations/fixture.ts' },
1878
+ { code: 'policy(no-keyword-privacy)', filename: 'src/violations/fixture.ts' },
1879
+ { code: 'policy(no-nested-functions)', filename: 'src/violations/fixture.ts' },
1880
+ { code: 'policy(no-misplaced-type)', filename: 'src/violations/fixture.ts' },
1881
+ { code: 'policy(no-misplaced-data)', filename: 'src/violations/fixture.ts' },
1882
+ { code: 'policy(no-misplaced-function)', filename: 'src/violations/fixture.ts' },
1883
+ { code: 'policy(no-misplaced-class)', filename: 'src/violations/fixture.ts' },
1884
+ { code: 'policy(no-host-line-endings)', filename: 'src/violations/fixture.ts' },
1885
+ { code: 'policy(no-hidden-declaration)', filename: 'src/violations/helpers.ts' },
1886
+ { code: 'policy(no-misnamed-parser)', filename: 'src/violations/parsers.ts' },
1887
+ { code: 'policy(no-misnamed-factory)', filename: 'src/violations/factories.ts' },
1888
+ { code: 'policy(no-malformed-constant)', filename: 'src/violations/constants.ts' },
1889
+ { code: 'policy(no-malformed-domain)', filename: 'src/violations/composables.ts' },
1890
+ {
1891
+ code: 'policy(no-malformed-domain)',
1892
+ filename: 'app/browser/composables/useTheme.ts',
1893
+ },
1894
+ { code: 'policy(no-banned-term)', filename: 'src/violations/fixture.ts' },
1895
+ { code: 'policy(no-malformed-summary)', filename: 'src/violations/fixture.ts' },
1896
+ { code: 'typescript(parameter-properties)', filename: 'src/violations/fixture.ts' },
1897
+ {
1898
+ code: 'typescript(explicit-member-accessibility)',
1899
+ filename: 'src/violations/fixture.ts',
1900
+ },
987
1901
  ]) {
988
- expect(violationCodes).toContain(rule)
1902
+ expect(violationCodes).toContain(
1903
+ `${code} ${normalizePolicyFilename(comparisonRoot, filename)}`,
1904
+ )
989
1905
  }
1906
+ expect(violationCodes).toContain(
1907
+ `eslint(no-debugger) ${normalizePolicyFilename(comparisonRoot, 'scripts/read.ts')}`,
1908
+ )
1909
+ expect(violationCodes).not.toContain(
1910
+ `policy(no-host-line-endings) ${normalizePolicyFilename(comparisonRoot, 'scripts/read.ts')}`,
1911
+ )
990
1912
  expect(clean.status).toBe(0)
991
1913
  expect(cleanCodes).toHaveLength(0)
992
1914
  } finally {
@@ -1001,8 +1923,10 @@ describe('configuration helpers', () => {
1001
1923
  'ENVIRONMENT_MODULE_BYTES',
1002
1924
  'PACKAGE_MANIFEST_BYTES',
1003
1925
  'WORKSPACE_ROOT',
1926
+ 'buildExtractorOverride',
1004
1927
  'containedPath',
1005
1928
  'decodeAssetSource',
1929
+ 'declarationRollup',
1006
1930
  'enforceBuildLog',
1007
1931
  'enforceOutputPath',
1008
1932
  'environmentAssetSources',
@@ -1012,8 +1936,10 @@ describe('configuration helpers', () => {
1012
1936
  'fileSystemPath',
1013
1937
  'hasAsciiUrlControl',
1014
1938
  'isBoundaryExemptModule',
1939
+ 'isExtractorModule',
1015
1940
  'isOutsideWorkspacePath',
1016
1941
  'isPackageBoundary',
1942
+ 'isStringList',
1017
1943
  'isStylesheetPath',
1018
1944
  'isWorkspaceBoundaryModule',
1019
1945
  'outputBoundary',
@@ -1021,8 +1947,11 @@ describe('configuration helpers', () => {
1021
1947
  'packageNameOf',
1022
1948
  'packageRootForResolved',
1023
1949
  'packageRootOf',
1950
+ 'parseProjectScope',
1024
1951
  'physicalPath',
1025
1952
  'readBoundedFile',
1953
+ 'readCompilerOutput',
1954
+ 'rewriteCoreSpecifier',
1026
1955
  'sourceFallback',
1027
1956
  'trustedPackageRootFor',
1028
1957
  'workspacePath',
@@ -1220,4 +2149,259 @@ describe('configuration helpers', () => {
1220
2149
  rmSync(workspace, { recursive: true, force: true })
1221
2150
  }
1222
2151
  })
2152
+
2153
+ it('reads the compiler scope and fixed extractor override a declaration roll-up requires', () => {
2154
+ const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc')
2155
+ // The order mirrors ENVIRONMENTS in src/core/constants.ts; a server-only workspace vendors
2156
+ // no core project, so this walks to the first face the workspace actually carries.
2157
+ const faces = ['core', 'browser', 'server']
2158
+ const face = faces.find((candidate) =>
2159
+ existsSync(resolve(root, `configs/src/tsconfig.${candidate}.json`)),
2160
+ )
2161
+ if (face === undefined) throw new Error('The workspace declares no face project')
2162
+ const project = resolve(root, `configs/src/tsconfig.${face}.json`)
2163
+ const declared: unknown = JSON.parse(readFileSync(project, 'utf8'))
2164
+ if (typeof declared !== 'object' || declared === null) {
2165
+ throw new Error(`The ${face} project is not a TypeScript configuration record`)
2166
+ }
2167
+ const declaredOptions: unknown = Object.getOwnPropertyDescriptor(
2168
+ declared,
2169
+ 'compilerOptions',
2170
+ )?.value
2171
+ if (typeof declaredOptions !== 'object' || declaredOptions === null) {
2172
+ throw new Error(`The ${face} project carries no compiler options`)
2173
+ }
2174
+ const declaredLib: unknown = Object.getOwnPropertyDescriptor(declaredOptions, 'lib')?.value
2175
+ const declaredTypes: unknown = Object.getOwnPropertyDescriptor(declaredOptions, 'types')?.value
2176
+ if (!configHelpers.isStringList(declaredLib) || !configHelpers.isStringList(declaredTypes)) {
2177
+ throw new Error(`The ${face} project declares no lib or types`)
2178
+ }
2179
+ const declaredRootDir: unknown = Object.getOwnPropertyDescriptor(
2180
+ declaredOptions,
2181
+ 'rootDir',
2182
+ )?.value
2183
+ if (typeof declaredRootDir !== 'string') {
2184
+ throw new Error(`The ${face} project declares no rootDir`)
2185
+ }
2186
+ const expectedRoot = resolve(dirname(project), declaredRootDir)
2187
+
2188
+ const scope = configHelpers.parseProjectScope(
2189
+ configHelpers.readCompilerOutput(compiler, ['--showConfig', '-p', project]),
2190
+ project,
2191
+ )
2192
+ if (scope === undefined) throw new Error(`The ${face} project resolved no compiler scope`)
2193
+ // The compiler lowercases every resolved library name, so the committed project is the
2194
+ // second mechanism this reading is compared against rather than the reading itself.
2195
+ expect(scope.lib.map((entry) => entry.toLowerCase())).toStrictEqual(
2196
+ declaredLib.map((entry) => entry.toLowerCase()),
2197
+ )
2198
+ expect(scope.types).toStrictEqual(declaredTypes)
2199
+ expect(scope.root).toBe(expectedRoot)
2200
+
2201
+ expect(configHelpers.parseProjectScope('not a configuration', project)).toBeUndefined()
2202
+ expect(
2203
+ configHelpers.parseProjectScope('{"compilerOptions":{"lib":[],"types":[]}}', project),
2204
+ ).toBeUndefined()
2205
+ expect(
2206
+ configHelpers.parseProjectScope('{"compilerOptions":{"lib":[1],"rootDir":"."}}', project),
2207
+ ).toBeUndefined()
2208
+ expect(() =>
2209
+ configHelpers.readCompilerOutput(compiler, [
2210
+ '--showConfig',
2211
+ '-p',
2212
+ resolve(root, 'configs/src/tsconfig.absent.json'),
2213
+ ]),
2214
+ ).toThrow('The declaration compiler failed')
2215
+
2216
+ expect(configHelpers.isStringList(['a', 'b'])).toBe(true)
2217
+ expect(configHelpers.isStringList([])).toBe(true)
2218
+ expect(configHelpers.isStringList(['a', 1])).toBe(false)
2219
+ expect(configHelpers.isStringList('a')).toBe(false)
2220
+
2221
+ // The extractor runs its own bundled engine, so this override is the whole option set the
2222
+ // roll-up may hand it: passing more leaves that engine unable to follow a symbol.
2223
+ expect(
2224
+ configHelpers.buildExtractorOverride('/w/dist/index.d.ts', ['esnext'], ['node']),
2225
+ ).toStrictEqual({
2226
+ compilerOptions: {
2227
+ types: ['node'],
2228
+ lib: ['esnext'],
2229
+ target: 'ESNext',
2230
+ module: 'ESNext',
2231
+ moduleResolution: 'bundler',
2232
+ skipLibCheck: true,
2233
+ strict: true,
2234
+ },
2235
+ files: ['/w/dist/index.d.ts'],
2236
+ })
2237
+
2238
+ const name = configHelpers.packageManifestName(configHelpers.WORKSPACE_ROOT)
2239
+ if (name === undefined) throw new Error('The workspace manifest names no package')
2240
+ expect(configHelpers.rewriteCoreSpecifier("from '@src/core'")).toBe(`from '${name}'`)
2241
+ expect(configHelpers.rewriteCoreSpecifier("from '../../core/index.js'")).toBe(`from '${name}'`)
2242
+ expect(configHelpers.rewriteCoreSpecifier("from './sibling.js'")).toBe("from './sibling.js'")
2243
+
2244
+ // The mechanism the roll-up proof's skip reads: an absent package rejects resolution.
2245
+ expect(() => createRequire(import.meta.url).resolve('@absent/declaration-extractor')).toThrow(
2246
+ 'Cannot find module',
2247
+ )
2248
+
2249
+ expect(configHelpers.isExtractorModule(undefined)).toBe(false)
2250
+ expect(configHelpers.isExtractorModule({ Extractor: {}, ExtractorConfig: {} })).toBe(false)
2251
+ expect(
2252
+ configHelpers.isExtractorModule({
2253
+ Extractor: () => undefined,
2254
+ ExtractorConfig: () => undefined,
2255
+ }),
2256
+ ).toBe(false)
2257
+ expect(
2258
+ configHelpers.isExtractorModule({
2259
+ Extractor: Object.assign(() => undefined, { invoke: () => undefined }),
2260
+ ExtractorConfig: Object.assign(() => undefined, { prepare: () => undefined }),
2261
+ }),
2262
+ ).toBe(true)
2263
+
2264
+ // The guard reads `invoke` and `prepare` through the prototype chain, as its remarks state.
2265
+ expect(
2266
+ configHelpers.isExtractorModule({
2267
+ Extractor: Object.setPrototypeOf(() => undefined, { invoke: () => undefined }),
2268
+ ExtractorConfig: Object.setPrototypeOf(() => undefined, { prepare: () => undefined }),
2269
+ }),
2270
+ ).toBe(true)
2271
+ })
2272
+
2273
+ // A hoisted install can place the extractor anywhere `require.resolve` reaches, so the skip
2274
+ // control compares against the path resolution actually returned rather than a fixed layout.
2275
+ it.skipIf(extractorPath === undefined)('finds the resolved extractor on disk', () => {
2276
+ if (extractorPath === undefined) throw new Error('The extractor resolution left no path')
2277
+ expect(existsSync(extractorPath)).toBe(true)
2278
+ })
2279
+
2280
+ it.skipIf(extractorPath !== undefined)('rejects resolving the unavailable extractor', () => {
2281
+ expect(() => createRequire(import.meta.url).resolve('@microsoft/api-extractor')).toThrow(
2282
+ 'Cannot find module',
2283
+ )
2284
+ })
2285
+
2286
+ // The roll-up loads the declaration extractor, which only a workspace publishing source from
2287
+ // `src` installs. Where `require.resolve` rejects that package, this proof does not apply.
2288
+ it.skipIf(extractorPath === undefined)(
2289
+ 'rolls one face into a single declaration and rewrites its core specifier',
2290
+ async () => {
2291
+ const scratch = createPolicyScratch({ prefix: 'orkestrel-config-rollup-' })
2292
+ // The hook's temporary declaration emit is proven removed: no name beginning
2293
+ // `orkestrel-declarations-` present after the builds that was absent before them.
2294
+ const before = new Set(
2295
+ readdirSync(tmpdir()).filter((entry) => entry.startsWith('orkestrel-declarations-')),
2296
+ )
2297
+ try {
2298
+ const workspace = scratch.path
2299
+ const project = join(workspace, 'tsconfig.json')
2300
+ const source = join(workspace, 'source', 'server', 'index.ts')
2301
+ scratch.write(
2302
+ 'tsconfig.json',
2303
+ JSON.stringify({
2304
+ compilerOptions: {
2305
+ target: 'ESNext',
2306
+ module: 'ESNext',
2307
+ moduleResolution: 'bundler',
2308
+ lib: ['ESNext'],
2309
+ types: [],
2310
+ strict: true,
2311
+ verbatimModuleSyntax: true,
2312
+ skipLibCheck: true,
2313
+ declaration: true,
2314
+ emitDeclarationOnly: true,
2315
+ noEmit: false,
2316
+ rootDir: './source',
2317
+ outDir: './emit',
2318
+ paths: { '@src/core': ['./source/core/index.ts'] },
2319
+ },
2320
+ include: ['./source/server/**/*.ts'],
2321
+ }),
2322
+ )
2323
+ scratch.write(
2324
+ 'source/core/index.ts',
2325
+ 'export interface FixtureLabel {\n\treadonly label: string\n}\n',
2326
+ )
2327
+ scratch.write(
2328
+ 'source/server/index.ts',
2329
+ "import type { FixtureLabel } from '@src/core'\n\nexport interface FixtureRecord {\n\treadonly label: FixtureLabel\n\treadonly count: number\n}\n",
2330
+ )
2331
+
2332
+ const name = configHelpers.packageManifestName(configHelpers.WORKSPACE_ROOT)
2333
+ if (name === undefined) throw new Error('The workspace manifest names no package')
2334
+ const rewritten = join(workspace, 'rewritten')
2335
+ const kept = join(workspace, 'kept')
2336
+ const builds = [
2337
+ { output: rewritten, rewrite: true },
2338
+ { output: kept, rewrite: false },
2339
+ ]
2340
+ for (const face of builds) {
2341
+ await build({
2342
+ root: workspace,
2343
+ configFile: false,
2344
+ logLevel: 'silent',
2345
+ publicDir: false,
2346
+ plugins: [
2347
+ configHelpers.declarationRollup(
2348
+ face.rewrite
2349
+ ? { project, rewrite: configHelpers.rewriteCoreSpecifier }
2350
+ : { project },
2351
+ ),
2352
+ ],
2353
+ build: {
2354
+ write: true,
2355
+ outDir: face.output,
2356
+ lib: {
2357
+ entry: source,
2358
+ formats: ['es'],
2359
+ fileName: () => 'index.js',
2360
+ },
2361
+ rolldownOptions: { external: [/^node:/u, /^@orkestrel\//u] },
2362
+ },
2363
+ })
2364
+ }
2365
+
2366
+ const idle = join(workspace, 'idle')
2367
+ const serving = configHelpers.declarationRollup({
2368
+ project,
2369
+ rewrite: configHelpers.rewriteCoreSpecifier,
2370
+ })
2371
+ const configure = serving.configResolved
2372
+ const close = serving.closeBundle
2373
+ if (typeof configure !== 'function' || typeof close !== 'function') {
2374
+ throw new Error('The declaration roll-up exposes no build hooks')
2375
+ }
2376
+ Reflect.apply(configure, undefined, [
2377
+ { command: 'serve', root: workspace, build: { outDir: idle, lib: { entry: source } } },
2378
+ ])
2379
+ await Reflect.apply(close, undefined, [])
2380
+
2381
+ const after = readdirSync(tmpdir()).filter((entry) =>
2382
+ entry.startsWith('orkestrel-declarations-'),
2383
+ )
2384
+ expect(after.every((entry) => before.has(entry))).toBe(true)
2385
+
2386
+ // The face ships exactly one declaration: the emit's scratch tree leaves with it.
2387
+ expect(globSync('**/*.d.ts', { cwd: rewritten })).toStrictEqual(['index.d.ts'])
2388
+ expect(existsSync(join(rewritten, 'declarations'))).toBe(false)
2389
+ expect(globSync('**/*.d.ts', { cwd: kept })).toStrictEqual(['index.d.ts'])
2390
+ expect(existsSync(join(kept, 'declarations'))).toBe(false)
2391
+ expect(existsSync(idle)).toBe(false)
2392
+
2393
+ const rolled = readFileSync(join(rewritten, 'index.d.ts'), 'utf8')
2394
+ expect(rolled).toContain('FixtureRecord')
2395
+ expect(rolled).toContain(`from '${name}'`)
2396
+ expect(rolled).not.toContain('@src/core')
2397
+
2398
+ // The control: the same face without a rewrite ships the specifier the extractor kept.
2399
+ const control = readFileSync(join(workspace, 'kept', 'index.d.ts'), 'utf8')
2400
+ expect(control).toContain('@src/core')
2401
+ expect(control).not.toContain(`from '${name}'`)
2402
+ } finally {
2403
+ scratch.destroy()
2404
+ }
2405
+ },
2406
+ )
1223
2407
  })