@filipebraida/adonis-function-points 0.3.0 → 0.4.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 CHANGED
@@ -6,6 +6,66 @@ release moves the number for unchanged code, the rule set version moves with it
6
6
  otherwise the difference would measure the tool's change rather than the work, and
7
7
  that difference becomes an invoice.
8
8
 
9
+ ## 0.4.0
10
+
11
+ **Rule set `afp@1.3.0`.** Conditional validator groups now count, and a nested
12
+ schema is recognised however the formatter wrapped it — so a 0.3.0 baseline has to
13
+ be recounted.
14
+
15
+ Five items reported from real use of 0.3.0, three of them defects.
16
+
17
+ ### Fixed
18
+
19
+ - **A newline decided whether a field counted.** The recogniser for a nested schema
20
+ was a regex over the property's source text (`/vine\.object/`), and Prettier breaks
21
+ a long chain across lines — `data: vine` then `.object({})` — so the regex missed
22
+ and the field was counted as one leaf instead of its nested ones, and never marked
23
+ opaque. Decided by structure now: is this literal the argument of a call named
24
+ `object`? The same applied to `.merge(…)` and `group.if(…)`, which had the same
25
+ kind of check. A count that depends on where the formatter put a newline is not a
26
+ measurement — the reason the implementation-scope hash strips whitespace before
27
+ hashing.
28
+ - **The list of unreadable DETs ignored the overrides that answered it.** It was
29
+ computed before `applyOverrides` ran, so a function whose floor `detFromSchema` had
30
+ already replaced still appeared under "this is a FLOOR", telling the reader to map
31
+ something already mapped. It caused a real misreading of a production report, by
32
+ the author of this code. It now runs after the overrides, is grouped by FUNCTION,
33
+ and states per function how many were replaced by an override, how many reviewed,
34
+ and how many are still unanswered — only the last being a request to do anything.
35
+ - **Emitted artefacts carried absolute paths.** `CountSource.app` is documented as
36
+ never being one, because that says where the machine keeps its files and travels
37
+ with every artefact sent anywhere — and the rule was applied to that one field. A
38
+ production count carried **858** absolute paths in its traces; an inventory
39
+ carried **2036** across ten fields, including the data-store `id`. Every path that
40
+ leaves is now relative to the application root, and the internal absolute form is
41
+ untouched because that is what ts-morph resolves against.
42
+ - **`vine.group` and `.merge()` were not read.**
43
+ `vine.object({}).merge(vine.group([vine.group.if(p, {…})]))` reported the whole
44
+ validator as an open input object: five fields counted as one, and the report said
45
+ they were data when they are in the code. The branches are mutually exclusive at
46
+ runtime and the transaction can carry any of them, so §7.2 counts their union — a
47
+ field two branches share counts once. The group usually lives in an unexported
48
+ constant beside the validator, so the reference is resolved in the validator's own
49
+ file rather than the caller's.
50
+ - **`fp:explain` matched by substring even when the exact name existed.** Asking
51
+ about `POST /orders/:param/submit` returned four functions, because
52
+ `/submit-ready` and `/submit-ready/return` contain it. An exact name now wins
53
+ outright; the substring search is the fallback.
54
+
55
+ ### New
56
+
57
+ - **`detFromSchema` accepts a list**, unioned by leaf path. An ILF's DETs are the
58
+ fields the user recognises in the file, and an application with one schema per
59
+ template recognises all of them. Pointing at the largest and justifying it in
60
+ `reason` gives the same answer only while they land in the same band — reasoning
61
+ the configuration should not have to carry.
62
+ - **`overrides.<fn>.opaqueReviewed`** records that someone looked at an opaque DET
63
+ and decided 1 is right. 1 DET is a floor and `fp:count` says so on every run, but
64
+ some of those columns really are one field, and a warning that cannot be answered
65
+ is one the team learns to scroll past. It moves no number, it is not counted as a
66
+ declared override in the "Declared by override" share, and the volume reviewed is
67
+ still printed.
68
+
9
69
  ## 0.3.0
10
70
 
11
71
  **Rule set `afp@1.2.0`.** Three counting fixes move the number for unchanged code,
package/README.md CHANGED
@@ -13,7 +13,7 @@ node ace fp:count
13
13
 
14
14
  ```
15
15
  Unadjusted count: 46 FP
16
- Ruleset: afp@1.2.0
16
+ Ruleset: afp@1.3.0
17
17
 
18
18
  type n FP
19
19
  ILF 2 14
@@ -14,27 +14,27 @@
14
14
  const commands = [
15
15
  {
16
16
  commandName: "fp:inventory",
17
- importer: () => import("../fp_inventory-Bu6O1Nn0.js")
17
+ importer: () => import("../fp_inventory-DHwZzEQf.js")
18
18
  },
19
19
  {
20
20
  commandName: "fp:metrics",
21
- importer: () => import("../fp_metrics-MGDppfSa.js")
21
+ importer: () => import("../fp_metrics-M84qLYaE.js")
22
22
  },
23
23
  {
24
24
  commandName: "fp:count",
25
- importer: () => import("../fp_count-D21tQ_pv.js")
25
+ importer: () => import("../fp_count-DNSwaLUD.js")
26
26
  },
27
27
  {
28
28
  commandName: "fp:explain",
29
- importer: () => import("../fp_explain-BwFs-LW-.js")
29
+ importer: () => import("../fp_explain-Dpiby5Qx.js")
30
30
  },
31
31
  {
32
32
  commandName: "fp:diff",
33
- importer: () => import("../fp_diff-D0pHGMgi.js")
33
+ importer: () => import("../fp_diff-CCKxqGKh.js")
34
34
  },
35
35
  {
36
36
  commandName: "fp:calibrate",
37
- importer: () => import("../fp_calibrate-iFAec0tA.js")
37
+ importer: () => import("../fp_calibrate-DLZP5bUp.js")
38
38
  }
39
39
  ];
40
40
  let cache = null;
@@ -1,4 +1,4 @@
1
- import { s as printResult, t as runCalibrate } from "./runners-CmxNHuuq.js";
1
+ import { s as printResult, t as runCalibrate } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_calibrate.ts
@@ -1,4 +1,4 @@
1
- import { n as runCount, s as printResult } from "./runners-CmxNHuuq.js";
1
+ import { n as runCount, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_count.ts
@@ -1,4 +1,4 @@
1
- import { r as runDiff, s as printResult } from "./runners-CmxNHuuq.js";
1
+ import { r as runDiff, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_diff.ts
@@ -1,4 +1,4 @@
1
- import { i as runExplain, s as printResult } from "./runners-CmxNHuuq.js";
1
+ import { i as runExplain, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_explain.ts
@@ -1,4 +1,4 @@
1
- import { a as runInventory, s as printResult } from "./runners-CmxNHuuq.js";
1
+ import { a as runInventory, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_inventory.ts
@@ -1,4 +1,4 @@
1
- import { o as runMetrics, s as printResult } from "./runners-CmxNHuuq.js";
1
+ import { o as runMetrics, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_metrics.ts
package/build/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { configure } from "./configure.js";
2
2
  import { a as AEP_FACTORS, c as diffCounts, i as measureStructure, n as parseSamples, o as IncomparableRulesetsError, r as measureConformance, s as IncomparableSourcesError, t as calibrate, u as defineConfig } from "./calibration-8eV8CEix.js";
3
3
  import "./src/types.js";
4
- import { t as BUILTIN_CALL_RESOLVERS } from "./resolvers-CRB6lXoo.js";
5
- import { i as RULESET_VERSION, n as analyze, r as RULESET, t as CoverageTooLowError } from "./pipeline-CIAydCcT.js";
4
+ import { t as BUILTIN_CALL_RESOLVERS } from "./resolvers-vMahHkAd.js";
5
+ import { i as RULESET_VERSION, n as analyze, r as RULESET, t as CoverageTooLowError } from "./pipeline-Dm9KvUvF.js";
6
6
  export { AEP_FACTORS, BUILTIN_CALL_RESOLVERS, CoverageTooLowError, IncomparableRulesetsError, IncomparableSourcesError, RULESET, RULESET_VERSION, analyze, calibrate, configure, defineConfig, diffCounts, measureConformance, measureStructure, parseSamples };
@@ -1,4 +1,4 @@
1
- import { a as rootSymbolOf, c as toPosix, i as hooksFiredBy, n as resolveCall, o as collectEventBindings, r as detectAccess, s as samePath, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-CRB6lXoo.js";
1
+ import { a as rootSymbolOf, c as samePath, i as hooksFiredBy, l as toPosix, n as resolveCall, o as collectEventBindings, r as detectAccess, s as relativeTo, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-vMahHkAd.js";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Node, Project, SyntaxKind } from "ts-morph";
@@ -1248,7 +1248,14 @@ function validatorFieldsIn(body, file, app) {
1248
1248
  const name = argument.getText();
1249
1249
  const declaration = findValidator(name, file, app);
1250
1250
  if (!declaration) continue;
1251
- const { leaves, opaque: unreadable } = leavesOf(declaration);
1251
+ /**
1252
+ * The reference is resolved in the VALIDATOR's file, not the caller's.
1253
+ *
1254
+ * `vine.object({}).merge(openaiOrAws)` names a constant that lives beside the
1255
+ * validator and is usually not exported, so looking for it where the call site
1256
+ * is finds nothing — which is how five fields stayed invisible.
1257
+ */
1258
+ const { leaves, opaque: unreadable } = leavesOf(declaration, (ref) => findValidator(ref, declaration.getSourceFile(), app));
1252
1259
  const isOpaque = new Set(unreadable);
1253
1260
  for (const leaf of leaves) {
1254
1261
  const field = `${name}.${leaf}`;
@@ -1344,7 +1351,54 @@ function findValidator(name, file, app) {
1344
1351
  }
1345
1352
  return null;
1346
1353
  }
1347
- function leavesOf(node) {
1354
+ /**
1355
+ * Leaves of a VineJS schema, per the table in §7, and which of them are opaque.
1356
+ *
1357
+ * `answers: vine.object({}).allowUnknownProperties()` declares a field whose own
1358
+ * fields live in data, not in code. Walking into the empty literal found nothing
1359
+ * and then never pushed `answers` either, so the field counted ZERO — while an
1360
+ * opaque JSON column in the same position counts 1. The two are the same
1361
+ * situation and now get the same answer: one DET, and a warning that says the
1362
+ * number is a floor.
1363
+ *
1364
+ * That zero is also why `detFromSchema` was off by one. Its formula replaces the
1365
+ * opaque placeholder with the schema's fields, and there was no placeholder to
1366
+ * replace, so the subtraction ate a real field instead.
1367
+ */
1368
+ /**
1369
+ * Is this literal the argument of a call named `name`?
1370
+ *
1371
+ * Structural, because the test used to be a regex over the property's source text
1372
+ * (`/vine\.object/`) and Prettier breaks a long chain across lines:
1373
+ *
1374
+ * data: vine
1375
+ * .object({})
1376
+ * .allowUnknownProperties()
1377
+ *
1378
+ * `vine` and `.object` then sit on different lines, the regex misses, and the field
1379
+ * silently stops being recognised as an open object. A count that depends on where
1380
+ * the formatter put a newline is not a measurement — the same reason the
1381
+ * implementation-scope hash strips whitespace before hashing.
1382
+ */
1383
+ function isArgumentOfCallNamed(literal, name) {
1384
+ const call = literal.getParent();
1385
+ if (!call || !Node.isCallExpression(call)) return false;
1386
+ const callee = call.getExpression();
1387
+ return Node.isPropertyAccessExpression(callee) && callee.getName() === name;
1388
+ }
1389
+ /** The last member of a call's callee: `vine.group.if(…)` is `if`, however it is wrapped. */
1390
+ function calleeName(call) {
1391
+ const callee = call.getExpression();
1392
+ return Node.isPropertyAccessExpression(callee) ? callee.getName() : void 0;
1393
+ }
1394
+ /** …and the member before it, so `group.if` can be told from any other `if`. */
1395
+ function calleeOwner(call) {
1396
+ const callee = call.getExpression();
1397
+ if (!Node.isPropertyAccessExpression(callee)) return void 0;
1398
+ const owner = callee.getExpression();
1399
+ return Node.isPropertyAccessExpression(owner) ? owner.getName() : void 0;
1400
+ }
1401
+ function leavesOf(node, resolveRef) {
1348
1402
  const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1349
1403
  if (!object) return {
1350
1404
  leaves: [],
@@ -1356,9 +1410,8 @@ function leavesOf(node) {
1356
1410
  for (const property of literal.getProperties()) {
1357
1411
  if (!Node.isPropertyAssignment(property)) continue;
1358
1412
  const name = property.getName().replace(/['"]/g, "");
1359
- const text = property.getText();
1360
1413
  const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1361
- if (nested && /vine\.object/.test(text)) {
1414
+ if (nested && isArgumentOfCallNamed(nested, "object")) {
1362
1415
  const path = prefix ? `${prefix}.${name}` : name;
1363
1416
  /**
1364
1417
  * An object declaring no properties enumerates nothing. It is the field
@@ -1378,6 +1431,20 @@ function leavesOf(node) {
1378
1431
  };
1379
1432
  walk(object, "");
1380
1433
  /**
1434
+ * Conditional groups: `vine.object({}).merge(vine.group([vine.group.if(p, {…})]))`.
1435
+ *
1436
+ * The branches are mutually exclusive at runtime and the transaction can carry
1437
+ * any of them, so §7.2 counts the UNION — the fields the elementary process
1438
+ * handles. Read from the first object literal alone, the whole validator looked
1439
+ * like an open object and the count said the fields were data when they are
1440
+ * plainly in the code: five fields reported as one, and `detFromSchema` could not
1441
+ * fix it, because a group is not a JSON Schema.
1442
+ *
1443
+ * A `group.if` literal inside the outer object would be a nested schema `walk`
1444
+ * already handled, so only the ones outside it are roots here.
1445
+ */
1446
+ for (const branch of groupBranchesIn(node, object, resolveRef)) walk(branch, "");
1447
+ /**
1381
1448
  * The whole validator is an open object: nothing is enumerable, and the body
1382
1449
  * that carries it is measured at the floor. Counted as one, reported as such.
1383
1450
  */
@@ -1386,10 +1453,38 @@ function leavesOf(node) {
1386
1453
  opaque: ["*"]
1387
1454
  };
1388
1455
  return {
1389
- leaves,
1390
- opaque
1456
+ leaves: [...new Set(leaves)],
1457
+ opaque: [...new Set(opaque)]
1391
1458
  };
1392
1459
  }
1460
+ /**
1461
+ * Object literals passed to `vine.group.if(…)`, following `.merge(x)` when `x` is
1462
+ * a name this file can resolve.
1463
+ *
1464
+ * The groups usually live in their own constant — which is why following the
1465
+ * reference matters more than recognising the inline form.
1466
+ */
1467
+ function groupBranchesIn(node, outer, resolveRef, depth = 0) {
1468
+ if (depth > 3) return [];
1469
+ const found = [];
1470
+ for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1471
+ const method = calleeName(call);
1472
+ if ((method === "if" || method === "else") && calleeOwner(call) === "group") {
1473
+ for (const argument of call.getArguments()) {
1474
+ const literal = argument.asKind(SyntaxKind.ObjectLiteralExpression);
1475
+ if (literal && !outer.getDescendants().includes(literal)) found.push(literal);
1476
+ }
1477
+ continue;
1478
+ }
1479
+ /** `.merge(openaiOrAws)`: the group is declared elsewhere */
1480
+ if (method !== "merge" || !resolveRef) continue;
1481
+ const reference = call.getArguments()[0];
1482
+ if (!reference || !Node.isIdentifier(reference)) continue;
1483
+ const declaration = resolveRef(reference.getText());
1484
+ if (declaration) found.push(...groupBranchesIn(declaration, outer, resolveRef, depth + 1));
1485
+ }
1486
+ return found;
1487
+ }
1393
1488
  /** file name, to identify the unresolved call without dumping the full path */
1394
1489
  const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
1395
1490
  const DEFAULT_MAX_DEPTH = 3;
@@ -2104,7 +2199,10 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
2104
2199
  rule: behavior.writes ? "afp:6.5.3 modifies a data store -> EI" : "afp:6.5.3 uses without modifying -> EO (EQ collapsed per 6.5.3)",
2105
2200
  detSources: sources,
2106
2201
  refSources: touched.map((store) => `reaches:${store}`),
2107
- trace: behavior.trace
2202
+ trace: behavior.trace.map((step) => ({
2203
+ ...step,
2204
+ file: relativeTo(options.root, step.file)
2205
+ }))
2108
2206
  }
2109
2207
  });
2110
2208
  }
@@ -2250,7 +2348,7 @@ const RULESET = "afp";
2250
2348
  * against this one and bills the tool's own improvement as work done. The guard
2251
2349
  * exists for exactly that, and only this constant arms it.
2252
2350
  */
2253
- const RULESET_VERSION = "1.2.0";
2351
+ const RULESET_VERSION = "1.3.0";
2254
2352
  function count(input, options = {}) {
2255
2353
  const warnings = [];
2256
2354
  const usage = usageOf(input);
@@ -2296,14 +2394,38 @@ function count(input, options = {}) {
2296
2394
  const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
2297
2395
  const transactionalFunctions = countTransactionalFunctions(input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? "")), input.behaviors, {
2298
2396
  countedStores,
2397
+ root: input.app.root,
2299
2398
  messageDet: options.messageDet ?? 0,
2300
2399
  tables,
2301
2400
  weights
2302
2401
  });
2303
- warnings.push(...opaqueColumnWarnings(countable, input));
2304
- warnings.push(...unreadableInputWarnings(input));
2305
- warnings.push(...openValidatorWarnings(input));
2402
+ /**
2403
+ * `opaqueReviewed` answers a warning that is CORRECT and therefore permanent.
2404
+ *
2405
+ * 1 DET for an opaque column is a floor, and `fp:count` says so on every run. But
2406
+ * some of those columns are one field — a copy, a checksum, a bag of metadata —
2407
+ * and there was no way to record that someone had looked. A warning that cannot be
2408
+ * answered is one the team learns to scroll past, which costs more than the warning
2409
+ * reports. It silences nothing else: the count does not move, and how many were
2410
+ * reviewed is still printed.
2411
+ */
2412
+ const reviewed = reviewedOpaque(options.overrides ?? {});
2306
2413
  const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2414
+ /**
2415
+ * Reported AFTER the overrides are applied, because the overrides are the answer
2416
+ * to it.
2417
+ *
2418
+ * Computed first, the list kept naming functions whose floor had already been
2419
+ * replaced by `detFromSchema` — telling the reader to go and map something that
2420
+ * was mapped. It cost a real misreading: a report was taken as "two forms still
2421
+ * unmapped" when both were declared, by whoever wrote this code.
2422
+ */
2423
+ const declared = new Set(functions.filter((fn) => fn.rationale.overrides?.some((o) => o.fields.includes("det"))).map((fn) => fn.name));
2424
+ warnings.push(...opaqueWarnings(countable, input, {
2425
+ reviewed,
2426
+ declared
2427
+ }));
2428
+ warnings.push(...unreadableInputWarnings(input));
2307
2429
  return {
2308
2430
  ruleset: "afp",
2309
2431
  rulesetVersion: RULESET_VERSION,
@@ -2325,45 +2447,93 @@ function count(input, options = {}) {
2325
2447
  * `metadata` column changes no number, and warning about it would be the noise
2326
2448
  * that teaches people to stop reading the confidence block.
2327
2449
  */
2328
- function opaqueColumnWarnings(stores, input) {
2450
+ /**
2451
+ * Opaque DETs someone has declared reviewed, in both spellings a person might use.
2452
+ *
2453
+ * Qualified (`Petition.schema`) is unambiguous; bare (`schema`) is what someone reads
2454
+ * off the warning line.
2455
+ */
2456
+ function reviewedOpaque(overrides) {
2457
+ const reviewed = /* @__PURE__ */ new Set();
2458
+ for (const [name, override] of Object.entries(overrides)) for (const entry of override.opaqueReviewed ?? []) {
2459
+ reviewed.add(entry);
2460
+ reviewed.add(`${name}.${entry}`);
2461
+ }
2462
+ return reviewed;
2463
+ }
2464
+ /** a column whose shape says nothing about what it holds */
2465
+ const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2466
+ /**
2467
+ * DETs the analysis cannot read: an opaque column, or an open input object.
2468
+ *
2469
+ * Both count 1, which is a FLOOR rather than a measurement, and counting-decisions §8
2470
+ * is the trade. Reporting it is the point — this was the one known blind spot the
2471
+ * package reported nowhere.
2472
+ *
2473
+ * Grouped by FUNCTION and stating what has already been answered, because a flat list
2474
+ * of columns could not say that. Computed before the overrides ran, it named functions
2475
+ * whose floor `detFromSchema` had already replaced, which reads as "go and map this"
2476
+ * about something already mapped. That misreading actually happened, to the author of
2477
+ * this code, reading someone else's report.
2478
+ *
2479
+ * Three states per function, and only the third is a request to do something:
2480
+ *
2481
+ * replaced a `detFromSchema` override stands in for one of them
2482
+ * reviewed someone looked and 1 is the right answer
2483
+ * floor still unanswered
2484
+ */
2485
+ function opaqueWarnings(stores, input, state) {
2329
2486
  const reached = /* @__PURE__ */ new Map();
2330
2487
  for (const entry of input.entryPoints) for (const store of input.behaviors.get(entry.id)?.touches ?? []) reached.set(store, (reached.get(store) ?? 0) + 1);
2331
- const found = [];
2488
+ const byFunction = /* @__PURE__ */ new Map();
2489
+ const tally = (name, kind, transactions) => {
2490
+ const found = byFunction.get(name) ?? {
2491
+ kind,
2492
+ floor: [],
2493
+ reviewed: 0,
2494
+ transactions
2495
+ };
2496
+ byFunction.set(name, found);
2497
+ return found;
2498
+ };
2332
2499
  for (const store of stores) {
2333
- const transactions = reached.get(store.name);
2334
- if (!transactions) continue;
2500
+ if (!reached.get(store.name)) continue;
2335
2501
  for (const attribute of store.attributes) {
2336
2502
  if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
2337
- found.push(` ${store.name}.${attribute.name} (${attribute.type}) — ${transactions} transaction(s)`);
2503
+ const entry = tally(store.name, "column", reached.get(store.name) ?? 0);
2504
+ if (state.reviewed.has(`${store.name}.${attribute.name}`) || state.reviewed.has(attribute.name)) entry.reviewed += 1;
2505
+ else entry.floor.push(`${attribute.name} (${attribute.type})`);
2338
2506
  }
2339
2507
  }
2340
- if (found.length === 0) return [];
2341
- return [`${found.length} opaque column(s), each counted as 1 DET. If the user recognises fields inside one, declare the count with \`overrides\` — counting-decisions §8:`, ...found];
2342
- }
2343
- /**
2344
- * Input fields declared as an open object: `vine.object({}).allowUnknownProperties()`.
2345
- *
2346
- * The same blind spot as an opaque column and, until now, reported nowhere — the
2347
- * opaque-column warning names stores, and this one is on the transaction side, so
2348
- * a route whose whole form arrives through one of these was invisible in the
2349
- * confidence block. On a production application that was the route that saves the
2350
- * main document, and it was the reason its EI never looked wrong.
2351
- *
2352
- * It counts 1 DET, which is a floor. When the fields are declared in the code
2353
- * somewhere — a seeder, a schema module — `detFromSchema` replaces the floor with
2354
- * the real count, and §8 of counting-decisions says how.
2355
- */
2356
- function openValidatorWarnings(input) {
2357
- const found = [];
2358
- for (const entry of input.entryPoints) {
2359
- const fields = input.behaviors.get(entry.id)?.opaqueInputFields ?? [];
2360
- for (const field of fields) found.push(` ${entry.trigger} ${entry.signature} — ${field}`);
2508
+ for (const point of input.entryPoints) for (const field of input.behaviors.get(point.id)?.opaqueInputFields ?? []) {
2509
+ const entry = tally(point.identity, "input object", 1);
2510
+ if (state.reviewed.has(field) || state.reviewed.has(`${point.identity}.${field}`)) entry.reviewed += 1;
2511
+ else entry.floor.push(field);
2361
2512
  }
2362
- if (found.length === 0) return [];
2513
+ const lines = [];
2514
+ let answered = 0;
2515
+ for (const [name, entry] of byFunction) {
2516
+ /** a declared schema stands in for exactly one placeholder — §8, and the override warns when there are more */
2517
+ const replaced = state.declared.has(name) && entry.floor.length > 0 ? 1 : 0;
2518
+ const remaining = entry.floor.slice(replaced);
2519
+ if (remaining.length === 0) {
2520
+ answered += 1;
2521
+ continue;
2522
+ }
2523
+ const answeredHere = [...replaced > 0 ? [`${replaced} replaced by override`] : [], ...entry.reviewed > 0 ? [`${entry.reviewed} reviewed`] : []];
2524
+ /**
2525
+ * How many transactions reach the store, so the reader can judge whether the
2526
+ * floor is worth answering. A blob nothing touches changes no number.
2527
+ */
2528
+ const reach = entry.kind === "column" ? `, reached by ${entry.transactions} transaction(s)` : "";
2529
+ lines.push(` ${name} — ${remaining.length} ${entry.kind}(s) at 1 DET${reach}` + (answeredHere.length > 0 ? ` (${answeredHere.join(", ")} already)` : "") + `: ${remaining.map((f) => entry.kind === "column" ? `${name}.${f}` : f).join(", ")}`);
2530
+ }
2531
+ const settled = answered === 0 ? [] : [` (${answered} more function(s) whose opaque DETs are all accounted for)`];
2532
+ if (lines.length === 0) return settled;
2363
2533
  return [
2364
- `${found.length} open input object(s), each counted as 1 DET. The fields the user fills are data, not code, so this is a FLOOR: if they are declared anywhere in the source, name that schema with \`overrides.detFromSchema\` — counting-decisions §8:`,
2365
- ...found.slice(0, 10),
2366
- ...found.length > 10 ? [` … and ${found.length - 10} more`] : []
2534
+ `${lines.length} function(s) with a DET the analysis cannot read, counted as 1 each — a FLOOR, not a measurement. Where the fields are declared in the source, name that schema with \`overrides.detFromSchema\`; where 1 is the right answer, record it with \`overrides.<fn>.opaqueReviewed\` — counting-decisions §8:`,
2535
+ ...lines,
2536
+ ...settled
2367
2537
  ];
2368
2538
  }
2369
2539
  /**
@@ -2402,8 +2572,6 @@ function unreadableInputWarnings(input) {
2402
2572
  ...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
2403
2573
  ];
2404
2574
  }
2405
- /** a column whose shape says nothing about what it holds */
2406
- const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2407
2575
  /**
2408
2576
  * Replaces what the analysis found with what a person declared.
2409
2577
  *
@@ -2438,7 +2606,25 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2438
2606
  * is born, not when a field is.
2439
2607
  */
2440
2608
  if (override.detFromSchema) {
2441
- const schema = schemas.get(override.detFromSchema);
2609
+ /**
2610
+ * One name or several, unioned by leaf path.
2611
+ *
2612
+ * An ILF's DETs are the fields the user recognises in the file, and an
2613
+ * application with one schema per template recognises all of them. A field
2614
+ * two templates share is one DET, so the union is over paths rather than a
2615
+ * sum of counts.
2616
+ */
2617
+ const named = [override.detFromSchema].flat();
2618
+ const resolved = named.map((name) => schemas.get(name)).filter((s) => s !== void 0);
2619
+ const missing = named.filter((name) => !schemas.has(name));
2620
+ const union = new Set(resolved.flatMap((s) => s.leaves));
2621
+ const schema = resolved.length === 0 ? void 0 : {
2622
+ /** only what resolved: naming a schema that contributed nothing would mislead */
2623
+ name: resolved.map((s) => s.name).join(" + "),
2624
+ fields: union.size,
2625
+ leaves: [...union]
2626
+ };
2627
+ for (const name of missing) warnings.push(`override for "${fn.name}" names schema "${name}", which is not declared anywhere in the code: it contributed nothing. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
2442
2628
  if (schema) {
2443
2629
  /**
2444
2630
  * Replaces the opaque placeholder — the one the rationale marks — rather
@@ -2454,10 +2640,20 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2454
2640
  by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
2455
2641
  if (placeholders.length === 0) warnings.push(`override for "${fn.name}" names schema "${schema.name}", but this function has no opaque DET for it to stand in for: the ${schema.fields} fields were ADDED to the ${fn.det} already counted. Check the override is on the right function.`);
2456
2642
  else if (placeholders.length > 1) warnings.push(`override for "${fn.name}" names one schema and the function has ${placeholders.length} opaque DETs (${placeholders.join(", ")}). Only one was replaced; the others still count 1 each.`);
2457
- } else warnings.push(`override for "${fn.name}" names schema "${override.detFromSchema}", which is not declared anywhere in the code: the DET count was left as found. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
2643
+ }
2458
2644
  }
2459
2645
  const refs = override.refs ?? fn.refs;
2460
2646
  const complexity = complexityOf(fn.type, refs, det, tables);
2647
+ /**
2648
+ * Only a DECLARED NUMBER is an override in the rationale.
2649
+ *
2650
+ * `fp:count` prints what share of the total came from a person, and that line is
2651
+ * the reason the mechanism is acceptable at all. An `opaqueReviewed`-only entry
2652
+ * declares no number, and recording it here read as "1 function, 7 FP, 35% of the
2653
+ * total declared by override" — misrepresenting the one number that exists to keep
2654
+ * this honest. The review is recorded in the warning, which is where it belongs.
2655
+ */
2656
+ if (fields.length === 0) return fn;
2461
2657
  return {
2462
2658
  ...fn,
2463
2659
  det,
@@ -2601,7 +2797,7 @@ function describeSource(root, config) {
2601
2797
  ]),
2602
2798
  dirty: status === void 0 ? void 0 : status.length > 0,
2603
2799
  countedAt: (/* @__PURE__ */ new Date()).toISOString(),
2604
- config: config === null ? null : toPosix(config)
2800
+ config: config === null ? null : relativeTo(root, config)
2605
2801
  };
2606
2802
  }
2607
2803
  function appName(root) {
@@ -2635,17 +2831,51 @@ async function analyze(root, options = {}) {
2635
2831
  const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
2636
2832
  const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
2637
2833
  const unresolvedCalls = storeProblems.length + routeProblems.length + [...behaviors.values()].reduce((total, behavior) => total + behavior.unresolved.length, 0);
2834
+ /**
2835
+ * Every path that LEAVES is relative to the application root.
2836
+ *
2837
+ * `CountSource.app` is documented as never being the absolute path, because that
2838
+ * says where the machine keeps its files and travels with every artefact sent
2839
+ * anywhere. The rule was stated on one field and applied to one field: the
2840
+ * inventory carried 2036 absolute paths across ten of them, and a count carried
2841
+ * 858 in its traces alone.
2842
+ *
2843
+ * Absolute is right INTERNALLY — it is what ts-morph resolves and what the call
2844
+ * graph keys its caches on — so the conversion happens here, at the boundary, and
2845
+ * the data-store `id` is relativised only after the ancestor filter has used it.
2846
+ */
2847
+ const source = describeSource(root, options.configFile ?? null);
2848
+ const emit = (value) => relativeTo(app.root, value);
2849
+ const emitProvenance = (p) => ({
2850
+ ...p,
2851
+ file: emit(p.file)
2852
+ });
2638
2853
  const inventory = {
2639
2854
  version: 1,
2640
2855
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2641
- app: app.root,
2856
+ app: source.app,
2642
2857
  framework: {
2643
2858
  core: app.framework.core,
2644
2859
  lucid: app.framework.lucid,
2645
2860
  orm: app.framework.orm
2646
2861
  },
2647
- dataStores: stores,
2648
- entryPoints,
2862
+ dataStores: stores.map((store) => ({
2863
+ ...store,
2864
+ id: emit(store.id),
2865
+ provenance: emitProvenance(store.provenance),
2866
+ attributes: store.attributes.map((a) => ({
2867
+ ...a,
2868
+ provenance: emitProvenance(a.provenance)
2869
+ }))
2870
+ })),
2871
+ entryPoints: entryPoints.map((entry) => ({
2872
+ ...entry,
2873
+ provenance: emitProvenance(entry.provenance),
2874
+ handler: entry.handler ? {
2875
+ ...entry.handler,
2876
+ file: emit(entry.handler.file)
2877
+ } : null
2878
+ })),
2649
2879
  behaviors: [...behaviors.entries()].map(([entryPointId, behavior]) => ({
2650
2880
  entryPointId,
2651
2881
  writes: behavior.writes,
@@ -2653,28 +2883,34 @@ async function analyze(root, options = {}) {
2653
2883
  inputFields: behavior.inputFields.map((name) => ({
2654
2884
  name,
2655
2885
  provenance: {
2656
- file: app.root,
2886
+ file: emit(app.root),
2657
2887
  by: "validator"
2658
2888
  }
2659
2889
  })),
2660
2890
  opaqueInputFields: behavior.opaqueInputFields.map((name) => ({
2661
2891
  name,
2662
2892
  provenance: {
2663
- file: app.root,
2893
+ file: emit(app.root),
2664
2894
  by: "validator"
2665
2895
  }
2666
2896
  })),
2667
2897
  requestFields: behavior.requestFields.map((name) => ({
2668
2898
  name,
2669
2899
  provenance: {
2670
- file: app.root,
2900
+ file: emit(app.root),
2671
2901
  by: "request"
2672
2902
  }
2673
2903
  })),
2674
2904
  opaqueRequest: behavior.opaqueRequest,
2675
2905
  outputFields: [],
2676
- trace: behavior.trace,
2677
- unresolved: behavior.unresolved
2906
+ trace: behavior.trace.map((step) => ({
2907
+ ...step,
2908
+ file: emit(step.file)
2909
+ })),
2910
+ unresolved: behavior.unresolved.map((call) => ({
2911
+ ...call,
2912
+ file: emit(call.file)
2913
+ }))
2678
2914
  })),
2679
2915
  coverage: {
2680
2916
  entryPointsTotal: entryPoints.length,
@@ -2696,7 +2932,7 @@ async function analyze(root, options = {}) {
2696
2932
  jsonSchemas,
2697
2933
  writtenAnywhere: analyzer.writtenAnywhere()
2698
2934
  }, options),
2699
- source: describeSource(root, options.configFile ?? null)
2935
+ source
2700
2936
  }
2701
2937
  };
2702
2938
  }
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { Node, Project, SyntaxKind } from "ts-morph";
2
3
  //#region src/inventory/paths.ts
3
4
  /**
@@ -20,6 +21,23 @@ import { Node, Project, SyntaxKind } from "ts-morph";
20
21
  * it to each comparison costs vigilance forever.
21
22
  */
22
23
  const toPosix = (value) => value.split("\\").join("/");
24
+ /**
25
+ * A path as it should appear in an EMITTED artefact: relative to the application.
26
+ *
27
+ * `CountSource.app` is documented as never being the absolute path, because that
28
+ * says where the machine keeps its files and travels with every count sent
29
+ * anywhere. One field below it, `config` shipped the absolute path — and so did
30
+ * every `trace[].file`, 858 times in a single production count. The rule was
31
+ * stated and then applied to one field.
32
+ *
33
+ * Internally the absolute path is the right thing: it is what ts-morph resolves
34
+ * and what the call graph keys its caches on. So this converts at the boundary
35
+ * where a path LEAVES, and nowhere else.
36
+ *
37
+ * A path outside the root keeps its `../` prefix, which describes where it is
38
+ * without naming the home directory.
39
+ */
40
+ const relativeTo = (root, value) => toPosix(path.relative(toPosix(root), toPosix(value))) || ".";
23
41
  /** Compares two paths that may have come from different sources. */
24
42
  const samePath = (a, b) => a !== void 0 && b !== void 0 && toPosix(a) === toPosix(b);
25
43
  //#endregion
@@ -819,4 +837,4 @@ function resolveCall(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
819
837
  return null;
820
838
  }
821
839
  //#endregion
822
- export { rootSymbolOf as a, toPosix as c, hooksFiredBy as i, resolveCall as n, collectEventBindings as o, detectAccess as r, samePath as s, BUILTIN_CALL_RESOLVERS as t };
840
+ export { rootSymbolOf as a, samePath as c, hooksFiredBy as i, toPosix as l, resolveCall as n, collectEventBindings as o, detectAccess as r, relativeTo as s, BUILTIN_CALL_RESOLVERS as t };
@@ -1,6 +1,6 @@
1
1
  import { c as diffCounts, i as measureStructure, l as DEFAULTS, n as parseSamples, o as IncomparableRulesetsError, r as measureConformance, s as IncomparableSourcesError, t as calibrate, u as defineConfig } from "./calibration-8eV8CEix.js";
2
- import { c as toPosix } from "./resolvers-CRB6lXoo.js";
3
- import { n as analyze } from "./pipeline-CIAydCcT.js";
2
+ import { l as toPosix } from "./resolvers-vMahHkAd.js";
3
+ import { n as analyze } from "./pipeline-Dm9KvUvF.js";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { existsSync } from "node:fs";
@@ -359,7 +359,17 @@ async function runMetrics(options) {
359
359
  async function runExplain(options) {
360
360
  const { config, notes } = await configFor(options.root);
361
361
  const { count } = await analyze(options.root, config);
362
- const matched = count.functions.filter((fn) => fn.name.toLowerCase().includes(options.name.toLowerCase()));
362
+ /**
363
+ * An exact name wins outright; the substring search is the fallback.
364
+ *
365
+ * `fp:explain "POST /orders/:param/submit"` returned four functions, because
366
+ * `/submit`, `/submit-ready` and `/submit-ready/return` all contain it. Asking
367
+ * about a function by its exact name and being handed its neighbours makes the
368
+ * command useless for the thing it exists for — defending one number.
369
+ */
370
+ const wanted = options.name.toLowerCase();
371
+ const exact = count.functions.filter((fn) => fn.name.toLowerCase() === wanted);
372
+ const matched = exact.length > 0 ? exact : count.functions.filter((fn) => fn.name.toLowerCase().includes(wanted));
363
373
  if (matched.length === 0) return {
364
374
  output: "",
365
375
  notes,
@@ -34,7 +34,7 @@ export declare const RULESET = "afp";
34
34
  * against this one and bills the tool's own improvement as work done. The guard
35
35
  * exists for exactly that, and only this constant arms it.
36
36
  */
37
- export declare const RULESET_VERSION = "1.2.0";
37
+ export declare const RULESET_VERSION = "1.3.0";
38
38
  export type CountInput = {
39
39
  app: AppContext;
40
40
  stores: CollectedDataStore[];
@@ -31,5 +31,14 @@ export type TransactionOptions = {
31
31
  messageDet: number;
32
32
  tables: Record<FunctionType, ComplexityTable>;
33
33
  weights: Record<FunctionType, Record<Complexity, number>>;
34
+ /**
35
+ * Application root, used only to relativise the paths that LEAVE in the trace.
36
+ *
37
+ * `CountSource.app` is documented as never being the absolute path, because it
38
+ * says where the machine keeps its files and travels with every count sent
39
+ * anywhere. The trace shipped the absolute path regardless — 858 times in a
40
+ * single production count, which is most of the artefact a ledger would store.
41
+ */
42
+ root: string;
34
43
  };
35
44
  export declare function countTransactionalFunctions(entryPoints: CollectedEntryPoint[], behaviors: Map<string, Behavior>, options: TransactionOptions): CountedFunction[];
package/build/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
- import { t as CoverageTooLowError } from "../pipeline-CIAydCcT.js";
2
- import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-CmxNHuuq.js";
1
+ import { t as CoverageTooLowError } from "../pipeline-Dm9KvUvF.js";
2
+ import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-DetZGfh5.js";
3
3
  import path from "node:path";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -161,9 +161,34 @@ export type FunctionOverride = {
161
161
  *
162
162
  * A name that matches no schema is a warning, never a silent fallback.
163
163
  */
164
- detFromSchema?: string;
164
+ /**
165
+ * Name of a declared schema, or several whose fields are UNIONED.
166
+ *
167
+ * An ILF's DETs are the fields the user recognises in the file, and an
168
+ * application with one schema per template recognises the fields of all of them.
169
+ * Pointing at the largest and justifying it in `reason` gives the same answer
170
+ * only while they land in the same complexity band — which is a piece of
171
+ * reasoning the configuration should not have to carry.
172
+ *
173
+ * Unioned by leaf path, so a field two templates share counts once.
174
+ */
175
+ detFromSchema?: string | string[];
165
176
  /** declared RET (data function) or FTR (transaction) */
166
177
  refs?: number;
178
+ /**
179
+ * Opaque DETs someone has looked at and decided are correct at 1.
180
+ *
181
+ * `fp:count` reports every opaque column and open input object, because 1 DET is
182
+ * a floor rather than a measurement. But some of them ARE one field — a copy, a
183
+ * checksum, a bag of metadata — and there was no way to say so, so the warning
184
+ * fired on every run forever. A warning that cannot be answered is a warning the
185
+ * team learns to scroll past, which costs more than the one it reports.
186
+ *
187
+ * It silences nothing else: the count does not move, and `fp:count` still says
188
+ * how many were reviewed. Names are matched bare (`schema`) or qualified
189
+ * (`Petition.schema`).
190
+ */
191
+ opaqueReviewed?: string[];
167
192
  /** why — required, and printed by `fp:explain` beside the number */
168
193
  reason: string;
169
194
  };
@@ -18,5 +18,22 @@
18
18
  * it to each comparison costs vigilance forever.
19
19
  */
20
20
  export declare const toPosix: (value: string) => string;
21
+ /**
22
+ * A path as it should appear in an EMITTED artefact: relative to the application.
23
+ *
24
+ * `CountSource.app` is documented as never being the absolute path, because that
25
+ * says where the machine keeps its files and travels with every count sent
26
+ * anywhere. One field below it, `config` shipped the absolute path — and so did
27
+ * every `trace[].file`, 858 times in a single production count. The rule was
28
+ * stated and then applied to one field.
29
+ *
30
+ * Internally the absolute path is the right thing: it is what ts-morph resolves
31
+ * and what the call graph keys its caches on. So this converts at the boundary
32
+ * where a path LEAVES, and nowhere else.
33
+ *
34
+ * A path outside the root keeps its `../` prefix, which describes where it is
35
+ * without naming the home directory.
36
+ */
37
+ export declare const relativeTo: (root: string, value: string) => string;
21
38
  /** Compares two paths that may have come from different sources. */
22
39
  export declare const samePath: (a: string | undefined, b: string | undefined) => boolean;
@@ -1,2 +1,2 @@
1
- import { n as resolveCall, t as BUILTIN_CALL_RESOLVERS } from "../../../resolvers-CRB6lXoo.js";
1
+ import { n as resolveCall, t as BUILTIN_CALL_RESOLVERS } from "../../../resolvers-vMahHkAd.js";
2
2
  export { BUILTIN_CALL_RESOLVERS, resolveCall };
@@ -33,7 +33,14 @@ export type CountSource = {
33
33
  */
34
34
  dirty?: boolean;
35
35
  countedAt: string;
36
- /** configuration file that shaped the count, or null for the defaults */
36
+ /**
37
+ * Configuration file that shaped the count, RELATIVE to the application root,
38
+ * or null for the defaults.
39
+ *
40
+ * Relative for the same reason `app` is a name: an absolute path says where the
41
+ * machine keeps its files, and this artefact is what goes into a ledger and to
42
+ * whoever receives the invoice.
43
+ */
37
44
  config: string | null;
38
45
  };
39
46
  export declare function describeSource(root: string, config: string | null): CountSource;
@@ -1,2 +1,2 @@
1
- import { n as analyze, t as CoverageTooLowError } from "../pipeline-CIAydCcT.js";
1
+ import { n as analyze, t as CoverageTooLowError } from "../pipeline-Dm9KvUvF.js";
2
2
  export { CoverageTooLowError, analyze };
@@ -42,7 +42,13 @@ export default defineConfig({
42
42
  * here.
43
43
  */
44
44
  // overrides: {
45
- // 'POST /forms': { det: 42, reason: 'JSON Schema form; 42 user fields' },
45
+ // // one schema, or several whose fields are unioned by leaf path
46
+ // Form: { detFromSchema: ['intakeSchema', 'reviewSchema'], reason: 'one per template' },
47
+ //
48
+ // // 1 DET is a floor, and `fp:count` says so on every run. When 1 IS the right
49
+ // // answer, record that someone checked — otherwise the warning becomes noise
50
+ // // the team learns to scroll past. It moves no number.
51
+ // Petition: { opaqueReviewed: ['schema', 'uiSchema'], reason: 'metadata; one field each' },
46
52
  // },
47
53
 
48
54
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@filipebraida/adonis-function-points",
3
3
  "description": "Automated function point counting and code metrics for AdonisJS applications.",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "engines": {
6
6
  "node": ">=24.0.0"
7
7
  },