@meantjs/profile 0.0.0-bootstrap → 0.1.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/CHANGELOG.md +37 -0
- package/README.md +248 -4
- package/bin/candid-profile.mjs +6 -0
- package/data/packs/icrc1.profile.json +275 -0
- package/data/packs/icrc2.profile.json +515 -0
- package/data/profile-v0.schema.json +966 -0
- package/data/profile-v0.validate.mjs +9 -0
- package/package.json +60 -4
- package/src/address.d.mts +30 -0
- package/src/address.mjs +62 -0
- package/src/analyze.mjs +298 -0
- package/src/browser.d.mts +208 -0
- package/src/browser.mjs +50 -0
- package/src/checks.mjs +81 -0
- package/src/cli.mjs +114 -0
- package/src/compile.mjs +304 -0
- package/src/doc-annotations.mjs +394 -0
- package/src/idl.mjs +10 -0
- package/src/iface.mjs +123 -0
- package/src/index.d.mts +70 -0
- package/src/index.mjs +15 -0
- package/src/pack-match.mjs +19 -0
- package/src/packs.mjs +25 -0
- package/src/resolve.mjs +114 -0
- package/src/resolved.mjs +60 -0
- package/src/schema-core.mjs +45 -0
- package/src/schema.mjs +23 -0
- package/src/served.mjs +59 -0
- package/src/sha256.mjs +59 -0
- package/src/subtype.mjs +271 -0
- package/src/tags.mjs +90 -0
- package/src/util.mjs +16 -0
- package/src/validate.mjs +49 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc annotations (spec section 9): the profile tags an author writes as doc comments in the `.did`, read by
|
|
3
|
+
* candid-core (doc attributes v1, `docs/doc-attributes.md` in the candid-core repository) and turned here into an
|
|
4
|
+
* `author` canister profile bound to the interface's `interface_id`.
|
|
5
|
+
*
|
|
6
|
+
* candid-core parses every `@name(key = value, ...)` doc line, refuses a malformed one and hands each out with the
|
|
7
|
+
* SourceInfo path of the item it documents; it gives no name a meaning. This module decides which names are profile
|
|
8
|
+
* tags and where each lands:
|
|
9
|
+
*
|
|
10
|
+
* method docs method tags at the method; value tags need `at` ("<relpath>" for the arguments,
|
|
11
|
+
* "-><relpath>" for the results); `describe` without `at` describes the method
|
|
12
|
+
* declaration docs `type:<Decl>`, plus `at` when given (a pathless address is the resolver's E_TYPE_NODE check)
|
|
13
|
+
* field docs `type:<Decl>.<path>` in a declaration, or a method address for a field of an inline actor
|
|
14
|
+
* type (the OQ-4 mapping, docs/proposals/w1-source-info.md), plus `at` when given
|
|
15
|
+
* prose `describe.help` of the item, except where it would change how a value is read (below)
|
|
16
|
+
*
|
|
17
|
+
* Only bare names that are profile tags are read. A dotted name (`@ui.hidden`) belongs to another tool and is left
|
|
18
|
+
* alone; so is any other bare name, with a warning when it is one or two letters from a tag (`@amout`).
|
|
19
|
+
*
|
|
20
|
+
* Prose never makes an integer typable. The tool layer reads an untagged integer with a `describe` as plain digits
|
|
21
|
+
* (D-044), so a comment such as "the amount in e8s" would turn a bare number into an agent input without a unit. Prose
|
|
22
|
+
* on an integer position is attached only when the same doc gives it a value tag that reads it (`amount`,
|
|
23
|
+
* `timestamp`, `duration`, `named`) or an explicit `@describe`; otherwise it is reported (I_DOC_PROSE).
|
|
24
|
+
*
|
|
25
|
+
* New diagnostic codes, proposed in docs/proposals/w1-doc-attributes.md: E_DOC_INPUT, E_DOC_SYNTAX, E_DOC_TARGET,
|
|
26
|
+
* E_DOC_VALUE, W_DOC_UNKNOWN_TAG, I_DOC_IGNORED, I_DOC_PROSE; candid-core's compile failures pass through as E_CANDID.
|
|
27
|
+
*/
|
|
28
|
+
import { parsePath } from "./address.mjs";
|
|
29
|
+
import { METHOD_TAGS, NUMERIC } from "./checks.mjs";
|
|
30
|
+
|
|
31
|
+
/** The doc attributes version this module reads. */
|
|
32
|
+
export const DOC_ATTRIBUTES_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
const TAGS = new Set([
|
|
35
|
+
"amount", "timestamp", "duration", "account", "subaccount", "bytes", "default", "describe", "text", "range", "ui",
|
|
36
|
+
"named", ...METHOD_TAGS,
|
|
37
|
+
]);
|
|
38
|
+
/** Value tags that decide how an integer is read: prose next to one of them cannot change the reading. */
|
|
39
|
+
const READING_TAGS = new Set(["amount", "timestamp", "duration", "named"]);
|
|
40
|
+
/** Arguments the schema holds as decimal strings (`int_string`): a number written there is kept as its digits. */
|
|
41
|
+
const STRING_INTEGERS = new Set(["range.min", "range.max"]);
|
|
42
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
43
|
+
// the schema's relpath, without the leading "->"
|
|
44
|
+
const RELPATH = /^(\((0|[1-9][0-9]*)\))?(\.?([A-Za-z_][A-Za-z0-9_]*|#(0|[1-9][0-9]{0,9}))|\[\])?((\.([A-Za-z_][A-Za-z0-9_]*|#(0|[1-9][0-9]{0,9})))|\[\])*$/;
|
|
45
|
+
// one step of a SourceInfo occurrence path after its origin (OQ-4)
|
|
46
|
+
const STEP = /\.(type|inner|function|fields|args|results|methods|init)(?:\[(0|[1-9][0-9]*)\])?/y;
|
|
47
|
+
|
|
48
|
+
class DocError extends Error {
|
|
49
|
+
constructor(code, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An `author` canister profile from the doc comments of a `.did`.
|
|
57
|
+
*
|
|
58
|
+
* @param response what `didToDocAttributes(sources)` of `@candid-core/cli` returns: `{ ok: true, contract,
|
|
59
|
+
* source_info, doc_attributes }`, or `{ ok: false, diagnostics }`, which is passed through
|
|
60
|
+
* @param opts.name the profile's name (default "doc-annotations")
|
|
61
|
+
* @param opts.origin "author" (default) or "third_party"
|
|
62
|
+
* @param opts.language default "en"
|
|
63
|
+
* @param opts.canisterId bind the profile to one canister too
|
|
64
|
+
* @returns { ok, doc, diagnostics }: `doc` is the profile, or null when the comments hold no tag (or the response is
|
|
65
|
+
* not readable); `ok` is false when any diagnostic is an error. Run the profile through `resolveService` as
|
|
66
|
+
* usual: this module places tags, the resolver checks them.
|
|
67
|
+
*/
|
|
68
|
+
export function profileFromDocAttributes(response, opts = {}) {
|
|
69
|
+
const diagnostics = [];
|
|
70
|
+
const read = readResponse(response, diagnostics);
|
|
71
|
+
if (read === null) return { ok: false, doc: null, diagnostics };
|
|
72
|
+
const graph = new Graph(read.contract, read.sourceInfo);
|
|
73
|
+
const annotations = [];
|
|
74
|
+
for (const item of read.items) placeItem(graph, item, annotations, diagnostics);
|
|
75
|
+
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
76
|
+
if (annotations.length === 0) {
|
|
77
|
+
if (ok) diagnostics.push({ severity: "info", code: "I_DOC_IGNORED", at: read.entry, message: "the doc comments hold no profile tag, so there is no profile" });
|
|
78
|
+
return { ok, doc: null, diagnostics };
|
|
79
|
+
}
|
|
80
|
+
const binds = { interface_id: read.contract.identities.interface };
|
|
81
|
+
if (opts.canisterId !== undefined) binds.canister_id = opts.canisterId;
|
|
82
|
+
const doc = {
|
|
83
|
+
format: "candid-profile",
|
|
84
|
+
format_version: 0,
|
|
85
|
+
kind: "canister",
|
|
86
|
+
name: opts.name ?? "doc-annotations",
|
|
87
|
+
origin: opts.origin ?? "author",
|
|
88
|
+
language: opts.language ?? "en",
|
|
89
|
+
description: `From the doc comments of ${read.entry}.`,
|
|
90
|
+
binds,
|
|
91
|
+
annotations,
|
|
92
|
+
};
|
|
93
|
+
return { ok, doc, diagnostics };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readResponse(response, diagnostics) {
|
|
97
|
+
const fail = (message) => {
|
|
98
|
+
diagnostics.push({ severity: "error", code: "E_DOC_INPUT", at: "service.did", message });
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
if (response === null || typeof response !== "object" || Array.isArray(response)) return fail("not a didToDocAttributes response: not a JSON object");
|
|
102
|
+
if (response.ok === false) {
|
|
103
|
+
const list = Array.isArray(response.diagnostics) ? response.diagnostics : [];
|
|
104
|
+
for (const d of list) {
|
|
105
|
+
const syntax = typeof d?.code === "string" && (d.code.startsWith("doc_attribute_") || d.resource_limit?.resource === "source_nesting");
|
|
106
|
+
diagnostics.push({ severity: "error", code: syntax ? "E_DOC_SYNTAX" : "E_CANDID", at: d?.path ?? d?.span?.source_name ?? "service.did", message: `${d?.code}: ${d?.message}` });
|
|
107
|
+
}
|
|
108
|
+
if (list.length === 0) fail("the response failed and gives no diagnostics");
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const { contract, source_info: sourceInfo, doc_attributes: attrs } = response;
|
|
112
|
+
if (response.ok !== true || !contract || !sourceInfo || !attrs) return fail("not a didToDocAttributes response: it needs ok, contract, source_info and doc_attributes (is @candid-core/cli older than doc attributes?)");
|
|
113
|
+
if (attrs.doc_attributes_version !== DOC_ATTRIBUTES_VERSION) return fail(`doc_attributes_version is ${JSON.stringify(attrs.doc_attributes_version)}; this compiler reads version ${DOC_ATTRIBUTES_VERSION}`);
|
|
114
|
+
const id = contract.identities?.interface;
|
|
115
|
+
if (typeof id !== "string" || !/^candid-core:interface:v1:sha256:[0-9a-f]{64}$/.test(id)) return fail("the contract has no identities.interface");
|
|
116
|
+
if (sourceInfo.contract_id !== contract.identities.contract) return fail("source_info belongs to another contract");
|
|
117
|
+
if (!Array.isArray(attrs.items) || !Array.isArray(sourceInfo.methods) || !Array.isArray(sourceInfo.field_labels)) return fail("doc_attributes.items, source_info.methods and source_info.field_labels must be arrays");
|
|
118
|
+
const entry = sourceInfo.sources?.[0]?.name ?? "the .did";
|
|
119
|
+
return { contract, sourceInfo, items: attrs.items, entry };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The Contract and SourceInfo lookups placement needs. */
|
|
123
|
+
class Graph {
|
|
124
|
+
constructor(contract, sourceInfo) {
|
|
125
|
+
this.types = contract.types;
|
|
126
|
+
this.decls = new Map(contract.declarations.map((d) => [d.name, d.type]));
|
|
127
|
+
const actor = contract.actor;
|
|
128
|
+
this.service = actor?.kind === "service" ? actor.service : actor?.kind === "class" ? this.types[actor.class].service : null;
|
|
129
|
+
this.methods = new Map();
|
|
130
|
+
for (const m of this.service === null ? [] : this.types[this.service].methods) this.methods.set(m.name, this.types[m.function]);
|
|
131
|
+
// trap 1: SourceInfo's method index is not the Contract's; methods are found by their SourceInfo path
|
|
132
|
+
this.methodEntries = sourceInfo.methods;
|
|
133
|
+
this.fieldLabels = new Map(sourceInfo.field_labels.map((f) => [f.path, f]));
|
|
134
|
+
}
|
|
135
|
+
unwrapOpt(ref) {
|
|
136
|
+
while (this.types[ref]?.kind === "opt") ref = this.types[ref].inner;
|
|
137
|
+
return ref;
|
|
138
|
+
}
|
|
139
|
+
kindOf(ref) {
|
|
140
|
+
const n = this.types[this.unwrapOpt(ref)];
|
|
141
|
+
return n?.kind === "primitive" ? n.primitive : n?.kind;
|
|
142
|
+
}
|
|
143
|
+
/** The SourceInfo method entry an item path starts with: equal to it, or followed by a step. */
|
|
144
|
+
methodEntryOf(path) {
|
|
145
|
+
let best = null;
|
|
146
|
+
for (const m of this.methodEntries) {
|
|
147
|
+
if ((path === m.path || path.startsWith(`${m.path}.`)) && (best === null || m.path.length > best.path.length)) best = m;
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
/** The canister method an entry describes, or a DocError saying why it is not one. */
|
|
152
|
+
canisterMethod(entry) {
|
|
153
|
+
if (entry.service !== this.service) throw new DocError("E_DOC_TARGET", `${entry.name} is a method of a service type that is not this canister's (a callback or a reference), so a profile cannot address it`);
|
|
154
|
+
if (!IDENT.test(entry.name)) throw new DocError("E_DOC_TARGET", `method "${entry.name}" is not identifier-shaped; v0 addresses cannot name it (OQ-3)`);
|
|
155
|
+
return { name: entry.name, func: this.methods.get(entry.name) };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Where an item's docs land: { kind: "method", method, func } | { kind: "value", address, ref, method?, func? }
|
|
159
|
+
* | { kind: "actor" }. Throws a DocError for an item no address reaches.
|
|
160
|
+
*/
|
|
161
|
+
place(item) {
|
|
162
|
+
if (item.kind === "actor") return { kind: "actor" };
|
|
163
|
+
const entry = this.methodEntryOf(item.path);
|
|
164
|
+
if (entry !== null) {
|
|
165
|
+
const { name, func } = this.canisterMethod(entry);
|
|
166
|
+
if (item.path === entry.path) return { kind: "method", method: name, func };
|
|
167
|
+
const steps = readSteps(item.path.slice(entry.path.length));
|
|
168
|
+
const side = steps[0]?.step === "function" ? steps[1] : undefined;
|
|
169
|
+
if (side === undefined || (side.step !== "args" && side.step !== "results") || side.index === undefined) throw new DocError("E_DOC_TARGET", `cannot read the occurrence path ${item.path}`);
|
|
170
|
+
const tuple = side.step === "args" ? func.args : func.results;
|
|
171
|
+
const base = `${name}${side.step === "args" ? "" : "->"}(${side.index})`;
|
|
172
|
+
return { kind: "value", ...this.walk(base, tuple[side.index], steps.slice(2), item.path), method: name, func };
|
|
173
|
+
}
|
|
174
|
+
const decl = /^type:([A-Za-z_][A-Za-z0-9_]*)/.exec(item.path);
|
|
175
|
+
if (decl === null || !this.decls.has(decl[1])) throw new DocError("E_DOC_TARGET", `cannot read the occurrence path ${item.path}`);
|
|
176
|
+
const rest = item.path.slice(decl[0].length);
|
|
177
|
+
return { kind: "value", ...this.walk(`type:${decl[1]}`, this.decls.get(decl[1]), readSteps(rest), item.path) };
|
|
178
|
+
}
|
|
179
|
+
/** Follow occurrence steps from a ref, writing the address: `.label` per field, nothing for opt, `[]` for vec. */
|
|
180
|
+
walk(address, ref, steps, path) {
|
|
181
|
+
let prefix = path.slice(0, path.length - steps.reduce((n, s) => n + s.text.length, 0));
|
|
182
|
+
for (const s of steps) {
|
|
183
|
+
prefix += s.text;
|
|
184
|
+
const node = this.types[ref];
|
|
185
|
+
if (s.step === "type") continue;
|
|
186
|
+
if (s.step === "inner" && (node?.kind === "opt" || node?.kind === "vec")) {
|
|
187
|
+
if (node.kind === "vec") address += "[]";
|
|
188
|
+
ref = node.inner;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (s.step === "fields" && (node?.kind === "record" || node?.kind === "variant") && s.index < node.fields.length) {
|
|
192
|
+
const field = node.fields[s.index];
|
|
193
|
+
const entry = this.fieldLabels.get(prefix);
|
|
194
|
+
if (entry !== undefined && (entry.container !== ref || entry.id !== field.id)) throw new DocError("E_DOC_TARGET", `SourceInfo and the Contract disagree at ${prefix}`);
|
|
195
|
+
const name = entry?.label?.kind === "named" ? entry.label.name : null;
|
|
196
|
+
address += name !== null && IDENT.test(name) ? `.${name}` : `.#${field.id}`;
|
|
197
|
+
ref = field.type;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// trap 6: a position inside a func or service value has no v0 address
|
|
201
|
+
if (s.step === "args" || s.step === "results" || s.step === "methods" || s.step === "function") throw new DocError("E_DOC_TARGET", `${path} is inside a func or service value, which v0 addresses cannot reach`);
|
|
202
|
+
throw new DocError("E_DOC_TARGET", `cannot follow ${path} through the Contract`);
|
|
203
|
+
}
|
|
204
|
+
return { address, ref };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readSteps(text) {
|
|
209
|
+
const steps = [];
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (i < text.length) {
|
|
212
|
+
STEP.lastIndex = i;
|
|
213
|
+
const m = STEP.exec(text);
|
|
214
|
+
if (m === null) throw new DocError("E_DOC_TARGET", `cannot read the occurrence path step "${text.slice(i)}"`);
|
|
215
|
+
steps.push({ step: m[1], index: m[2] === undefined ? undefined : Number(m[2]), text: m[0] });
|
|
216
|
+
i = STEP.lastIndex;
|
|
217
|
+
}
|
|
218
|
+
return steps;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function placeItem(graph, item, annotations, diagnostics) {
|
|
222
|
+
// `at` is the item's address once it has one, else its SourceInfo path; `path` and `source` say where the comment is
|
|
223
|
+
let at = item.path;
|
|
224
|
+
const where = { path: item.path, ...(item.origin?.source ? { source: item.origin.source } : {}) };
|
|
225
|
+
const diag = (severity, code, line, name, message) => diagnostics.push({
|
|
226
|
+
severity, code, at, message: line === null ? message : `doc line ${line + 1}: @${name}: ${message}`, ...where,
|
|
227
|
+
});
|
|
228
|
+
let place;
|
|
229
|
+
try {
|
|
230
|
+
place = graph.place(item);
|
|
231
|
+
at = place.kind === "method" ? place.method : place.kind === "value" ? place.address : "actor";
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (!(e instanceof DocError)) throw e;
|
|
234
|
+
for (const a of item.attributes) {
|
|
235
|
+
if (TAGS.has(a.name)) diag("error", e.code, a.doc_line, a.name, e.message);
|
|
236
|
+
}
|
|
237
|
+
if (prose(item) !== null) diag("info", "I_DOC_PROSE", null, null, `the prose is not attached: ${e.message}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const own = []; // this item's annotations, for the prose rule
|
|
241
|
+
for (const a of item.attributes) {
|
|
242
|
+
if (!TAGS.has(a.name)) {
|
|
243
|
+
if (a.name.includes(".")) diag("info", "I_DOC_IGNORED", a.doc_line, a.name, "a dotted name belongs to another tool; not a profile tag");
|
|
244
|
+
else {
|
|
245
|
+
const near = nearestTag(a.name);
|
|
246
|
+
if (near !== null) diag("warning", "W_DOC_UNKNOWN_TAG", a.doc_line, a.name, `not a profile tag; did you mean @${near}?`);
|
|
247
|
+
else diag("info", "I_DOC_IGNORED", a.doc_line, a.name, "not a profile tag; left for other tools");
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const args = {};
|
|
253
|
+
let target;
|
|
254
|
+
for (const { key, value } of a.args) {
|
|
255
|
+
if (key === "at") {
|
|
256
|
+
if (value?.kind !== "text") throw new DocError("E_DOC_VALUE", "`at` is a path written as text, for example at = \"amount\" or at = \"->(0).Ok\"");
|
|
257
|
+
target = value.value;
|
|
258
|
+
} else args[key] = jsonValue(value, `${a.name}.${key}`);
|
|
259
|
+
}
|
|
260
|
+
const address = targetOf(graph, place, a.name, target);
|
|
261
|
+
const annotation = { at: address, tag: a.name, args };
|
|
262
|
+
annotations.push(annotation);
|
|
263
|
+
own.push({ annotation, explicitAt: target !== undefined });
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (!(e instanceof DocError)) throw e;
|
|
266
|
+
diag("error", e.code, a.doc_line, a.name, e.message);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const help = prose(item);
|
|
270
|
+
if (help === null) return;
|
|
271
|
+
const skip = (why) => diag("info", "I_DOC_PROSE", null, null, `the prose is not attached: ${why}`);
|
|
272
|
+
if (place.kind === "actor") return skip("no tag describes the service as a whole");
|
|
273
|
+
const self = place.kind === "method" ? place.method : place.address;
|
|
274
|
+
const describe = own.find((o) => o.annotation.tag === "describe" && !o.explicitAt);
|
|
275
|
+
if (describe !== undefined) {
|
|
276
|
+
if (describe.annotation.args.help !== undefined) return skip("@describe already gives help");
|
|
277
|
+
describe.annotation.args.help = help;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (place.kind === "value") {
|
|
281
|
+
const kind = graph.kindOf(place.ref);
|
|
282
|
+
if (item.kind === "declaration" && kind !== "record" && kind !== "variant") {
|
|
283
|
+
return skip(`type:${item.name} is a ${kind}, and only a record or a variant can be described as a whole (spec 3.2 rule 8); describe the fields or methods that use it`);
|
|
284
|
+
}
|
|
285
|
+
const read = own.some((o) => !o.explicitAt && READING_TAGS.has(o.annotation.tag));
|
|
286
|
+
if (NUMERIC.has(kind) && !read) {
|
|
287
|
+
return skip("as describe.help it would make this untagged integer an agent input of plain digits (D-044); add a value tag such as @amount, or an explicit @describe if plain digits are right");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
annotations.push({ at: self, tag: "describe", args: { help } });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The address an attribute lands on, or a DocError. */
|
|
294
|
+
function targetOf(graph, place, tag, target) {
|
|
295
|
+
const methodTag = METHOD_TAGS.has(tag);
|
|
296
|
+
if (place.kind === "actor") throw new DocError("E_DOC_TARGET", "no tag applies to the service as a whole; put it on a method, a declaration or a field");
|
|
297
|
+
if (place.kind === "method") {
|
|
298
|
+
if (methodTag) {
|
|
299
|
+
if (target !== undefined) throw new DocError("E_DOC_TARGET", "a method tag applies to the method itself and takes no `at`");
|
|
300
|
+
return place.method;
|
|
301
|
+
}
|
|
302
|
+
if (target === undefined) {
|
|
303
|
+
if (tag === "describe") return place.method;
|
|
304
|
+
throw new DocError("E_DOC_TARGET", "a value tag on a method needs `at`: at = \"<path>\" for an argument, at = \"-><path>\" for a result");
|
|
305
|
+
}
|
|
306
|
+
const results = target.startsWith("->");
|
|
307
|
+
const rel = results ? target.slice(2) : target;
|
|
308
|
+
if (rel === "" || !RELPATH.test(rel)) throw new DocError("E_DOC_TARGET", `at = ${JSON.stringify(target)} is not a relative path (spec 3.1 relpath)`);
|
|
309
|
+
const m = /^\((\d+)\)/.exec(rel);
|
|
310
|
+
const tuple = results ? place.func.results : place.func.args;
|
|
311
|
+
if (m === null && tuple.length !== 1) throw new DocError("E_DOC_TARGET", `${place.method} has ${tuple.length} ${results ? "results" : "arguments"}, so at must start with (i) (spec 3.2 rule 9)`);
|
|
312
|
+
return `${place.method}${results ? "->" : ""}${m === null ? "(0)" : m[0]}${tail(m === null ? rel : rel.slice(m[0].length))}`;
|
|
313
|
+
}
|
|
314
|
+
if (methodTag) throw new DocError("E_DOC_TARGET", "a method tag belongs on a method's doc comment");
|
|
315
|
+
if (target === undefined) return place.address;
|
|
316
|
+
if (target.startsWith("->") || target.startsWith("(") || !RELPATH.test(target)) {
|
|
317
|
+
throw new DocError("E_DOC_TARGET", `at = ${JSON.stringify(target)}: on a field or a declaration, at is a path inside it, such as "owner" or "[].amount"`);
|
|
318
|
+
}
|
|
319
|
+
return `${place.address}${tail(target)}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A relpath tail as address segments: "amount" -> ".amount", ".a[]" and "[]" stay. */
|
|
323
|
+
function tail(rel) {
|
|
324
|
+
if (rel === "") return "";
|
|
325
|
+
const text = rel.startsWith(".") || rel.startsWith("[") ? rel : `.${rel}`;
|
|
326
|
+
if (parsePath(text) === null) throw new DocError("E_DOC_TARGET", `cannot read the path ${JSON.stringify(rel)}`);
|
|
327
|
+
return text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A doc value as the JSON a profile holds (spec section 4), or a DocError. */
|
|
331
|
+
function jsonValue(v, where) {
|
|
332
|
+
switch (v?.kind) {
|
|
333
|
+
case "text":
|
|
334
|
+
case "ident":
|
|
335
|
+
return v.value;
|
|
336
|
+
case "bool":
|
|
337
|
+
return v.value;
|
|
338
|
+
case "number": {
|
|
339
|
+
// no silent rounding (spec 5.3, D-006): a fraction is refused, an integer is kept exact
|
|
340
|
+
if (!/^-?(0|[1-9][0-9]*)$/.test(v.value)) throw new DocError("E_DOC_VALUE", `${v.value} is not a whole number; no tag argument takes a fraction`);
|
|
341
|
+
const n = BigInt(v.value);
|
|
342
|
+
if (STRING_INTEGERS.has(where)) return n.toString();
|
|
343
|
+
return n >= BigInt(Number.MIN_SAFE_INTEGER) && n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : n.toString();
|
|
344
|
+
}
|
|
345
|
+
case "call": {
|
|
346
|
+
if (v.name === "query") {
|
|
347
|
+
const [m] = v.args;
|
|
348
|
+
if (v.args.length !== 1 || (m.kind !== "ident" && m.kind !== "text") || !IDENT.test(m.value)) throw new DocError("E_DOC_VALUE", "query(...) takes one method name, for example query(decimals)");
|
|
349
|
+
return { from: `query:${m.value}` };
|
|
350
|
+
}
|
|
351
|
+
if (v.name === "now" || v.name === "caller") {
|
|
352
|
+
if (v.args.length !== 0) throw new DocError("E_DOC_VALUE", `${v.name}() takes no arguments`);
|
|
353
|
+
return { from: v.name };
|
|
354
|
+
}
|
|
355
|
+
throw new DocError("E_DOC_VALUE", `${v.name}(...) is not a dynamic value; the dynamic values are query(<method>), now() and caller() (spec 4)`);
|
|
356
|
+
}
|
|
357
|
+
case "list":
|
|
358
|
+
return v.items.map((x) => jsonValue(x, where));
|
|
359
|
+
case "record":
|
|
360
|
+
return Object.fromEntries(v.fields.map((f) => [f.key, jsonValue(f.value, `${where}.${f.key}`)]));
|
|
361
|
+
default:
|
|
362
|
+
throw new DocError("E_DOC_VALUE", `a value of kind ${JSON.stringify(v?.kind)} is not one this compiler reads`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** An item's prose as describe.help: lines of a paragraph joined by spaces, paragraphs by a blank line. */
|
|
367
|
+
function prose(item) {
|
|
368
|
+
const paragraphs = [];
|
|
369
|
+
let current = [];
|
|
370
|
+
for (const line of item.prose ?? []) {
|
|
371
|
+
if (line === "") {
|
|
372
|
+
if (current.length) paragraphs.push(current.join(" "));
|
|
373
|
+
current = [];
|
|
374
|
+
} else current.push(line);
|
|
375
|
+
}
|
|
376
|
+
if (current.length) paragraphs.push(current.join(" "));
|
|
377
|
+
return paragraphs.length ? paragraphs.join("\n\n") : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The profile tag one or two edits from a name, if exactly one is. */
|
|
381
|
+
function nearestTag(name) {
|
|
382
|
+
const near = [...TAGS].filter((t) => editDistance(t, name) <= 2 && Math.min(t.length, name.length) > 2);
|
|
383
|
+
return near.length === 1 ? near[0] : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function editDistance(a, b) {
|
|
387
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
388
|
+
for (let i = 1; i <= a.length; i++) {
|
|
389
|
+
const row = [i];
|
|
390
|
+
for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
391
|
+
prev = row;
|
|
392
|
+
}
|
|
393
|
+
return prev[b.length];
|
|
394
|
+
}
|
package/src/idl.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Candid label ids. Pure and dependency-free: the browser-safe `@meantjs/profile/address` entry imports it.
|
|
2
|
+
|
|
3
|
+
export const U32_MAX = 4294967295;
|
|
4
|
+
|
|
5
|
+
/** Candid field/method id: the hash of the label (Candid spec, "Hash of field names"). */
|
|
6
|
+
export function idlHash(label) {
|
|
7
|
+
let h = 0;
|
|
8
|
+
for (const b of new TextEncoder().encode(label)) h = (Math.imul(h, 223) + b) >>> 0;
|
|
9
|
+
return h;
|
|
10
|
+
}
|
package/src/iface.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A compiled Candid interface: the candid-core Contract graph plus the field-name table, with the lookups the
|
|
3
|
+
* resolver needs (address resolution, opt-transparent walks, shape tests). Moved verbatim from scripts/validate.mjs;
|
|
4
|
+
* the only additions are `label` (used in subtype diagnostics) and `interfaceId`.
|
|
5
|
+
*/
|
|
6
|
+
import { idlHash } from "./util.mjs";
|
|
7
|
+
import { parseRelPath, methodKey, edgeKey } from "./address.mjs";
|
|
8
|
+
|
|
9
|
+
export class Iface {
|
|
10
|
+
/** @param envelope a candid-core ContractEnvelope; @param label how diagnostics name this side ("canister", "pack fragment") */
|
|
11
|
+
constructor(envelope, label = "interface") {
|
|
12
|
+
this.label = label;
|
|
13
|
+
this.contract = envelope.contract;
|
|
14
|
+
this.types = this.contract.types;
|
|
15
|
+
this.names = new Map();
|
|
16
|
+
for (const [c, id, name] of envelope.extensions?.["org.candid-core.field-names/v1"] ?? []) {
|
|
17
|
+
this.names.set(`${c}:${id}`, name);
|
|
18
|
+
}
|
|
19
|
+
this.decls = new Map(this.contract.declarations.map((d) => [d.name, d.type]));
|
|
20
|
+
// Interning: structurally equal declarations share one node. A type: address on one reaches the others (spec §3.2 rule 7).
|
|
21
|
+
this.aliases = new Map();
|
|
22
|
+
for (const d of this.contract.declarations) {
|
|
23
|
+
if (!this.aliases.has(d.type)) this.aliases.set(d.type, []);
|
|
24
|
+
this.aliases.get(d.type).push(d.name);
|
|
25
|
+
}
|
|
26
|
+
this.methods = new Map();
|
|
27
|
+
const actor = this.contract.actor;
|
|
28
|
+
const svcRef = actor?.kind === "service" ? actor.service : actor?.kind === "class" ? this.types[actor.class].service : null;
|
|
29
|
+
if (svcRef !== null && svcRef !== undefined) {
|
|
30
|
+
for (const m of this.types[svcRef].methods) {
|
|
31
|
+
const f = this.types[m.function];
|
|
32
|
+
this.methods.set(m.name, { mode: f.mode, args: f.args, results: f.results });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
get interfaceId() { return this.contract.identities.interface ?? null; }
|
|
37
|
+
node(ref) { return this.types[ref]; }
|
|
38
|
+
unwrapOpt(ref) {
|
|
39
|
+
let optional = false;
|
|
40
|
+
while (this.types[ref].kind === "opt") { optional = true; ref = this.types[ref].inner; }
|
|
41
|
+
return { ref, optional };
|
|
42
|
+
}
|
|
43
|
+
fieldName(container, id) { return this.names.get(`${container}:${id}`) ?? `#${id}`; }
|
|
44
|
+
isBlob(ref) { const n = this.types[ref]; return n.kind === "vec" && this.types[n.inner].kind === "primitive" && this.types[n.inner].primitive === "nat8"; }
|
|
45
|
+
prim(ref) { const n = this.types[ref]; return n.kind === "primitive" ? n.primitive : null; }
|
|
46
|
+
isAccount(ref) {
|
|
47
|
+
const n = this.types[ref];
|
|
48
|
+
if (n.kind !== "record" || n.fields.length !== 2) return false;
|
|
49
|
+
const owner = n.fields.find((f) => f.id === idlHash("owner"));
|
|
50
|
+
const sub = n.fields.find((f) => f.id === idlHash("subaccount"));
|
|
51
|
+
if (!owner || !sub || this.prim(owner.type) !== "principal") return false;
|
|
52
|
+
const s = this.types[sub.type];
|
|
53
|
+
return s.kind === "opt" && this.isBlob(s.inner);
|
|
54
|
+
}
|
|
55
|
+
describeType(ref) {
|
|
56
|
+
const { ref: r, optional } = this.unwrapOpt(ref);
|
|
57
|
+
const n = this.types[r];
|
|
58
|
+
const base = n.kind === "primitive" ? n.primitive : this.isBlob(r) ? "blob" : n.kind;
|
|
59
|
+
return optional ? `opt ${base}` : base;
|
|
60
|
+
}
|
|
61
|
+
/** Walk segments from a start ref. Returns {ref, parent} or {error}. */
|
|
62
|
+
walk(start, segs) {
|
|
63
|
+
let cur = start;
|
|
64
|
+
let parent = null;
|
|
65
|
+
for (const s of segs) {
|
|
66
|
+
cur = this.unwrapOpt(cur).ref;
|
|
67
|
+
const n = this.types[cur];
|
|
68
|
+
if (s.kind === "elem") {
|
|
69
|
+
if (n.kind !== "vec") return { error: `"[]" applied to ${this.describeType(cur)}` };
|
|
70
|
+
parent = { container: cur, kind: "vec" };
|
|
71
|
+
cur = n.inner;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (n.kind !== "record" && n.kind !== "variant") return { error: `".${s.label}" applied to ${this.describeType(cur)}` };
|
|
75
|
+
const f = n.fields.find((x) => x.id === s.id);
|
|
76
|
+
if (!f) return { error: `no ${n.kind === "record" ? "field" : "case"} "${s.label}" in ${n.kind}` };
|
|
77
|
+
parent = { container: cur, kind: n.kind, id: s.id };
|
|
78
|
+
cur = f.type;
|
|
79
|
+
}
|
|
80
|
+
return { ref: cur, parent };
|
|
81
|
+
}
|
|
82
|
+
/** Resolve an address. Returns {ref, parent, key} | {method, key} | {error}. */
|
|
83
|
+
resolve(addr) {
|
|
84
|
+
if (addr.kind === "type") {
|
|
85
|
+
const start = this.decls.get(addr.decl);
|
|
86
|
+
if (start === undefined) return { error: `no declaration "${addr.decl}"` };
|
|
87
|
+
const r = this.walk(start, addr.segs);
|
|
88
|
+
if (r.error) return r;
|
|
89
|
+
if (!r.parent) {
|
|
90
|
+
// Pathless: the whole declared type. Only records and variants, or a primitive alias would tag every such value (spec §3.2 rule 8).
|
|
91
|
+
const kind = this.types[this.unwrapOpt(r.ref).ref].kind;
|
|
92
|
+
if (kind !== "record" && kind !== "variant") {
|
|
93
|
+
const t = this.describeType(r.ref);
|
|
94
|
+
return { error: `pathless type: address on "${addr.decl}" (${t}) would tag every ${t} in the interface; use a field path or a method address`, code: "E_TYPE_NODE" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ...r, key: r.parent ? edgeKey(r.parent) : `n:${this.unwrapOpt(r.ref).ref}` };
|
|
98
|
+
}
|
|
99
|
+
const m = this.methods.get(addr.method);
|
|
100
|
+
if (!m) return { error: `no method "${addr.method}"` };
|
|
101
|
+
if (addr.kind === "method") return { method: m, key: `m:${addr.method}` };
|
|
102
|
+
const tuple = addr.kind === "arg" ? m.args : m.results;
|
|
103
|
+
if (addr.index >= tuple.length) return { error: `${addr.kind} index ${addr.index} out of range (${tuple.length})` };
|
|
104
|
+
const r = this.walk(tuple[addr.index], addr.segs);
|
|
105
|
+
if (r.error) return r;
|
|
106
|
+
return { ...r, key: methodKey(addr.method, addr.kind === "arg" ? "arg" : "res", addr.index, addr.segs) };
|
|
107
|
+
}
|
|
108
|
+
resolveRel(methodName, side, rel) {
|
|
109
|
+
const m = this.methods.get(methodName);
|
|
110
|
+
const p = parseRelPath(rel);
|
|
111
|
+
if (!p) return { error: `bad relative path "${rel}"` };
|
|
112
|
+
const tuple = side === "args" ? m.args : m.results;
|
|
113
|
+
let index = p.index;
|
|
114
|
+
if (index === null) {
|
|
115
|
+
if (tuple.length !== 1) return { error: `relative path "${rel}" must start with (i): ${side} tuple has ${tuple.length} elements` };
|
|
116
|
+
index = 0;
|
|
117
|
+
}
|
|
118
|
+
if (index >= tuple.length) return { error: `index ${index} out of range` };
|
|
119
|
+
const r = this.walk(tuple[index], p.segs);
|
|
120
|
+
if (r.error) return r;
|
|
121
|
+
return { ...r, key: methodKey(methodName, side === "args" ? "arg" : "res", index, p.segs) };
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/index.d.mts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Types for @meantjs/profile (the Node entry). Hand-written; the source is plain ES modules (src/*.mjs). The
|
|
2
|
+
// declarations it shares with the browser entry live in browser.d.mts. test/types.test.mjs keeps the declared names
|
|
3
|
+
// equal to the runtime exports, and `npm run types` compiles a consumer against them under strict TypeScript.
|
|
4
|
+
import type { Pack, SchemaValidator } from "./browser.mjs";
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
Address,
|
|
8
|
+
ContractNode,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
DocDiagnostic,
|
|
11
|
+
Pack,
|
|
12
|
+
PathSegment,
|
|
13
|
+
Position,
|
|
14
|
+
ProfileDocument,
|
|
15
|
+
ProfileStatus,
|
|
16
|
+
ResolveOptions,
|
|
17
|
+
ResolveResult,
|
|
18
|
+
ResolvedForm,
|
|
19
|
+
SchemaValidator,
|
|
20
|
+
ValidateResult,
|
|
21
|
+
} from "./browser.mjs";
|
|
22
|
+
export {
|
|
23
|
+
DOC_ATTRIBUTES_VERSION,
|
|
24
|
+
Iface,
|
|
25
|
+
LAYERS,
|
|
26
|
+
LayeredTags,
|
|
27
|
+
SubtypeChecker,
|
|
28
|
+
TagIndex,
|
|
29
|
+
analyzeProfiles,
|
|
30
|
+
buildTree,
|
|
31
|
+
compileInterface,
|
|
32
|
+
idlHash,
|
|
33
|
+
interfaceFromEnvelope,
|
|
34
|
+
jcs,
|
|
35
|
+
matchPack,
|
|
36
|
+
matchService,
|
|
37
|
+
parseAddress,
|
|
38
|
+
parseRelPath,
|
|
39
|
+
positions,
|
|
40
|
+
profileFromDocAttributes,
|
|
41
|
+
profileId,
|
|
42
|
+
readServedProfile,
|
|
43
|
+
resolveService,
|
|
44
|
+
resolvedForm,
|
|
45
|
+
SERVED_MAX_BYTES,
|
|
46
|
+
SERVED_METADATA,
|
|
47
|
+
SERVED_SECTION,
|
|
48
|
+
typeSubtype,
|
|
49
|
+
validateProfile,
|
|
50
|
+
} from "./browser.mjs";
|
|
51
|
+
|
|
52
|
+
/** The schema this package ships (a copy of the repository's spec/profile-v0.schema.json). */
|
|
53
|
+
export const DEFAULT_SCHEMA_PATH: string;
|
|
54
|
+
/** The directory of the standard packs this package ships (copies of the repository's packs/). */
|
|
55
|
+
export const DEFAULT_PACKS_DIR: string;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The validate function for the profile schema. The shipped schema (the default, or a file or document equal to it) is
|
|
59
|
+
* compiled ahead of time; any other schema is compiled by ajv at run time, with `new Function`.
|
|
60
|
+
*
|
|
61
|
+
* @param schema a path to a schema file, or the schema document (default: the schema this package ships)
|
|
62
|
+
*/
|
|
63
|
+
export function createSchemaValidator(schema?: string | Record<string, unknown>): Promise<SchemaValidator>;
|
|
64
|
+
/** Every `*.profile.json` of kind "pack" in a directory, sorted by file name (default: the packs this package ships). */
|
|
65
|
+
export function loadPacks(dir?: string): Promise<Pack[]>;
|
|
66
|
+
|
|
67
|
+
/** `candid-profile` as a function: the exit code (0 no error, 1 an error diagnostic, 2 usage or I/O). */
|
|
68
|
+
export function runCli(argv: string[], io: { stdout: { write(text: string): unknown }; stderr: { write(text: string): unknown } }): Promise<0 | 1 | 2>;
|
|
69
|
+
/** The CLI's help text. */
|
|
70
|
+
export const USAGE: string;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { idlHash, jcs, profileId } from "./util.mjs";
|
|
2
|
+
export { parseAddress, parseRelPath } from "./address.mjs";
|
|
3
|
+
export { Iface } from "./iface.mjs";
|
|
4
|
+
export { TagIndex, LayeredTags, positions } from "./tags.mjs";
|
|
5
|
+
export { createSchemaValidator, DEFAULT_SCHEMA_PATH } from "./schema.mjs";
|
|
6
|
+
export { compileInterface, interfaceFromEnvelope } from "./compile.mjs";
|
|
7
|
+
export { validateProfile } from "./validate.mjs";
|
|
8
|
+
export { resolveService } from "./resolve.mjs";
|
|
9
|
+
export { matchPack, loadPacks, DEFAULT_PACKS_DIR } from "./packs.mjs";
|
|
10
|
+
export { matchService, typeSubtype, SubtypeChecker } from "./subtype.mjs";
|
|
11
|
+
export { analyzeProfiles, LAYERS } from "./analyze.mjs";
|
|
12
|
+
export { main as runCli, USAGE } from "./cli.mjs";
|
|
13
|
+
export { resolvedForm, buildTree } from "./resolved.mjs";
|
|
14
|
+
export { profileFromDocAttributes, DOC_ATTRIBUTES_VERSION } from "./doc-annotations.mjs";
|
|
15
|
+
export { readServedProfile, SERVED_SECTION, SERVED_METADATA, SERVED_MAX_BYTES } from "./served.mjs";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pack matching (spec section 7, D-005, D-019): a pack applies to a canister whose service is a subtype of the
|
|
3
|
+
* Candid fragment in `binds.requires`, with result variants allowed to be wider. No file system here; packs.mjs adds
|
|
4
|
+
* reading packs from a directory.
|
|
5
|
+
*/
|
|
6
|
+
import { compileInterface } from "./compile.mjs";
|
|
7
|
+
import { matchService } from "./subtype.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param canister an Iface (label "canister")
|
|
11
|
+
* @param pack a parsed, schema-valid pack document
|
|
12
|
+
* @returns { matched, reasons, relaxed } or { error: [candid-core diagnostics] } when `requires` does not compile
|
|
13
|
+
*/
|
|
14
|
+
export async function matchPack(canister, pack, opts = {}) {
|
|
15
|
+
const compiled = await compileInterface(pack.binds.requires, "pack fragment");
|
|
16
|
+
if (compiled.failure) return { error: compiled.failure };
|
|
17
|
+
const r = matchService(canister, compiled.iface, { resultVariants: "relaxed", lossyOpt: false, ...opts });
|
|
18
|
+
return { matched: r.ok, reasons: r.reasons, relaxed: r.relaxed, fragment: compiled.iface };
|
|
19
|
+
}
|
package/src/packs.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Reading packs from a directory (Node). Matching a pack to a service is pack-match.mjs. */
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export { matchPack } from "./pack-match.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The standard packs this package ships (data/packs/), so the default works wherever the package is installed.
|
|
10
|
+
* They are copies of the repository's packs/ (W0's paths), regenerated by `npm run data -w packages/resolver`;
|
|
11
|
+
* test/data.test.mjs fails when a copy is stale.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_PACKS_DIR = fileURLToPath(new URL("../data/packs", import.meta.url));
|
|
14
|
+
|
|
15
|
+
/** Read every `*.profile.json` of kind "pack" in a directory, sorted by file name. Unparseable files are reported, not skipped. */
|
|
16
|
+
export async function loadPacks(dir = DEFAULT_PACKS_DIR) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const f of (await readdir(dir)).sort()) {
|
|
19
|
+
if (!f.endsWith(".profile.json")) continue;
|
|
20
|
+
const file = path.join(dir, f);
|
|
21
|
+
const doc = JSON.parse(await readFile(file, "utf8"));
|
|
22
|
+
if (doc?.kind === "pack") out.push({ file, doc });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|