madr-lint 0.4.1 → 0.4.3

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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [`f854ca3`](https://github.com/knktkc/madr-lint/commit/f854ca3a84e2694307356117bfd23c9d5db3ef2c) Thanks [@knktkc](https://github.com/knktkc)! - Reject JavaScript frontmatter instead of evaluating it. A document that opens with `---js` or `---javascript` now fails with an error, and its frontmatter block is not executed.
8
+
9
+ ## 0.4.2
10
+
11
+ ### Patch Changes
12
+
13
+ - [#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.
14
+
15
+ - **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.
16
+ - **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.
17
+
18
+ - [#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.
19
+
20
+ - **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.
21
+ - **`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.
22
+ - **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.
23
+
24
+ - [#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.
25
+
26
+ - **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.
27
+ - **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.
28
+ - **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.
29
+
30
+ - [#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.
31
+
32
+ - **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.
33
+ - **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.
34
+ - **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.
35
+
3
36
  ## 0.4.1
4
37
 
5
38
  ### Patch Changes
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
 
2
- export { }
2
+ export { };
package/dist/cli.js CHANGED
@@ -130,11 +130,27 @@ 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({
141
+ engines: {
142
+ javascript: () => {
143
+ throw new Error(
144
+ "JavaScript frontmatter (---js) is not supported. Use YAML frontmatter."
145
+ );
146
+ }
147
+ }
148
+ });
133
149
  function frontmatterOffset(content) {
134
- return content.length - grayMatter(content).content.length;
150
+ return content.length - grayMatter(content, GRAY_MATTER_OPTIONS).content.length;
135
151
  }
136
152
  function parseFile(content) {
137
- const matter = grayMatter(content);
153
+ const matter = grayMatter(content, GRAY_MATTER_OPTIONS);
138
154
  const data = matter.data;
139
155
  const frontmatter = Object.keys(data).length > 0 ? data : null;
140
156
  const ast = fromMarkdown(matter.content);
@@ -144,7 +160,7 @@ function parseFile(content) {
144
160
  let metadataLoc = listResult && Object.keys(listResult.loc).length > 0 ? { ...listResult.loc } : null;
145
161
  let metadataValueLoc = listResult && Object.keys(listResult.valueOffsets).length > 0 ? { ...listResult.valueOffsets } : null;
146
162
  if (frontmatter && listMetadata) {
147
- const frontmatterDefined = {};
163
+ const frontmatterDefined = nullProtoMap();
148
164
  for (const [k, v] of Object.entries(frontmatter)) {
149
165
  if (v !== null && v !== void 0) frontmatterDefined[k] = v;
150
166
  }
@@ -211,7 +227,7 @@ function extractListMetadataWithLoc(ast, body) {
211
227
  bridged = true;
212
228
  continue;
213
229
  }
214
- if (child.type === "list" && bridged) {
230
+ if (child.type === "list" && (bridged || child.children.length > 0 && child.children.every((item) => extractListItemKV(item)))) {
215
231
  segments.push(child);
216
232
  bridged = false;
217
233
  continue;
@@ -219,13 +235,13 @@ function extractListMetadataWithLoc(ast, body) {
219
235
  break;
220
236
  }
221
237
  if (segments.length === 0) return null;
222
- const values = {};
223
- const loc = {};
224
- const valueOffsets = {};
238
+ const values = nullProtoMap();
239
+ const loc = nullProtoMap();
240
+ const valueOffsets = nullProtoMap();
225
241
  for (const list of segments) {
226
242
  for (const item of list.children) {
227
243
  const pair = extractListItemKV(item);
228
- if (pair && !(pair.key in values)) {
244
+ if (pair && !Object.hasOwn(values, pair.key)) {
229
245
  values[pair.key] = pair.value;
230
246
  const start = item.position?.start;
231
247
  if (start) loc[pair.key] = { line: start.line, column: start.column };
@@ -238,7 +254,7 @@ function extractListMetadataWithLoc(ast, body) {
238
254
  const hasRecognizedKey = Object.keys(values).some(
239
255
  (k) => RECOGNIZED_METADATA_KEYS.has(k)
240
256
  );
241
- return hasRecognizedKey ? { values, loc, valueOffsets } : null;
257
+ return hasRecognizedKey ? { values: { ...values }, loc, valueOffsets } : null;
242
258
  }
243
259
  var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
244
260
  var SEPARATOR_PATTERN = /^\s*:\s*/;
@@ -445,9 +461,6 @@ var BASELINE_DISPLAY_PATH = ".madr-lint/baseline.json";
445
461
  function baselinePath(cwd) {
446
462
  return join3(cwd, ".madr-lint", "baseline.json");
447
463
  }
448
- function nullProtoMap() {
449
- return /* @__PURE__ */ Object.create(null);
450
- }
451
464
  function buildBaseline(diagnostics) {
452
465
  const entries = nullProtoMap();
453
466
  for (const d of diagnostics) {
@@ -605,7 +618,13 @@ function loadManifest(path) {
605
618
  ) {
606
619
  return null;
607
620
  }
608
- return data;
621
+ const files = nullProtoMap();
622
+ for (const [key, entry] of Object.entries(
623
+ data.files
624
+ )) {
625
+ files[key] = entry;
626
+ }
627
+ return { ...data, files };
609
628
  } catch {
610
629
  return null;
611
630
  }
@@ -812,7 +831,7 @@ var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
812
831
  "disable-next-line"
813
832
  ]);
814
833
  var DIRECTIVE_PREFIX = "madr-lint-";
815
- function parseDirective(value, line) {
834
+ function parseDirective(value, line, endLine) {
816
835
  const trimmed = value.trim();
817
836
  if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
818
837
  const inner = trimmed.slice(4, -3).trim();
@@ -823,7 +842,12 @@ function parseDirective(value, line) {
823
842
  const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
824
843
  if (!DIRECTIVE_KINDS.has(keyword)) return null;
825
844
  const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
826
- return { kind: keyword, rules: parseRuleList(rest), line };
845
+ return {
846
+ kind: keyword,
847
+ rules: parseRuleList(rest),
848
+ line,
849
+ endLine
850
+ };
827
851
  }
828
852
  function parseRuleList(rest) {
829
853
  const trimmed = rest.trim();
@@ -834,8 +858,9 @@ function parseRuleList(rest) {
834
858
  function walkHtml(node, out) {
835
859
  if (node.type === "html") {
836
860
  const line = node.position?.start.line;
837
- if (typeof line === "number") {
838
- const directive = parseDirective(node.value, line);
861
+ const endLine = node.position?.end.line;
862
+ if (typeof line === "number" && typeof endLine === "number") {
863
+ const directive = parseDirective(node.value, line, endLine);
839
864
  if (directive) out.push(directive);
840
865
  }
841
866
  }
@@ -874,7 +899,7 @@ function collectDirectives(ast, body) {
874
899
  break;
875
900
  }
876
901
  case "disable-next-line": {
877
- const target = nextNonBlankLine(directive.line);
902
+ const target = nextNonBlankLine(directive.endLine);
878
903
  if (target === null) break;
879
904
  let entry = index.nextLine.get(target);
880
905
  if (!entry) {
@@ -1186,7 +1211,7 @@ function lintFiles(opts) {
1186
1211
  schemaVersion: CACHE_SCHEMA_VERSION,
1187
1212
  version: opts.cache.pkgVersion,
1188
1213
  configHash: opts.cache.configHash,
1189
- files: {}
1214
+ files: nullProtoMap()
1190
1215
  };
1191
1216
  }
1192
1217
  }