@savvy-web/silk-effects 4.2.3 → 4.2.5

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.
@@ -1,6 +1,6 @@
1
1
  import { VERSION_RE } from "../../schemas/dependency-table.js";
2
2
  import { RULE_DOCS } from "../../constants.js";
3
- import { getHeadingLevel, getHeadingText } from "./utils.js";
3
+ import { getHeadingLevel, getHeadingText, unescapeMarkdown } from "./utils.js";
4
4
 
5
5
  //#region src/changesets/markdownlint/rules/dependency-table-format.ts
6
6
  const EM_DASH = "—";
@@ -31,13 +31,13 @@ const EXPECTED_HEADERS = [
31
31
  * The `tableContent` token has a `.text` property with the cell value.
32
32
  *
33
33
  * @param cell - A GFM table cell token (header or data)
34
- * @returns The trimmed text content of the cell, or an empty string
34
+ * @returns The trimmed, escape-resolved text content of the cell, or an empty string
35
35
  *
36
36
  * @internal
37
37
  */
38
38
  function getCellText(cell) {
39
39
  const content = cell.children.find((c) => c.type === "tableContent");
40
- return content ? content.text.trim() : "";
40
+ return content ? unescapeMarkdown(content.text.trim()) : "";
41
41
  }
42
42
  /**
43
43
  * Extract all cell texts from a `tableRow` token.
@@ -14,8 +14,38 @@ function getHeadingLevel(heading) {
14
14
  return sequence ? sequence.text.length : 0;
15
15
  }
16
16
  /**
17
+ * CommonMark backslash escapes: a backslash is literal unless it precedes an
18
+ * ASCII punctuation character, in which case the pair denotes that character.
19
+ */
20
+ const BACKSLASH_ESCAPE_RE = /\\([!-/:-@[-`{-~])/g;
21
+ /**
22
+ * Resolve CommonMark backslash escapes in raw micromark source text.
23
+ *
24
+ * @remarks
25
+ * Every rule in this directory has a sibling remark implementation that reads
26
+ * the parsed mdast tree, where the parser has already resolved escapes, via
27
+ * `mdast-util-to-string`. Micromark tokens instead carry RAW source. Comparing
28
+ * raw text against a value the parsed form would produce makes the two
29
+ * implementations of one documented rule disagree about the same file
30
+ * (issue #367).
31
+ *
32
+ * Applied at the shared extractors below so both implementations judge the
33
+ * value a reader actually sees, rather than per-rule where the next extractor
34
+ * added would silently reintroduce the split.
35
+ *
36
+ * @internal
37
+ */
38
+ function unescapeMarkdown(raw) {
39
+ return raw.replace(BACKSLASH_ESCAPE_RE, "$1");
40
+ }
41
+ /**
17
42
  * Get the plain text content of an `atxHeading` token.
18
43
  *
44
+ * @remarks
45
+ * Escape-resolved, so a heading compares equal to the same heading as the
46
+ * remark rules see it. `getHeadingLevel` needs no equivalent — it counts
47
+ * sequence characters rather than reading text.
48
+ *
19
49
  * @param heading - The `atxHeading` micromark token
20
50
  * @returns The heading text, or empty string if no text token found
21
51
  *
@@ -23,8 +53,8 @@ function getHeadingLevel(heading) {
23
53
  */
24
54
  function getHeadingText(heading) {
25
55
  const textToken = heading.children.find((c) => c.type === "atxHeadingText");
26
- return textToken ? textToken.text : "";
56
+ return textToken ? unescapeMarkdown(textToken.text) : "";
27
57
  }
28
58
 
29
59
  //#endregion
30
- export { RULE_DOCS, getHeadingLevel, getHeadingText };
60
+ export { RULE_DOCS, getHeadingLevel, getHeadingText, unescapeMarkdown };
@@ -11,10 +11,23 @@ const DEP_TYPE_MAP = [
11
11
  ["optionalDependencies", "optionalDependency"]
12
12
  ];
13
13
  /**
14
- * Resolve a specifier against the snapshot it belongs to, falling back to
15
- * the raw specifier string when the snapshot cannot resolve it.
14
+ * Resolve a specifier against the snapshot it belongs to, scoped to the
15
+ * importer that declared it, falling back to the raw specifier string when
16
+ * the snapshot cannot resolve it.
17
+ *
18
+ * @remarks
19
+ * Uses `resolveIn` rather than the workspace-wide `resolve`. Both consult the
20
+ * ref's own lockfile importer entries when a `catalog:` specifier is not in the
21
+ * catalog set — which is what makes hook-injected catalogs (`catalog:effect:peers`,
22
+ * injected by a config-dependency pnpmfile and recorded in neither
23
+ * `pnpm-workspace.yaml` nor the lockfile's `catalogs:` block) resolvable at all.
24
+ * The workspace-wide form abstains whenever two importers disagree on a
25
+ * dependency's version, which in a multi-package repo reproduces the original
26
+ * defect: both sides fall back to the identical raw specifier, compare equal,
27
+ * and emit no row. Scoping to the declaring importer answers precisely where
28
+ * the workspace-wide form cannot.
16
29
  */
17
- const resolveOrRaw = (snapshot, dep, spec) => Option.getOrElse(snapshot.resolve(dep, spec), () => spec);
30
+ const resolveOrRaw = (snapshot, importerPath, dep, spec) => Option.getOrElse(snapshot.resolveIn(importerPath, dep, spec), () => spec);
18
31
  /**
19
32
  * Drop no-net-change field moves: the same dependency removed from one field
20
33
  * and added to another with an equal resolved version (e.g. a dep promoted
@@ -59,13 +72,15 @@ function computeWorkspaceDependencyDiffs(before, after) {
59
72
  for (const afterPkg of after.packages) {
60
73
  const beforePkg = Option.getOrNull(before.package(afterPkg.name));
61
74
  const rows = [];
75
+ const afterImporter = afterPkg.relativePath;
76
+ const beforeImporter = beforePkg?.relativePath ?? afterImporter;
62
77
  for (const [field, type] of DEP_TYPE_MAP) {
63
78
  const beforeRecord = beforePkg?.[field] ?? {};
64
79
  const afterRecord = afterPkg[field];
65
80
  const seen = /* @__PURE__ */ new Set();
66
81
  for (const [name, beforeSpec] of Object.entries(beforeRecord)) {
67
82
  seen.add(name);
68
- const from = resolveOrRaw(before, name, beforeSpec);
83
+ const from = resolveOrRaw(before, beforeImporter, name, beforeSpec);
69
84
  const afterSpec = afterRecord[name];
70
85
  if (afterSpec === void 0) {
71
86
  rows.push({
@@ -77,7 +92,7 @@ function computeWorkspaceDependencyDiffs(before, after) {
77
92
  });
78
93
  continue;
79
94
  }
80
- const to = resolveOrRaw(after, name, afterSpec);
95
+ const to = resolveOrRaw(after, afterImporter, name, afterSpec);
81
96
  if (from !== to) rows.push({
82
97
  dependency: name,
83
98
  type,
@@ -93,7 +108,7 @@ function computeWorkspaceDependencyDiffs(before, after) {
93
108
  type,
94
109
  action: "added",
95
110
  from: EM_DASH,
96
- to: resolveOrRaw(after, name, afterSpec)
111
+ to: resolveOrRaw(after, afterImporter, name, afterSpec)
97
112
  });
98
113
  }
99
114
  }
@@ -1,8 +1,6 @@
1
+ import { literalText, stringifyMarkdown } from "./remark-pipeline.js";
1
2
  import { DependencyTableRowSchema } from "../schemas/dependency-table.js";
2
3
  import { Schema } from "effect";
3
- import remarkGfm from "remark-gfm";
4
- import remarkStringify from "remark-stringify";
5
- import { unified } from "unified";
6
4
  import { toString } from "mdast-util-to-string";
7
5
 
8
6
  //#region src/changesets/utils/dependency-table.ts
@@ -102,6 +100,12 @@ function parseDependencyTable(table) {
102
100
  /**
103
101
  * Create a table cell with a text node.
104
102
  *
103
+ * @remarks
104
+ * The cell text is marked literal, so stringification writes it verbatim
105
+ * instead of escaping characters that could open a markdown construct. See
106
+ * {@link literalText} — without it `~0.2.1` is written as `\~0.2.1` and
107
+ * `some_pkg` as `some\_pkg`.
108
+ *
105
109
  * @param text - The cell text content
106
110
  * @returns An MDAST `TableCell` node
107
111
  *
@@ -110,10 +114,7 @@ function parseDependencyTable(table) {
110
114
  function makeCell(text) {
111
115
  return {
112
116
  type: "tableCell",
113
- children: [{
114
- type: "text",
115
- value: text
116
- }]
117
+ children: [literalText(text)]
117
118
  };
118
119
  }
119
120
  /**
@@ -188,11 +189,10 @@ function serializeDependencyTable(rows) {
188
189
  * @internal
189
190
  */
190
191
  function serializeDependencyTableToMarkdown(rows) {
191
- const tree = {
192
+ return stringifyMarkdown({
192
193
  type: "root",
193
194
  children: [serializeDependencyTable(rows)]
194
- };
195
- return unified().use(remarkGfm).use(remarkStringify).stringify(tree).trim();
195
+ }).trim();
196
196
  }
197
197
  /**
198
198
  * Sort priority for dependency actions: removed first, then updated, then added.
@@ -5,6 +5,80 @@ import { unified } from "unified";
5
5
 
6
6
  //#region src/changesets/utils/remark-pipeline.ts
7
7
  /**
8
+ * Marker set on the `data` of a text node whose value must be written to
9
+ * markdown verbatim rather than escaped.
10
+ *
11
+ * @remarks
12
+ * `remark-stringify` escapes any character that could open a markdown
13
+ * construct. Inside a dependency table that is always wrong: with
14
+ * `remark-gfm` enabled `~` is the strikethrough delimiter, so the specifier
15
+ * `~0.2.1` is written as `\~0.2.1`, and `_` in a package name becomes `\_`.
16
+ * The corruption compounds on every re-serialization, and dependency-table
17
+ * cells round-trip through consolidation and PR-body reconstruction.
18
+ *
19
+ * The marker is deliberately narrow. It is set only by the dependency-table
20
+ * cell builder, whose cell vocabulary is closed — package name, dependency
21
+ * type, action, version specifier, and the em-dash sentinel — and none of
22
+ * those can legitimately carry markdown. Prose elsewhere in a changeset keeps
23
+ * the default escaping.
24
+ *
25
+ * @internal
26
+ */
27
+ const LITERAL_TEXT_MARKER = "silkLiteralText";
28
+ /**
29
+ * Mark a text node so {@link createRemarkProcessor} writes it verbatim.
30
+ *
31
+ * @param value - The literal cell text
32
+ * @returns An MDAST `Text` node carrying the literal marker
33
+ *
34
+ * @internal
35
+ */
36
+ function literalText(value) {
37
+ return {
38
+ type: "text",
39
+ value,
40
+ data: { [LITERAL_TEXT_MARKER]: true }
41
+ };
42
+ }
43
+ /**
44
+ * Whether a text node was marked by {@link literalText}.
45
+ *
46
+ * @internal
47
+ */
48
+ function isLiteralText(node) {
49
+ return node.data?.[LITERAL_TEXT_MARKER] === true;
50
+ }
51
+ /**
52
+ * Escape the two characters that would otherwise corrupt the table grid.
53
+ *
54
+ * @remarks
55
+ * A `|` would close the cell early and a `\` would be read as an escape
56
+ * introducer, so both are escaped to keep the written cell parseable back to
57
+ * its exact input. Every other character is emitted as-is. Because parsing
58
+ * consumes the backslash, a value that already carries a legacy `\~` from the
59
+ * old escaping path re-parses to its clean form rather than accumulating
60
+ * another layer.
61
+ *
62
+ * @internal
63
+ */
64
+ function escapeCellLiteral(value) {
65
+ return value.replace(/[\\|]/g, "\\$&");
66
+ }
67
+ /**
68
+ * `mdast-util-to-markdown` handler overrides used by the shared processor.
69
+ *
70
+ * @remarks
71
+ * Replaces the default `text` handler — which is `state.safe(node.value, info)`
72
+ * — with one that bypasses escaping for nodes marked by {@link literalText}
73
+ * and delegates to the default behavior for everything else.
74
+ *
75
+ * @internal
76
+ */
77
+ const literalTextHandlers = { text: (node, _parent, state, info) => {
78
+ const text = node;
79
+ return isLiteralText(text) ? escapeCellLiteral(text.value) : state.safe(text.value, info);
80
+ } };
81
+ /**
8
82
  * Create a unified processor configured with remark-parse, remark-gfm,
9
83
  * and remark-stringify.
10
84
  *
@@ -12,7 +86,8 @@ import { unified } from "unified";
12
86
  * Each call creates a fresh processor instance. The plugin chain is:
13
87
  * 1. `remark-parse` — markdown to MDAST
14
88
  * 2. `remark-gfm` — GitHub Flavored Markdown extensions (tables, etc.)
15
- * 3. `remark-stringify` — MDAST back to markdown
89
+ * 3. `remark-stringify` — MDAST back to markdown, with the
90
+ * {@link literalText} handler override
16
91
  *
17
92
  * @privateRemarks
18
93
  * Return type is intentionally inferred because the unified `Processor`
@@ -23,7 +98,7 @@ import { unified } from "unified";
23
98
  * @internal
24
99
  */
25
100
  function createRemarkProcessor() {
26
- return unified().use(remarkParse).use(remarkGfm).use(remarkStringify);
101
+ return unified().use(remarkParse).use(remarkGfm).use(remarkStringify, { handlers: literalTextHandlers });
27
102
  }
28
103
  /**
29
104
  * Parse a markdown string into an MDAST AST synchronously.
@@ -76,4 +151,4 @@ function stringifyMarkdown(tree) {
76
151
  }
77
152
 
78
153
  //#endregion
79
- export { createRemarkProcessor, parseMarkdown, stringifyMarkdown };
154
+ export { LITERAL_TEXT_MARKER, createRemarkProcessor, literalText, parseMarkdown, stringifyMarkdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "4.2.3",
3
+ "version": "4.2.5",
4
4
  "private": false,
5
5
  "description": "Shared Effect library for Silk Suite conventions",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/silk-effects",
@@ -33,13 +33,13 @@
33
33
  "@changesets/config": "^4.0.0-next.6",
34
34
  "@changesets/get-github-info": "^1.0.0-next.3",
35
35
  "@changesets/get-release-plan": "^5.0.0-next.7",
36
- "@effected/git": "^0.4.1",
37
- "@effected/glob": "^0.2.0",
38
- "@effected/jsonc": "^0.5.0",
39
- "@effected/package-json": "^0.5.0",
40
- "@effected/walker": "^0.3.1",
41
- "@effected/workspaces": "^0.6.1",
42
- "@effected/yaml": "^0.5.0",
36
+ "@effected/git": "^0.4.2",
37
+ "@effected/glob": "^0.2.1",
38
+ "@effected/jsonc": "^0.5.1",
39
+ "@effected/package-json": "^0.5.1",
40
+ "@effected/walker": "^0.3.2",
41
+ "@effected/workspaces": "^0.7.0",
42
+ "@effected/yaml": "^0.5.1",
43
43
  "@manypkg/get-packages": "^3.1.0",
44
44
  "mdast-util-heading-range": "^4.0.0",
45
45
  "mdast-util-to-string": "^4.0.0",
@@ -54,6 +54,6 @@
54
54
  "yaml-lint": "^1.7.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "effect": "4.0.0-beta.99"
57
+ "effect": "4.0.0-beta.101"
58
58
  }
59
59
  }