@geonosis/verify-arch 1.0.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/LICENSE +202 -0
- package/README.md +161 -0
- package/bin/geonosis-verify-arch.mjs +4 -0
- package/dist/chunk-2N2AFWCY.js +940 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +28 -0
- package/dist/verify-arch-cli.js +89 -0
- package/examples/dielime.verify-arch.json +27 -0
- package/examples/during-day.verify-arch.json +16 -0
- package/package.json +44 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
// src/packs/composition-tree/index.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
// src/packs/composition-tree/options.ts
|
|
6
|
+
var rung = (name, selfComposition) => ({
|
|
7
|
+
name,
|
|
8
|
+
pattern: `(?:^|/)(?:features/[^/]+/)?${name}/`,
|
|
9
|
+
selfComposition
|
|
10
|
+
});
|
|
11
|
+
var DEFAULT_TIERS = [
|
|
12
|
+
rung("atoms", false),
|
|
13
|
+
rung("molecules", true),
|
|
14
|
+
rung("compounds", true),
|
|
15
|
+
rung("organelles", true),
|
|
16
|
+
rung("cells", false),
|
|
17
|
+
rung("tissues", true)
|
|
18
|
+
];
|
|
19
|
+
var strings = (options, key, fallback) => {
|
|
20
|
+
const value = options[key];
|
|
21
|
+
if (value === void 0) return fallback;
|
|
22
|
+
if (!Array.isArray(value) || value.some((one) => typeof one !== "string")) {
|
|
23
|
+
throw new TypeError(
|
|
24
|
+
`verify-arch: composition-tree option \`${key}\` must be a list of strings.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
var tiers = (options) => {
|
|
30
|
+
const value = options.tiers;
|
|
31
|
+
if (value === void 0) return DEFAULT_TIERS;
|
|
32
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
33
|
+
throw new TypeError("verify-arch: composition-tree option `tiers` must be a non-empty list.");
|
|
34
|
+
}
|
|
35
|
+
return value.map((one) => {
|
|
36
|
+
const tier = one;
|
|
37
|
+
if (typeof tier.name !== "string" || typeof tier.pattern !== "string") {
|
|
38
|
+
throw new TypeError("verify-arch: every `tiers` entry needs a `name` and a `pattern`.");
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
name: tier.name,
|
|
42
|
+
pattern: tier.pattern,
|
|
43
|
+
selfComposition: tier.selfComposition === true
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
var aliases = (options) => {
|
|
48
|
+
const value = options.aliases;
|
|
49
|
+
if (value === void 0) return [];
|
|
50
|
+
if (!Array.isArray(value)) {
|
|
51
|
+
throw new TypeError("verify-arch: composition-tree option `aliases` must be a list.");
|
|
52
|
+
}
|
|
53
|
+
return value.map((one) => {
|
|
54
|
+
const alias = one;
|
|
55
|
+
if (typeof alias.prefix !== "string" || typeof alias.to !== "string") {
|
|
56
|
+
throw new TypeError("verify-arch: every `aliases` entry needs a `prefix` and a `to`.");
|
|
57
|
+
}
|
|
58
|
+
return { prefix: alias.prefix, to: alias.to };
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
var readOptions = (options) => ({
|
|
62
|
+
aliases: aliases(options),
|
|
63
|
+
// Empty, and meant to be: orphan detection with no entry points would call every file dead.
|
|
64
|
+
entryPoints: strings(options, "entryPoints", []),
|
|
65
|
+
extensions: strings(options, "extensions", [".ts", ".tsx"]),
|
|
66
|
+
roots: strings(options, "roots", ["."]),
|
|
67
|
+
tiers: tiers(options)
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// src/packs/composition-tree/index.ts
|
|
71
|
+
var slashes = (path) => path.replaceAll("\\", "/");
|
|
72
|
+
var SPECIFIER = /\bfrom\s*['"]([^'"]+)['"]|\bimport\s*\(\s*['"]([^'"]+)['"]|\brequire\s*\(\s*['"]([^'"]+)['"]|^\s*import\s+['"]([^'"]+)['"]/gm;
|
|
73
|
+
var withoutComments = (source) => source.replaceAll(/\/\/[^\n]*/g, "").replaceAll(/\/\*[\s\S]*?\*\//g, "");
|
|
74
|
+
var specifiersIn = (source) => {
|
|
75
|
+
const found = [];
|
|
76
|
+
const scanner = new RegExp(SPECIFIER.source, SPECIFIER.flags);
|
|
77
|
+
let match;
|
|
78
|
+
while ((match = scanner.exec(withoutComments(source))) !== null) {
|
|
79
|
+
const named = match[1] ?? match[2] ?? match[3] ?? match[4];
|
|
80
|
+
if (named !== void 0) found.push(named);
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
};
|
|
84
|
+
var tierOf = (rel, tiers2) => tiers2.findIndex((tier) => new RegExp(tier.pattern).test(rel));
|
|
85
|
+
var build = ({ files, options, root }) => {
|
|
86
|
+
const read2 = readOptions(options);
|
|
87
|
+
const known = /* @__PURE__ */ new Set();
|
|
88
|
+
const inScope = [];
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const rel = slashes(relative(root, file));
|
|
91
|
+
if (!read2.extensions.some((one) => rel.endsWith(one))) continue;
|
|
92
|
+
if (!read2.roots.some((one) => one === "." || rel === one || rel.startsWith(`${one}/`))) continue;
|
|
93
|
+
known.add(rel);
|
|
94
|
+
inScope.push(rel);
|
|
95
|
+
}
|
|
96
|
+
const resolveTo = (from, specifier) => {
|
|
97
|
+
const aliased = read2.aliases.find((one) => specifier.startsWith(one.prefix));
|
|
98
|
+
const asPath = aliased === void 0 ? specifier.startsWith(".") ? slashes(join(dirname(from), specifier)) : void 0 : slashes(join(aliased.to, specifier.slice(aliased.prefix.length)));
|
|
99
|
+
if (asPath === void 0) return void 0;
|
|
100
|
+
if (known.has(asPath)) return asPath;
|
|
101
|
+
for (const extension of read2.extensions) {
|
|
102
|
+
if (known.has(`${asPath}${extension}`)) return `${asPath}${extension}`;
|
|
103
|
+
if (known.has(`${asPath}/index${extension}`)) return `${asPath}/index${extension}`;
|
|
104
|
+
}
|
|
105
|
+
return void 0;
|
|
106
|
+
};
|
|
107
|
+
const edges = /* @__PURE__ */ new Map();
|
|
108
|
+
const tier = /* @__PURE__ */ new Map();
|
|
109
|
+
const entries = [];
|
|
110
|
+
const isEntry = read2.entryPoints.map((pattern) => new RegExp(pattern));
|
|
111
|
+
for (const rel of inScope) {
|
|
112
|
+
const at = tierOf(rel, read2.tiers);
|
|
113
|
+
if (at !== -1) tier.set(rel, at);
|
|
114
|
+
if (isEntry.some((pattern) => pattern.test(`/${rel}`))) entries.push(rel);
|
|
115
|
+
let source;
|
|
116
|
+
try {
|
|
117
|
+
source = readFileSync(resolve(root, rel), "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
edges.set(
|
|
122
|
+
rel,
|
|
123
|
+
specifiersIn(source).map((specifier) => resolveTo(rel, specifier)).filter((one) => one !== void 0)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return { edges, entries, tier };
|
|
127
|
+
};
|
|
128
|
+
var ladderViolations = (graph, tiers2, root) => {
|
|
129
|
+
const found = [];
|
|
130
|
+
for (const [from, targets] of graph.edges) {
|
|
131
|
+
const at = graph.tier.get(from);
|
|
132
|
+
if (at === void 0) continue;
|
|
133
|
+
for (const to of targets) {
|
|
134
|
+
const landed = graph.tier.get(to);
|
|
135
|
+
if (landed === void 0 || to === from) continue;
|
|
136
|
+
const here = tiers2[at];
|
|
137
|
+
const there = tiers2[landed];
|
|
138
|
+
if (landed > at) {
|
|
139
|
+
found.push({
|
|
140
|
+
file: resolve(root, from),
|
|
141
|
+
message: `${here.name} composes ${there.name}: ${from} -> ${to}. A tier may only compose tiers at or below its own.`,
|
|
142
|
+
scanner: "direction"
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (landed === at && !here.selfComposition) {
|
|
147
|
+
found.push({
|
|
148
|
+
file: resolve(root, from),
|
|
149
|
+
message: `${here.name} composes another ${there.name}: ${from} -> ${to}. That tier is not self-composing.`,
|
|
150
|
+
scanner: "self-composition"
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
};
|
|
157
|
+
var orphans = (graph, root) => {
|
|
158
|
+
const reached = new Set(graph.entries);
|
|
159
|
+
const queue = [...graph.entries];
|
|
160
|
+
while (queue.length > 0) {
|
|
161
|
+
const at = queue.shift();
|
|
162
|
+
for (const to of graph.edges.get(at) ?? []) {
|
|
163
|
+
if (reached.has(to)) continue;
|
|
164
|
+
reached.add(to);
|
|
165
|
+
queue.push(to);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return [...graph.tier.keys()].filter((rel) => !reached.has(rel)).toSorted().map((rel) => ({
|
|
169
|
+
file: resolve(root, rel),
|
|
170
|
+
message: `Nothing reaches ${rel} from any entry point \u2014 it is dead code, or an entry point is missing from the config.`,
|
|
171
|
+
scanner: "orphan"
|
|
172
|
+
}));
|
|
173
|
+
};
|
|
174
|
+
var LADDER = [
|
|
175
|
+
{ id: "direction", label: "direction" },
|
|
176
|
+
{ id: "self-composition", label: "self-composition" }
|
|
177
|
+
];
|
|
178
|
+
var ORPHAN = { id: "orphan", label: "orphan" };
|
|
179
|
+
var compositionTree = {
|
|
180
|
+
/**
|
|
181
|
+
* With no entry points declared there is nothing to be reachable FROM, so every classified file
|
|
182
|
+
* would be an orphan. The check is then neither run nor CLAIMED — a pass line naming a check that
|
|
183
|
+
* did nothing is exactly the silent green this package exists to refuse.
|
|
184
|
+
*/
|
|
185
|
+
checks: (options) => readOptions(options).entryPoints.length === 0 ? LADDER : [...LADDER, ORPHAN],
|
|
186
|
+
id: "composition-tree",
|
|
187
|
+
scan: (context) => {
|
|
188
|
+
const read2 = readOptions(context.options);
|
|
189
|
+
const graph = build(context);
|
|
190
|
+
const ladder = ladderViolations(graph, read2.tiers, context.root);
|
|
191
|
+
return read2.entryPoints.length === 0 ? ladder : [...ladder, ...orphans(graph, context.root)];
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/types.ts
|
|
196
|
+
var VerifyArchError = class extends Error {
|
|
197
|
+
constructor(message) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "VerifyArchError";
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/walk.ts
|
|
204
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
205
|
+
import { join as join2 } from "path";
|
|
206
|
+
var DEFAULT_IGNORED = [
|
|
207
|
+
".git",
|
|
208
|
+
".medusa",
|
|
209
|
+
".next",
|
|
210
|
+
".turbo",
|
|
211
|
+
"__tests__",
|
|
212
|
+
"coverage",
|
|
213
|
+
"dist",
|
|
214
|
+
"node_modules",
|
|
215
|
+
"out"
|
|
216
|
+
];
|
|
217
|
+
var walkFiles = (root, ignored = DEFAULT_IGNORED) => {
|
|
218
|
+
if (!existsSync(root)) return [];
|
|
219
|
+
const skip = new Set(ignored);
|
|
220
|
+
const found = [];
|
|
221
|
+
const stack = [root];
|
|
222
|
+
while (stack.length > 0) {
|
|
223
|
+
const dir = stack.pop();
|
|
224
|
+
let entries;
|
|
225
|
+
try {
|
|
226
|
+
entries = readdirSync(dir);
|
|
227
|
+
} catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (skip.has(entry)) continue;
|
|
232
|
+
const full = join2(dir, entry);
|
|
233
|
+
try {
|
|
234
|
+
if (statSync(full).isDirectory()) stack.push(full);
|
|
235
|
+
else found.push(full);
|
|
236
|
+
} catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// src/packs/medusa/checks.ts
|
|
245
|
+
import { basename } from "path";
|
|
246
|
+
|
|
247
|
+
// src/packs/medusa/text.ts
|
|
248
|
+
var lineAt = (source, index) => source.slice(0, index).split("\n").length;
|
|
249
|
+
var balancedFrom = (source, open) => {
|
|
250
|
+
const opener = source[open];
|
|
251
|
+
const closer = opener === "(" ? ")" : "}";
|
|
252
|
+
let depth = 0;
|
|
253
|
+
for (let at = open; at < source.length; at++) {
|
|
254
|
+
const char = source[at];
|
|
255
|
+
if (char === opener) depth++;
|
|
256
|
+
else if (char === closer) {
|
|
257
|
+
depth--;
|
|
258
|
+
if (depth === 0) return source.slice(open, at + 1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
};
|
|
263
|
+
var afterCall = (source, open) => {
|
|
264
|
+
const call = balancedFrom(source, open);
|
|
265
|
+
return call === null ? -1 : open + call.length;
|
|
266
|
+
};
|
|
267
|
+
var commentEnd = (text2, at) => {
|
|
268
|
+
if (text2[at] !== "/") return null;
|
|
269
|
+
if (text2[at + 1] === "/") {
|
|
270
|
+
const newline = text2.indexOf("\n", at);
|
|
271
|
+
return newline === -1 ? text2.length : newline;
|
|
272
|
+
}
|
|
273
|
+
if (text2[at + 1] === "*") {
|
|
274
|
+
const end = text2.indexOf("*/", at + 2);
|
|
275
|
+
return end === -1 ? text2.length : end + 1;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
};
|
|
279
|
+
var skipLeadingComments = (text2) => {
|
|
280
|
+
let at = 0;
|
|
281
|
+
for (; ; ) {
|
|
282
|
+
while (at < text2.length && /\s/.test(text2[at])) at++;
|
|
283
|
+
const end = commentEnd(text2, at);
|
|
284
|
+
if (end === null) return text2.slice(at);
|
|
285
|
+
at = end + 1;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
var countTopLevelArgs = (call) => {
|
|
289
|
+
let depth = 0;
|
|
290
|
+
let args = 1;
|
|
291
|
+
let inString = null;
|
|
292
|
+
let lastMeaningful = "";
|
|
293
|
+
for (let at = 0; at < call.length; at++) {
|
|
294
|
+
const char = call[at];
|
|
295
|
+
if (inString !== null) {
|
|
296
|
+
if (char === inString && call[at - 1] !== "\\") inString = null;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const skipTo = commentEnd(call, at);
|
|
300
|
+
if (skipTo !== null) {
|
|
301
|
+
at = skipTo;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (char === "'" || char === '"' || char === "`") inString = char;
|
|
305
|
+
else if (char === "(" || char === "{" || char === "[") depth++;
|
|
306
|
+
else if (char === ")" || char === "}" || char === "]") {
|
|
307
|
+
depth--;
|
|
308
|
+
if (depth === 0 && lastMeaningful === ",") args--;
|
|
309
|
+
} else if (char === "," && depth === 1) args++;
|
|
310
|
+
if (!/\s/.test(char)) lastMeaningful = char;
|
|
311
|
+
}
|
|
312
|
+
return args;
|
|
313
|
+
};
|
|
314
|
+
var withoutComments2 = (source) => {
|
|
315
|
+
const out = [...source];
|
|
316
|
+
let inString = null;
|
|
317
|
+
for (let at = 0; at < source.length; at++) {
|
|
318
|
+
const char = source[at];
|
|
319
|
+
if (inString !== null) {
|
|
320
|
+
if (char === "\\") at++;
|
|
321
|
+
else if (char === inString) inString = null;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
325
|
+
inString = char;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const end = commentEnd(source, at);
|
|
329
|
+
if (end === null) continue;
|
|
330
|
+
for (let blank = at; blank <= end && blank < source.length; blank++) {
|
|
331
|
+
if (out[blank] !== "\n") out[blank] = " ";
|
|
332
|
+
}
|
|
333
|
+
at = end;
|
|
334
|
+
}
|
|
335
|
+
return out.join("");
|
|
336
|
+
};
|
|
337
|
+
var matchesOf = (source, pattern) => {
|
|
338
|
+
const scanner = new RegExp(
|
|
339
|
+
pattern.source,
|
|
340
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`
|
|
341
|
+
);
|
|
342
|
+
const found = [];
|
|
343
|
+
let match;
|
|
344
|
+
while ((match = scanner.exec(source)) !== null) {
|
|
345
|
+
found.push(match);
|
|
346
|
+
if (match[0] === "") scanner.lastIndex++;
|
|
347
|
+
}
|
|
348
|
+
return found;
|
|
349
|
+
};
|
|
350
|
+
var anyOf = (names) => names.map((one) => one.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join("|");
|
|
351
|
+
|
|
352
|
+
// src/packs/medusa/checks.ts
|
|
353
|
+
var mutationCall = (options) => new RegExp(String.raw`\.\s*(?:${anyOf(options.mutationPrefixes)})[A-Z][A-Za-z]*\s*\(`, "g");
|
|
354
|
+
var stepFactory = (options) => new RegExp(String.raw`\b(?:${anyOf(options.stepFactories)})\s*\(`, "g");
|
|
355
|
+
var serviceNameUnique = (modules) => {
|
|
356
|
+
const byName = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const one of modules) {
|
|
358
|
+
byName.set(one.serviceName, [...byName.get(one.serviceName) ?? [], one]);
|
|
359
|
+
}
|
|
360
|
+
const found = [];
|
|
361
|
+
for (const [serviceName, group] of byName) {
|
|
362
|
+
if (group.length < 2) continue;
|
|
363
|
+
for (const one of group) {
|
|
364
|
+
const others = group.filter((other) => other.file !== one.file).map((other) => other.rel).join(", ");
|
|
365
|
+
found.push({
|
|
366
|
+
file: one.file,
|
|
367
|
+
message: `Module serviceName '${serviceName}' is not globally unique \u2014 also declared in: ${others}. Colliding serviceNames silently last-win at config merge; rename one.`,
|
|
368
|
+
scanner: "R3.7"
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return found;
|
|
373
|
+
};
|
|
374
|
+
var routeNamespacing = (routes) => {
|
|
375
|
+
const found = [];
|
|
376
|
+
for (const route of routes) {
|
|
377
|
+
if (!route.namespaced) continue;
|
|
378
|
+
const segments2 = route.routePath.split("/").filter(Boolean);
|
|
379
|
+
const scope = segments2[0];
|
|
380
|
+
if (scope !== "store" && scope !== "admin") {
|
|
381
|
+
found.push({
|
|
382
|
+
file: route.file,
|
|
383
|
+
message: `Plugin route '${route.routePath}' must be scoped under /store or /admin.`,
|
|
384
|
+
scanner: "R3.8"
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (segments2.length < 2) {
|
|
389
|
+
found.push({
|
|
390
|
+
file: route.file,
|
|
391
|
+
message: `Plugin route '${route.routePath}' has no namespace segment \u2014 it must be /${scope}/<plugin-namespace>/*; a bare /${scope}/<leaf> collides cross-package.`,
|
|
392
|
+
scanner: "R3.8"
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const [routePath, group] of groupBy(routes, (route) => route.routePath)) {
|
|
397
|
+
if (group.length < 2) continue;
|
|
398
|
+
for (const route of group) {
|
|
399
|
+
const others = group.filter((other) => other.file !== route.file).map((other) => `${other.pkg} (${other.rel})`).join(", ");
|
|
400
|
+
found.push({
|
|
401
|
+
file: route.file,
|
|
402
|
+
message: `Route path '${routePath}' collides across packages \u2014 also defined by: ${others}. The routes-loader map is last-write-wins; one handler silently clobbers the other.`,
|
|
403
|
+
scanner: "R3.8"
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return found;
|
|
408
|
+
};
|
|
409
|
+
var SIDE_EFFECT = {
|
|
410
|
+
job: /\bconfig\b[\s\S]{0,80}\bschedule\s*:/,
|
|
411
|
+
subscriber: /\bSubscriberConfig\b|\bconfig\b[\s\S]{0,80}\bevent\s*:/,
|
|
412
|
+
workflow: /\b(?:createWorkflow|createStep|createHook|createScheduledWorkflow|createCartMutatingWorkflow)\s*\(|\.hooks\.[A-Za-z_$]/
|
|
413
|
+
};
|
|
414
|
+
var loaderIndexSideEffects = (files, options) => {
|
|
415
|
+
const kinds2 = new Set(options.kinds);
|
|
416
|
+
return files.filter((one) => {
|
|
417
|
+
const name = basename(one.file);
|
|
418
|
+
return (name === "index.ts" || name === "index.js") && kinds2.has(one.kind) && SIDE_EFFECT[one.kind].test(one.code);
|
|
419
|
+
}).map((one) => ({
|
|
420
|
+
file: one.file,
|
|
421
|
+
message: `${one.kind} file named '${basename(one.file)}' carries a loader side-effect but is SKIPPED by Medusa's ResourceLoader auto-discovery (it filters parsedName.name !== "index"). Rename it to a descriptive sibling and keep a re-export barrel at index.ts.`,
|
|
422
|
+
scanner: "#15442"
|
|
423
|
+
}));
|
|
424
|
+
};
|
|
425
|
+
var duplicateQueryStep = (sources, options) => {
|
|
426
|
+
const factory = new RegExp(String.raw`\b(?:${anyOf(options.workflowFactories)})\s*\(`, "g");
|
|
427
|
+
const step = new RegExp(String.raw`\b(${anyOf(options.queryStepCalls)})\s*\(`, "g");
|
|
428
|
+
const found = [];
|
|
429
|
+
for (const source of sources) {
|
|
430
|
+
for (const match of matchesOf(source.code, factory)) {
|
|
431
|
+
const open = source.code.indexOf("{", match.index);
|
|
432
|
+
if (open === -1) continue;
|
|
433
|
+
const body = balancedFrom(source.code, open);
|
|
434
|
+
if (body === null) continue;
|
|
435
|
+
const steps = matchesOf(body, step).map((one) => {
|
|
436
|
+
const closed = afterCall(body, one.index + one[0].length - 1);
|
|
437
|
+
if (closed === -1) return { at: open + one.index, kind: one[1], name: void 0 };
|
|
438
|
+
const chained = /^\s*\.config\s*\(\s*\{[^}]*?\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(
|
|
439
|
+
body.slice(closed, closed + 200)
|
|
440
|
+
);
|
|
441
|
+
return {
|
|
442
|
+
at: open + one.index,
|
|
443
|
+
kind: one[1],
|
|
444
|
+
name: chained === null ? void 0 : chained[1]
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
if (steps.length < 2) continue;
|
|
448
|
+
const seen = /* @__PURE__ */ new Set();
|
|
449
|
+
for (const one of steps) {
|
|
450
|
+
if (one.name === void 0) {
|
|
451
|
+
found.push({
|
|
452
|
+
file: source.file,
|
|
453
|
+
line: lineAt(source.source, one.at),
|
|
454
|
+
message: `A workflow body has ${steps.length} ${one.kind} calls but at least one has no .config({ name }) \u2014 duplicate steps silently last-win. Give every duplicate query step a unique .config({ name }).`,
|
|
455
|
+
scanner: "R4.3"
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (seen.has(one.name)) {
|
|
460
|
+
found.push({
|
|
461
|
+
file: source.file,
|
|
462
|
+
line: lineAt(source.source, one.at),
|
|
463
|
+
message: `Two query steps in one workflow share .config({ name: '${one.name}' }) \u2014 names must be unique.`,
|
|
464
|
+
scanner: "R4.3"
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
seen.add(one.name);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return found;
|
|
472
|
+
};
|
|
473
|
+
var routeMutationsViaWorkflows = (routes, options) => {
|
|
474
|
+
const mutation = mutationCall(options);
|
|
475
|
+
const found = [];
|
|
476
|
+
for (const route of routes) {
|
|
477
|
+
const lines = route.source.split("\n");
|
|
478
|
+
const code = route.code.split("\n");
|
|
479
|
+
for (const [at, line] of lines.entries()) {
|
|
480
|
+
if (!new RegExp(mutation.source).test(code[at])) continue;
|
|
481
|
+
if (line.includes(options.inlineMutationOk) || at > 0 && lines[at - 1].includes(options.inlineMutationOk)) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
found.push({
|
|
485
|
+
file: route.file,
|
|
486
|
+
line: at + 1,
|
|
487
|
+
message: `Route handler mutates a service directly (\`${line.trim().slice(0, 90)}\`) \u2014 mutations go through a workflow, where compensation and retry live. Wrap it, or annotate \`// ${options.inlineMutationOk}(<reason>)\` if the write is deliberately bare.`,
|
|
488
|
+
scanner: "R5.1"
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return found;
|
|
493
|
+
};
|
|
494
|
+
var mutatingSteps = (source, options) => {
|
|
495
|
+
const mutation = mutationCall(options);
|
|
496
|
+
return matchesOf(source.code, stepFactory(options)).map((match) => {
|
|
497
|
+
const open = match.index + match[0].length - 1;
|
|
498
|
+
const call = balancedFrom(source.code, open);
|
|
499
|
+
return call === null ? null : { at: match.index, call, firstArg: skipLeadingComments(call.slice(1)) };
|
|
500
|
+
}).filter((one) => one !== null && new RegExp(mutation.source).test(one.call));
|
|
501
|
+
};
|
|
502
|
+
var configObject = (firstArg) => firstArg.startsWith("{") ? balancedFrom(firstArg, 0) : null;
|
|
503
|
+
var nameIn = (firstArg) => /['"`]([^'"`]+)['"`]/.exec(firstArg)?.[1] ?? "<unnamed>";
|
|
504
|
+
var mutatingStepsDeclareRetry = (sources, options) => {
|
|
505
|
+
const retry = new RegExp(
|
|
506
|
+
options.retrySpreads.length === 0 ? String.raw`maxRetries\s*:` : String.raw`\.\.\.\s*(?:${anyOf(options.retrySpreads)})[A-Za-z0-9_]*|maxRetries\s*:`
|
|
507
|
+
);
|
|
508
|
+
return sources.flatMap(
|
|
509
|
+
(source) => mutatingSteps(source, options).filter((step) => {
|
|
510
|
+
const config = configObject(step.firstArg);
|
|
511
|
+
return config === null || !retry.test(config);
|
|
512
|
+
}).map((step) => ({
|
|
513
|
+
file: source.file,
|
|
514
|
+
line: lineAt(source.source, step.at),
|
|
515
|
+
message: `Mutating step '${nameIn(step.firstArg)}' declares no retry class \u2014 steps default to maxRetries: 0, so a transient failure reverts real state. Give it a retry class in the step config object.`,
|
|
516
|
+
scanner: "R5.2"
|
|
517
|
+
}))
|
|
518
|
+
);
|
|
519
|
+
};
|
|
520
|
+
var METHOD = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b|export\s+const\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*=/g;
|
|
521
|
+
var routeShadowing = (routes, options) => {
|
|
522
|
+
const found = [];
|
|
523
|
+
for (const [path, group] of groupBy(routes, (route) => route.routePath)) {
|
|
524
|
+
if (group.length < 2) continue;
|
|
525
|
+
const withMethods = group.map((route) => ({
|
|
526
|
+
methods: new Set(matchesOf(route.code, METHOD).map((one) => one[1] ?? one[2])),
|
|
527
|
+
route
|
|
528
|
+
}));
|
|
529
|
+
for (let left = 0; left < withMethods.length; left++) {
|
|
530
|
+
for (let right = left + 1; right < withMethods.length; right++) {
|
|
531
|
+
const one = withMethods[left];
|
|
532
|
+
const other = withMethods[right];
|
|
533
|
+
if (one.route.pkg === other.route.pkg) continue;
|
|
534
|
+
const shared = [...one.methods].filter((method) => other.methods.has(method));
|
|
535
|
+
if (shared.length === 0) continue;
|
|
536
|
+
if (one.route.source.includes(options.routeOverrideOk) || other.route.source.includes(options.routeOverrideOk)) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
found.push({
|
|
540
|
+
file: other.route.file,
|
|
541
|
+
message: `Route ${shared.join("/")} ${path} is defined by BOTH ${one.route.pkg} and ${other.route.pkg} \u2014 the loader silently last-wins. Rename one, or annotate the deliberate override with // ${options.routeOverrideOk}(<reason>).`,
|
|
542
|
+
scanner: "R5.3"
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return found;
|
|
548
|
+
};
|
|
549
|
+
var subscriberIds = (files) => files.filter(
|
|
550
|
+
(one) => one.kind === "subscriber" && /export\s+const\s+config\s*[:=]/.test(one.code) && !/subscriberId\s*:/.test(one.code)
|
|
551
|
+
).map((one) => ({
|
|
552
|
+
file: one.file,
|
|
553
|
+
message: "Subscriber has no explicit config.context.subscriberId \u2014 Medusa infers ids from function/file names, which collide silently across a plugin fleet.",
|
|
554
|
+
scanner: "R5.4"
|
|
555
|
+
}));
|
|
556
|
+
var namedWhen = (sources) => sources.flatMap((source) => {
|
|
557
|
+
const code = source.code;
|
|
558
|
+
return matchesOf(code, /\bwhen\s*\(\s*([^)]?)/g).filter((match) => !["'", '"', "`"].includes(match[1] ?? "")).map((match) => ({
|
|
559
|
+
file: source.file,
|
|
560
|
+
line: lineAt(code, match.index),
|
|
561
|
+
message: "when() without a name derives a nondeterministic when-then-{ulid} step name \u2014 checkpoint identity drifts across processes and releases. Use when('stable-name', values, condition).",
|
|
562
|
+
scanner: "R5.5"
|
|
563
|
+
}));
|
|
564
|
+
});
|
|
565
|
+
var mutatingStepsCompensation = (sources, options) => sources.flatMap(
|
|
566
|
+
(source) => mutatingSteps(source, options).filter((step) => {
|
|
567
|
+
const config = configObject(step.firstArg);
|
|
568
|
+
if (config !== null && /noCompensation\s*:\s*true/.test(config)) return false;
|
|
569
|
+
return countTopLevelArgs(step.call) < 3;
|
|
570
|
+
}).map((step) => ({
|
|
571
|
+
file: source.file,
|
|
572
|
+
line: lineAt(source.source, step.at),
|
|
573
|
+
message: `Mutating step '${nameIn(step.firstArg)}' has neither a compensation function nor noCompensation: true \u2014 every write is revertable or honestly flagged.`,
|
|
574
|
+
scanner: "R5.6"
|
|
575
|
+
}))
|
|
576
|
+
);
|
|
577
|
+
var groupBy = (items, key) => {
|
|
578
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
579
|
+
for (const item of items) {
|
|
580
|
+
const at = key(item);
|
|
581
|
+
grouped.set(at, [...grouped.get(at) ?? [], item]);
|
|
582
|
+
}
|
|
583
|
+
return grouped;
|
|
584
|
+
};
|
|
585
|
+
var CHECKS = [
|
|
586
|
+
{ id: "R3.7", label: "serviceName", run: (collected) => serviceNameUnique(collected.modules) },
|
|
587
|
+
{ id: "R3.8", label: "routes", run: (collected) => routeNamespacing(collected.routes) },
|
|
588
|
+
{
|
|
589
|
+
id: "#15442",
|
|
590
|
+
label: "loader-index",
|
|
591
|
+
run: (collected, options) => loaderIndexSideEffects(collected.loaderFiles, options)
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: "R4.3",
|
|
595
|
+
label: "query-step",
|
|
596
|
+
run: (collected, options) => duplicateQueryStep(collected.workflows, options)
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
id: "R5.1",
|
|
600
|
+
label: "route-mutations",
|
|
601
|
+
run: (collected, options) => routeMutationsViaWorkflows(collected.routes, options)
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
id: "R5.2",
|
|
605
|
+
label: "step-retry",
|
|
606
|
+
run: (collected, options) => mutatingStepsDeclareRetry(collected.workflows, options)
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
id: "R5.3",
|
|
610
|
+
label: "route-shadow",
|
|
611
|
+
run: (collected, options) => routeShadowing(collected.routes, options)
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
id: "R5.4",
|
|
615
|
+
label: "subscriber-ids",
|
|
616
|
+
run: (collected) => subscriberIds(collected.loaderFiles)
|
|
617
|
+
},
|
|
618
|
+
{ id: "R5.5", label: "named-when", run: (collected) => namedWhen(collected.workflows) },
|
|
619
|
+
{
|
|
620
|
+
id: "R5.6",
|
|
621
|
+
label: "compensation-or-flag",
|
|
622
|
+
run: (collected, options) => mutatingStepsCompensation(collected.workflows, options)
|
|
623
|
+
}
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
// src/packs/medusa/collect.ts
|
|
627
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
628
|
+
import { basename as basename2, relative as relative2 } from "path";
|
|
629
|
+
var KIND_DIRS = {
|
|
630
|
+
jobs: "job",
|
|
631
|
+
subscribers: "subscriber",
|
|
632
|
+
workflows: "workflow"
|
|
633
|
+
};
|
|
634
|
+
var slashes2 = (path) => path.replaceAll("\\", "/");
|
|
635
|
+
var isTest = (path) => /\.(?:spec|test)\.[cm]?[jt]sx?$/.test(path);
|
|
636
|
+
var isSource = (path) => /\.[cm]?[jt]s$/.test(path) && !path.endsWith(".d.ts");
|
|
637
|
+
var under = (rel, roots) => roots.some((root) => root === "." || root === "" || rel === root || rel.startsWith(`${root}/`));
|
|
638
|
+
var segments = (rel) => rel.split("/");
|
|
639
|
+
var hasSegment = (rel, name) => segments(rel).includes(name);
|
|
640
|
+
var deriveRoutePath = (apiRelative) => {
|
|
641
|
+
const derived = slashes2(apiRelative).replace(/route\.[cm]?[jt]s$/, "").split("/").filter(Boolean).map((one) => one.startsWith("[") ? `:${one.slice(1, -1)}` : one);
|
|
642
|
+
return `/${derived.join("/")}`;
|
|
643
|
+
};
|
|
644
|
+
var packageOf = (rel, rules) => {
|
|
645
|
+
for (const rule of rules) {
|
|
646
|
+
const prefix = `${rule.under}/`;
|
|
647
|
+
if (!rel.startsWith(prefix)) continue;
|
|
648
|
+
const dir = rel.slice(prefix.length).split("/")[0];
|
|
649
|
+
if (dir === void 0 || dir === "") continue;
|
|
650
|
+
return { name: rule.name.replaceAll("{dir}", dir), namespaced: rule.namespaced === true };
|
|
651
|
+
}
|
|
652
|
+
return { name: rel, namespaced: false };
|
|
653
|
+
};
|
|
654
|
+
var read = (file) => {
|
|
655
|
+
try {
|
|
656
|
+
return readFileSync2(file, "utf8");
|
|
657
|
+
} catch {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
var loaderKindOf = (rel) => {
|
|
662
|
+
const parts = rel.split("/");
|
|
663
|
+
for (let at = parts.length - 2; at >= 0; at--) {
|
|
664
|
+
const kind = KIND_DIRS[parts[at]];
|
|
665
|
+
if (kind !== void 0) return kind;
|
|
666
|
+
}
|
|
667
|
+
return void 0;
|
|
668
|
+
};
|
|
669
|
+
var collect = (root, files, options) => {
|
|
670
|
+
const collected = { loaderFiles: [], modules: [], routes: [], workflows: [] };
|
|
671
|
+
const declaresWork = new RegExp(
|
|
672
|
+
`\\b(?:${[...options.workflowFactories, ...options.stepFactories].join("|")})\\s*\\(`
|
|
673
|
+
);
|
|
674
|
+
for (const file of files) {
|
|
675
|
+
const rel = slashes2(relative2(root, file));
|
|
676
|
+
if (isTest(rel) || !isSource(rel)) continue;
|
|
677
|
+
const name = basename2(rel);
|
|
678
|
+
let cached;
|
|
679
|
+
const contents = () => {
|
|
680
|
+
if (cached === void 0) {
|
|
681
|
+
const source = read(file);
|
|
682
|
+
cached = source === null ? null : { code: withoutComments2(source), source };
|
|
683
|
+
}
|
|
684
|
+
return cached;
|
|
685
|
+
};
|
|
686
|
+
if ((name === "index.ts" || name === "index.js") && hasSegment(rel, "modules") && !hasSegment(rel, "models") && under(rel, options.moduleRoots)) {
|
|
687
|
+
const text2 = contents();
|
|
688
|
+
const serviceName = text2 === null ? null : serviceNameIn(text2.code);
|
|
689
|
+
if (serviceName !== null) collected.modules.push({ file, rel, serviceName });
|
|
690
|
+
}
|
|
691
|
+
if ((name === "route.ts" || name === "route.js") && hasSegment(rel, "api") && under(rel, options.sourceRoots)) {
|
|
692
|
+
const text2 = contents();
|
|
693
|
+
if (text2 !== null) {
|
|
694
|
+
const parts = segments(rel);
|
|
695
|
+
const owner = packageOf(rel, options.packages);
|
|
696
|
+
collected.routes.push({
|
|
697
|
+
code: text2.code,
|
|
698
|
+
file,
|
|
699
|
+
namespaced: owner.namespaced,
|
|
700
|
+
pkg: owner.name,
|
|
701
|
+
rel,
|
|
702
|
+
routePath: deriveRoutePath(parts.slice(parts.lastIndexOf("api") + 1).join("/")),
|
|
703
|
+
source: text2.source
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const kind = loaderKindOf(rel);
|
|
708
|
+
if (kind !== void 0 && under(rel, options.sourceRoots)) {
|
|
709
|
+
const text2 = contents();
|
|
710
|
+
if (text2 !== null) {
|
|
711
|
+
collected.loaderFiles.push({ code: text2.code, file, kind, source: text2.source });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (hasSegment(rel, "workflows") && under(rel, options.workflowRoots)) {
|
|
715
|
+
const text2 = contents();
|
|
716
|
+
if (text2 !== null && declaresWork.test(text2.code)) {
|
|
717
|
+
collected.workflows.push({ code: text2.code, file, source: text2.source });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return collected;
|
|
722
|
+
};
|
|
723
|
+
var serviceNameIn = (source) => {
|
|
724
|
+
const call = /\bModule\s*\(\s*([A-Za-z_$][\w$]*)\s*,/.exec(source);
|
|
725
|
+
if (call === null) return null;
|
|
726
|
+
const ident = call[1];
|
|
727
|
+
const literal = new RegExp(`\\b${ident}\\s*=\\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
728
|
+
return literal === null ? null : literal[1];
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// src/packs/medusa/options.ts
|
|
732
|
+
var EVERYWHERE = ["."];
|
|
733
|
+
var LOADER_KINDS_STILL_SKIPPED = ["subscriber", "job"];
|
|
734
|
+
var KNOWN_KINDS = /* @__PURE__ */ new Set(["job", "subscriber", "workflow"]);
|
|
735
|
+
var strings2 = (options, key, fallback) => {
|
|
736
|
+
const value = options[key];
|
|
737
|
+
if (value === void 0) return fallback;
|
|
738
|
+
if (!Array.isArray(value) || value.some((one) => typeof one !== "string")) {
|
|
739
|
+
throw new TypeError(`verify-arch: medusa option \`${key}\` must be a list of strings.`);
|
|
740
|
+
}
|
|
741
|
+
return value;
|
|
742
|
+
};
|
|
743
|
+
var text = (options, key, fallback) => {
|
|
744
|
+
const value = options[key];
|
|
745
|
+
if (value === void 0) return fallback;
|
|
746
|
+
if (typeof value !== "string") {
|
|
747
|
+
throw new TypeError(`verify-arch: medusa option \`${key}\` must be a string.`);
|
|
748
|
+
}
|
|
749
|
+
return value;
|
|
750
|
+
};
|
|
751
|
+
var packageRules = (options) => {
|
|
752
|
+
const value = options.packages;
|
|
753
|
+
if (value === void 0) return [];
|
|
754
|
+
if (!Array.isArray(value)) {
|
|
755
|
+
throw new TypeError("verify-arch: medusa option `packages` must be a list.");
|
|
756
|
+
}
|
|
757
|
+
return value.map((one) => {
|
|
758
|
+
const rule = one;
|
|
759
|
+
if (typeof rule.under !== "string" || typeof rule.name !== "string") {
|
|
760
|
+
throw new TypeError(
|
|
761
|
+
"verify-arch: every `packages` entry needs `under` (a directory) and `name` (a template using {dir})."
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
return { name: rule.name, namespaced: rule.namespaced === true, under: rule.under };
|
|
765
|
+
});
|
|
766
|
+
};
|
|
767
|
+
var kinds = (options) => {
|
|
768
|
+
const named = strings2(options, "kinds", LOADER_KINDS_STILL_SKIPPED);
|
|
769
|
+
const unknown = named.filter((one) => !KNOWN_KINDS.has(one));
|
|
770
|
+
if (unknown.length > 0) {
|
|
771
|
+
throw new TypeError(
|
|
772
|
+
`verify-arch: medusa option \`kinds\` names ${unknown.join(", ")}; the loader kinds are workflow, subscriber, job.`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
return named;
|
|
776
|
+
};
|
|
777
|
+
var readOptions2 = (options) => {
|
|
778
|
+
const sourceRoots = strings2(options, "sourceRoots", EVERYWHERE);
|
|
779
|
+
return {
|
|
780
|
+
checks: options.checks === void 0 ? void 0 : strings2(options, "checks", []),
|
|
781
|
+
ignoreDirs: strings2(options, "ignoreDirs", DEFAULT_IGNORED),
|
|
782
|
+
inlineMutationOk: text(options, "inlineMutationOk", "arch:inline-mutation-ok"),
|
|
783
|
+
kinds: kinds(options),
|
|
784
|
+
moduleRoots: strings2(options, "moduleRoots", sourceRoots),
|
|
785
|
+
// The CRUD names `MedusaService` generates. A framework fact, so a default rather than a blank.
|
|
786
|
+
mutationPrefixes: strings2(options, "mutationPrefixes", [
|
|
787
|
+
"create",
|
|
788
|
+
"update",
|
|
789
|
+
"upsert",
|
|
790
|
+
"delete",
|
|
791
|
+
"softDelete",
|
|
792
|
+
"restore"
|
|
793
|
+
]),
|
|
794
|
+
packages: packageRules(options),
|
|
795
|
+
queryStepCalls: strings2(options, "queryStepCalls", ["useQueryGraphStep", "useRemoteQueryStep"]),
|
|
796
|
+
// `...RETRY_DB` is one repo's constant; `maxRetries:` is the framework's key and is always read.
|
|
797
|
+
retrySpreads: strings2(options, "retrySpreads", []),
|
|
798
|
+
routeOverrideOk: text(options, "routeOverrideOk", "arch:route-override-ok"),
|
|
799
|
+
sourceRoots,
|
|
800
|
+
stepFactories: strings2(options, "stepFactories", ["createStep"]),
|
|
801
|
+
workflowFactories: strings2(options, "workflowFactories", ["createWorkflow"]),
|
|
802
|
+
workflowRoots: strings2(options, "workflowRoots", sourceRoots)
|
|
803
|
+
};
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// src/packs/medusa/index.ts
|
|
807
|
+
var selected = (options) => {
|
|
808
|
+
const asked = readOptions2(options).checks;
|
|
809
|
+
if (asked === void 0) return CHECKS;
|
|
810
|
+
const known = new Set(CHECKS.map((one) => one.id));
|
|
811
|
+
const unknown = asked.filter((id) => !known.has(id));
|
|
812
|
+
if (unknown.length > 0) {
|
|
813
|
+
throw new VerifyArchError(
|
|
814
|
+
`verify-arch: the medusa pack has no check named ${unknown.join(", ")}. It runs ${[...known].join(", ")}.`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
return CHECKS.filter((one) => asked.includes(one.id));
|
|
818
|
+
};
|
|
819
|
+
var medusa = {
|
|
820
|
+
checks: (options) => selected(options).map(({ id, label }) => ({ id, label })),
|
|
821
|
+
id: "medusa",
|
|
822
|
+
scan: ({ files, options, root }) => {
|
|
823
|
+
const read2 = readOptions2(options);
|
|
824
|
+
const collected = collect(root, files, read2);
|
|
825
|
+
return selected(options).flatMap((check) => check.run(collected, read2));
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
// src/packs/index.ts
|
|
830
|
+
var PACKS = {
|
|
831
|
+
"composition-tree": compositionTree,
|
|
832
|
+
medusa
|
|
833
|
+
};
|
|
834
|
+
var PACK_NAMES = Object.keys(PACKS).toSorted();
|
|
835
|
+
|
|
836
|
+
// src/config.ts
|
|
837
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
838
|
+
import { join as join3 } from "path";
|
|
839
|
+
var CONFIG_FILE = "geonosis.verify-arch.json";
|
|
840
|
+
var DEFAULT_LABEL = "verify-arch";
|
|
841
|
+
var packOf = (entry, at) => {
|
|
842
|
+
const named = entry;
|
|
843
|
+
if (typeof named?.pack !== "string") {
|
|
844
|
+
throw new VerifyArchError(`${CONFIG_FILE}: packs[${at}] has no \`pack\` name.`);
|
|
845
|
+
}
|
|
846
|
+
if (PACKS[named.pack] === void 0) {
|
|
847
|
+
throw new VerifyArchError(
|
|
848
|
+
`${CONFIG_FILE}: no pack named "${named.pack}". This build ships ${PACK_NAMES.join(", ")}.`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
return { options: named.options ?? {}, pack: named.pack };
|
|
852
|
+
};
|
|
853
|
+
var readConfig = (root) => {
|
|
854
|
+
const path = join3(root, CONFIG_FILE);
|
|
855
|
+
if (!existsSync2(path)) {
|
|
856
|
+
throw new VerifyArchError(
|
|
857
|
+
`${CONFIG_FILE} not found in ${root}. Write one naming the packs this repo runs \u2014 a scanner with no packs would report PASS having looked at nothing.`
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
let parsed;
|
|
861
|
+
try {
|
|
862
|
+
parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
863
|
+
} catch (error) {
|
|
864
|
+
throw new VerifyArchError(`${CONFIG_FILE} is not valid JSON: ${error.message}`);
|
|
865
|
+
}
|
|
866
|
+
const config = parsed;
|
|
867
|
+
if (!Array.isArray(config.packs) || config.packs.length === 0) {
|
|
868
|
+
throw new VerifyArchError(`${CONFIG_FILE}: \`packs\` must name at least one pack to run.`);
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
label: typeof config.label === "string" && config.label !== "" ? config.label : DEFAULT_LABEL,
|
|
872
|
+
packs: config.packs.map(packOf)
|
|
873
|
+
};
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// src/report.ts
|
|
877
|
+
import { relative as relative3 } from "path";
|
|
878
|
+
var MARK = "\u2717";
|
|
879
|
+
var shortPath = (root, file) => relative3(root, file).replaceAll("\\", "/") || file;
|
|
880
|
+
var formatHuman = ({
|
|
881
|
+
checks,
|
|
882
|
+
label,
|
|
883
|
+
root,
|
|
884
|
+
violations
|
|
885
|
+
}) => {
|
|
886
|
+
if (violations.length === 0) {
|
|
887
|
+
const ran = checks.map((check) => `${check.id} ${check.label}`).join(", ");
|
|
888
|
+
return `${label} \u2014 PASS (${ran})
|
|
889
|
+
`;
|
|
890
|
+
}
|
|
891
|
+
const detail = violations.map((one) => `${MARK} [${one.scanner}] ${shortPath(root, one.file)}
|
|
892
|
+
${one.message}
|
|
893
|
+
`).join("\n");
|
|
894
|
+
return `${label} \u2014 FAIL: ${violations.length} violation(s)
|
|
895
|
+
|
|
896
|
+
${detail}`;
|
|
897
|
+
};
|
|
898
|
+
var formatRatchet = ({
|
|
899
|
+
root,
|
|
900
|
+
violations
|
|
901
|
+
}) => violations.map((one) => {
|
|
902
|
+
const at = one.line === void 0 ? "" : `:${one.line}`;
|
|
903
|
+
const said = one.message.replaceAll(/\s+/g, " ").trim();
|
|
904
|
+
return `${MARK} ${shortPath(root, one.file)}${at} ${one.scanner} ${said}
|
|
905
|
+
`;
|
|
906
|
+
}).join("");
|
|
907
|
+
|
|
908
|
+
// src/run.ts
|
|
909
|
+
var runPacks = (root, packs) => {
|
|
910
|
+
const files = walkFiles(root);
|
|
911
|
+
const checks = [];
|
|
912
|
+
const violations = [];
|
|
913
|
+
for (const configured of packs) {
|
|
914
|
+
const pack = PACKS[configured.pack];
|
|
915
|
+
if (pack === void 0) {
|
|
916
|
+
throw new VerifyArchError(
|
|
917
|
+
`No pack named "${configured.pack}". This build ships ${PACK_NAMES.join(", ")}.`
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
const options = configured.options ?? {};
|
|
921
|
+
checks.push(...pack.checks(options));
|
|
922
|
+
violations.push(...pack.scan({ files, options, root }));
|
|
923
|
+
}
|
|
924
|
+
return { checks, violations };
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
export {
|
|
928
|
+
compositionTree,
|
|
929
|
+
VerifyArchError,
|
|
930
|
+
DEFAULT_IGNORED,
|
|
931
|
+
walkFiles,
|
|
932
|
+
medusa,
|
|
933
|
+
PACKS,
|
|
934
|
+
PACK_NAMES,
|
|
935
|
+
CONFIG_FILE,
|
|
936
|
+
readConfig,
|
|
937
|
+
formatHuman,
|
|
938
|
+
formatRatchet,
|
|
939
|
+
runPacks
|
|
940
|
+
};
|