@ontrails/trails 1.0.0-beta.18 → 1.0.0-beta.19
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 +117 -0
- package/README.md +7 -10
- package/package.json +13 -12
- package/src/app.ts +14 -4
- package/src/cli.ts +16 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/project-writes.ts +62 -5
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-collision.ts +1 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +18 -18
- package/src/trails/add-trail.ts +3 -2
- package/src/trails/add-verify.ts +30 -6
- package/src/trails/{topo-compile.ts → compile.ts} +16 -8
- package/src/trails/completions-complete.ts +1 -1
- package/src/trails/create-adapter.ts +1084 -0
- package/src/trails/create-scaffold.ts +243 -29
- package/src/trails/create.ts +118 -17
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +2 -2
- package/src/trails/dev-reset.ts +2 -2
- package/src/trails/dev-stats.ts +1 -1
- package/src/trails/doctor.ts +56 -0
- package/src/trails/draft-promote.ts +1 -0
- package/src/trails/guide.ts +2 -2
- package/src/trails/revise.ts +53 -0
- package/src/trails/run-example.ts +12 -7
- package/src/trails/run-examples.ts +3 -3
- package/src/trails/run.ts +7 -4
- package/src/trails/survey.ts +332 -25
- package/src/trails/topo-history.ts +1 -1
- package/src/trails/topo-output-schemas.ts +30 -1
- package/src/trails/topo-pin.ts +3 -2
- package/src/trails/topo-read-support.ts +49 -8
- package/src/trails/topo-reports.ts +39 -22
- package/src/trails/topo-store-support.ts +62 -16
- package/src/trails/topo-support.ts +1 -1
- package/src/trails/topo-unpin.ts +2 -2
- package/src/trails/topo.ts +2 -2
- package/src/trails/{topo-verify.ts → validate.ts} +7 -7
- package/src/trails/version-lifecycle-support.ts +945 -0
- package/src/trails/warden-guide.ts +8 -0
- package/src/trails/warden.ts +18 -2
- package/src/versions.ts +4 -1
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { Result, ValidationError } from '@ontrails/core';
|
|
5
|
+
import type { AnyTrail, Topo } from '@ontrails/core';
|
|
6
|
+
import { deriveTopoGraph } from '@ontrails/topographer';
|
|
7
|
+
import type { TopoGraph, TopoGraphForceEntry } from '@ontrails/topographer';
|
|
8
|
+
import { findTrailDefinitions, parse } from '@ontrails/warden/ast';
|
|
9
|
+
|
|
10
|
+
import { tryLoadFreshAppLease } from './load-app.js';
|
|
11
|
+
import {
|
|
12
|
+
readLifecycleSourceFile,
|
|
13
|
+
writeLifecycleSourceFile,
|
|
14
|
+
} from '../lifecycle-source-io.js';
|
|
15
|
+
import { resolveTrailRootDir } from './root-dir.js';
|
|
16
|
+
|
|
17
|
+
export type LifecycleEntryKind = 'revision' | 'fork';
|
|
18
|
+
export type LifecycleStatusKind = 'deprecated' | 'archived';
|
|
19
|
+
|
|
20
|
+
export interface LifecycleCommandInput {
|
|
21
|
+
readonly module?: string | undefined;
|
|
22
|
+
readonly rootDir?: string | undefined;
|
|
23
|
+
readonly target?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LifecycleWriteResult {
|
|
27
|
+
readonly file: string;
|
|
28
|
+
readonly trailId: string;
|
|
29
|
+
readonly updated: boolean;
|
|
30
|
+
readonly warnings?: readonly string[] | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TrailSourceMatch {
|
|
34
|
+
readonly configEnd: number;
|
|
35
|
+
readonly configStart: number;
|
|
36
|
+
readonly filePath: string;
|
|
37
|
+
readonly source: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PropertyMatch {
|
|
41
|
+
readonly end: number;
|
|
42
|
+
readonly key: string;
|
|
43
|
+
readonly start: number;
|
|
44
|
+
readonly value: string;
|
|
45
|
+
readonly valueEnd: number;
|
|
46
|
+
readonly valueStart: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ParsedVersionTarget {
|
|
50
|
+
readonly trailId: string;
|
|
51
|
+
readonly version?: number | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const managedSourceGlob = new Bun.Glob('src/**/*.ts');
|
|
55
|
+
|
|
56
|
+
const literal = (value: string): string => JSON.stringify(value);
|
|
57
|
+
|
|
58
|
+
const parseVersionTarget = (
|
|
59
|
+
target: string
|
|
60
|
+
): Result<ParsedVersionTarget, Error> => {
|
|
61
|
+
const separator = target.lastIndexOf('@');
|
|
62
|
+
if (separator === -1) {
|
|
63
|
+
return Result.ok({ trailId: target });
|
|
64
|
+
}
|
|
65
|
+
const trailId = target.slice(0, separator);
|
|
66
|
+
const rawVersion = target.slice(separator + 1);
|
|
67
|
+
const version = Number(rawVersion);
|
|
68
|
+
if (
|
|
69
|
+
trailId.length === 0 ||
|
|
70
|
+
!Number.isInteger(version) ||
|
|
71
|
+
version < 1 ||
|
|
72
|
+
String(version) !== rawVersion
|
|
73
|
+
) {
|
|
74
|
+
return Result.err(
|
|
75
|
+
new ValidationError('Version target must use trail.id@positiveInteger')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return Result.ok({ trailId, version });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const scanManagedSourceFiles = (rootDir: string): readonly string[] =>
|
|
82
|
+
[...managedSourceGlob.scanSync({ cwd: rootDir, onlyFiles: true })]
|
|
83
|
+
.map((path) => join(rootDir, path))
|
|
84
|
+
.toSorted();
|
|
85
|
+
|
|
86
|
+
const findTrailSource = (
|
|
87
|
+
rootDir: string,
|
|
88
|
+
trailId: string
|
|
89
|
+
): Result<TrailSourceMatch, Error> => {
|
|
90
|
+
for (const filePath of scanManagedSourceFiles(rootDir)) {
|
|
91
|
+
if (!existsSync(filePath)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const source = readLifecycleSourceFile(filePath);
|
|
95
|
+
if (source.isErr()) {
|
|
96
|
+
return source;
|
|
97
|
+
}
|
|
98
|
+
if (!source.value.includes(trailId)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const ast = parse(filePath, source.value);
|
|
102
|
+
if (!ast) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const match = findTrailDefinitions(ast).find(
|
|
106
|
+
(definition) => definition.kind === 'trail' && definition.id === trailId
|
|
107
|
+
);
|
|
108
|
+
if (match !== undefined) {
|
|
109
|
+
return Result.ok({
|
|
110
|
+
configEnd: match.config.end,
|
|
111
|
+
configStart: match.config.start,
|
|
112
|
+
filePath,
|
|
113
|
+
source: source.value,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Result.err(
|
|
119
|
+
new ValidationError(`Could not find source trail definition for ${trailId}`)
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const isWhitespace = (ch: string | undefined): boolean =>
|
|
124
|
+
ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
|
|
125
|
+
|
|
126
|
+
const scanPropertyValueEnd = (
|
|
127
|
+
source: string,
|
|
128
|
+
start: number,
|
|
129
|
+
end: number
|
|
130
|
+
): number => {
|
|
131
|
+
const state: {
|
|
132
|
+
depth: number;
|
|
133
|
+
escaped: boolean;
|
|
134
|
+
quote: '"' | "'" | undefined;
|
|
135
|
+
skipNext: boolean;
|
|
136
|
+
templateStack: number[];
|
|
137
|
+
} = {
|
|
138
|
+
depth: 0,
|
|
139
|
+
escaped: false,
|
|
140
|
+
quote: undefined,
|
|
141
|
+
skipNext: false,
|
|
142
|
+
templateStack: [],
|
|
143
|
+
};
|
|
144
|
+
const currentTemplateDepth = (): number | undefined =>
|
|
145
|
+
state.templateStack.length === 0 ? undefined : state.templateStack.at(-1);
|
|
146
|
+
const consumeQuoted = (ch: string | undefined): boolean => {
|
|
147
|
+
if (state.quote === undefined) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (state.escaped) {
|
|
151
|
+
state.escaped = false;
|
|
152
|
+
} else if (ch === '\\') {
|
|
153
|
+
state.escaped = true;
|
|
154
|
+
} else if (ch === state.quote) {
|
|
155
|
+
state.quote = undefined;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
};
|
|
159
|
+
const consumeTemplateText = (
|
|
160
|
+
ch: string | undefined,
|
|
161
|
+
next: string | undefined
|
|
162
|
+
): boolean => {
|
|
163
|
+
if (currentTemplateDepth() !== 0) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (state.escaped) {
|
|
167
|
+
state.escaped = false;
|
|
168
|
+
} else if (ch === '\\') {
|
|
169
|
+
state.escaped = true;
|
|
170
|
+
} else if (ch === '`') {
|
|
171
|
+
state.templateStack.pop();
|
|
172
|
+
} else if (ch === '$' && next === '{') {
|
|
173
|
+
state.templateStack[state.templateStack.length - 1] = 1;
|
|
174
|
+
state.depth += 1;
|
|
175
|
+
state.skipNext = true;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
179
|
+
const consumeOpen = (ch: string | undefined): boolean => {
|
|
180
|
+
if (ch !== '(' && ch !== '{' && ch !== '[') {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const templateDepth = currentTemplateDepth();
|
|
184
|
+
state.depth += 1;
|
|
185
|
+
if (templateDepth !== undefined && ch === '{') {
|
|
186
|
+
state.templateStack[state.templateStack.length - 1] = templateDepth + 1;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
};
|
|
190
|
+
const consumeClose = (ch: string | undefined): boolean => {
|
|
191
|
+
if (ch !== ')' && ch !== '}' && ch !== ']') {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const templateDepth = currentTemplateDepth();
|
|
195
|
+
state.depth -= 1;
|
|
196
|
+
if (templateDepth !== undefined && ch === '}') {
|
|
197
|
+
state.templateStack[state.templateStack.length - 1] = templateDepth - 1;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
for (let index = start; index < end; index += 1) {
|
|
203
|
+
const ch = source[index];
|
|
204
|
+
if (consumeQuoted(ch) || consumeTemplateText(ch, source[index + 1])) {
|
|
205
|
+
if (state.skipNext) {
|
|
206
|
+
state.skipNext = false;
|
|
207
|
+
index += 1;
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (ch === '"' || ch === "'") {
|
|
213
|
+
state.quote = ch;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (ch === '`') {
|
|
217
|
+
state.templateStack.push(0);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (consumeOpen(ch)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (ch === ')' || ch === '}' || ch === ']') {
|
|
224
|
+
if (state.depth === 0) {
|
|
225
|
+
return index;
|
|
226
|
+
}
|
|
227
|
+
consumeClose(ch);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (ch === ',' && state.depth === 0) {
|
|
231
|
+
return index;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let valueEnd = end;
|
|
235
|
+
while (valueEnd > start && isWhitespace(source[valueEnd - 1])) {
|
|
236
|
+
valueEnd -= 1;
|
|
237
|
+
}
|
|
238
|
+
return valueEnd;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const skipIgnored = (source: string, start: number, end: number): number => {
|
|
242
|
+
let index = start;
|
|
243
|
+
while (index < end) {
|
|
244
|
+
const ch = source[index];
|
|
245
|
+
if (isWhitespace(ch)) {
|
|
246
|
+
index += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (ch === '/' && source[index + 1] === '/') {
|
|
250
|
+
const nextLine = source.indexOf('\n', index + 2);
|
|
251
|
+
index = nextLine === -1 ? end : nextLine + 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (ch === '/' && source[index + 1] === '*') {
|
|
255
|
+
const commentEnd = source.indexOf('*/', index + 2);
|
|
256
|
+
index = commentEnd === -1 ? end : commentEnd + 2;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
return index;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const isIdentifierStart = (ch: string | undefined): boolean =>
|
|
265
|
+
ch !== undefined && /^[A-Za-z_$]$/.test(ch);
|
|
266
|
+
|
|
267
|
+
const isIdentifierPart = (ch: string | undefined): boolean =>
|
|
268
|
+
ch !== undefined && /^[A-Za-z0-9_$]$/.test(ch);
|
|
269
|
+
|
|
270
|
+
const readIdentifierKey = (
|
|
271
|
+
source: string,
|
|
272
|
+
start: number
|
|
273
|
+
): { readonly key: string; readonly keyEnd: number } | undefined => {
|
|
274
|
+
if (!isIdentifierStart(source[start])) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
let keyEnd = start + 1;
|
|
278
|
+
while (isIdentifierPart(source[keyEnd])) {
|
|
279
|
+
keyEnd += 1;
|
|
280
|
+
}
|
|
281
|
+
return { key: source.slice(start, keyEnd), keyEnd };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const readQuotedKey = (
|
|
285
|
+
source: string,
|
|
286
|
+
start: number,
|
|
287
|
+
end: number
|
|
288
|
+
): { readonly key: string; readonly keyEnd: number } | undefined => {
|
|
289
|
+
const quote = source[start];
|
|
290
|
+
if (quote !== '"' && quote !== "'") {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
let escaped = false;
|
|
294
|
+
let key = '';
|
|
295
|
+
for (let index = start + 1; index < end; index += 1) {
|
|
296
|
+
const ch = source[index];
|
|
297
|
+
if (escaped) {
|
|
298
|
+
key += ch ?? '';
|
|
299
|
+
escaped = false;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (ch === '\\') {
|
|
303
|
+
escaped = true;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (ch === quote) {
|
|
307
|
+
return { key, keyEnd: index + 1 };
|
|
308
|
+
}
|
|
309
|
+
key += ch ?? '';
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const readNumericKey = (
|
|
315
|
+
source: string,
|
|
316
|
+
start: number
|
|
317
|
+
): { readonly key: string; readonly keyEnd: number } | undefined => {
|
|
318
|
+
if (source[start] === undefined || !/^\d$/.test(source[start] ?? '')) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
let keyEnd = start + 1;
|
|
322
|
+
while (/^\d$/.test(source[keyEnd] ?? '')) {
|
|
323
|
+
keyEnd += 1;
|
|
324
|
+
}
|
|
325
|
+
return { key: source.slice(start, keyEnd), keyEnd };
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const readPropertyKey = (
|
|
329
|
+
source: string,
|
|
330
|
+
start: number,
|
|
331
|
+
end: number
|
|
332
|
+
): { readonly key: string; readonly keyEnd: number } | undefined =>
|
|
333
|
+
readIdentifierKey(source, start) ??
|
|
334
|
+
readQuotedKey(source, start, end) ??
|
|
335
|
+
readNumericKey(source, start);
|
|
336
|
+
|
|
337
|
+
const propertyLineStart = (source: string, keyStart: number): number => {
|
|
338
|
+
const lineStart = source.lastIndexOf('\n', keyStart) + 1;
|
|
339
|
+
return source.slice(lineStart, keyStart).trim().length === 0
|
|
340
|
+
? lineStart
|
|
341
|
+
: keyStart;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const findConfigProperty = (
|
|
345
|
+
match: TrailSourceMatch,
|
|
346
|
+
key: string
|
|
347
|
+
): PropertyMatch | undefined => {
|
|
348
|
+
const bodyStart = match.configStart + 1;
|
|
349
|
+
const bodyEnd =
|
|
350
|
+
match.source[match.configEnd - 1] === '}'
|
|
351
|
+
? match.configEnd - 1
|
|
352
|
+
: match.configEnd;
|
|
353
|
+
let index = bodyStart;
|
|
354
|
+
|
|
355
|
+
while (index < bodyEnd) {
|
|
356
|
+
index = skipIgnored(match.source, index, bodyEnd);
|
|
357
|
+
if (match.source[index] === ',') {
|
|
358
|
+
index += 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const keyStart = index;
|
|
363
|
+
const propertyKey = readPropertyKey(match.source, keyStart, bodyEnd);
|
|
364
|
+
if (propertyKey === undefined) {
|
|
365
|
+
const valueEnd = scanPropertyValueEnd(match.source, keyStart, bodyEnd);
|
|
366
|
+
index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const colon = skipIgnored(match.source, propertyKey.keyEnd, bodyEnd);
|
|
371
|
+
if (match.source[colon] !== ':') {
|
|
372
|
+
const valueEnd = scanPropertyValueEnd(match.source, keyStart, bodyEnd);
|
|
373
|
+
index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const valueStart = colon + 1;
|
|
378
|
+
const valueEnd = scanPropertyValueEnd(match.source, valueStart, bodyEnd);
|
|
379
|
+
if (propertyKey.key === key) {
|
|
380
|
+
const end = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
|
|
381
|
+
return {
|
|
382
|
+
end,
|
|
383
|
+
key,
|
|
384
|
+
start: propertyLineStart(match.source, keyStart),
|
|
385
|
+
value: match.source.slice(valueStart, valueEnd).trim(),
|
|
386
|
+
valueEnd,
|
|
387
|
+
valueStart,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return undefined;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const replaceRange = (
|
|
397
|
+
source: string,
|
|
398
|
+
start: number,
|
|
399
|
+
end: number,
|
|
400
|
+
replacement: string
|
|
401
|
+
): string => `${source.slice(0, start)}${replacement}${source.slice(end)}`;
|
|
402
|
+
|
|
403
|
+
const coreNamedImportPattern =
|
|
404
|
+
/import\s+(?<importKind>type\s+)?\{(?<imports>[^}]*)\}\s*from\s*['"]@ontrails\/core['"]/g;
|
|
405
|
+
|
|
406
|
+
const declaresRuntimeResultBinding = (specifier: string): boolean => {
|
|
407
|
+
const trimmed = specifier.trim();
|
|
408
|
+
if (trimmed.startsWith('type ')) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
const [imported, local] = trimmed.split(/\s+as\s+/);
|
|
412
|
+
return imported === 'Result' && (local === undefined || local === 'Result');
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const hasDirectResultImport = (source: string): boolean => {
|
|
416
|
+
for (const match of source.matchAll(coreNamedImportPattern)) {
|
|
417
|
+
if (match.groups?.['importKind'] !== undefined) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const imports = match.groups?.['imports'];
|
|
421
|
+
if (
|
|
422
|
+
imports
|
|
423
|
+
?.split(',')
|
|
424
|
+
.some((specifier) => declaresRuntimeResultBinding(specifier)) === true
|
|
425
|
+
) {
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return false;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const missingResultImportWarning = (source: string): readonly string[] =>
|
|
433
|
+
hasDirectResultImport(source)
|
|
434
|
+
? []
|
|
435
|
+
: [
|
|
436
|
+
'Fork blaze placeholder references Result.err, but this file does not import Result from @ontrails/core.',
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
const lineIndentAt = (source: string, index: number): string => {
|
|
440
|
+
const lineStart = source.lastIndexOf('\n', index) + 1;
|
|
441
|
+
const nextLine = source.indexOf('\n', lineStart);
|
|
442
|
+
const lineEnd = nextLine === -1 ? source.length : nextLine;
|
|
443
|
+
return /^\s*/.exec(source.slice(lineStart, lineEnd))?.[0] ?? '';
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const propertyObjectStart = (source: string, entry: PropertyMatch): number => {
|
|
447
|
+
const start = source.indexOf('{', entry.valueStart);
|
|
448
|
+
return start === -1 ? entry.valueStart : start;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const propertyObjectCloseLineStart = (
|
|
452
|
+
source: string,
|
|
453
|
+
entry: PropertyMatch
|
|
454
|
+
): number => {
|
|
455
|
+
const close = source.lastIndexOf('}', entry.valueEnd);
|
|
456
|
+
return source.lastIndexOf('\n', close) + 1;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const buildVersionEntry = (
|
|
460
|
+
version: number,
|
|
461
|
+
kind: LifecycleEntryKind,
|
|
462
|
+
input: string,
|
|
463
|
+
output: string,
|
|
464
|
+
blaze: string | undefined
|
|
465
|
+
): string => {
|
|
466
|
+
if (kind === 'fork') {
|
|
467
|
+
return ` ${version}: {
|
|
468
|
+
input: ${input},
|
|
469
|
+
output: ${output},
|
|
470
|
+
blaze: ${blaze ?? 'async () => Result.err(new Error("TODO: implement fork blaze"))'},
|
|
471
|
+
},`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return ` ${version}: {
|
|
475
|
+
input: ${input},
|
|
476
|
+
output: ${output},
|
|
477
|
+
transpose: {
|
|
478
|
+
input: ({ input }) => input as never,
|
|
479
|
+
output: ({ output }) => output as never,
|
|
480
|
+
},
|
|
481
|
+
},`;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const insertVersionEntry = (
|
|
485
|
+
source: string,
|
|
486
|
+
match: TrailSourceMatch,
|
|
487
|
+
entry: string
|
|
488
|
+
): string => {
|
|
489
|
+
const versions = findConfigProperty(match, 'versions');
|
|
490
|
+
if (versions === undefined) {
|
|
491
|
+
const insertAt = match.configEnd - 1;
|
|
492
|
+
return replaceRange(
|
|
493
|
+
source,
|
|
494
|
+
insertAt,
|
|
495
|
+
insertAt,
|
|
496
|
+
` versions: {\n${entry}\n },\n`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const open = source.indexOf('{', versions.valueStart);
|
|
501
|
+
return replaceRange(source, open + 1, open + 1, `\n${entry}`);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const upsertCurrentVersion = (
|
|
505
|
+
source: string,
|
|
506
|
+
match: TrailSourceMatch,
|
|
507
|
+
nextVersion: number
|
|
508
|
+
): string => {
|
|
509
|
+
const existing = findConfigProperty(match, 'version');
|
|
510
|
+
if (existing !== undefined) {
|
|
511
|
+
return replaceRange(
|
|
512
|
+
source,
|
|
513
|
+
existing.valueStart,
|
|
514
|
+
existing.valueEnd,
|
|
515
|
+
` ${nextVersion}`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
const insertAt = match.configStart + 1;
|
|
519
|
+
return replaceRange(
|
|
520
|
+
source,
|
|
521
|
+
insertAt,
|
|
522
|
+
insertAt,
|
|
523
|
+
`\n version: ${nextVersion},`
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export const reviseTrailSource = (
|
|
528
|
+
rootDir: string,
|
|
529
|
+
trail: AnyTrail,
|
|
530
|
+
kind: LifecycleEntryKind
|
|
531
|
+
): Result<LifecycleWriteResult, Error> => {
|
|
532
|
+
const match = findTrailSource(rootDir, trail.id);
|
|
533
|
+
if (match.isErr()) {
|
|
534
|
+
return match;
|
|
535
|
+
}
|
|
536
|
+
const input = findConfigProperty(match.value, 'input');
|
|
537
|
+
const output = findConfigProperty(match.value, 'output');
|
|
538
|
+
if (input === undefined || output === undefined) {
|
|
539
|
+
return Result.err(
|
|
540
|
+
new ValidationError(`Trail ${trail.id} must declare input and output`)
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const currentVersion = trail.version ?? 1;
|
|
545
|
+
const nextVersion = currentVersion + 1;
|
|
546
|
+
const blaze = findConfigProperty(match.value, 'blaze');
|
|
547
|
+
const usesForkPlaceholder = kind === 'fork' && blaze?.value === undefined;
|
|
548
|
+
let nextSource = upsertCurrentVersion(
|
|
549
|
+
match.value.source,
|
|
550
|
+
match.value,
|
|
551
|
+
nextVersion
|
|
552
|
+
);
|
|
553
|
+
const shiftedMatch = {
|
|
554
|
+
...match.value,
|
|
555
|
+
configEnd:
|
|
556
|
+
match.value.configEnd + (nextSource.length - match.value.source.length),
|
|
557
|
+
source: nextSource,
|
|
558
|
+
};
|
|
559
|
+
nextSource = insertVersionEntry(
|
|
560
|
+
nextSource,
|
|
561
|
+
shiftedMatch,
|
|
562
|
+
buildVersionEntry(
|
|
563
|
+
currentVersion,
|
|
564
|
+
kind,
|
|
565
|
+
input.value,
|
|
566
|
+
output.value,
|
|
567
|
+
blaze?.value
|
|
568
|
+
)
|
|
569
|
+
);
|
|
570
|
+
const written = writeLifecycleSourceFile(match.value.filePath, nextSource);
|
|
571
|
+
if (written.isErr()) {
|
|
572
|
+
return Result.err(written.error);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return Result.ok({
|
|
576
|
+
file: match.value.filePath,
|
|
577
|
+
trailId: trail.id,
|
|
578
|
+
updated: true,
|
|
579
|
+
...(usesForkPlaceholder
|
|
580
|
+
? { warnings: missingResultImportWarning(match.value.source) }
|
|
581
|
+
: {}),
|
|
582
|
+
});
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
interface ForkVersionEntryRewrite {
|
|
586
|
+
readonly source: string;
|
|
587
|
+
readonly usedPlaceholder: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const forkVersionEntry = (
|
|
591
|
+
source: string,
|
|
592
|
+
match: TrailSourceMatch,
|
|
593
|
+
entry: PropertyMatch
|
|
594
|
+
): ForkVersionEntryRewrite => {
|
|
595
|
+
const entryMatch: TrailSourceMatch = {
|
|
596
|
+
...match,
|
|
597
|
+
configEnd: entry.valueEnd,
|
|
598
|
+
configStart: propertyObjectStart(source, entry),
|
|
599
|
+
};
|
|
600
|
+
const forkBlaze =
|
|
601
|
+
'blaze: async () => Result.err(new Error("TODO: implement fork blaze"))';
|
|
602
|
+
const transpose = findConfigProperty(entryMatch, 'transpose');
|
|
603
|
+
if (transpose !== undefined) {
|
|
604
|
+
const indent = lineIndentAt(source, transpose.start);
|
|
605
|
+
return {
|
|
606
|
+
source: replaceRange(
|
|
607
|
+
source,
|
|
608
|
+
transpose.start,
|
|
609
|
+
transpose.end,
|
|
610
|
+
`${indent}${forkBlaze},`
|
|
611
|
+
),
|
|
612
|
+
usedPlaceholder: true,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const blaze = findConfigProperty(entryMatch, 'blaze');
|
|
616
|
+
if (blaze !== undefined) {
|
|
617
|
+
return { source, usedPlaceholder: false };
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
source: replaceRange(
|
|
621
|
+
source,
|
|
622
|
+
propertyObjectCloseLineStart(source, entry),
|
|
623
|
+
propertyObjectCloseLineStart(source, entry),
|
|
624
|
+
` ${forkBlaze},\n`
|
|
625
|
+
),
|
|
626
|
+
usedPlaceholder: true,
|
|
627
|
+
};
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const statusBlock = (
|
|
631
|
+
status: LifecycleStatusKind,
|
|
632
|
+
input: {
|
|
633
|
+
readonly migration?: readonly string[] | undefined;
|
|
634
|
+
readonly note?: string | undefined;
|
|
635
|
+
readonly reason?: string | undefined;
|
|
636
|
+
readonly successor?: number | undefined;
|
|
637
|
+
}
|
|
638
|
+
): string => {
|
|
639
|
+
if (status === 'archived') {
|
|
640
|
+
return `status: { state: 'archived'${input.reason ? `, reason: ${literal(input.reason)}` : ''} }`;
|
|
641
|
+
}
|
|
642
|
+
const migration =
|
|
643
|
+
input.migration === undefined || input.migration.length === 0
|
|
644
|
+
? ''
|
|
645
|
+
: `, migration: [${input.migration.map(literal).join(', ')}]`;
|
|
646
|
+
const successor =
|
|
647
|
+
input.successor === undefined ? '' : `, successor: ${input.successor}`;
|
|
648
|
+
const note = input.note === undefined ? '' : `, note: ${literal(input.note)}`;
|
|
649
|
+
return `status: { state: 'deprecated'${successor}${migration}${note} }`;
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const findVersionEntryProperty = (
|
|
653
|
+
match: TrailSourceMatch,
|
|
654
|
+
version: number
|
|
655
|
+
): PropertyMatch | undefined => {
|
|
656
|
+
const versions = findConfigProperty(match, 'versions');
|
|
657
|
+
if (versions === undefined) {
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
const configStart = propertyObjectStart(match.source, versions);
|
|
661
|
+
const entry = findConfigProperty(
|
|
662
|
+
{
|
|
663
|
+
...match,
|
|
664
|
+
configEnd: versions.valueEnd,
|
|
665
|
+
configStart,
|
|
666
|
+
},
|
|
667
|
+
String(version)
|
|
668
|
+
);
|
|
669
|
+
if (entry === undefined) {
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
return entry;
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
export const setVersionStatusSource = (
|
|
676
|
+
rootDir: string,
|
|
677
|
+
target: ParsedVersionTarget,
|
|
678
|
+
status: LifecycleStatusKind,
|
|
679
|
+
input: {
|
|
680
|
+
readonly migration?: readonly string[] | undefined;
|
|
681
|
+
readonly note?: string | undefined;
|
|
682
|
+
readonly reason?: string | undefined;
|
|
683
|
+
readonly successor?: number | undefined;
|
|
684
|
+
}
|
|
685
|
+
): Result<LifecycleWriteResult, Error> => {
|
|
686
|
+
if (target.version === undefined) {
|
|
687
|
+
return Result.err(
|
|
688
|
+
new ValidationError(
|
|
689
|
+
'Deprecate requires an explicit trail.id@version target'
|
|
690
|
+
)
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const match = findTrailSource(rootDir, target.trailId);
|
|
694
|
+
if (match.isErr()) {
|
|
695
|
+
return match;
|
|
696
|
+
}
|
|
697
|
+
const entry = findVersionEntryProperty(match.value, target.version);
|
|
698
|
+
if (entry === undefined) {
|
|
699
|
+
return Result.err(
|
|
700
|
+
new ValidationError(
|
|
701
|
+
`Trail ${target.trailId} does not declare version ${target.version}`
|
|
702
|
+
)
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const fakeEntryMatch: TrailSourceMatch = {
|
|
706
|
+
...match.value,
|
|
707
|
+
configEnd: entry.valueEnd,
|
|
708
|
+
configStart: propertyObjectStart(match.value.source, entry),
|
|
709
|
+
};
|
|
710
|
+
const existing = findConfigProperty(fakeEntryMatch, 'status');
|
|
711
|
+
const nextStatus = statusBlock(status, input);
|
|
712
|
+
const nextSource =
|
|
713
|
+
existing === undefined
|
|
714
|
+
? replaceRange(
|
|
715
|
+
match.value.source,
|
|
716
|
+
propertyObjectCloseLineStart(match.value.source, entry),
|
|
717
|
+
propertyObjectCloseLineStart(match.value.source, entry),
|
|
718
|
+
` ${nextStatus},\n`
|
|
719
|
+
)
|
|
720
|
+
: replaceRange(
|
|
721
|
+
match.value.source,
|
|
722
|
+
existing.start,
|
|
723
|
+
existing.end,
|
|
724
|
+
`${lineIndentAt(match.value.source, existing.start)}${nextStatus},`
|
|
725
|
+
);
|
|
726
|
+
if (nextSource === match.value.source) {
|
|
727
|
+
return Result.ok({
|
|
728
|
+
file: match.value.filePath,
|
|
729
|
+
trailId: target.trailId,
|
|
730
|
+
updated: false,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
const written = writeLifecycleSourceFile(match.value.filePath, nextSource);
|
|
734
|
+
if (written.isErr()) {
|
|
735
|
+
return Result.err(written.error);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return Result.ok({
|
|
739
|
+
file: match.value.filePath,
|
|
740
|
+
trailId: target.trailId,
|
|
741
|
+
updated: true,
|
|
742
|
+
});
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
export const forkVersionEntrySource = (
|
|
746
|
+
rootDir: string,
|
|
747
|
+
target: ParsedVersionTarget
|
|
748
|
+
): Result<LifecycleWriteResult, Error> => {
|
|
749
|
+
if (target.version === undefined) {
|
|
750
|
+
return Result.err(
|
|
751
|
+
new ValidationError(
|
|
752
|
+
'Forking a historical entry requires trail.id@version'
|
|
753
|
+
)
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
const match = findTrailSource(rootDir, target.trailId);
|
|
757
|
+
if (match.isErr()) {
|
|
758
|
+
return match;
|
|
759
|
+
}
|
|
760
|
+
const entry = findVersionEntryProperty(match.value, target.version);
|
|
761
|
+
if (entry === undefined) {
|
|
762
|
+
return Result.err(
|
|
763
|
+
new ValidationError(
|
|
764
|
+
`Trail ${target.trailId} does not declare version ${target.version}`
|
|
765
|
+
)
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const rewrite = forkVersionEntry(match.value.source, match.value, entry);
|
|
769
|
+
const nextSource = rewrite.source;
|
|
770
|
+
if (nextSource === match.value.source) {
|
|
771
|
+
return Result.ok({
|
|
772
|
+
file: match.value.filePath,
|
|
773
|
+
trailId: target.trailId,
|
|
774
|
+
updated: false,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const written = writeLifecycleSourceFile(match.value.filePath, nextSource);
|
|
778
|
+
if (written.isErr()) {
|
|
779
|
+
return Result.err(written.error);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return Result.ok({
|
|
783
|
+
file: match.value.filePath,
|
|
784
|
+
trailId: target.trailId,
|
|
785
|
+
updated: true,
|
|
786
|
+
...(rewrite.usedPlaceholder
|
|
787
|
+
? { warnings: missingResultImportWarning(match.value.source) }
|
|
788
|
+
: {}),
|
|
789
|
+
});
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
export const parseLifecycleTarget = parseVersionTarget;
|
|
793
|
+
|
|
794
|
+
export const withLifecycleApp = async <T>(
|
|
795
|
+
input: LifecycleCommandInput,
|
|
796
|
+
cwd: string | undefined,
|
|
797
|
+
consume: (
|
|
798
|
+
app: Topo,
|
|
799
|
+
rootDir: string
|
|
800
|
+
) => Result<T, Error> | Promise<Result<T, Error>>
|
|
801
|
+
): Promise<Result<T, Error>> => {
|
|
802
|
+
const root = resolveTrailRootDir(input.rootDir, cwd);
|
|
803
|
+
if (root.isErr()) {
|
|
804
|
+
return root;
|
|
805
|
+
}
|
|
806
|
+
const lease = await tryLoadFreshAppLease(input.module, root.value);
|
|
807
|
+
if (lease.isErr()) {
|
|
808
|
+
return Result.err(lease.error);
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
return await consume(lease.value.app, root.value);
|
|
812
|
+
} finally {
|
|
813
|
+
lease.value.release();
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
export const findLifecycleTrail = (
|
|
818
|
+
app: Topo,
|
|
819
|
+
trailId: string
|
|
820
|
+
): Result<AnyTrail, Error> => {
|
|
821
|
+
const found = app.get(trailId);
|
|
822
|
+
return found === undefined
|
|
823
|
+
? Result.err(new ValidationError(`Trail not found: ${trailId}`))
|
|
824
|
+
: Result.ok(found as AnyTrail);
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
export interface DoctorForceDetail extends TopoGraphForceEntry {
|
|
828
|
+
readonly scope: 'entry' | 'graph';
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export interface DoctorSummary {
|
|
832
|
+
readonly archived: number;
|
|
833
|
+
readonly deprecated: number;
|
|
834
|
+
readonly forceDetails: readonly DoctorForceDetail[];
|
|
835
|
+
readonly forceEvents: number;
|
|
836
|
+
readonly mode: 'doctor';
|
|
837
|
+
readonly trails: number;
|
|
838
|
+
readonly versioned: number;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const doctorForceKey = (force: TopoGraphForceEntry): string =>
|
|
842
|
+
JSON.stringify([
|
|
843
|
+
force.kind,
|
|
844
|
+
force.id,
|
|
845
|
+
force.change,
|
|
846
|
+
force.detail,
|
|
847
|
+
force.reason,
|
|
848
|
+
force.severity,
|
|
849
|
+
force.source,
|
|
850
|
+
]);
|
|
851
|
+
|
|
852
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
853
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
854
|
+
|
|
855
|
+
const isDoctorForceEntry = (value: unknown): value is TopoGraphForceEntry =>
|
|
856
|
+
isRecord(value) &&
|
|
857
|
+
typeof value['acceptedAt'] === 'string' &&
|
|
858
|
+
(value['change'] === 'modified' || value['change'] === 'removed') &&
|
|
859
|
+
typeof value['detail'] === 'string' &&
|
|
860
|
+
typeof value['id'] === 'string' &&
|
|
861
|
+
(value['kind'] === 'contour' ||
|
|
862
|
+
value['kind'] === 'trail' ||
|
|
863
|
+
value['kind'] === 'signal' ||
|
|
864
|
+
value['kind'] === 'resource') &&
|
|
865
|
+
(value['reason'] === undefined || typeof value['reason'] === 'string') &&
|
|
866
|
+
value['severity'] === 'breaking' &&
|
|
867
|
+
value['source'] === 'trails compile --force';
|
|
868
|
+
|
|
869
|
+
const doctorForceEntries = (value: unknown): readonly TopoGraphForceEntry[] =>
|
|
870
|
+
Array.isArray(value) ? value.filter(isDoctorForceEntry) : [];
|
|
871
|
+
|
|
872
|
+
const pushDoctorForceDetail = (
|
|
873
|
+
details: DoctorForceDetail[],
|
|
874
|
+
seen: Set<string>,
|
|
875
|
+
force: TopoGraphForceEntry,
|
|
876
|
+
scope: DoctorForceDetail['scope']
|
|
877
|
+
): void => {
|
|
878
|
+
const key = doctorForceKey(force);
|
|
879
|
+
if (seen.has(key)) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
seen.add(key);
|
|
883
|
+
details.push({
|
|
884
|
+
acceptedAt: force.acceptedAt,
|
|
885
|
+
change: force.change,
|
|
886
|
+
detail: force.detail,
|
|
887
|
+
id: force.id,
|
|
888
|
+
kind: force.kind,
|
|
889
|
+
...(force.reason === undefined ? {} : { reason: force.reason }),
|
|
890
|
+
scope,
|
|
891
|
+
severity: force.severity,
|
|
892
|
+
source: force.source,
|
|
893
|
+
});
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const collectDoctorForceDetails = (
|
|
897
|
+
graph: TopoGraph
|
|
898
|
+
): readonly DoctorForceDetail[] => {
|
|
899
|
+
const details: DoctorForceDetail[] = [];
|
|
900
|
+
const seen = new Set<string>();
|
|
901
|
+
for (const entry of graph.entries) {
|
|
902
|
+
for (const force of doctorForceEntries(entry.forces)) {
|
|
903
|
+
pushDoctorForceDetail(details, seen, force, 'entry');
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
for (const force of doctorForceEntries(graph.forces)) {
|
|
907
|
+
pushDoctorForceDetail(details, seen, force, 'graph');
|
|
908
|
+
}
|
|
909
|
+
return details;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
export const deriveDoctorSummary = (
|
|
913
|
+
app: Topo,
|
|
914
|
+
options?: { readonly forceGraph?: TopoGraph | null | undefined }
|
|
915
|
+
): DoctorSummary => {
|
|
916
|
+
const graph = deriveTopoGraph(app);
|
|
917
|
+
const forceDetails = collectDoctorForceDetails(options?.forceGraph ?? graph);
|
|
918
|
+
let archived = 0;
|
|
919
|
+
let deprecated = 0;
|
|
920
|
+
let versioned = 0;
|
|
921
|
+
for (const entry of graph.entries) {
|
|
922
|
+
if (entry.kind !== 'trail') {
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (entry.version !== undefined) {
|
|
926
|
+
versioned += 1;
|
|
927
|
+
}
|
|
928
|
+
for (const version of Object.values(entry.versions ?? {})) {
|
|
929
|
+
if (version.status?.state === 'archived') {
|
|
930
|
+
archived += 1;
|
|
931
|
+
} else if (version.status?.state === 'deprecated') {
|
|
932
|
+
deprecated += 1;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
archived,
|
|
938
|
+
deprecated,
|
|
939
|
+
forceDetails,
|
|
940
|
+
forceEvents: forceDetails.length,
|
|
941
|
+
mode: 'doctor',
|
|
942
|
+
trails: app.list().length,
|
|
943
|
+
versioned,
|
|
944
|
+
};
|
|
945
|
+
};
|