@orkestrel/scaffold 0.0.72 → 0.0.74

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.
@@ -4,7 +4,7 @@ import {
4
4
  hasCanonicalSegments,
5
5
  resolveLink,
6
6
  } from '@orkestrel/guide'
7
- import { HOST_PATHS } from '@orkestrel/scaffold'
7
+ import { BASE_DEV_DEPENDENCIES, HOST_PATHS } from '@orkestrel/scaffold'
8
8
  import {
9
9
  existsSync,
10
10
  globSync,
@@ -156,6 +156,7 @@ export interface PolicyControl {
156
156
  readonly directories?: readonly string[]
157
157
  readonly line?: number
158
158
  readonly message?: string
159
+ readonly violations?: readonly PolicyViolation[]
159
160
  }
160
161
 
161
162
  /** Describes a contained temporary directory owned by one vendored test. */
@@ -954,14 +955,423 @@ export function inspectSkillTemplateTODOs(
954
955
  return violations
955
956
  }
956
957
 
958
+ /** Names why a declaration reading produced no inventory. */
959
+ export type SkillDeclarationRefusal =
960
+ | 'entry'
961
+ | 'form'
962
+ | 'name'
963
+ | 'package'
964
+ | 'specifier'
965
+ | 'syntax'
966
+ | 'target'
967
+
968
+ /** Names what one declaration reading produced. */
969
+ export type SkillDeclarationOutcome = SkillDeclarationRefusal | 'cycle' | 'read'
970
+
971
+ /** Reports one declaration entry's exported names, or the cause that refused the reading. */
972
+ export interface SkillDeclarationResult {
973
+ readonly outcome: SkillDeclarationOutcome
974
+ readonly names: readonly string[]
975
+ readonly detail?: string
976
+ }
977
+
978
+ /** Supplies the sentence each refused declaration reading reports, ahead of its own detail. */
979
+ export const SKILL_DECLARATION_MESSAGES: Readonly<Record<SkillDeclarationRefusal, string>> =
980
+ Object.freeze({
981
+ entry: 'has no declaration entry for',
982
+ form: 'has an unsupported declaration form:',
983
+ name: 're-exports a name its target does not declare:',
984
+ package: 'has no installed package',
985
+ specifier: 'is not a supported package entry specifier',
986
+ syntax: 'has a declaration syntax error:',
987
+ target: 'has no declaration file at',
988
+ })
989
+
990
+ /**
991
+ * Reads one package manifest from the directory that holds it.
992
+ *
993
+ * @param directory - The package directory to read the manifest from.
994
+ * @returns The parsed manifest, or `undefined` when the directory holds none.
995
+ * @throws SyntaxError - Thrown when the manifest exists and holds no valid JSON.
996
+ */
997
+ export function readSkillManifest(directory: string): unknown {
998
+ const path = join(directory, 'package.json')
999
+ if (!existsSync(path)) return undefined
1000
+ const manifest: unknown = JSON.parse(readFileSync(path, 'utf8'))
1001
+ return manifest
1002
+ }
1003
+
1004
+ /**
1005
+ * Reads a public entry's exported declaration names without loading its runtime.
1006
+ *
1007
+ * @param root - The workspace whose own manifest or installed packages supply the declarations.
1008
+ * @param specifier - The public package entry to resolve through its exports map.
1009
+ * @returns The exported names, or the outcome naming why the reading refused.
1010
+ * @remarks Resolves the workspace's own package against `root` when the root manifest carries that
1011
+ * name, and every other package under node_modules, so a checkout reads the package it publishes.
1012
+ * Reads exact exports-map keys through the types, import, and default conditions; a wildcard key,
1013
+ * an array, a source alias, and a runtime-only entry have no declaration entry. The Oxc parser
1014
+ * reads exported function, variable, class, enum, interface, and type declarations, local export
1015
+ * lists, and relative star and named re-exports, including aliases and type forms. Relative .js,
1016
+ * .mjs, and .cjs targets resolve to .d.ts, .d.mts, and .d.cts declarations. This is a name
1017
+ * inventory, not TypeScript semantic validation.
1018
+ */
1019
+ export function readSkillExports(root: string, specifier: string): SkillDeclarationResult {
1020
+ if (!/^@orkestrel\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/u.test(specifier))
1021
+ return { outcome: 'specifier', names: [] }
1022
+ const segments = specifier.split('/')
1023
+ const key = segments.length === 2 ? '.' : `./${segments.slice(2).join('/')}`
1024
+ const own = readSkillManifest(root)
1025
+ const owned = isPolicyRecord(own) && own['name'] === segments.slice(0, 2).join('/')
1026
+ const directory = owned ? root : join(root, 'node_modules', ...segments.slice(0, 2))
1027
+ const manifest = owned ? own : readSkillManifest(directory)
1028
+ if (!isPolicyRecord(manifest)) return { outcome: 'package', names: [] }
1029
+ const exports = manifest['exports']
1030
+ const target = resolveSkillDeclaration(
1031
+ isPolicyRecord(exports) && Object.keys(exports).some((name) => name.startsWith('.'))
1032
+ ? exports[key]
1033
+ : key === '.'
1034
+ ? exports
1035
+ : undefined,
1036
+ )
1037
+ if (target === undefined || !target.startsWith('./') || !hasCanonicalSegments(target.slice(2)))
1038
+ return { outcome: 'entry', names: [], detail: key }
1039
+ return readSkillDeclarations(resolve(directory, target))
1040
+ }
1041
+
957
1042
  /**
958
- * Inspects one discovered skill's required files, metadata, token, and references.
1043
+ * Resolves an explicit declaration target under supported exports-map conditions.
1044
+ *
1045
+ * @param target - The installed exports-map value.
1046
+ * @returns The declaration path, or `undefined` for an unsupported target.
1047
+ */
1048
+ export function resolveSkillDeclaration(target: unknown): string | undefined {
1049
+ if (typeof target === 'string') return /\.d\.(?:ts|mts|cts)$/u.test(target) ? target : undefined
1050
+ if (!isPolicyRecord(target)) return undefined
1051
+ for (const condition of ['types', 'import', 'default']) {
1052
+ if (Object.hasOwn(target, condition)) return resolveSkillDeclaration(target[condition])
1053
+ }
1054
+ return undefined
1055
+ }
1056
+
1057
+ /** Enumerates the refusals a declaration file can carry, each written as the form that raises it. */
1058
+ export const SKILL_DECLARATION_REFUSALS = Object.freeze([
1059
+ 'export default function read(): void',
1060
+ 'declare const value: string\nexport = value',
1061
+ 'declare module "foreign" { export const value: string }',
1062
+ 'export declare namespace Vocabulary { }',
1063
+ 'export declare const value: string\nexport { value as default }',
1064
+ 'export * as vocabulary from "./values.js"',
1065
+ 'export as namespace Vocabulary',
1066
+ 'export * from "foreign"',
1067
+ 'export { absent } from "./values.js"',
1068
+ 'export * from "./absent.js"',
1069
+ 'export const =',
1070
+ ])
1071
+
1072
+ /** Describes one planted package entry and the refusal sentence its fenced import reports. */
1073
+ export interface SkillRefusalCase {
1074
+ readonly label: string
1075
+ readonly specifier: string
1076
+ readonly files: readonly PolicySource[]
1077
+ readonly message: string
1078
+ }
1079
+
1080
+ /** Supplies one planted package for each refusal cause a fenced import reports by its own name. */
1081
+ export const SKILL_REFUSAL_CASES: readonly SkillRefusalCase[] = Object.freeze([
1082
+ {
1083
+ label: 'an entry segment outside the specifier grammar',
1084
+ specifier: '@orkestrel/test/Browser',
1085
+ files: [
1086
+ {
1087
+ path: 'node_modules/@orkestrel/test/package.json',
1088
+ content: '{"name":"@orkestrel/test","exports":{".":{"types":"./entry.d.ts"}}}',
1089
+ },
1090
+ {
1091
+ path: 'node_modules/@orkestrel/test/entry.d.ts',
1092
+ content: 'export declare const VALUE: string\n',
1093
+ },
1094
+ ],
1095
+ message:
1096
+ 'skill fence import @orkestrel/test/Browser is not a supported package entry specifier',
1097
+ },
1098
+ {
1099
+ label: 'a package the workspace does not hold',
1100
+ specifier: '@orkestrel/test',
1101
+ files: [],
1102
+ message: 'skill fence import @orkestrel/test has no installed package',
1103
+ },
1104
+ {
1105
+ label: 'an exports key the map does not declare',
1106
+ specifier: '@orkestrel/test/missing',
1107
+ files: [
1108
+ {
1109
+ path: 'node_modules/@orkestrel/test/package.json',
1110
+ content: '{"name":"@orkestrel/test","exports":{".":{"types":"./entry.d.ts"}}}',
1111
+ },
1112
+ {
1113
+ path: 'node_modules/@orkestrel/test/entry.d.ts',
1114
+ content: 'export declare const VALUE: string\n',
1115
+ },
1116
+ ],
1117
+ message: 'skill fence import @orkestrel/test/missing has no declaration entry for ./missing',
1118
+ },
1119
+ {
1120
+ label: 'a wildcard exports key the reader does not expand',
1121
+ specifier: '@orkestrel/test/browser',
1122
+ files: [
1123
+ {
1124
+ path: 'node_modules/@orkestrel/test/package.json',
1125
+ content: '{"name":"@orkestrel/test","exports":{"./*":{"types":"./*.d.ts"}}}',
1126
+ },
1127
+ {
1128
+ path: 'node_modules/@orkestrel/test/browser.d.ts',
1129
+ content: 'export declare const VALUE: string\n',
1130
+ },
1131
+ ],
1132
+ message: 'skill fence import @orkestrel/test/browser has no declaration entry for ./browser',
1133
+ },
1134
+ {
1135
+ label: 'a declaration target the package does not hold',
1136
+ specifier: '@orkestrel/test',
1137
+ files: [
1138
+ {
1139
+ path: 'node_modules/@orkestrel/test/package.json',
1140
+ content: '{"name":"@orkestrel/test","exports":{".":{"types":"./entry.d.ts"}}}',
1141
+ },
1142
+ ],
1143
+ message: 'skill fence import @orkestrel/test has no declaration file at entry.d.ts',
1144
+ },
1145
+ {
1146
+ label: 'a declaration form the reader refuses',
1147
+ specifier: '@orkestrel/test',
1148
+ files: [
1149
+ {
1150
+ path: 'node_modules/@orkestrel/test/package.json',
1151
+ content: '{"name":"@orkestrel/test","exports":{".":{"types":"./entry.d.ts"}}}',
1152
+ },
1153
+ {
1154
+ path: 'node_modules/@orkestrel/test/entry.d.ts',
1155
+ content: 'export default function read(): void\n',
1156
+ },
1157
+ ],
1158
+ message:
1159
+ 'skill fence import @orkestrel/test has an unsupported declaration form: export default',
1160
+ },
1161
+ {
1162
+ label: 'a re-exported name the target does not declare',
1163
+ specifier: '@orkestrel/test',
1164
+ files: [
1165
+ {
1166
+ path: 'node_modules/@orkestrel/test/package.json',
1167
+ content: '{"name":"@orkestrel/test","exports":{".":{"types":"./entry.d.ts"}}}',
1168
+ },
1169
+ {
1170
+ path: 'node_modules/@orkestrel/test/entry.d.ts',
1171
+ content: "export { absent } from './values.js'\n",
1172
+ },
1173
+ {
1174
+ path: 'node_modules/@orkestrel/test/values.d.ts',
1175
+ content: 'export declare const VALUE: string\n',
1176
+ },
1177
+ ],
1178
+ message:
1179
+ 'skill fence import @orkestrel/test re-exports a name its target does not declare: absent',
1180
+ },
1181
+ ])
1182
+
1183
+ /**
1184
+ * Reads declaration names while following relative declaration re-exports without execution.
1185
+ *
1186
+ * @param path - The absolute declaration path.
1187
+ * @param ancestors - The declaration paths visited along this branch.
1188
+ * @returns The exported names, or the outcome naming why the reading refused.
1189
+ * @remarks Supports the declaration forms documented on {@link readSkillExports}. A file the branch
1190
+ * already visited reports the `cycle` outcome carrying the names that file declares itself: a named
1191
+ * re-export resolves against those declarations, and a star re-export of a visited file contributes
1192
+ * nothing, because the frame still reading that file contributes its names already. Local export
1193
+ * lists supply their written names; this parser does not typecheck their bindings or resolve
1194
+ * ambiguous star exports.
1195
+ */
1196
+ export function readSkillDeclarations(
1197
+ path: string,
1198
+ ancestors: readonly string[] = [],
1199
+ ): SkillDeclarationResult {
1200
+ if (!existsSync(path)) return { outcome: 'target', names: [], detail: basename(path) }
1201
+ const source = parseSync(path, readFileSync(path, 'utf8'))
1202
+ const [failure] = source.errors
1203
+ if (failure !== undefined) return { outcome: 'syntax', names: [], detail: failure.message }
1204
+ const names = new Set<string>()
1205
+ for (const statement of source.program.body) {
1206
+ if (statement.type === 'ExportDefaultDeclaration')
1207
+ return { outcome: 'form', names: [], detail: 'export default' }
1208
+ if (statement.type === 'TSExportAssignment')
1209
+ return { outcome: 'form', names: [], detail: 'export assignment' }
1210
+ if (statement.type === 'TSNamespaceExportDeclaration')
1211
+ return { outcome: 'form', names: [], detail: 'export as namespace' }
1212
+ if (statement.type === 'TSModuleDeclaration')
1213
+ return { outcome: 'form', names: [], detail: 'ambient module' }
1214
+ if (statement.type !== 'ExportNamedDeclaration' || statement.source !== null) continue
1215
+ for (const binding of statement.specifiers) {
1216
+ const name =
1217
+ binding.exported.type === 'Identifier' ? binding.exported.name : binding.exported.value
1218
+ if (name === 'default') return { outcome: 'form', names: [], detail: 'default export list' }
1219
+ names.add(name)
1220
+ }
1221
+ const declaration = statement.declaration
1222
+ if (declaration === null) continue
1223
+ if (declaration.type === 'VariableDeclaration') {
1224
+ for (const variable of declaration.declarations) {
1225
+ if (variable.id.type !== 'Identifier')
1226
+ return { outcome: 'form', names: [], detail: 'destructured declaration' }
1227
+ names.add(variable.id.name)
1228
+ }
1229
+ } else if (declaration.type === 'TSModuleDeclaration') {
1230
+ return { outcome: 'form', names: [], detail: 'exported namespace' }
1231
+ } else if (
1232
+ declaration.type === 'TSDeclareFunction' ||
1233
+ declaration.type === 'ClassDeclaration' ||
1234
+ declaration.type === 'TSEnumDeclaration' ||
1235
+ declaration.type === 'TSInterfaceDeclaration' ||
1236
+ declaration.type === 'TSTypeAliasDeclaration'
1237
+ ) {
1238
+ if (declaration.id === null)
1239
+ return { outcome: 'form', names: [], detail: 'anonymous declaration' }
1240
+ names.add(declaration.id.name)
1241
+ } else return { outcome: 'form', names: [], detail: 'unsupported exported declaration' }
1242
+ }
1243
+ if (ancestors.includes(path)) return { outcome: 'cycle', names: [...names] }
1244
+ for (const statement of source.program.body) {
1245
+ if (statement.type !== 'ExportAllDeclaration' && statement.type !== 'ExportNamedDeclaration')
1246
+ continue
1247
+ const origin = statement.source
1248
+ if (origin === null) continue
1249
+ if (statement.type === 'ExportAllDeclaration' && statement.exported !== null)
1250
+ return { outcome: 'form', names: [], detail: 'star export alias' }
1251
+ if (!origin.value.startsWith('./') && !origin.value.startsWith('../'))
1252
+ return { outcome: 'form', names: [], detail: 'non-relative re-export' }
1253
+ const declaration = /\.d\.(?:ts|mts|cts)$/u.test(origin.value)
1254
+ ? origin.value
1255
+ : origin.value.replace(/\.(js|mjs|cjs)$/u, (_, extension: string) =>
1256
+ extension === 'mjs' ? '.d.mts' : extension === 'cjs' ? '.d.cts' : '.d.ts',
1257
+ )
1258
+ if (!/\.d\.(?:ts|mts|cts)$/u.test(declaration))
1259
+ return { outcome: 'form', names: [], detail: 'untyped re-export target' }
1260
+ const targets = readSkillDeclarations(resolve(dirname(path), declaration), [...ancestors, path])
1261
+ if (targets.outcome !== 'read' && targets.outcome !== 'cycle') return targets
1262
+ if (statement.type === 'ExportAllDeclaration') {
1263
+ if (targets.outcome === 'read') for (const name of targets.names) names.add(name)
1264
+ continue
1265
+ }
1266
+ for (const binding of statement.specifiers) {
1267
+ const name =
1268
+ binding.exported.type === 'Identifier' ? binding.exported.name : binding.exported.value
1269
+ const original =
1270
+ binding.local.type === 'Identifier' ? binding.local.name : binding.local.value
1271
+ if (name === 'default') return { outcome: 'form', names: [], detail: 'default re-export' }
1272
+ if (!targets.names.includes(original)) return { outcome: 'name', names: [], detail: original }
1273
+ names.add(name)
1274
+ }
1275
+ }
1276
+ return { outcome: 'read', names: [...names].sort() }
1277
+ }
1278
+
1279
+ /**
1280
+ * Inspects named Orkestrel imports in every Markdown fence against installed declaration exports.
1281
+ *
1282
+ * @param root - The workspace whose installed packages supply the declarations.
1283
+ * @param path - The workspace-relative skill document path reported on a violation.
1284
+ * @param content - The raw Markdown text.
1285
+ * @returns Every unparsed fence, unsupported package, refused declaration, or unexported binding
1286
+ * violation.
1287
+ * @remarks The guide parser supplies fences, including fences nested in lists and blockquotes.
1288
+ * The Oxc parser reads named value and type bindings, aliases, comments, and multiline imports.
1289
+ * A fence the parser refuses reports a violation when its text carries an `@orkestrel/` substring
1290
+ * anywhere, including comments and string literals, because error recovery drops the statements
1291
+ * after the failure. A refused fence without that substring stays outside this check, as prose,
1292
+ * table cells, indented code, default imports,
1293
+ * namespace imports, and imports from other scopes do. A package outside BASE_DEV_DEPENDENCIES
1294
+ * reports a violation, and each refused declaration reading reports the cause
1295
+ * {@link SKILL_DECLARATION_MESSAGES} names. This check proves exported names, not call signatures
1296
+ * or runtime behavior.
1297
+ */
1298
+ export function inspectSkillImports(
1299
+ root: string,
1300
+ path: string,
1301
+ content: string,
1302
+ ): readonly PolicyViolation[] {
1303
+ const violations: PolicyViolation[] = []
1304
+ const entries = new Map<string, SkillDeclarationResult>()
1305
+ for (const fence of createGuide(content).fences()) {
1306
+ const source = parseSync('skill.ts', fence.code)
1307
+ const [failure] = source.errors
1308
+ if (failure !== undefined && fence.code.includes('@orkestrel/')) {
1309
+ violations.push(
1310
+ createPolicyViolation('skill', path, `skill fence could not be parsed: ${failure.message}`),
1311
+ )
1312
+ continue
1313
+ }
1314
+ for (const statement of source.program.body) {
1315
+ if (statement.type !== 'ImportDeclaration') continue
1316
+ const specifier = statement.source.value
1317
+ const bindings = statement.specifiers.filter((binding) => binding.type === 'ImportSpecifier')
1318
+ if (!specifier.startsWith('@orkestrel/') || bindings.length === 0) continue
1319
+ const packageName = specifier.split('/').slice(0, 2).join('/')
1320
+ if (!Object.hasOwn(BASE_DEV_DEPENDENCIES, packageName)) {
1321
+ violations.push(
1322
+ createPolicyViolation(
1323
+ 'skill',
1324
+ path,
1325
+ `skill fence import ${specifier} is outside BASE_DEV_DEPENDENCIES: ${packageName}`,
1326
+ ),
1327
+ )
1328
+ continue
1329
+ }
1330
+ const cached = entries.get(specifier)
1331
+ const entry = cached ?? readSkillExports(root, specifier)
1332
+ if (cached === undefined) entries.set(specifier, entry)
1333
+ if (entry.outcome !== 'read' && entry.outcome !== 'cycle') {
1334
+ const phrase = SKILL_DECLARATION_MESSAGES[entry.outcome]
1335
+ violations.push(
1336
+ createPolicyViolation(
1337
+ 'skill',
1338
+ path,
1339
+ `skill fence import ${specifier} ${entry.detail === undefined ? phrase : `${phrase} ${entry.detail}`}`,
1340
+ ),
1341
+ )
1342
+ continue
1343
+ }
1344
+ for (const binding of bindings) {
1345
+ const name =
1346
+ binding.imported.type === 'Identifier' ? binding.imported.name : binding.imported.value
1347
+ if (!entry.names.includes(name)) {
1348
+ violations.push(
1349
+ createPolicyViolation(
1350
+ 'skill',
1351
+ path,
1352
+ `skill fence import ${specifier} does not export ${name}`,
1353
+ ),
1354
+ )
1355
+ }
1356
+ }
1357
+ }
1358
+ }
1359
+ return violations
1360
+ }
1361
+
1362
+ /**
1363
+ * Inspects one discovered skill's required files, metadata, token, references, and fenced imports.
959
1364
  *
960
1365
  * @param root - The workspace root to inspect.
961
1366
  * @param name - The discovered skill directory name.
1367
+ * @param installation - The workspace supplying installed declaration entries. Default: root.
962
1368
  * @returns Every skill-family violation in invariant order.
963
1369
  */
964
- export function inspectSkill(root: string, name: string): readonly PolicyViolation[] {
1370
+ export function inspectSkill(
1371
+ root: string,
1372
+ name: string,
1373
+ installation = root,
1374
+ ): readonly PolicyViolation[] {
965
1375
  const base = `${SKILL_FAMILY_ROOT}/${name}`
966
1376
  const skill = `${base}/SKILL.md`
967
1377
  const metadata = `${base}/agents/openai.yaml`
@@ -1013,6 +1423,7 @@ export function inspectSkill(root: string, name: string): readonly PolicyViolati
1013
1423
  }
1014
1424
  }
1015
1425
  violations.push(...inspectSkillTemplateTODOs('skill', skill, content))
1426
+ violations.push(...inspectSkillImports(installation, skill, content))
1016
1427
  }
1017
1428
  const hasMetadata = isPolicyFile(root, metadata)
1018
1429
  if (!hasMetadata) {
@@ -1056,8 +1467,10 @@ export function inspectSkill(root: string, name: string): readonly PolicyViolati
1056
1467
  ),
1057
1468
  )
1058
1469
  } else {
1470
+ const referenceContent = readFileSync(join(root, path), 'utf8')
1059
1471
  violations.push(
1060
- ...inspectSkillTemplateTODOs('skill', path, readFileSync(join(root, path), 'utf8')),
1472
+ ...inspectSkillTemplateTODOs('skill', path, referenceContent),
1473
+ ...inspectSkillImports(installation, path, referenceContent),
1061
1474
  )
1062
1475
  }
1063
1476
  }
@@ -1148,11 +1561,13 @@ export function inspectSkill(root: string, name: string): readonly PolicyViolati
1148
1561
  * Inspects every immediate member of the discovered skill family.
1149
1562
  *
1150
1563
  * @param root - The workspace root to inspect.
1564
+ * @param installation - The workspace supplying installed declaration entries. Default: root.
1151
1565
  * @returns Every skill-family violation in directory and invariant order.
1152
1566
  */
1153
- export function inspectSkillFamily(root: string): readonly PolicyViolation[] {
1567
+ export function inspectSkillFamily(root: string, installation = root): readonly PolicyViolation[] {
1154
1568
  const violations: PolicyViolation[] = []
1155
- for (const name of readSkillFamily(root)) violations.push(...inspectSkill(root, name))
1569
+ for (const name of readSkillFamily(root))
1570
+ violations.push(...inspectSkill(root, name, installation))
1156
1571
  return violations
1157
1572
  }
1158
1573
 
@@ -2041,7 +2456,7 @@ export function inspectPolicyControl(control: PolicyControl): readonly PolicyVio
2041
2456
  scratch.write(marker, '')
2042
2457
  rmSync(join(scratch.path, marker))
2043
2458
  }
2044
- if (control.rule === 'skill') return inspectSkillFamily(scratch.path)
2459
+ if (control.rule === 'skill') return inspectSkillFamily(scratch.path, process.cwd())
2045
2460
  if (control.rule === 'bridge') return inspectSkillBridges(scratch.path)
2046
2461
  return inspectPolicyWorkspace(scratch.path)
2047
2462
  } finally {
@@ -2121,6 +2536,155 @@ export const POLICY_CONTROLS: readonly PolicyControl[] = Object.freeze([
2121
2536
 
2122
2537
  /** Lists the physical in-family controls for every skill-family assertion class. */
2123
2538
  export const SKILL_POLICY_CONTROLS: readonly PolicyControl[] = Object.freeze([
2539
+ {
2540
+ label: 'rejects an unexported value binding in a skill fence',
2541
+ membership: 'named value imports in canonical skill fences',
2542
+ rule: 'skill',
2543
+ files: [
2544
+ {
2545
+ path: '.agents/skills/sample/SKILL.md',
2546
+ content:
2547
+ SKILL_POLICY_TEXT +
2548
+ '\n```ts\nimport { s2MissingValue } from "@orkestrel/test/browser"\n```\n',
2549
+ },
2550
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2551
+ ],
2552
+ violations: [
2553
+ {
2554
+ rule: 'skill',
2555
+ path: '.agents/skills/sample/SKILL.md',
2556
+ message: 'skill fence import @orkestrel/test/browser does not export s2MissingValue',
2557
+ },
2558
+ ],
2559
+ },
2560
+ {
2561
+ label: 'rejects an unexported type binding in a skill fence',
2562
+ membership: 'named type imports in canonical skill fences',
2563
+ rule: 'skill',
2564
+ files: [
2565
+ {
2566
+ path: '.agents/skills/sample/SKILL.md',
2567
+ content:
2568
+ SKILL_POLICY_TEXT +
2569
+ '\n```ts\nimport type { S2MissingType } from "@orkestrel/test/browser"\n```\n',
2570
+ },
2571
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2572
+ ],
2573
+ violations: [
2574
+ {
2575
+ rule: 'skill',
2576
+ path: '.agents/skills/sample/SKILL.md',
2577
+ message: 'skill fence import @orkestrel/test/browser does not export S2MissingType',
2578
+ },
2579
+ ],
2580
+ },
2581
+ {
2582
+ label: 'accepts exported value and type bindings from root and browser entries',
2583
+ membership: 'named value and type imports in canonical skill fences',
2584
+ rule: 'skill',
2585
+ files: [
2586
+ {
2587
+ path: '.agents/skills/sample/SKILL.md',
2588
+ content:
2589
+ SKILL_POLICY_TEXT +
2590
+ '\n```ts\nimport { waitForCondition, type WaitOptions } from "@orkestrel/test"\nimport { clickAccessible } from "@orkestrel/test/browser"\nimport type { CaptureVariant } from "@orkestrel/test/browser"\n```\n',
2591
+ },
2592
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2593
+ ],
2594
+ violations: [],
2595
+ },
2596
+ {
2597
+ label: 'reports one absent binding beside exported bindings in the same fence',
2598
+ membership: 'named value and type imports in canonical skill fences',
2599
+ rule: 'skill',
2600
+ files: [
2601
+ {
2602
+ path: '.agents/skills/sample/SKILL.md',
2603
+ content:
2604
+ SKILL_POLICY_TEXT +
2605
+ '\n```ts\nimport { s2MissingValue, type WaitOptions } from "@orkestrel/test"\nimport { clickAccessible } from "@orkestrel/test/browser"\nimport type { CaptureVariant } from "@orkestrel/test/browser"\n```\n',
2606
+ },
2607
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2608
+ ],
2609
+ violations: [
2610
+ {
2611
+ rule: 'skill',
2612
+ path: '.agents/skills/sample/SKILL.md',
2613
+ message: 'skill fence import @orkestrel/test does not export s2MissingValue',
2614
+ },
2615
+ ],
2616
+ },
2617
+ {
2618
+ label: 'accepts a fenced import of the package the inspected workspace itself publishes',
2619
+ membership: 'named imports of the workspace package in canonical skill fences',
2620
+ rule: 'skill',
2621
+ files: [
2622
+ {
2623
+ path: '.agents/skills/sample/SKILL.md',
2624
+ content:
2625
+ SKILL_POLICY_TEXT +
2626
+ '\n```ts\nimport { BASE_DEV_DEPENDENCIES } from "@orkestrel/scaffold"\n```\n',
2627
+ },
2628
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2629
+ ],
2630
+ violations: [],
2631
+ },
2632
+ {
2633
+ label: 'rejects a skill fence the parser cannot read beside an Orkestrel import',
2634
+ membership: 'canonical skill fences whose text names an Orkestrel specifier',
2635
+ rule: 'skill',
2636
+ files: [
2637
+ {
2638
+ path: '.agents/skills/sample/SKILL.md',
2639
+ content:
2640
+ SKILL_POLICY_TEXT +
2641
+ '\n```ts\nimport { waitForCondition } from "@orkestrel/test"\nconst value =\n```\n',
2642
+ },
2643
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2644
+ ],
2645
+ },
2646
+ {
2647
+ label: 'rejects a fenced package outside the base dependency set',
2648
+ membership: 'Orkestrel package imports in canonical skill fences',
2649
+ rule: 'skill',
2650
+ files: [
2651
+ {
2652
+ path: '.agents/skills/sample/SKILL.md',
2653
+ content:
2654
+ SKILL_POLICY_TEXT + '\n```ts\nimport { isString } from "@orkestrel/contract"\n```\n',
2655
+ },
2656
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2657
+ ],
2658
+ violations: [
2659
+ {
2660
+ rule: 'skill',
2661
+ path: '.agents/skills/sample/SKILL.md',
2662
+ message:
2663
+ 'skill fence import @orkestrel/contract is outside BASE_DEV_DEPENDENCIES: @orkestrel/contract',
2664
+ },
2665
+ ],
2666
+ },
2667
+ {
2668
+ label: 'reports an unexported binding against its named reference file',
2669
+ membership: 'named imports in fences in referenced skill documents',
2670
+ rule: 'skill',
2671
+ files: [
2672
+ { path: '.agents/skills/sample/SKILL.md', content: SKILL_REFERENCE_TEXT },
2673
+ { path: '.agents/skills/sample/agents/openai.yaml', content: createSkillMetadata('sample') },
2674
+ {
2675
+ path: '.agents/skills/sample/references/example.md',
2676
+ content:
2677
+ '# Example\n\n~~~ts\nimport { s2MissingReference } from "@orkestrel/test/browser"\n~~~\n',
2678
+ },
2679
+ ],
2680
+ violations: [
2681
+ {
2682
+ rule: 'skill',
2683
+ path: '.agents/skills/sample/references/example.md',
2684
+ message: 'skill fence import @orkestrel/test/browser does not export s2MissingReference',
2685
+ },
2686
+ ],
2687
+ },
2124
2688
  {
2125
2689
  label: 'rejects a SKILL.md without frontmatter',
2126
2690
  membership: 'exact-case regular SKILL.md files in discovered skill directories',