@noctcore/lint-meta-rules 0.5.0 → 0.6.1

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.
Files changed (42) hide show
  1. package/README.md +94 -60
  2. package/dist/{chunk-Z7TXSZR4.js → chunk-OYFQKSJN.js} +9 -1
  3. package/dist/i18n.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/prisma.js +3 -3
  6. package/dist/session.cjs +297 -0
  7. package/dist/session.d.cts +226 -0
  8. package/dist/session.d.ts +226 -0
  9. package/dist/session.js +244 -0
  10. package/dist/trpc.cjs +122 -0
  11. package/dist/trpc.d.cts +59 -0
  12. package/dist/trpc.d.ts +59 -0
  13. package/dist/trpc.js +73 -0
  14. package/docs/rules/agents-doc-presence.md +13 -4
  15. package/docs/rules/canonical-helpers-single-home.md +13 -3
  16. package/docs/rules/dockerfile-base-image-digest-pin.md +17 -4
  17. package/docs/rules/eslint-config-no-warn.md +22 -7
  18. package/docs/rules/file-size-ratchet.md +19 -9
  19. package/docs/rules/github-actions-least-privilege-permissions.md +20 -11
  20. package/docs/rules/github-actions-no-template-injection.md +21 -12
  21. package/docs/rules/github-actions-runner-pinned.md +19 -6
  22. package/docs/rules/github-actions-sha-pinned.md +19 -5
  23. package/docs/rules/idempotency-key-parity.md +91 -0
  24. package/docs/rules/layer-rank.md +17 -6
  25. package/docs/rules/no-cloned-component-folders.md +13 -4
  26. package/docs/rules/no-warn-severity.md +14 -3
  27. package/docs/rules/package-shape.md +13 -6
  28. package/docs/rules/prisma-method-surface.md +19 -4
  29. package/docs/rules/security-scanner-version-parity.md +16 -7
  30. package/docs/rules/service-image-digest-pin.md +21 -7
  31. package/docs/rules/session-epoch-captured.md +89 -0
  32. package/docs/rules/session-kind-stamped.md +89 -0
  33. package/docs/rules/session-landing-declared.md +95 -0
  34. package/docs/rules/session-mint-callers.md +85 -0
  35. package/docs/rules/tenant-model-registry-parity.md +18 -4
  36. package/docs/rules/test-runner-segregation.md +15 -5
  37. package/docs/rules/test-sibling-enforcement.md +12 -5
  38. package/docs/rules/test-workspace-enrollment.md +14 -6
  39. package/docs/rules/translation-dead-keys.md +33 -20
  40. package/docs/rules/ui-primitive-shape.md +13 -4
  41. package/docs/rules/workspace-graph-parity.md +15 -5
  42. package/package.json +18 -8
@@ -0,0 +1,244 @@
1
+ import {
2
+ DEFAULT_SKIP_DIRS,
3
+ escapeRegExp,
4
+ globFiles,
5
+ readSourceText
6
+ } from "./chunk-OYFQKSJN.js";
7
+
8
+ // src/session/scan.ts
9
+ var DEFAULT_EXCLUDE_SUFFIXES = [
10
+ ".spec.ts",
11
+ ".spec.tsx",
12
+ ".test.ts",
13
+ ".test.tsx"
14
+ ];
15
+ function scopedSources(ctx, options) {
16
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
17
+ const excludeSuffixes = options.excludeSuffixes ?? DEFAULT_EXCLUDE_SUFFIXES;
18
+ const matched = globFiles(ctx.glob, options.sourceGlobs ?? [], skipDirs).map(toPosix);
19
+ const sources = [];
20
+ for (const file of matched) {
21
+ if (excludeSuffixes.some((suffix) => file.endsWith(suffix))) continue;
22
+ const text = readSourceText(ctx.read, file);
23
+ if (text !== null) sources.push({ file, text });
24
+ }
25
+ return { matched: new Set(matched), sources };
26
+ }
27
+ function toPosix(file) {
28
+ return file.replace(/\\/gu, "/").replace(/^\.\//u, "");
29
+ }
30
+ function pathSet(files) {
31
+ return new Set((files ?? []).map(toPosix));
32
+ }
33
+ function callsMethod(source, method) {
34
+ return new RegExp(`\\.${escapeRegExp(method)}\\s*\\(`, "u").test(source);
35
+ }
36
+ function callArguments(source, method) {
37
+ const needle = `.${method}`;
38
+ const calls = [];
39
+ let from = 0;
40
+ for (; ; ) {
41
+ const hit = source.indexOf(needle, from);
42
+ if (hit === -1) break;
43
+ from = hit + needle.length;
44
+ const open = source.indexOf("(", from);
45
+ if (open === -1) break;
46
+ if (source.slice(from, open).trim() !== "") continue;
47
+ let depth = 0;
48
+ let end = open;
49
+ for (; end < source.length; end += 1) {
50
+ const char = source[end];
51
+ if (char === "(") depth += 1;
52
+ else if (char === ")") {
53
+ depth -= 1;
54
+ if (depth === 0) break;
55
+ }
56
+ }
57
+ calls.push(source.slice(open + 1, end));
58
+ from = end;
59
+ }
60
+ return calls;
61
+ }
62
+ function mentionsIdentifier(text, identifier) {
63
+ return new RegExp(`\\b${escapeRegExp(identifier)}\\b`, "u").test(text);
64
+ }
65
+ function withHint(message, hint) {
66
+ return hint === void 0 || hint === "" ? message : `${message} ${hint}`;
67
+ }
68
+
69
+ // src/session/session-epoch-captured.ts
70
+ var DEFAULT_ID = "session-epoch-captured";
71
+ var DEFAULT_FIELD = "epoch";
72
+ function createSessionEpochCapturedRule(options = {}) {
73
+ const id = options.id ?? DEFAULT_ID;
74
+ const call = options.call ?? "";
75
+ const field = options.field ?? DEFAULT_FIELD;
76
+ const exempt = pathSet(options.exempt);
77
+ return {
78
+ id,
79
+ category: "source-text",
80
+ ciCritical: options.ciCritical ?? true,
81
+ description: "Every call into the sign-in seam must pass the session epoch captured before the credential was read, or a revocation landing during the credential check loses the race.",
82
+ run(ctx) {
83
+ if (call === "") return [];
84
+ const violations = [];
85
+ for (const { file, text } of scopedSources(ctx, options).sources) {
86
+ if (exempt.has(file)) continue;
87
+ for (const args of callArguments(text, call)) {
88
+ if (mentionsIdentifier(args, field)) continue;
89
+ violations.push({
90
+ file,
91
+ rule: id,
92
+ message: withHint(
93
+ `${call}() must be passed \`${field}\`, captured${options.captureCall === void 0 ? "" : ` with ${options.captureCall}`} BEFORE this flow reads the credential it authenticates on. Without it the epoch fence starts at the session write, so a revocation landing during the credential check loses the race.`,
94
+ options.hint
95
+ )
96
+ });
97
+ }
98
+ }
99
+ return violations;
100
+ }
101
+ };
102
+ }
103
+
104
+ // src/session/session-kind-stamped.ts
105
+ var DEFAULT_ID2 = "session-kind-stamped";
106
+ var DEFAULT_FIELD2 = "kind";
107
+ function createSessionKindStampedRule(options = {}) {
108
+ const id = options.id ?? DEFAULT_ID2;
109
+ const mintCall = options.mintCall ?? "";
110
+ const field = options.field ?? DEFAULT_FIELD2;
111
+ const allowUnstamped = pathSet(options.allowUnstamped);
112
+ return {
113
+ id,
114
+ category: "source-text",
115
+ ciCritical: options.ciCritical ?? true,
116
+ description: "Every call that mints a session must stamp the principal kind onto it, or sit in an allowlisted, provably single-kind flow; a session read without the kind falls back to a default and can silently promote one kind of account into another.",
117
+ run(ctx) {
118
+ if (mintCall === "") return [];
119
+ const violations = [];
120
+ for (const { file, text } of scopedSources(ctx, options).sources) {
121
+ if (allowUnstamped.has(file)) continue;
122
+ const calls = callArguments(text, mintCall);
123
+ if (calls.length === 0) continue;
124
+ const fileStamps = mentionsIdentifier(text, field);
125
+ for (const args of calls) {
126
+ const stamped = args.includes("{") ? mentionsIdentifier(args, field) : fileStamps;
127
+ if (stamped) continue;
128
+ violations.push({
129
+ file,
130
+ rule: id,
131
+ message: withHint(
132
+ `${mintCall}() must stamp \`${field}\` onto the session it mints${options.stampExample === void 0 ? "" : `: add \`${options.stampExample}\` to the options`}. A session read without \`${field}\` falls back to the reader's default, so an unstamped mint can silently promote one kind of account into another. If this flow provably can never mint for an account that needs the field, add the file to \`allowUnstamped\` with the proof.`,
133
+ options.hint
134
+ )
135
+ });
136
+ }
137
+ }
138
+ return violations;
139
+ }
140
+ };
141
+ }
142
+
143
+ // src/session/session-landing-declared.ts
144
+ var DEFAULT_ID3 = "session-landing-declared";
145
+ function createSessionLandingDeclaredRule(options = {}) {
146
+ const id = options.id ?? DEFAULT_ID3;
147
+ const doorCalls = (options.doorCalls ?? []).filter((name) => name !== "");
148
+ const doors = (options.doors ?? []).map((door) => ({ ...door, file: toPosix(door.file) }));
149
+ const landings = options.landings ?? {};
150
+ const landingNames = Object.keys(landings);
151
+ const callList = doorCalls.join(" or ");
152
+ return {
153
+ id,
154
+ category: "source-text",
155
+ ciCritical: options.ciCritical ?? true,
156
+ description: "Every file that opens a door into a session must declare where it leaves the caller, and a door whose landing demands a return shape (the one that carries the principal kind to the client) must have it.",
157
+ run(ctx) {
158
+ if (doorCalls.length === 0) return [];
159
+ const violations = [];
160
+ const report = (file, message) => {
161
+ violations.push({ file, rule: id, message: withHint(message, options.hint) });
162
+ };
163
+ for (const door of doors) {
164
+ if (!Object.hasOwn(landings, door.landing)) {
165
+ report(
166
+ door.file,
167
+ `\`doors\` declares this file with landing \`${door.landing}\`, which is not one of the configured landings (${landingNames.join(", ") || "none"}).`
168
+ );
169
+ }
170
+ if (door.because.trim() === "") {
171
+ report(door.file, "`doors` declares this file with an empty `because`. Write down why its landing is the right one.");
172
+ }
173
+ }
174
+ const { matched, sources } = scopedSources(ctx, options);
175
+ for (const { file, text } of sources) {
176
+ if (!doorCalls.some((name) => callsMethod(text, name))) continue;
177
+ const declared = doors.find((door) => door.file === file);
178
+ if (declared === void 0) {
179
+ report(
180
+ file,
181
+ `This file opens a door into a session (${callList}) and does not declare where it leaves the caller. Add it to \`doors\` with one of the landings (${landingNames.join(", ") || "none"}) and a \`because\`: can this door end a sign-in for an account the client must route by kind? Then declare the landing that returns the kind. Does the caller already hold a session? Or can such an account provably never reach it? Say so in \`because\`.`
182
+ );
183
+ continue;
184
+ }
185
+ const required = landings[declared.landing];
186
+ if (typeof required === "string" && required !== "" && !text.includes(required)) {
187
+ report(
188
+ file,
189
+ `This door is declared \`${declared.landing}\` but its source never contains \`${required}\`, so the sign-in it completes hands the client nothing to route the account by. Return it, or reclassify the door in \`doors\` with the proof that it cannot end such a sign-in.`
190
+ );
191
+ }
192
+ }
193
+ for (const door of doors) {
194
+ if (!matched.has(door.file)) {
195
+ report(
196
+ door.file,
197
+ "`doors` names a file that no longer exists, or that `sourceGlobs` do not reach. Remove the entry, or point it at the file the door moved to."
198
+ );
199
+ }
200
+ }
201
+ return violations;
202
+ }
203
+ };
204
+ }
205
+
206
+ // src/session/session-mint-callers.ts
207
+ var DEFAULT_ID4 = "session-mint-callers";
208
+ function createSessionMintCallersRule(options = {}) {
209
+ const id = options.id ?? DEFAULT_ID4;
210
+ const mintCall = options.mintCall ?? "";
211
+ const allowed = pathSet(options.allowedCallers);
212
+ const gate = options.gateCall;
213
+ return {
214
+ id,
215
+ category: "source-text",
216
+ ciCritical: options.ciCritical ?? true,
217
+ description: "The method that mints a session may only be called from allowlisted files; a new sign-in entry point must route through the gate in front of it so the gate cannot be bypassed.",
218
+ run(ctx) {
219
+ if (mintCall === "") return [];
220
+ const violations = [];
221
+ const allowedList = [...allowed].join(", ") || "none";
222
+ for (const { file, text } of scopedSources(ctx, options).sources) {
223
+ if (allowed.has(file)) continue;
224
+ if (!callsMethod(text, mintCall)) continue;
225
+ violations.push({
226
+ file,
227
+ rule: id,
228
+ message: withHint(
229
+ `${mintCall}() may only be called from the allowlisted files (${allowedList}).${gate === void 0 ? "" : ` A sign-in or OAuth entry point must call ${gate}() instead, so the checks it runs before minting are not bypassed.`} If this is a genuine mint that passes no gate (a signup, or a re-issue to a caller who already holds a session), add the file to \`allowedCallers\` with a justification.`,
230
+ options.hint
231
+ )
232
+ });
233
+ }
234
+ return violations;
235
+ }
236
+ };
237
+ }
238
+ export {
239
+ callArguments,
240
+ createSessionEpochCapturedRule,
241
+ createSessionKindStampedRule,
242
+ createSessionLandingDeclaredRule,
243
+ createSessionMintCallersRule
244
+ };
package/dist/trpc.cjs ADDED
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/trpc.ts
21
+ var trpc_exports = {};
22
+ __export(trpc_exports, {
23
+ createIdempotencyKeyParityRule: () => createIdempotencyKeyParityRule
24
+ });
25
+ module.exports = __toCommonJS(trpc_exports);
26
+
27
+ // src/rules/shared.ts
28
+ function escapeRegExp(value) {
29
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
+ }
31
+ var DEFAULT_SKIP_DIRS = [
32
+ "node_modules",
33
+ ".git",
34
+ "dist",
35
+ ".turbo",
36
+ "coverage"
37
+ ];
38
+ function globFiles(glob, globs, skipDirs = []) {
39
+ const skip = new Set(skipDirs);
40
+ const found = /* @__PURE__ */ new Set();
41
+ for (const pattern of globs) {
42
+ for (const rel of glob(pattern)) {
43
+ if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
44
+ }
45
+ }
46
+ return [...found].sort();
47
+ }
48
+ function readSourceText(read, rel) {
49
+ try {
50
+ return read(rel);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // src/trpc/idempotency-key-parity.ts
57
+ var DEFAULT_ID = "idempotency-key-parity";
58
+ function routerAlias(source, names) {
59
+ return names.alias.exec(source)?.[2] ?? null;
60
+ }
61
+ function guardedMethods(source, names) {
62
+ const methods = [];
63
+ const decorator = new RegExp(names.decorator.source, "gu");
64
+ let match;
65
+ while ((match = decorator.exec(source)) !== null) {
66
+ if (!names.middleware.test(match[1] ?? "")) continue;
67
+ const rest = source.slice(match.index + match[0].length);
68
+ const method = /\basync\s+([A-Za-z_$][\w$]*)\s*\(/u.exec(rest)?.[1];
69
+ if (method !== void 0) methods.push(method);
70
+ }
71
+ return methods;
72
+ }
73
+ function createIdempotencyKeyParityRule(options = {}) {
74
+ const id = options.id ?? DEFAULT_ID;
75
+ const middleware = options.middleware ?? "";
76
+ const keyToken = options.keyToken ?? "idempotencyKey";
77
+ const clientPrefix = options.clientPrefix ?? "trpc.";
78
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
79
+ const exempt = new Set(options.exempt ?? []);
80
+ const names = {
81
+ alias: new RegExp(
82
+ `@${escapeRegExp(options.routerDecorator ?? "Router")}\\(\\{[^}]*\\b${escapeRegExp(options.aliasKey ?? "alias")}:\\s*(['"])([^'"]+)\\1`,
83
+ "u"
84
+ ),
85
+ decorator: new RegExp(`@${escapeRegExp(options.middlewareDecorator ?? "UseMiddlewares")}\\(([^)]*)\\)`, "u"),
86
+ middleware: new RegExp(`\\b${escapeRegExp(middleware)}\\b`, "u")
87
+ };
88
+ return {
89
+ id,
90
+ category: "source-text",
91
+ ciCritical: options.ciCritical ?? true,
92
+ description: "Procedures carrying the idempotency middleware must have a client caller that sends an idempotency key, or no client caller at all.",
93
+ run(ctx) {
94
+ if (middleware === "") return [];
95
+ const clientSources = globFiles(ctx.glob, options.clientGlobs ?? [], skipDirs).map((file) => readSourceText(ctx.read, file)).filter((text) => text !== null);
96
+ const violations = [];
97
+ for (const file of globFiles(ctx.glob, options.routerGlobs ?? [], skipDirs)) {
98
+ const source = readSourceText(ctx.read, file);
99
+ if (source === null || !names.middleware.test(source)) continue;
100
+ const alias = routerAlias(source, names);
101
+ if (alias === null) continue;
102
+ for (const method of guardedMethods(source, names)) {
103
+ const path = `${alias}.${method}`;
104
+ if (exempt.has(path)) continue;
105
+ const callers = clientSources.filter((text) => text.includes(`${clientPrefix}${path}`));
106
+ if (callers.length === 0) continue;
107
+ if (callers.some((text) => text.includes(keyToken))) continue;
108
+ violations.push({
109
+ file,
110
+ rule: id,
111
+ message: `\`${path}\` carries ${middleware} but its client callers never send \`${keyToken}\`, so the guard is inert. Send the key from the caller, or drop the middleware and say why.${options.hint === void 0 || options.hint === "" ? "" : ` ${options.hint}`}`
112
+ });
113
+ }
114
+ }
115
+ return violations;
116
+ }
117
+ };
118
+ }
119
+ // Annotate the CommonJS export names for ESM import in node:
120
+ 0 && (module.exports = {
121
+ createIdempotencyKeyParityRule
122
+ });
@@ -0,0 +1,59 @@
1
+ import { IMetaRule } from '@noctcore/harness';
2
+
3
+ /**
4
+ * Options for {@link createIdempotencyKeyParityRule}.
5
+ *
6
+ * Ported from Settly, which hardcoded the middleware (`IdempotencyMiddleware`),
7
+ * the key (`idempotencyKey`) and the two trees (`apps/api/src` routers,
8
+ * `apps/web/src` callers). Those are options; the decorator shape defaults to
9
+ * `nestjs-trpc`'s (`@Router({ alias })`, `@UseMiddlewares(...)`) and the client
10
+ * shape to a `trpc.<alias>.<method>` proxy. With no `middleware` the rule is inert.
11
+ */
12
+ interface IdempotencyKeyParityOptions {
13
+ /** Rule id, for running more than one instance. Default `idempotency-key-parity`. */
14
+ readonly id?: string;
15
+ /** The middleware class that de-duplicates on a client-sent key. Unset or empty = inert. */
16
+ readonly middleware?: string;
17
+ /** Globs of the server router files. Default: none (inert). */
18
+ readonly routerGlobs?: readonly string[];
19
+ /** Globs of every client file that can call a procedure or send a key. Default: none. */
20
+ readonly clientGlobs?: readonly string[];
21
+ /** The token a client file must mention to count as sending a key. Default `idempotencyKey`. */
22
+ readonly keyToken?: string;
23
+ /** What precedes `<alias>.<method>` at a client call site. Default `trpc.`. */
24
+ readonly clientPrefix?: string;
25
+ /** The class decorator that names the router. Default `Router`. */
26
+ readonly routerDecorator?: string;
27
+ /** The key in that decorator's object argument holding the router's alias. Default `alias`. */
28
+ readonly aliasKey?: string;
29
+ /** The method decorator listing a procedure's middlewares. Default `UseMiddlewares`. */
30
+ readonly middlewareDecorator?: string;
31
+ /**
32
+ * `<alias>.<method>` procedures whose caller deliberately sends no key. Keep
33
+ * it empty: the honest fix for "a key will never be sent here" is to drop the
34
+ * middleware. Default: none.
35
+ */
36
+ readonly exempt?: readonly string[];
37
+ /** Paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
38
+ readonly skipDirs?: readonly string[];
39
+ /** Appended to every message: how this project threads the key. */
40
+ readonly hint?: string;
41
+ /** Whether a violation fails CI. Default `true`. */
42
+ readonly ciCritical?: boolean;
43
+ }
44
+ /**
45
+ * Every idempotency-guarded procedure has a client caller that sends a key, or
46
+ * no client caller at all.
47
+ *
48
+ * The middleware only de-duplicates when the client sends a key, and neither
49
+ * half fails when the other is missing: the server passes the request through,
50
+ * the client gets a normal response, and both test suites pass. A guard nobody
51
+ * sends a key to is decoration advertising double-submit protection that does
52
+ * not exist. The rule holds the two lists side by side. A procedure with no
53
+ * client caller passes: the guard is correct in advance of the screen that will
54
+ * use it. The reverse (a key sent to an unguarded procedure) is inert and needs
55
+ * no rule.
56
+ */
57
+ declare function createIdempotencyKeyParityRule(options?: IdempotencyKeyParityOptions): IMetaRule;
58
+
59
+ export { type IdempotencyKeyParityOptions, createIdempotencyKeyParityRule };
package/dist/trpc.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { IMetaRule } from '@noctcore/harness';
2
+
3
+ /**
4
+ * Options for {@link createIdempotencyKeyParityRule}.
5
+ *
6
+ * Ported from Settly, which hardcoded the middleware (`IdempotencyMiddleware`),
7
+ * the key (`idempotencyKey`) and the two trees (`apps/api/src` routers,
8
+ * `apps/web/src` callers). Those are options; the decorator shape defaults to
9
+ * `nestjs-trpc`'s (`@Router({ alias })`, `@UseMiddlewares(...)`) and the client
10
+ * shape to a `trpc.<alias>.<method>` proxy. With no `middleware` the rule is inert.
11
+ */
12
+ interface IdempotencyKeyParityOptions {
13
+ /** Rule id, for running more than one instance. Default `idempotency-key-parity`. */
14
+ readonly id?: string;
15
+ /** The middleware class that de-duplicates on a client-sent key. Unset or empty = inert. */
16
+ readonly middleware?: string;
17
+ /** Globs of the server router files. Default: none (inert). */
18
+ readonly routerGlobs?: readonly string[];
19
+ /** Globs of every client file that can call a procedure or send a key. Default: none. */
20
+ readonly clientGlobs?: readonly string[];
21
+ /** The token a client file must mention to count as sending a key. Default `idempotencyKey`. */
22
+ readonly keyToken?: string;
23
+ /** What precedes `<alias>.<method>` at a client call site. Default `trpc.`. */
24
+ readonly clientPrefix?: string;
25
+ /** The class decorator that names the router. Default `Router`. */
26
+ readonly routerDecorator?: string;
27
+ /** The key in that decorator's object argument holding the router's alias. Default `alias`. */
28
+ readonly aliasKey?: string;
29
+ /** The method decorator listing a procedure's middlewares. Default `UseMiddlewares`. */
30
+ readonly middlewareDecorator?: string;
31
+ /**
32
+ * `<alias>.<method>` procedures whose caller deliberately sends no key. Keep
33
+ * it empty: the honest fix for "a key will never be sent here" is to drop the
34
+ * middleware. Default: none.
35
+ */
36
+ readonly exempt?: readonly string[];
37
+ /** Paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
38
+ readonly skipDirs?: readonly string[];
39
+ /** Appended to every message: how this project threads the key. */
40
+ readonly hint?: string;
41
+ /** Whether a violation fails CI. Default `true`. */
42
+ readonly ciCritical?: boolean;
43
+ }
44
+ /**
45
+ * Every idempotency-guarded procedure has a client caller that sends a key, or
46
+ * no client caller at all.
47
+ *
48
+ * The middleware only de-duplicates when the client sends a key, and neither
49
+ * half fails when the other is missing: the server passes the request through,
50
+ * the client gets a normal response, and both test suites pass. A guard nobody
51
+ * sends a key to is decoration advertising double-submit protection that does
52
+ * not exist. The rule holds the two lists side by side. A procedure with no
53
+ * client caller passes: the guard is correct in advance of the screen that will
54
+ * use it. The reverse (a key sent to an unguarded procedure) is inert and needs
55
+ * no rule.
56
+ */
57
+ declare function createIdempotencyKeyParityRule(options?: IdempotencyKeyParityOptions): IMetaRule;
58
+
59
+ export { type IdempotencyKeyParityOptions, createIdempotencyKeyParityRule };
package/dist/trpc.js ADDED
@@ -0,0 +1,73 @@
1
+ import {
2
+ DEFAULT_SKIP_DIRS,
3
+ escapeRegExp,
4
+ globFiles,
5
+ readSourceText
6
+ } from "./chunk-OYFQKSJN.js";
7
+
8
+ // src/trpc/idempotency-key-parity.ts
9
+ var DEFAULT_ID = "idempotency-key-parity";
10
+ function routerAlias(source, names) {
11
+ return names.alias.exec(source)?.[2] ?? null;
12
+ }
13
+ function guardedMethods(source, names) {
14
+ const methods = [];
15
+ const decorator = new RegExp(names.decorator.source, "gu");
16
+ let match;
17
+ while ((match = decorator.exec(source)) !== null) {
18
+ if (!names.middleware.test(match[1] ?? "")) continue;
19
+ const rest = source.slice(match.index + match[0].length);
20
+ const method = /\basync\s+([A-Za-z_$][\w$]*)\s*\(/u.exec(rest)?.[1];
21
+ if (method !== void 0) methods.push(method);
22
+ }
23
+ return methods;
24
+ }
25
+ function createIdempotencyKeyParityRule(options = {}) {
26
+ const id = options.id ?? DEFAULT_ID;
27
+ const middleware = options.middleware ?? "";
28
+ const keyToken = options.keyToken ?? "idempotencyKey";
29
+ const clientPrefix = options.clientPrefix ?? "trpc.";
30
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
31
+ const exempt = new Set(options.exempt ?? []);
32
+ const names = {
33
+ alias: new RegExp(
34
+ `@${escapeRegExp(options.routerDecorator ?? "Router")}\\(\\{[^}]*\\b${escapeRegExp(options.aliasKey ?? "alias")}:\\s*(['"])([^'"]+)\\1`,
35
+ "u"
36
+ ),
37
+ decorator: new RegExp(`@${escapeRegExp(options.middlewareDecorator ?? "UseMiddlewares")}\\(([^)]*)\\)`, "u"),
38
+ middleware: new RegExp(`\\b${escapeRegExp(middleware)}\\b`, "u")
39
+ };
40
+ return {
41
+ id,
42
+ category: "source-text",
43
+ ciCritical: options.ciCritical ?? true,
44
+ description: "Procedures carrying the idempotency middleware must have a client caller that sends an idempotency key, or no client caller at all.",
45
+ run(ctx) {
46
+ if (middleware === "") return [];
47
+ const clientSources = globFiles(ctx.glob, options.clientGlobs ?? [], skipDirs).map((file) => readSourceText(ctx.read, file)).filter((text) => text !== null);
48
+ const violations = [];
49
+ for (const file of globFiles(ctx.glob, options.routerGlobs ?? [], skipDirs)) {
50
+ const source = readSourceText(ctx.read, file);
51
+ if (source === null || !names.middleware.test(source)) continue;
52
+ const alias = routerAlias(source, names);
53
+ if (alias === null) continue;
54
+ for (const method of guardedMethods(source, names)) {
55
+ const path = `${alias}.${method}`;
56
+ if (exempt.has(path)) continue;
57
+ const callers = clientSources.filter((text) => text.includes(`${clientPrefix}${path}`));
58
+ if (callers.length === 0) continue;
59
+ if (callers.some((text) => text.includes(keyToken))) continue;
60
+ violations.push({
61
+ file,
62
+ rule: id,
63
+ message: `\`${path}\` carries ${middleware} but its client callers never send \`${keyToken}\`, so the guard is inert. Send the key from the caller, or drop the middleware and say why.${options.hint === void 0 || options.hint === "" ? "" : ` ${options.hint}`}`
64
+ });
65
+ }
66
+ }
67
+ return violations;
68
+ }
69
+ };
70
+ }
71
+ export {
72
+ createIdempotencyKeyParityRule
73
+ };
@@ -2,6 +2,10 @@
2
2
 
3
3
  > An agent-contract doc must exist at the repo root, every surface, and every non-opted-out package.
4
4
 
5
+ <!-- begin generated rule header -->
6
+ Runs under `@noctcore/harness`, not ESLint · Factory `createAgentsDocPresenceRule` from `@noctcore/lint-meta-rules` · Category `source-text` · Fails CI by default: yes
7
+ <!-- end generated rule header -->
8
+
5
9
  ## Why
6
10
 
7
11
  An agent editing a boundary should read its guardrails first. Requiring an `AGENTS.md` (or whatever
@@ -17,7 +21,15 @@ Reports a missing doc at:
17
21
  - every directory derived from `surfaceGlobs` (all surfaces), and
18
22
  - every directory derived from `packageGlobs`, except those in `optOut`.
19
23
 
20
- ## Factory
24
+ ## What it does not flag
25
+
26
+ - A package directory listed in `optOut`.
27
+ - The repo root when `requireAtRoot` is `false`.
28
+ - Directories that no `surfaceGlobs` or `packageGlobs` entry matches (the defaults look one level deep
29
+ under `apps/` and `packages/`).
30
+ - The doc's content: an empty or stale file passes, since only its presence is checked.
31
+
32
+ ## Options
21
33
 
22
34
  ```ts
23
35
  createAgentsDocPresenceRule(options?: AgentsDocPresenceOptions): IMetaRule
@@ -32,9 +44,6 @@ createAgentsDocPresenceRule(options?: AgentsDocPresenceOptions): IMetaRule
32
44
  | `optOut` | `string[]` | `[]` | Package directories exempt from the requirement. |
33
45
  | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
34
46
 
35
- De-projected from nightcore, which hardcoded `AGENTS.md`, the root/apps/packages layout and a fixed
36
- leaf opt-out set.
37
-
38
47
  ## When not to use it
39
48
 
40
49
  If your repo does not adopt an agent-contract doc convention, skip it.
@@ -2,6 +2,10 @@
2
2
 
3
3
  > A helper symbol must not be exported from two different helper homes.
4
4
 
5
+ <!-- begin generated rule header -->
6
+ Runs under `@noctcore/harness`, not ESLint · Factory `createCanonicalHelpersSingleHomeRule` from `@noctcore/lint-meta-rules` · Category `source-text` · Fails CI by default: yes
7
+ <!-- end generated rule header -->
8
+
5
9
  ## Why
6
10
 
7
11
  When the same helper name is exported from multiple files, callers import inconsistent copies and the
@@ -15,7 +19,15 @@ extracts top-level exported identifiers (from `export function|const|let|var …
15
19
  lists — keyed on the **local** name before any `as`), and flags any name that appears as an export in
16
20
  more than one file. Strict, no baseline.
17
21
 
18
- ## Factory
22
+ ## What it does not flag
23
+
24
+ - A name exported from only one helper home, however many files import it.
25
+ - An `export { x as y }` alias under a new public name: the check keys on the local name `x`.
26
+ - Files outside `include`, and any path containing an `excludeContains` fragment (`/lib/` by default).
27
+ - `export default`, `export class`, `export type` and `export interface` declarations: only
28
+ `function`, `const`, `let`, `var` declarations and `export { … }` lists are read.
29
+
30
+ ## Options
19
31
 
20
32
  ```ts
21
33
  createCanonicalHelpersSingleHomeRule(options?: CanonicalHelpersSingleHomeOptions): IMetaRule
@@ -27,8 +39,6 @@ createCanonicalHelpersSingleHomeRule(options?: CanonicalHelpersSingleHomeOptions
27
39
  | `excludeContains` | `string[]` | `['/lib/']` | Drop any matched path containing one of these fragments. |
28
40
  | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
29
41
 
30
- De-projected from nightcore, which hardcoded `apps/web/src/**/*.utils.ts` and a `/lib/` exclusion.
31
-
32
42
  ## When not to use it
33
43
 
34
44
  If your project intentionally re-exports the same symbol from several modules (barrels, façades), scope
@@ -2,6 +2,10 @@
2
2
 
3
3
  > Dockerfile `FROM` base images are pinned by `@sha256:` digest.
4
4
 
5
+ <!-- begin generated rule header -->
6
+ Runs under `@noctcore/harness`, not ESLint · Factory `createDockerfileBaseImageDigestPinRule` from `@noctcore/lint-meta-rules` · Category `ci` · Fails CI by default: yes
7
+ <!-- end generated rule header -->
8
+
5
9
  ## Why
6
10
 
7
11
  `FROM node:22-slim` names whatever the registry serves at build time, so two builds of one commit can
@@ -25,7 +29,16 @@ FROM deps AS build
25
29
  FROM scratch
26
30
  ```
27
31
 
28
- ## Factory
32
+ ## What it does not flag
33
+
34
+ - `FROM scratch` and `FROM <earlier stage>` (a name given by a previous `AS`).
35
+ - `FROM ${BASE}` whose `ARG BASE=<default>` before the first `FROM` is digest-pinned.
36
+ - Dockerfiles under any `skipDirs` segment (`node_modules`, `.git`, `dist`, `.turbo`, `coverage`) and
37
+ files the `dockerfileGlobs` do not match.
38
+ - A `FROM` split with a line continuation (`\`): it is not read. Add a `Containerfile` glob if you use
39
+ Podman naming.
40
+
41
+ ## Options
29
42
 
30
43
  ```ts
31
44
  createDockerfileBaseImageDigestPinRule(options?: DockerfileBaseImageDigestPinOptions): IMetaRule
@@ -37,7 +50,7 @@ createDockerfileBaseImageDigestPinRule(options?: DockerfileBaseImageDigestPinOpt
37
50
  | `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | A path with any of these segments is skipped. |
38
51
  | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
39
52
 
40
- ## Limits
53
+ ## When not to use it
41
54
 
42
- A `FROM` split with a line continuation (`\`) is not read. Add a `Containerfile` glob if you use
43
- Podman naming.
55
+ If your images are built only for local development and you accept whatever a tag serves at build
56
+ time, skip it.