@goodbones/oxlint 0.1.0-beta.1 → 0.1.0-beta.11

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.
Files changed (37) hide show
  1. package/build/dts/campaigns-rule.d.ts +6 -0
  2. package/build/dts/campaigns-rule.d.ts.map +1 -0
  3. package/build/dts/config-loader.d.ts +2 -1
  4. package/build/dts/config-loader.d.ts.map +1 -1
  5. package/build/dts/exports-rule.d.ts.map +1 -1
  6. package/build/dts/imports-rule.d.ts.map +1 -1
  7. package/build/dts/members-rule.d.ts.map +1 -1
  8. package/build/dts/oxlint-api.d.ts +4 -33
  9. package/build/dts/oxlint-api.d.ts.map +1 -1
  10. package/build/dts/plugin.d.ts +1 -0
  11. package/build/dts/plugin.d.ts.map +1 -1
  12. package/build/dts/surface-rule.d.ts.map +1 -1
  13. package/build/esm/campaigns-rule.js +164 -0
  14. package/build/esm/campaigns-rule.js.map +1 -0
  15. package/build/esm/config-loader.js +85 -7
  16. package/build/esm/config-loader.js.map +1 -1
  17. package/build/esm/exports-rule.js +15 -71
  18. package/build/esm/exports-rule.js.map +1 -1
  19. package/build/esm/imports-rule.js +14 -26
  20. package/build/esm/imports-rule.js.map +1 -1
  21. package/build/esm/members-rule.js +10 -112
  22. package/build/esm/members-rule.js.map +1 -1
  23. package/build/esm/oxlint-api.js +22 -20
  24. package/build/esm/oxlint-api.js.map +1 -1
  25. package/build/esm/plugin.js +6 -2
  26. package/build/esm/plugin.js.map +1 -1
  27. package/build/esm/surface-rule.js +11 -127
  28. package/build/esm/surface-rule.js.map +1 -1
  29. package/package.json +5 -3
  30. package/src/campaigns-rule.ts +242 -0
  31. package/src/config-loader.ts +105 -7
  32. package/src/exports-rule.ts +19 -106
  33. package/src/imports-rule.ts +16 -35
  34. package/src/members-rule.ts +11 -165
  35. package/src/oxlint-api.ts +31 -47
  36. package/src/plugin.ts +7 -2
  37. package/src/surface-rule.ts +15 -189
@@ -0,0 +1,242 @@
1
+ import * as path from "node:path";
2
+
3
+ import {
4
+ type CampaignHit,
5
+ type CampaignInput,
6
+ campaignsOf,
7
+ campaignsSelecting,
8
+ type CompiledCampaign,
9
+ type CompiledObjective,
10
+ derivePhase,
11
+ detectorCandidatesOf,
12
+ detectorOf,
13
+ evaluateObjectives,
14
+ ledgerKeyOf,
15
+ LEGACY_PHASE,
16
+ LEGACY_SECTOR,
17
+ membershipOf,
18
+ needsSyntax,
19
+ objectivesInWindow,
20
+ positionOf,
21
+ reconcileSector,
22
+ sectorEntryOf,
23
+ type SectorIndex,
24
+ } from "@goodbones/campaigns";
25
+ import { formatMessage, type LoadedPolicy, ReportUnavailable } from "@goodbones/core";
26
+ import { sourceFactsOf } from "@goodbones/typescript";
27
+
28
+ import {
29
+ factsOfProgram,
30
+ type OxlintRule,
31
+ type Program,
32
+ type RuleContext,
33
+ toRepoRelative,
34
+ } from "./oxlint-api.js";
35
+
36
+ // The campaigns family, one file at a time: every hit of an objective in
37
+ // window for the file's sector that its ledger does not carry, reported at
38
+ // the match's position with the objective's `how` as the message. The
39
+ // facts come from oxlint's tree through the pack's reader, as the other
40
+ // rules read them; the `syntax` term is answered by the same matcher the
41
+ // CLI uses, over `sourceCode.text` — "one engine" is this family's parity
42
+ // contract, rather than a corpus.
43
+ //
44
+ // The sector comes from the perimeter on this file (a marker or nx index
45
+ // the host built at load, for the two forms that need the other files),
46
+ // and the sector's phase from the ledgers — which objectives carry
47
+ // holdouts for it, and its record's `reached` and attestations — never
48
+ // from a walk. So the plugin's answer is the code as of the last `clear`,
49
+ // and the CLI's is the code now; between a fix and its `clear` they
50
+ // differ, and `check` fails on the stale holdout until they agree. A
51
+ // `has` objective and an end state are never reported here: their holdout
52
+ // is the sector, and they reach this file only as the phase they put it at.
53
+
54
+ // The sectors a host discovered at load, per campaign, for the perimeters
55
+ // that need the other files.
56
+ export type SectorIndexes = ReadonlyMap<string, SectorIndex>;
57
+
58
+ const unledgered = (
59
+ policy: LoadedPolicy,
60
+ rule: CompiledCampaign,
61
+ sector: string,
62
+ root: string,
63
+ hits: ReadonlyArray<CampaignHit>,
64
+ ): ReadonlyArray<CampaignHit> => {
65
+ const carried = new Set<CampaignHit>();
66
+ const byObjective = new Map<string, Array<CampaignHit>>();
67
+ for (const hit of hits) {
68
+ byObjective.set(hit.objective, [...(byObjective.get(hit.objective) ?? []), hit]);
69
+ }
70
+ for (const [objectiveId, own] of byObjective) {
71
+ const objective = rule.objectives.find((one) => one.id === objectiveId);
72
+ const ledger = campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objectiveId));
73
+ if (objective === undefined || ledger === undefined) continue;
74
+ const entries = own.map((hit) => sectorEntryOf(hit.violation, root));
75
+ const known = new Set(reconcileSector(ledger, sector, entries, objective.unit).ledgered);
76
+ own.forEach((hit, i) => {
77
+ if (known.has(entries[i] ?? "")) carried.add(hit);
78
+ });
79
+ }
80
+ return hits.filter((hit) => !carried.has(hit));
81
+ };
82
+
83
+ // The objectives in window for the sector, read off the ledgers. A sector
84
+ // no `clear` has placed — no record of it — stands at the first phase, as
85
+ // the legacy does, rather than deriving to the end from ledgers that
86
+ // carry nothing for it.
87
+ const inWindowFor = (
88
+ policy: LoadedPolicy,
89
+ rule: CompiledCampaign,
90
+ sector: string,
91
+ ): ReadonlyArray<CompiledObjective> => {
92
+ const record = campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
93
+ const position = positionOf(rule, record);
94
+ const counts = (objectiveId: string): number =>
95
+ campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objectiveId))?.sectors[sector]?.holdouts.length ?? 0;
96
+ const phase =
97
+ sector === LEGACY_SECTOR || record === undefined
98
+ ? Math.min(LEGACY_PHASE, rule.phases.length)
99
+ : derivePhase(rule, counts, position);
100
+ return objectivesInWindow(rule, phase, position).filter((one) => one.detect !== null);
101
+ };
102
+
103
+ // A `report` a campaign names that cannot be read — a command the kernel
104
+ // refused to spawn, a file no step wrote — is one fact about the run, not
105
+ // one about each file the campaign selects. The live source keeps the
106
+ // failure per spec; this keeps which failures have been said, so the first
107
+ // selected file carries the notice and the rest stay quiet. Without it, a
108
+ // lint of eight hundred files is eight hundred copies of oxlint's generic
109
+ // "error running JS plugin", with the cause dropped by the terser formats.
110
+ const aside = (cause: ReportUnavailable): string =>
111
+ `A report a campaign names could not be read, so no campaign naming it is judged in this ` +
112
+ `run: ${cause.message}`;
113
+
114
+ export const makeCampaignsRule = (
115
+ policy: LoadedPolicy,
116
+ indexes: SectorIndexes = new Map(),
117
+ ): OxlintRule => {
118
+ // Per rule instance, which is per plugin load — one process — rather than
119
+ // per `createOnce`, which a host may call more often than that.
120
+ const said = new Set<string>();
121
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
122
+ return {
123
+ meta: {
124
+ type: "problem" as const,
125
+ docs: {
126
+ description:
127
+ "the campaigns: every place a pattern the repository is migrating away from still occurs, in a sector whose phase asks for it, that its ledger does not carry",
128
+ },
129
+ schema: [],
130
+ },
131
+
132
+ createOnce(context: RuleContext) {
133
+ let file = "";
134
+ let selected: ReadonlyArray<CompiledCampaign> = [];
135
+
136
+ return {
137
+ before() {
138
+ file = toRepoRelative(policy.repoRoot, context.filename);
139
+ if (file.startsWith("..")) return false;
140
+ // A file the packs would not walk — one oxlint lints anyway — is
141
+ // seen only by a campaign that widened its scope to it, so both
142
+ // hosts answer about the same files.
143
+ const extension = path.extname(file);
144
+ selected = campaignsSelecting(campaignsOf(policy).campaignRules, file).filter(
145
+ (rule) =>
146
+ known.size === 0 || known.has(extension) || rule.extensions.includes(extension),
147
+ );
148
+ return selected.length > 0;
149
+ },
150
+
151
+ Program(node: Program) {
152
+ const text = context.sourceCode.text;
153
+ const detectors = selected.flatMap((rule) => [
154
+ ...rule.objectives.flatMap((one) => {
155
+ const detect = detectorOf(one);
156
+ return detect === null ? [] : [detect];
157
+ }),
158
+ ...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
159
+ ]);
160
+ const input: CampaignInput = {
161
+ file,
162
+ text,
163
+ facts: sourceFactsOf(factsOfProgram(file, node)),
164
+ resolver: policy.resolver,
165
+ fileSystem: policy.fileSystem,
166
+ syntax: needsSyntax(detectors) ? policy.syntax.parse(file, text) : null,
167
+ functions: campaignsOf(policy).functions,
168
+ reports: campaignsOf(policy).reports,
169
+ };
170
+ // One campaign at a time, so a report one of them cannot read costs
171
+ // that campaign's answer for this file and not its neighbours'.
172
+ const unrecorded: Array<CampaignHit> = [];
173
+ for (const rule of selected) {
174
+ try {
175
+ const perimeter = rule.perimeter;
176
+ const anchors =
177
+ perimeter?.kind === "match"
178
+ ? detectorCandidatesOf(perimeter.detect, perimeter.unit, input).map(
179
+ (one) => one.key.split("#")[0] ?? one.key,
180
+ )
181
+ : [];
182
+ const place = membershipOf(rule, file, indexes.get(rule.id) ?? null, anchors);
183
+ // Hits grouped by the sector they fall in, then judged
184
+ // against that sector's window and ledger.
185
+ const bySector = new Map<string, { root: string; hits: Array<CampaignHit> }>();
186
+ const windows = new Map<string, ReadonlySet<string>>();
187
+ const placedAt = (subject: string | null) => {
188
+ const placement = place(subject);
189
+ if (placement === null) return null;
190
+ if (!windows.has(placement.sector)) {
191
+ windows.set(
192
+ placement.sector,
193
+ new Set(inWindowFor(policy, rule, placement.sector).map((one) => one.id)),
194
+ );
195
+ }
196
+ return placement;
197
+ };
198
+ const whole = placedAt(null);
199
+ const candidates =
200
+ whole === null
201
+ ? rule.objectives.filter((one) => one.detect !== null)
202
+ : rule.objectives.filter(
203
+ (one) =>
204
+ one.detect !== null && (windows.get(whole.sector)?.has(one.id) ?? false),
205
+ );
206
+ if (perimeter?.kind !== "match" && whole === null) continue;
207
+ for (const hit of evaluateObjectives(candidates, input)) {
208
+ const placement = placedAt(hit.violation.subject);
209
+ if (placement === null) continue;
210
+ if (!(windows.get(placement.sector)?.has(hit.objective) ?? false)) continue;
211
+ const group = bySector.get(placement.sector) ?? { root: placement.root, hits: [] };
212
+ group.hits.push(hit);
213
+ bySector.set(placement.sector, group);
214
+ }
215
+ for (const [sector, group] of bySector) {
216
+ for (const hit of unledgered(policy, rule, sector, group.root, group.hits)) {
217
+ unrecorded.push(hit);
218
+ }
219
+ }
220
+ } catch (cause) {
221
+ if (!(cause instanceof ReportUnavailable)) throw cause;
222
+ const message = aside(cause);
223
+ if (said.has(message)) continue;
224
+ said.add(message);
225
+ context.report({ message, loc: { line: 1, column: 0 } });
226
+ }
227
+ }
228
+
229
+ for (const hit of unrecorded) {
230
+ // A match lands where it was found; a file-unit hit, on line 1, as
231
+ // the structure rule places a finding about the file itself.
232
+ const at = hit.range ?? { start: { line: 0, column: 0 } };
233
+ context.report({
234
+ message: formatMessage(hit.violation),
235
+ loc: { line: at.start.line + 1, column: at.start.column },
236
+ });
237
+ }
238
+ },
239
+ };
240
+ },
241
+ };
242
+ };
@@ -1,17 +1,78 @@
1
1
  import * as path from "node:path";
2
2
 
3
+ import { astGrepMatcher } from "@goodbones/ast-grep";
3
4
  import {
4
- DEFAULT_CONFIG_FILENAME,
5
+ campaignsExtension,
6
+ campaignsOf,
7
+ discoverSectors,
8
+ loadCampaignFunctions,
9
+ makeReportSourceLive,
10
+ reportSpecsOf,
11
+ type SectorIndex,
12
+ } from "@goodbones/campaigns";
13
+ import {
14
+ findManifestFile,
15
+ globToRegExp,
16
+ listSourceFiles,
17
+ listWorkspaceProjects,
5
18
  type LoadedPolicy,
6
19
  loadPolicy,
7
20
  makeFileSystemLive,
8
21
  readManifestFile,
22
+ ReportUnavailable,
9
23
  } from "@goodbones/core";
10
24
  import { typescriptLanguage } from "@goodbones/typescript";
11
25
  import * as Result from "effect/Result";
12
26
 
13
27
  export type { LoadedPolicy } from "@goodbones/core";
14
- export { DEFAULT_CONFIG_FILENAME } from "@goodbones/core";
28
+
29
+ // A `marker` or `nx` perimeter needs the other files to say which sector a
30
+ // file is in — the markers, or the workspace's projects — so the plugin
31
+ // reads them once at load, from a walk of the repository that reads no
32
+ // source text but the markers'. The other perimeters answer from the path
33
+ // or the file itself, and need nothing here.
34
+ export const discoverSectorIndexes = (policy: LoadedPolicy): ReadonlyMap<string, SectorIndex> => {
35
+ const indexes = new Map<string, SectorIndex>();
36
+ const needing = campaignsOf(policy).campaignRules.filter(
37
+ (rule) => rule.perimeter?.kind === "marker" || rule.perimeter?.kind === "nx",
38
+ );
39
+ if (needing.length === 0) return indexes;
40
+ const widened = [...new Set(needing.flatMap((rule) => rule.extensions))];
41
+ const files = listSourceFiles(policy.repoRoot, ["."], policy.languages, widened);
42
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
43
+ const projects = needing.some((rule) => rule.perimeter?.kind === "nx")
44
+ ? listWorkspaceProjects(policy.repoRoot, ["."])
45
+ : [];
46
+ for (const rule of needing) {
47
+ const scoped = files.filter(
48
+ (file) =>
49
+ rule.scope.some((pattern) => pattern.test(file)) &&
50
+ (known.has(path.extname(file)) || rule.extensions.includes(path.extname(file))),
51
+ );
52
+ indexes.set(
53
+ rule.id,
54
+ discoverSectors(rule, {
55
+ files: scoped,
56
+ readText: (file) => policy.fileSystem.readText(file),
57
+ globToRegExp,
58
+ projects,
59
+ }),
60
+ );
61
+ }
62
+ return indexes;
63
+ };
64
+
65
+ // The clock the campaigns are judged by; `ARCHITECTURE_NOW` pins it, as it
66
+ // does for the CLI.
67
+ const hostNow = (): number => {
68
+ const pinned = process.env.ARCHITECTURE_NOW;
69
+ if (pinned === undefined || pinned === "") return Date.now();
70
+ const parsed = /^\d+$/.test(pinned) ? Number(pinned) : Date.parse(pinned);
71
+ if (Number.isNaN(parsed)) {
72
+ throw new Error(`ARCHITECTURE_NOW is ${JSON.stringify(pinned)}, which is not a date.`);
73
+ }
74
+ return parsed;
75
+ };
15
76
 
16
77
  // The plugin's composition root: read the manifest file, construct the
17
78
  // language packs and the live file system, and hand them to the loader. A load
@@ -20,16 +81,53 @@ export { DEFAULT_CONFIG_FILENAME } from "@goodbones/core";
20
81
  // indistinguishable from a clean codebase.
21
82
  export const loadPolicyFromFile = async (
22
83
  repoRoot: string,
23
- configFilename: string = DEFAULT_CONFIG_FILENAME,
84
+ configFilename?: string,
24
85
  ): Promise<LoadedPolicy> => {
25
- const configPath = path.resolve(repoRoot, configFilename);
86
+ // Named, or discovered: architecture.yaml, .yml, .json, or .config.mjs,
87
+ // exactly one of which may be present.
88
+ const configPath =
89
+ configFilename === undefined
90
+ ? findManifestFile(repoRoot)
91
+ : path.resolve(repoRoot, configFilename);
92
+ const read = await readManifestFile(configPath);
93
+ // The `fn` terms are imported here, before the policy loads, as the CLI
94
+ // does; the pack is composed with ast-grep for the campaigns family's
95
+ // `syntax` term, and the plugin parses with it for that family only.
96
+ const { functions, manifest } = await loadCampaignFunctions(configPath, read.manifest);
26
97
  const loaded = loadPolicy({
27
98
  repoRoot,
28
99
  configPath,
29
- manifest: await readManifestFile(configPath),
30
- languages: [typescriptLanguage()],
100
+ manifest,
101
+ locate: read.locate,
102
+ languages: [typescriptLanguage({ syntax: astGrepMatcher() })],
31
103
  fileSystem: makeFileSystemLive(repoRoot),
104
+ // A `report` command runs once per process — once per editor session
105
+ // for oxlint's language server, which then sees that report until it
106
+ // restarts. A report the build writes to a file is the predictable form.
107
+ extensions: [campaignsExtension({ functions, reports: makeReportSourceLive(repoRoot) })],
108
+ now: hostNow(),
32
109
  });
33
110
  if (Result.isFailure(loaded)) throw loaded.failure;
34
- return loaded.success;
111
+ const policy = loaded.success;
112
+ // Every `report` a campaign names is read now, while oxlint has linted
113
+ // nothing. A `command` forks this process, and once the linter is running
114
+ // Linux can refuse the fork: the linter's per-thread AST buffers merge
115
+ // into one mapping larger than RAM and swap together, which the default
116
+ // overcommit heuristic refuses to duplicate. The source keeps the answer,
117
+ // and a failure, so the rules read what was read here — a term's several
118
+ // commands run at once. A report that cannot be read is not a load
119
+ // failure — the campaigns rule reports it once, on the first file a
120
+ // campaign naming it selects; one that does not parse is.
121
+ const campaigns = campaignsOf(policy);
122
+ await Promise.all(
123
+ reportSpecsOf(campaigns.campaignRules).map(async (spec) => {
124
+ if (campaigns.reports.read !== undefined) return campaigns.reports.read(spec);
125
+ try {
126
+ campaigns.reports.diagnosticsOf(spec, "");
127
+ } catch (cause) {
128
+ if (!(cause instanceof ReportUnavailable)) throw cause;
129
+ }
130
+ }),
131
+ );
132
+ return policy;
35
133
  };
@@ -1,97 +1,29 @@
1
1
  import {
2
- type Binding,
3
2
  evaluateSelectedBindings,
4
3
  exportRulesSelecting,
5
4
  formatMessage,
6
5
  type LoadedPolicy,
7
6
  type SelectedExportRule,
8
7
  } from "@goodbones/core";
8
+ import type { ReadBinding, ReadEdge } from "@goodbones/typescript";
9
9
  import * as Result from "effect/Result";
10
10
 
11
11
  import {
12
- type CallNode,
12
+ at,
13
+ factsOfProgram,
13
14
  type Fixer,
14
- type ImportEqualsNode,
15
- importEqualsSpecifierOf,
16
- type ImportExpressionNode,
17
- importExpressionSpecifierOf,
18
15
  type OxlintRule,
19
- type ReportableNode,
20
- requireSpecifierOf,
16
+ type Program,
21
17
  type RuleContext,
22
18
  toRepoRelative,
23
19
  } from "./oxlint-api.js";
24
20
 
25
- type NamedNode = ReportableNode & { readonly name?: unknown; readonly value?: unknown };
26
-
27
- type SpecifierNode = ReportableNode & {
28
- readonly type: string;
29
- readonly imported?: NamedNode | null;
30
- readonly local?: NamedNode | null;
31
- };
32
-
33
- type DeclarationNode = ReportableNode & {
34
- readonly source?: { readonly value?: unknown } | null;
35
- readonly specifiers?: ReadonlyArray<SpecifierNode> | null;
36
- };
37
-
38
- const nameOf = (node: NamedNode | null | undefined): string | null => {
39
- if (node === null || node === undefined) return null;
40
- if (typeof node.name === "string") return node.name;
41
- // `import { "a-b" as ab }` — a string-literal export name.
42
- return typeof node.value === "string" ? node.value : null;
43
- };
44
-
45
- type Bound = Binding & { readonly node: ReportableNode; readonly local: string };
46
-
47
- // The whole module, as one binding. `export * from "m"`, `export * as ns from
48
- // "m"`, `import x = require("m")`, `import("m")` and `require("m")` all carry
49
- // every export of `m` at once, exactly as `import * as ns` does — and are the
50
- // same way around a rule about a name. A side-effect import carries nothing.
51
- const wholeModule = (node: ReportableNode, local = ""): Bound => ({
52
- symbol: "*",
53
- kind: "namespace",
54
- node,
55
- local,
56
- });
57
-
58
- type ExportAllNode = DeclarationNode & { readonly exported?: NamedNode | null };
59
-
60
- const boundOf = (specifier: SpecifierNode): Bound | null => {
61
- const local = nameOf(specifier.local) ?? "";
62
- switch (specifier.type) {
63
- case "ImportSpecifier": {
64
- const symbol = nameOf(specifier.imported);
65
- return symbol === null ? null : { symbol, kind: "named", node: specifier, local };
66
- }
67
- case "ImportDefaultSpecifier":
68
- return { symbol: "default", kind: "default", node: specifier, local };
69
- case "ImportNamespaceSpecifier":
70
- return { symbol: "*", kind: "namespace", node: specifier, local };
71
- // `export { a } from "…"` — `local` is the name in the source module.
72
- case "ExportSpecifier": {
73
- const symbol = nameOf(specifier.local);
74
- return symbol === null ? null : { symbol, kind: "named", node: specifier, local: symbol };
75
- }
76
- default:
77
- return null;
78
- }
79
- };
80
-
81
- const specifierOf = (node: DeclarationNode): string | null => {
82
- const value = node.source?.value;
83
- return typeof value === "string" ? value : null;
84
- };
85
-
86
- const boundSpecifiers = (node: DeclarationNode): ReadonlyArray<Bound> =>
87
- (node.specifiers ?? []).map(boundOf).filter((one): one is Bound => one !== null);
88
-
89
21
  // `import { A, B as C } from "pkg"` becomes `import * as A from "pkg/A"` and
90
22
  // `import * as C from "pkg/B"`. Only whole-declaration rewrites are offered: a
91
23
  // declaration mixing restricted named imports with a default or namespace one
92
24
  // would need comma surgery inside the braces, and a fix that is subtly wrong is
93
25
  // worse than a diagnostic the author resolves by hand.
94
- const subpathNamespaceImport = (specifier: string, bound: ReadonlyArray<Bound>): string =>
26
+ const subpathNamespaceImport = (specifier: string, bound: ReadonlyArray<ReadBinding>): string =>
95
27
  bound
96
28
  .map((binding) => `import * as ${binding.local} from "${specifier}/${binding.symbol}";`)
97
29
  .join("\n");
@@ -111,20 +43,16 @@ export const makeExportsRule = (policy: LoadedPolicy): OxlintRule => ({
111
43
  let importer = "";
112
44
  let selected: ReadonlyArray<SelectedExportRule> = [];
113
45
 
114
- // `fixable` is whether a rewrite could apply: only an `import` declaration
115
- // can be rewritten into subpath namespace imports. `export *` and the
116
- // whole-module forms are reported and left for the author.
117
- const check = (
118
- node: ReportableNode,
119
- specifierValue: string | null,
120
- bound: ReadonlyArray<Bound>,
121
- fixable: boolean,
122
- ): void => {
123
- if (specifierValue === null || bound.length === 0) return;
46
+ // Only an `import` declaration can be rewritten into subpath namespace
47
+ // imports. `export *` and the whole-module forms are reported and left
48
+ // for the author.
49
+ const check = (edge: ReadEdge): void => {
50
+ const { bindings: bound, node, specifier } = edge;
51
+ if (bound.length === 0) return;
124
52
 
125
53
  const outcome = evaluateSelectedBindings(selected, policy.resolver, {
126
54
  importer,
127
- specifier: specifierValue,
55
+ specifier,
128
56
  bindings: bound,
129
57
  });
130
58
 
@@ -136,30 +64,30 @@ export const makeExportsRule = (policy: LoadedPolicy): OxlintRule => ({
136
64
 
137
65
  for (const { bindings, rule, violation } of outcome.success) {
138
66
  if (policy.baseline.isBaselined(violation)) continue;
139
- const offending = bound.filter((one: Bound) =>
67
+ const offending = bound.filter((one) =>
140
68
  bindings.some((binding) => binding.symbol === one.symbol && binding.kind === one.kind),
141
69
  );
142
70
  // The rewrite is `import * as X from "pkg/<name>"`, which only means
143
71
  // something for a named binding — a namespace one has no name to put
144
72
  // in the subpath.
145
73
  const rewritable =
146
- fixable &&
74
+ edge.form === "import" &&
147
75
  rule.fix === "subpath-namespace-import" &&
148
76
  offending.length === bound.length &&
149
77
  offending.every((one) => one.kind === "named");
150
78
 
151
79
  if (rewritable) {
152
80
  context.report({
153
- node,
81
+ node: at(node),
154
82
  message: formatMessage(violation),
155
83
  fix: (fixer: Fixer) =>
156
- fixer.replaceText(node, subpathNamespaceImport(specifierValue, offending)),
84
+ fixer.replaceText(at(node), subpathNamespaceImport(specifier, offending)),
157
85
  });
158
86
  continue;
159
87
  }
160
88
 
161
89
  context.report({
162
- node: offending[0]?.node ?? node,
90
+ node: at(offending[0]?.node ?? node),
163
91
  message: formatMessage(violation),
164
92
  });
165
93
  }
@@ -172,23 +100,8 @@ export const makeExportsRule = (policy: LoadedPolicy): OxlintRule => ({
172
100
  selected = exportRulesSelecting(policy.exportRules, importer);
173
101
  return selected.length > 0;
174
102
  },
175
- ImportDeclaration(node: DeclarationNode) {
176
- check(node, specifierOf(node), boundSpecifiers(node), true);
177
- },
178
- ExportNamedDeclaration(node: DeclarationNode) {
179
- check(node, specifierOf(node), boundSpecifiers(node), false);
180
- },
181
- ExportAllDeclaration(node: ExportAllNode) {
182
- check(node, specifierOf(node), [wholeModule(node, nameOf(node.exported) ?? "")], false);
183
- },
184
- ImportExpression(node: ImportExpressionNode) {
185
- check(node, importExpressionSpecifierOf(node), [wholeModule(node)], false);
186
- },
187
- CallExpression(node: CallNode) {
188
- check(node, requireSpecifierOf(node), [wholeModule(node)], false);
189
- },
190
- TSImportEqualsDeclaration(node: ImportEqualsNode) {
191
- check(node, importEqualsSpecifierOf(node), [wholeModule(node)], false);
103
+ Program(node: Program) {
104
+ for (const edge of factsOfProgram(importer, node).edges) check(edge);
192
105
  },
193
106
  };
194
107
  },
@@ -5,20 +5,15 @@ import {
5
5
  rulesSelecting,
6
6
  type SelectedRule,
7
7
  } from "@goodbones/core";
8
+ import type { ReadEdge } from "@goodbones/typescript";
8
9
  import * as Result from "effect/Result";
9
10
 
10
11
  import {
11
- type CallNode,
12
- type ImportEqualsNode,
13
- importEqualsSpecifierOf,
14
- type ImportExpressionNode,
15
- importExpressionSpecifierOf,
12
+ at,
13
+ factsOfProgram,
16
14
  type OxlintRule,
17
- type ReportableNode,
18
- requireSpecifierOf,
15
+ type Program,
19
16
  type RuleContext,
20
- type SourceNode,
21
- specifierOf,
22
17
  toRepoRelative,
23
18
  } from "./oxlint-api.js";
24
19
 
@@ -44,32 +39,29 @@ export const makeImportsRule = (policy: LoadedPolicy): OxlintRule => ({
44
39
  let importer = "";
45
40
  let selected: ReadonlyArray<SelectedRule> = [];
46
41
 
47
- const check = (node: ReportableNode, specifier: string | null): void => {
48
- if (specifier === null) return;
49
-
42
+ const check = (edge: ReadEdge): void => {
43
+ const { specifier } = edge;
50
44
  const outcome = evaluateSelectedEdge(selected, policy.resolver, { importer, specifier });
51
45
 
52
46
  if (Result.isFailure(outcome)) {
53
47
  if (policy.config.resolve.unresolved === "off") return;
54
48
  if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) return;
55
- context.report({ node, message: unresolvedMessage(specifier, outcome.failure.detail) });
49
+ context.report({
50
+ node: at(edge.node),
51
+ message: unresolvedMessage(specifier, outcome.failure.detail),
52
+ });
56
53
  return;
57
54
  }
58
55
 
59
56
  for (const violation of outcome.success) {
60
57
  if (policy.baseline.isBaselined(violation)) continue;
61
- context.report({ node, message: formatMessage(violation) });
58
+ context.report({ node: at(edge.node), message: formatMessage(violation) });
62
59
  }
63
60
  };
64
61
 
65
- const checkSource = (node: SourceNode): void => {
66
- check(node, specifierOf(node));
67
- };
68
-
69
- // Every form that names a module is an edge. The CLI adapter reads the same
70
- // five out of TypeScript's tree, and the parity suite holds the two to it: a
71
- // `require` the plugin skipped would be a rule that enforces nothing under
72
- // `oxlint` while failing under `architecture check`.
62
+ // Every form that names a module is an edge, and the pack's reader is
63
+ // what says which forms those are — the same reader the CLI reads a file
64
+ // through, so a form one host sees the other sees too.
73
65
  return {
74
66
  before() {
75
67
  importer = toRepoRelative(policy.repoRoot, context.filename);
@@ -77,19 +69,8 @@ export const makeImportsRule = (policy: LoadedPolicy): OxlintRule => ({
77
69
  selected = rulesSelecting(policy.importRules, importer);
78
70
  return selected.length > 0;
79
71
  },
80
- ImportDeclaration: checkSource,
81
- ExportNamedDeclaration: checkSource,
82
- ExportAllDeclaration: checkSource,
83
- // `import("m")` with a literal argument. A computed one is not a fact a
84
- // static policy can speak about, in either adapter.
85
- ImportExpression(node: ImportExpressionNode) {
86
- check(node, importExpressionSpecifierOf(node));
87
- },
88
- CallExpression(node: CallNode) {
89
- check(node, requireSpecifierOf(node));
90
- },
91
- TSImportEqualsDeclaration(node: ImportEqualsNode) {
92
- check(node, importEqualsSpecifierOf(node));
72
+ Program(node: Program) {
73
+ for (const edge of factsOfProgram(importer, node).edges) check(edge);
93
74
  },
94
75
  };
95
76
  },