@alint-js/config 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -104,7 +104,7 @@ declare const declarativeFindingSchema: import("valibot").SchemaWithPipe<readonl
104
104
  readonly confidence: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").PicklistSchema<["high", "medium", "low"], undefined>, import("valibot").DescriptionAction<"high" | "medium" | "low", "Confidence in this finding. Use exactly \"high\", \"medium\", or \"low\" when confidence is known.">]>, undefined>;
105
105
  readonly filePath: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Path to the file that owns this finding. Omit when the finding belongs to the reviewed target file.">]>, undefined>;
106
106
  readonly line: import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<undefined>, import("valibot").DescriptionAction<number, "Use the left-column line number from the numbered source block.">]>;
107
- readonly message: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Human-readable diagnostic message describing the issue.">]>;
107
+ readonly message: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Human-readable diagnostic message describing the problem the target must fix. Never describe the absence of a problem.">]>;
108
108
  readonly suggestion: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Concrete remediation direction for the finding.">]>, undefined>;
109
109
  }, undefined>, import("valibot").DescriptionAction<{
110
110
  confidence?: "high" | "medium" | "low" | undefined;
@@ -112,13 +112,13 @@ declare const declarativeFindingSchema: import("valibot").SchemaWithPipe<readonl
112
112
  line: number;
113
113
  message: string;
114
114
  suggestion?: string | undefined;
115
- }, "One declarative rule finding.">]>;
115
+ }, "One declarative rule finding. Report only a change the reviewed target must make.">]>;
116
116
  declare const declarativeFindingResponseSchema: import("valibot").SchemaWithPipe<readonly [import("valibot").ObjectSchema<{
117
117
  readonly findings: import("valibot").SchemaWithPipe<readonly [import("valibot").ArraySchema<import("valibot").SchemaWithPipe<readonly [import("valibot").ObjectSchema<{
118
118
  readonly confidence: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").PicklistSchema<["high", "medium", "low"], undefined>, import("valibot").DescriptionAction<"high" | "medium" | "low", "Confidence in this finding. Use exactly \"high\", \"medium\", or \"low\" when confidence is known.">]>, undefined>;
119
119
  readonly filePath: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Path to the file that owns this finding. Omit when the finding belongs to the reviewed target file.">]>, undefined>;
120
120
  readonly line: import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<undefined>, import("valibot").DescriptionAction<number, "Use the left-column line number from the numbered source block.">]>;
121
- readonly message: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Human-readable diagnostic message describing the issue.">]>;
121
+ readonly message: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Human-readable diagnostic message describing the problem the target must fix. Never describe the absence of a problem.">]>;
122
122
  readonly suggestion: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").DescriptionAction<string, "Concrete remediation direction for the finding.">]>, undefined>;
123
123
  }, undefined>, import("valibot").DescriptionAction<{
124
124
  confidence?: "high" | "medium" | "low" | undefined;
@@ -126,13 +126,13 @@ declare const declarativeFindingResponseSchema: import("valibot").SchemaWithPipe
126
126
  line: number;
127
127
  message: string;
128
128
  suggestion?: string | undefined;
129
- }, "One declarative rule finding.">]>, undefined>, import("valibot").DescriptionAction<{
129
+ }, "One declarative rule finding. Report only a change the reviewed target must make.">]>, undefined>, import("valibot").DescriptionAction<{
130
130
  confidence?: "high" | "medium" | "low" | undefined;
131
131
  filePath?: string | undefined;
132
132
  line: number;
133
133
  message: string;
134
134
  suggestion?: string | undefined;
135
- }[], "All findings for the declarative rule. Return an empty array when there are no issues.">]>;
135
+ }[], "All findings for the declarative rule. Return an empty array when there are no issues, and never add a finding that only states the target is already correct.">]>;
136
136
  }, undefined>, import("valibot").DescriptionAction<{
137
137
  findings: {
138
138
  confidence?: "high" | "medium" | "low" | undefined;
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
+ import { access, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
1
2
  import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
2
3
  import { loadConfig } from "c12";
3
4
  import { createJiti } from "jiti/static";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
- import { access, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { array, boolean, check, description, finite, integer, literal, looseObject, maxValue, minValue, nonEmpty, number, object, optional, parse, picklist, pipe, record, strictObject, string, transform, tupleWithRest, undefined_, union, unknown, variant } from "valibot";
7
7
  import process from "node:process";
8
8
  import { homedir } from "node:os";
@@ -104,10 +104,10 @@ const declarativeFindingSchema = pipe(object({
104
104
  ]), description("Confidence in this finding. Use exactly \"high\", \"medium\", or \"low\" when confidence is known."))),
105
105
  filePath: optional(pipe(string(), description("Path to the file that owns this finding. Omit when the finding belongs to the reviewed target file."))),
106
106
  line: pipe(number(), description("Use the left-column line number from the numbered source block.")),
107
- message: pipe(string(), description("Human-readable diagnostic message describing the issue.")),
107
+ message: pipe(string(), description("Human-readable diagnostic message describing the problem the target must fix. Never describe the absence of a problem.")),
108
108
  suggestion: optional(pipe(string(), description("Concrete remediation direction for the finding.")))
109
- }), description("One declarative rule finding."));
110
- const declarativeFindingResponseSchema = pipe(object({ findings: pipe(array(declarativeFindingSchema), description("All findings for the declarative rule. Return an empty array when there are no issues.")) }), description("Structured declarative rule findings."));
109
+ }), description("One declarative rule finding. Report only a change the reviewed target must make."));
110
+ const declarativeFindingResponseSchema = pipe(object({ findings: pipe(array(declarativeFindingSchema), description("All findings for the declarative rule. Return an empty array when there are no issues, and never add a finding that only states the target is already correct.")) }), description("Structured declarative rule findings."));
111
111
  function isBuiltInAgentName(value) {
112
112
  return builtInAgentNames.includes(value);
113
113
  }
@@ -195,6 +195,89 @@ function parseDeclarativeRuleFileInput(input, filePath) {
195
195
  }
196
196
  }
197
197
  //#endregion
198
+ //#region src/plugins/declarative/cache-key.ts
199
+ /**
200
+ * Behavior version of each built-in agent.
201
+ *
202
+ * A declarative rule is built from a shared factory, so the rule source that the run engine
203
+ * hashes is identical for every declarative rule. Nothing in that source tells the engine what
204
+ * the preset actually sends to the model: the instruction, the supplemental files, and the
205
+ * message layout all sit in the rule definition or in this package.
206
+ *
207
+ * Bump the version of an agent when its prompt assembly, message layout, or finding reporting
208
+ * changes, so cached findings never outlive the behavior that produced them.
209
+ */
210
+ const builtInAgentVersions = {
211
+ "basic-coding-agent": 1,
212
+ "basic-structured": 1
213
+ };
214
+ /**
215
+ * Cache key for one declarative rule: every declared input that changes its findings.
216
+ *
217
+ * Without it, editing `instruction` in `rule.alint.toml` leaves the rule source untouched, the
218
+ * cache fingerprint stays equal, and the next run replays findings the new rule text would never
219
+ * produce. The run only reports that the entry was a cache hit, so the stale result is silent.
220
+ *
221
+ * @see {@link builtInAgentVersions}
222
+ */
223
+ function createDeclarativeCacheKey(rule) {
224
+ return {
225
+ agent: rule.builtInAgent,
226
+ agentVersion: builtInAgentVersions[rule.builtInAgent],
227
+ excludeFiles: rule.excludeFiles,
228
+ includeFiles: rule.includeFiles,
229
+ instruction: rule.instruction
230
+ };
231
+ }
232
+ //#endregion
233
+ //#region src/plugins/declarative/findings.ts
234
+ /**
235
+ * Phrases that report the absence of a problem instead of reporting nothing.
236
+ *
237
+ * A clean target makes some models answer with a finding that asserts compliance, for example
238
+ * "No violations found." The finding has the shape of a real one, so it reaches the reporter and
239
+ * renders as a diagnostic whose remediation is "change nothing", with a confidence that makes it
240
+ * look as trustworthy as a real hit.
241
+ *
242
+ * Every pattern requires an explicit compliance verb ("found", "needed", "compliant") so a real
243
+ * finding that merely starts with a negative word survives: "No guard clause before the loop" is
244
+ * a missing guard, not a statement that the file is clean.
245
+ */
246
+ const compliancePatterns = [
247
+ /\bno\s+(?:\w+\s+){0,4}(?:violations?|issues?|problems?)\s+(?:\w+\s+){0,3}(?:found|detected|identified|present)\b/iu,
248
+ /\bno\s+(?:\w+\s+){0,3}(?:action|change|changes|fix|fixes|modification|modifications)\s+(?:\w+\s+){0,2}(?:needed|required|necessary)\b/iu,
249
+ /\bnothing\s+(?:to|needs?\s+to)\s+(?:address|change|do|fix|report)\b/iu,
250
+ /\b(?:is|are|looks?|appears?|seems?)\s+(?:fully\s+|otherwise\s+)?compliant\b/iu,
251
+ /\bcompl(?:y|ies|iant)\s+with\b/iu,
252
+ /\b(?:is|are)\s+(?:already\s+)?(?:correct|fine|good)\b/iu,
253
+ /\b(?:lgtm|looks good|looks fine)\b/iu
254
+ ];
255
+ /**
256
+ * Compliance phrased in Simplified Chinese, which the output language option can request.
257
+ *
258
+ * "不符合规范" negates the same characters, so the pattern refuses to match right after 不 or 未.
259
+ */
260
+ const chineseCompliancePattern = /没有(?:违规|问题)|未发现(?:问题|违规|异常)|(?<![不未])符合(?:规范|要求|约定|预期)|(?<![不未])无需(?:修改|改动)/u;
261
+ /**
262
+ * A negation anywhere in the message keeps the finding: the message may describe a violation,
263
+ * such as "The module is not compliant with the rule."
264
+ */
265
+ const negationPattern = /\b(?:can(?:no|'?t)|didn'?t|doesn'?t|don'?t|isn'?t|aren'?t|never|no longer|not)\b/iu;
266
+ /**
267
+ * Whether a finding asks for a change the reviewed target can act on.
268
+ *
269
+ * Judged on the message alone: a real violation whose suggestion happens to say "no further
270
+ * change needed" is still a violation the reader has to see.
271
+ */
272
+ function isActionableFinding(finding) {
273
+ return !isComplianceStatement(finding.message);
274
+ }
275
+ function isComplianceStatement(message) {
276
+ if (chineseCompliancePattern.test(message)) return true;
277
+ if (negationPattern.test(message)) return false;
278
+ return compliancePatterns.some((pattern) => pattern.test(message));
279
+ }
280
+ //#endregion
198
281
  //#region src/plugins/declarative/scope.ts
199
282
  function createReportScope(options) {
200
283
  const includeFiles = options.includeFiles;
@@ -228,6 +311,7 @@ const maxSupplementalFiles = 12;
228
311
  function createBasicStructuredRule(rule) {
229
312
  return {
230
313
  cache: rule.includeFiles === void 0 || rule.includeFiles.length === 0,
314
+ cacheKey: createDeclarativeCacheKey(rule),
231
315
  create: (ctx) => ({
232
316
  /**
233
317
  * Runs one structured declarative review for a planned file.
@@ -313,6 +397,10 @@ function reportDeclarativeFindings(options) {
313
397
  targetFilePath: options.targetFilePath
314
398
  });
315
399
  for (const finding of options.findings) {
400
+ if (!isActionableFinding(finding)) {
401
+ options.ctx.logger.debug("Ignoring declarative finding that reports no issue", { message: finding.message });
402
+ continue;
403
+ }
316
404
  const filePath = resolveFindingFilePath(options.ctx.cwd, options.targetFilePath, finding.filePath);
317
405
  if (!scope.canReport(filePath)) {
318
406
  options.ctx.logger.debug("Ignoring out-of-scope declarative finding", {
@@ -461,7 +549,7 @@ function createCodingAgent(model, runner = chat({
461
549
  parameters: agentTool.parameters
462
550
  }));
463
551
  tools.push(rawTool({
464
- description: "Submit all findings and finish the review. Submit an empty findings array when there are no issues.",
552
+ description: "Submit all findings and finish the review. Submit an empty findings array when there are no issues, instead of a finding that states the target is already correct.",
465
553
  execute: async (input) => {
466
554
  report = parse(declarativeFindingResponseSchema, input);
467
555
  return report;
@@ -484,7 +572,7 @@ function createCodingAgent(model, runner = chat({
484
572
  instructions: [
485
573
  options.instruction,
486
574
  "Use the filesystem tools to inspect the project as needed before reaching a conclusion.",
487
- `When the review is complete, call ${reportFindingsToolName} exactly once with all findings. Submit an empty findings array when there are no issues.`
575
+ `When the review is complete, call ${reportFindingsToolName} exactly once with all findings. Submit an empty findings array when there are no issues, instead of a finding that states the target is already correct.`
488
576
  ].join("\n\n"),
489
577
  tools,
490
578
  turnId: "alint"
@@ -1167,6 +1255,38 @@ function toAlintConfigItems(value, options = {}) {
1167
1255
  }
1168
1256
  //#endregion
1169
1257
  //#region src/config/load.ts
1258
+ /**
1259
+ * Config file names a nested config may use, mirroring what c12 resolves for the root.
1260
+ *
1261
+ * @see {@link loadConfig} `configFile`
1262
+ */
1263
+ const nestedConfigFileNames = [
1264
+ "alint.config.cjs",
1265
+ "alint.config.cts",
1266
+ "alint.config.js",
1267
+ "alint.config.json",
1268
+ "alint.config.json5",
1269
+ "alint.config.jsonc",
1270
+ "alint.config.mjs",
1271
+ "alint.config.mts",
1272
+ "alint.config.toml",
1273
+ "alint.config.ts",
1274
+ "alint.config.yaml",
1275
+ "alint.config.yml"
1276
+ ];
1277
+ /**
1278
+ * Directories that never hold a config a project means to run.
1279
+ *
1280
+ * Hidden directories are skipped wholesale so `.git`, `.cache`, and `.next` cost no lookups.
1281
+ */
1282
+ const skippedDiscoveryDirectoryNames = /* @__PURE__ */ new Set([
1283
+ "build",
1284
+ "coverage",
1285
+ "dist",
1286
+ "node_modules",
1287
+ "out",
1288
+ "vendor"
1289
+ ]);
1170
1290
  async function loadAlintConfig(cwd, configFile) {
1171
1291
  return (await loadAlintConfigWithMetadata(cwd, configFile)).config;
1172
1292
  }
@@ -1197,10 +1317,88 @@ async function loadAlintConfigWithMetadata(cwd, configFile) {
1197
1317
  async function loadStaticConfig(cwd, configFile) {
1198
1318
  return (await loadStaticConfigSource(cwd, configFile)).config;
1199
1319
  }
1320
+ /**
1321
+ * Rejects two config files claiming one plugin alias with different plugins.
1322
+ *
1323
+ * The lock file is keyed by alias, so the second specifier would silently win and every rule id
1324
+ * from the first plugin would come from the wrong plugin.
1325
+ */
1326
+ function assertDistinctPluginAliases(sources) {
1327
+ const seen = /* @__PURE__ */ new Map();
1328
+ for (const source of sources) for (const group of source.config.groups) for (const reference of group.plugins) {
1329
+ const key = getPluginSpecifierKey(reference.specifier);
1330
+ const previous = seen.get(reference.alias);
1331
+ if (previous === void 0) {
1332
+ seen.set(reference.alias, {
1333
+ configFile: source.configFile,
1334
+ key
1335
+ });
1336
+ continue;
1337
+ }
1338
+ if (previous.key === key) continue;
1339
+ throw new Error(`Static plugin "${reference.alias}" is configured in ${formatConfigOrigin(previous.configFile)} and ${formatConfigOrigin(source.configFile)} with different specifiers ("${previous.key}" and "${key}"). Nested configs share one plugin lock file, so give each plugin a distinct alias.`);
1340
+ }
1341
+ }
1342
+ async function collectNestedConfigFiles(root, directory, found) {
1343
+ let entries;
1344
+ try {
1345
+ entries = await readdir(directory, { withFileTypes: true });
1346
+ } catch {
1347
+ return;
1348
+ }
1349
+ const childDirectories = [];
1350
+ for (const entry of entries) {
1351
+ const path = join(directory, entry.name);
1352
+ if (entry.isDirectory()) {
1353
+ if (!shouldSkipDiscoveryDirectory(entry.name)) childDirectories.push(path);
1354
+ continue;
1355
+ }
1356
+ if (entry.isFile() && directory !== root && nestedConfigFileNames.includes(entry.name)) found.push(path);
1357
+ }
1358
+ for (const child of childDirectories.sort()) await collectNestedConfigFiles(root, child, found);
1359
+ }
1360
+ function directoryDepth(path) {
1361
+ return path.split(sep).length;
1362
+ }
1363
+ async function discoverNestedConfigFiles(cwd) {
1364
+ const found = [];
1365
+ await collectNestedConfigFiles(cwd, cwd, found);
1366
+ return found.sort((left, right) => {
1367
+ const depthDifference = directoryDepth(left) - directoryDepth(right);
1368
+ return depthDifference === 0 ? left.localeCompare(right) : depthDifference;
1369
+ });
1370
+ }
1371
+ function formatConfigOrigin(configFile) {
1372
+ return configFile ?? "the inline config";
1373
+ }
1200
1374
  function formatPluginReferences(references) {
1201
1375
  return references.map((reference) => `${reference.alias} (${reference.specifier.raw})`).join(", ");
1202
1376
  }
1203
- async function loadStaticConfigSource(cwd, configFile) {
1377
+ /**
1378
+ * Loads every `alint.config.*` below `cwd`, except the root config itself.
1379
+ *
1380
+ * A package that owns its rules writes one config next to its code instead of registering a
1381
+ * directory plugin in the root config, scoping the plugin, and repeating that scope in every
1382
+ * tool that reads it. Discovery is depth-first, parents before children, so a deeper config
1383
+ * overrides an outer one the same way a later flat-config item does.
1384
+ */
1385
+ async function loadNestedStaticConfigSources(cwd) {
1386
+ const configFiles = await discoverNestedConfigFiles(cwd);
1387
+ const sources = [];
1388
+ for (const configFile of configFiles) {
1389
+ const directory = dirname(configFile);
1390
+ const source = await loadSingleStaticConfigSource(directory, basename(configFile), { dotenv: false });
1391
+ if (source.configFile === void 0) continue;
1392
+ sources.push({
1393
+ config: source.config,
1394
+ configFile: source.configFile,
1395
+ relativeDirectory: toPosixPath(relative(cwd, directory))
1396
+ });
1397
+ }
1398
+ return sources;
1399
+ }
1400
+ /** Loads one config file in one directory. The root and every nested config go through here. */
1401
+ async function loadSingleStaticConfigSource(cwd, configFile, options = {}) {
1204
1402
  const jiti = createJiti(resolve(cwd, configFile ?? "alint.config"), {
1205
1403
  interopDefault: true,
1206
1404
  moduleCache: false
@@ -1208,7 +1406,7 @@ async function loadStaticConfigSource(cwd, configFile) {
1208
1406
  const result = await loadConfig({
1209
1407
  configFile,
1210
1408
  cwd,
1211
- dotenv: true,
1409
+ dotenv: options.dotenv ?? true,
1212
1410
  import: (id) => jiti.import(id),
1213
1411
  name: "alint"
1214
1412
  });
@@ -1219,6 +1417,51 @@ async function loadStaticConfigSource(cwd, configFile) {
1219
1417
  configFile: resolvedConfigFile
1220
1418
  };
1221
1419
  }
1420
+ async function loadStaticConfigSource(cwd, configFile) {
1421
+ const root = await loadSingleStaticConfigSource(cwd, configFile);
1422
+ if (configFile !== void 0) return root;
1423
+ const nested = await loadNestedStaticConfigSources(cwd);
1424
+ if (nested.length === 0) return root;
1425
+ assertDistinctPluginAliases([{
1426
+ config: root.config,
1427
+ configFile: root.configFile
1428
+ }, ...nested]);
1429
+ return {
1430
+ config: { groups: [...root.config.groups, ...nested.flatMap((source) => scopeNestedGroups(source))] },
1431
+ configFile: root.configFile
1432
+ };
1433
+ }
1434
+ /**
1435
+ * Scopes one nested config item to the directory that declares it.
1436
+ *
1437
+ * `basePath` makes every pattern in the item resolve against that directory, so an `ignores`
1438
+ * entry or a plugin config the item extends stays inside it. The two defaults cover the item
1439
+ * shapes that declare no patterns at all: without them a rule set written next to a package
1440
+ * would silently apply to the whole repository.
1441
+ *
1442
+ * Limits: an item cannot target the project root (`onTargetProject`) from a nested config,
1443
+ * because the project target sits outside the item's base path.
1444
+ */
1445
+ function scopeNestedConfigItem(item, relativeDirectory) {
1446
+ return {
1447
+ ...item,
1448
+ basePath: item.basePath === void 0 ? relativeDirectory : `${relativeDirectory}/${toPosixPath(item.basePath)}`,
1449
+ directories: item.directories ?? ["**"],
1450
+ files: item.files ?? ["**/*"]
1451
+ };
1452
+ }
1453
+ function scopeNestedGroups(source) {
1454
+ return source.config.groups.map((group) => ({
1455
+ ...group,
1456
+ item: scopeNestedConfigItem(group.item, source.relativeDirectory)
1457
+ }));
1458
+ }
1459
+ function shouldSkipDiscoveryDirectory(name) {
1460
+ return name.startsWith(".") || skippedDiscoveryDirectoryNames.has(name);
1461
+ }
1462
+ function toPosixPath(path) {
1463
+ return path.split(sep).join("/");
1464
+ }
1222
1465
  //#endregion
1223
1466
  //#region src/config/stop-gate-write.ts
1224
1467
  async function setStopGateConfig(options) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alint-js/config",
3
3
  "type": "module",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "description": "Configuration loading and setup utilities for alint",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -41,9 +41,9 @@
41
41
  "tar-stream": "^3.2.0",
42
42
  "valibot": "^1.4.2",
43
43
  "xdg-basedir": "^5.1.0",
44
- "@alint-js/tools-fs": "0.7.1",
45
- "@alint-js/core": "0.7.1",
46
- "@alint-js/utils": "0.7.1"
44
+ "@alint-js/core": "0.7.3",
45
+ "@alint-js/tools-fs": "0.7.3",
46
+ "@alint-js/utils": "0.7.3"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/tar-stream": "^3.1.4",