@ontrails/core 1.0.0-beta.30 → 1.0.0-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @ontrails/core
2
2
 
3
+ ## 1.0.0-beta.32
4
+
5
+ ### Patch Changes
6
+
7
+ - 3e5c0fc: Export shared diagnostic base types from core and align governance diagnostic
8
+ severity vocabulary across adapter checks, permits, and Warden.
9
+ - f3c4fef: Export a shared `escapeRegExp` helper from core and migrate first-party callers off local copies.
10
+ - cb0a9d8: Export shared workspace package discovery helpers from core and migrate first-party discovery callers.
11
+ - 21c6dda: Rename topo and draft report types to `TopoDiagnostic` and `DraftDiagnostic`, with deprecated `TopoIssue` and `DraftFinding` aliases preserved for source compatibility.
12
+ - fe72b84: Fold remaining Regrade and Warden scan-target surfaces onto the shared path-scope vocabulary.
13
+
14
+ ## 1.0.0-beta.31
15
+
16
+ ### Patch Changes
17
+
18
+ - 4cd5d4e: Add shared glob, path-scope, and trail-id glob contracts for downstream Trails tooling.
19
+ - 38907cc: Adopt the shared trail-id glob engine for surface filtering and Wayfinder entity filters so dotted `*`, `**`, and `?` patterns behave consistently across graph inspection and surface selection.
20
+
3
21
  ## 1.0.0-beta.30
4
22
 
5
23
  ## 1.0.0-beta.29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/core",
3
- "version": "1.0.0-beta.30",
3
+ "version": "1.0.0-beta.32",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
package/src/derive.ts CHANGED
@@ -145,7 +145,7 @@ const propagateDescription = (
145
145
  }
146
146
  };
147
147
 
148
- /** Step one level of optional/default unwrapping. Returns null if not a wrapper type. */
148
+ /** Step one level of transparent wrapper unwrapping. Returns null if not a wrapper type. */
149
149
  const unwrapStep = (
150
150
  current: ZodInternals,
151
151
  state: {
@@ -155,10 +155,16 @@ const unwrapStep = (
155
155
  }
156
156
  ): ZodInternals | null => {
157
157
  const defType = current._zod.def['type'] as string;
158
- if (defType !== 'optional' && defType !== 'default') {
158
+ if (
159
+ defType !== 'optional' &&
160
+ defType !== 'default' &&
161
+ defType !== 'readonly'
162
+ ) {
159
163
  return null;
160
164
  }
161
- state.required = false;
165
+ if (defType !== 'readonly') {
166
+ state.required = false;
167
+ }
162
168
  if (defType === 'default') {
163
169
  state.defaultValue = current._zod.def['defaultValue'];
164
170
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared diagnostic vocabulary for governance-style findings.
3
+ *
4
+ * Runtime side-channel records and field-state reports can still define their
5
+ * own shapes. This base exists for tools that report rule or check failures.
6
+ */
7
+
8
+ export type DiagnosticSeverity = 'error' | 'warn';
9
+
10
+ export interface DiagnosticBase<TCode extends string = string> {
11
+ readonly code?: TCode | undefined;
12
+ readonly message: string;
13
+ readonly severity: DiagnosticSeverity;
14
+ }
15
+
16
+ export interface RuleDiagnosticBase<
17
+ TCode extends string = string,
18
+ TRule extends string = string,
19
+ > extends DiagnosticBase<TCode> {
20
+ readonly rule: TRule;
21
+ }
package/src/draft.ts CHANGED
@@ -25,7 +25,7 @@ export interface DraftDependency {
25
25
  readonly toId: string;
26
26
  }
27
27
 
28
- export interface DraftFinding {
28
+ export interface DraftDiagnostic {
29
29
  readonly id: string;
30
30
  readonly kind: 'contour' | 'resource' | 'signal' | 'trail' | 'unknown';
31
31
  readonly message: string;
@@ -34,11 +34,19 @@ export interface DraftFinding {
34
34
  readonly dependsOn?: string | undefined;
35
35
  }
36
36
 
37
+ /**
38
+ * @deprecated Use {@link DraftDiagnostic}. Kept as a source-compatible alias
39
+ * during the v1 vocabulary cutover.
40
+ */
41
+ export interface DraftFinding extends DraftDiagnostic {
42
+ readonly id: DraftDiagnostic['id'];
43
+ }
44
+
37
45
  export interface DraftReport {
38
46
  readonly contaminatedIds: ReadonlySet<string>;
39
47
  readonly declaredDraftIds: ReadonlySet<string>;
40
48
  readonly dependencies: readonly DraftDependency[];
41
- readonly findings: readonly DraftFinding[];
49
+ readonly findings: readonly DraftDiagnostic[];
42
50
  }
43
51
 
44
52
  interface DraftReason {
@@ -120,7 +128,7 @@ const nodeKind = (
120
128
  trails: ReadonlyMap<string, AnyTrail>,
121
129
  signals: ReadonlyMap<string, AnySignal>,
122
130
  resources: ReadonlyMap<string, AnyResource>
123
- ): DraftFinding['kind'] => {
131
+ ): DraftDiagnostic['kind'] => {
124
132
  if (contours.has(id)) {
125
133
  return 'contour';
126
134
  }
@@ -136,7 +144,7 @@ const nodeKind = (
136
144
  return 'unknown';
137
145
  };
138
146
 
139
- const displayKind = (kind: DraftFinding['kind']): string =>
147
+ const displayKind = (kind: DraftDiagnostic['kind']): string =>
140
148
  kind === 'unknown' ? 'Node' : kind[0]?.toUpperCase() + kind.slice(1);
141
149
 
142
150
  const draftIdsFromKeys = (keys: Iterable<string>): string[] =>
@@ -152,8 +160,8 @@ const collectDeclaredDraftIds = (topo: Topo): ReadonlySet<string> =>
152
160
 
153
161
  const findingForDraftId = (
154
162
  id: string,
155
- kind: DraftFinding['kind']
156
- ): DraftFinding => ({
163
+ kind: DraftDiagnostic['kind']
164
+ ): DraftDiagnostic => ({
157
165
  id,
158
166
  kind,
159
167
  message: `${displayKind(id ? kind : 'unknown')} "${id}" is draft and cannot appear in the established graph.`,
@@ -162,9 +170,9 @@ const findingForDraftId = (
162
170
 
163
171
  const findingForContamination = (
164
172
  id: string,
165
- kind: DraftFinding['kind'],
173
+ kind: DraftDiagnostic['kind'],
166
174
  reason: DraftReason
167
- ): DraftFinding => {
175
+ ): DraftDiagnostic => {
168
176
  const dependencyLabel = isDraftId(reason.dependsOn)
169
177
  ? `draft "${reason.dependsOn}"`
170
178
  : `draft-contaminated "${reason.dependsOn}"`;
@@ -244,7 +252,7 @@ const contaminationFindingForId = (
244
252
  trails: ReadonlyMap<string, AnyTrail>,
245
253
  signals: ReadonlyMap<string, AnySignal>,
246
254
  resources: ReadonlyMap<string, AnyResource>
247
- ): DraftFinding | undefined => {
255
+ ): DraftDiagnostic | undefined => {
248
256
  if (declaredDraftIds.has(id)) {
249
257
  return undefined;
250
258
  }
@@ -269,7 +277,7 @@ const collectFindings = (
269
277
  trails: ReadonlyMap<string, AnyTrail>,
270
278
  signals: ReadonlyMap<string, AnySignal>,
271
279
  resources: ReadonlyMap<string, AnyResource>
272
- ): DraftFinding[] => [
280
+ ): DraftDiagnostic[] => [
273
281
  ...[...declaredDraftIds]
274
282
  .toSorted()
275
283
  .map((id) =>
package/src/glob.ts ADDED
@@ -0,0 +1,81 @@
1
+ export interface GlobConfig {
2
+ readonly separator: '/' | '.';
3
+ }
4
+
5
+ /**
6
+ * Escape a literal string so it can be embedded safely in a RegExp source.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const pattern = new RegExp(`^${escapeRegExp('@ontrails/core')}$`);
11
+ * ```
12
+ */
13
+ export const escapeRegExp = (value: string): string =>
14
+ value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+
16
+ const separatorRegExp = (separator: GlobConfig['separator']): string =>
17
+ escapeRegExp(separator);
18
+
19
+ export const globToRegExp = (pattern: string, config: GlobConfig): RegExp => {
20
+ const separator = separatorRegExp(config.separator);
21
+ const parts: string[] = ['^'];
22
+
23
+ for (let index = 0; index < pattern.length; index += 1) {
24
+ const char = pattern[index];
25
+ const next = pattern[index + 1];
26
+
27
+ if (char === '*' && next === '*') {
28
+ if (pattern[index + 2] === config.separator) {
29
+ parts.push(`(?:.*${separator})?`);
30
+ index += 2;
31
+ } else {
32
+ parts.push('.*');
33
+ index += 1;
34
+ }
35
+ continue;
36
+ }
37
+
38
+ if (char === '*') {
39
+ parts.push(`[^${separator}]*`);
40
+ continue;
41
+ }
42
+
43
+ if (char === '?') {
44
+ parts.push(`[^${separator}]`);
45
+ continue;
46
+ }
47
+
48
+ parts.push(escapeRegExp(char ?? ''));
49
+ }
50
+
51
+ parts.push('$');
52
+ return new RegExp(parts.join(''));
53
+ };
54
+
55
+ export const matchesGlob = (
56
+ value: string,
57
+ pattern: string,
58
+ config: GlobConfig
59
+ ): boolean => {
60
+ if (value === pattern) {
61
+ return true;
62
+ }
63
+
64
+ const terminalDoubleStar = `${config.separator}**`;
65
+ if (pattern.endsWith(terminalDoubleStar)) {
66
+ const parent = pattern.slice(0, -terminalDoubleStar.length);
67
+ if (value === parent) {
68
+ return true;
69
+ }
70
+ }
71
+
72
+ return globToRegExp(pattern, config).test(value);
73
+ };
74
+
75
+ export const matchesAnyGlob = (
76
+ value: string,
77
+ patterns: readonly string[] | undefined,
78
+ config: GlobConfig
79
+ ): boolean =>
80
+ patterns !== undefined &&
81
+ patterns.some((pattern) => matchesGlob(value, pattern, config));
package/src/index.ts CHANGED
@@ -68,6 +68,11 @@ export {
68
68
  redactErrorString,
69
69
  } from './error-projection.js';
70
70
  export type { ErrorDiagnosticsProjection } from './error-projection.js';
71
+ export type {
72
+ DiagnosticBase,
73
+ DiagnosticSeverity,
74
+ RuleDiagnosticBase,
75
+ } from './diagnostics.js';
71
76
 
72
77
  // Types
73
78
  export type {
@@ -112,6 +117,25 @@ export type {
112
117
  // Context factory
113
118
  export { createTrailContext, passthroughTrace } from './context.js';
114
119
 
120
+ // Glob and path scope
121
+ export {
122
+ escapeRegExp,
123
+ globToRegExp,
124
+ matchesAnyGlob,
125
+ matchesGlob,
126
+ } from './glob.js';
127
+ export type { GlobConfig } from './glob.js';
128
+ export {
129
+ includedByPathScope,
130
+ matchesAnyPathGlob,
131
+ matchesPathGlob,
132
+ normalizePathScopePath,
133
+ pathScopeSchema,
134
+ } from './path-scope.js';
135
+ export type { PathGlob, PathScope, ScanTargets } from './path-scope.js';
136
+ export { matchesAnyTrailIdGlob, matchesTrailIdGlob } from './trail-id-glob.js';
137
+ export type { TrailIdGlob } from './trail-id-glob.js';
138
+
115
139
  // Resource
116
140
  export {
117
141
  createResourceLookup,
@@ -417,13 +441,14 @@ export {
417
441
  export type {
418
442
  DraftDependency,
419
443
  DraftDependencyKind,
444
+ DraftDiagnostic,
420
445
  DraftFinding,
421
446
  DraftReport,
422
447
  } from './draft.js';
423
448
 
424
449
  // Topo validation
425
450
  export { validateTopo } from './validate-topo.js';
426
- export type { TopoIssue } from './validate-topo.js';
451
+ export type { TopoDiagnostic, TopoIssue } from './validate-topo.js';
427
452
  export { validateEstablishedTopo } from './validate-established-topo.js';
428
453
 
429
454
  // Layer
@@ -581,10 +606,15 @@ export { securePath, isPathSafe, deriveSafePath } from './path-security.js';
581
606
 
582
607
  // Workspace
583
608
  export {
609
+ deriveRelativePath,
610
+ findWorkspacePackage,
584
611
  findWorkspaceRoot,
585
612
  isInsideWorkspace,
586
- deriveRelativePath,
613
+ listWorkspacePackageDirs,
614
+ listWorkspacePackages,
615
+ listWorkspacePatterns,
587
616
  } from './workspace.js';
617
+ export type { WorkspacePackage, WorkspaceRootManifest } from './workspace.js';
588
618
 
589
619
  // Blob
590
620
  export {
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+
3
+ import { matchesAnyGlob, matchesGlob } from './glob.js';
4
+
5
+ declare const pathGlobBrand: unique symbol;
6
+
7
+ export type PathGlob = string & {
8
+ readonly [pathGlobBrand]: 'PathGlob';
9
+ };
10
+
11
+ export const normalizePathScopePath = (value: string): string =>
12
+ value.replaceAll('\\', '/').replace(/^\.\//, '');
13
+
14
+ export const matchesPathGlob = (path: string, pattern: string): boolean =>
15
+ matchesGlob(normalizePathScopePath(path), normalizePathScopePath(pattern), {
16
+ separator: '/',
17
+ });
18
+
19
+ export const matchesAnyPathGlob = (
20
+ path: string,
21
+ patterns: readonly string[] | undefined
22
+ ): boolean =>
23
+ matchesAnyGlob(
24
+ normalizePathScopePath(path),
25
+ patterns?.map(normalizePathScopePath),
26
+ { separator: '/' }
27
+ );
28
+
29
+ const pathGlobArraySchema = z.array(z.string()).readonly();
30
+
31
+ export const pathScopeSchema = z
32
+ .object({
33
+ exclude: pathGlobArraySchema.optional(),
34
+ extensions: z.array(z.string()).readonly().optional(),
35
+ include: pathGlobArraySchema.optional(),
36
+ })
37
+ .strict();
38
+
39
+ export type PathScope = z.output<typeof pathScopeSchema>;
40
+
41
+ export type ScanTargets = Pick<PathScope, 'exclude' | 'extensions'>;
42
+
43
+ const extensionOf = (path: string): string => {
44
+ const normalized = normalizePathScopePath(path);
45
+ const name = normalized.slice(normalized.lastIndexOf('/') + 1);
46
+ const dot = name.lastIndexOf('.');
47
+ return dot <= 0 ? '' : name.slice(dot);
48
+ };
49
+
50
+ const normalizeExtension = (extension: string): string =>
51
+ extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
52
+
53
+ const includedByExtension = (
54
+ path: string,
55
+ extensions: readonly string[] | undefined
56
+ ): boolean =>
57
+ extensions === undefined ||
58
+ extensions.length === 0 ||
59
+ extensions.map(normalizeExtension).includes(extensionOf(path));
60
+
61
+ export const includedByPathScope = (path: string, scope?: PathScope): boolean =>
62
+ (scope?.include === undefined ||
63
+ scope.include.length === 0 ||
64
+ matchesAnyPathGlob(path, scope.include)) &&
65
+ !matchesAnyPathGlob(path, scope?.exclude) &&
66
+ includedByExtension(path, scope?.extensions);
@@ -1,4 +1,5 @@
1
1
  import type { Intent, Trail } from './trail.js';
2
+ import { matchesAnyTrailIdGlob, matchesTrailIdGlob } from './trail-id-glob.js';
2
3
  import type { SurfaceSelectionOptions } from './surface-derivation.js';
3
4
 
4
5
  // ---------------------------------------------------------------------------
@@ -7,84 +8,10 @@ import type { SurfaceSelectionOptions } from './surface-derivation.js';
7
8
 
8
9
  export type SurfaceFilterOptions = SurfaceSelectionOptions;
9
10
 
10
- // ---------------------------------------------------------------------------
11
- // Glob matching
12
- // ---------------------------------------------------------------------------
13
-
14
- const trailIdSegments = (trailId: string): readonly string[] =>
15
- trailId.split('.');
16
-
17
- const patternSegments = (pattern: string): readonly string[] =>
18
- pattern.split('.');
19
-
20
- type SegmentMatcher = (
21
- trail: readonly string[],
22
- pattern: readonly string[],
23
- trailIndex: number,
24
- patternIndex: number
25
- ) => boolean;
26
-
27
- const matchesDoubleStar = (
28
- trail: readonly string[],
29
- pattern: readonly string[],
30
- trailIndex: number,
31
- patternIndex: number,
32
- matchSegments: SegmentMatcher
33
- ): boolean =>
34
- patternIndex === pattern.length - 1 ||
35
- Array.from(
36
- { length: trail.length - trailIndex + 1 },
37
- (_, offset) => trailIndex + offset
38
- ).some((index) => matchSegments(trail, pattern, index, patternIndex + 1));
39
-
40
- const matchesSingleSegment = (
41
- trail: readonly string[],
42
- pattern: readonly string[],
43
- trailIndex: number,
44
- patternIndex: number,
45
- current: string,
46
- matchSegments: SegmentMatcher
47
- ): boolean =>
48
- trailIndex < trail.length &&
49
- (current === '*' || current === trail[trailIndex]) &&
50
- matchSegments(trail, pattern, trailIndex + 1, patternIndex + 1);
51
-
52
- const matchesFrom = function matchesFrom(
53
- trail: readonly string[],
54
- pattern: readonly string[],
55
- trailIndex: number,
56
- patternIndex: number
57
- ): boolean {
58
- if (patternIndex >= pattern.length) {
59
- return trailIndex >= trail.length;
60
- }
61
-
62
- const current = pattern[patternIndex];
63
- if (current === undefined) {
64
- return false;
65
- }
66
-
67
- return current === '**'
68
- ? matchesDoubleStar(trail, pattern, trailIndex, patternIndex, matchesFrom)
69
- : matchesSingleSegment(
70
- trail,
71
- pattern,
72
- trailIndex,
73
- patternIndex,
74
- current,
75
- matchesFrom
76
- );
77
- };
78
-
79
11
  export const matchesTrailPattern = (
80
12
  trailId: string,
81
13
  pattern: string
82
- ): boolean => {
83
- if (pattern === trailId) {
84
- return true;
85
- }
86
- return matchesFrom(trailIdSegments(trailId), patternSegments(pattern), 0, 0);
87
- };
14
+ ): boolean => matchesTrailIdGlob(trailId, pattern);
88
15
 
89
16
  // ---------------------------------------------------------------------------
90
17
  // Surface filtering
@@ -93,9 +20,7 @@ export const matchesTrailPattern = (
93
20
  const matchesAnyPattern = (
94
21
  trailId: string,
95
22
  patterns: readonly string[] | undefined
96
- ): boolean =>
97
- patterns !== undefined &&
98
- patterns.some((pattern) => matchesTrailPattern(trailId, pattern));
23
+ ): boolean => matchesAnyTrailIdGlob(trailId, patterns);
99
24
 
100
25
  const isExplicitInternalInclude = (
101
26
  trailId: string,
@@ -0,0 +1,15 @@
1
+ import { matchesAnyGlob, matchesGlob } from './glob.js';
2
+
3
+ declare const trailIdGlobBrand: unique symbol;
4
+
5
+ export type TrailIdGlob = string & {
6
+ readonly [trailIdGlobBrand]: 'TrailIdGlob';
7
+ };
8
+
9
+ export const matchesTrailIdGlob = (id: string, pattern: string): boolean =>
10
+ matchesGlob(id, pattern, { separator: '.' });
11
+
12
+ export const matchesAnyTrailIdGlob = (
13
+ id: string,
14
+ patterns: readonly string[] | undefined
15
+ ): boolean => matchesAnyGlob(id, patterns, { separator: '.' });
@@ -2,7 +2,7 @@ import { ValidationError } from './errors.js';
2
2
  import { Result } from './result.js';
3
3
  import type { Topo } from './topo.js';
4
4
  import { validateDraftFreeTopo } from './draft.js';
5
- import type { TopoIssue } from './validate-topo.js';
5
+ import type { TopoDiagnostic } from './validate-topo.js';
6
6
  import { validateTopo } from './validate-topo.js';
7
7
 
8
8
  const PROJECTION_BLOCKING_RULES = new Set([
@@ -27,7 +27,7 @@ const keepProjectionBlockingIssues = (
27
27
  }
28
28
 
29
29
  const issues = (
30
- result.error.context as { issues?: readonly TopoIssue[] } | undefined
30
+ result.error.context as { issues?: readonly TopoDiagnostic[] } | undefined
31
31
  )?.issues;
32
32
  const remainingIssues = issues?.filter((issue) =>
33
33
  PROJECTION_BLOCKING_RULES.has(issue.rule)
@@ -34,7 +34,7 @@ import { validateWebhookSource } from './webhook.js';
34
34
  // Issue shape
35
35
  // ---------------------------------------------------------------------------
36
36
 
37
- export interface TopoIssue {
37
+ export interface TopoDiagnostic {
38
38
  readonly trailId: string;
39
39
  readonly rule: string;
40
40
  readonly message: string;
@@ -44,6 +44,14 @@ export interface TopoIssue {
44
44
  readonly sourceKind?: string;
45
45
  }
46
46
 
47
+ /**
48
+ * @deprecated Use {@link TopoDiagnostic}. Kept as a source-compatible alias
49
+ * during the v1 vocabulary cutover.
50
+ */
51
+ export interface TopoIssue extends TopoDiagnostic {
52
+ readonly trailId: TopoDiagnostic['trailId'];
53
+ }
54
+
47
55
  export type TopoSchemaIssue = ActivationSchemaIssue;
48
56
 
49
57
  // ---------------------------------------------------------------------------
@@ -92,8 +100,8 @@ const buildComposeGraph = (
92
100
  /** Detect multi-node cycles in the trail composing graph via DFS. */
93
101
  const detectComposeCycles = (
94
102
  trails: ReadonlyMap<string, AnyTrail>
95
- ): TopoIssue[] => {
96
- const issues: TopoIssue[] = [];
103
+ ): TopoDiagnostic[] => {
104
+ const issues: TopoDiagnostic[] = [];
97
105
  const { color, graph } = buildComposeGraph(trails);
98
106
 
99
107
  const dfs = (node: string, path: string[]): void => {
@@ -128,8 +136,8 @@ const detectComposeCycles = (
128
136
  const checkComposes = (
129
137
  trails: ReadonlyMap<string, AnyTrail>,
130
138
  topo: Topo
131
- ): TopoIssue[] => {
132
- const issues: TopoIssue[] = [];
139
+ ): TopoDiagnostic[] => {
140
+ const issues: TopoDiagnostic[] = [];
133
141
  for (const [id, trail] of trails) {
134
142
  for (const composedId of trail.composes) {
135
143
  if (composedId === id) {
@@ -182,8 +190,8 @@ const checkComposes = (
182
190
  const checkResources = (
183
191
  trails: ReadonlyMap<string, AnyTrail>,
184
192
  topo: Topo
185
- ): TopoIssue[] => {
186
- const issues: TopoIssue[] = [];
193
+ ): TopoDiagnostic[] => {
194
+ const issues: TopoDiagnostic[] = [];
187
195
 
188
196
  for (const [id, trail] of trails) {
189
197
  for (const declaredResource of trail.resources) {
@@ -237,8 +245,8 @@ const checkOneExample = (
237
245
  inputSchema: { safeParse: (data: unknown) => { success: boolean } },
238
246
  hasOutput: boolean,
239
247
  label = `Example "${example.name}"`
240
- ): TopoIssue[] => {
241
- const issues: TopoIssue[] = [];
248
+ ): TopoDiagnostic[] => {
249
+ const issues: TopoDiagnostic[] = [];
242
250
  const result = validateInput(inputSchema as AnyTrail['input'], example.input);
243
251
  if (result.isErr() && example.error !== 'ValidationError') {
244
252
  issues.push({
@@ -257,7 +265,7 @@ const checkOneExample = (
257
265
  return issues;
258
266
  };
259
267
 
260
- const checkVersionExamples = (id: string, trail: AnyTrail): TopoIssue[] =>
268
+ const checkVersionExamples = (id: string, trail: AnyTrail): TopoDiagnostic[] =>
261
269
  Object.entries(trail.versions ?? {}).flatMap(([version, entry]) => {
262
270
  if (isArchivedTrailVersionEntry(entry)) {
263
271
  return [];
@@ -274,8 +282,10 @@ const checkVersionExamples = (id: string, trail: AnyTrail): TopoIssue[] =>
274
282
  );
275
283
  });
276
284
 
277
- const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
278
- const issues: TopoIssue[] = [];
285
+ const checkExamples = (
286
+ trails: ReadonlyMap<string, AnyTrail>
287
+ ): TopoDiagnostic[] => {
288
+ const issues: TopoDiagnostic[] = [];
279
289
  for (const [id, trail] of trails) {
280
290
  if (trail.examples) {
281
291
  for (const example of trail.examples) {
@@ -292,8 +302,8 @@ const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
292
302
  const checkSignalOrigins = (
293
303
  signals: ReadonlyMap<string, AnySignal>,
294
304
  topo: Topo
295
- ): TopoIssue[] => {
296
- const issues: TopoIssue[] = [];
305
+ ): TopoDiagnostic[] => {
306
+ const issues: TopoDiagnostic[] = [];
297
307
  for (const [id, evt] of signals) {
298
308
  if (!evt.from) {
299
309
  continue;
@@ -314,8 +324,8 @@ const checkSignalOrigins = (
314
324
  const checkSignalReferences = (
315
325
  trails: ReadonlyMap<string, AnyTrail>,
316
326
  signals: ReadonlyMap<string, AnySignal>
317
- ): TopoIssue[] => {
318
- const issues: TopoIssue[] = [];
327
+ ): TopoDiagnostic[] => {
328
+ const issues: TopoDiagnostic[] = [];
319
329
 
320
330
  for (const [id, trail] of trails) {
321
331
  for (const signalId of trail.fires ?? []) {
@@ -344,8 +354,8 @@ const checkSignalReferences = (
344
354
 
345
355
  const checkActivationSources = (
346
356
  trails: ReadonlyMap<string, AnyTrail>
347
- ): TopoIssue[] => {
348
- const issues: TopoIssue[] = [];
357
+ ): TopoDiagnostic[] => {
358
+ const issues: TopoDiagnostic[] = [];
349
359
  const sourceDeclarations = new Map<
350
360
  string,
351
361
  {
@@ -440,7 +450,7 @@ const createSourceCompatibilityIssue = (
440
450
  trailId: string,
441
451
  activation: ActivationEntry,
442
452
  schemaIssues: readonly TopoSchemaIssue[]
443
- ): TopoIssue => {
453
+ ): TopoDiagnostic => {
444
454
  const [firstIssue] = schemaIssues;
445
455
  const inputPath = firstIssue?.path ?? Object.freeze([]);
446
456
  return {
@@ -458,7 +468,7 @@ const checkSourcePayloadCompatibility = (
458
468
  trail: AnyTrail,
459
469
  activation: ActivationEntry,
460
470
  signals: ReadonlyMap<string, AnySignal>
461
- ): TopoIssue | undefined => {
471
+ ): TopoDiagnostic | undefined => {
462
472
  if (
463
473
  !isKnownActivationSourceKind(activation.source.kind) ||
464
474
  isDraftId(activation.source.id)
@@ -483,8 +493,8 @@ const checkSourcePayloadCompatibility = (
483
493
  const checkActivationSourceInputCompatibility = (
484
494
  trails: ReadonlyMap<string, AnyTrail>,
485
495
  signals: ReadonlyMap<string, AnySignal>
486
- ): TopoIssue[] => {
487
- const issues: TopoIssue[] = [];
496
+ ): TopoDiagnostic[] => {
497
+ const issues: TopoDiagnostic[] = [];
488
498
 
489
499
  for (const trail of trails.values()) {
490
500
  for (const activation of trail.activationSources ?? []) {
@@ -501,8 +511,8 @@ const checkActivationSourceInputCompatibility = (
501
511
  const checkContourReferences = (
502
512
  contours: ReadonlyMap<string, AnyContour>,
503
513
  topo: Topo
504
- ): TopoIssue[] => {
505
- const issues: TopoIssue[] = [];
514
+ ): TopoDiagnostic[] => {
515
+ const issues: TopoDiagnostic[] = [];
506
516
 
507
517
  for (const [name, contourDef] of contours) {
508
518
  for (const ref of getContourReferences(contourDef)) {
package/src/workspace.ts CHANGED
@@ -5,11 +5,46 @@
5
5
  * helpers for working with paths relative to a workspace.
6
6
  */
7
7
 
8
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
8
9
  import { resolve, relative, dirname, join, isAbsolute } from 'node:path';
9
10
 
10
11
  import { NotFoundError } from './errors.js';
11
12
  import { Result } from './result.js';
12
13
 
14
+ export interface WorkspaceRootManifest {
15
+ readonly workspaces?: unknown;
16
+ }
17
+
18
+ export interface WorkspacePackage<
19
+ Manifest extends object = Record<string, unknown>,
20
+ > {
21
+ readonly manifest: Manifest;
22
+ readonly packageJsonPath: string;
23
+ readonly packageRoot: string;
24
+ readonly workspacePath: string;
25
+ }
26
+
27
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
28
+ typeof value === 'object' && value !== null && !Array.isArray(value);
29
+
30
+ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
31
+
32
+ const normalizeRealPath = (path: string): string => {
33
+ try {
34
+ return normalizePath(realpathSync(path));
35
+ } catch {
36
+ return normalizePath(resolve(path));
37
+ }
38
+ };
39
+
40
+ const readJsonSync = <T>(path: string): T | undefined => {
41
+ try {
42
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ };
47
+
13
48
  /** Check if a directory has a package.json with a `workspaces` field. */
14
49
  const hasWorkspacesField = async (dir: string): Promise<boolean> => {
15
50
  const pkgPath = join(dir, 'package.json');
@@ -25,6 +60,132 @@ const hasWorkspacesField = async (dir: string): Promise<boolean> => {
25
60
  }
26
61
  };
27
62
 
63
+ /**
64
+ * List workspace patterns from a root package manifest.
65
+ *
66
+ * Supports npm/Bun's array form and Yarn-style `{ packages: [] }` form.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * listWorkspacePatterns({ workspaces: ['packages/*'] });
71
+ * ```
72
+ */
73
+ export const listWorkspacePatterns = (
74
+ manifest: WorkspaceRootManifest | undefined
75
+ ): readonly string[] => {
76
+ const { workspaces } = manifest ?? {};
77
+ if (Array.isArray(workspaces)) {
78
+ return workspaces.filter(
79
+ (pattern): pattern is string => typeof pattern === 'string'
80
+ );
81
+ }
82
+
83
+ const packages = isRecord(workspaces) ? workspaces['packages'] : undefined;
84
+ return Array.isArray(packages)
85
+ ? packages.filter(
86
+ (pattern): pattern is string => typeof pattern === 'string'
87
+ )
88
+ : [];
89
+ };
90
+
91
+ const workspaceDirsForPattern = (
92
+ rootDir: string,
93
+ pattern: string
94
+ ): readonly string[] => {
95
+ if (!pattern.endsWith('/*')) {
96
+ const workspaceDir = join(rootDir, pattern);
97
+ return existsSync(join(workspaceDir, 'package.json')) ? [workspaceDir] : [];
98
+ }
99
+
100
+ const groupDir = join(rootDir, pattern.slice(0, -2));
101
+ if (!existsSync(groupDir)) {
102
+ return [];
103
+ }
104
+
105
+ return readdirSync(groupDir, { withFileTypes: true })
106
+ .filter((entry) => entry.isDirectory())
107
+ .map((entry) => join(groupDir, entry.name))
108
+ .filter((workspaceDir) => existsSync(join(workspaceDir, 'package.json')))
109
+ .toSorted();
110
+ };
111
+
112
+ /**
113
+ * List package directories matched by workspace patterns.
114
+ *
115
+ * Only directories containing `package.json` are returned.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * listWorkspacePackageDirs('/repo', ['packages/*']);
120
+ * ```
121
+ */
122
+ export const listWorkspacePackageDirs = (
123
+ rootDir: string,
124
+ patterns: readonly string[]
125
+ ): readonly string[] =>
126
+ patterns.flatMap((pattern) => workspaceDirsForPattern(rootDir, pattern));
127
+
128
+ /**
129
+ * List workspace package manifests from a workspace root.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * const packages = listWorkspacePackages('/repo');
134
+ * ```
135
+ */
136
+ export const listWorkspacePackages = <
137
+ Manifest extends { readonly name?: unknown } = { readonly name?: unknown },
138
+ >(
139
+ rootDir: string
140
+ ): readonly WorkspacePackage<Manifest>[] => {
141
+ const normalizedRoot = normalizeRealPath(rootDir);
142
+ const rootManifest = readJsonSync<WorkspaceRootManifest>(
143
+ join(normalizedRoot, 'package.json')
144
+ );
145
+ const packages: WorkspacePackage<Manifest>[] = [];
146
+
147
+ for (const workspaceDir of listWorkspacePackageDirs(
148
+ normalizedRoot,
149
+ listWorkspacePatterns(rootManifest)
150
+ )) {
151
+ const packageJsonPath = join(workspaceDir, 'package.json');
152
+ const manifest = readJsonSync<Manifest>(packageJsonPath);
153
+ if (!manifest || typeof manifest.name !== 'string') {
154
+ continue;
155
+ }
156
+
157
+ const packageRoot = normalizeRealPath(dirname(packageJsonPath));
158
+ packages.push({
159
+ manifest,
160
+ packageJsonPath: normalizeRealPath(packageJsonPath),
161
+ packageRoot,
162
+ workspacePath: normalizePath(relative(normalizedRoot, packageRoot)),
163
+ });
164
+ }
165
+
166
+ return packages.toSorted((left, right) =>
167
+ left.workspacePath.localeCompare(right.workspacePath)
168
+ );
169
+ };
170
+
171
+ /**
172
+ * Find a workspace package by its package name.
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * const workspace = findWorkspacePackage('/repo', '@ontrails/core');
177
+ * ```
178
+ */
179
+ export const findWorkspacePackage = <
180
+ Manifest extends { readonly name?: unknown } = { readonly name?: unknown },
181
+ >(
182
+ rootDir: string,
183
+ packageName: string
184
+ ): WorkspacePackage<Manifest> | undefined =>
185
+ listWorkspacePackages<Manifest>(rootDir).find(
186
+ (workspacePackage) => workspacePackage.manifest.name === packageName
187
+ );
188
+
28
189
  /**
29
190
  * Walks up from `startDir` (defaults to `process.cwd()`) looking for a
30
191
  * `package.json` that contains a `"workspaces"` field.