madr-lint 0.4.1 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#127](https://github.com/knktkc/madr-lint/pull/127) [`ac0883e`](https://github.com/knktkc/madr-lint/commit/ac0883e1b9c015cf328ea2d4e806c564cc42489f) Thanks [@knktkc](https://github.com/knktkc)! - Bypass gray-matter's content-keyed parse cache ([#122](https://github.com/knktkc/madr-lint/issues/122)). gray-matter 4.0.3 memoizes a parse only when it is called with no options, in a plain object keyed by the *whole document* (`matter.cache[content]`). Both parser call sites now pass a shared, frozen options object, which turns that memoization off.
8
+
9
+ - **A document whose entire content is any `Object.prototype` member name (`constructor`, `toString`, `valueOf`, `__proto__`, …) no longer crashes.** Every one of those twelve lookups hit an inherited member, which gray-matter accepted as a cache hit and returned as a file with `content: undefined` — so `parseFile` threw `TypeError: Cannot convert undefined or null to object` and `frontmatterOffset` threw `TypeError: Cannot read properties of undefined (reading 'length')`. The throw escaped `lintFiles`, so `madr-lint docs/adr` on a directory holding such a file exited with a stack trace instead of reporting; it now reports the file's violations like any other. Only those exact bodies were affected — a trailing newline made the key an ordinary miss.
10
+ - **madr-lint no longer keeps every parsed document in memory.** The cache retained each document's full content for the process lifetime, as the key, the stripped body, and an `orig` Buffer. Measured over 1,000 distinct 8.4 KB documents: 16.9 MB retained before (8.9 MB heap + 8.0 MB buffers, about 2.1x the corpus), 0 MB now. Per-file lint throughput is unchanged (-1% to +3% across the rule benches, i.e. noise). `--fix` now performs a real second parse in `frontmatterOffset` where it previously got a memo hit — about +3 µs per file per pass, measured on this repo's own ADRs; correctness is unaffected.
11
+
12
+ - [#134](https://github.com/knktkc/madr-lint/pull/134) [`7512968`](https://github.com/knktkc/madr-lint/commit/7512968fc1420e232bd60cb977b5f7cb091be5aa) Thanks [@knktkc](https://github.com/knktkc)! - Read the fields below a bullet-marker change in a MADR v2 metadata block ([#115](https://github.com/knktkc/madr-lint/issues/115)). `* Status: accepted` followed by `- Date: 2026/07/06` is two CommonMark lists with no node between them, so the HTML-comment bridge added for [#73](https://github.com/knktkc/madr-lint/issues/73) could not reach the second one and every field below the marker change vanished from metadata.
13
+
14
+ - **A marker change no longer drops fields.** An adjacent list now joins the metadata block when *every* one of its items reads as a `Key: value` pair, so the `Date` above is parsed, keeps its own line number, is targetable by `madr-lint-disable-next-line`, and is repaired by `--fix` — instead of degrading a line-suppressible `invalidDate` into an unsuppressible file-level `missingDate`. The gate is shape, not marker: an ordered list (`1. Date: …`) after an unordered one merges on the same terms; a list with any non-`Key: value` item is prose and still ends the block, as do a paragraph, a code fence, a thematic break, a blockquote, a heading of any depth, and visible HTML.
15
+ - **`extractListMetadata` (public export) returns more keys for some documents.** The accepted cost of a shape-only gate is over-merging: a genuine prose list whose every bullet happens to read `Key: value` (`- Option A: do nothing`) is now merged into the block, so its keys appear in `listMetadata` / `metadata`. Merging can only add keys — duplicates still resolve first-wins, so an earlier field always shadows a later one and no document loses a key it had. The recognized-key gate (`status`, `date`, `deciders`, `decision-makers`, `consulted`, `informed`) is unchanged as a mechanism, but its outcome can change: a document whose only recognized key sits in the adjacent list now returns metadata where it previously returned `null` (which also lets `madr-lint init` cast a v2 version vote for it). Absorbed junk keys on their own never flip that gate.
16
+ - **Upgrading with a baseline**: a v2 field below a marker change was reported as a file-level `missingDate` / `missingStatus`; it is now reported as `invalidDate` / `invalidStatus` on the field's own line. If you baselined the old entry, re-run `madr-lint --update-baseline` — the stored entry no longer absorbs the new diagnostic, and the file goes red on an otherwise unchanged tree.
17
+
18
+ - [#135](https://github.com/knktkc/madr-lint/pull/135) [`eba7822`](https://github.com/knktkc/madr-lint/commit/eba78224ea23a018b6d1102c5de4af1a114903ca) Thanks [@knktkc](https://github.com/knktkc)! - Resolve `madr-lint-disable-next-line` from the comment's last line ([#116](https://github.com/knktkc/madr-lint/issues/116)). A directive spelled as a multi-line `<!--` … `-->` block parsed correctly, but the suppression layer counted its target from the html node's **start** line, so it landed on the comment's own interior instead of the content below `-->`. Directives now carry the node's end line and count from there.
19
+
20
+ - **A multi-line directive now reaches the line it points at.** Previously the diagnostic below `-->` was reported despite the directive — for the issue's document (a `disable-next-line madr/date-iso8601` split across three lines between two MADR v2 metadata items), `invalidDate` on the `* Date:` line was reported, not suppressed. A blank line between `-->` and the content is skipped, exactly as it is for a one-line directive.
21
+ - **Single-line directives are unchanged**: a one-line comment's end line *is* its start line, so nothing about the existing spelling moves. `disable` / `enable` ranges still open and close at the comment's **start** line, which is what preserves their pre-existing behavior. That choice is observable: an inline multi-line comment shares its start line with the content beside it — `[gone](./nope.md) <!--` … `-->` spans three lines while `madr/no-broken-links` reports the link on the first — so counting a range from the end line would stop such a `disable` from covering its own line.
22
+ - **The docs no longer ask you to keep a directive on one line.** The suppression guide (en/ja) and the `adopt-madr-lint` / `new-adr` skills previously called the multi-line spelling a miss; they now document it as working.
23
+
24
+ - [#123](https://github.com/knktkc/madr-lint/pull/123) [`657e3ef`](https://github.com/knktkc/madr-lint/commit/657e3ef7ab641fc8055796d22c8303a2b931b4b0) Thanks [@knktkc](https://github.com/knktkc)! - Make filename- and document-derived record keys prototype-safe ([#56](https://github.com/knktkc/madr-lint/issues/56)). Both the cache manifest and the MADR v2 metadata parser accumulated untrusted keys on plain objects, where a key that names an `Object.prototype` member is read and written *through* the prototype instead of stored as data.
25
+
26
+ - **Cache manifest keys are now prototype-safe.** `manifest.files` is a null-prototype map on both the cold path and rehydration, so a file whose relative path is `__proto__` caches, serializes, and cache-hits like any other. Previously the write went through the inherited `__proto__` setter — the entry never reached `manifest.json` and every run was a cold miss. No user-facing bug is fixed: the key is unreachable from the CLI (`findAdrFiles` drops every non-`.md` target, and a `__proto__.md` file keys as `__proto__.md`) and from the published API (`lintFiles` is not exported from `madr-lint`). This is hardening for the internal `lintFiles` seam and any future entry point that passes explicit paths. It was never a global pollution vector either: plain assignment swapped that one record's prototype rather than mutating `Object.prototype`. `saveManifest` output is byte-identical for ordinary keys, so existing manifests stay valid — no `CACHE_SCHEMA_VERSION` bump.
27
+ - **A v2 body-list key named `constructor` is now read.** The leading metadata block de-duplicates with `Object.hasOwn` instead of `in`, so `* Constructor: x` lands in `metadata.constructor` (with its body position and value offset) rather than being silently skipped because `'constructor' in {}` is true through inheritance. `constructor` is the only reachable case: key normalization lowercases, so `toString` arrives as `tostring` (not a member), and `__proto__` never matches the key pattern, which requires a leading letter. No rule reads these fields — the fix is for library consumers of `extractListMetadata` and `ParsedFile`, which previously got `Object` back for such a key. The returned records keep an ordinary prototype, so `hasOwnProperty` still works on them.
28
+ - **A frontmatter key named `__proto__` survives the v2 merge.** When a file carries both YAML frontmatter and a v2 body list, the merge step that drops explicit `null`/`undefined` now builds its record on a null prototype, so `__proto__: x` reaches `metadata.__proto__` as data instead of being swallowed by the inherited setter. The merged `metadata` is still a plain object.
29
+
3
30
  ## 0.4.1
4
31
 
5
32
  ### Patch Changes
package/dist/cli.js CHANGED
@@ -130,11 +130,19 @@ import { join as join2, resolve as resolve2 } from "path";
130
130
  import { fromMarkdown } from "mdast-util-from-markdown";
131
131
  import { toString } from "mdast-util-to-string";
132
132
  import grayMatter from "gray-matter";
133
+
134
+ // src/core/null-proto-map.ts
135
+ function nullProtoMap() {
136
+ return /* @__PURE__ */ Object.create(null);
137
+ }
138
+
139
+ // src/core/parser.ts
140
+ var GRAY_MATTER_OPTIONS = Object.freeze({});
133
141
  function frontmatterOffset(content) {
134
- return content.length - grayMatter(content).content.length;
142
+ return content.length - grayMatter(content, GRAY_MATTER_OPTIONS).content.length;
135
143
  }
136
144
  function parseFile(content) {
137
- const matter = grayMatter(content);
145
+ const matter = grayMatter(content, GRAY_MATTER_OPTIONS);
138
146
  const data = matter.data;
139
147
  const frontmatter = Object.keys(data).length > 0 ? data : null;
140
148
  const ast = fromMarkdown(matter.content);
@@ -144,7 +152,7 @@ function parseFile(content) {
144
152
  let metadataLoc = listResult && Object.keys(listResult.loc).length > 0 ? { ...listResult.loc } : null;
145
153
  let metadataValueLoc = listResult && Object.keys(listResult.valueOffsets).length > 0 ? { ...listResult.valueOffsets } : null;
146
154
  if (frontmatter && listMetadata) {
147
- const frontmatterDefined = {};
155
+ const frontmatterDefined = nullProtoMap();
148
156
  for (const [k, v] of Object.entries(frontmatter)) {
149
157
  if (v !== null && v !== void 0) frontmatterDefined[k] = v;
150
158
  }
@@ -211,7 +219,7 @@ function extractListMetadataWithLoc(ast, body) {
211
219
  bridged = true;
212
220
  continue;
213
221
  }
214
- if (child.type === "list" && bridged) {
222
+ if (child.type === "list" && (bridged || child.children.length > 0 && child.children.every((item) => extractListItemKV(item)))) {
215
223
  segments.push(child);
216
224
  bridged = false;
217
225
  continue;
@@ -219,13 +227,13 @@ function extractListMetadataWithLoc(ast, body) {
219
227
  break;
220
228
  }
221
229
  if (segments.length === 0) return null;
222
- const values = {};
223
- const loc = {};
224
- const valueOffsets = {};
230
+ const values = nullProtoMap();
231
+ const loc = nullProtoMap();
232
+ const valueOffsets = nullProtoMap();
225
233
  for (const list of segments) {
226
234
  for (const item of list.children) {
227
235
  const pair = extractListItemKV(item);
228
- if (pair && !(pair.key in values)) {
236
+ if (pair && !Object.hasOwn(values, pair.key)) {
229
237
  values[pair.key] = pair.value;
230
238
  const start = item.position?.start;
231
239
  if (start) loc[pair.key] = { line: start.line, column: start.column };
@@ -238,7 +246,7 @@ function extractListMetadataWithLoc(ast, body) {
238
246
  const hasRecognizedKey = Object.keys(values).some(
239
247
  (k) => RECOGNIZED_METADATA_KEYS.has(k)
240
248
  );
241
- return hasRecognizedKey ? { values, loc, valueOffsets } : null;
249
+ return hasRecognizedKey ? { values: { ...values }, loc, valueOffsets } : null;
242
250
  }
243
251
  var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
244
252
  var SEPARATOR_PATTERN = /^\s*:\s*/;
@@ -445,9 +453,6 @@ var BASELINE_DISPLAY_PATH = ".madr-lint/baseline.json";
445
453
  function baselinePath(cwd) {
446
454
  return join3(cwd, ".madr-lint", "baseline.json");
447
455
  }
448
- function nullProtoMap() {
449
- return /* @__PURE__ */ Object.create(null);
450
- }
451
456
  function buildBaseline(diagnostics) {
452
457
  const entries = nullProtoMap();
453
458
  for (const d of diagnostics) {
@@ -605,7 +610,13 @@ function loadManifest(path) {
605
610
  ) {
606
611
  return null;
607
612
  }
608
- return data;
613
+ const files = nullProtoMap();
614
+ for (const [key, entry] of Object.entries(
615
+ data.files
616
+ )) {
617
+ files[key] = entry;
618
+ }
619
+ return { ...data, files };
609
620
  } catch {
610
621
  return null;
611
622
  }
@@ -812,7 +823,7 @@ var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
812
823
  "disable-next-line"
813
824
  ]);
814
825
  var DIRECTIVE_PREFIX = "madr-lint-";
815
- function parseDirective(value, line) {
826
+ function parseDirective(value, line, endLine) {
816
827
  const trimmed = value.trim();
817
828
  if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
818
829
  const inner = trimmed.slice(4, -3).trim();
@@ -823,7 +834,12 @@ function parseDirective(value, line) {
823
834
  const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
824
835
  if (!DIRECTIVE_KINDS.has(keyword)) return null;
825
836
  const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
826
- return { kind: keyword, rules: parseRuleList(rest), line };
837
+ return {
838
+ kind: keyword,
839
+ rules: parseRuleList(rest),
840
+ line,
841
+ endLine
842
+ };
827
843
  }
828
844
  function parseRuleList(rest) {
829
845
  const trimmed = rest.trim();
@@ -834,8 +850,9 @@ function parseRuleList(rest) {
834
850
  function walkHtml(node, out) {
835
851
  if (node.type === "html") {
836
852
  const line = node.position?.start.line;
837
- if (typeof line === "number") {
838
- const directive = parseDirective(node.value, line);
853
+ const endLine = node.position?.end.line;
854
+ if (typeof line === "number" && typeof endLine === "number") {
855
+ const directive = parseDirective(node.value, line, endLine);
839
856
  if (directive) out.push(directive);
840
857
  }
841
858
  }
@@ -874,7 +891,7 @@ function collectDirectives(ast, body) {
874
891
  break;
875
892
  }
876
893
  case "disable-next-line": {
877
- const target = nextNonBlankLine(directive.line);
894
+ const target = nextNonBlankLine(directive.endLine);
878
895
  if (target === null) break;
879
896
  let entry = index.nextLine.get(target);
880
897
  if (!entry) {
@@ -1186,7 +1203,7 @@ function lintFiles(opts) {
1186
1203
  schemaVersion: CACHE_SCHEMA_VERSION,
1187
1204
  version: opts.cache.pkgVersion,
1188
1205
  configHash: opts.cache.configHash,
1189
- files: {}
1206
+ files: nullProtoMap()
1190
1207
  };
1191
1208
  }
1192
1209
  }