@comity-dev/eslint-plugin 0.1.2 → 0.3.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/README.md CHANGED
@@ -6,7 +6,7 @@ Reusable ESLint rules for Comity-wide source-code policy. This package owns the
6
6
 
7
7
  ## Scope
8
8
 
9
- Six rules, each addressing a Comity-wide concern:
9
+ Seven rules, each addressing a Comity-wide concern:
10
10
 
11
11
  | Rule | Concern |
12
12
  | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -16,6 +16,7 @@ Six rules, each addressing a Comity-wide concern:
16
16
  | `no-crypto-in-core` | Forbid `crypto` in Core Modules (domain must be deterministic). |
17
17
  | `no-forbidden-subpath-import` | Disallow imports from forbidden package subpaths (`utils`, `helpers`, `shared`, `internal`, `lazy`). |
18
18
  | `no-render-core-http-runtime` | Forbid render Core Modules from depending on HTTP runtime. |
19
+ | `require-internal-marker` | Require an `@internal` JSDoc tag on production sources under `src/internal/` and `src/lazy/` (owner-approved Contract B). |
19
20
 
20
21
  ## Ownership
21
22
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @comity-dev/eslint-plugin — Reusable ESLint rules for Comity source-code policy.
3
+ *
4
+ * This package owns the canonical ESLint rules for Comity. Repositories
5
+ * consume these rules via the shared orchestrator; they MUST NOT redefine
6
+ * source-code policy.
7
+ *
8
+ * Standard references:
9
+ * - architecture-validation.md §4
10
+ * - public-api.md §3.2 (forbidden subpath imports)
11
+ * - layering-policy.md §8 (rendering core MUST NOT depend on HTTP runtime)
12
+ * - coding.md (general style policy)
13
+ *
14
+ * Public surface (intentional):
15
+ * - default export: the ESLint plugin (loaded into flat-config)
16
+ * - `recommended`: the canonical Comity rule set
17
+ * - `RULE_IDS`: stable list of rule identifiers
18
+ */
19
+ export { plugin as default, RULE_IDS } from "./plugin.js";
20
+ export { recommended } from "./recommended.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @comity-dev/eslint-plugin — Reusable ESLint rules for Comity source-code policy.
3
+ *
4
+ * This package owns the canonical ESLint rules for Comity. Repositories
5
+ * consume these rules via the shared orchestrator; they MUST NOT redefine
6
+ * source-code policy.
7
+ *
8
+ * Standard references:
9
+ * - architecture-validation.md §4
10
+ * - public-api.md §3.2 (forbidden subpath imports)
11
+ * - layering-policy.md §8 (rendering core MUST NOT depend on HTTP runtime)
12
+ * - coding.md (general style policy)
13
+ *
14
+ * Public surface (intentional):
15
+ * - default export: the ESLint plugin (loaded into flat-config)
16
+ * - `recommended`: the canonical Comity rule set
17
+ * - `RULE_IDS`: stable list of rule identifiers
18
+ */
19
+ export { plugin as default, RULE_IDS } from "./plugin.js";
20
+ export { recommended } from "./recommended.js";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @comity-dev/eslint-plugin — Reusable ESLint rules for Comity source-code policy.
3
+ *
4
+ * This package owns the canonical ESLint rules for Comity. Repositories
5
+ * consume these rules via the shared orchestrator; they MUST NOT redefine
6
+ * source-code policy.
7
+ *
8
+ * Standard references:
9
+ * - architecture-validation.md §4
10
+ * - public-api.md §3.2 (forbidden subpath imports)
11
+ * - layering-policy.md §8 (rendering core MUST NOT depend on HTTP runtime)
12
+ * - coding.md (general style policy)
13
+ *
14
+ * Public surface (intentional):
15
+ * - default export: the ESLint plugin (loaded into flat-config)
16
+ * - `recommended`: the canonical Comity rule set
17
+ * - `RULE_IDS`: stable list of rule identifiers
18
+ */
19
+ import type { ESLint } from "eslint";
20
+ export interface BuiltinRule {
21
+ /** The stable identifier of the rule */
22
+ name: string;
23
+ /** The rule implementation */
24
+ rule: unknown;
25
+ }
26
+ export declare const BUILTIN_RULES: BuiltinRule[];
27
+ export declare const plugin: ESLint.Plugin;
28
+ /** Stable list of rule identifiers exported by this plugin. */
29
+ export declare const RULE_IDS: string[];
package/dist/plugin.js ADDED
@@ -0,0 +1,25 @@
1
+ import noCryptoInCore from "./rules/no-crypto-in-core.js";
2
+ import noDateInCore from "./rules/no-date-in-core.js";
3
+ import noForbiddenDeepImport from "./rules/no-forbidden-deep-import.js";
4
+ import noForbiddenSubpathImport from "./rules/no-forbidden-subpath-import.js";
5
+ import noLayerViolation from "./rules/no-layer-violation.js";
6
+ import noRenderCoreHttpRuntime from "./rules/no-render-core-http-runtime.js";
7
+ import requireInternalMarker from "./rules/require-internal-marker.js";
8
+ export const BUILTIN_RULES = [
9
+ { name: "no-forbidden-deep-import", rule: noForbiddenDeepImport },
10
+ { name: "no-layer-violation", rule: noLayerViolation },
11
+ { name: "no-date-in-core", rule: noDateInCore },
12
+ { name: "no-crypto-in-core", rule: noCryptoInCore },
13
+ { name: "no-forbidden-subpath-import", rule: noForbiddenSubpathImport },
14
+ { name: "no-render-core-http-runtime", rule: noRenderCoreHttpRuntime },
15
+ { name: "require-internal-marker", rule: requireInternalMarker },
16
+ ];
17
+ export const plugin = {
18
+ meta: {
19
+ name: "@comity-dev/eslint-plugin",
20
+ version: "0.1.0",
21
+ },
22
+ rules: Object.fromEntries(BUILTIN_RULES.map((r) => [r.name, r.rule])),
23
+ };
24
+ /** Stable list of rule identifiers exported by this plugin. */
25
+ export const RULE_IDS = BUILTIN_RULES.map((r) => r.name);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Recommended Comity ESLint rule set.
3
+ *
4
+ * The "recommended" preset bundles the canonical Comity rules with their
5
+ * default severities. Repositories MAY override severities; they MUST NOT
6
+ * disable rules silently.
7
+ */
8
+ import type { Linter } from "eslint";
9
+ export declare const recommended: Linter.Config;
@@ -0,0 +1,11 @@
1
+ export const recommended = {
2
+ rules: {
3
+ "@comity-dev/no-forbidden-deep-import": "error",
4
+ "@comity-dev/no-layer-violation": "error",
5
+ "@comity-dev/no-date-in-core": "error",
6
+ "@comity-dev/no-crypto-in-core": "error",
7
+ "@comity-dev/no-forbidden-subpath-import": "error",
8
+ "@comity-dev/no-render-core-http-runtime": "error",
9
+ "@comity-dev/require-internal-marker": "error",
10
+ },
11
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @comity-dev/no-crypto-in-core
3
+ *
4
+ * Forbid Node's `crypto` and WebCrypto (`globalThis.crypto`) modules in Core
5
+ * packages. Core must be deterministic and side-effect free; cryptography
6
+ * belongs in Adapters that own runtime concerns.
7
+ *
8
+ * Standard references:
9
+ * - layering-policy.md §2.2 (Core boundaries)
10
+ * - adapters.md
11
+ */
12
+ type Rule = unknown;
13
+ declare const rule: Rule;
14
+ export default rule;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @comity-dev/no-crypto-in-core
3
+ *
4
+ * Forbid Node's `crypto` and WebCrypto (`globalThis.crypto`) modules in Core
5
+ * packages. Core must be deterministic and side-effect free; cryptography
6
+ * belongs in Adapters that own runtime concerns.
7
+ *
8
+ * Standard references:
9
+ * - layering-policy.md §2.2 (Core boundaries)
10
+ * - adapters.md
11
+ */
12
+ const FORBIDDEN_SPECIFIERS = new Set(["crypto", "node:crypto", "webcrypto"]);
13
+ const messages = {
14
+ forbiddenImport: "Importing '{{specifier}}' is forbidden in Core (layering-policy.md §2.2). Use a Technology Adapter instead.",
15
+ forbiddenGlobal: "Accessing the `crypto` global is forbidden in Core (layering-policy.md §2.2). Use a Technology Adapter instead.",
16
+ };
17
+ function isInCorePackage(filename) {
18
+ return (filename.includes("/packages/") &&
19
+ (filename.includes("/primitives/") ||
20
+ filename.includes("/kernel/") ||
21
+ filename.includes("/composition/") ||
22
+ filename.includes("/http/") ||
23
+ filename.includes("/router/") ||
24
+ filename.includes("/html/") ||
25
+ filename.includes("/hydration/") ||
26
+ filename.includes("/seo/") ||
27
+ filename.includes("/content/") ||
28
+ filename.includes("/media/") ||
29
+ filename.includes("/search/") ||
30
+ filename.includes("/cache/") ||
31
+ filename.includes("/auth/") ||
32
+ filename.includes("/catalog/") ||
33
+ filename.includes("/customer/") ||
34
+ filename.includes("/inventory/") ||
35
+ filename.includes("/order/") ||
36
+ filename.includes("/payment/") ||
37
+ filename.includes("/pricing/") ||
38
+ filename.includes("/storefront/") ||
39
+ filename.includes("/taxonomy/") ||
40
+ filename.includes("/validation/") ||
41
+ filename.includes("/address/") ||
42
+ filename.includes("/geography/") ||
43
+ filename.includes("/identity/") ||
44
+ filename.includes("/organization/")));
45
+ }
46
+ const rule = {
47
+ meta: {
48
+ type: "problem",
49
+ docs: {
50
+ description: "Forbid `crypto` imports in Core packages — cryptography belongs in Adapters.",
51
+ category: "Comity Architecture",
52
+ recommended: true,
53
+ },
54
+ schema: [
55
+ {
56
+ type: "object",
57
+ properties: {
58
+ sourceName: {
59
+ type: "string",
60
+ description: "Name of the source package (e.g., '@comity/auth'). Used to scope the rule.",
61
+ },
62
+ },
63
+ additionalProperties: false,
64
+ },
65
+ ],
66
+ messages,
67
+ },
68
+ create(context) {
69
+ const opts = (context.options[0] ?? {});
70
+ const sourceName = opts.sourceName ?? null;
71
+ // If sourceName provided, use it for classification; otherwise fall back to filename
72
+ const inCore = sourceName
73
+ ? sourceName.startsWith("@comity/") &&
74
+ !["@comity/cli", "@comity/graphql-builder"].includes(sourceName)
75
+ : isInCorePackage(context.filename ?? context.getFilename());
76
+ // Disable this rule outside Core packages
77
+ if (!inCore) {
78
+ return {};
79
+ }
80
+ function checkSpecifier(value) {
81
+ if (!value)
82
+ return false;
83
+ return FORBIDDEN_SPECIFIERS.has(value);
84
+ }
85
+ return {
86
+ ImportDeclaration(node) {
87
+ if (checkSpecifier(node.source.value)) {
88
+ context.report({
89
+ node,
90
+ messageId: "forbiddenImport",
91
+ data: { specifier: node.source.value },
92
+ });
93
+ }
94
+ },
95
+ ImportExpression(node) {
96
+ if (node.source.type === "Literal" &&
97
+ typeof node.source.value === "string" &&
98
+ checkSpecifier(node.source.value)) {
99
+ context.report({
100
+ node,
101
+ messageId: "forbiddenImport",
102
+ data: { specifier: node.source.value },
103
+ });
104
+ }
105
+ },
106
+ Identifier(node) {
107
+ if (node.name !== "crypto")
108
+ return;
109
+ // Top-level access of the `crypto` global
110
+ const parent = node.parent;
111
+ if (!parent)
112
+ return;
113
+ if (parent.type === "ExpressionStatement" ||
114
+ parent.type === "CallExpression" ||
115
+ parent.type === "MemberExpression") {
116
+ context.report({ node, messageId: "forbiddenGlobal" });
117
+ }
118
+ },
119
+ };
120
+ },
121
+ };
122
+ export default rule;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @comity-dev/no-date-in-core
3
+ *
4
+ * Forbid `Date`, `performance.now`, and `Math.random` access in Core and
5
+ * Kernel packages — they introduce non-deterministic behavior that breaks
6
+ * the Comity reproducibility contract.
7
+ *
8
+ * Legitimate exceptions:
9
+ * - Telemetry/timing measurements using `performance.now()` (observational, not domain logic)
10
+ * - Timestamp generation using `Date.now()` or `Date` constructor for logging/metadata
11
+ * - Time primitives in `@comity/primitives/time` (explicitly allowed): `Date`
12
+ * use under `packages/primitives/src/time/` is the sanctioned clock
13
+ * boundary of the framework.
14
+ *
15
+ * Standard references:
16
+ * - layering-policy.md §6 (determinism)
17
+ * - domain-modeling.md
18
+ */
19
+ type Rule = unknown;
20
+ declare const rule: Rule;
21
+ export default rule;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @comity-dev/no-date-in-core
3
+ *
4
+ * Forbid `Date`, `performance.now`, and `Math.random` access in Core and
5
+ * Kernel packages — they introduce non-deterministic behavior that breaks
6
+ * the Comity reproducibility contract.
7
+ *
8
+ * Legitimate exceptions:
9
+ * - Telemetry/timing measurements using `performance.now()` (observational, not domain logic)
10
+ * - Timestamp generation using `Date.now()` or `Date` constructor for logging/metadata
11
+ * - Time primitives in `@comity/primitives/time` (explicitly allowed): `Date`
12
+ * use under `packages/primitives/src/time/` is the sanctioned clock
13
+ * boundary of the framework.
14
+ *
15
+ * Standard references:
16
+ * - layering-policy.md §6 (determinism)
17
+ * - domain-modeling.md
18
+ */
19
+ const FORBIDDEN_GLOBALS = new Set(["Date", "performance"]);
20
+ const FORBIDDEN_PROPERTIES = {
21
+ Math: ["random"],
22
+ };
23
+ // Package names that are explicitly allowed to use Date/performance for telemetry
24
+ const TELEMETRY_ALLOWLIST = new Set([
25
+ "@comity/primitives", // time/instant primitives
26
+ "@comity/http", // facade timing
27
+ "@comity/html", // renderer pipeline timing
28
+ "@comity/hydration", // browser capability implementations (createBrowserHydrationCapabilities, createNoopHydrationCapabilities)
29
+ ]);
30
+ // Allowlist for specific property access patterns that are legitimate telemetry
31
+ const TELEMETRY_PROPERTY_ALLOWLIST = new Set(["performance.now", "Date.now"]);
32
+ const messages = {
33
+ forbiddenGlobal: "Non-deterministic global '{{name}}' is forbidden in Core / Kernel (layering-policy.md §6). Use telemetry allowlist or primitives/time for timing.",
34
+ forbiddenProperty: "Non-deterministic '{{object}}.{{property}}' is forbidden in Core / Kernel (layering-policy.md §6).",
35
+ };
36
+ function normalizeFilename(filename) {
37
+ return filename.replace(/\\/g, "/");
38
+ }
39
+ function isInCoreOrKernelPackage(filename) {
40
+ const normalized = normalizeFilename(filename);
41
+ return (normalized.includes("/packages/") &&
42
+ (normalized.includes("/primitives/") ||
43
+ normalized.includes("/kernel/") ||
44
+ normalized.includes("/composition/") ||
45
+ normalized.includes("/http/") ||
46
+ normalized.includes("/router/") ||
47
+ normalized.includes("/html/") ||
48
+ normalized.includes("/hydration/") ||
49
+ normalized.includes("/seo/") ||
50
+ normalized.includes("/content/") ||
51
+ normalized.includes("/media/") ||
52
+ normalized.includes("/search/")));
53
+ }
54
+ function isTelemetryAllowedPackage(filename) {
55
+ const normalized = normalizeFilename(filename);
56
+ return Array.from(TELEMETRY_ALLOWLIST).some((pkg) => normalized.includes(`/packages/${pkg.replace("@comity/", "")}/`) ||
57
+ normalized.includes(`/community/packages/${pkg.replace("@comity/", "")}/`));
58
+ }
59
+ /**
60
+ * Returns whether the file is one of the sanctioned time primitives that
61
+ * own the framework's `Date` clock boundary.
62
+ *
63
+ * The exemption is deliberately path-narrow: only files under
64
+ * `packages/primitives/src/time/` qualify. `Date` use anywhere else in
65
+ * Core — including elsewhere inside `@comity/primitives` — remains
66
+ * forbidden.
67
+ *
68
+ * @param filename - The file under lint, in platform-native form.
69
+ *
70
+ * @returns `true` when the file is a sanctioned time primitive.
71
+ */
72
+ function isTimePrimitiveFile(filename) {
73
+ return normalizeFilename(filename).includes("/packages/primitives/src/time/");
74
+ }
75
+ function isTelemetryPropertyAccess(objectName, propertyName) {
76
+ return TELEMETRY_PROPERTY_ALLOWLIST.has(`${objectName}.${propertyName}`);
77
+ }
78
+ const rule = {
79
+ meta: {
80
+ type: "problem",
81
+ docs: {
82
+ description: "Forbid Date / performance.now / Math.random in Core and Kernel packages.",
83
+ category: "Comity Architecture",
84
+ recommended: true,
85
+ },
86
+ schema: [
87
+ {
88
+ type: "object",
89
+ properties: {
90
+ sourceName: {
91
+ type: "string",
92
+ description: "Name of the source package (e.g., '@comity/http'). Used to scope the rule.",
93
+ },
94
+ },
95
+ additionalProperties: false,
96
+ },
97
+ ],
98
+ messages,
99
+ },
100
+ create(context) {
101
+ const opts = (context.options[0] ?? {});
102
+ const sourceName = opts.sourceName ?? null;
103
+ const filename = context.filename ?? context.getFilename();
104
+ // If sourceName provided, use it for classification; otherwise fall back to filename
105
+ const inCoreOrKernel = sourceName
106
+ ? sourceName.startsWith("@comity/") &&
107
+ !["@comity/cli", "@comity/graphql-builder"].includes(sourceName) // these are not core/kernel
108
+ : isInCoreOrKernelPackage(filename);
109
+ // Disable this rule outside Core/Kernel packages
110
+ if (!inCoreOrKernel) {
111
+ return {};
112
+ }
113
+ // Check if this package is allowed to use telemetry patterns
114
+ const allowTelemetry = sourceName
115
+ ? TELEMETRY_ALLOWLIST.has(sourceName)
116
+ : isTelemetryAllowedPackage(filename);
117
+ // Sanctioned clock boundary: time primitives own `Date` usage.
118
+ const isTimePrimitive = isTimePrimitiveFile(filename);
119
+ return {
120
+ Identifier(node) {
121
+ // Detect globals like `Date` and `performance`
122
+ if ((node.name === "Date" || node.name === "performance") &&
123
+ // Only top-level usage (not as property of another object)
124
+ node.parent &&
125
+ (node.parent.type === "ExpressionStatement" ||
126
+ node.parent.type === "VariableDeclarator" ||
127
+ node.parent.type === "CallExpression" ||
128
+ node.parent.type === "NewExpression" ||
129
+ node.parent.type === "MemberExpression")) {
130
+ // Time primitives own the `Date` clock boundary: `Date.now()` as
131
+ // the clock source and `new Date(ms)` for deterministic rendering
132
+ // are legitimate exactly here and nowhere else in Core.
133
+ if (node.name === "Date" && isTimePrimitive) {
134
+ return;
135
+ }
136
+ // Allow telemetry patterns in allowlisted packages
137
+ if (allowTelemetry &&
138
+ isTelemetryPropertyAccess(node.parent.object?.name ?? "", node.name)) {
139
+ return;
140
+ }
141
+ // Allow Date.now() and performance.now() in telemetry allowlisted packages
142
+ if (allowTelemetry &&
143
+ ((node.name === "Date" &&
144
+ node.parent.type === "MemberExpression" &&
145
+ node.parent.property?.name === "now") ||
146
+ (node.name === "performance" &&
147
+ node.parent.type === "MemberExpression" &&
148
+ node.parent.property?.name === "now"))) {
149
+ return;
150
+ }
151
+ // Also handle optional chaining: window?.performance?.now()
152
+ // Walk up the ancestor chain to find a MemberExpression with property 'now'
153
+ if (allowTelemetry &&
154
+ (node.name === "Date" || node.name === "performance")) {
155
+ let current = node.parent;
156
+ while (current) {
157
+ if (current.type === "MemberExpression" &&
158
+ current.property?.name === "now") {
159
+ return;
160
+ }
161
+ if (current.type === "ChainExpression") {
162
+ current = current.expression;
163
+ continue;
164
+ }
165
+ current = current.parent;
166
+ }
167
+ }
168
+ if (FORBIDDEN_GLOBALS.has(node.name)) {
169
+ context.report({
170
+ node,
171
+ messageId: "forbiddenGlobal",
172
+ data: { name: node.name },
173
+ });
174
+ }
175
+ }
176
+ // Detect property access like Math.random
177
+ if (node.parent &&
178
+ node.parent.type === "MemberExpression" &&
179
+ node.parent.property === node) {
180
+ const obj = node.parent.object;
181
+ if (obj.type === "Identifier") {
182
+ const allowed = FORBIDDEN_PROPERTIES[obj.name];
183
+ if (allowed && allowed.includes(node.name)) {
184
+ // Allow Math.random in primitives/time (explicit time primitive)
185
+ if (allowTelemetry && sourceName === "@comity/primitives") {
186
+ return;
187
+ }
188
+ context.report({
189
+ node,
190
+ messageId: "forbiddenProperty",
191
+ data: { object: obj.name, property: node.name },
192
+ });
193
+ }
194
+ }
195
+ }
196
+ },
197
+ };
198
+ },
199
+ };
200
+ export default rule;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @comity-dev/no-forbidden-deep-import
3
+ *
4
+ * Forbid deep imports into another `@comity/*` package's internals.
5
+ *
6
+ * Examples:
7
+ * import { foo } from "@comity/pricing/src/foo" // ❌
8
+ * import { foo } from "@comity/pricing/dist/foo.js" // ❌
9
+ * import { foo } from "@comity/pricing/internal/x" // ❌
10
+ * import { foo } from "@comity/pricing/lazy/x" // ❌
11
+ * import { foo } from "@comity/pricing" // ✅
12
+ * import { foo } from "@comity/pricing/exports/foo" // ✅
13
+ *
14
+ * Standard references:
15
+ * - public-api.md §3.2
16
+ * - architecture-validation.md §4.1
17
+ */
18
+ type Rule = unknown;
19
+ declare const rule: Rule;
20
+ export default rule;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @comity-dev/no-forbidden-deep-import
3
+ *
4
+ * Forbid deep imports into another `@comity/*` package's internals.
5
+ *
6
+ * Examples:
7
+ * import { foo } from "@comity/pricing/src/foo" // ❌
8
+ * import { foo } from "@comity/pricing/dist/foo.js" // ❌
9
+ * import { foo } from "@comity/pricing/internal/x" // ❌
10
+ * import { foo } from "@comity/pricing/lazy/x" // ❌
11
+ * import { foo } from "@comity/pricing" // ✅
12
+ * import { foo } from "@comity/pricing/exports/foo" // ✅
13
+ *
14
+ * Standard references:
15
+ * - public-api.md §3.2
16
+ * - architecture-validation.md §4.1
17
+ */
18
+ const FORBIDDEN_PATTERNS = [
19
+ /^@comity\/[^/]+\/src\//,
20
+ /^@comity\/[^/]+\/dist\//,
21
+ /^@comity\/[^/]+\/internal/,
22
+ /^@comity\/[^/]+\/lazy/,
23
+ ];
24
+ function isForbidden(specifier) {
25
+ return FORBIDDEN_PATTERNS.some((re) => re.test(specifier));
26
+ }
27
+ const messages = {
28
+ forbidden: "Deep imports into another package's internals are forbidden (public-api.md §3.2). Import only from declared public subpaths.",
29
+ };
30
+ const rule = {
31
+ meta: {
32
+ type: "problem",
33
+ docs: {
34
+ description: "Forbid deep imports into other `@comity/*` packages' internals (src/, dist/, internal/, lazy/).",
35
+ category: "Comity Architecture",
36
+ recommended: true,
37
+ },
38
+ schema: [],
39
+ messages,
40
+ },
41
+ create(context) {
42
+ return {
43
+ ImportDeclaration(node) {
44
+ const specifier = node.source.value;
45
+ if (!specifier || !specifier.startsWith("@comity/"))
46
+ return;
47
+ if (isForbidden(specifier)) {
48
+ context.report({ node, messageId: "forbidden" });
49
+ }
50
+ },
51
+ ExportNamedDeclaration(node) {
52
+ if (!node.source || !node.source.value)
53
+ return;
54
+ const specifier = node.source.value;
55
+ if (!specifier.startsWith("@comity/"))
56
+ return;
57
+ if (isForbidden(specifier)) {
58
+ context.report({ node, messageId: "forbidden" });
59
+ }
60
+ },
61
+ ExportAllDeclaration(node) {
62
+ const specifier = node.source.value;
63
+ if (!specifier || !specifier.startsWith("@comity/"))
64
+ return;
65
+ if (isForbidden(specifier)) {
66
+ context.report({ node, messageId: "forbidden" });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ };
72
+ export default rule;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @comity-dev/no-forbidden-subpath-import
3
+ *
4
+ * Forbid exports to forbidden subpaths (`utils`, `helpers`, `shared`,
5
+ * `internal`, `lazy`) per `public-api.md §3.2`.
6
+ *
7
+ * Standard references:
8
+ * - public-api.md §3.2
9
+ * - architecture-validation.md §4.2
10
+ */
11
+ type Rule = unknown;
12
+ declare const rule: Rule;
13
+ export default rule;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @comity-dev/no-forbidden-subpath-import
3
+ *
4
+ * Forbid exports to forbidden subpaths (`utils`, `helpers`, `shared`,
5
+ * `internal`, `lazy`) per `public-api.md §3.2`.
6
+ *
7
+ * Standard references:
8
+ * - public-api.md §3.2
9
+ * - architecture-validation.md §4.2
10
+ */
11
+ const FORBIDDEN_SUBPATHS = new Set([
12
+ "utils",
13
+ "helpers",
14
+ "shared",
15
+ "internal",
16
+ "lazy",
17
+ ]);
18
+ const messages = {
19
+ forbiddenSubpath: "Exports subpath './{{segment}}' is forbidden (public-api.md §3.2).",
20
+ };
21
+ const rule = {
22
+ meta: {
23
+ type: "problem",
24
+ docs: {
25
+ description: "Forbid exports subpaths `utils`, `helpers`, `shared`, `internal`, `lazy`.",
26
+ category: "Comity Architecture",
27
+ recommended: true,
28
+ },
29
+ schema: [],
30
+ messages,
31
+ },
32
+ create(context) {
33
+ function isComityPackage(pkgName) {
34
+ return pkgName.startsWith("@comity/");
35
+ }
36
+ return {
37
+ ExportNamedDeclaration(node) {
38
+ // No source = local export; not relevant
39
+ if (!node.source || !node.source.value)
40
+ return;
41
+ const specifier = node.source.value;
42
+ if (!specifier.startsWith("@comity/"))
43
+ return;
44
+ // Only check imports/exports OF other @comity packages
45
+ const pkgMatch = specifier.match(/^@comity\/[^/]+/);
46
+ if (!pkgMatch || !isComityPackage(pkgMatch[0]))
47
+ return;
48
+ // Subpath after the package name
49
+ const subpath = specifier.slice(pkgMatch[0].length).replace(/^\//, "");
50
+ const segment = subpath.split("/")[0];
51
+ if (segment && FORBIDDEN_SUBPATHS.has(segment)) {
52
+ context.report({
53
+ node,
54
+ messageId: "forbiddenSubpath",
55
+ data: { segment },
56
+ });
57
+ }
58
+ },
59
+ ImportDeclaration(node) {
60
+ const specifier = node.source.value;
61
+ if (!specifier || !specifier.startsWith("@comity/"))
62
+ return;
63
+ const pkgMatch = specifier.match(/^@comity\/[^/]+/);
64
+ if (!pkgMatch || !isComityPackage(pkgMatch[0]))
65
+ return;
66
+ const subpath = specifier.slice(pkgMatch[0].length).replace(/^\//, "");
67
+ const segment = subpath.split("/")[0];
68
+ if (segment && FORBIDDEN_SUBPATHS.has(segment)) {
69
+ context.report({
70
+ node,
71
+ messageId: "forbiddenSubpath",
72
+ data: { segment },
73
+ });
74
+ }
75
+ },
76
+ };
77
+ },
78
+ };
79
+ export default rule;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @comity-dev/no-layer-violation
3
+ *
4
+ * Forbid imports that violate Comity's canonical layering policy.
5
+ *
6
+ * Disallowed edges (default):
7
+ * - primitives → any @comity/*
8
+ * - kernel → anything except primitives
9
+ * - composition → anything except primitives, kernel
10
+ * - core → adapters
11
+ * - adapters → adapters (except integration-adapter → technology-adapter)
12
+ *
13
+ * Technology adapters MAY import the core contract they implement
14
+ * (declared via `implements` metadata).
15
+ *
16
+ * Allowed Core-to-Core edges are gated by ADR-008; this rule does NOT
17
+ * evaluate that register (it is enforced separately by the dependency
18
+ * rules / orchestrator). Use this rule only to catch the obvious violations.
19
+ *
20
+ * Standard references:
21
+ * - layering-policy.md §2 (canonical layering model)
22
+ * - architecture-validation.md §7
23
+ * - ADR-007 (adapter categories)
24
+ */
25
+ type Rule = unknown;
26
+ declare const rule: Rule;
27
+ export default rule;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @comity-dev/no-layer-violation
3
+ *
4
+ * Forbid imports that violate Comity's canonical layering policy.
5
+ *
6
+ * Disallowed edges (default):
7
+ * - primitives → any @comity/*
8
+ * - kernel → anything except primitives
9
+ * - composition → anything except primitives, kernel
10
+ * - core → adapters
11
+ * - adapters → adapters (except integration-adapter → technology-adapter)
12
+ *
13
+ * Technology adapters MAY import the core contract they implement
14
+ * (declared via `implements` metadata).
15
+ *
16
+ * Allowed Core-to-Core edges are gated by ADR-008; this rule does NOT
17
+ * evaluate that register (it is enforced separately by the dependency
18
+ * rules / orchestrator). Use this rule only to catch the obvious violations.
19
+ *
20
+ * Standard references:
21
+ * - layering-policy.md §2 (canonical layering model)
22
+ * - architecture-validation.md §7
23
+ * - ADR-007 (adapter categories)
24
+ */
25
+ const messages = {
26
+ forbidden: "This import violates Comity's layering policy (layering-policy.md §2).",
27
+ };
28
+ const LAYER_PROFILES = {
29
+ primitives: { allowed: new Set() },
30
+ kernel: { allowed: new Set(["primitives"]) },
31
+ composition: { allowed: new Set(["primitives", "kernel"]) },
32
+ core: { allowed: new Set(["primitives", "kernel", "core"]) },
33
+ "technology-adapter": {
34
+ allowed: new Set(["primitives", "kernel"]),
35
+ },
36
+ "integration-adapter": {
37
+ allowed: new Set(["primitives", "kernel", "core", "technology-adapter"]),
38
+ },
39
+ };
40
+ function classifyTarget(specifier) {
41
+ const m = specifier.match(/^(@comity\/[^/]+)/);
42
+ if (!m || !m[1])
43
+ return null;
44
+ return m[1];
45
+ }
46
+ function inferLayerFromFilename(filename) {
47
+ if (!filename)
48
+ return null;
49
+ if (filename.includes("/primitives/"))
50
+ return "primitives";
51
+ if (filename.includes("/kernel/"))
52
+ return "kernel";
53
+ if (filename.includes("/composition/"))
54
+ return "composition";
55
+ if (filename.includes("/packages/")) {
56
+ // Check for core packages
57
+ const corePackages = [
58
+ "http",
59
+ "router",
60
+ "html",
61
+ "hydration",
62
+ "seo",
63
+ "content",
64
+ "media",
65
+ "search",
66
+ "cache",
67
+ "auth",
68
+ "catalog",
69
+ "customer",
70
+ "inventory",
71
+ "order",
72
+ "payment",
73
+ "pricing",
74
+ "storefront",
75
+ "taxonomy",
76
+ "validation",
77
+ "address",
78
+ "geography",
79
+ "identity",
80
+ "organization",
81
+ "acl",
82
+ "auth-tokens",
83
+ "graphql-builder",
84
+ "graphql-client",
85
+ "i18n",
86
+ "sql",
87
+ "storage",
88
+ ];
89
+ for (const pkg of corePackages) {
90
+ if (filename.includes(`/packages/${pkg}/`))
91
+ return "core";
92
+ }
93
+ // Check for technology adapters
94
+ const adapterPackages = [
95
+ "html-preact",
96
+ "html-react",
97
+ "hydration-preact",
98
+ "hydration-react",
99
+ "http-hono",
100
+ "http-fetch",
101
+ "router-path-to-regexp",
102
+ "acl-casl",
103
+ "auth-jose",
104
+ "cache-kv",
105
+ "cache-redis",
106
+ "cli-commander",
107
+ "graphql-client-fetch",
108
+ "graphql-client-ws",
109
+ "i18n-typesafe",
110
+ "sql-kysely",
111
+ "validation-zod",
112
+ ];
113
+ for (const pkg of adapterPackages) {
114
+ if (filename.includes(`/packages/${pkg}/`))
115
+ return "technology-adapter";
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ const rule = {
121
+ meta: {
122
+ type: "problem",
123
+ docs: {
124
+ description: "Forbid imports that violate Comity's canonical layering policy.",
125
+ category: "Comity Architecture",
126
+ recommended: true,
127
+ },
128
+ schema: [
129
+ {
130
+ type: "object",
131
+ properties: {
132
+ sourceName: {
133
+ type: "string",
134
+ description: "Name of the source package (e.g., '@comity/pricing'). Required.",
135
+ },
136
+ classification: {
137
+ type: "object",
138
+ description: "Map of package name → layer. The plugin receives this via configuration; it MUST NOT hardcode layers.",
139
+ },
140
+ implements: {
141
+ type: "object",
142
+ description: "Map of package name → implemented core package (for technology-adapter packages).",
143
+ },
144
+ },
145
+ required: ["sourceName", "classification"],
146
+ additionalProperties: false,
147
+ },
148
+ ],
149
+ messages,
150
+ },
151
+ create(context) {
152
+ const opts = (context.options[0] ?? {});
153
+ const classification = opts.classification ?? {};
154
+ const implementsMap = opts.implements ?? {};
155
+ const sourceName = opts.sourceName ?? null;
156
+ // Fallback: infer source layer from filename if sourceName not provided
157
+ let sourceLayer = sourceName
158
+ ? classification[sourceName]
159
+ : inferLayerFromFilename(context.filename ?? context.getFilename());
160
+ if (!sourceLayer)
161
+ return {};
162
+ const profile = LAYER_PROFILES[sourceLayer];
163
+ if (!profile)
164
+ return {};
165
+ // If sourceName not provided, we can't check implementsMap, so skip that allowance
166
+ const effectiveSourceName = sourceName;
167
+ return {
168
+ ImportDeclaration(node) {
169
+ const specifier = node.source.value;
170
+ if (!specifier)
171
+ return;
172
+ const target = classifyTarget(specifier);
173
+ if (!target)
174
+ return;
175
+ // Look up the target package's layer
176
+ const targetLayer = classification[target];
177
+ if (!targetLayer)
178
+ return; // unknown package — outside this rule's scope
179
+ // Allow technology-adapter to import its implemented core contract
180
+ if (effectiveSourceName &&
181
+ sourceLayer === "technology-adapter" &&
182
+ targetLayer === "core" &&
183
+ implementsMap[effectiveSourceName] === target) {
184
+ return; // Allowed: adapter implements this core contract
185
+ }
186
+ if (!profile.allowed.has(targetLayer)) {
187
+ context.report({ node, messageId: "forbidden" });
188
+ }
189
+ },
190
+ };
191
+ },
192
+ };
193
+ export default rule;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @comity-dev/no-render-core-http-runtime
3
+ *
4
+ * Forbid Rendering Core Modules (html, hydration, search, seo, content)
5
+ * from importing HTTP runtime implementations (http-hono, http-fetch,
6
+ * html-preact, html-react, hydration-preact, hydration-react).
7
+ *
8
+ * The rule is scoped to only apply to files within the declared Rendering
9
+ * Core packages. Technology adapters and client entry points are permitted
10
+ * to import their respective runtimes.
11
+ *
12
+ * Standard references:
13
+ * - layering-policy.md §8 (rendering core MUST NOT depend on HTTP runtime)
14
+ * - architecture-validation.md §4.3
15
+ */
16
+ type Rule = unknown;
17
+ declare const rule: Rule;
18
+ export default rule;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @comity-dev/no-render-core-http-runtime
3
+ *
4
+ * Forbid Rendering Core Modules (html, hydration, search, seo, content)
5
+ * from importing HTTP runtime implementations (http-hono, http-fetch,
6
+ * html-preact, html-react, hydration-preact, hydration-react).
7
+ *
8
+ * The rule is scoped to only apply to files within the declared Rendering
9
+ * Core packages. Technology adapters and client entry points are permitted
10
+ * to import their respective runtimes.
11
+ *
12
+ * Standard references:
13
+ * - layering-policy.md §8 (rendering core MUST NOT depend on HTTP runtime)
14
+ * - architecture-validation.md §4.3
15
+ */
16
+ const RENDERING_CORE_PACKAGES = new Set([
17
+ "@comity/html",
18
+ "@comity/hydration",
19
+ "@comity/search",
20
+ "@comity/seo",
21
+ "@comity/content",
22
+ ]);
23
+ const FORBIDDEN_RUNTIME_PACKAGES = new Set([
24
+ "@comity/http-hono",
25
+ "@comity/http-fetch",
26
+ "@comity/html-preact",
27
+ "@comity/html-react",
28
+ "@comity/hydration-preact",
29
+ "@comity/hydration-react",
30
+ ]);
31
+ const messages = {
32
+ forbiddenRuntime: "Rendering Core Module MUST NOT import HTTP/Rendering runtime '{{target}}' (layering-policy.md §8).",
33
+ };
34
+ function isInRenderingCorePackage(filename) {
35
+ return Array.from(RENDERING_CORE_PACKAGES).some((pkg) => filename.includes(`/packages/${pkg.replace("@comity/", "")}/`));
36
+ }
37
+ const rule = {
38
+ meta: {
39
+ type: "problem",
40
+ docs: {
41
+ description: "Forbid Rendering Core Modules from importing HTTP / Rendering runtimes.",
42
+ category: "Comity Architecture",
43
+ recommended: true,
44
+ },
45
+ schema: [
46
+ {
47
+ type: "object",
48
+ properties: {
49
+ sourceName: {
50
+ type: "string",
51
+ description: "Name of the source package (e.g., '@comity/html'). Used to scope the rule.",
52
+ },
53
+ },
54
+ additionalProperties: false,
55
+ },
56
+ ],
57
+ messages,
58
+ },
59
+ create(context) {
60
+ const opts = (context.options[0] ?? {});
61
+ const sourceName = opts.sourceName ?? null;
62
+ // If no sourceName provided, fall back to filename heuristic
63
+ const inRenderingCore = sourceName
64
+ ? RENDERING_CORE_PACKAGES.has(sourceName)
65
+ : isInRenderingCorePackage(context.filename ?? context.getFilename());
66
+ // Disable this rule outside rendering core packages
67
+ if (!inRenderingCore) {
68
+ return {};
69
+ }
70
+ function checkImport(specifier, node) {
71
+ if (!specifier.startsWith("@comity/"))
72
+ return;
73
+ if (FORBIDDEN_RUNTIME_PACKAGES.has(specifier)) {
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ context.report({
76
+ node: node,
77
+ messageId: "forbiddenRuntime",
78
+ data: { target: specifier },
79
+ });
80
+ }
81
+ }
82
+ return {
83
+ ImportDeclaration(node) {
84
+ checkImport(node.source.value, node);
85
+ },
86
+ ImportExpression(node) {
87
+ if (node.source.type === "Literal" &&
88
+ typeof node.source.value === "string") {
89
+ checkImport(node.source.value, node);
90
+ }
91
+ },
92
+ ExportNamedDeclaration(node) {
93
+ if (node.source && node.source.value) {
94
+ checkImport(node.source.value, node);
95
+ }
96
+ },
97
+ };
98
+ },
99
+ };
100
+ export default rule;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @comity-dev/require-internal-marker
3
+ *
4
+ * Contract B: production TypeScript sources under `packages/<name>/src/internal/`
5
+ * or `packages/<name>/src/lazy/` MUST declare an `@internal` JSDoc tag.
6
+ *
7
+ * Both accepted marker forms are equivalent: the inline form (the tag on the
8
+ * same line as the JSDoc opener) and the conventional multiline form (the tag
9
+ * on its own line inside the block).
10
+ *
11
+ * The marker must be an actual JSDoc tag, so it must occupy a JSDoc tag
12
+ * position — immediately after the block opener, or at the start of a line
13
+ * inside the block after the optional leading asterisk. Consequences:
14
+ *
15
+ * - A prose mention of `@internal` inside a JSDoc description is not a tag.
16
+ * - `@internalOnly` and `@internal-extra` are different tag names.
17
+ * - Line comments, ordinary block comments, and marker-like text inside
18
+ * strings, template literals, tagged templates, or regular-expression
19
+ * literals never satisfy the rule, because the parser only produces
20
+ * comment tokens for real comments.
21
+ *
22
+ * The requirement is file-level: a file satisfies it when it contains a valid
23
+ * `@internal` JSDoc block followed by code, since the marker annotates the
24
+ * file's declarations. A marker attached to any declaration — top-level,
25
+ * class member, or nested — satisfies it, and a trailing marker with no
26
+ * following code does not. The repository's internal sources attach the
27
+ * docblock to the declaration it documents, which is the narrowest reading
28
+ * consistent with the approved contract.
29
+ *
30
+ * Scope: production TypeScript sources under the two internal directories
31
+ * only. Test files, mocks, fixtures, declaration/generated output, build and
32
+ * coverage output, and dependency directories are excluded, matching the
33
+ * repository's established ESLint conventions.
34
+ *
35
+ * Traceability: the marker policy is an owner-approved contract decision. It
36
+ * is NOT defined by public-api.md §3.2, which forbids public `/internal` and
37
+ * `/lazy` sub-entrypoints. This rule replaces the retired Semgrep rule
38
+ * `comity-module-must-have-internal-marker`, which could not inspect real
39
+ * JSDoc syntax (Semgrep strips comments from structural patterns).
40
+ */
41
+ type Rule = unknown;
42
+ declare const rule: Rule;
43
+ export default rule;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @comity-dev/require-internal-marker
3
+ *
4
+ * Contract B: production TypeScript sources under `packages/<name>/src/internal/`
5
+ * or `packages/<name>/src/lazy/` MUST declare an `@internal` JSDoc tag.
6
+ *
7
+ * Both accepted marker forms are equivalent: the inline form (the tag on the
8
+ * same line as the JSDoc opener) and the conventional multiline form (the tag
9
+ * on its own line inside the block).
10
+ *
11
+ * The marker must be an actual JSDoc tag, so it must occupy a JSDoc tag
12
+ * position — immediately after the block opener, or at the start of a line
13
+ * inside the block after the optional leading asterisk. Consequences:
14
+ *
15
+ * - A prose mention of `@internal` inside a JSDoc description is not a tag.
16
+ * - `@internalOnly` and `@internal-extra` are different tag names.
17
+ * - Line comments, ordinary block comments, and marker-like text inside
18
+ * strings, template literals, tagged templates, or regular-expression
19
+ * literals never satisfy the rule, because the parser only produces
20
+ * comment tokens for real comments.
21
+ *
22
+ * The requirement is file-level: a file satisfies it when it contains a valid
23
+ * `@internal` JSDoc block followed by code, since the marker annotates the
24
+ * file's declarations. A marker attached to any declaration — top-level,
25
+ * class member, or nested — satisfies it, and a trailing marker with no
26
+ * following code does not. The repository's internal sources attach the
27
+ * docblock to the declaration it documents, which is the narrowest reading
28
+ * consistent with the approved contract.
29
+ *
30
+ * Scope: production TypeScript sources under the two internal directories
31
+ * only. Test files, mocks, fixtures, declaration/generated output, build and
32
+ * coverage output, and dependency directories are excluded, matching the
33
+ * repository's established ESLint conventions.
34
+ *
35
+ * Traceability: the marker policy is an owner-approved contract decision. It
36
+ * is NOT defined by public-api.md §3.2, which forbids public `/internal` and
37
+ * `/lazy` sub-entrypoints. This rule replaces the retired Semgrep rule
38
+ * `comity-module-must-have-internal-marker`, which could not inspect real
39
+ * JSDoc syntax (Semgrep strips comments from structural patterns).
40
+ */
41
+ const messages = {
42
+ missing: "Production sources under `src/internal/` or `src/lazy/` MUST declare an `@internal` JSDoc tag (inline or multiline).",
43
+ };
44
+ /** File extensions treated as production TypeScript sources. */
45
+ const TYPESCRIPT_FILE = /\.(?:ts|tsx|mts|cts)$/;
46
+ /** Test files: excluded by the validator ESLint engine and the consumer config. */
47
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
48
+ /** Declaration output is generated by `tsc`; the consumer config ignores it. */
49
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
50
+ /** Generated sources are excluded by the consumer's ESLint ignore list. */
51
+ const GENERATED_FILE = /\.generated\./;
52
+ /**
53
+ * Path segments excluded by repository convention: dependency directories,
54
+ * build/coverage output, and test/mock/fixture trees (the consumer keeps real
55
+ * `src/__mocks__` directories, and the retired Semgrep rule excluded
56
+ * `__fixtures__`).
57
+ */
58
+ const EXCLUDED_SEGMENTS = new Set([
59
+ "node_modules",
60
+ "dist",
61
+ "build",
62
+ "out",
63
+ "coverage",
64
+ "__tests__",
65
+ "__fixtures__",
66
+ "__mocks__",
67
+ ]);
68
+ /** `packages/<name>/src/internal/` or `packages/<name>/src/lazy/` */
69
+ const SCOPED_PATH = /(?:^|\/)packages\/[^/]+\/src\/(?:internal|lazy)\//;
70
+ function normalizeFilename(filename) {
71
+ return filename.replace(/\\/g, "/");
72
+ }
73
+ function isScopedProductionFile(filename) {
74
+ if (!filename)
75
+ return false;
76
+ const normalized = normalizeFilename(filename);
77
+ if (!TYPESCRIPT_FILE.test(normalized))
78
+ return false;
79
+ if (TEST_FILE.test(normalized))
80
+ return false;
81
+ if (DECLARATION_FILE.test(normalized))
82
+ return false;
83
+ if (GENERATED_FILE.test(normalized))
84
+ return false;
85
+ if (normalized.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment))) {
86
+ return false;
87
+ }
88
+ return SCOPED_PATH.test(normalized);
89
+ }
90
+ /** A JSDoc tag must not be followed by more tag-name characters. */
91
+ const INTERNAL_TAG = /^[ \t]*@internal(?![A-Za-z0-9_-])/;
92
+ /**
93
+ * Whether a JSDoc block comment body declares the `@internal` tag.
94
+ *
95
+ * @param value - The comment value. A JSDoc block starts with `*`.
96
+ *
97
+ * @remarks
98
+ * The tag must begin a line inside the block (after the optional leading
99
+ * asterisk) or immediately follow the block opener, which is where JSDoc
100
+ * allows tags. Prose that merely mentions `@internal` is therefore not a tag.
101
+ */
102
+ function declaresInternalTag(value) {
103
+ const lines = value.split(/\r?\n/);
104
+ for (let index = 0; index < lines.length; index++) {
105
+ const raw = lines[index] ?? "";
106
+ const content = index === 0 ? raw.slice(1) : raw.replace(/^[ \t]*/, "");
107
+ if (index > 0 && content.startsWith("*")) {
108
+ if (INTERNAL_TAG.test(content.slice(1)))
109
+ return true;
110
+ continue;
111
+ }
112
+ if (INTERNAL_TAG.test(content))
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+ const rule = {
118
+ meta: {
119
+ type: "problem",
120
+ docs: {
121
+ description: "Require an `@internal` JSDoc marker on production sources under `src/internal/` and `src/lazy/`.",
122
+ category: "Comity Architecture",
123
+ recommended: true,
124
+ },
125
+ schema: [],
126
+ messages,
127
+ },
128
+ create(context) {
129
+ const filename = context.filename ?? context.getFilename();
130
+ if (!isScopedProductionFile(String(filename ?? "")))
131
+ return {};
132
+ return {
133
+ Program(node) {
134
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
135
+ const lastToken = sourceCode.getLastToken(node);
136
+ for (const comment of sourceCode.getAllComments()) {
137
+ if (comment.type !== "Block")
138
+ continue;
139
+ const value = String(comment.value ?? "");
140
+ // JSDoc block comments start with `*`; `/* ... */` does not.
141
+ if (!value.startsWith("*"))
142
+ continue;
143
+ if (!declaresInternalTag(value))
144
+ continue;
145
+ // The marker must annotate following code, not trail the file.
146
+ if (lastToken && lastToken.range[0] > comment.range[1])
147
+ return;
148
+ }
149
+ context.report({ node, messageId: "missing" });
150
+ },
151
+ };
152
+ },
153
+ };
154
+ export default rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comity-dev/eslint-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Reusable ESLint rules for Comity source-code policy. Development tooling.",
5
5
  "type": "module",
6
6
  "private": false,