@drkmttr/adhere 0.18.1 → 0.23.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/README.md +62 -1575
- package/package.json +18 -15
- package/presets/README.md +140 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +194 -29
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +60 -26
- package/src/cli.ts +334 -36
- package/src/config.ts +173 -142
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +363 -0
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +191 -0
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +562 -0
- package/src/create/trial.ts +611 -0
- package/src/create/wizard.ts +466 -0
- package/src/index.ts +2 -0
- package/src/init.ts +171 -41
- package/src/install.ts +296 -94
- package/src/instructions.ts +98 -0
- package/src/markdown.ts +15 -11
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +67 -21
- package/src/services/AuditCache.ts +176 -169
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +22 -0
- package/src/services/Jev.ts +35 -13
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +4 -4
- package/src/workflows/audit.ts +85 -14
- package/src/workflows/format.ts +98 -1
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
package/src/workflows/audit.ts
CHANGED
|
@@ -40,7 +40,13 @@ import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
|
40
40
|
import { priceOf } from "#pricing.ts";
|
|
41
41
|
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
42
42
|
import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
dependentFilesIn,
|
|
45
|
+
importSpecifiersIn,
|
|
46
|
+
packageOf,
|
|
47
|
+
workspacePackagesIn,
|
|
48
|
+
} from "#workspaces.ts";
|
|
49
|
+
import { relative, sep } from "node:path";
|
|
44
50
|
import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
|
|
45
51
|
|
|
46
52
|
export interface Finding {
|
|
@@ -125,19 +131,24 @@ const LAYOUT = "sections";
|
|
|
125
131
|
|
|
126
132
|
/**
|
|
127
133
|
* What a judgment depends on besides the file: the model, how the file is
|
|
128
|
-
* laid out,
|
|
129
|
-
* judge question, and its matchers' when it
|
|
130
|
-
* matcher re-judges the rule, and a question asked
|
|
131
|
-
* every rule. So does an edited
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
+
* laid out, what the config has Jev read, and the questions asked for the
|
|
135
|
+
* rule, which carry the rule: its judge question, and its matchers' when it
|
|
136
|
+
* has any. An edited rule or matcher re-judges the rule, and a question asked
|
|
137
|
+
* differently, or other reads, re-judge every rule. So does an edited
|
|
138
|
+
* `appendState`, by its source; what the hook reads outside the file is not
|
|
139
|
+
* known until it runs, so a change there does not. Options set differently
|
|
140
|
+
* re-judge their rule: decoded, a struct's keys come in its schema's order,
|
|
141
|
+
* however the config writes them. What the reads find, such as related
|
|
142
|
+
* files, does not invalidate judgments.
|
|
134
143
|
*/
|
|
135
|
-
const fingerprintOf = (model: string, rule: Rule) => {
|
|
144
|
+
const fingerprintOf = (model: string, reads: string, rule: Rule) => {
|
|
136
145
|
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
137
146
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
138
147
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
148
|
+
const options =
|
|
149
|
+
rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
|
|
139
150
|
return sha256(
|
|
140
|
-
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
|
|
151
|
+
`${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
|
|
141
152
|
);
|
|
142
153
|
};
|
|
143
154
|
|
|
@@ -368,9 +379,14 @@ const hasLocatedFindings = (plan: FilePlan): boolean =>
|
|
|
368
379
|
);
|
|
369
380
|
});
|
|
370
381
|
|
|
382
|
+
/** At most this many of a file's dependent files are listed; the rest are counted. */
|
|
383
|
+
const MAX_DEPENDENT_FILES = 50;
|
|
384
|
+
|
|
371
385
|
export interface PlanOptions {
|
|
372
386
|
/** Select at most this many files needing judge or cached-locate requests; zero plans only. */
|
|
373
387
|
readonly limit?: number | undefined;
|
|
388
|
+
/** The checkout Jev's paths are relative to. Defaults to the working directory. */
|
|
389
|
+
readonly root?: string | undefined;
|
|
374
390
|
}
|
|
375
391
|
|
|
376
392
|
export const planAudit = (
|
|
@@ -386,6 +402,7 @@ export const planAudit = (
|
|
|
386
402
|
const workspacePackages = config.reads.includes("workspacePackages")
|
|
387
403
|
? (config.workspacePackages ?? {})
|
|
388
404
|
: undefined;
|
|
405
|
+
const catalog = config.workspacePackages ?? {};
|
|
389
406
|
const nativeReads: SourceReadRequest = {
|
|
390
407
|
symbols: config.typeAware,
|
|
391
408
|
references: config.typeAware,
|
|
@@ -395,6 +412,40 @@ export const planAudit = (
|
|
|
395
412
|
};
|
|
396
413
|
const walker = yield* SourceWalker;
|
|
397
414
|
const files = yield* walker.files;
|
|
415
|
+
// Each file's path from the checkout, `/`-separated, as Jev reads it.
|
|
416
|
+
const root = options.root ?? process.cwd();
|
|
417
|
+
const fromRoot = new Map(
|
|
418
|
+
files.map((file) => [file.path, relative(root, file.path).split(sep).join("/")]),
|
|
419
|
+
);
|
|
420
|
+
// Only the specifiers stay in memory: each file is read again when it is planned.
|
|
421
|
+
const dependentFiles = config.reads.includes("dependents")
|
|
422
|
+
? dependentFilesIn(
|
|
423
|
+
new Map(
|
|
424
|
+
yield* Effect.forEach(
|
|
425
|
+
files,
|
|
426
|
+
(file) =>
|
|
427
|
+
Effect.map(
|
|
428
|
+
walker.read(file),
|
|
429
|
+
({ lines }) =>
|
|
430
|
+
[fromRoot.get(file.path)!, importSpecifiersIn(lines.join("\n"))] as const,
|
|
431
|
+
),
|
|
432
|
+
{ concurrency: 8 },
|
|
433
|
+
),
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
: undefined;
|
|
437
|
+
const dependentsOf = (file: SourceFile) => {
|
|
438
|
+
const at = fromRoot.get(file.path)!;
|
|
439
|
+
const name = packageOf(at, catalog);
|
|
440
|
+
const importers = dependentFiles?.get(at) ?? [];
|
|
441
|
+
return {
|
|
442
|
+
packages: (name === undefined ? undefined : config.workspaceDependents?.[name]) ?? {},
|
|
443
|
+
files: importers.slice(0, MAX_DEPENDENT_FILES),
|
|
444
|
+
...(importers.length > MAX_DEPENDENT_FILES
|
|
445
|
+
? { omittedFiles: importers.length - MAX_DEPENDENT_FILES }
|
|
446
|
+
: {}),
|
|
447
|
+
};
|
|
448
|
+
};
|
|
398
449
|
|
|
399
450
|
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
400
451
|
const presetOfRule = new Map(
|
|
@@ -416,11 +467,23 @@ export const planAudit = (
|
|
|
416
467
|
);
|
|
417
468
|
|
|
418
469
|
// Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
|
|
470
|
+
// The reads that take effect key every judgment; code-only keeps the key it always had.
|
|
471
|
+
// includeComments shapes other files' `references` excerpts, which the target's hash misses.
|
|
472
|
+
const reads = [
|
|
473
|
+
...(workspacePackages === undefined ? [] : ["workspacePackages"]),
|
|
474
|
+
...(dependentFiles === undefined ? [] : ["dependents"]),
|
|
475
|
+
...(config.typeAware
|
|
476
|
+
? [
|
|
477
|
+
`symbols:${config.maxSymbolLength}:${config.maxSymbolDocumentationLength}:${config.includeComments === true}`,
|
|
478
|
+
]
|
|
479
|
+
: []),
|
|
480
|
+
];
|
|
481
|
+
const readsKey = reads.length === 0 ? "" : `\u0000${reads.join(",")}`;
|
|
419
482
|
const preparedRules = new Map<Rule, PreparedRule>(
|
|
420
483
|
yield* Effect.forEach(
|
|
421
484
|
new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
|
|
422
485
|
(rule) =>
|
|
423
|
-
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
486
|
+
Effect.map(fingerprintOf(config.model, readsKey, rule), (fingerprint) => {
|
|
424
487
|
const preset = presetOfRule.get(rule);
|
|
425
488
|
return [
|
|
426
489
|
rule,
|
|
@@ -440,10 +503,12 @@ export const planAudit = (
|
|
|
440
503
|
const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
|
|
441
504
|
const rules = configuredRules(file, suppressions);
|
|
442
505
|
const code = lines.join("\n");
|
|
443
|
-
const readState: ReadState =
|
|
444
|
-
workspacePackages === undefined
|
|
506
|
+
const readState: ReadState = {
|
|
507
|
+
...(workspacePackages === undefined
|
|
445
508
|
? {}
|
|
446
|
-
: { workspacePackages: workspacePackagesIn(code, workspacePackages) }
|
|
509
|
+
: { workspacePackages: workspacePackagesIn(code, workspacePackages) }),
|
|
510
|
+
...(dependentFiles === undefined ? {} : { dependents: dependentsOf(file) }),
|
|
511
|
+
};
|
|
447
512
|
// Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
|
|
448
513
|
const hash = yield* sha256(code);
|
|
449
514
|
const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
|
|
@@ -802,7 +867,13 @@ export const executeAudit = (
|
|
|
802
867
|
const room = questionRoom(file.lines, readState);
|
|
803
868
|
const sentSamples = Record.filter(sampled, (_, id) => {
|
|
804
869
|
const k = pending[id];
|
|
805
|
-
|
|
870
|
+
if (k === undefined) return false;
|
|
871
|
+
// A rule with options asks beside them, in a request of its own.
|
|
872
|
+
const left =
|
|
873
|
+
k.rule.optionValues === undefined
|
|
874
|
+
? room
|
|
875
|
+
: questionRoom(file.lines, { ...readState, options: k.rule.optionValues });
|
|
876
|
+
return tokensOf(linterQuestion(k.rule)) <= left;
|
|
806
877
|
});
|
|
807
878
|
const sampleIds = Object.keys(sentSamples);
|
|
808
879
|
const { probabilities, linter, matchers }: Judged = isEmpty(pending)
|
package/src/workflows/format.ts
CHANGED
|
@@ -89,6 +89,10 @@ const hanging = (label: Span, lines: ReadonlyArray<Line>): ReadonlyArray<Line> =
|
|
|
89
89
|
const highlighted = (code: string): ReadonlyArray<Line> =>
|
|
90
90
|
tokenize(code).map((tokens) => tokens.map(({ text, kind }) => span(text, kind)));
|
|
91
91
|
|
|
92
|
+
/** Code as lines of text, highlighted as a report highlights it when `color`. */
|
|
93
|
+
export const highlightedLines = (code: string, color: boolean): ReadonlyArray<string> =>
|
|
94
|
+
highlighted(code).map((line) => serialize(line, color));
|
|
95
|
+
|
|
92
96
|
/** Code as highlighted lines, without the blank lines at either end. */
|
|
93
97
|
const codeLines = (code: string): ReadonlyArray<Line> => {
|
|
94
98
|
const lines = highlighted(code);
|
|
@@ -210,7 +214,7 @@ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line
|
|
|
210
214
|
/**
|
|
211
215
|
* The warning on a finding whose file may not show enough to check its rule,
|
|
212
216
|
* which its context score in the header says how far, and help: how to add
|
|
213
|
-
* what the code relies on as a note Jev reads, in the form the
|
|
217
|
+
* what the code relies on as a note Jev reads, in the form the docs ask for.
|
|
214
218
|
*/
|
|
215
219
|
const insufficient = (finding: Finding, sufficiencyThreshold: number): ReadonlyArray<Line> =>
|
|
216
220
|
!lacksContext(finding, sufficiencyThreshold)
|
|
@@ -398,6 +402,99 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
398
402
|
].map((line) => serialize(line, options.color === true));
|
|
399
403
|
};
|
|
400
404
|
|
|
405
|
+
/** The complete audit result, with the same rule ids and relative paths as the text report. */
|
|
406
|
+
export const renderJson = (result: AuditResult, options: RenderOptions = {}): string =>
|
|
407
|
+
JSON.stringify(
|
|
408
|
+
{
|
|
409
|
+
...result,
|
|
410
|
+
threshold: options.threshold ?? DEFAULT_THRESHOLD,
|
|
411
|
+
sufficiencyThreshold: options.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
|
|
412
|
+
...(options.provider === undefined ? {} : { provider: options.provider }),
|
|
413
|
+
findings: result.findings.map((finding) => ({
|
|
414
|
+
...finding,
|
|
415
|
+
rule: shownId({ id: finding.rule, preset: finding.preset }),
|
|
416
|
+
file: displayPath(finding.file, options.root),
|
|
417
|
+
})),
|
|
418
|
+
blocked: result.blocked.map((blocked) => ({
|
|
419
|
+
...blocked,
|
|
420
|
+
file: displayPath(blocked.file, options.root),
|
|
421
|
+
})),
|
|
422
|
+
},
|
|
423
|
+
null,
|
|
424
|
+
2,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const markdownText = (text: string): string => text.replace(/[\\`*_[\]<>#]/g, "\\$&");
|
|
428
|
+
|
|
429
|
+
/** A fence longer than any backtick run in the code, including template literals. */
|
|
430
|
+
const fenced = (code: string): string => {
|
|
431
|
+
const fence = "`".repeat(
|
|
432
|
+
Math.max(3, ...Array.from(code.matchAll(/`+/g), ([run]) => run.length + 1)),
|
|
433
|
+
);
|
|
434
|
+
return `${fence}typescript\n${code}\n${fence}`;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** All findings grouped by rule, followed by the run summary once. */
|
|
438
|
+
export const renderMarkdown = (result: AuditResult, options: RenderOptions = {}): string => {
|
|
439
|
+
const sufficiencyThreshold = options.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD;
|
|
440
|
+
const groups = Map.groupBy(result.findings, (finding) =>
|
|
441
|
+
shownId({ id: finding.rule, preset: finding.preset }),
|
|
442
|
+
);
|
|
443
|
+
return [
|
|
444
|
+
...Array.from(groups, ([id, findings]) =>
|
|
445
|
+
[
|
|
446
|
+
`# ${markdownText(id)}`,
|
|
447
|
+
counted(findings.length, "finding", "findings"),
|
|
448
|
+
...findings.flatMap((finding) => {
|
|
449
|
+
const lines = finding.lines ?? finding.section;
|
|
450
|
+
const location = `${displayPath(finding.file, options.root)}:${lines.first}${lines.last === lines.first ? "" : `–${lines.last}`}`;
|
|
451
|
+
return [
|
|
452
|
+
`## ${markdownText(location)}`,
|
|
453
|
+
finding.description,
|
|
454
|
+
`Level: **${finding.level}** · Confidence: ${finding.probability} · Threshold: ${finding.threshold}${finding.context === undefined ? "" : ` · Context: ${finding.context}`}`,
|
|
455
|
+
...(lacksContext(finding, sufficiencyThreshold)
|
|
456
|
+
? [
|
|
457
|
+
`> Warning: this file may not show enough to check this rule (context below ${sufficiencyThreshold}). Add missing context with an \`@adhere\` note.`,
|
|
458
|
+
]
|
|
459
|
+
: []),
|
|
460
|
+
`Excerpt starting at line ${finding.excerpt.start}:`,
|
|
461
|
+
fenced(finding.excerpt.lines.join("\n")),
|
|
462
|
+
...[finding.examples.good, finding.examples.bad].flatMap((example) =>
|
|
463
|
+
example === undefined ? [] : [`### ${example.word}`, fenced(example.code.trim())],
|
|
464
|
+
),
|
|
465
|
+
];
|
|
466
|
+
}),
|
|
467
|
+
].join("\n\n"),
|
|
468
|
+
),
|
|
469
|
+
"## Run summary",
|
|
470
|
+
...summary(result).map((line) => serialize(line, false)),
|
|
471
|
+
...blockedFiles(result, options.root)
|
|
472
|
+
.filter((line) => line.length > 0)
|
|
473
|
+
.map((line) => serialize(line, false)),
|
|
474
|
+
].join("\n\n");
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/** One file per violated rule in each requested format, with run counts retained as context. */
|
|
478
|
+
export const renderRuleReports = (
|
|
479
|
+
result: AuditResult,
|
|
480
|
+
formats: { readonly json: boolean; readonly md: boolean },
|
|
481
|
+
options: RenderOptions = {},
|
|
482
|
+
): ReadonlyMap<string, string> => {
|
|
483
|
+
const groups = Map.groupBy(result.findings, (finding) =>
|
|
484
|
+
shownId({ id: finding.rule, preset: finding.preset }),
|
|
485
|
+
);
|
|
486
|
+
return new Map(
|
|
487
|
+
Array.from(groups).flatMap(([id, findings]): Array<[string, string]> => {
|
|
488
|
+
const name = encodeURIComponent(id);
|
|
489
|
+
const report = { ...result, findings };
|
|
490
|
+
const files: Array<[string, string]> = [];
|
|
491
|
+
if (formats.json) files.push([`${name}.json`, `${renderJson(report, options)}\n`]);
|
|
492
|
+
if (formats.md) files.push([`${name}.md`, `${renderMarkdown(report, options)}\n`]);
|
|
493
|
+
return files;
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
};
|
|
497
|
+
|
|
401
498
|
/**
|
|
402
499
|
* What a run will do, before it asks: the files and rules found, the checks
|
|
403
500
|
* between them and how many the cache answers, then what judging the rest
|
package/src/workspaces.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
dependentFilesIn,
|
|
4
|
+
importSpecifiersIn,
|
|
5
|
+
packageOf,
|
|
6
|
+
workspacePackagesIn,
|
|
7
|
+
} from "./workspaces.ts";
|
|
3
8
|
|
|
4
9
|
const catalog = {
|
|
5
10
|
"@repo/foo": "packages/foo",
|
|
@@ -71,3 +76,34 @@ describe("file-local workspace packages", () => {
|
|
|
71
76
|
expect(workspacePackagesIn("import(`@repo/foo/${name}`)", catalog)).toEqual({});
|
|
72
77
|
});
|
|
73
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("dependent files", () => {
|
|
81
|
+
it("resolves relative specifiers to known files as written, by emitted extension, added extension, or index", () => {
|
|
82
|
+
const dependents = dependentFilesIn(
|
|
83
|
+
new Map([
|
|
84
|
+
["src/a.ts", importSpecifiersIn('import { b } from "./b.js"; export * from "./lib";')],
|
|
85
|
+
["src/b.ts", importSpecifiersIn('import "../util"; import x from "pkg/./b";')],
|
|
86
|
+
[
|
|
87
|
+
"src/lib/index.ts",
|
|
88
|
+
importSpecifiersIn('const self = require("./index.ts"); import("..");'),
|
|
89
|
+
],
|
|
90
|
+
["src/c.tsx", importSpecifiersIn('import { b } from "./b"; import "./missing";')],
|
|
91
|
+
["util.ts", []],
|
|
92
|
+
["src/index.ts", []],
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
expect(Object.fromEntries(dependents)).toEqual({
|
|
96
|
+
"src/b.ts": ["src/a.ts", "src/c.tsx"],
|
|
97
|
+
"src/lib/index.ts": ["src/a.ts"],
|
|
98
|
+
"util.ts": ["src/b.ts"],
|
|
99
|
+
"src/index.ts": ["src/lib/index.ts"],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("places a file in the deepest package directory that holds it", () => {
|
|
104
|
+
const catalog = { outer: "packages/outer", inner: "packages/outer/inner", other: "packages/o" };
|
|
105
|
+
expect(packageOf("packages/outer/inner/src/a.ts", catalog)).toBe("inner");
|
|
106
|
+
expect(packageOf("packages/outer/src/a.ts", catalog)).toBe("outer");
|
|
107
|
+
expect(packageOf("packages/outer-x/a.ts", catalog)).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/workspaces.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
+
import { posix } from "node:path";
|
|
2
3
|
import { scan, type Token } from "#highlight.ts";
|
|
3
4
|
|
|
4
5
|
/** A workspace's packages: each one's name, with its directory from the root. */
|
|
@@ -38,15 +39,12 @@ const specifierOf = (token: Token | undefined, templates = false): string | unde
|
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
* or import/require calls. Tokens keep comments,
|
|
43
|
-
* opaque; computed modules and imports inside
|
|
44
|
-
*
|
|
42
|
+
* Every module specifier named by this file's literal imports, type imports,
|
|
43
|
+
* re-exports, or import/require calls, once each, in order. Tokens keep comments,
|
|
44
|
+
* strings, regexes, and templates opaque; computed modules and imports inside
|
|
45
|
+
* template substitutions are not resolved.
|
|
45
46
|
*/
|
|
46
|
-
export const
|
|
47
|
-
code: string,
|
|
48
|
-
catalog: WorkspacePackages,
|
|
49
|
-
): WorkspacePackages => {
|
|
47
|
+
export const importSpecifiersIn = (code: string): ReadonlyArray<string> => {
|
|
50
48
|
const tokens = scan(code).flatMap((token) => {
|
|
51
49
|
if (token.kind === "comment" || token.text.trim() === "") return [];
|
|
52
50
|
// Highlighting groups punctuation; module clauses need individual delimiters.
|
|
@@ -54,11 +52,9 @@ export const workspacePackagesIn = (
|
|
|
54
52
|
? [...token.text].map((text): Token => ({ text }))
|
|
55
53
|
: [token];
|
|
56
54
|
});
|
|
57
|
-
const
|
|
55
|
+
const specifiers = new Set<string>();
|
|
58
56
|
const keep = (specifier: string | undefined) => {
|
|
59
|
-
if (specifier
|
|
60
|
-
const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
|
|
61
|
-
if (Object.hasOwn(catalog, name)) names.add(name);
|
|
57
|
+
if (specifier !== undefined) specifiers.add(specifier);
|
|
62
58
|
};
|
|
63
59
|
for (const [index, token] of tokens.entries()) {
|
|
64
60
|
if (tokens[index - 1]?.text === ".") continue;
|
|
@@ -91,6 +87,23 @@ export const workspacePackagesIn = (
|
|
|
91
87
|
if (part.kind === "keyword" && !["type", "as", "from"].includes(part.text)) break;
|
|
92
88
|
}
|
|
93
89
|
}
|
|
90
|
+
return [...specifiers];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Only packages this file imports, by `importSpecifiersIn`. Subpaths identify
|
|
95
|
+
* their canonical package, without walking the global catalog.
|
|
96
|
+
*/
|
|
97
|
+
export const workspacePackagesIn = (
|
|
98
|
+
code: string,
|
|
99
|
+
catalog: WorkspacePackages,
|
|
100
|
+
): WorkspacePackages => {
|
|
101
|
+
const names = new Set<string>();
|
|
102
|
+
for (const specifier of importSpecifiersIn(code)) {
|
|
103
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) continue;
|
|
104
|
+
const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
|
|
105
|
+
if (Object.hasOwn(catalog, name)) names.add(name);
|
|
106
|
+
}
|
|
94
107
|
return Object.fromEntries([...names].sort().map((name) => [name, catalog[name]!]));
|
|
95
108
|
};
|
|
96
109
|
|
|
@@ -230,3 +243,99 @@ export const workspacePackagesOf = Effect.fn("workspacePackagesOf")(function* (r
|
|
|
230
243
|
named.flat().sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
231
244
|
) as WorkspacePackages;
|
|
232
245
|
});
|
|
246
|
+
|
|
247
|
+
const DEPENDENCY_FIELDS = [
|
|
248
|
+
"dependencies",
|
|
249
|
+
"devDependencies",
|
|
250
|
+
"peerDependencies",
|
|
251
|
+
"optionalDependencies",
|
|
252
|
+
] as const;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* For each workspace package, the other workspace packages whose package.json
|
|
256
|
+
* depends on it, by name with directory, sorted. A package no other one names
|
|
257
|
+
* is left out.
|
|
258
|
+
*/
|
|
259
|
+
export const workspaceDependentsOf = Effect.fn("workspaceDependentsOf")(function* (
|
|
260
|
+
root: string,
|
|
261
|
+
catalog: WorkspacePackages,
|
|
262
|
+
) {
|
|
263
|
+
const fs = yield* FileSystem.FileSystem;
|
|
264
|
+
const path = yield* Path.Path;
|
|
265
|
+
const dependents = new Map<string, Map<string, string>>();
|
|
266
|
+
// Sequential over the sorted catalog, so each package's dependents are inserted sorted.
|
|
267
|
+
yield* Effect.forEach(Object.entries(catalog), ([name, directory]) =>
|
|
268
|
+
Effect.map(
|
|
269
|
+
fs
|
|
270
|
+
.readFileString(path.join(root, directory, "package.json"))
|
|
271
|
+
.pipe(Effect.orElseSucceed(() => "")),
|
|
272
|
+
(text) => {
|
|
273
|
+
const manifest = parsed(text) as Readonly<Record<string, unknown>> | undefined;
|
|
274
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
275
|
+
const named = manifest?.[field];
|
|
276
|
+
if (named === null || typeof named !== "object") continue;
|
|
277
|
+
for (const dependency of Object.keys(named)) {
|
|
278
|
+
if (dependency === name || !Object.hasOwn(catalog, dependency)) continue;
|
|
279
|
+
const found = dependents.get(dependency) ?? new Map<string, string>();
|
|
280
|
+
dependents.set(dependency, found.set(name, directory));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
return Object.fromEntries(
|
|
287
|
+
[...dependents].map(([name, found]) => [name, Object.fromEntries(found)]),
|
|
288
|
+
) as Readonly<Record<string, WorkspacePackages>>;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/** TypeScript source extensions an import resolves to, in the order tried. */
|
|
292
|
+
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
|
|
293
|
+
/** The source extensions an emitted extension in a specifier stands for. */
|
|
294
|
+
const EMITTED: Readonly<Record<string, ReadonlyArray<string>>> = {
|
|
295
|
+
".js": [".ts", ".tsx"],
|
|
296
|
+
".jsx": [".tsx"],
|
|
297
|
+
".mjs": [".mts"],
|
|
298
|
+
".cjs": [".cts"],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** The paths a relative specifier can name, most specific first. */
|
|
302
|
+
const candidatesOf = (target: string): ReadonlyArray<string> => {
|
|
303
|
+
const extension = posix.extname(target);
|
|
304
|
+
const emitted = EMITTED[extension] ?? [];
|
|
305
|
+
const stem = target.slice(0, target.length - extension.length);
|
|
306
|
+
return [
|
|
307
|
+
target,
|
|
308
|
+
...emitted.map((source) => `${stem}${source}`),
|
|
309
|
+
...SOURCE_EXTENSIONS.map((source) => `${target}${source}`),
|
|
310
|
+
...SOURCE_EXTENSIONS.map((source) => `${target}/index${source}`),
|
|
311
|
+
];
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* For each file, the files that import it by a relative specifier, sorted.
|
|
316
|
+
* Keys and values are `/`-separated paths from one root, as `imports` gives
|
|
317
|
+
* them; a specifier resolves only to a file `imports` has, by the first of its
|
|
318
|
+
* candidates found: as written, with `.js` as `.ts`, with a source extension
|
|
319
|
+
* added, then as a directory's index. Package and path-alias imports do not count.
|
|
320
|
+
*/
|
|
321
|
+
export const dependentFilesIn = (
|
|
322
|
+
imports: ReadonlyMap<string, ReadonlyArray<string>>,
|
|
323
|
+
): ReadonlyMap<string, ReadonlyArray<string>> => {
|
|
324
|
+
const dependents = new Map<string, Set<string>>();
|
|
325
|
+
for (const [importer, specifiers] of imports) {
|
|
326
|
+
for (const specifier of specifiers) {
|
|
327
|
+
if (specifier !== "." && specifier !== ".." && !/^\.\.?\//.test(specifier)) continue;
|
|
328
|
+
const target = posix.join(posix.dirname(importer), specifier);
|
|
329
|
+
const found = candidatesOf(target).find((candidate) => imports.has(candidate));
|
|
330
|
+
if (found === undefined || found === importer) continue;
|
|
331
|
+
dependents.set(found, (dependents.get(found) ?? new Set()).add(importer));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return new Map([...dependents].map(([file, found]) => [file, [...found].sort()]));
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** The workspace package a `/`-separated path from the root lies in: the one with the deepest directory holding it. */
|
|
338
|
+
export const packageOf = (file: string, catalog: WorkspacePackages): string | undefined =>
|
|
339
|
+
Object.entries(catalog)
|
|
340
|
+
.filter(([, directory]) => file.startsWith(`${directory}/`))
|
|
341
|
+
.sort(([, a], [, b]) => b.length - a.length)[0]?.[0];
|