madr-lint 0.2.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -5,6 +5,38 @@ type MdastNodeType = Root['type'] | Nodes['type'];
5
5
  type MdastNode = Root | Nodes;
6
6
  /** Severity levels supported by every rule. */
7
7
  type Severity = 'error' | 'warn';
8
+ /**
9
+ * A raw-text edit: replace `content.slice(range[0], range[1])` with `text`.
10
+ * Offsets are into the ORIGINAL WHOLE-FILE content (frontmatter included) —
11
+ * the applier's coordinate space. Rules never build these directly; they call
12
+ * the `Fixer` helpers, which translate body (mdast) offsets to whole-file
13
+ * offsets. See ADR-0008.
14
+ */
15
+ interface TextEdit {
16
+ range: readonly [number, number];
17
+ text: string;
18
+ }
19
+ /**
20
+ * Helpers handed to a rule's `fix` thunk. Range / offset arguments are in
21
+ * BODY (mdast) coordinates — the same space as `node.position.*.offset` — and
22
+ * the fixer adds the stripped-frontmatter length so the returned `TextEdit`
23
+ * carries whole-file offsets. Keep the surface minimal: replace, insert, remove.
24
+ */
25
+ interface Fixer {
26
+ /** Replace the body range with `text`. */
27
+ replaceRange(range: readonly [number, number], text: string): TextEdit;
28
+ /** Insert `text` at a body offset (zero-width edit). */
29
+ insertAt(offset: number, text: string): TextEdit;
30
+ /** Delete the body range. */
31
+ remove(range: readonly [number, number]): TextEdit;
32
+ }
33
+ /**
34
+ * A lazy fix producer. Invoked ONLY when fixing is requested (zero cost on a
35
+ * normal lint). Returns one edit, several, or `null` to decline (e.g. the
36
+ * concrete fix is not mechanically safe for this occurrence). A rule may only
37
+ * attach a fix when its `meta.fixable === 'code'`.
38
+ */
39
+ type FixFn = (fixer: Fixer) => TextEdit | TextEdit[] | null;
8
40
  /**
9
41
  * The reserved rule name used for internal-error diagnostics. A real rule
10
42
  * cannot register with this name (the registry should reject collisions).
@@ -33,6 +65,21 @@ interface RuleMeta<TOptions = Record<string, unknown>> {
33
65
  };
34
66
  /** messageId -> template string with {{placeholders}}. */
35
67
  messages: Record<string, string>;
68
+ /**
69
+ * messageId -> machine-actionable remediation template, parallel to
70
+ * `messages` and interpolated the same way. Partial: declare an entry only
71
+ * where a concrete, mechanical fix exists; omit where the fix is inherently
72
+ * contextual. The runner resolves the entry for a reported messageId into
73
+ * `Diagnostic.suggestion` (null when absent). See issue #67.
74
+ */
75
+ suggestions?: Record<string, string>;
76
+ /**
77
+ * Whether this rule can produce autofixes. `'code'` = the rule may attach a
78
+ * `fix` thunk to `context.report(...)`; the runner honors it only for
79
+ * fixable rules and routes a fix from a non-fixable rule through
80
+ * `core/internal-error` (a rule bug). Defaults to `false`. See ADR-0008.
81
+ */
82
+ fixable?: 'code' | false;
36
83
  /** Default options merged with user config. */
37
84
  defaultOptions: TOptions;
38
85
  /** AJV JSON Schema for options. Validated at runRule time. */
@@ -60,6 +107,33 @@ interface Diagnostic {
60
107
  };
61
108
  /** Data for placeholder interpolation in messages[messageId]. */
62
109
  data?: Record<string, unknown>;
110
+ /**
111
+ * Machine-actionable remediation, resolved by the runner at report time from
112
+ * `rule.meta.suggestions[messageId]` (interpolated with `data`, exactly like
113
+ * the message). `null` when the rule declares no suggestion for this
114
+ * messageId. Every runner-produced diagnostic sets this. See issue #67.
115
+ */
116
+ suggestion: string | null;
117
+ /**
118
+ * The rule's documentation URL (`rule.meta.docs.url`), resolved by the runner
119
+ * so consumers need not reconstruct it from `ruleName`. `core/internal-error`
120
+ * diagnostics point at the repository; a rule without a docs URL yields `''`.
121
+ */
122
+ docsUrl: string;
123
+ /**
124
+ * Whether an autofix is available for THIS diagnostic — the rule is
125
+ * `meta.fixable: 'code'` AND attached a `fix` thunk here. Durable (serialized
126
+ * to the cache and json output; reporters render a 🔧 marker). Every
127
+ * runner-produced diagnostic sets this. See ADR-0008 / issue #28.
128
+ */
129
+ fixable: boolean;
130
+ /**
131
+ * The live fix producer, present only when `fixable` is true. TRANSIENT — a
132
+ * closure, so it is dropped by JSON serialization (cache / json output) and
133
+ * absent on cache-hydrated diagnostics. Consumed by the autofix applier
134
+ * (`src/core/fix.ts`) after suppression + baseline filtering. See ADR-0008.
135
+ */
136
+ fix?: FixFn;
63
137
  }
64
138
  interface RuleContext<TOptions = Record<string, unknown>> {
65
139
  /** The file being linted. */
@@ -86,10 +160,27 @@ interface RuleContext<TOptions = Record<string, unknown>> {
86
160
  line: number;
87
161
  column: number;
88
162
  }> | null;
163
+ /**
164
+ * Body-coordinate offset RANGE of the effective value for `metadata` keys
165
+ * whose value came from the v2 leading list AND was a single contiguous text
166
+ * token (no inline markup). Autofix uses this to target the value precisely
167
+ * (e.g. rewrite the status value in place). Frontmatter-sourced keys are
168
+ * absent — frontmatter is stripped before mdast parsing, so it has no body
169
+ * offset and needs YAML-aware rewriting instead. null when nothing qualifies.
170
+ * See ADR-0008.
171
+ */
172
+ metadataValueLoc: Record<string, {
173
+ start: number;
174
+ end: number;
175
+ }> | null;
89
176
  /** User-merged options for this rule (validated against rule.meta.schema). */
90
177
  options: TOptions;
91
- /** Emit a diagnostic. */
92
- report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity' | 'path'>): void;
178
+ /**
179
+ * Emit a diagnostic. `suggestion` / `docsUrl` / `fixable` are resolved by the
180
+ * runner. A rule may pass a `fix` thunk ONLY when its `meta.fixable` is
181
+ * `'code'` (otherwise the runner routes it through `core/internal-error`).
182
+ */
183
+ report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity' | 'path' | 'suggestion' | 'docsUrl' | 'fixable'>): void;
93
184
  }
94
185
  /**
95
186
  * Visitor map dispatched during single-pass mdast traversal.
@@ -156,9 +247,9 @@ interface ProjectRuleContext<TOptions = Record<string, unknown>> {
156
247
  /**
157
248
  * Emit a diagnostic. Unlike per-file rules, project rules MUST set
158
249
  * `path` explicitly — the runner cannot infer which file the
159
- * diagnostic relates to.
250
+ * diagnostic relates to. `suggestion` / `docsUrl` are resolved by the runner.
160
251
  */
161
- report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity'>): void;
252
+ report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity' | 'suggestion' | 'docsUrl' | 'fixable'>): void;
162
253
  }
163
254
  /**
164
255
  * A cross-file rule. Receives all parsed files at once and reports
@@ -177,6 +268,79 @@ type AnyRule<TOptions = Record<string, unknown>> = Rule<TOptions> | ProjectRule<
177
268
  /** Type guard distinguishing project rules from per-file rules. */
178
269
  declare function isProjectRule(rule: AnyRule): rule is ProjectRule;
179
270
 
271
+ /** Fixpoint iteration cap (matches ESLint's autofix passes). */
272
+ declare const MAX_FIX_PASSES = 10;
273
+ /**
274
+ * Build a `Fixer` whose helpers take BODY offsets and emit whole-file
275
+ * `TextEdit`s by adding `frontmatterOffset` (the length gray-matter stripped).
276
+ */
277
+ declare function makeFixer(base: number): Fixer;
278
+ /**
279
+ * Apply text edits to `content` in a single pass. Edits are sorted by start
280
+ * offset; any edit overlapping a previously-applied one is dropped (first wins).
281
+ * Invalid ranges (non-integer, negative, inverted, out of bounds) are dropped.
282
+ * Whole-file offsets in, new string out.
283
+ */
284
+ declare function applyEdits(content: string, edits: readonly TextEdit[]): string;
285
+ /**
286
+ * Counting variant of `applyEdits`: `applied` is the number of edits that
287
+ * actually LANDED (post overlap/bounds filtering) — the honest source for
288
+ * `summary.fixed`, which must not count dropped edits. Exported for the
289
+ * cross-file fixpoint (`lintAndFix`), which applies project-rule edits per file
290
+ * and needs the landed count.
291
+ */
292
+ declare function applyEditsCounted(content: string, edits: readonly TextEdit[]): {
293
+ text: string;
294
+ applied: number;
295
+ };
296
+ /**
297
+ * Invoke each diagnostic's `fix` thunk (if any) with a `Fixer` built for
298
+ * `content`, flattening arrays and skipping thunks that decline (return null).
299
+ * The frontmatter offset is derived from `content` so translation always
300
+ * matches the exact bytes being edited.
301
+ */
302
+ declare function collectFixes(diagnostics: readonly Diagnostic[], content: string): TextEdit[];
303
+ /**
304
+ * Collect CROSS-FILE (project-rule) fixes, grouped by the file each edits.
305
+ * A project diagnostic names the file it targets via `diagnostic.path`; its fix
306
+ * emits an edit for THAT file. Unlike per-file fixes, project fixes operate in
307
+ * WHOLE-FILE coordinates: they may edit YAML frontmatter, which body (mdast)
308
+ * coordinates strip, so there is no frontmatter shift to add — the fixer's base
309
+ * is 0. See #29 (ADR-0008's per-file edit-set seam).
310
+ *
311
+ * `contentByPath(path)` returns the current content of a target file, or
312
+ * undefined if it is not in the fix set (its edits are then skipped). At most
313
+ * ONE fix contributes per file per pass — a second insertion at the same spot
314
+ * would duplicate a key; the fixpoint re-runs the project pass so the runners-up
315
+ * re-evaluate against the now-edited file (and typically decline).
316
+ */
317
+ declare function collectProjectFixes(diagnostics: readonly Diagnostic[], contentByPath: (path: string) => string | undefined): Map<string, TextEdit[]>;
318
+ interface FixFileResult {
319
+ /** Content after the fixpoint loop (unchanged if nothing applied). */
320
+ fixedContent: string;
321
+ /** Diagnostics of the FINAL content (post-suppression / -baseline via `lint`). */
322
+ remaining: Diagnostic[];
323
+ /** Whether any edit actually changed the content. */
324
+ changed: boolean;
325
+ /** Number of applied passes (0 when nothing was fixed). */
326
+ passes: number;
327
+ /** Edits that actually LANDED across all passes (dropped edits excluded). */
328
+ applied: number;
329
+ }
330
+ /**
331
+ * Drive the autofix fixpoint for ONE file. `lint(content)` must return the
332
+ * REPORTED diagnostics for `content` — already suppression- and
333
+ * baseline-filtered — carrying live `fix` thunks. The loop lints at the top of
334
+ * every iteration, so `remaining` always reflects the returned `fixedContent`.
335
+ * Stops when: no edits remain, an edit makes no progress, or `maxPasses` is hit.
336
+ */
337
+ declare function fixFileContent(originalContent: string, lint: (content: string) => Diagnostic[], maxPasses?: number): FixFileResult;
338
+ /**
339
+ * Produce a unified diff between `before` and `after` for display. Returns an
340
+ * empty string when the two are identical.
341
+ */
342
+ declare function unifiedDiff(path: string, before: string, after: string): string;
343
+
180
344
  /**
181
345
  * Thrown when a rule's merged options fail AJV validation against
182
346
  * `rule.meta.schema`. Distinct from generic `Error` so callers can
@@ -284,11 +448,31 @@ interface ParsedFile {
284
448
  * effective value is list-sourced.
285
449
  */
286
450
  metadataLoc: Record<string, MetadataPosition> | null;
451
+ /**
452
+ * Body-coordinate offset RANGE of the effective value for keys whose value
453
+ * came from the v2 leading list AND was a single contiguous text token (no
454
+ * inline markup), verified to slice back to the exact value. Autofix targets
455
+ * the value precisely with this. Frontmatter-won keys are absent (frontmatter
456
+ * is stripped before parsing → no body offset). null when nothing qualifies.
457
+ */
458
+ metadataValueLoc: Record<string, {
459
+ start: number;
460
+ end: number;
461
+ }> | null;
287
462
  /** mdast root of the body (frontmatter stripped). */
288
463
  ast: Root;
289
464
  /** Body content with frontmatter stripped. */
290
465
  body: string;
291
466
  }
467
+ /**
468
+ * Character length of the frontmatter block that gray-matter strips from the
469
+ * front of `content` — i.e. the offset at which the body begins in the whole
470
+ * file. mdast positions are body-relative, so `fileOffset = bodyOffset +
471
+ * frontmatterOffset(content)`. gray-matter never mutates the body, so the body
472
+ * is always an exact suffix and this length is exact (verified across CRLF and
473
+ * leading-newline inputs). Used by the autofix fixer. See ADR-0008.
474
+ */
475
+ declare function frontmatterOffset(content: string): number;
292
476
  /**
293
477
  * Parse a Markdown file: extract YAML frontmatter via gray-matter, then
294
478
  * feed the body into mdast-util-from-markdown directly (per ADR-0002,
@@ -458,4 +642,4 @@ interface MadrLintConfig {
458
642
  */
459
643
  declare function defineConfig(config: MadrLintConfig): MadrLintConfig;
460
644
 
461
- export { type AnyRule, BASELINE_VERSION, type Baseline, type BaselineApplyResult, type Diagnostic, type FileContext, INTERNAL_ERROR_RULE_NAME, type MadrLintConfig, type MadrVersion, type MdastNode, type MdastNodeType, type ParsedFile, type ProjectFile, type ProjectRule, type ProjectRuleContext, type Rule, type RuleContext, type RuleListeners, type RuleMeta, RuleOptionsError, type RuleSeverity, type RunRuleOptions, type Severity, applyBaseline, baselinePath, buildBaseline, buildProjectFile, defineConfig, extractListMetadata, isProjectRule, loadBaseline, parseFile, recommended, index as rules, runRule, runRulesOnFile, runRulesOnProject, serializeBaseline, writeBaseline };
645
+ export { type AnyRule, BASELINE_VERSION, type Baseline, type BaselineApplyResult, type Diagnostic, type FileContext, type FixFileResult, type FixFn, type Fixer, INTERNAL_ERROR_RULE_NAME, MAX_FIX_PASSES, type MadrLintConfig, type MadrVersion, type MdastNode, type MdastNodeType, type ParsedFile, type ProjectFile, type ProjectRule, type ProjectRuleContext, type Rule, type RuleContext, type RuleListeners, type RuleMeta, RuleOptionsError, type RuleSeverity, type RunRuleOptions, type Severity, type TextEdit, applyBaseline, applyEdits, applyEditsCounted, baselinePath, buildBaseline, buildProjectFile, collectFixes, collectProjectFixes, defineConfig, extractListMetadata, fixFileContent, frontmatterOffset, isProjectRule, loadBaseline, makeFixer, parseFile, recommended, index as rules, runRule, runRulesOnFile, runRulesOnProject, serializeBaseline, unifiedDiff, writeBaseline };