Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/actionable-resolution-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@arethetypeswrong/core": patch
---

Report actionable internal resolution errors.
12 changes: 12 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,18 @@ attw --summary/--no-summary <file-name>

In the config file, `summary` can be a boolean value.

#### Verbose

Adds the full resolution trace to internal resolution error output. Defaults to off.

In the CLI: `--verbose`

```shell
attw --verbose <file-name>
```

In the config file, `verbose` can be a boolean value.

#### Emoji/No Emoji

Whether to print the information with emojis. Defaults to printing with emojis (`--emoji`).
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ particularly ESM-related module resolution issues.`,
new Option("--profile <profile>", "Specify analysis profile").choices(Object.keys(profiles)).default("strict"),
)
.option("--summary, --no-summary", "Whether to print summary information about the different errors")
.option("--verbose", "Print the full resolution trace for internal resolution errors")
.option("--emoji, --no-emoji", "Whether to use any emojis")
.option("--color, --no-color", "Whether to use any colors (the FORCE_COLOR env variable is also available)")
.option("--config-path <path>", "Path to config file (default: ./.attw.json)")
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface RenderOptions {
color?: boolean;
summary?: boolean;
emoji?: boolean;
verbose?: boolean;
}

export * from "./typed.js";
Expand Down
98 changes: 88 additions & 10 deletions packages/cli/src/render/typed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,23 @@ import type { RenderOptions } from "./index.js";

export async function typed(
analysis: core.Analysis,
{ emoji = true, summary = true, format = "auto", ignoreRules = [], ignoreResolutions = [] }: RenderOptions,
{
emoji = true,
summary = true,
format = "auto",
ignoreRules = [],
ignoreResolutions = [],
verbose = false,
}: RenderOptions,
): Promise<string> {
let output = "";
const problems = analysis.problems.filter(
(problem) => !ignoreRules || !ignoreRules.includes(problemFlags[problem.kind]),
);
const internalResolutionProblems = problems.filter(
(problem): problem is core.InternalResolutionErrorProblem => problem.kind === "InternalResolutionError",
);
const otherProblems = problems.filter((problem) => problem.kind !== "InternalResolutionError");
// sort resolutions with required (impacts result) first and ignored after
const requiredResolutions = allResolutionKinds.filter((kind) => !ignoreResolutions.includes(kind));
const ignoredResolutions = allResolutionKinds.filter((kind) => ignoreResolutions.includes(kind));
Expand Down Expand Up @@ -62,6 +73,28 @@ export async function typed(
const grouped = groupProblemsByKind(problems);
const summaryTexts = Object.entries(grouped).map(([kind, kindProblems]) => {
const info = problemKindInfo[kind as core.ProblemKind];
if (kind === "InternalResolutionError") {
const problems = kindProblems as core.InternalResolutionErrorProblem[];
const affectsRequiredResolution = problems.some((problem) =>
requiredResolutions.includes(problem.resolutionKind),
);
const description = marked(`${info.description} ${info.docsUrl}`);
const diagnostics = Array.from(
new Set(
problems.map((problem) => {
const ignoredPrefix = ignoreResolutions.includes(problem.resolutionKind)
? "(ignored per resolution) "
: "";
return `${ignoredPrefix}${formatInternalResolutionError(problem)}`;
}),
Comment thread
andrewbranch marked this conversation as resolved.
),
)
.map((line) => ` ${line}`)
.join("\n");
return `${affectsRequiredResolution ? "" : "(ignored per resolution) "}${
emoji ? `${info.emoji} ` : ""
}${description}${diagnostics}\n\n`;
}
const affectsRequiredResolution = kindProblems.some((p) =>
requiredResolutions.some((r) => problemAffectsResolutionKind(p, r, analysis)),
);
Expand All @@ -76,30 +109,61 @@ export async function typed(
out(summaryTexts.join("") || defaultSummary);
}

const tracedInternalResolutionProblems = verbose
? internalResolutionProblems.filter((problem) => problem.trace.length)
: [];
if (tracedInternalResolutionProblems.length) {
out("Internal resolution traces:");
for (const problem of tracedInternalResolutionProblems) {
out();
out(
`Internal resolution trace for ${quote(problem.moduleSpecifier)} from entrypoint ${quote(
getEntrypointName(problem.entrypoint, analysis.packageName),
)} using ${problem.resolutionKind}:`,
);
out(problem.trace.map((line) => ` ${line}`).join("\n"));
}
out();
}

const entrypointNames = entrypoints.map(
(s) => `"${s === "." ? analysis.packageName : `${analysis.packageName}/${s.substring(2)}`}"`,
);
const entrypointHeaders = entrypoints.map((s, i) => {
const hasProblems = problems.some((p) => problemAffectsEntrypoint(p, s, analysis));
const hasProblems = problems.some((p) =>
p.kind === "InternalResolutionError" ? p.entrypoint === s : problemAffectsEntrypoint(p, s, analysis),
);
const color = hasProblems ? "redBright" : "greenBright";
return chalk.bold[color](entrypointNames[i]);
});

const getCellContents = memo((subpath: string, resolutionKind: core.ResolutionKind) => {
const ignoredPrefix = ignoreResolutions.includes(resolutionKind) ? "(ignored) " : "";
const problemsForCell = groupProblemsByKind(
filterProblems(problems, analysis, { entrypoint: subpath, resolutionKind }),
filterProblems(otherProblems, analysis, { entrypoint: subpath, resolutionKind }),
);
const internalResolutionProblemsForCell = internalResolutionProblems.filter(
(problem) => problem.entrypoint === subpath && problem.resolutionKind === resolutionKind,
);
const entrypoint = analysis.entrypoints[subpath].resolutions[resolutionKind];
const resolution = entrypoint.resolution;
const kinds = Object.keys(problemsForCell) as core.ProblemKind[];
if (kinds.length) {
return kinds
.map(
(kind) =>
ignoredPrefix + (emoji ? `${problemKindInfo[kind].emoji} ` : "") + problemKindInfo[kind].shortDescription,
)
.join("\n");
if (kinds.length || internalResolutionProblemsForCell.length) {
const lines = kinds.map(
(kind) =>
ignoredPrefix + (emoji ? `${problemKindInfo[kind].emoji} ` : "") + problemKindInfo[kind].shortDescription,
);
lines.push(
...internalResolutionProblemsForCell.map(
(problem) =>
ignoredPrefix +
(emoji ? `${problemKindInfo.InternalResolutionError.emoji} ` : "") +
(verbose
? formatInternalResolutionError(problem)
: `${problemKindInfo.InternalResolutionError.shortDescription}: ${quote(problem.moduleSpecifier)}`),
),
);
return lines.join("\n");
}

const jsonResult = !emoji ? "OK (JSON)" : "🟢 (JSON)";
Expand Down Expand Up @@ -187,3 +251,17 @@ function memo<Args extends (string | number)[], Result>(fn: (...args: Args) => R
return result;
};
}

function formatInternalResolutionError(problem: core.InternalResolutionErrorProblem): string {
return `${quote(problem.moduleSpecifier)} failed to resolve using ${problem.resolutionKind} from ${quote(
problem.fileName,
)}`;
Comment on lines +255 to +258
}

function getEntrypointName(entrypoint: string, packageName: string): string {
return entrypoint === "." ? packageName : `${packageName}/${entrypoint.replace(/^\.\//, "")}`;
}

function quote(value: string): string {
return `'${value.replace(/'/g, "\\'")}'`;
}
145 changes: 145 additions & 0 deletions packages/cli/test/render/typed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import assert from "node:assert";
import { test } from "node:test";
import type {
Analysis,
EntrypointResolutionAnalysis,
InternalResolutionErrorProblem,
ResolutionKind,
} from "@arethetypeswrong/core";
import { typed } from "@arethetypeswrong/cli/internal/render";

type StructuredInternalResolutionErrorProblem = InternalResolutionErrorProblem & {
entrypoint: string;
resolutionKind: ResolutionKind;
resolutionModeName: "esm" | "cjs";
conditions?: string[];
resolverMessage?: string;
};

const trace = [
"Resolving in ESM mode with conditions 'import', 'types', 'node'.",
"======== Module name './missing' was not resolved. ========",
];

const problem: StructuredInternalResolutionErrorProblem = {
kind: "InternalResolutionError",
entrypoint: ".",
resolutionKind: "node16-esm",
resolutionOption: "node16",
resolutionMode: 99 as InternalResolutionErrorProblem["resolutionMode"],
resolutionModeName: "esm",
fileName: "/node_modules/fixture/index.d.mts",
moduleSpecifier: "./missing",
conditions: ["import", "types", "node"],
resolverMessage: "Module name './missing' was not resolved.",
pos: 0,
end: 11,
trace,
};

function resolution(resolutionKind: ResolutionKind, visibleProblems?: number[]): EntrypointResolutionAnalysis {
return {
name: "fixture",
resolutionKind,
visibleProblems,
};
}

function analysis(problems: Analysis["problems"] = [problem]): Analysis {
return {
packageName: "fixture",
packageVersion: "1.0.0",
buildTools: {},
types: { kind: "included" },
entrypoints: {
".": {
subpath: ".",
hasTypes: true,
isWildcard: false,
resolutions: {
node10: resolution("node10"),
"node16-cjs": resolution("node16-cjs"),
"node16-esm": resolution("node16-esm", problems.length ? [0] : undefined),
bundler: resolution("bundler"),
},
},
},
programInfo: {
node10: {},
node16: { moduleKinds: {} },
bundler: {},
},
problems,
};
}

test("typed renders actionable internal resolution diagnostics and verbose traces", async () => {
const concise = await typed(analysis(), { emoji: false, format: "ascii" });
assert.match(
concise,
/'\.\/missing' failed to resolve using node16-esm from '\/node_modules\/fixture\/index\.d\.mts'/,
);
assert.match(concise, /\n {2}'\.\/missing' failed to resolve using node16-esm/);
assert.doesNotMatch(concise, /for entrypoint/);
assert.doesNotMatch(concise, /with conditions/);
assert.doesNotMatch(concise, /from declaration/);
assert.doesNotMatch(concise, /Module name '\.\/missing' was not resolved/);
assert.doesNotMatch(concise, /Use -f json/);

const verbose = await typed(analysis(), {
emoji: false,
format: "ascii",
verbose: true,
});
assert.match(verbose, /Internal resolution trace for '\.\/missing'/);
for (const line of trace) {
assert.ok(verbose.includes(line));
}
});

test("typed dedupes internal resolution diagnostics across entrypoints in the summary", async () => {
const duplicate: StructuredInternalResolutionErrorProblem = { ...problem, entrypoint: "./sub" };
const concise = await typed(analysis([problem, duplicate]), { emoji: false, format: "ascii" });
const matches =
concise.match(/'\.\/missing' failed to resolve using node16-esm from '\/node_modules\/fixture\/index\.d\.mts'/g) ??
[];
assert.strictEqual(matches.length, 1);
});

test("typed keeps table cells concise when summary is disabled", async () => {
const output = await typed(analysis(), {
emoji: false,
format: "ascii",
summary: false,
});
assert.match(output, /Internal resolution error: '\.\/missing'/);
assert.doesNotMatch(output, /with conditions 'import', 'types', 'node'/);
assert.doesNotMatch(output, /from declaration '\/node_modules\/fixture\/index\.d\.mts'/);
});

test("typed accepts verbose mode without internal resolution errors", async () => {
const output = await typed(analysis([]), {
emoji: false,
format: "ascii",
verbose: true,
});
assert.doesNotMatch(output, /Internal resolution trace/);
});

test("typed omits unavailable diagnostic parts for an unusual declaration path", async () => {
const incompleteProblem: StructuredInternalResolutionErrorProblem = {
...problem,
fileName: "/",
conditions: undefined,
resolverMessage: undefined,
trace: [],
};
const output = await typed(analysis([incompleteProblem]), {
emoji: false,
format: "ascii",
verbose: true,
});
assert.match(output, /from '\/'/);
assert.doesNotMatch(output, /from declaration/);
assert.doesNotMatch(output, /undefined|Internal resolution trace/);
});
Loading