@goodbones/core 0.1.0-beta.7 → 0.1.0-beta.9

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 (113) hide show
  1. package/build/dts/core/campaigns.d.ts +141 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/ledger.d.ts +61 -0
  4. package/build/dts/core/ledger.d.ts.map +1 -0
  5. package/build/dts/core/structure.d.ts +1 -0
  6. package/build/dts/core/structure.d.ts.map +1 -1
  7. package/build/dts/domain/architecture-config.d.ts +309 -1
  8. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  9. package/build/dts/domain/architecture-error.d.ts +8 -0
  10. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  11. package/build/dts/domain/facts.d.ts +1 -0
  12. package/build/dts/domain/facts.d.ts.map +1 -1
  13. package/build/dts/domain/report.d.ts +15 -0
  14. package/build/dts/domain/report.d.ts.map +1 -0
  15. package/build/dts/domain/snapshot.d.ts +55 -3
  16. package/build/dts/domain/snapshot.d.ts.map +1 -1
  17. package/build/dts/domain/violation.d.ts +1 -1
  18. package/build/dts/domain/violation.d.ts.map +1 -1
  19. package/build/dts/index.d.ts +12 -4
  20. package/build/dts/index.d.ts.map +1 -1
  21. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  22. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  23. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  24. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  25. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  26. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  27. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  28. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  29. package/build/dts/load/policy.d.ts +14 -0
  30. package/build/dts/load/policy.d.ts.map +1 -1
  31. package/build/dts/manifest/compile.d.ts +2 -1
  32. package/build/dts/manifest/compile.d.ts.map +1 -1
  33. package/build/dts/manifest/json-schema.d.ts.map +1 -1
  34. package/build/dts/manifest/manifest.d.ts +362 -0
  35. package/build/dts/manifest/manifest.d.ts.map +1 -1
  36. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  37. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  38. package/build/dts/ports/language.d.ts +2 -0
  39. package/build/dts/ports/language.d.ts.map +1 -1
  40. package/build/dts/ports/report-source.d.ts +12 -0
  41. package/build/dts/ports/report-source.d.ts.map +1 -0
  42. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  43. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  44. package/build/dts/testing.d.ts +2 -0
  45. package/build/dts/testing.d.ts.map +1 -1
  46. package/build/esm/core/campaigns.js +778 -0
  47. package/build/esm/core/campaigns.js.map +1 -0
  48. package/build/esm/core/ledger.js +171 -0
  49. package/build/esm/core/ledger.js.map +1 -0
  50. package/build/esm/core/structure.js +5 -2
  51. package/build/esm/core/structure.js.map +1 -1
  52. package/build/esm/domain/architecture-config.js +133 -1
  53. package/build/esm/domain/architecture-config.js.map +1 -1
  54. package/build/esm/domain/architecture-error.js +27 -0
  55. package/build/esm/domain/architecture-error.js.map +1 -1
  56. package/build/esm/domain/report.js +151 -0
  57. package/build/esm/domain/report.js.map +1 -0
  58. package/build/esm/domain/snapshot.js +21 -0
  59. package/build/esm/domain/snapshot.js.map +1 -1
  60. package/build/esm/domain/violation.js.map +1 -1
  61. package/build/esm/index.js +8 -2
  62. package/build/esm/index.js.map +1 -1
  63. package/build/esm/infrastructure/campaign-functions.js +67 -0
  64. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  65. package/build/esm/infrastructure/report-source-fake.js +6 -0
  66. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  67. package/build/esm/infrastructure/report-source-live.js +74 -0
  68. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  69. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  70. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  71. package/build/esm/load/policy.js +121 -1
  72. package/build/esm/load/policy.js.map +1 -1
  73. package/build/esm/manifest/compile.js +186 -1
  74. package/build/esm/manifest/compile.js.map +1 -1
  75. package/build/esm/manifest/json-schema.js +7 -3
  76. package/build/esm/manifest/json-schema.js.map +1 -1
  77. package/build/esm/manifest/manifest.js +148 -1
  78. package/build/esm/manifest/manifest.js.map +1 -1
  79. package/build/esm/ports/campaign-predicate.js +2 -0
  80. package/build/esm/ports/campaign-predicate.js.map +1 -0
  81. package/build/esm/ports/report-source.js +2 -0
  82. package/build/esm/ports/report-source.js.map +1 -0
  83. package/build/esm/ports/syntax-matcher.js +12 -0
  84. package/build/esm/ports/syntax-matcher.js.map +1 -0
  85. package/build/esm/testing.js +2 -0
  86. package/build/esm/testing.js.map +1 -1
  87. package/package.json +1 -1
  88. package/schema/architecture-node.schema.json +843 -0
  89. package/schema/architecture.schema.json +1253 -0
  90. package/schema/conformance.schema.json +118 -3
  91. package/src/core/campaigns.ts +1056 -0
  92. package/src/core/ledger.ts +242 -0
  93. package/src/core/structure.ts +10 -5
  94. package/src/domain/architecture-config.ts +178 -1
  95. package/src/domain/architecture-error.ts +30 -0
  96. package/src/domain/facts.ts +5 -0
  97. package/src/domain/report.ts +186 -0
  98. package/src/domain/snapshot.ts +52 -0
  99. package/src/domain/violation.ts +4 -1
  100. package/src/index.ts +76 -0
  101. package/src/infrastructure/campaign-functions.ts +98 -0
  102. package/src/infrastructure/report-source-fake.ts +10 -0
  103. package/src/infrastructure/report-source-live.ts +80 -0
  104. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  105. package/src/load/policy.ts +187 -1
  106. package/src/manifest/compile.ts +221 -0
  107. package/src/manifest/json-schema.ts +7 -3
  108. package/src/manifest/manifest.ts +192 -1
  109. package/src/ports/campaign-predicate.ts +30 -0
  110. package/src/ports/language.ts +6 -0
  111. package/src/ports/report-source.ts +21 -0
  112. package/src/ports/syntax-matcher.ts +42 -0
  113. package/src/testing.ts +2 -0
@@ -0,0 +1,186 @@
1
+ // A campaign's `report` term names a pattern no parser of ours sees: a type
2
+ // error, a lint finding from another tool, anything a program prints one
3
+ // line per occurrence. This is the vocabulary such a report is read into —
4
+ // a diagnostic with a file, a position, a code and a message — and the
5
+ // readers for the formats a report comes in. Pure: text in, diagnostics out.
6
+ // Running the command or reading the file is the report source's business.
7
+
8
+ export type Diagnostic = {
9
+ // Repo-relative, forward slashes.
10
+ readonly file: string;
11
+ // Zero-based, as a syntax match's range is.
12
+ readonly line: number;
13
+ readonly column: number;
14
+ // `TS2551`, `eslint(no-unused-vars)`, `no-debugger` — whatever the tool
15
+ // calls the kind of finding; `""` when the format carries none.
16
+ readonly code: string;
17
+ // The first line of the message.
18
+ readonly message: string;
19
+ };
20
+
21
+ export type ReportFormat = "tsc" | "eslint" | "oxlint" | "regex";
22
+
23
+ export type ParseReportOptions = {
24
+ // Absolute paths in the report are made relative to this.
25
+ readonly repoRoot: string;
26
+ // `regex` only: a pattern with named groups `file` and `line`, and
27
+ // optionally `column`, `code` and `message`; one diagnostic per line that
28
+ // matches, positions one-based as tools print them.
29
+ readonly pattern?: string | undefined;
30
+ };
31
+
32
+ const relativeTo = (repoRoot: string, file: string): string => {
33
+ const slashed = file.replaceAll("\\", "/");
34
+ const root = repoRoot.replaceAll("\\", "/").replace(/\/$/, "");
35
+ const relative =
36
+ slashed === root
37
+ ? ""
38
+ : slashed.startsWith(`${root}/`)
39
+ ? slashed.slice(root.length + 1)
40
+ : slashed;
41
+ return relative.replace(/^\.\//, "");
42
+ };
43
+
44
+ const oneBased = (value: string | undefined, fallback = 1): number => {
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed - 1 : fallback - 1;
47
+ };
48
+
49
+ const firstLine = (message: string): string => message.split(/\r?\n/, 1)[0]?.trim() ?? "";
50
+
51
+ // `src/a.ts(12,5): error TS2551: Property 'x' does not exist…`, one per
52
+ // line, with the continuation lines tsc indents beneath a message ignored —
53
+ // the first line is the message.
54
+ const TSC_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
55
+
56
+ const parseTsc = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
57
+ const found: Array<Diagnostic> = [];
58
+ for (const line of text.split(/\r?\n/)) {
59
+ const match = TSC_LINE.exec(line);
60
+ if (match === null) continue;
61
+ const [, file = "", row, column, code = "", message = ""] = match;
62
+ found.push({
63
+ file: relativeTo(options.repoRoot, file),
64
+ line: oneBased(row),
65
+ column: oneBased(column),
66
+ code,
67
+ message: firstLine(message),
68
+ });
69
+ }
70
+ return found;
71
+ };
72
+
73
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
74
+ typeof value === "object" && value !== null && !Array.isArray(value);
75
+
76
+ const asString = (value: unknown): string => (typeof value === "string" ? value : "");
77
+ const asNumber = (value: unknown): number | undefined =>
78
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
79
+
80
+ const parseJson = (text: string, format: string): unknown => {
81
+ try {
82
+ return JSON.parse(text);
83
+ } catch (cause) {
84
+ throw new Error(`a ${format} report is JSON, and this one does not parse: ${String(cause)}`);
85
+ }
86
+ };
87
+
88
+ // `eslint --format json`: one object per file with its messages. A message
89
+ // with no `ruleId` (a parse error) carries the code `""`.
90
+ const parseEslint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
91
+ const parsed = parseJson(text, "eslint");
92
+ if (!Array.isArray(parsed)) throw new Error("an eslint report is a JSON array of files");
93
+ const found: Array<Diagnostic> = [];
94
+ for (const entry of parsed) {
95
+ if (!isRecord(entry) || !Array.isArray(entry.messages)) continue;
96
+ const file = relativeTo(options.repoRoot, asString(entry.filePath));
97
+ for (const message of entry.messages) {
98
+ if (!isRecord(message)) continue;
99
+ found.push({
100
+ file,
101
+ line: (asNumber(message.line) ?? 1) - 1,
102
+ column: (asNumber(message.column) ?? 1) - 1,
103
+ code: asString(message.ruleId),
104
+ message: firstLine(asString(message.message)),
105
+ });
106
+ }
107
+ }
108
+ return found;
109
+ };
110
+
111
+ // `oxlint --format json`: `{ diagnostics: [{ filename, code, message,
112
+ // labels: [{ span: { line, column } }] }] }`, positions one-based.
113
+ const parseOxlint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
114
+ const parsed = parseJson(text, "oxlint");
115
+ if (!isRecord(parsed) || !Array.isArray(parsed.diagnostics)) {
116
+ throw new Error("an oxlint report is a JSON object with a `diagnostics` array");
117
+ }
118
+ const found: Array<Diagnostic> = [];
119
+ for (const entry of parsed.diagnostics) {
120
+ if (!isRecord(entry)) continue;
121
+ const labels: ReadonlyArray<unknown> = Array.isArray(entry.labels) ? entry.labels : [];
122
+ const label: unknown = labels[0];
123
+ const span = isRecord(label) && isRecord(label.span) ? label.span : {};
124
+ found.push({
125
+ file: relativeTo(options.repoRoot, asString(entry.filename)),
126
+ line: (asNumber(span.line) ?? 1) - 1,
127
+ column: (asNumber(span.column) ?? 1) - 1,
128
+ code: asString(entry.code),
129
+ message: firstLine(asString(entry.message)),
130
+ });
131
+ }
132
+ return found;
133
+ };
134
+
135
+ const parseRegex = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
136
+ if (options.pattern === undefined) {
137
+ throw new Error("a `regex` report needs a `pattern` with named groups `file` and `line`");
138
+ }
139
+ const pattern = new RegExp(options.pattern);
140
+ const found: Array<Diagnostic> = [];
141
+ for (const line of text.split(/\r?\n/)) {
142
+ const match = pattern.exec(line);
143
+ if (match === null) continue;
144
+ const groups = match.groups ?? {};
145
+ if (groups.file === undefined) continue;
146
+ found.push({
147
+ file: relativeTo(options.repoRoot, groups.file),
148
+ line: oneBased(groups.line),
149
+ column: oneBased(groups.column),
150
+ code: groups.code ?? "",
151
+ message: firstLine(groups.message ?? ""),
152
+ });
153
+ }
154
+ return found;
155
+ };
156
+
157
+ export const parseReport = (
158
+ format: ReportFormat,
159
+ text: string,
160
+ options: ParseReportOptions,
161
+ ): ReadonlyArray<Diagnostic> => {
162
+ switch (format) {
163
+ case "tsc":
164
+ return parseTsc(text, options);
165
+ case "eslint":
166
+ return parseEslint(text, options);
167
+ case "oxlint":
168
+ return parseOxlint(text, options);
169
+ case "regex":
170
+ return parseRegex(text, options);
171
+ }
172
+ };
173
+
174
+ // The diagnostics indexed by file, which is how a per-file evaluator asks
175
+ // for them.
176
+ export const indexByFile = (
177
+ diagnostics: Iterable<Diagnostic>,
178
+ ): ReadonlyMap<string, ReadonlyArray<Diagnostic>> => {
179
+ const byFile = new Map<string, Array<Diagnostic>>();
180
+ for (const one of diagnostics) {
181
+ const found = byFile.get(one.file);
182
+ if (found === undefined) byFile.set(one.file, [one]);
183
+ else found.push(one);
184
+ }
185
+ return byFile;
186
+ };
@@ -34,6 +34,7 @@ const VIOLATION_KINDS = [
34
34
  "member",
35
35
  "surface",
36
36
  "graph",
37
+ "campaign",
37
38
  ] as const satisfies ReadonlyArray<ViolationKind>;
38
39
 
39
40
  const Path = describe(Schema.String, "Repo-relative, with forward slashes.");
@@ -63,6 +64,10 @@ export const SnapshotViolation = Schema.Struct({
63
64
  ),
64
65
  message: Schema.String,
65
66
  baselined: describe(Schema.Boolean, "Carried by the baseline, so `check` does not fail on it."),
67
+ ledgered: describe(
68
+ Schema.Boolean,
69
+ "For a campaign hit: carried by the campaign's ledger, so `check` does not fail on it.",
70
+ ),
66
71
  });
67
72
 
68
73
  const AllowanceKind = describe(
@@ -109,6 +114,48 @@ export const SnapshotVacancy = Schema.Struct({
109
114
  ),
110
115
  });
111
116
 
117
+ // One campaign's burn-down: what its ledger says, and what the clock says
118
+ // about it. Every count is over ledger entries, which are hits by fingerprint.
119
+ export const SnapshotCampaign = Schema.Struct({
120
+ id: describe(Schema.String, "The campaign's id; its ledger is `<ledger>/<id>.json`."),
121
+ title: Schema.optionalKey(describe(Schema.String, "The campaign's title, when it states one.")),
122
+ owner: Schema.optionalKey(
123
+ describe(Schema.String, "Who is running the campaign, as the manifest names them."),
124
+ ),
125
+ initial: describe(Schema.Finite, "Entries the day the ledger was written."),
126
+ allowed: describe(
127
+ Schema.Finite,
128
+ "Entries added since by `campaigns allow`, each with a regression record.",
129
+ ),
130
+ count: describe(Schema.Finite, "Entries in the ledger now."),
131
+ fixed: describe(
132
+ Schema.Finite,
133
+ "Entries removed by `campaigns prune` since the ledger was written.",
134
+ ),
135
+ progress: describe(
136
+ Schema.Finite,
137
+ "`1 - count / (initial + allowed)`: how much of everything ever ledgered has been paid down.",
138
+ ),
139
+ lastProgress: describe(
140
+ Schema.String,
141
+ "When an entry last left the ledger, ISO 8601 — the stall clock's reading.",
142
+ ),
143
+ regressions: describe(Schema.Finite, "How many times the count was allowed to go up."),
144
+ stalled: describe(
145
+ Schema.Boolean,
146
+ "Entries remain and `lastProgress` is older than the campaign's `staleAfter`.",
147
+ ),
148
+ complete: describe(Schema.Boolean, "No entries remain."),
149
+ onComplete: describe(
150
+ Schema.Literals(["keep", "remove"]),
151
+ "What the manifest asks once complete: keep the campaign as a guard, or remove it.",
152
+ ),
153
+ ledgered: describe(
154
+ Schema.Boolean,
155
+ "Whether a ledger exists. A campaign with none has been declared and not yet initialised.",
156
+ ),
157
+ });
158
+
112
159
  export const Snapshot = Schema.Struct({
113
160
  version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
114
161
  manifest: describe(
@@ -187,6 +234,10 @@ export const Snapshot = Schema.Struct({
187
234
  }),
188
235
  'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
189
236
  ),
237
+ campaigns: describe(
238
+ Schema.Array(SnapshotCampaign),
239
+ "Every campaign the manifest declares, in manifest order, with its burn-down. Campaign hits are not counted in `coverage` or `residue`: a campaign is scoped by construction.",
240
+ ),
190
241
  });
191
242
 
192
243
  export type Snapshot = typeof Snapshot.Type;
@@ -194,6 +245,7 @@ export type SnapshotViolation = typeof SnapshotViolation.Type;
194
245
  export type SnapshotSlack = typeof SnapshotSlack.Type;
195
246
  export type SnapshotConcentration = typeof SnapshotConcentration.Type;
196
247
  export type SnapshotVacancy = typeof SnapshotVacancy.Type;
248
+ export type SnapshotCampaign = typeof SnapshotCampaign.Type;
197
249
 
198
250
  // Decodes a document some other run wrote — the base of a pull request, a
199
251
  // stored one — refusing a key the shape does not declare, so a consumer never
@@ -1,4 +1,7 @@
1
- export type ViolationKind = "import" | "export" | "structure" | "member" | "surface" | "graph";
1
+ // `campaign` is the one kind whose entries live in a ledger rather than the
2
+ // baseline: a hit is debt a campaign is paying down, not a rule being broken.
3
+ export type ViolationKind =
4
+ "import" | "export" | "structure" | "member" | "surface" | "graph" | "campaign";
2
5
 
3
6
  export type Violation = {
4
7
  readonly kind: ViolationKind;
package/src/index.ts CHANGED
@@ -14,6 +14,25 @@ export {
14
14
  staleEntriesOf,
15
15
  unbaselined,
16
16
  } from "./core/baseline.js";
17
+ export {
18
+ type CampaignHit,
19
+ type CampaignInput,
20
+ campaignsFailingTheirProbe,
21
+ campaignsSelecting,
22
+ compileCampaignRule,
23
+ compileCampaignRules,
24
+ type CompiledCampaign,
25
+ type CompiledDetector,
26
+ evaluateCampaign,
27
+ evaluateCampaigns,
28
+ explainCampaign,
29
+ type FailedProbe,
30
+ leafTermsOf,
31
+ matchKeyOf,
32
+ probeInputOf,
33
+ reportSpecsOf,
34
+ type TermAnswer,
35
+ } from "./core/campaigns.js";
17
36
  export {
18
37
  type Coverage,
19
38
  type CoverageFamily,
@@ -64,6 +83,27 @@ export {
64
83
  rulesSelecting,
65
84
  type SelectedRule,
66
85
  } from "./core/imports.js";
86
+ export {
87
+ allowed,
88
+ decodeLedger,
89
+ EMPTY_LEDGER,
90
+ entryOf,
91
+ isComplete,
92
+ isStalled,
93
+ type Ledger,
94
+ ledgerArithmeticHolds,
95
+ ledgerOf,
96
+ Ledger as LedgerSchema,
97
+ newEntriesOf,
98
+ progressOf,
99
+ pruned,
100
+ reconcile,
101
+ type Reconciliation,
102
+ type Regression,
103
+ type RegressionRecord,
104
+ serializeLedger,
105
+ staleEntriesOf as staleLedgerEntriesOf,
106
+ } from "./core/ledger.js";
67
107
  export {
68
108
  type CompiledMemberRule,
69
109
  compileMemberRules,
@@ -96,7 +136,11 @@ export {
96
136
  export {
97
137
  type Allowance,
98
138
  type BindingKind,
139
+ type CampaignProbe,
140
+ type CampaignRule,
141
+ type CampaignUnit,
99
142
  type DeclarationKind,
143
+ type Detector,
100
144
  type ExportFix,
101
145
  type ExportRule,
102
146
  type GraphConfig,
@@ -108,6 +152,7 @@ export {
108
152
  type ImportRule,
109
153
  type MemberRule,
110
154
  type MemberSubject,
155
+ type ProbeDiagnostic,
111
156
  type ResolveConfig,
112
157
  type ResolveScope,
113
158
  type StructureConfig,
@@ -117,6 +162,7 @@ export {
117
162
  ConfigInvalid,
118
163
  ImportUnresolved,
119
164
  PatternInvalid,
165
+ ReportUnavailable,
120
166
  ScopeInvalid,
121
167
  } from "./domain/architecture-error.js";
122
168
  export {
@@ -131,11 +177,19 @@ export {
131
177
  type ManifestPosition,
132
178
  renderManifestPath,
133
179
  } from "./domain/manifest-location.js";
180
+ export {
181
+ type Diagnostic,
182
+ indexByFile,
183
+ parseReport,
184
+ type ParseReportOptions,
185
+ type ReportFormat,
186
+ } from "./domain/report.js";
134
187
  export {
135
188
  decodeSnapshot,
136
189
  type Snapshot,
137
190
  SNAPSHOT_SCHEMA_ID,
138
191
  SNAPSHOT_VERSION,
192
+ type SnapshotCampaign,
139
193
  type SnapshotConcentration,
140
194
  snapshotJsonSchema,
141
195
  Snapshot as SnapshotSchema,
@@ -149,6 +203,10 @@ export {
149
203
  type Violation,
150
204
  type ViolationKind,
151
205
  } from "./domain/violation.js";
206
+ export {
207
+ loadCampaignFunctions,
208
+ type LoadedCampaignFunctions,
209
+ } from "./infrastructure/campaign-functions.js";
152
210
  export { makeFileSystemLive } from "./infrastructure/file-system-live.js";
153
211
  export {
154
212
  findManifestFile,
@@ -163,6 +221,7 @@ export {
163
221
  type IncludeReader,
164
222
  type SourceDocument,
165
223
  } from "./infrastructure/manifest-include.js";
224
+ export { makeReportSourceLive } from "./infrastructure/report-source-live.js";
166
225
  export {
167
226
  listPackageRoots,
168
227
  listSourceFiles,
@@ -204,13 +263,23 @@ export {
204
263
  manifestNodeJsonSchema,
205
264
  } from "./manifest/json-schema.js";
206
265
  export {
266
+ type CampaignSpec,
207
267
  type DecodedManifest,
208
268
  decodeManifest,
209
269
  type DecodeManifestOptions,
270
+ DEFAULT_LEDGER_DIR,
271
+ type DetectorSpec,
272
+ durationMs,
210
273
  type Manifest,
211
274
  type ManifestNode,
212
275
  Manifest as ManifestSchema,
213
276
  } from "./manifest/manifest.js";
277
+ export {
278
+ type CampaignPredicate,
279
+ type CampaignPredicateInput,
280
+ type CampaignSubject,
281
+ type Range,
282
+ } from "./ports/campaign-predicate.js";
214
283
  export { type FactExtractor } from "./ports/fact-extractor.js";
215
284
  export { type FileSystem } from "./ports/file-system.js";
216
285
  export { type Language } from "./ports/language.js";
@@ -219,3 +288,10 @@ export {
219
288
  type ModuleResolver,
220
289
  type ResolvedTarget,
221
290
  } from "./ports/module-resolver.js";
291
+ export { NO_REPORTS, type ReportSource, type ReportSpec } from "./ports/report-source.js";
292
+ export {
293
+ type Position,
294
+ type SyntaxMatch,
295
+ type SyntaxMatcher,
296
+ type SyntaxTree,
297
+ } from "./ports/syntax-matcher.js";
@@ -0,0 +1,98 @@
1
+ import * as path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ import { ConfigInvalid } from "../domain/architecture-error.js";
5
+ import type { CampaignPredicate } from "../ports/campaign-predicate.js";
6
+
7
+ // A campaign's `fn` term names a predicate function as `module#export`,
8
+ // resolved relative to the root manifest. Importing it touches the module
9
+ // loader, which is why this lives in infrastructure and both hosts call it
10
+ // before `loadPolicy`: the core receives the functions as a map and never
11
+ // imports anything.
12
+ //
13
+ // A `.mjs` manifest may write the function itself in place of the string.
14
+ // It is lifted out here, keyed by a synthesized name, and the manifest handed
15
+ // back with the string in its place — so the schema, which decodes a data
16
+ // file and a module alike, sees one shape. The whole value is walked, not
17
+ // only `campaigns`, so a term written in a `defs` fragment is found too.
18
+
19
+ export type LoadedCampaignFunctions = {
20
+ readonly functions: ReadonlyMap<string, CampaignPredicate>;
21
+ // The manifest with every function value replaced by its synthesized name.
22
+ readonly manifest: unknown;
23
+ };
24
+
25
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
26
+ typeof value === "object" && value !== null && !Array.isArray(value);
27
+
28
+ const REFERENCE = /^(.+)#([A-Za-z_$][\w$]*)$/;
29
+
30
+ // A `fn` term is an object whose one key is `fn`.
31
+ const isFnTerm = (value: Record<string, unknown>): boolean =>
32
+ "fn" in value && Object.keys(value).length === 1;
33
+
34
+ export const loadCampaignFunctions = async (
35
+ manifestPath: string,
36
+ rawManifest: unknown,
37
+ ): Promise<LoadedCampaignFunctions> => {
38
+ const functions = new Map<string, CampaignPredicate>();
39
+ const root = path.dirname(manifestPath);
40
+ const refuse = (detail: string): never => {
41
+ throw new ConfigInvalid({ configPath: manifestPath, detail });
42
+ };
43
+
44
+ const imported = async (reference: string): Promise<void> => {
45
+ if (functions.has(reference)) return;
46
+ const parsed = REFERENCE.exec(reference);
47
+ if (parsed === null) {
48
+ return refuse(
49
+ `the \`fn\` term ${JSON.stringify(reference)} is not \`module#export\`: name the ` +
50
+ `module (relative to the manifest) and the export, joined by \`#\`.`,
51
+ );
52
+ }
53
+ const [, module = "", exportName = ""] = parsed;
54
+ const at = path.resolve(root, module);
55
+ let loaded: unknown;
56
+ try {
57
+ loaded = await import(pathToFileURL(at).href);
58
+ } catch (cause) {
59
+ return refuse(
60
+ `the \`fn\` term ${JSON.stringify(reference)} names a module that does not load: ${String(cause)}`,
61
+ );
62
+ }
63
+ const found = isRecord(loaded) ? loaded[exportName] : undefined;
64
+ if (typeof found !== "function") {
65
+ return refuse(
66
+ `the \`fn\` term ${JSON.stringify(reference)} names an export that is not a function ` +
67
+ `(${found === undefined ? "absent" : typeof found}).`,
68
+ );
69
+ }
70
+ functions.set(reference, found as CampaignPredicate);
71
+ };
72
+
73
+ let synthesized = 0;
74
+ const lift = async (value: unknown): Promise<unknown> => {
75
+ if (Array.isArray(value)) {
76
+ const items: Array<unknown> = [];
77
+ for (const item of value) items.push(await lift(item));
78
+ return items;
79
+ }
80
+ if (!isRecord(value)) return value;
81
+ if (isFnTerm(value)) {
82
+ const fn = value.fn;
83
+ if (typeof fn === "function") {
84
+ synthesized += 1;
85
+ const name = `<manifest>#fn${String(synthesized)}`;
86
+ functions.set(name, fn as CampaignPredicate);
87
+ return { fn: name };
88
+ }
89
+ if (typeof fn === "string") await imported(fn);
90
+ return value;
91
+ }
92
+ const rebuilt: Record<string, unknown> = {};
93
+ for (const [key, entry] of Object.entries(value)) rebuilt[key] = await lift(entry);
94
+ return rebuilt;
95
+ };
96
+
97
+ return { functions, manifest: await lift(rawManifest) };
98
+ };
@@ -0,0 +1,10 @@
1
+ import type { Diagnostic } from "../domain/report.js";
2
+ import type { ReportSource } from "../ports/report-source.js";
3
+
4
+ // Keyed by file: a test states which diagnostics another tool reported on
5
+ // which file, and never runs the tool. Every spec is answered alike.
6
+ export const makeReportSourceFake = (
7
+ staged: Readonly<Record<string, ReadonlyArray<Omit<Diagnostic, "file">>>>,
8
+ ): ReportSource => ({
9
+ diagnosticsOf: (_spec, file) => (staged[file] ?? []).map((one) => ({ ...one, file })),
10
+ });
@@ -0,0 +1,80 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ import { ReportUnavailable } from "../domain/architecture-error.js";
6
+ import { type Diagnostic, indexByFile, parseReport } from "../domain/report.js";
7
+ import type { ReportSource, ReportSpec } from "../ports/report-source.js";
8
+
9
+ // The report source on this host: runs a `report` term's command from the
10
+ // repository root, or reads the file it names, and parses the output once —
11
+ // a report is about the whole repository, a campaign asks per file, and the
12
+ // plugin asks for one file at a time across a whole lint run. Cached per
13
+ // spec for the life of the process: the CLI is one process per `check`, and
14
+ // oxlint's language server is one process per editor session, which sees the
15
+ // report as of when it loaded the plugin. A report the build writes to a file
16
+ // is the predictable form for the editor, and for CI.
17
+ //
18
+ // A non-zero exit is not a failure — `tsc` exits 2 when there are errors,
19
+ // which is the case the term exists for. A command that cannot be spawned,
20
+ // or a file that is not there, is — and that failure is cached as the report
21
+ // would have been, so the second file to ask gets the same answer without a
22
+ // second spawn. The command is forked from the asking process; under the
23
+ // oxlint plugin that is the linter, which Linux refuses to fork once its AST
24
+ // buffers have merged into one mapping larger than RAM and swap — so the
25
+ // plugin asks at load, and retrying per file would only repeat the refusal.
26
+
27
+ const MAX_BUFFER = 256 * 1024 * 1024;
28
+
29
+ const textOf = (repoRoot: string, spec: ReportSpec): string => {
30
+ if (spec.file !== undefined) {
31
+ const at = path.resolve(repoRoot, spec.file);
32
+ try {
33
+ return readFileSync(at, "utf8");
34
+ } catch (cause) {
35
+ throw new ReportUnavailable({ kind: "file", source: spec.file, detail: String(cause) });
36
+ }
37
+ }
38
+ const command = spec.command ?? "";
39
+ const run = spawnSync(command, {
40
+ cwd: repoRoot,
41
+ shell: true,
42
+ encoding: "utf8",
43
+ maxBuffer: MAX_BUFFER,
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ });
46
+ if (run.error !== undefined) {
47
+ throw new ReportUnavailable({ kind: "command", source: command, detail: run.error.message });
48
+ }
49
+ return run.stdout;
50
+ };
51
+
52
+ const keyOf = (spec: ReportSpec): string => JSON.stringify(spec);
53
+
54
+ type Indexed = ReadonlyMap<string, ReadonlyArray<Diagnostic>>;
55
+
56
+ export const makeReportSourceLive = (repoRoot: string): ReportSource => {
57
+ const cache = new Map<string, Indexed | ReportUnavailable>();
58
+ const answerOf = (spec: ReportSpec): Indexed | ReportUnavailable => {
59
+ try {
60
+ return indexByFile(
61
+ parseReport(spec.format, textOf(repoRoot, spec), { repoRoot, pattern: spec.pattern }),
62
+ );
63
+ } catch (cause) {
64
+ if (cause instanceof ReportUnavailable) return cause;
65
+ throw cause;
66
+ }
67
+ };
68
+ return {
69
+ diagnosticsOf: (spec, file) => {
70
+ const key = keyOf(spec);
71
+ let answer = cache.get(key);
72
+ if (answer === undefined) {
73
+ answer = answerOf(spec);
74
+ cache.set(key, answer);
75
+ }
76
+ if (answer instanceof ReportUnavailable) throw answer;
77
+ return answer.get(file) ?? [];
78
+ },
79
+ };
80
+ };
@@ -0,0 +1,51 @@
1
+ import type { SyntaxMatch, SyntaxMatcher } from "../ports/syntax-matcher.js";
2
+
3
+ // One match as a test states it: the text, its captures as a plain object,
4
+ // the anchor, and optionally a line — the rest of the range is filled in.
5
+ // `rule` narrows the match to one rule (compared by its JSON), so a test
6
+ // about two `syntax` terms can answer each differently; omit it to answer
7
+ // every rule the same.
8
+ export type StagedMatch = {
9
+ readonly text: string;
10
+ readonly captures?: Readonly<Record<string, string>>;
11
+ readonly anchor?: string | null;
12
+ readonly line?: number;
13
+ readonly rule?: unknown;
14
+ };
15
+
16
+ const ZERO = { line: 0, column: 0 };
17
+
18
+ // Keyed by the source text, as the extractor fake is: a core test states
19
+ // what a snippet's syntax yields and never the parse, which has its own
20
+ // tests in the matcher package. A text not staged parses to no matches; a
21
+ // file whose extension is not among `extensions` does not parse at all,
22
+ // which is the shape of a language with no matcher.
23
+ export const makeSyntaxMatcherFake = (
24
+ staged: Readonly<Record<string, ReadonlyArray<StagedMatch>>>,
25
+ extensions: ReadonlyArray<string> | null = null,
26
+ ): SyntaxMatcher => ({
27
+ parse: (file, text) => {
28
+ if (extensions !== null && !extensions.some((extension) => file.endsWith(extension))) {
29
+ return null;
30
+ }
31
+ const matches = staged[text] ?? [];
32
+ return {
33
+ // A staged match's line is what a position is anchored by.
34
+ anchorAt: (position) => matches.find((one) => one.line === position.line)?.anchor ?? null,
35
+ findAll: (rule) => {
36
+ const asked = JSON.stringify(rule);
37
+ return matches
38
+ .filter((one) => one.rule === undefined || JSON.stringify(one.rule) === asked)
39
+ .map((one): SyntaxMatch => ({
40
+ text: one.text,
41
+ captures: new Map(Object.entries(one.captures ?? {})),
42
+ range: {
43
+ start: one.line === undefined ? ZERO : { line: one.line, column: 0 },
44
+ end: one.line === undefined ? ZERO : { line: one.line, column: one.text.length },
45
+ },
46
+ anchor: one.anchor ?? null,
47
+ }));
48
+ },
49
+ };
50
+ },
51
+ });