@drkmttr/adhere 0.15.0 → 0.18.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.
@@ -288,9 +288,10 @@ const legend = (
288
288
  result: AuditResult,
289
289
  threshold: number,
290
290
  sufficiencyThreshold: number,
291
+ answerer = "Jev",
291
292
  ): ReadonlyArray<Line> => {
292
293
  const rows = [
293
- ["confidence", cellsOf(threshold), "Jev's probability that the file breaks the rule"],
294
+ ["confidence", cellsOf(threshold), `${answerer}'s probability that the file breaks the rule`],
294
295
  [
295
296
  "context",
296
297
  cellsOf(sufficiencyThreshold),
@@ -370,6 +371,8 @@ export interface RenderOptions {
370
371
  readonly threshold?: number;
371
372
  /** Below this, a finding's context is low and it carries a warning. Defaults to 0.6. */
372
373
  readonly sufficiencyThreshold?: number;
374
+ /** The model of the config's provider, which the legend names in place of Jev. */
375
+ readonly provider?: string;
373
376
  }
374
377
 
375
378
  /**
@@ -384,7 +387,12 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
384
387
  ...frame(finding, options.root, sufficiencyThreshold),
385
388
  [],
386
389
  ]),
387
- ...legend(result, options.threshold ?? DEFAULT_THRESHOLD, sufficiencyThreshold),
390
+ ...legend(
391
+ result,
392
+ options.threshold ?? DEFAULT_THRESHOLD,
393
+ sufficiencyThreshold,
394
+ options.provider,
395
+ ),
388
396
  ...summary(result),
389
397
  ...blockedFiles(result, options.root),
390
398
  ].map((line) => serialize(line, options.color === true));
@@ -444,51 +452,100 @@ const costLine = (plan: AuditPlan): string => {
444
452
  return `Those carry about ${tokensSaid(plan.tokens)} input tokens${priced}, and more for locating findings.`;
445
453
  };
446
454
 
455
+ /** Who answers the plan's requests: Jev, or the model of the config's provider. */
456
+ const answererOf = (plan: AuditPlan): string => plan.provider ?? "Jev";
457
+
458
+ const NATIVE_READS_CAVEAT =
459
+ "Estimates cover only code and workspace-package context; symbol/reference input, possible extra request splits, and size-skips are determined at send time.";
460
+
447
461
  export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
462
+ const native = plan.nativeReads === undefined ? [] : [NATIVE_READS_CAVEAT];
463
+ const locating = plan.cachedLocateRequests ?? 0;
448
464
  const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
449
465
  const skipped =
450
466
  plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
451
467
  const matching = (limits.filter?.length ?? 0) > 0 ? " matching the filter" : "";
452
468
  const found = `${counted(plan.files.length, "file", "files")}${matching} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
453
- const pending = plan.checks - plan.cached;
454
469
  const waiting =
455
470
  plan.deferred > 0
456
- ? ` The other ${plan.deferred} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
471
+ ? ` ${counted(plan.deferred, "file", "files")} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
457
472
  : "";
473
+ if (plan.planOnly === true) {
474
+ return [
475
+ found,
476
+ "Plan only (--limit 0): no native reads will be resolved and no requests will be sent.",
477
+ ...(plan.deferred > 0
478
+ ? [`${counted(plan.deferred, "file", "files")} left for a later run.`]
479
+ : []),
480
+ ...native,
481
+ ];
482
+ }
483
+ if (plan.requests === 0 && locating > 0) {
484
+ const pace =
485
+ limits.rpm === undefined
486
+ ? ""
487
+ : ` At ${limits.rpm} a minute, they take about ${durationAt(locating, limits.rpm)}.`;
488
+ return [
489
+ found,
490
+ `Locating cached findings takes an estimated ${counted(locating, "request", "requests")} to ${answererOf(plan)}.${pace}${waiting}`,
491
+ "Locating input tokens and cost are not estimated.",
492
+ ...native,
493
+ ];
494
+ }
458
495
  if (plan.requests === 0) {
459
496
  const none =
460
497
  plan.deferred > 0
461
498
  ? plan.deferred === 1
462
- ? "The limit leaves the 1 unjudged check for a later run."
463
- : `The limit leaves all ${plan.deferred} unjudged checks for a later run.`
499
+ ? "The limit leaves 1 file needing requests for a later run."
500
+ : `The limit leaves all ${plan.deferred} files needing requests for a later run.`
464
501
  : plan.checks === 0
465
502
  ? "Nothing to judge."
466
503
  : "The cache answers every check.";
467
- return [found, none];
504
+ return [found, none, ...native];
468
505
  }
469
- const judging = pending - plan.deferred;
470
- const rest =
471
- plan.deferred > 0
472
- ? `${judging} of ${plan.cached > 0 ? `the other ${pending}` : "them"}`
473
- : plan.cached > 0
474
- ? `the other ${pending}`
475
- : "them";
506
+ const judging = plan.files.filter(
507
+ (file) => !file.skipped && !file.deferred && Object.keys(file.pending).length > 0,
508
+ ).length;
509
+ const rest = counted(judging, "file", "files");
476
510
  const pace =
477
511
  limits.rpm === undefined
478
512
  ? ""
479
- : ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests, limits.rpm)}.`;
513
+ : ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests + locating, limits.rpm)}.`;
480
514
  return [
481
515
  found,
482
- `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
516
+ `Judging ${rest} takes ${plan.nativeReads === undefined ? "" : "an estimated "}${counted(plan.requests, "request", "requests")} to ${answererOf(plan)}, plus 1 or more for each file with a finding.${pace}${waiting}`,
483
517
  ...(plan.tokens > 0 ? [costLine(plan)] : []),
518
+ ...(locating > 0
519
+ ? [
520
+ `Known cached findings need an estimated ${counted(locating, "request", "requests")} to locate; locating input tokens and cost are not included.`,
521
+ ]
522
+ : []),
523
+ ...native,
484
524
  ];
485
525
  };
486
526
 
487
527
  /** The question before a run sends anything. */
488
528
  export const sendQuestion = (plan: AuditPlan): string => {
529
+ if (plan.planOnly === true) {
530
+ return "Plan only (--limit 0): no native reads will be resolved and no requests will be sent.";
531
+ }
489
532
  const cost = costOf(plan);
490
533
  const priced = cost === undefined || plan.tokens === 0 ? "" : `, ${dollars(cost)}`;
491
- return `Send ${counted(plan.requests, "request", "requests")} to Jev${priced}?`;
534
+ const estimate = plan.nativeReads === undefined ? "" : "an estimated ";
535
+ const caveat = plan.nativeReads === undefined ? "" : `${NATIVE_READS_CAVEAT} `;
536
+ const locating = plan.cachedLocateRequests ?? 0;
537
+ if (locating > 0) {
538
+ const work =
539
+ plan.requests === 0
540
+ ? "to locate cached findings"
541
+ : `(${counted(plan.requests, "request", "requests")} judging, ${counted(locating, "request", "requests")} locating cached findings)`;
542
+ const costs =
543
+ plan.requests === 0 || cost === undefined || plan.tokens === 0
544
+ ? "Locating input tokens and cost are not estimated. "
545
+ : `Judging costs ${dollars(cost)}; locating input tokens and cost are not estimated. `;
546
+ return `${caveat}${costs}Send an estimated ${counted(plan.requests + locating, "request", "requests")} to ${answererOf(plan)} ${work}?`;
547
+ }
548
+ return `${caveat}Send ${estimate}${counted(plan.requests, "request", "requests")} to ${answererOf(plan)}${priced}?`;
492
549
  };
493
550
 
494
551
  /** A run's progress so far: every file it finished, with the requests and findings they added up to. */
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { workspacePackagesIn } from "./workspaces.ts";
3
+
4
+ const catalog = {
5
+ "@repo/foo": "packages/foo",
6
+ "@repo/types": "packages/types",
7
+ "@repo/reexport": "packages/reexport",
8
+ "@repo/dynamic": "packages/dynamic",
9
+ legacy: "packages/legacy",
10
+ };
11
+
12
+ describe("file-local workspace packages", () => {
13
+ it("keeps static and type imports and re-exports under canonical package names", () => {
14
+ const code = `
15
+ import Foo, { value } from "@repo/foo/utils";
16
+ import type { Model } from "@repo/types";
17
+ import type DefaultModel from "@repo/types/model";
18
+ import { type OtherModel } from "@repo/types/other";
19
+ import * as Types from "@repo/types";
20
+ import "legacy/setup";
21
+ export { value as renamed } from "@repo/reexport/value";
22
+ export type { Shape } from "@repo/reexport/types";
23
+ export type * from "@repo/reexport";
24
+ export * as all from "@repo/reexport/all";
25
+ import Missing from "@repo/foobar";
26
+ import External from "external";
27
+ import Relative from "../@repo/foo";
28
+ `;
29
+ expect(workspacePackagesIn(code, catalog)).toEqual({
30
+ "@repo/foo": "packages/foo",
31
+ "@repo/types": "packages/types",
32
+ "@repo/reexport": "packages/reexport",
33
+ legacy: "packages/legacy",
34
+ });
35
+ expect(workspacePackagesIn("export const value = 1;", catalog)).toEqual({});
36
+ expect(catalog).toHaveProperty("@repo/dynamic");
37
+ });
38
+
39
+ it("ignores import text in comments, strings, regexes, and templates, and computed modules", () => {
40
+ const code = `
41
+ // import Foo from "@repo/foo";
42
+ /* export * from "@repo/reexport"; */
43
+ const quoted = 'import type { Model } from "@repo/types";';
44
+ const pattern = /require("legacy")/;
45
+ const template = \`import("@repo/dynamic")\`;
46
+ api.require("legacy");
47
+ api.import("@repo/foo");
48
+ api?.require("@repo/types");
49
+ import(moduleName);
50
+ require("@repo/foo/" + name);
51
+ import("@repo/dynamic" + suffix);
52
+ `;
53
+ expect(workspacePackagesIn(code, catalog)).toEqual({});
54
+ expect(workspacePackagesIn('import "@repo/foo";', {})).toEqual({});
55
+ });
56
+
57
+ it("reads escaped specifiers and literal import/require calls without a compiler", () => {
58
+ const code = String.raw`
59
+ import type { Model } from "\u0040repo\u002ftypes/model";
60
+ export * from '\x40repo/reexport';
61
+ const dynamic = import /* loader */ ("@repo/dynamic/utils", { with: { type: "json" } });
62
+ type Model = import("@repo/types").Model;
63
+ const legacy = require /* loader */ ('legacy/subpath',);
64
+ import Legacy = require("legacy");
65
+ const foo = require("\u{40}repo\/foo");
66
+ `;
67
+ expect(workspacePackagesIn(code, catalog)).toEqual(catalog);
68
+ expect(workspacePackagesIn("import(`@repo/foo/utils`)", catalog)).toEqual({
69
+ "@repo/foo": "packages/foo",
70
+ });
71
+ expect(workspacePackagesIn("import(`@repo/foo/${name}`)", catalog)).toEqual({});
72
+ });
73
+ });
package/src/workspaces.ts CHANGED
@@ -1,8 +1,99 @@
1
1
  import { Effect, FileSystem, Path } from "effect";
2
+ import { scan, type Token } from "#highlight.ts";
2
3
 
3
4
  /** A workspace's packages: each one's name, with its directory from the root. */
4
5
  export type WorkspacePackages = Readonly<Record<string, string>>;
5
6
 
7
+ const ESCAPES: Readonly<Record<string, string>> = {
8
+ n: "\n",
9
+ r: "\r",
10
+ t: "\t",
11
+ b: "\b",
12
+ f: "\f",
13
+ v: "\v",
14
+ "0": "\0",
15
+ };
16
+
17
+ /** Decode a quoted module name without evaluating code; only calls accept plain templates. */
18
+ const specifierOf = (token: Token | undefined, templates = false): string | undefined => {
19
+ if (token?.kind !== "string") return undefined;
20
+ const quote = token.text[0];
21
+ if (quote !== '"' && quote !== "'" && !(templates && quote === "`")) return undefined;
22
+ if (token.text.at(-1) !== quote) return undefined;
23
+ const body = token.text.slice(1, -1);
24
+ if (quote === "`" && /(?:^|[^\\])(?:\\\\)*\$\{/.test(body)) return undefined;
25
+ return body.replace(
26
+ /\\(u\{[\da-fA-F]{1,6}\}|u[\da-fA-F]{4}|x[\da-fA-F]{2}|[\s\S])/g,
27
+ (_, escaped: string) => {
28
+ if (/^(?:u\{|u[\da-fA-F]{4}$|x[\da-fA-F]{2}$)/.test(escaped)) {
29
+ const point = Number.parseInt(
30
+ escaped.startsWith("u{") ? escaped.slice(2, -1) : escaped.slice(1),
31
+ 16,
32
+ );
33
+ return point <= 0x10ffff ? String.fromCodePoint(point) : `\\${escaped}`;
34
+ }
35
+ return ESCAPES[escaped] ?? escaped;
36
+ },
37
+ );
38
+ };
39
+
40
+ /**
41
+ * Only packages named by this file's literal imports, type imports, re-exports,
42
+ * or import/require calls. Tokens keep comments, strings, regexes, and templates
43
+ * opaque; computed modules and imports inside template substitutions are not resolved.
44
+ * Subpaths identify their canonical package, without walking the global catalog.
45
+ */
46
+ export const workspacePackagesIn = (
47
+ code: string,
48
+ catalog: WorkspacePackages,
49
+ ): WorkspacePackages => {
50
+ const tokens = scan(code).flatMap((token) => {
51
+ if (token.kind === "comment" || token.text.trim() === "") return [];
52
+ // Highlighting groups punctuation; module clauses need individual delimiters.
53
+ return token.kind === undefined && /^[^\w$]+$/.test(token.text)
54
+ ? [...token.text].map((text): Token => ({ text }))
55
+ : [token];
56
+ });
57
+ const names = new Set<string>();
58
+ const keep = (specifier: string | undefined) => {
59
+ if (specifier === undefined || specifier.startsWith(".") || specifier.startsWith("/")) return;
60
+ const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
61
+ if (Object.hasOwn(catalog, name)) names.add(name);
62
+ };
63
+ for (const [index, token] of tokens.entries()) {
64
+ if (tokens[index - 1]?.text === ".") continue;
65
+ const next = tokens[index + 1];
66
+ if ((token.text === "import" || token.text === "require") && next?.text === "(") {
67
+ const end = tokens[index + 3]?.text;
68
+ if (end === ")" || end === ",") {
69
+ keep(specifierOf(tokens[index + 2], true));
70
+ }
71
+ continue;
72
+ }
73
+ if (token.kind !== "keyword" || (token.text !== "import" && token.text !== "export")) continue;
74
+ if (token.text === "import" && next?.kind === "string") {
75
+ keep(specifierOf(next));
76
+ continue;
77
+ }
78
+ const head = next?.text === "type" ? tokens[index + 2]?.text : next?.text;
79
+ if (token.text === "export" && head !== "{" && head !== "*") continue;
80
+ let depth = 0;
81
+ for (let at = index + 1; at < tokens.length; at += 1) {
82
+ const part = tokens[at]!;
83
+ if ([";", "=", "(", ")", "."].includes(part.text)) break;
84
+ if (part.text === "{") depth += 1;
85
+ else if (part.text === "}") depth -= 1;
86
+ if (depth !== 0) continue;
87
+ if (part.text === "from" && tokens[at + 1]?.kind === "string") {
88
+ keep(specifierOf(tokens[at + 1]));
89
+ break;
90
+ }
91
+ if (part.kind === "keyword" && !["type", "as", "from"].includes(part.text)) break;
92
+ }
93
+ }
94
+ return Object.fromEntries([...names].sort().map((name) => [name, catalog[name]!]));
95
+ };
96
+
6
97
  /** What JSON text holds, or nothing when it is not JSON. */
7
98
  const parsed = (text: string): unknown => {
8
99
  try {