@ethisyscore/eslint-plugin-coreconnect 1.109.0 → 1.111.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethisyscore/eslint-plugin-coreconnect",
3
- "version": "1.109.0",
3
+ "version": "1.111.0",
4
4
  "description": "ESLint rules enforcing EthisysCore plugin frontend conventions. Published so a new rule reaches every plugin on a version bump, rather than being copied into each scaffold and drifting.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -9,6 +9,9 @@ import noLocalInProgressBanner from "./rules/no-local-in-progress-banner.js";
9
9
  import noLocalMappingEditor from "./rules/no-local-mapping-editor.js";
10
10
  import noLocalSearchInput from "./rules/no-local-search-input.js";
11
11
  import noParamInNavPath from "./rules/no-param-in-nav-path.js";
12
+ import noSecondMarkdownRenderer, {
13
+ MARKDOWN_RESTRICTED_IMPORTS,
14
+ } from "./rules/no-second-markdown-renderer.js";
12
15
 
13
16
  const rules = {
14
17
  "no-hardcoded-colors": noHardcodedColors,
@@ -22,6 +25,7 @@ const rules = {
22
25
  "no-local-mapping-editor": noLocalMappingEditor,
23
26
  "no-local-search-input": noLocalSearchInput,
24
27
  "no-param-in-nav-path": noParamInNavPath,
28
+ "no-second-markdown-renderer": noSecondMarkdownRenderer,
25
29
  };
26
30
 
27
31
  /**
@@ -58,5 +62,30 @@ plugin.configs.recommended = {
58
62
  rules: Object.fromEntries(Object.keys(rules).map((name) => [`coreconnect/${name}`, "error"])),
59
63
  };
60
64
 
65
+ /**
66
+ * The markdown import bans ship as `coreconnect/no-second-markdown-renderer`, and reach
67
+ * `recommended` through the mapping above like every other rule — so a plugin still receives them
68
+ * on a version bump, which was the point of shipping them here at all.
69
+ *
70
+ * AT "error", LIKE EVERY OTHER RULE, and deliberately so. The question was asked: a rule that
71
+ * bans five specifiers outright could turn a repo outside the migration red on its next bump.
72
+ * Swept the estate for the five before release — hr, legal, timesheets and the plugin template —
73
+ * and no source file in any of them imports one (hr declares `hast-util-sanitize` and
74
+ * `rehype-sanitize` in package.json and imports neither, which this rule does not police). The
75
+ * opt-out is the one every rule here has and which the config comment above describes: turn the
76
+ * rule off after the spread, where the exemption is visible in the repo that took it.
77
+ *
78
+ * They used to be assigned here as the CORE `no-restricted-imports` rule, and that was a foot-gun.
79
+ * Flat-config rule entries REPLACE, they do not merge: a consumer configuring `no-restricted-imports`
80
+ * for its own import policy silently dropped all five bans, and a file turning that rule off for an
81
+ * unrelated reason turned the markdown bans off with it. The cost was visible downstream as the same
82
+ * five paths restated in three config blocks with a comment explaining the collision.
83
+ *
84
+ * The paths list is still exported, unchanged, for a consumer that wants these as core
85
+ * `no-restricted-imports` paths alongside its own. The rule module owns it, so the list a consumer
86
+ * restates and the list the rule enforces cannot name different modules.
87
+ */
88
+ export { MARKDOWN_RESTRICTED_IMPORTS };
89
+
61
90
  export default plugin;
62
91
  export { rules };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Disallow importing any module that would build a second markdown renderer.
3
+ *
4
+ * The markdown renderer is a single shared component, and the property that makes it safe is that
5
+ * `rehype-sanitize` runs LAST in its rehype chain. That ordering is structurally enforced inside
6
+ * `@ethisyscore/plugin-ui/markdown` - the only module permitted to import the sanitizer.
7
+ *
8
+ * These bans exist because the historical failure mode was not "someone reordered the chain"; it
9
+ * was "someone wrote a sixth renderer". Five renderers and five copies of a sanitize schema had
10
+ * drifted across six repositories before this consolidation. A lint rule is what stops the next one
11
+ * being written, since the shared component cannot police code that never calls it.
12
+ *
13
+ * WHY THIS IS A NAMESPACED RULE AND NOT CORE `no-restricted-imports`. Flat-config rule entries
14
+ * REPLACE, they do not merge: shipping the bans as the core rule meant any consumer that configured
15
+ * `no-restricted-imports` for its own import policy silently dropped all five, and any file that
16
+ * turned that rule off - `src/icons/index.ts` in the web host, for one - turned the markdown bans
17
+ * off with it. The cost was already visible downstream as the same five paths restated in three
18
+ * config blocks with a comment explaining the collision. Under its own name the rule composes with
19
+ * whatever import policy a consumer writes, instead of racing it.
20
+ *
21
+ * @type {import("eslint").Rule.RuleModule}
22
+ */
23
+
24
+ /**
25
+ * The banned module ids and why each is banned. Data, not prose: the same list drives the rule's
26
+ * reports and is exported for a consumer that still wants these as core `no-restricted-imports`
27
+ * paths, so the two cannot name different modules.
28
+ */
29
+ export const MARKDOWN_RESTRICTED_IMPORTS = [
30
+ {
31
+ name: "react-markdown",
32
+ message:
33
+ "Render markdown through MarkdownView from @ethisyscore/plugin-ui/markdown. A local react-markdown call site owns its own plugin ordering, which is the bug this package removes.",
34
+ },
35
+ {
36
+ name: "rehype-sanitize",
37
+ message:
38
+ "The sanitize chain is owned by @ethisyscore/plugin-ui/markdown (pipeline.ts). Widen it with a MarkdownCapability.extendSchema instead of assembling a second chain.",
39
+ },
40
+ {
41
+ name: "hast-util-sanitize",
42
+ message:
43
+ "The sanitize schema is owned by @ethisyscore/plugin-ui/markdown. Import MarkdownSanitizeSchema from there if you need the type.",
44
+ },
45
+ {
46
+ name: "rehype-raw",
47
+ message: "Never. Raw HTML is not rendered - MarkdownView sets skipHtml and has no raw-HTML seam.",
48
+ },
49
+ {
50
+ name: "ds-markdown",
51
+ message:
52
+ "Removed. Use MarkdownView from @ethisyscore/plugin-ui/markdown; ds-markdown routed sanitize through an ordered plugins array and dragged react-router-dom into the bundle.",
53
+ },
54
+ ];
55
+
56
+ /**
57
+ * A subpath counts. `react-markdown/lib/ast-to-react` is the same renderer reached the long way
58
+ * round, and core `no-restricted-imports` `paths` would have walked past it.
59
+ */
60
+ function bannedEntry(source) {
61
+ return MARKDOWN_RESTRICTED_IMPORTS.find(
62
+ (entry) => source === entry.name || source.startsWith(`${entry.name}/`),
63
+ );
64
+ }
65
+
66
+ export default {
67
+ meta: {
68
+ type: "problem",
69
+ docs: {
70
+ description:
71
+ "Disallow importing react-markdown, rehype-sanitize, hast-util-sanitize, rehype-raw or ds-markdown - render through MarkdownView",
72
+ },
73
+ // One id carrying the entry's own message, so the reason a module is banned lives beside the
74
+ // ban rather than in a parallel table that drifts from it.
75
+ messages: { noSecondMarkdownRenderer: "{{why}}" },
76
+ schema: [],
77
+ },
78
+ create(context) {
79
+ const report = (node, source) => {
80
+ const entry = bannedEntry(source);
81
+ if (entry) {
82
+ context.report({ node, messageId: "noSecondMarkdownRenderer", data: { why: entry.message } });
83
+ }
84
+ };
85
+
86
+ // `export { x } from` has a null source when it re-exports nothing; a non-literal source is a
87
+ // computed specifier, which cannot be resolved statically and would be a guess to report.
88
+ const fromSource = (node) => {
89
+ if (node.source && node.source.type === "Literal" && typeof node.source.value === "string") {
90
+ report(node.source, node.source.value);
91
+ }
92
+ };
93
+
94
+ return {
95
+ ImportDeclaration: fromSource,
96
+ ExportNamedDeclaration: fromSource,
97
+ ExportAllDeclaration: fromSource,
98
+ // `await import("react-markdown")`. Espree parses this as an ImportExpression, NOT a
99
+ // CallExpression with an `Import` callee, so a CallExpression-only rule walks past the one
100
+ // form a call site reaches for when it wants to dodge a static-import lint rule.
101
+ ImportExpression: fromSource,
102
+ // `import md = require("react-markdown")`. Under @typescript-eslint/parser this is a
103
+ // TSImportEqualsDeclaration holding a TSExternalModuleReference, so it is neither an
104
+ // ImportDeclaration nor a CallExpression and every other visitor here walks past it. The
105
+ // SDK's own structural guard (singleImporter.test.ts) reads this form; that guard only
106
+ // scans the SDK's own source, and this rule is the half of the pair that faces the
107
+ // consumer repositories — so it must not be the weaker half. Narrow in practice (the form
108
+ // is unusable in an ESM package) and wide in principle, which is the cheapest kind of gap
109
+ // to close.
110
+ TSImportEqualsDeclaration(node) {
111
+ const reference = node.moduleReference;
112
+ if (!reference || reference.type !== "TSExternalModuleReference") return;
113
+
114
+ const { expression } = reference;
115
+ if (expression && expression.type === "Literal" && typeof expression.value === "string") {
116
+ report(expression, expression.value);
117
+ }
118
+ },
119
+ // `require("react-markdown")`. Reaching the module at runtime has the same ordering problem
120
+ // as reaching it statically.
121
+ CallExpression(node) {
122
+ if (node.callee.type !== "Identifier" || node.callee.name !== "require") return;
123
+
124
+ const [argument] = node.arguments;
125
+ if (argument && argument.type === "Literal" && typeof argument.value === "string") {
126
+ report(argument, argument.value);
127
+ }
128
+ },
129
+ };
130
+ },
131
+ };