@ontrails/warden 0.2.0

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 (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Factory that wraps a WardenRule as a trail.
3
+ *
4
+ * Keeps each rule trail file minimal — just the import + examples.
5
+ */
6
+
7
+ import { InternalError, trail, Result } from '@ontrails/core';
8
+ import type { Trail } from '@ontrails/core';
9
+
10
+ import type {
11
+ ProjectAwareWardenRule,
12
+ ProjectContext,
13
+ TopoAwareWardenRule,
14
+ WardenRule,
15
+ } from '../rules/types.js';
16
+ import { getWardenRuleMetadata } from '../rules/metadata.js';
17
+ import {
18
+ projectAwareRuleInput,
19
+ ruleInput,
20
+ ruleOutput,
21
+ topoAwareRuleInput,
22
+ } from './schema.js';
23
+ import type {
24
+ ProjectAwareRuleInput,
25
+ RuleInput,
26
+ RuleOutput,
27
+ TopoAwareRuleInput,
28
+ } from './schema.js';
29
+
30
+ interface WrapRuleOptions {
31
+ /** The existing warden rule to wrap. */
32
+ readonly rule: WardenRule;
33
+ /** Trail examples for testing and documentation. */
34
+ readonly examples: Trail<RuleInput, RuleOutput>['examples'];
35
+ }
36
+
37
+ interface WrapProjectAwareRuleOptions {
38
+ /** The existing project-aware warden rule to wrap. */
39
+ readonly rule: ProjectAwareWardenRule;
40
+ /** Trail examples for testing and documentation. */
41
+ readonly examples: Trail<ProjectAwareRuleInput, RuleOutput>['examples'];
42
+ }
43
+
44
+ const buildRuleMeta = (rule: WardenRule | TopoAwareWardenRule) => {
45
+ const metadata = getWardenRuleMetadata(rule);
46
+ return {
47
+ category: 'governance',
48
+ ...(metadata ? { warden: metadata } : {}),
49
+ severity: rule.severity,
50
+ };
51
+ };
52
+
53
+ const buildGovernedHistoryContext = (
54
+ input: ProjectAwareRuleInput
55
+ ): Pick<
56
+ ProjectContext,
57
+ | 'governedVocabularyHistoryByTransitionId'
58
+ | 'governedVocabularyHistoryIssues'
59
+ | 'governedVocabularyHistoryRequired'
60
+ > => ({
61
+ ...(input.governedVocabularyHistories
62
+ ? {
63
+ governedVocabularyHistoryByTransitionId: new Map(
64
+ input.governedVocabularyHistories.map((history) => [
65
+ history.transitionId,
66
+ history,
67
+ ])
68
+ ),
69
+ }
70
+ : {}),
71
+ ...(input.governedVocabularyHistoryIssues
72
+ ? {
73
+ governedVocabularyHistoryIssues:
74
+ input.governedVocabularyHistoryIssues.map((issue) => ({
75
+ message: issue.message,
76
+ path: issue.path,
77
+ ...(issue.transitionId === undefined
78
+ ? {}
79
+ : { transitionId: issue.transitionId }),
80
+ })),
81
+ }
82
+ : {}),
83
+ ...(input.governedVocabularyHistoryRequired === undefined
84
+ ? {}
85
+ : {
86
+ governedVocabularyHistoryRequired:
87
+ input.governedVocabularyHistoryRequired,
88
+ }),
89
+ });
90
+
91
+ export const buildProjectContext = (
92
+ input: ProjectAwareRuleInput
93
+ ): ProjectContext => ({
94
+ ...(input.unownedWorkspaceLocks
95
+ ? { unownedWorkspaceLocks: input.unownedWorkspaceLocks }
96
+ : {}),
97
+ ...(input.authoredMcpSurfaceBindingSets
98
+ ? { authoredMcpSurfaceBindingSets: input.authoredMcpSurfaceBindingSets }
99
+ : {}),
100
+ ...(input.entityReferencesByName
101
+ ? {
102
+ entityReferencesByName: new Map(
103
+ Object.entries(input.entityReferencesByName)
104
+ ),
105
+ }
106
+ : {}),
107
+ ...(input.crudTableIds ? { crudTableIds: new Set(input.crudTableIds) } : {}),
108
+ ...(input.crudCoverageByEntity
109
+ ? {
110
+ crudCoverageByEntity: new Map(
111
+ Object.entries(input.crudCoverageByEntity).map(
112
+ ([entityId, operations]) => [
113
+ entityId,
114
+ new Set(operations) as ReadonlySet<string>,
115
+ ]
116
+ )
117
+ ),
118
+ }
119
+ : {}),
120
+ ...(input.knownEntityIds
121
+ ? { knownEntityIds: new Set(input.knownEntityIds) }
122
+ : {}),
123
+ ...(input.importResolutionsByFile
124
+ ? {
125
+ importResolutionsByFile: new Map(
126
+ Object.entries(input.importResolutionsByFile)
127
+ ),
128
+ }
129
+ : {}),
130
+ ...(input.documentedImportResolutionsByFile
131
+ ? {
132
+ documentedImportResolutionsByFile: new Map(
133
+ Object.entries(input.documentedImportResolutionsByFile)
134
+ ),
135
+ }
136
+ : {}),
137
+ ...(input.exportedSymbolDefinitionsByName
138
+ ? {
139
+ exportedSymbolDefinitionsByName: new Map(
140
+ Object.entries(input.exportedSymbolDefinitionsByName)
141
+ ),
142
+ }
143
+ : {}),
144
+ ...buildGovernedHistoryContext(input),
145
+ ...(input.publicWorkspaces
146
+ ? { publicWorkspaces: new Map(Object.entries(input.publicWorkspaces)) }
147
+ : {}),
148
+ knownTrailIds: input.knownTrailIds
149
+ ? new Set(input.knownTrailIds)
150
+ : new Set<string>(),
151
+ ...(input.composeTargetTrailIds
152
+ ? { composeTargetTrailIds: new Set(input.composeTargetTrailIds) }
153
+ : {}),
154
+ ...(input.knownResourceIds
155
+ ? { knownResourceIds: new Set(input.knownResourceIds) }
156
+ : {}),
157
+ ...(input.knownSignalIds
158
+ ? { knownSignalIds: new Set(input.knownSignalIds) }
159
+ : {}),
160
+ ...(input.onTargetSignalIds
161
+ ? { onTargetSignalIds: new Set(input.onTargetSignalIds) }
162
+ : {}),
163
+ ...(input.reconcileTableIds
164
+ ? { reconcileTableIds: new Set(input.reconcileTableIds) }
165
+ : {}),
166
+ ...(input.trailIntentsById
167
+ ? { trailIntentsById: new Map(Object.entries(input.trailIntentsById)) }
168
+ : {}),
169
+ ...(input.topoTrailIds ? { topoTrailIds: new Set(input.topoTrailIds) } : {}),
170
+ });
171
+
172
+ /**
173
+ * Wrap an existing `WardenRule` as a trail with typed input/output.
174
+ *
175
+ * The trail ID follows the pattern `warden.rule.<rule-name>`.
176
+ */
177
+ export function wrapRule(
178
+ options: WrapProjectAwareRuleOptions
179
+ ): Trail<ProjectAwareRuleInput, RuleOutput>;
180
+ export function wrapRule(
181
+ options: WrapRuleOptions
182
+ ): Trail<RuleInput, RuleOutput>;
183
+ export function wrapRule(
184
+ options: WrapRuleOptions | WrapProjectAwareRuleOptions
185
+ ): Trail<RuleInput, RuleOutput> | Trail<ProjectAwareRuleInput, RuleOutput> {
186
+ const { rule, examples } = options;
187
+ const isProjectAware = 'checkWithContext' in rule;
188
+
189
+ if (isProjectAware) {
190
+ const projectAwareRule = rule as ProjectAwareWardenRule;
191
+ return trail(`warden.rule.${rule.name}`, {
192
+ description: rule.description,
193
+ examples: examples as Trail<
194
+ ProjectAwareRuleInput,
195
+ RuleOutput
196
+ >['examples'],
197
+ implementation: (input: ProjectAwareRuleInput) => {
198
+ const context = buildProjectContext(input);
199
+ const diagnostics = projectAwareRule.checkWithContext(
200
+ input.sourceCode,
201
+ input.filePath,
202
+ context
203
+ );
204
+ return Result.ok({ diagnostics: [...diagnostics] });
205
+ },
206
+ input: projectAwareRuleInput,
207
+ intent: 'read',
208
+ meta: buildRuleMeta(rule),
209
+ output: ruleOutput,
210
+ });
211
+ }
212
+
213
+ return trail(`warden.rule.${rule.name}`, {
214
+ description: rule.description,
215
+ examples: examples as Trail<RuleInput, RuleOutput>['examples'],
216
+ implementation: (input: RuleInput) => {
217
+ const diagnostics = rule.check(input.sourceCode, input.filePath);
218
+ return Result.ok({ diagnostics: [...diagnostics] });
219
+ },
220
+ input: ruleInput,
221
+ intent: 'read',
222
+ meta: buildRuleMeta(rule),
223
+ output: ruleOutput,
224
+ });
225
+ }
226
+
227
+ interface WrapTopoRuleOptions {
228
+ /** The existing topo-aware warden rule to wrap. */
229
+ readonly rule: TopoAwareWardenRule;
230
+ /** Trail examples for testing and documentation. */
231
+ readonly examples: Trail<TopoAwareRuleInput, RuleOutput>['examples'];
232
+ }
233
+
234
+ /**
235
+ * Wrap an existing `TopoAwareWardenRule` as a trail.
236
+ *
237
+ * Mirrors `wrapRule` for the per-topo dispatch path. Topo-aware rules run
238
+ * once per topo against the compiled runtime graph rather than per file,
239
+ * so the trail accepts the live `Topo` as input.
240
+ */
241
+ export const wrapTopoRule = (
242
+ options: WrapTopoRuleOptions
243
+ ): Trail<TopoAwareRuleInput, RuleOutput> => {
244
+ const { rule, examples } = options;
245
+ return trail(`warden.rule.${rule.name}`, {
246
+ description: rule.description,
247
+ examples,
248
+ implementation: async (input: TopoAwareRuleInput) => {
249
+ try {
250
+ const diagnostics = await rule.checkTopo(input.topo, {
251
+ graph: input.graph,
252
+ });
253
+ return Result.ok({ diagnostics: [...diagnostics] });
254
+ } catch (error) {
255
+ const cause = error instanceof Error ? error : new Error(String(error));
256
+ return Result.err(
257
+ new InternalError(
258
+ `Topo-aware rule "${rule.name}" threw while inspecting topo: ${cause.message}`,
259
+ { cause }
260
+ )
261
+ );
262
+ }
263
+ },
264
+ input: topoAwareRuleInput,
265
+ intent: 'read',
266
+ meta: buildRuleMeta(rule),
267
+ output: ruleOutput,
268
+ });
269
+ };
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Workspace-manifest discovery for Warden project-aware rules.
3
+ */
4
+
5
+ import { readFileSync, realpathSync } from 'node:fs';
6
+ import { dirname, join, posix, relative, resolve } from 'node:path';
7
+
8
+ import {
9
+ listWorkspacePackages,
10
+ listWorkspacePatterns,
11
+ matchesAnyPathGlob,
12
+ } from '@ontrails/core';
13
+
14
+ export interface WardenPublicWorkspace {
15
+ readonly name: string;
16
+ readonly rootDir: string;
17
+ readonly packageJsonPath: string;
18
+ readonly hasExports: boolean;
19
+ readonly bin?: Readonly<Record<string, string>> | undefined;
20
+ readonly exportTargets?: Readonly<Record<string, string>> | undefined;
21
+ readonly files?: readonly string[] | undefined;
22
+ }
23
+
24
+ export interface WardenWorkspaceCollectionOptions {
25
+ /** Root-relative package manifests observed by the owning source collection. */
26
+ readonly collectedPackageJsonPaths?: ReadonlySet<string> | undefined;
27
+ readonly exclude?: readonly string[];
28
+ }
29
+
30
+ interface PackageManifest {
31
+ readonly bin?: unknown;
32
+ readonly exports?: unknown;
33
+ readonly files?: unknown;
34
+ readonly name?: unknown;
35
+ readonly private?: unknown;
36
+ readonly workspaces?: unknown;
37
+ }
38
+
39
+ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
40
+
41
+ const normalizeRealPath = (path: string): string => {
42
+ try {
43
+ return normalizePath(realpathSync(path));
44
+ } catch {
45
+ return normalizePath(resolve(path));
46
+ }
47
+ };
48
+
49
+ const rootRelativePath = (rootDir: string, path: string): string =>
50
+ normalizePath(relative(rootDir, path));
51
+
52
+ const readPackageManifest = (path: string): PackageManifest | undefined => {
53
+ try {
54
+ return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ };
59
+
60
+ const matchesWorkspacePattern = (
61
+ workspacePath: string,
62
+ pattern: string
63
+ ): boolean => {
64
+ const normalizedPattern = normalizePath(pattern).replace(/^\.\//, '');
65
+ return normalizedPattern.endsWith('/*')
66
+ ? posix.dirname(workspacePath) === normalizedPattern.slice(0, -2)
67
+ : workspacePath === normalizedPattern;
68
+ };
69
+
70
+ interface WorkspaceManifestCandidate {
71
+ readonly manifest: PackageManifest;
72
+ readonly packageJsonPath: string;
73
+ }
74
+
75
+ const collectedWorkspaceManifests = (
76
+ rootDir: string,
77
+ packageJsonPaths: ReadonlySet<string>
78
+ ): readonly WorkspaceManifestCandidate[] => {
79
+ if (!packageJsonPaths.has('package.json')) {
80
+ return [];
81
+ }
82
+ const rootManifest = readPackageManifest(join(rootDir, 'package.json'));
83
+ const patterns = listWorkspacePatterns(rootManifest);
84
+
85
+ return [...packageJsonPaths]
86
+ .filter((path) => path !== 'package.json' && path.endsWith('/package.json'))
87
+ .toSorted()
88
+ .flatMap((path): readonly WorkspaceManifestCandidate[] => {
89
+ const normalizedPath = normalizePath(path).replace(/^\.\//, '');
90
+ const workspacePath = posix.dirname(normalizedPath);
91
+ if (
92
+ posix.isAbsolute(normalizedPath) ||
93
+ normalizedPath.startsWith('../') ||
94
+ !patterns.some((pattern) =>
95
+ matchesWorkspacePattern(workspacePath, pattern)
96
+ )
97
+ ) {
98
+ return [];
99
+ }
100
+ const packageJsonPath = resolve(rootDir, normalizedPath);
101
+ const manifest = readPackageManifest(packageJsonPath);
102
+ return manifest ? [{ manifest, packageJsonPath }] : [];
103
+ });
104
+ };
105
+
106
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
107
+ Boolean(value && typeof value === 'object' && !Array.isArray(value));
108
+
109
+ const packageLocalName = (name: string): string =>
110
+ name.split('/').at(-1) ?? name;
111
+
112
+ const normalizeBin = (
113
+ name: string,
114
+ value: unknown
115
+ ): Readonly<Record<string, string>> | undefined => {
116
+ if (typeof value === 'string') {
117
+ return { [packageLocalName(name)]: value };
118
+ }
119
+ if (!isRecord(value)) {
120
+ return undefined;
121
+ }
122
+
123
+ const entries = Object.entries(value).filter(
124
+ (entry): entry is [string, string] => typeof entry[1] === 'string'
125
+ );
126
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
127
+ };
128
+
129
+ const normalizeFiles = (value: unknown): readonly string[] | undefined => {
130
+ if (!Array.isArray(value)) {
131
+ return undefined;
132
+ }
133
+
134
+ const files = value.filter(
135
+ (entry): entry is string => typeof entry === 'string'
136
+ );
137
+ return files.length > 0 ? files : undefined;
138
+ };
139
+
140
+ const resolveExportTarget = (
141
+ target: unknown,
142
+ depth = 0
143
+ ): string | undefined => {
144
+ if (typeof target === 'string') {
145
+ return target;
146
+ }
147
+ if (!isRecord(target) || depth > 8) {
148
+ return undefined;
149
+ }
150
+
151
+ for (const condition of ['bun', 'import', 'default', 'require'] as const) {
152
+ const conditionalTarget = target[condition];
153
+ const resolvedTarget = resolveExportTarget(conditionalTarget, depth + 1);
154
+ if (resolvedTarget) {
155
+ return resolvedTarget;
156
+ }
157
+ }
158
+ return undefined;
159
+ };
160
+
161
+ const exportSpecifierFromKey = (
162
+ packageName: string,
163
+ key: string
164
+ ): string | undefined => {
165
+ if (key === '.') {
166
+ return packageName;
167
+ }
168
+ if (!key.startsWith('./') || key.includes('*')) {
169
+ return undefined;
170
+ }
171
+ return `${packageName}/${key.slice(2)}`;
172
+ };
173
+
174
+ const normalizeExportTargets = (
175
+ packageDir: string,
176
+ packageName: string,
177
+ exportsValue: unknown
178
+ ): Readonly<Record<string, string>> | undefined => {
179
+ if (!isRecord(exportsValue)) {
180
+ return undefined;
181
+ }
182
+
183
+ const targets: Record<string, string> = {};
184
+ for (const [key, value] of Object.entries(exportsValue)) {
185
+ const specifier = exportSpecifierFromKey(packageName, key);
186
+ const target = resolveExportTarget(value);
187
+ if (!specifier || !target) {
188
+ continue;
189
+ }
190
+ targets[specifier] = normalizeRealPath(join(packageDir, target));
191
+ }
192
+
193
+ return Object.keys(targets).length > 0 ? targets : undefined;
194
+ };
195
+
196
+ const publicWorkspaceFromManifest = (
197
+ packageJsonPath: string,
198
+ manifest: PackageManifest
199
+ ): WardenPublicWorkspace | undefined => {
200
+ if (typeof manifest.name !== 'string') {
201
+ return undefined;
202
+ }
203
+ if (!manifest.name.startsWith('@ontrails/')) {
204
+ return undefined;
205
+ }
206
+ if (manifest.private === true) {
207
+ return undefined;
208
+ }
209
+
210
+ const packageDir = dirname(packageJsonPath);
211
+ const bin = normalizeBin(manifest.name, manifest.bin);
212
+ const exportTargets = normalizeExportTargets(
213
+ packageDir,
214
+ manifest.name,
215
+ manifest.exports
216
+ );
217
+ const files = normalizeFiles(manifest.files);
218
+
219
+ return {
220
+ ...(bin ? { bin } : {}),
221
+ ...(exportTargets ? { exportTargets } : {}),
222
+ ...(files ? { files } : {}),
223
+ hasExports: manifest.exports !== undefined,
224
+ name: manifest.name,
225
+ packageJsonPath: normalizeRealPath(packageJsonPath),
226
+ rootDir: normalizeRealPath(dirname(packageJsonPath)),
227
+ };
228
+ };
229
+
230
+ export const collectPublicWorkspaces = (
231
+ rootDir: string,
232
+ options: WardenWorkspaceCollectionOptions = {}
233
+ ): ReadonlyMap<string, WardenPublicWorkspace> => {
234
+ const normalizedRoot = normalizeRealPath(rootDir);
235
+ const workspaces = new Map<string, WardenPublicWorkspace>();
236
+ const candidates = options.collectedPackageJsonPaths
237
+ ? collectedWorkspaceManifests(
238
+ normalizedRoot,
239
+ options.collectedPackageJsonPaths
240
+ )
241
+ : listWorkspacePackages<PackageManifest>(normalizedRoot);
242
+
243
+ for (const workspacePackage of candidates) {
244
+ const workspace = publicWorkspaceFromManifest(
245
+ workspacePackage.packageJsonPath,
246
+ workspacePackage.manifest
247
+ );
248
+ if (
249
+ workspace &&
250
+ !matchesAnyPathGlob(
251
+ rootRelativePath(normalizedRoot, workspace.rootDir),
252
+ options.exclude ?? []
253
+ ) &&
254
+ !matchesAnyPathGlob(
255
+ rootRelativePath(normalizedRoot, workspace.packageJsonPath),
256
+ options.exclude ?? []
257
+ )
258
+ ) {
259
+ workspaces.set(workspace.name, workspace);
260
+ }
261
+ }
262
+
263
+ return workspaces;
264
+ };