@noctcore/lint-meta-rules 0.1.0 → 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
@@ -44,7 +44,8 @@ over the whole catalog.
44
44
 
45
45
  13 nightcore lint-meta rules are ported as 12 factories — nightcore's `web-file-size-ratchet` and
46
46
  `engine-file-size-ratchet` were byte-identical logic and collapse into a single
47
- `createFileSizeRatchetRule` (instantiated once per capped area).
47
+ `createFileSizeRatchetRule` (instantiated once per capped area). Five CI-hygiene rules (category
48
+ `ci`) are ported from a production NestJS + Vite monorepo, one factory each, for 17 factories in all.
48
49
 
49
50
  | Factory | Source rule(s) | Category | What it enforces |
50
51
  | --- | --- | --- | --- |
@@ -60,6 +61,23 @@ over the whole catalog.
60
61
  | [`createUiPrimitiveShapeRule`](./docs/rules/ui-primitive-shape.md) | `ui-primitive-shape` | source-text | A folder primitive ships its proof siblings; a flat primitive carries none at the ui root. |
61
62
  | [`createTestWorkspaceEnrollmentRule`](./docs/rules/test-workspace-enrollment.md) | `test-workspace-enrollment` | testing | Every tested package is enumerated in the aggregate test script. |
62
63
  | [`createTestRunnerSegregationRule`](./docs/rules/test-runner-segregation.md) | `test-runner-segregation` | testing | Bun-side and foreign-side test runners are never mixed within a package. |
64
+ | [`createGithubActionsShaPinnedRule`](./docs/rules/github-actions-sha-pinned.md) | `github-actions-sha-pinned` | ci | Workflow `uses:` refs are pinned to a full commit SHA with a `# vN` comment. |
65
+ | [`createGithubActionsRunnerPinnedRule`](./docs/rules/github-actions-runner-pinned.md) | `github-actions-runner-pinned` | ci | Workflow jobs run on a named runner image, never a `*-latest` label. |
66
+ | [`createServiceImageDigestPinRule`](./docs/rules/service-image-digest-pin.md) | `service-image-digest-pin` | ci | Workflow service/container images and compose images are pinned by `@sha256:` digest. |
67
+ | [`createDockerfileBaseImageDigestPinRule`](./docs/rules/dockerfile-base-image-digest-pin.md) | `dockerfile-base-image-digest-pin` | ci | Dockerfile `FROM` base images are pinned by `@sha256:` digest. |
68
+ | [`createSecurityScannerVersionParityRule`](./docs/rules/security-scanner-version-parity.md) | `security-scanner-version-parity` | ci | CI and the local pre-push hook pin the same secret-scanner version, and the hook checks it at run time. |
63
69
 
64
70
  Every factory is callable with no arguments (all options default), so `createAllRules()` and
65
71
  per-factory defaults work out of the box; supply options to retarget a rule at your own repo.
72
+
73
+ ### `@noctcore/lint-meta-rules/i18n`
74
+
75
+ Whole-program translation checks live on a separate entry point, because they run ESLint's parser
76
+ and scope analysis (reusing the i18n visitor behind `noctcore-contracts/translation-key-exists`).
77
+ The main entry never loads ESLint; this one needs the optional peers `eslint` and
78
+ `@typescript-eslint/parser`. Its factories are not part of `RULE_FACTORIES` / `createAllRules()`:
79
+ they are inert until you point them at your catalogs.
80
+
81
+ | Factory | Category | What it enforces |
82
+ | --- | --- | --- |
83
+ | [`createTranslationDeadKeysRule`](./docs/rules/translation-dead-keys.md) | source-text | Every catalog key is reachable: named by a translation call, or spelled by some string in the source. |
@@ -0,0 +1,63 @@
1
+ // src/rules/shared.ts
2
+ function dirOf(rel, basename = "package.json") {
3
+ return rel.replace(new RegExp(`/${escapeRegExp(basename)}$`), "");
4
+ }
5
+ function baseName(pathLike) {
6
+ const parts = pathLike.split("/").filter(Boolean);
7
+ return parts[parts.length - 1] ?? pathLike;
8
+ }
9
+ function escapeRegExp(value) {
10
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ }
12
+ function countLines(text) {
13
+ const lines = text.split("\n");
14
+ if (lines[lines.length - 1] === "") lines.pop();
15
+ return lines.length;
16
+ }
17
+ function recursiveGlobs(roots, extensions) {
18
+ return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
19
+ }
20
+ var DEFAULT_WORKFLOW_GLOBS = [
21
+ ".github/workflows/*.yml",
22
+ ".github/workflows/*.yaml"
23
+ ];
24
+ var DEFAULT_SKIP_DIRS = [
25
+ "node_modules",
26
+ ".git",
27
+ "dist",
28
+ ".turbo",
29
+ "coverage"
30
+ ];
31
+ function globFiles(glob, globs, skipDirs = []) {
32
+ const skip = new Set(skipDirs);
33
+ const found = /* @__PURE__ */ new Set();
34
+ for (const pattern of globs) {
35
+ for (const rel of glob(pattern)) {
36
+ if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
37
+ }
38
+ }
39
+ return [...found].sort();
40
+ }
41
+ function stripYamlComment(line) {
42
+ return line.replace(/(^|\s)#.*$/u, "");
43
+ }
44
+ function unquote(value) {
45
+ return value.trim().replace(/^(['"])(.*)\1$/u, "$2");
46
+ }
47
+ function anywhereGlobs(baseNames) {
48
+ return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
49
+ }
50
+
51
+ export {
52
+ dirOf,
53
+ baseName,
54
+ escapeRegExp,
55
+ countLines,
56
+ recursiveGlobs,
57
+ DEFAULT_WORKFLOW_GLOBS,
58
+ DEFAULT_SKIP_DIRS,
59
+ globFiles,
60
+ stripYamlComment,
61
+ unquote,
62
+ anywhereGlobs
63
+ };
package/dist/i18n.cjs ADDED
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/i18n.ts
31
+ var i18n_exports = {};
32
+ __export(i18n_exports, {
33
+ createTranslationDeadKeysRule: () => createTranslationDeadKeysRule,
34
+ deriveNamespaces: () => deriveNamespaces
35
+ });
36
+ module.exports = __toCommonJS(i18n_exports);
37
+
38
+ // src/i18n/translation-dead-keys.ts
39
+ var import_node_path = __toESM(require("path"), 1);
40
+ var import_eslint_plugin_contracts = require("@noctcore/eslint-plugin-contracts");
41
+ var tsParser = __toESM(require("@typescript-eslint/parser"), 1);
42
+ var import_utils = require("@typescript-eslint/utils");
43
+ var import_eslint = require("eslint");
44
+
45
+ // src/rules/shared.ts
46
+ function escapeRegExp(value) {
47
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+ }
49
+ var DEFAULT_SKIP_DIRS = [
50
+ "node_modules",
51
+ ".git",
52
+ "dist",
53
+ ".turbo",
54
+ "coverage"
55
+ ];
56
+ function globFiles(glob, globs, skipDirs = []) {
57
+ const skip = new Set(skipDirs);
58
+ const found = /* @__PURE__ */ new Set();
59
+ for (const pattern of globs) {
60
+ for (const rel of glob(pattern)) {
61
+ if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
62
+ }
63
+ }
64
+ return [...found].sort();
65
+ }
66
+
67
+ // src/i18n/translation-dead-keys.ts
68
+ var DEFAULT_ID = "translation-dead-keys";
69
+ var NS_PLACEHOLDER = "{ns}";
70
+ var SOURCE_FILES = ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
71
+ function namespacesFromFileTemplate(ctx, file) {
72
+ const segments = file.split("/");
73
+ if (segments.filter((segment) => segment.includes(NS_PLACEHOLDER)).length !== 1) return [];
74
+ const pattern = file.replaceAll(NS_PLACEHOLDER, "*");
75
+ const capture = new RegExp(
76
+ `^${file.split(NS_PLACEHOLDER).map((part) => escapeRegExp(part).replaceAll("\\*", "[^/]*")).join("([^/]+)")}$`,
77
+ "u"
78
+ );
79
+ const found = [];
80
+ for (const rel of ctx.glob(pattern)) {
81
+ const namespace = capture.exec(rel)?.[1];
82
+ if (namespace !== void 0) found.push(namespace);
83
+ }
84
+ return found;
85
+ }
86
+ function namespacesFromKeyPath(ctx, file, keyPath) {
87
+ const segments = keyPath.split(".");
88
+ if (segments.indexOf(NS_PLACEHOLDER) !== segments.length - 1) return null;
89
+ const text = ctx.read(file);
90
+ if (text === null) return [];
91
+ let node = JSON.parse(text);
92
+ for (const segment of segments.slice(0, -1)) {
93
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, segment)) return [];
94
+ node = node[segment];
95
+ }
96
+ return node !== null && typeof node === "object" ? Object.keys(node) : [];
97
+ }
98
+ function deriveNamespaces(ctx, catalogs, defaultNamespace) {
99
+ const found = /* @__PURE__ */ new Set();
100
+ for (const source of catalogs) {
101
+ const fileTemplated = source.file.includes(NS_PLACEHOLDER);
102
+ const keyPathTemplated = source.keyPath?.includes(NS_PLACEHOLDER) ?? false;
103
+ if (!fileTemplated && !keyPathTemplated) {
104
+ found.add(source.namespace ?? defaultNamespace);
105
+ continue;
106
+ }
107
+ const derived = fileTemplated ? namespacesFromFileTemplate(ctx, source.file) : namespacesFromKeyPath(ctx, source.file, source.keyPath ?? "");
108
+ if (derived === null || fileTemplated && keyPathTemplated) {
109
+ throw new Error(
110
+ `cannot enumerate the namespaces of catalog ${JSON.stringify(source)}: list them in \`namespaces\``
111
+ );
112
+ }
113
+ for (const namespace of derived) found.add(namespace);
114
+ }
115
+ return [...found].sort();
116
+ }
117
+ function holePattern(fragments) {
118
+ if (fragments.join("").replace(/[.:_\-/\s]/gu, "").length < 2) return null;
119
+ return new RegExp(`^${fragments.map(escapeRegExp).join(".*")}$`, "u");
120
+ }
121
+ function textFragments(root) {
122
+ const fragments = [""];
123
+ let sawText = false;
124
+ const hole = () => {
125
+ if (fragments.length === 1 || fragments[fragments.length - 1] !== "") fragments.push("");
126
+ };
127
+ const text = (value) => {
128
+ sawText = true;
129
+ fragments[fragments.length - 1] += value;
130
+ };
131
+ const visit = (node) => {
132
+ if (node.type === import_utils.AST_NODE_TYPES.BinaryExpression && node.operator === "+") {
133
+ visit(node.left);
134
+ visit(node.right);
135
+ } else if (node.type === import_utils.AST_NODE_TYPES.Literal && typeof node.value === "string") {
136
+ text(node.value);
137
+ } else if (node.type === import_utils.AST_NODE_TYPES.TemplateLiteral) {
138
+ node.quasis.forEach((quasi, index) => {
139
+ if (index > 0) hole();
140
+ text(quasi.value.cooked ?? "");
141
+ });
142
+ } else {
143
+ hole();
144
+ }
145
+ };
146
+ visit(root);
147
+ return sawText ? fragments : null;
148
+ }
149
+ function collectReach(ctx, files, options, reach) {
150
+ const settings = (0, import_eslint_plugin_contracts.translationSettingsOf)(options);
151
+ const fallbackNamespaces = options.fallbackNamespaces ?? [];
152
+ const keySeparator = settings.keySeparator;
153
+ const addPrefix = (namespace, prefix) => {
154
+ reach.prefixes.set(namespace, [...reach.prefixes.get(namespace) ?? [], prefix]);
155
+ };
156
+ const onUsage = (usage) => {
157
+ if (usage.kind === "key") {
158
+ for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) {
159
+ for (const key of usage.keys) {
160
+ reach.exact.add(`${namespace}\0${key}`);
161
+ if (usage.returnObjects && keySeparator !== false) addPrefix(namespace, `${key}${keySeparator}`);
162
+ }
163
+ }
164
+ } else if (usage.kind === "prefix") {
165
+ for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) addPrefix(namespace, usage.prefix);
166
+ }
167
+ };
168
+ const addText = (node) => {
169
+ const fragments = textFragments(node);
170
+ if (fragments === null) return;
171
+ if (fragments.length === 1) {
172
+ if (fragments[0] !== "") reach.literals.add(fragments[0] ?? "");
173
+ return;
174
+ }
175
+ const pattern = holePattern(fragments);
176
+ if (pattern !== null) reach.patterns.push(pattern);
177
+ };
178
+ const collector = {
179
+ defaultOptions: [],
180
+ meta: { type: "problem", schema: [], messages: { never: "never reported" } },
181
+ create(context) {
182
+ return {
183
+ ...(0, import_eslint_plugin_contracts.createTranslationVisitor)(context, settings, onUsage),
184
+ Literal(node) {
185
+ if (typeof node.value === "string" && node.value !== "") reach.literals.add(node.value);
186
+ },
187
+ TemplateLiteral(node) {
188
+ if (!isConcatOperand(node)) addText(node);
189
+ },
190
+ BinaryExpression(node) {
191
+ if (node.operator === "+" && !isConcatOperand(node)) addText(node);
192
+ }
193
+ };
194
+ }
195
+ };
196
+ const linter = new import_eslint.Linter({ cwd: ctx.root });
197
+ const config = [
198
+ {
199
+ // Not `**/*`: flat config treats a match-everything pattern as universal
200
+ // and applies it to no file on its own.
201
+ files: SOURCE_FILES,
202
+ languageOptions: {
203
+ parser: tsParser,
204
+ parserOptions: { ecmaFeatures: { jsx: true } }
205
+ },
206
+ linterOptions: { reportUnusedDisableDirectives: "off" },
207
+ plugins: { deadKeys: { rules: { collect: collector } } },
208
+ rules: { "deadKeys/collect": "error" }
209
+ }
210
+ ];
211
+ const unparsed = [];
212
+ for (const file of files) {
213
+ const text = ctx.read(file);
214
+ if (text === null) continue;
215
+ const messages = linter.verify(text, config, { filename: import_node_path.default.join(ctx.root, file) });
216
+ if (messages.some((message) => message.ruleId === null)) unparsed.push(file);
217
+ }
218
+ return unparsed;
219
+ }
220
+ function isConcatOperand(node) {
221
+ const parent = node.parent;
222
+ return parent?.type === import_utils.AST_NODE_TYPES.BinaryExpression && parent.operator === "+";
223
+ }
224
+ function allowPatterns(allow) {
225
+ return allow.map((pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u"));
226
+ }
227
+ function lookupForms(key, keySeparator) {
228
+ const forms = [key];
229
+ const lastSegmentStart = keySeparator === false ? 0 : key.lastIndexOf(keySeparator) + 1;
230
+ let base = key;
231
+ while (base.lastIndexOf("_") > lastSegmentStart) {
232
+ base = base.slice(0, base.lastIndexOf("_"));
233
+ forms.push(base);
234
+ }
235
+ return forms;
236
+ }
237
+ function isReached(namespace, key, reach, nsSeparator, keySeparator) {
238
+ for (const prefix of reach.prefixes.get(namespace) ?? []) {
239
+ if (key.startsWith(prefix)) return true;
240
+ }
241
+ const texts = [];
242
+ for (const form of lookupForms(key, keySeparator)) {
243
+ if (reach.exact.has(`${namespace}\0${form}`)) return true;
244
+ texts.push(form);
245
+ if (nsSeparator !== false) texts.push(`${namespace}${nsSeparator}${form}`);
246
+ }
247
+ if (texts.some((text) => reach.literals.has(text))) return true;
248
+ return reach.patterns.some((pattern) => texts.some((text) => pattern.test(text)));
249
+ }
250
+ function createTranslationDeadKeysRule(options = {}) {
251
+ const id = options.id ?? DEFAULT_ID;
252
+ const catalogs = options.catalogs ?? [];
253
+ const sourceGlobs = options.sourceGlobs ?? [];
254
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
255
+ const allow = allowPatterns(options.allow ?? []);
256
+ const ciCritical = options.ciCritical ?? true;
257
+ const defaultNamespace = options.defaultNamespace ?? import_eslint_plugin_contracts.TRANSLATION_DEFAULTS.defaultNamespace;
258
+ const nsSeparator = options.nsSeparator ?? import_eslint_plugin_contracts.TRANSLATION_DEFAULTS.nsSeparator;
259
+ const keySeparator = options.keySeparator ?? import_eslint_plugin_contracts.TRANSLATION_DEFAULTS.keySeparator;
260
+ return {
261
+ id,
262
+ category: "source-text",
263
+ ciCritical,
264
+ description: "Every translation catalog key must be reachable from the source: named by a translation call, or spelled by some string in the code.",
265
+ run(ctx) {
266
+ if (catalogs.length === 0 || sourceGlobs.length === 0) return [];
267
+ const violations = [];
268
+ const report = (file, message) => {
269
+ violations.push({ file, rule: id, message });
270
+ };
271
+ const namespaces = options.namespaces ?? deriveNamespaces(ctx, catalogs, defaultNamespace);
272
+ const keyed = [];
273
+ for (const namespace of namespaces) {
274
+ const loaded = (0, import_eslint_plugin_contracts.catalogsForNamespace)(namespace, catalogs, {
275
+ cwd: ctx.root,
276
+ defaultNamespace,
277
+ keySeparator
278
+ });
279
+ for (const reason of loaded.errors) report(reason.split(":")[0] ?? reason, `Catalog could not be loaded: ${reason}.`);
280
+ keyed.push({ namespace, catalogs: loaded.catalogs });
281
+ }
282
+ if (violations.length > 0) return violations;
283
+ const reach = { exact: /* @__PURE__ */ new Set(), prefixes: /* @__PURE__ */ new Map(), literals: /* @__PURE__ */ new Set(), patterns: [] };
284
+ const files = globFiles((pattern) => ctx.glob(pattern), sourceGlobs, skipDirs);
285
+ if (files.length === 0) {
286
+ report(sourceGlobs.join(", "), "No source file matches `sourceGlobs`, so every key would look dead. Fix the globs.");
287
+ return violations;
288
+ }
289
+ const unparsed = collectReach(ctx, files, options, reach);
290
+ if (unparsed.length > 0) {
291
+ for (const file of unparsed) {
292
+ report(file, "Could not analyse this file (a parse error, or not a JS/TS file), so the keys it reaches are unknown. Dead-key analysis is skipped until every source file is analysable.");
293
+ }
294
+ return violations;
295
+ }
296
+ for (const { namespace, catalogs: sources } of keyed) {
297
+ for (const catalog of sources) {
298
+ for (const key of [...catalog.leaves].sort()) {
299
+ if (isReached(namespace, key, reach, nsSeparator, keySeparator)) continue;
300
+ const qualified = `${namespace}:${key}`;
301
+ if (allow.some((pattern) => pattern.test(qualified))) continue;
302
+ report(
303
+ catalog.label.split("#")[0] ?? catalog.label,
304
+ `Translation key \`${qualified}\` (${catalog.label}) is named by no translation call and spelled by no string in the source. Delete it, or add it to \`allow\` if it is reached from outside the scanned files.`
305
+ );
306
+ }
307
+ }
308
+ }
309
+ return violations;
310
+ }
311
+ };
312
+ }
313
+ // Annotate the CommonJS export names for ESM import in node:
314
+ 0 && (module.exports = {
315
+ createTranslationDeadKeysRule,
316
+ deriveNamespaces
317
+ });
@@ -0,0 +1,60 @@
1
+ import { IMetaRule, IMetaCtx } from '@noctcore/harness';
2
+ import { TranslationKeyExistsOptions, CatalogSource } from '@noctcore/eslint-plugin-contracts';
3
+
4
+ /**
5
+ * Options for {@link createTranslationDeadKeysRule}.
6
+ *
7
+ * Every resolution option (`defaultNamespace`, `hooks`, `namespaceIdentifiers`,
8
+ * separators, ...) is the SAME option `noctcore-contracts/translation-key-exists`
9
+ * takes, and is resolved by the same code, so the two checks cannot disagree on
10
+ * which key a call site means. Pass them the same values.
11
+ */
12
+ interface TranslationDeadKeysOptions extends Omit<TranslationKeyExistsOptions, 'catalogs' | 'dynamicKeys'> {
13
+ /** Rule id, for running more than one instance. Default `translation-dead-keys`. */
14
+ readonly id?: string;
15
+ /**
16
+ * The catalogs whose keys must be reachable, in `translation-key-exists`'
17
+ * `CatalogSource` shape. List ONE language (the reference one): a key is dead
18
+ * or alive regardless of how many languages translate it. Empty = inert.
19
+ */
20
+ readonly catalogs?: readonly CatalogSource[];
21
+ /**
22
+ * The namespaces to check. Default: derived from `catalogs`, where a `{ns}`
23
+ * file segment is globbed and a trailing `{ns}` keyPath segment lists the
24
+ * object's keys. Required when a `{ns}` placeholder sits anywhere else.
25
+ */
26
+ readonly namespaces?: readonly string[];
27
+ /** Globs of every file that can reach a key (call sites AND key tables). Empty = inert. */
28
+ readonly sourceGlobs?: readonly string[];
29
+ /** Source paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
30
+ readonly skipDirs?: readonly string[];
31
+ /**
32
+ * `ns:key` patterns known to be reached from outside the scanned source
33
+ * (server-sent codes, a CMS, another app). `*` matches any run of characters.
34
+ */
35
+ readonly allow?: readonly string[];
36
+ /** Whether a dead key fails CI. Default `true`. */
37
+ readonly ciCritical?: boolean;
38
+ }
39
+ /** The namespaces `catalogs` supply, or throws when a placeholder cannot be enumerated. */
40
+ declare function deriveNamespaces(ctx: IMetaCtx, catalogs: readonly CatalogSource[], defaultNamespace: string): string[];
41
+ /**
42
+ * Catalog keys nothing in the source can reach. A whole-program check, so it
43
+ * lives here and not in ESLint: a per-file rule never sees every call site, and
44
+ * keys flow as data (key tables, helper functions, server codes), which a
45
+ * call-site-only analysis cannot follow.
46
+ *
47
+ * CONSERVATIVE BY DESIGN. A key counts as reached when ANY of these holds:
48
+ * - a translation call resolves to it (same visitor as `translation-key-exists`),
49
+ * - a template key's static head is a prefix of it (`` t(`status.${s}`) ``),
50
+ * - any string literal in the source equals it, `ns:key`, an ancestor of it,
51
+ * or its plural/context base (`key` for `key_one`),
52
+ * - any template literal or `+` chain in the source can produce it,
53
+ * - it matches an `allow` pattern.
54
+ * And it reports NOTHING when a source file cannot be parsed. What is left was
55
+ * named by no call and spelled by no string: dead, or reached from outside the
56
+ * scanned source (see the rule doc's blind spots).
57
+ */
58
+ declare function createTranslationDeadKeysRule(options?: TranslationDeadKeysOptions): IMetaRule;
59
+
60
+ export { type TranslationDeadKeysOptions, createTranslationDeadKeysRule, deriveNamespaces };
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { IMetaRule, IMetaCtx } from '@noctcore/harness';
2
+ import { TranslationKeyExistsOptions, CatalogSource } from '@noctcore/eslint-plugin-contracts';
3
+
4
+ /**
5
+ * Options for {@link createTranslationDeadKeysRule}.
6
+ *
7
+ * Every resolution option (`defaultNamespace`, `hooks`, `namespaceIdentifiers`,
8
+ * separators, ...) is the SAME option `noctcore-contracts/translation-key-exists`
9
+ * takes, and is resolved by the same code, so the two checks cannot disagree on
10
+ * which key a call site means. Pass them the same values.
11
+ */
12
+ interface TranslationDeadKeysOptions extends Omit<TranslationKeyExistsOptions, 'catalogs' | 'dynamicKeys'> {
13
+ /** Rule id, for running more than one instance. Default `translation-dead-keys`. */
14
+ readonly id?: string;
15
+ /**
16
+ * The catalogs whose keys must be reachable, in `translation-key-exists`'
17
+ * `CatalogSource` shape. List ONE language (the reference one): a key is dead
18
+ * or alive regardless of how many languages translate it. Empty = inert.
19
+ */
20
+ readonly catalogs?: readonly CatalogSource[];
21
+ /**
22
+ * The namespaces to check. Default: derived from `catalogs`, where a `{ns}`
23
+ * file segment is globbed and a trailing `{ns}` keyPath segment lists the
24
+ * object's keys. Required when a `{ns}` placeholder sits anywhere else.
25
+ */
26
+ readonly namespaces?: readonly string[];
27
+ /** Globs of every file that can reach a key (call sites AND key tables). Empty = inert. */
28
+ readonly sourceGlobs?: readonly string[];
29
+ /** Source paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
30
+ readonly skipDirs?: readonly string[];
31
+ /**
32
+ * `ns:key` patterns known to be reached from outside the scanned source
33
+ * (server-sent codes, a CMS, another app). `*` matches any run of characters.
34
+ */
35
+ readonly allow?: readonly string[];
36
+ /** Whether a dead key fails CI. Default `true`. */
37
+ readonly ciCritical?: boolean;
38
+ }
39
+ /** The namespaces `catalogs` supply, or throws when a placeholder cannot be enumerated. */
40
+ declare function deriveNamespaces(ctx: IMetaCtx, catalogs: readonly CatalogSource[], defaultNamespace: string): string[];
41
+ /**
42
+ * Catalog keys nothing in the source can reach. A whole-program check, so it
43
+ * lives here and not in ESLint: a per-file rule never sees every call site, and
44
+ * keys flow as data (key tables, helper functions, server codes), which a
45
+ * call-site-only analysis cannot follow.
46
+ *
47
+ * CONSERVATIVE BY DESIGN. A key counts as reached when ANY of these holds:
48
+ * - a translation call resolves to it (same visitor as `translation-key-exists`),
49
+ * - a template key's static head is a prefix of it (`` t(`status.${s}`) ``),
50
+ * - any string literal in the source equals it, `ns:key`, an ancestor of it,
51
+ * or its plural/context base (`key` for `key_one`),
52
+ * - any template literal or `+` chain in the source can produce it,
53
+ * - it matches an `allow` pattern.
54
+ * And it reports NOTHING when a source file cannot be parsed. What is left was
55
+ * named by no call and spelled by no string: dead, or reached from outside the
56
+ * scanned source (see the rule doc's blind spots).
57
+ */
58
+ declare function createTranslationDeadKeysRule(options?: TranslationDeadKeysOptions): IMetaRule;
59
+
60
+ export { type TranslationDeadKeysOptions, createTranslationDeadKeysRule, deriveNamespaces };