@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 +18 -0
- package/package.json +1 -1
- package/src/derive.ts +9 -3
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +18 -10
- package/src/glob.ts +81 -0
- package/src/index.ts +32 -2
- package/src/path-scope.ts +66 -0
- package/src/surface-filter.ts +3 -78
- package/src/trail-id-glob.ts +15 -0
- package/src/validate-established-topo.ts +2 -2
- package/src/validate-topo.ts +34 -24
- package/src/workspace.ts +161 -0
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
package/src/derive.ts
CHANGED
|
@@ -145,7 +145,7 @@ const propagateDescription = (
|
|
|
145
145
|
}
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
-
/** Step one level of
|
|
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 (
|
|
158
|
+
if (
|
|
159
|
+
defType !== 'optional' &&
|
|
160
|
+
defType !== 'default' &&
|
|
161
|
+
defType !== 'readonly'
|
|
162
|
+
) {
|
|
159
163
|
return null;
|
|
160
164
|
}
|
|
161
|
-
|
|
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
|
|
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
|
|
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
|
-
):
|
|
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:
|
|
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:
|
|
156
|
-
):
|
|
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:
|
|
173
|
+
kind: DraftDiagnostic['kind'],
|
|
166
174
|
reason: DraftReason
|
|
167
|
-
):
|
|
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
|
-
):
|
|
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
|
-
):
|
|
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
|
-
|
|
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);
|
package/src/surface-filter.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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)
|
package/src/validate-topo.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { validateWebhookSource } from './webhook.js';
|
|
|
34
34
|
// Issue shape
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
|
-
export interface
|
|
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
|
-
):
|
|
96
|
-
const issues:
|
|
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
|
-
):
|
|
132
|
-
const issues:
|
|
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
|
-
):
|
|
186
|
-
const issues:
|
|
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
|
-
):
|
|
241
|
-
const issues:
|
|
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):
|
|
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 = (
|
|
278
|
-
|
|
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
|
-
):
|
|
296
|
-
const issues:
|
|
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
|
-
):
|
|
318
|
-
const issues:
|
|
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
|
-
):
|
|
348
|
-
const issues:
|
|
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
|
-
):
|
|
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
|
-
):
|
|
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
|
-
):
|
|
487
|
-
const issues:
|
|
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
|
-
):
|
|
505
|
-
const issues:
|
|
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.
|