@zgeoff/atc 2.20.0 → 2.23.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.
- package/package.json +1 -1
- package/src/cli.ts +187 -118
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +270 -20
- package/src/daemon/daemon-context.ts +8 -0
- package/src/daemon/daemon.ts +186 -32
- package/src/daemon/find-token-fingerprint.ts +23 -0
- package/src/daemon/handshake-throttle.ts +47 -0
- package/src/daemon/idempotency-ledger.ts +20 -2
- package/src/daemon/is-allowed-listen-host.ts +63 -0
- package/src/daemon/load-listener-tokens.ts +46 -0
- package/src/daemon/parse-listen-address.ts +33 -0
- package/src/daemon/start-tcp-listener.ts +165 -0
- package/src/federation/build-binding-payload-hash.ts +34 -0
- package/src/federation/build-daemon-outdated-error.ts +14 -0
- package/src/federation/build-events-filter-hash.ts +16 -0
- package/src/federation/build-gateway-error.ts +45 -0
- package/src/federation/build-gateway-id.ts +14 -0
- package/src/federation/build-gateway-result.ts +31 -0
- package/src/federation/build-ruled-value.ts +53 -0
- package/src/federation/collect-unruled-id-paths.ts +46 -0
- package/src/federation/daemon-caller.ts +473 -0
- package/src/federation/daemon-pool.ts +56 -0
- package/src/federation/decode-gateway-cursor.ts +73 -0
- package/src/federation/encode-gateway-cursor.ts +15 -0
- package/src/federation/gateway-error.ts +25 -0
- package/src/federation/gateway-store.ts +253 -0
- package/src/federation/id-rules.ts +85 -0
- package/src/federation/load-gateway-registry.ts +29 -0
- package/src/federation/max-events-cursor-bytes.ts +4 -0
- package/src/federation/max-registry-daemons.ts +26 -0
- package/src/federation/merge-event-pages.ts +228 -0
- package/src/federation/open-gateway-caller.ts +55 -0
- package/src/federation/parse-gateway-id.ts +28 -0
- package/src/federation/parse-gateway-registry.ts +123 -0
- package/src/federation/pick-daemon-state.ts +46 -0
- package/src/federation/plan-event-reads.ts +54 -0
- package/src/federation/read-fleet-events.ts +279 -0
- package/src/federation/require-serving-daemon.ts +27 -0
- package/src/federation/resolve-daemon-request.ts +59 -0
- package/src/federation/routing-caller.ts +450 -0
- package/src/federation/types.ts +33 -0
- package/src/federation/wait-for-outcome.ts +38 -0
- package/src/mcp/answer-rpc-request.ts +14 -1
- package/src/mcp/build-tool-list.ts +6 -5
- package/src/mcp/mcp-tools.ts +41 -8
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +15 -1
- package/src/mcp/start-mcp-http-server.ts +53 -9
- package/src/mcp/types.ts +8 -1
- package/src/protocol/daemon-features.ts +9 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/store/state-store.ts +16 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { DaemonConnection, TCPPeer } from './daemon-connection';
|
|
2
|
+
import { findTokenFingerprint } from './find-token-fingerprint';
|
|
3
|
+
import { HandshakeThrottle } from './handshake-throttle';
|
|
4
|
+
|
|
5
|
+
// The slice of an accepted socket a protocol connection writes to and ends.
|
|
6
|
+
interface ListenerSocket {
|
|
7
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket write takes a buffer with no readonly form
|
|
8
|
+
readonly write: (data: Uint8Array) => number;
|
|
9
|
+
readonly end: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TCPListenerOptions {
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly port: number;
|
|
15
|
+
readonly tokens: readonly string[];
|
|
16
|
+
|
|
17
|
+
// How long a handshake waits once its address has failed too often.
|
|
18
|
+
readonly failureDelayMs: number;
|
|
19
|
+
|
|
20
|
+
// How many delayed handshakes may wait at once across every address.
|
|
21
|
+
readonly maxDelayedHandshakes: number;
|
|
22
|
+
|
|
23
|
+
// Builds the protocol connection for a newly accepted socket, and
|
|
24
|
+
// releases it once the socket closes.
|
|
25
|
+
readonly openConnection: (socket: ListenerSocket, peer: TCPPeer) => DaemonConnection;
|
|
26
|
+
|
|
27
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a connection is a live object the daemon releases
|
|
28
|
+
readonly closeConnection: (connection: DaemonConnection) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TCPListener {
|
|
32
|
+
// The port the listener bound, which a requested port of 0 leaves to the
|
|
33
|
+
// kernel.
|
|
34
|
+
readonly port: number;
|
|
35
|
+
|
|
36
|
+
// Replaces the tokens a handshake may present, closing at once every
|
|
37
|
+
// connection whose handshake token is not among them. Null drops every
|
|
38
|
+
// token: every connection closes and every handshake is refused.
|
|
39
|
+
readonly setTokens: (tokens: readonly string[] | null) => void;
|
|
40
|
+
readonly stop: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Starts the TCP listener for the client protocol. Every connection must
|
|
45
|
+
* open with a `daemon.hello` that carries a bearer token from the token
|
|
46
|
+
* file, and acts only as the principals its requests give, never as the
|
|
47
|
+
* daemon's owner. A delayed handshake ends early, refused, once its socket
|
|
48
|
+
* closes, and a handshake that would wait while the cap of delayed
|
|
49
|
+
* handshakes is full is refused at once and counted as a failure, so a
|
|
50
|
+
* flood across many sockets holds at most the cap of waits.
|
|
51
|
+
*/
|
|
52
|
+
export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
53
|
+
let tokens: readonly string[] | null = opts.tokens;
|
|
54
|
+
|
|
55
|
+
const throttle = new HandshakeThrottle(opts.failureDelayMs);
|
|
56
|
+
const connections = new Set<DaemonConnection>();
|
|
57
|
+
|
|
58
|
+
let delayed = 0;
|
|
59
|
+
|
|
60
|
+
// Ends the delayed handshake of each open socket once it closes.
|
|
61
|
+
const closedSockets = new WeakMap<object, (value: 'closed') => void>();
|
|
62
|
+
|
|
63
|
+
const server = Bun.listen<DaemonConnection>({
|
|
64
|
+
hostname: opts.host,
|
|
65
|
+
port: opts.port,
|
|
66
|
+
socket: {
|
|
67
|
+
open(socket) {
|
|
68
|
+
const address = socket.remoteAddress;
|
|
69
|
+
const closed = Promise.withResolvers<'closed'>();
|
|
70
|
+
|
|
71
|
+
closedSockets.set(socket, closed.resolve);
|
|
72
|
+
|
|
73
|
+
const peer: TCPPeer = {
|
|
74
|
+
verifyHandshake: async (presented) => {
|
|
75
|
+
const delay = throttle.getDelay(address, Date.now());
|
|
76
|
+
|
|
77
|
+
if (delay > 0 && delayed >= opts.maxDelayedHandshakes) {
|
|
78
|
+
throttle.recordFailure(address, Date.now());
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (delay > 0) {
|
|
84
|
+
delayed++;
|
|
85
|
+
|
|
86
|
+
const elapsed = Promise.withResolvers<'elapsed'>();
|
|
87
|
+
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
elapsed.resolve('elapsed');
|
|
90
|
+
}, delay);
|
|
91
|
+
|
|
92
|
+
const waited = await Promise.race([elapsed.promise, closed.promise]);
|
|
93
|
+
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
|
|
96
|
+
delayed--;
|
|
97
|
+
|
|
98
|
+
if (waited === 'closed') {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fingerprint =
|
|
104
|
+
tokens === null || presented === null
|
|
105
|
+
? null
|
|
106
|
+
: findTokenFingerprint(tokens, presented);
|
|
107
|
+
|
|
108
|
+
if (fingerprint === null) {
|
|
109
|
+
throttle.recordFailure(address, Date.now());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return fingerprint;
|
|
113
|
+
},
|
|
114
|
+
recordFailure: () => {
|
|
115
|
+
throttle.recordFailure(address, Date.now());
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
socket.data = opts.openConnection(socket, peer);
|
|
120
|
+
|
|
121
|
+
connections.add(socket.data);
|
|
122
|
+
},
|
|
123
|
+
data(socket, buf) {
|
|
124
|
+
socket.data.applyChunk(socket.data.decodeChunk(buf));
|
|
125
|
+
},
|
|
126
|
+
drain(socket) {
|
|
127
|
+
socket.data.drain();
|
|
128
|
+
},
|
|
129
|
+
close(socket) {
|
|
130
|
+
closedSockets.get(socket)?.('closed');
|
|
131
|
+
closedSockets.delete(socket);
|
|
132
|
+
connections.delete(socket.data);
|
|
133
|
+
opts.closeConnection(socket.data);
|
|
134
|
+
},
|
|
135
|
+
error() {},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
port: server.port,
|
|
141
|
+
setTokens(next) {
|
|
142
|
+
tokens = next;
|
|
143
|
+
|
|
144
|
+
const kept =
|
|
145
|
+
next === null
|
|
146
|
+
? new Set<string>()
|
|
147
|
+
: new Set(next.map((token) => findTokenFingerprint([token], token)));
|
|
148
|
+
|
|
149
|
+
for (const connection of connections) {
|
|
150
|
+
const fingerprint = connection.tokenFingerprint;
|
|
151
|
+
|
|
152
|
+
if (fingerprint !== null && !kept.has(fingerprint)) {
|
|
153
|
+
connection.dispose();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
stop() {
|
|
158
|
+
for (const connection of connections) {
|
|
159
|
+
connection.dispose();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
server.stop(true);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The SHA-256 of a keyed request's params as canonical JSON: object keys
|
|
5
|
+
* sorted by UTF-16 code unit at every depth, with the idempotency key and
|
|
6
|
+
* the replay-only flag left out, so a retry that differs only in key order
|
|
7
|
+
* or in whether it only replays hashes the same and one with another
|
|
8
|
+
* payload does not. Code-unit order never ties
|
|
9
|
+
* two distinct keys, as a locale comparison can for two spellings of one
|
|
10
|
+
* accented letter.
|
|
11
|
+
*/
|
|
12
|
+
export function buildBindingPayloadHash(params: Readonly<Record<string, unknown>>): string {
|
|
13
|
+
const { idempotencyKey: _ignored, replayOnly: _replayOnly, ...payload } = params;
|
|
14
|
+
|
|
15
|
+
return createHash('sha256')
|
|
16
|
+
.update(JSON.stringify(sortKeysByCodeUnit(payload)))
|
|
17
|
+
.digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sortKeysByCodeUnit(value: unknown): unknown {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
return value.map((item) => sortKeysByCodeUnit(item));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof value === 'object' && value !== null) {
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.entries(value)
|
|
28
|
+
.toSorted(([a], [b]) => Number(a > b) - Number(a < b))
|
|
29
|
+
.map(([key, item]) => [key, sortKeysByCodeUnit(item)]),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
|
+
import { GatewayError } from './gateway-error';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The refusal for a call that relies on a feature the daemon's handshake
|
|
6
|
+
* did not announce, holding the daemon's name and the missing feature.
|
|
7
|
+
*/
|
|
8
|
+
export function buildDaemonOutdatedError(daemon: string, feature: DaemonFeature): GatewayError {
|
|
9
|
+
return new GatewayError(
|
|
10
|
+
'daemon_outdated',
|
|
11
|
+
`daemon '${daemon}' runs an atc build without ${feature}; call without the option that needs it, or upgrade that daemon`,
|
|
12
|
+
{ daemon, feature },
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The hash a gateway events cursor holds of the filters it was read
|
|
5
|
+
* under: the session filter, as the caller sent it, and the kind filter.
|
|
6
|
+
* A cursor read under one filter is refused under another, since its
|
|
7
|
+
* per-daemon positions only make sense for the events that filter kept.
|
|
8
|
+
*/
|
|
9
|
+
export function buildEventsFilterHash(
|
|
10
|
+
session: string | null,
|
|
11
|
+
kinds: readonly string[] | null,
|
|
12
|
+
): string {
|
|
13
|
+
const canonical = JSON.stringify([session, kinds === null ? null : kinds.toSorted()]);
|
|
14
|
+
|
|
15
|
+
return createHash('sha256').update(canonical).digest('base64url').slice(0, 22);
|
|
16
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { buildRuledValue } from './build-ruled-value';
|
|
3
|
+
import { ERROR_DATA_RULES } from './id-rules';
|
|
4
|
+
import type { RegistryDaemon } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A daemon's error as the gateway returns it: the same code, every id in
|
|
8
|
+
* its `data` rewritten for the daemon, and every daemon id its message
|
|
9
|
+
* quotes, from the data or from the request, replaced by its gateway id.
|
|
10
|
+
* `requestIDs` maps each daemon id the request carried to the gateway id
|
|
11
|
+
* the caller sent.
|
|
12
|
+
*/
|
|
13
|
+
export function buildGatewayError(
|
|
14
|
+
error: Readonly<DaemonError>,
|
|
15
|
+
daemon: Pick<RegistryDaemon, 'name' | 'incarnation'>,
|
|
16
|
+
requestIDs: ReadonlyMap<string, string>,
|
|
17
|
+
): DaemonError {
|
|
18
|
+
if (error.data === undefined) {
|
|
19
|
+
return new DaemonError(error.code, buildQuotedMessage(error.message, requestIDs));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rewritten = buildRuledValue(error.data, ERROR_DATA_RULES, daemon);
|
|
23
|
+
const data = typeof rewritten === 'object' && rewritten !== null ? { ...rewritten } : error.data;
|
|
24
|
+
|
|
25
|
+
const quoted = new Map(requestIDs);
|
|
26
|
+
|
|
27
|
+
for (const field of ERROR_DATA_RULES.keys()) {
|
|
28
|
+
const inner = error.data[field];
|
|
29
|
+
const outer = Reflect.get(data, field);
|
|
30
|
+
|
|
31
|
+
if (typeof inner === 'string' && typeof outer === 'string') {
|
|
32
|
+
quoted.set(inner, outer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new DaemonError(error.code, buildQuotedMessage(error.message, quoted), data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The message with each quoted daemon id replaced, longest first so an id
|
|
40
|
+
// that contains another is replaced whole.
|
|
41
|
+
function buildQuotedMessage(message: string, ids: ReadonlyMap<string, string>): string {
|
|
42
|
+
const ordered = [...ids].toSorted(([a], [b]) => b.length - a.length);
|
|
43
|
+
|
|
44
|
+
return ordered.reduce((text, [inner, outer]) => text.replaceAll(inner, outer), message);
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RegistryDaemon } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The id the gateway returns for a daemon's id: `<name>.<incarnation>.<id>`.
|
|
5
|
+
* The name is the daemon's logical identity and the incarnation the state
|
|
6
|
+
* identity pinned behind it, so an id held across a rebind of the name
|
|
7
|
+
* never reaches the new daemon.
|
|
8
|
+
*/
|
|
9
|
+
export function buildGatewayID(
|
|
10
|
+
daemon: Pick<RegistryDaemon, 'name' | 'incarnation'>,
|
|
11
|
+
id: string,
|
|
12
|
+
): string {
|
|
13
|
+
return `${daemon.name}.${daemon.incarnation}.${id}`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import { buildRuledValue } from './build-ruled-value';
|
|
3
|
+
import { ID_RULES } from './id-rules';
|
|
4
|
+
import type { RegistryDaemon } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A daemon method's answer as the gateway returns it, every id field in
|
|
8
|
+
* the method's rules rewritten for the daemon that answered. Throws for a
|
|
9
|
+
* method without rules, so no answer leaves the gateway unchecked, and for
|
|
10
|
+
* `events.read`, whose answer goes through the event merge, which owns its
|
|
11
|
+
* cursors.
|
|
12
|
+
*/
|
|
13
|
+
export function buildGatewayResult(
|
|
14
|
+
method: string,
|
|
15
|
+
result: Readonly<Record<string, unknown>>,
|
|
16
|
+
daemon: Pick<RegistryDaemon, 'name' | 'incarnation'>,
|
|
17
|
+
): Readonly<Record<string, unknown>> {
|
|
18
|
+
if (method === 'events.read') {
|
|
19
|
+
throw new Error('an events.read answer is rewritten by the event merge');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rules = ID_RULES[method];
|
|
23
|
+
|
|
24
|
+
if (rules === undefined) {
|
|
25
|
+
throw new Error(`the gateway has no id rules for ${method}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rewritten = buildRuledValue(result, rules, daemon);
|
|
29
|
+
|
|
30
|
+
return isRecord(rewritten) ? rewritten : result;
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import { buildGatewayID } from './build-gateway-id';
|
|
3
|
+
import type { IDRule } from './id-rules';
|
|
4
|
+
import type { RegistryDaemon } from './types';
|
|
5
|
+
|
|
6
|
+
type DaemonRef = Pick<RegistryDaemon, 'name' | 'incarnation'>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A daemon answer with every field a rule covers rewritten for the
|
|
10
|
+
* gateway: ids carry the daemon's name and incarnation, and a locator's
|
|
11
|
+
* daemon ID gives way to them. A `cursor` field is left for the event
|
|
12
|
+
* merge, a `keep` object is still searched for ruled fields below it, and
|
|
13
|
+
* an `opaque` value passes whole.
|
|
14
|
+
* Fields without a rule pass unchanged.
|
|
15
|
+
*/
|
|
16
|
+
export function buildRuledValue(
|
|
17
|
+
value: unknown,
|
|
18
|
+
rules: ReadonlyMap<string, IDRule>,
|
|
19
|
+
daemon: DaemonRef,
|
|
20
|
+
path = '',
|
|
21
|
+
): unknown {
|
|
22
|
+
const rule = rules.get(path);
|
|
23
|
+
|
|
24
|
+
if (rule === 'opaque') {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (rule === 'id' && typeof value === 'string') {
|
|
29
|
+
return buildGatewayID(daemon, value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (rule === 'locator' && isRecord(value)) {
|
|
33
|
+
const { daemonID: _daemonID, ...rest } = value;
|
|
34
|
+
|
|
35
|
+
return { daemon: daemon.name, incarnation: daemon.incarnation, ...rest };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.map((item: unknown) => buildRuledValue(item, rules, daemon, `${path}[]`));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isRecord(value)) {
|
|
43
|
+
return Object.fromEntries(
|
|
44
|
+
Object.entries(value).map(([key, inner]) => {
|
|
45
|
+
const innerPath = path === '' ? key : `${path}.${key}`;
|
|
46
|
+
|
|
47
|
+
return [key, buildRuledValue(inner, rules, daemon, innerPath)];
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import type { IDRule } from './id-rules';
|
|
3
|
+
|
|
4
|
+
// A value that holds one of atc's ids: a session id or daemon ID is a
|
|
5
|
+
// UUID, and a message id is a UUID behind `m-`.
|
|
6
|
+
const ID_PATTERN = /[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The paths of every string in a daemon answer that holds an id but has no
|
|
10
|
+
* rule, so a daemon field that starts carrying an id cannot leave the
|
|
11
|
+
* gateway without its daemon's name. A ruled field and everything below a
|
|
12
|
+
* ruled locator or an opaque value count as covered.
|
|
13
|
+
*/
|
|
14
|
+
export function collectUnruledIDPaths(
|
|
15
|
+
value: unknown,
|
|
16
|
+
rules: ReadonlyMap<string, IDRule>,
|
|
17
|
+
path = '',
|
|
18
|
+
): string[] {
|
|
19
|
+
const rule = rules.get(path);
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
rule === 'locator' ||
|
|
23
|
+
rule === 'opaque' ||
|
|
24
|
+
(rule !== undefined && typeof value === 'string')
|
|
25
|
+
) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
return ID_PATTERN.test(value) ? [path] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
return value.flatMap((item: unknown) => collectUnruledIDPaths(item, rules, `${path}[]`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isRecord(value)) {
|
|
38
|
+
return Object.entries(value).flatMap(([key, inner]) => {
|
|
39
|
+
const innerPath = path === '' ? key : `${path}.${key}`;
|
|
40
|
+
|
|
41
|
+
return collectUnruledIDPaths(inner, rules, innerPath);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [];
|
|
46
|
+
}
|