@transitrix/diagrams 1.8.8 → 1.8.13
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/dist/activities/index.d.ts +2 -0
- package/dist/activities/index.js +1 -0
- package/dist/activities/resolver.d.ts +54 -0
- package/dist/activities/resolver.js +172 -0
- package/dist/canon-resolver-utils.d.ts +21 -0
- package/dist/canon-resolver-utils.js +81 -0
- package/dist/fgca/resolver.js +5 -1
- package/dist/goals/index.d.ts +2 -0
- package/dist/goals/index.js +1 -0
- package/dist/goals/resolver.d.ts +63 -0
- package/dist/goals/resolver.js +154 -0
- package/dist/repo-validate/index.d.ts +2 -1
- package/dist/repo-validate/index.js +1 -0
- package/dist/repo-validate/resolve-model.d.ts +5 -0
- package/dist/repo-validate/resolve-model.js +71 -0
- package/dist/repo-validate/types.d.ts +56 -0
- package/dist/repo-validate/validate-repo.d.ts +11 -1
- package/dist/repo-validate/validate-repo.js +5 -4
- package/package.json +1 -1
- package/src/activities/index.ts +2 -0
- package/src/activities/resolver.ts +208 -0
- package/src/canon-resolver-utils.ts +89 -0
- package/src/fgca/resolver.ts +5 -1
- package/src/goals/index.ts +2 -0
- package/src/goals/resolver.ts +186 -0
- package/src/repo-validate/index.ts +2 -1
- package/src/repo-validate/resolve-model.ts +79 -0
- package/src/repo-validate/types.ts +59 -0
- package/src/repo-validate/validate-repo.ts +5 -4
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goals Tree canon resolver — assembles a flat canonical Goals document from
|
|
3
|
+
* a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/04-goals.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* and `activities/resolver.ts`'s `resolveAction`/`isActionViewDoc` pattern
|
|
7
|
+
* for the `goals` notation.
|
|
8
|
+
*
|
|
9
|
+
* Output shape matches the flat inline form `parseCanonicalGoals` expects:
|
|
10
|
+
* top-level `goal_types[]` + `goals[]`. Filesystem-free — callable from unit
|
|
11
|
+
* tests without vscode.
|
|
12
|
+
*
|
|
13
|
+
* `goal_types[]` synthesis: when `view_config.goal_types` is present it is
|
|
14
|
+
* used as-is (the documented projection-form shape always includes it) and
|
|
15
|
+
* each goal keeps its own authored `level`, validated against that table as
|
|
16
|
+
* usual. When absent, §5.2 says the renderer "infers levels from the parent
|
|
17
|
+
* chain depth (root goals at 0, each step deeper adds 1)" — this resolver
|
|
18
|
+
* implements that literally (`parentChainDepth`), overriding each selected
|
|
19
|
+
* goal's `level` with its structural depth, rather than trusting the
|
|
20
|
+
* element's own stored `level` field. A stored `level` can't be trusted here:
|
|
21
|
+
* nothing enforces cross-element consistency on it (ELEMENT_PRIMITIVES.md
|
|
22
|
+
* §7.2: `type`/`level` are independent, both-optional per-element fields),
|
|
23
|
+
* and worse, using it made the synthesized table's chosen level depend on
|
|
24
|
+
* which element the canon loader happened to enumerate first — the CLI
|
|
25
|
+
* (`readdirSync`, unsorted) and the VS Code preview
|
|
26
|
+
* (`vscode.workspace.fs.readDirectory`, unsorted) can enumerate the same
|
|
27
|
+
* canon store in different orders, so the two surfaces could disagree on
|
|
28
|
+
* `GOALS-008` findings for identical input. Structural depth has no such
|
|
29
|
+
* dependency. A goal with no `type` still surfaces as a genuine `GOALS-006`
|
|
30
|
+
* finding — this resolver never synthesizes a type label to paper over one.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { isObject, str, strArray, descendantsOf, parentChainDepth, stripEnvelope } from '../canon-resolver-utils.js';
|
|
34
|
+
|
|
35
|
+
export interface GoalsViewConfig {
|
|
36
|
+
scope?: {
|
|
37
|
+
root_goal?: string | null;
|
|
38
|
+
period?: string | null;
|
|
39
|
+
type_filter?: string[] | null;
|
|
40
|
+
valid_at?: string | null;
|
|
41
|
+
};
|
|
42
|
+
goal_types?: Array<{ name: string; level: number }>;
|
|
43
|
+
display?: {
|
|
44
|
+
depth?: number | null;
|
|
45
|
+
collapsed?: string[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GoalsCanonSources {
|
|
50
|
+
elements: unknown[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** GOAL elements by id (`notation: goal`). */
|
|
54
|
+
function collectGoalElements(docs: unknown[]): Map<string, Record<string, unknown>> {
|
|
55
|
+
const out = new Map<string, Record<string, unknown>>();
|
|
56
|
+
for (const doc of docs) {
|
|
57
|
+
if (!isObject(doc)) continue;
|
|
58
|
+
if (str(doc['notation']) !== 'goal') continue;
|
|
59
|
+
const id = str(doc['id']);
|
|
60
|
+
if (!id) continue;
|
|
61
|
+
if (!out.has(id)) out.set(id, doc); // first definition wins
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
68
|
+
* (`view_config` key present, no inline `goals[]`). Used by the preview and
|
|
69
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
70
|
+
* `parseCanonicalGoals` on the inline document.
|
|
71
|
+
*/
|
|
72
|
+
export function isGoalsViewDoc(parsed: unknown): boolean {
|
|
73
|
+
if (!isObject(parsed)) return false;
|
|
74
|
+
return 'view_config' in parsed && !('goals' in parsed);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Assemble a flat canonical Goals document from the canon GOAL element
|
|
79
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
80
|
+
* value is ready to pass to `parseCanonicalGoals`.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveGoals(
|
|
83
|
+
viewDoc: unknown,
|
|
84
|
+
sources: GoalsCanonSources,
|
|
85
|
+
): Record<string, unknown> {
|
|
86
|
+
if (!isObject(viewDoc)) {
|
|
87
|
+
return { notation: 'goals', goal_types: [], goals: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
|
|
91
|
+
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
92
|
+
|
|
93
|
+
const allGoals = collectGoalElements(sources.elements);
|
|
94
|
+
|
|
95
|
+
// 1. Scope by root_goal (this goal + its descendants), or every goal when
|
|
96
|
+
// omitted (§5.1: "Omit to show all root goals").
|
|
97
|
+
const rootGoal = str(scope['root_goal']);
|
|
98
|
+
let candidateIds: Set<string>;
|
|
99
|
+
if (rootGoal) {
|
|
100
|
+
candidateIds = allGoals.has(rootGoal) ? descendantsOf(rootGoal, allGoals) : new Set();
|
|
101
|
+
} else {
|
|
102
|
+
candidateIds = new Set(allGoals.keys());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Narrow by period — matched against the element's own `period` field
|
|
106
|
+
// when present (the GOAL element schema, ELEMENT_PRIMITIVES.md §7.2, does
|
|
107
|
+
// not define this field explicitly; treated as a forward-compatible tag).
|
|
108
|
+
const period = str(scope['period']);
|
|
109
|
+
if (period) {
|
|
110
|
+
candidateIds = new Set(
|
|
111
|
+
[...candidateIds].filter((id) => str(allGoals.get(id)?.['period']) === period),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Narrow by type_filter — only goals of a listed type.
|
|
116
|
+
const typeFilter = strArray(scope['type_filter']);
|
|
117
|
+
if (typeFilter.length > 0) {
|
|
118
|
+
candidateIds = new Set(
|
|
119
|
+
[...candidateIds].filter((id) => {
|
|
120
|
+
const t = str(allGoals.get(id)?.['type']);
|
|
121
|
+
return t !== undefined && typeFilter.includes(t);
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. Narrow by valid_at — only goals in effect on that date. Goals with no
|
|
127
|
+
// valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
128
|
+
// matching the action/dgca resolvers' stance).
|
|
129
|
+
const validAt = str(scope['valid_at']);
|
|
130
|
+
if (validAt) {
|
|
131
|
+
candidateIds = new Set(
|
|
132
|
+
[...candidateIds].filter((id) => {
|
|
133
|
+
const el = allGoals.get(id);
|
|
134
|
+
const from = str(el?.['valid_from']);
|
|
135
|
+
const to = str(el?.['valid_to']);
|
|
136
|
+
if (from && validAt < from) return false;
|
|
137
|
+
if (to && validAt > to) return false;
|
|
138
|
+
return true;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const explicitGoalTypes = Array.isArray(vc['goal_types'])
|
|
144
|
+
? (vc['goal_types'] as unknown[]).filter(isObject)
|
|
145
|
+
: undefined;
|
|
146
|
+
|
|
147
|
+
// Element fields map onto Goal fields unchanged — the admission/lifecycle
|
|
148
|
+
// envelope fields carry no rendering meaning for `parseCanonicalGoals` and
|
|
149
|
+
// are dropped.
|
|
150
|
+
let selectedGoals: Array<Record<string, unknown>>;
|
|
151
|
+
let goalTypes: Array<Record<string, unknown>>;
|
|
152
|
+
|
|
153
|
+
if (explicitGoalTypes) {
|
|
154
|
+
// Main-line case: goals keep their own authored `level`, validated
|
|
155
|
+
// against the explicit table by parseCanonicalGoals as usual.
|
|
156
|
+
selectedGoals = [...candidateIds].map((id) => ({ ...stripEnvelope(allGoals.get(id)!), id }));
|
|
157
|
+
goalTypes = explicitGoalTypes;
|
|
158
|
+
} else {
|
|
159
|
+
// §5.2 fallback: level is the goal's structural depth in the parent
|
|
160
|
+
// chain, not its (untrustworthy, order-dependent) stored `level` field.
|
|
161
|
+
const depthMemo = new Map<string, number>();
|
|
162
|
+
selectedGoals = [...candidateIds].map((id) => {
|
|
163
|
+
const { level: _level, ...rest } = stripEnvelope(allGoals.get(id)!);
|
|
164
|
+
return { ...rest, id, level: parentChainDepth(id, allGoals, depthMemo) };
|
|
165
|
+
});
|
|
166
|
+
const seen = new Map<string, number>();
|
|
167
|
+
for (const g of selectedGoals) {
|
|
168
|
+
const name = typeof g['type'] === 'string' ? g['type'] : undefined;
|
|
169
|
+
if (name !== undefined && !seen.has(name)) {
|
|
170
|
+
seen.set(name, g['level'] as number);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
goalTypes = [...seen.entries()].map(([name, level]) => ({ name, level }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
notation: 'goals',
|
|
178
|
+
id: viewDoc['id'],
|
|
179
|
+
name: viewDoc['name'],
|
|
180
|
+
spec_version: viewDoc['spec_version'],
|
|
181
|
+
description: viewDoc['description'],
|
|
182
|
+
period: viewDoc['period'],
|
|
183
|
+
goal_types: goalTypes,
|
|
184
|
+
goals: selectedGoals,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { validateRepoModel } from './validate-repo.js';
|
|
2
|
-
export
|
|
2
|
+
export { resolveRepoModel } from './resolve-model.js';
|
|
3
|
+
export type { RepoDoc, RepoFinding, RepoModelInput, ResolvedElementRecord, ResolvedRelationRecord, ResolvedRepoModel } from './types.js';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Resolved element/relation records for `validate --scope=repo --json --include-model`.
|
|
2
|
+
// A non-JS consumer (DSM's Go backend) needs the canon model this CLI already
|
|
3
|
+
// parses without re-implementing the notation schema — this is a pure
|
|
4
|
+
// projection over the same `RepoModelInput` the repo-scope validator
|
|
5
|
+
// consumes, not a second parse pass, so the two never drift out of sync.
|
|
6
|
+
|
|
7
|
+
import { docId, endpointId } from './validate-repo.js';
|
|
8
|
+
import type {
|
|
9
|
+
RepoDoc,
|
|
10
|
+
RepoModelInput,
|
|
11
|
+
ResolvedElementRecord,
|
|
12
|
+
ResolvedRelationRecord,
|
|
13
|
+
ResolvedRepoModel,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
|
|
16
|
+
/** Matches the `<NN>_<layer>` folder segment under `canon/elements/`, e.g.
|
|
17
|
+
* `01_motivation` -> `motivation` (ELEMENT_PRIMITIVES.md §2/§6). */
|
|
18
|
+
const LAYER_FOLDER_RE = /^\d+_(.+)$/;
|
|
19
|
+
|
|
20
|
+
function readString(data: Record<string, unknown>, key: string): string | undefined {
|
|
21
|
+
const v = data[key];
|
|
22
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Derive `layer` from the `canon/elements/<NN>_<layer>/…` folder segment when
|
|
26
|
+
* the doc has no explicit `layer` field — the folder is authoritative
|
|
27
|
+
* (ELEMENT_PRIMITIVES.md §3, `ELEM-003`). */
|
|
28
|
+
function layerFromPath(path: string): string | undefined {
|
|
29
|
+
const segs = path.split('/');
|
|
30
|
+
const layerSeg = segs[2]; // canon / elements / <NN>_<layer> / …
|
|
31
|
+
if (!layerSeg) return undefined;
|
|
32
|
+
const m = LAYER_FOLDER_RE.exec(layerSeg);
|
|
33
|
+
return m ? m[1] : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveElement(doc: RepoDoc): ResolvedElementRecord | null {
|
|
37
|
+
const id = docId(doc);
|
|
38
|
+
if (!id || !doc.data) return null;
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
name: readString(doc.data, 'name') ?? '',
|
|
42
|
+
notation: readString(doc.data, 'notation') ?? '',
|
|
43
|
+
type: readString(doc.data, 'type'),
|
|
44
|
+
layer: readString(doc.data, 'layer') ?? layerFromPath(doc.path),
|
|
45
|
+
sourceFile: doc.path,
|
|
46
|
+
data: doc.data,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveRelation(doc: RepoDoc): ResolvedRelationRecord | null {
|
|
51
|
+
if (!doc.data) return null;
|
|
52
|
+
const source = endpointId(doc.data['from']) ?? endpointId(doc.data['source']);
|
|
53
|
+
const target = endpointId(doc.data['to']) ?? endpointId(doc.data['target']);
|
|
54
|
+
if (!source || !target) return null;
|
|
55
|
+
return {
|
|
56
|
+
id: docId(doc) ?? '',
|
|
57
|
+
kind: readString(doc.data, 'type'),
|
|
58
|
+
source,
|
|
59
|
+
target,
|
|
60
|
+
sourceFile: doc.path,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Project a loaded canon model into the resolved element/relation records a
|
|
65
|
+
* non-JS consumer can read directly, without depending on this repo's YAML
|
|
66
|
+
* parsing internals. Pure: no IO. */
|
|
67
|
+
export function resolveRepoModel(input: RepoModelInput): ResolvedRepoModel {
|
|
68
|
+
const elements: ResolvedElementRecord[] = [];
|
|
69
|
+
for (const doc of input.elements) {
|
|
70
|
+
const rec = resolveElement(doc);
|
|
71
|
+
if (rec) elements.push(rec);
|
|
72
|
+
}
|
|
73
|
+
const relations: ResolvedRelationRecord[] = [];
|
|
74
|
+
for (const doc of input.relations) {
|
|
75
|
+
const rec = resolveRelation(doc);
|
|
76
|
+
if (rec) relations.push(rec);
|
|
77
|
+
}
|
|
78
|
+
return { elements, relations };
|
|
79
|
+
}
|
|
@@ -47,3 +47,62 @@ export interface RepoModelInput {
|
|
|
47
47
|
/** Documents found under `canon/relations/**`. */
|
|
48
48
|
relations: RepoDoc[];
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/** One resolved canon element, projected from a `RepoDoc` for a non-JS
|
|
52
|
+
* consumer (DSM's Go importer). The top-level fields below are a stable,
|
|
53
|
+
* minimal identity projection — kept for backward compatibility with
|
|
54
|
+
* consumers that only need `id`/`name`/`notation`/`type`/`layer`/`sourceFile`
|
|
55
|
+
* (mirrors DSM's `methodology_element` table). `data` carries the complete
|
|
56
|
+
* parsed element alongside it, so a consumer never needs an engine schema
|
|
57
|
+
* change to read a canon-authored field the engine already parsed (a goal's
|
|
58
|
+
* `level`/`parent`/`description`/`link`/`tags`, an action's scheduling and
|
|
59
|
+
* ownership fields, etc.) — the engine owns each notation's field set
|
|
60
|
+
* (ELEMENT_PRIMITIVES.md), so its output stays notation-shaped rather than
|
|
61
|
+
* growing a bespoke field list per consumer. This is the faithful-projection
|
|
62
|
+
* fallback: a typed per-notation shape may replace `data` later, but the
|
|
63
|
+
* contract must not be curated down to what one consumer currently needs. */
|
|
64
|
+
export interface ResolvedElementRecord {
|
|
65
|
+
/** Canonical id, e.g. `DRIVER-COMP-1`. */
|
|
66
|
+
id: string;
|
|
67
|
+
/** `name` field; `''` when absent (should not happen for an admitted element). */
|
|
68
|
+
name: string;
|
|
69
|
+
/** The element TYPE's short name — the doc's `notation` field, e.g. `driver`, `goal`, `capability`. */
|
|
70
|
+
notation: string;
|
|
71
|
+
/** Subtype from the TYPE's controlled vocabulary (ELEMENT_PRIMITIVES.md §3), e.g. `internal`/`external` for DRIVER. Omitted when the doc has none. */
|
|
72
|
+
type?: string;
|
|
73
|
+
/** `motivation` / `business` / `application` / `technology` / `implementation`. Read from the doc's `layer` field when present, else derived from the `canon/elements/<NN>_<layer>/…` folder. */
|
|
74
|
+
layer?: string;
|
|
75
|
+
/** Source file path, relative to the scanned root. */
|
|
76
|
+
sourceFile: string;
|
|
77
|
+
/** The full parsed element document — every field its author supplied,
|
|
78
|
+
* unfiltered (including the admission/lifecycle envelope). The same
|
|
79
|
+
* `RepoDoc.data` the repo-scope validator already consumes, so this
|
|
80
|
+
* projection and the validation pass never see a different parse. */
|
|
81
|
+
data: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One resolved canon relation, projected from a `RepoDoc`. Only
|
|
85
|
+
* emitted when both endpoints resolve to a non-empty id — an endpoint that
|
|
86
|
+
* fails to resolve is already surfaced as a referential-integrity finding by
|
|
87
|
+
* `validateRepoModel`; this projection does not duplicate that as a
|
|
88
|
+
* half-resolved record. */
|
|
89
|
+
export interface ResolvedRelationRecord {
|
|
90
|
+
/** Canonical id, e.g. `REL-EMP-PERSON-OPS-1`. `''` when the relation doc has no `id`. */
|
|
91
|
+
id: string;
|
|
92
|
+
/** The relation's `type` field, e.g. `employment`, `realizes`. Omitted when the doc has none. */
|
|
93
|
+
kind?: string;
|
|
94
|
+
/** Resolved `from` (or legacy `source`) endpoint id. */
|
|
95
|
+
source: string;
|
|
96
|
+
/** Resolved `to` (or legacy `target`) endpoint id. */
|
|
97
|
+
target: string;
|
|
98
|
+
/** Source file path, relative to the scanned root. */
|
|
99
|
+
sourceFile: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The resolved element/relation records for a repo-scope run —
|
|
103
|
+
* the shape `transitrix validate --scope=repo --json --include-model` adds
|
|
104
|
+
* alongside the validation findings. */
|
|
105
|
+
export interface ResolvedRepoModel {
|
|
106
|
+
elements: ResolvedElementRecord[];
|
|
107
|
+
relations: ResolvedRelationRecord[];
|
|
108
|
+
}
|
|
@@ -33,15 +33,16 @@ const PScope: RepoFinding['scope'] = 'repo';
|
|
|
33
33
|
/** Statuses that, per lint.py's policy check, require an owner. */
|
|
34
34
|
const OWNER_REQUIRED_STATUSES = new Set(['Active', 'Production']);
|
|
35
35
|
|
|
36
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
36
|
+
export function isRecord(v: unknown): v is Record<string, unknown> {
|
|
37
37
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Read a string `id` from a parsed doc, or `null` if absent/non-string.
|
|
41
41
|
* Mirrors lint.py, which only treats a doc as an element/relation when it has
|
|
42
42
|
* a top-level `id` (sidecars such as versioned-attribute files have no `id`
|
|
43
|
-
* and are ignored).
|
|
44
|
-
|
|
43
|
+
* and are ignored). Exported for reuse by `resolve-model.ts`, which needs the
|
|
44
|
+
* same id/endpoint reading rules to stay in lockstep with this validator. */
|
|
45
|
+
export function docId(doc: RepoDoc): string | null {
|
|
45
46
|
if (!doc.data) return null;
|
|
46
47
|
const id = doc.data['id'];
|
|
47
48
|
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
@@ -49,7 +50,7 @@ function docId(doc: RepoDoc): string | null {
|
|
|
49
50
|
|
|
50
51
|
/** Resolve a relation endpoint, which may be a bare id string or a `{ id }`
|
|
51
52
|
* mapping (lint.py accepts both). Returns the id string or `null`. */
|
|
52
|
-
function endpointId(value: unknown): string | null {
|
|
53
|
+
export function endpointId(value: unknown): string | null {
|
|
53
54
|
if (typeof value === 'string') return value.length > 0 ? value : null;
|
|
54
55
|
if (isRecord(value)) {
|
|
55
56
|
const id = value['id'];
|