@prairielearn/eslint-plugin 4.1.0 → 4.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.d.ts +15 -10
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +20 -21
  5. package/dist/index.js.map +1 -1
  6. package/dist/rules/aws-client-mandatory-config.d.ts.map +1 -1
  7. package/dist/rules/aws-client-mandatory-config.js +4 -6
  8. package/dist/rules/aws-client-mandatory-config.js.map +1 -1
  9. package/dist/rules/aws-client-shared-config.d.ts.map +1 -1
  10. package/dist/rules/aws-client-shared-config.js +4 -6
  11. package/dist/rules/aws-client-shared-config.js.map +1 -1
  12. package/dist/rules/html-no-duplicate-id.d.ts +10 -0
  13. package/dist/rules/html-no-duplicate-id.d.ts.map +1 -0
  14. package/dist/rules/html-no-duplicate-id.js +54 -0
  15. package/dist/rules/html-no-duplicate-id.js.map +1 -0
  16. package/dist/rules/jsx-no-dollar-interpolation.d.ts.map +1 -1
  17. package/dist/rules/jsx-no-dollar-interpolation.js +2 -4
  18. package/dist/rules/jsx-no-dollar-interpolation.js.map +1 -1
  19. package/dist/rules/no-current-target-in-callback.d.ts.map +1 -1
  20. package/dist/rules/no-current-target-in-callback.js +2 -4
  21. package/dist/rules/no-current-target-in-callback.js.map +1 -1
  22. package/dist/rules/no-hydrate-reslocals.d.ts.map +1 -1
  23. package/dist/rules/no-hydrate-reslocals.js +2 -4
  24. package/dist/rules/no-hydrate-reslocals.js.map +1 -1
  25. package/dist/rules/no-unused-sql-blocks.d.ts.map +1 -1
  26. package/dist/rules/no-unused-sql-blocks.js +4 -39
  27. package/dist/rules/no-unused-sql-blocks.js.map +1 -1
  28. package/dist/rules/require-trpc-permission-middleware.d.ts +4 -0
  29. package/dist/rules/require-trpc-permission-middleware.d.ts.map +1 -0
  30. package/dist/rules/require-trpc-permission-middleware.js +133 -0
  31. package/dist/rules/require-trpc-permission-middleware.js.map +1 -0
  32. package/dist/rules/safe-db-types.d.ts +1 -1
  33. package/dist/rules/safe-db-types.d.ts.map +1 -1
  34. package/dist/rules/safe-db-types.js +6 -40
  35. package/dist/rules/safe-db-types.js.map +1 -1
  36. package/dist/tests/aws-client-mandatory-config.test.d.ts.map +1 -1
  37. package/dist/tests/aws-client-mandatory-config.test.js +8 -13
  38. package/dist/tests/aws-client-mandatory-config.test.js.map +1 -1
  39. package/dist/tests/aws-client-shared-config.test.d.ts.map +1 -1
  40. package/dist/tests/aws-client-shared-config.test.js +8 -13
  41. package/dist/tests/aws-client-shared-config.test.js.map +1 -1
  42. package/dist/tests/html-no-duplicate-id.test.d.ts +2 -0
  43. package/dist/tests/html-no-duplicate-id.test.d.ts.map +1 -0
  44. package/dist/tests/html-no-duplicate-id.test.js +54 -0
  45. package/dist/tests/html-no-duplicate-id.test.js.map +1 -0
  46. package/dist/tests/jsx-no-dollar-interpolation.test.d.ts.map +1 -1
  47. package/dist/tests/jsx-no-dollar-interpolation.test.js +8 -13
  48. package/dist/tests/jsx-no-dollar-interpolation.test.js.map +1 -1
  49. package/dist/tests/no-current-target-in-callback.test.js +8 -13
  50. package/dist/tests/no-current-target-in-callback.test.js.map +1 -1
  51. package/dist/tests/no-hydrate-reslocals.test.d.ts.map +1 -1
  52. package/dist/tests/no-hydrate-reslocals.test.js +8 -13
  53. package/dist/tests/no-hydrate-reslocals.test.js.map +1 -1
  54. package/dist/tests/require-trpc-permission-middleware.test.d.ts +2 -0
  55. package/dist/tests/require-trpc-permission-middleware.test.d.ts.map +1 -0
  56. package/dist/tests/require-trpc-permission-middleware.test.js +106 -0
  57. package/dist/tests/require-trpc-permission-middleware.test.js.map +1 -0
  58. package/dist/utils.js +1 -4
  59. package/dist/utils.js.map +1 -1
  60. package/package.json +11 -10
  61. package/src/index.ts +7 -1
  62. package/src/rules/html-no-duplicate-id.ts +76 -0
  63. package/src/rules/require-trpc-permission-middleware.ts +168 -0
  64. package/src/tests/aws-client-mandatory-config.test.ts +1 -1
  65. package/src/tests/aws-client-shared-config.test.ts +1 -1
  66. package/src/tests/html-no-duplicate-id.test.ts +57 -0
  67. package/src/tests/jsx-no-dollar-interpolation.test.ts +1 -1
  68. package/src/tests/no-hydrate-reslocals.test.ts +1 -1
  69. package/src/tests/require-trpc-permission-middleware.test.ts +109 -0
  70. package/tsconfig.json +1 -7
@@ -1,15 +1,10 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const rule_tester_1 = require("@typescript-eslint/rule-tester");
7
- const vitest_1 = require("vitest");
8
- const no_hydrate_reslocals_1 = __importDefault(require("../rules/no-hydrate-reslocals"));
9
- rule_tester_1.RuleTester.afterAll = vitest_1.afterAll;
10
- rule_tester_1.RuleTester.describe = vitest_1.describe;
11
- rule_tester_1.RuleTester.it = vitest_1.it;
12
- const ruleTester = new rule_tester_1.RuleTester({
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { afterAll, describe, it } from 'vitest';
3
+ import rule from '../rules/no-hydrate-reslocals.js';
4
+ RuleTester.afterAll = afterAll;
5
+ RuleTester.describe = describe;
6
+ RuleTester.it = it;
7
+ const ruleTester = new RuleTester({
13
8
  languageOptions: {
14
9
  parserOptions: {
15
10
  ecmaFeatures: {
@@ -18,7 +13,7 @@ const ruleTester = new rule_tester_1.RuleTester({
18
13
  },
19
14
  },
20
15
  });
21
- ruleTester.run('no-hydrate-reslocals', no_hydrate_reslocals_1.default, {
16
+ ruleTester.run('no-hydrate-reslocals', rule, {
22
17
  valid: [
23
18
  {
24
19
  code: '<Hydrate><Child foo={1} bar="baz" /></Hydrate>',
@@ -1 +1 @@
1
- {"version":3,"file":"no-hydrate-reslocals.test.js","sourceRoot":"","sources":["../../src/tests/no-hydrate-reslocals.test.ts"],"names":[],"mappings":";;;;;AAAA,gEAA4D;AAC5D,mCAAgD;AAEhD,yFAAiD;AAEjD,wBAAU,CAAC,QAAQ,GAAG,iBAAQ,CAAC;AAC/B,wBAAU,CAAC,QAAQ,GAAG,iBAAQ,CAAC;AAC/B,wBAAU,CAAC,EAAE,GAAG,WAAE,CAAC;AAEnB,MAAM,UAAU,GAAG,IAAI,wBAAU,CAAC;IAChC,eAAe,EAAE;QACf,aAAa,EAAE;YACb,YAAY,EAAE;gBACZ,GAAG,EAAE,IAAI;aACV;SACF;KACF;CACF,CAAC,CAAC;AAEH,UAAU,CAAC,GAAG,CAAC,sBAAsB,EAAE,8BAAI,EAAE;IAC3C,KAAK,EAAE;QACL;YACE,IAAI,EAAE,gDAAgD;SACvD;QACD;YACE,IAAI,EAAE,sEAAsE;SAC7E;QACD;YACE,IAAI,EAAE,yDAAyD;SAChE;QACD;YACE,IAAI,EAAE,8CAA8C;SACrD;QACD;YACE,IAAI,EAAE,8CAA8C;SACrD;QACD;YACE,IAAI,EAAE,0CAA0C;SACjD;QACD;YACE,IAAI,EAAE,wCAAwC;SAC/C;QACD;YACE,IAAI,EAAE,iDAAiD;SACxD;KACF;IACD,OAAO,EAAE;QACP;YACE,IAAI,EAAE,qDAAqD;YAC3D,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;SACtE;QACD;YACE,IAAI,EAAE,kDAAkD;YACxD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC;SACnE;QACD;YACE,IAAI,EAAE,8CAA8C;YACpD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,6CAA6C;YACnD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,0CAA0C;YAChD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,wEAAwE;YAC9E,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;SACtE;QACD;YACE,IAAI,EAAE,+CAA+C;YACrD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;SACtE;QACD;YACE,IAAI,EAAE,4CAA4C;YAClD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC;SACnE;QACD;YACE,IAAI,EAAE,wCAAwC;YAC9C,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,6DAA6D;YACnE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;KACF;CACF,CAAC,CAAC","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/no-hydrate-reslocals';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester({\n languageOptions: {\n parserOptions: {\n ecmaFeatures: {\n jsx: true,\n },\n },\n },\n});\n\nruleTester.run('no-hydrate-reslocals', rule, {\n valid: [\n {\n code: '<Hydrate><Child foo={1} bar=\"baz\" /></Hydrate>',\n },\n {\n code: '<Hydrate><Child course={course} assessment={assessment} /></Hydrate>',\n },\n {\n code: '<SomeOther resLocals={res.locals}><Child /></SomeOther>',\n },\n {\n code: '<Hydrate><Child {...otherProps} /></Hydrate>',\n },\n {\n code: '<Hydrate resLocals={foo}><Child /></Hydrate>',\n },\n {\n code: 'hydrateHtml(<Child foo={1} bar=\"baz\" />)',\n },\n {\n code: 'hydrateHtml(<Child {...otherProps} />)',\n },\n {\n code: 'otherFunction(<Child resLocals={res.locals} />)',\n },\n ],\n invalid: [\n {\n code: '<Hydrate><Child resLocals={res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'resLocals' } }],\n },\n {\n code: '<Hydrate><Child locals={res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'locals' } }],\n },\n {\n code: '<Hydrate><Child {...res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: '<Hydrate><Child {...resLocals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: '<Hydrate><Child {...locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: '<Hydrate fullHeight><Child foo={1} resLocals={res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'resLocals' } }],\n },\n {\n code: 'hydrateHtml(<Child resLocals={res.locals} />)',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'resLocals' } }],\n },\n {\n code: 'hydrateHtml(<Child locals={res.locals} />)',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'locals' } }],\n },\n {\n code: 'hydrateHtml(<Child {...res.locals} />)',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: 'hydrateHtml(<Child {...resLocals} />, { fullHeight: true })',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n ],\n});\n"]}
1
+ {"version":3,"file":"no-hydrate-reslocals.test.js","sourceRoot":"","sources":["../../src/tests/no-hydrate-reslocals.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,IAAI,MAAM,kCAAkC,CAAC;AAEpD,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,EAAE,GAAG,EAAE,CAAC;AAEnB,MAAM,UAAU,GAAG,IAAI,UAAU,CAAC;IAChC,eAAe,EAAE;QACf,aAAa,EAAE;YACb,YAAY,EAAE;gBACZ,GAAG,EAAE,IAAI;aACV;SACF;KACF;CACF,CAAC,CAAC;AAEH,UAAU,CAAC,GAAG,CAAC,sBAAsB,EAAE,IAAI,EAAE;IAC3C,KAAK,EAAE;QACL;YACE,IAAI,EAAE,gDAAgD;SACvD;QACD;YACE,IAAI,EAAE,sEAAsE;SAC7E;QACD;YACE,IAAI,EAAE,yDAAyD;SAChE;QACD;YACE,IAAI,EAAE,8CAA8C;SACrD;QACD;YACE,IAAI,EAAE,8CAA8C;SACrD;QACD;YACE,IAAI,EAAE,0CAA0C;SACjD;QACD;YACE,IAAI,EAAE,wCAAwC;SAC/C;QACD;YACE,IAAI,EAAE,iDAAiD;SACxD;KACF;IACD,OAAO,EAAE;QACP;YACE,IAAI,EAAE,qDAAqD;YAC3D,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;SACtE;QACD;YACE,IAAI,EAAE,kDAAkD;YACxD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC;SACnE;QACD;YACE,IAAI,EAAE,8CAA8C;YACpD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,6CAA6C;YACnD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,0CAA0C;YAChD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,wEAAwE;YAC9E,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;SACtE;QACD;YACE,IAAI,EAAE,+CAA+C;YACrD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;SACtE;QACD;YACE,IAAI,EAAE,4CAA4C;YAClD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC;SACnE;QACD;YACE,IAAI,EAAE,wCAAwC;YAC9C,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;QACD;YACE,IAAI,EAAE,6DAA6D;YACnE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;SAC3C;KACF;CACF,CAAC,CAAC","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/no-hydrate-reslocals.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester({\n languageOptions: {\n parserOptions: {\n ecmaFeatures: {\n jsx: true,\n },\n },\n },\n});\n\nruleTester.run('no-hydrate-reslocals', rule, {\n valid: [\n {\n code: '<Hydrate><Child foo={1} bar=\"baz\" /></Hydrate>',\n },\n {\n code: '<Hydrate><Child course={course} assessment={assessment} /></Hydrate>',\n },\n {\n code: '<SomeOther resLocals={res.locals}><Child /></SomeOther>',\n },\n {\n code: '<Hydrate><Child {...otherProps} /></Hydrate>',\n },\n {\n code: '<Hydrate resLocals={foo}><Child /></Hydrate>',\n },\n {\n code: 'hydrateHtml(<Child foo={1} bar=\"baz\" />)',\n },\n {\n code: 'hydrateHtml(<Child {...otherProps} />)',\n },\n {\n code: 'otherFunction(<Child resLocals={res.locals} />)',\n },\n ],\n invalid: [\n {\n code: '<Hydrate><Child resLocals={res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'resLocals' } }],\n },\n {\n code: '<Hydrate><Child locals={res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'locals' } }],\n },\n {\n code: '<Hydrate><Child {...res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: '<Hydrate><Child {...resLocals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: '<Hydrate><Child {...locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: '<Hydrate fullHeight><Child foo={1} resLocals={res.locals} /></Hydrate>',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'resLocals' } }],\n },\n {\n code: 'hydrateHtml(<Child resLocals={res.locals} />)',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'resLocals' } }],\n },\n {\n code: 'hydrateHtml(<Child locals={res.locals} />)',\n errors: [{ messageId: 'forbiddenProp', data: { name: 'locals' } }],\n },\n {\n code: 'hydrateHtml(<Child {...res.locals} />)',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n {\n code: 'hydrateHtml(<Child {...resLocals} />, { fullHeight: true })',\n errors: [{ messageId: 'forbiddenSpread' }],\n },\n ],\n});\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=require-trpc-permission-middleware.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-trpc-permission-middleware.test.d.ts","sourceRoot":"","sources":["../../src/tests/require-trpc-permission-middleware.test.ts"],"names":[],"mappings":"","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/require-trpc-permission-middleware.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester();\n\nruleTester.run('require-trpc-permission-middleware', rule, {\n valid: [\n {\n code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',\n },\n {\n code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireEnhancedAccessControl)\n .use(requireCourseInstancePermissionView)\n .query(async () => {});`,\n },\n {\n code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireCourseInstancePermissionEdit)\n .input(z.object({}))\n .mutation(async () => {});`,\n },\n {\n code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',\n },\n // Not a t.procedure chain — should be ignored\n {\n code: 'const fn = somethingElse.query(async () => {});',\n },\n {\n code: 'const fn = otherRouter.procedure.query(async () => {});',\n },\n // Intermediate procedure-base variable: permission middleware on the base\n {\n code: `\n const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);\n const m = protectedProcedure.query(async () => {});\n `,\n },\n // Intermediate procedure-base variable: permission added downstream\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.use(requireAdministrator).mutation(async () => {});\n `,\n },\n // Imported procedure base — we can't follow across files, so don't fire\n {\n code: `\n import { protectedProcedure } from './foo.js';\n const m = protectedProcedure.query(async () => {});\n `,\n languageOptions: { parserOptions: { sourceType: 'module' } },\n },\n ],\n invalid: [\n {\n code: 'const list = t.procedure.query(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Feature gate alone is not a permission gate\n {\n code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `const m = t.procedure\n .input(z.object({}))\n .output(z.object({}))\n .subscription(async () => {});`,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Intermediate procedure-base variable with only a feature gate is still a miss\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.query(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.input(z.object({})).mutation(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n ],\n});\n"]}
@@ -0,0 +1,106 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { afterAll, describe, it } from 'vitest';
3
+ import rule from '../rules/require-trpc-permission-middleware.js';
4
+ RuleTester.afterAll = afterAll;
5
+ RuleTester.describe = describe;
6
+ RuleTester.it = it;
7
+ const ruleTester = new RuleTester();
8
+ ruleTester.run('require-trpc-permission-middleware', rule, {
9
+ valid: [
10
+ {
11
+ code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',
12
+ },
13
+ {
14
+ code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',
15
+ },
16
+ {
17
+ code: `const m = t.procedure
18
+ .use(requireEnhancedAccessControl)
19
+ .use(requireCourseInstancePermissionView)
20
+ .query(async () => {});`,
21
+ },
22
+ {
23
+ code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',
24
+ },
25
+ {
26
+ code: `const m = t.procedure
27
+ .use(requireCourseInstancePermissionEdit)
28
+ .input(z.object({}))
29
+ .mutation(async () => {});`,
30
+ },
31
+ {
32
+ code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',
33
+ },
34
+ // Not a t.procedure chain — should be ignored
35
+ {
36
+ code: 'const fn = somethingElse.query(async () => {});',
37
+ },
38
+ {
39
+ code: 'const fn = otherRouter.procedure.query(async () => {});',
40
+ },
41
+ // Intermediate procedure-base variable: permission middleware on the base
42
+ {
43
+ code: `
44
+ const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);
45
+ const m = protectedProcedure.query(async () => {});
46
+ `,
47
+ },
48
+ // Intermediate procedure-base variable: permission added downstream
49
+ {
50
+ code: `
51
+ const featureProcedure = t.procedure.use(requireAiGradingFeature);
52
+ const m = featureProcedure.use(requireAdministrator).mutation(async () => {});
53
+ `,
54
+ },
55
+ // Imported procedure base — we can't follow across files, so don't fire
56
+ {
57
+ code: `
58
+ import { protectedProcedure } from './foo.js';
59
+ const m = protectedProcedure.query(async () => {});
60
+ `,
61
+ languageOptions: { parserOptions: { sourceType: 'module' } },
62
+ },
63
+ ],
64
+ invalid: [
65
+ {
66
+ code: 'const list = t.procedure.query(async () => {});',
67
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
68
+ },
69
+ {
70
+ code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',
71
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
72
+ },
73
+ // Feature gate alone is not a permission gate
74
+ {
75
+ code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',
76
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
77
+ },
78
+ {
79
+ code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',
80
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
81
+ },
82
+ {
83
+ code: `const m = t.procedure
84
+ .input(z.object({}))
85
+ .output(z.object({}))
86
+ .subscription(async () => {});`,
87
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
88
+ },
89
+ // Intermediate procedure-base variable with only a feature gate is still a miss
90
+ {
91
+ code: `
92
+ const featureProcedure = t.procedure.use(requireAiGradingFeature);
93
+ const m = featureProcedure.query(async () => {});
94
+ `,
95
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
96
+ },
97
+ {
98
+ code: `
99
+ const featureProcedure = t.procedure.use(requireAiGradingFeature);
100
+ const m = featureProcedure.input(z.object({})).mutation(async () => {});
101
+ `,
102
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
103
+ },
104
+ ],
105
+ });
106
+ //# sourceMappingURL=require-trpc-permission-middleware.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-trpc-permission-middleware.test.js","sourceRoot":"","sources":["../../src/tests/require-trpc-permission-middleware.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,IAAI,MAAM,gDAAgD,CAAC;AAElE,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,EAAE,GAAG,EAAE,CAAC;AAEnB,MAAM,UAAU,GAAG,IAAI,UAAU,EAAE,CAAC;AAEpC,UAAU,CAAC,GAAG,CAAC,oCAAoC,EAAE,IAAI,EAAE;IACzD,KAAK,EAAE;QACL;YACE,IAAI,EAAE,kFAAkF;SACzF;QACD;YACE,IAAI,EAAE,qFAAqF;SAC5F;QACD;YACE,IAAI,EAAE;;;gCAGoB;SAC3B;QACD;YACE,IAAI,EAAE,2EAA2E;SAClF;QACD;YACE,IAAI,EAAE;;;mCAGuB;SAC9B;QACD;YACE,IAAI,EAAE,iFAAiF;SACxF;QACD,8CAA8C;QAC9C;YACE,IAAI,EAAE,iDAAiD;SACxD;QACD;YACE,IAAI,EAAE,yDAAyD;SAChE;QACD,0EAA0E;QAC1E;YACE,IAAI,EAAE;;;OAGL;SACF;QACD,oEAAoE;QACpE;YACE,IAAI,EAAE;;;OAGL;SACF;QACD,wEAAwE;QACxE;YACE,IAAI,EAAE;;;OAGL;YACD,eAAe,EAAE,EAAE,aAAa,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE;SAC7D;KACF;IACD,OAAO,EAAE;QACP;YACE,IAAI,EAAE,iDAAiD;YACvD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,qEAAqE;YAC3E,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD,8CAA8C;QAC9C;YACE,IAAI,EAAE,mFAAmF;YACzF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,8EAA8E;YACpF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE;;;uCAG2B;YACjC,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD,gFAAgF;QAChF;YACE,IAAI,EAAE;;;OAGL;YACD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE;;;OAGL;YACD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;KACF;CACF,CAAC,CAAC","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/require-trpc-permission-middleware.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester();\n\nruleTester.run('require-trpc-permission-middleware', rule, {\n valid: [\n {\n code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',\n },\n {\n code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireEnhancedAccessControl)\n .use(requireCourseInstancePermissionView)\n .query(async () => {});`,\n },\n {\n code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireCourseInstancePermissionEdit)\n .input(z.object({}))\n .mutation(async () => {});`,\n },\n {\n code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',\n },\n // Not a t.procedure chain — should be ignored\n {\n code: 'const fn = somethingElse.query(async () => {});',\n },\n {\n code: 'const fn = otherRouter.procedure.query(async () => {});',\n },\n // Intermediate procedure-base variable: permission middleware on the base\n {\n code: `\n const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);\n const m = protectedProcedure.query(async () => {});\n `,\n },\n // Intermediate procedure-base variable: permission added downstream\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.use(requireAdministrator).mutation(async () => {});\n `,\n },\n // Imported procedure base — we can't follow across files, so don't fire\n {\n code: `\n import { protectedProcedure } from './foo.js';\n const m = protectedProcedure.query(async () => {});\n `,\n languageOptions: { parserOptions: { sourceType: 'module' } },\n },\n ],\n invalid: [\n {\n code: 'const list = t.procedure.query(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Feature gate alone is not a permission gate\n {\n code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `const m = t.procedure\n .input(z.object({}))\n .output(z.object({}))\n .subscription(async () => {});`,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Intermediate procedure-base variable with only a feature gate is still a miss\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.query(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.input(z.object({})).mutation(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n ],\n});\n"]}
package/dist/utils.js CHANGED
@@ -1,6 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getAwsClientNamesFromImportDeclaration = getAwsClientNamesFromImportDeclaration;
4
1
  /**
5
2
  * Determines if the given identifier name corresponds to a client from the
6
3
  * given package.
@@ -29,7 +26,7 @@ function isIdentifierClient(identifierName, packageName) {
29
26
  *
30
27
  * then this function will return a set containing the strings "S3" and "S3Client".
31
28
  */
32
- function getAwsClientNamesFromImportDeclaration(node) {
29
+ export function getAwsClientNamesFromImportDeclaration(node) {
33
30
  const clientNames = new Set();
34
31
  const importSource = node.source.value;
35
32
  if (importSource.startsWith('@aws-sdk/client-')) {
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";;;AAAA;;;GAGG;AACH,SAAS,kBAAkB,CAAC,cAAsB,EAAE,WAAmB;IACrE,+DAA+D;IAC/D,IAAI,cAAc,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,8EAA8E;IAC9E,MAAM,UAAU,GAAG,WAAW,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;IAC/D,MAAM,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IACvE,IAAI,cAAc,CAAC,WAAW,EAAE,KAAK,iBAAiB,EAAE,CAAC;QACvD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;GAUG;AACH,gDAAuD,IAAS;IAC9D,MAAM,WAAW,GAAG,IAAI,GAAG,EAAU,CAAC;IAEtC,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC;IACvC,IAAI,YAAY,CAAC,UAAU,CAAC,kBAAkB,CAAC,EAAE,CAAC;QAChD,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,SAAc,EAAE,EAAE;YACzC,IAAI,SAAS,CAAC,IAAI,KAAK,iBAAiB,EAAE,CAAC;gBACzC,MAAM,aAAa,GAAG,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAC9C,IAAI,kBAAkB,CAAC,aAAa,EAAE,YAAY,CAAC,EAAE,CAAC;oBACpD,WAAW,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;gBACjC,CAAC;YACH,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC","sourcesContent":["/**\n * Determines if the given identifier name corresponds to a client from the\n * given package.\n */\nfunction isIdentifierClient(identifierName: string, packageName: string): boolean {\n // If the identifier ends with \"Client\", include it in the set.\n if (identifierName.endsWith('Client')) {\n return true;\n }\n\n // If the identifier matches the package name directly, include it in the set.\n const clientName = packageName.replace('@aws-sdk/client-', '');\n const packageIdentifier = clientName.replaceAll('-', '').toLowerCase();\n if (identifierName.toLowerCase() === packageIdentifier) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Retrieves the names of AWS clients specified by the given import declaration.\n *\n * For instance, if the import declaration is:\n *\n * ```ts\n * import { S3, S3Client } from '@aws-sdk/client-s3';\n * ```\n *\n * then this function will return a set containing the strings \"S3\" and \"S3Client\".\n */\nexport function getAwsClientNamesFromImportDeclaration(node: any) {\n const clientNames = new Set<string>();\n\n const importSource = node.source.value;\n if (importSource.startsWith('@aws-sdk/client-')) {\n node.specifiers.forEach((specifier: any) => {\n if (specifier.type === 'ImportSpecifier') {\n const specifierName = specifier.imported.name;\n if (isIdentifierClient(specifierName, importSource)) {\n clientNames.add(specifierName);\n }\n }\n });\n }\n\n return clientNames;\n}\n"]}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,SAAS,kBAAkB,CAAC,cAAsB,EAAE,WAAmB;IACrE,+DAA+D;IAC/D,IAAI,cAAc,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,8EAA8E;IAC9E,MAAM,UAAU,GAAG,WAAW,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;IAC/D,MAAM,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IACvE,IAAI,cAAc,CAAC,WAAW,EAAE,KAAK,iBAAiB,EAAE,CAAC;QACvD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,sCAAsC,CAAC,IAAS;IAC9D,MAAM,WAAW,GAAG,IAAI,GAAG,EAAU,CAAC;IAEtC,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC;IACvC,IAAI,YAAY,CAAC,UAAU,CAAC,kBAAkB,CAAC,EAAE,CAAC;QAChD,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,SAAc,EAAE,EAAE;YACzC,IAAI,SAAS,CAAC,IAAI,KAAK,iBAAiB,EAAE,CAAC;gBACzC,MAAM,aAAa,GAAG,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAC9C,IAAI,kBAAkB,CAAC,aAAa,EAAE,YAAY,CAAC,EAAE,CAAC;oBACpD,WAAW,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;gBACjC,CAAC;YACH,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC","sourcesContent":["/**\n * Determines if the given identifier name corresponds to a client from the\n * given package.\n */\nfunction isIdentifierClient(identifierName: string, packageName: string): boolean {\n // If the identifier ends with \"Client\", include it in the set.\n if (identifierName.endsWith('Client')) {\n return true;\n }\n\n // If the identifier matches the package name directly, include it in the set.\n const clientName = packageName.replace('@aws-sdk/client-', '');\n const packageIdentifier = clientName.replaceAll('-', '').toLowerCase();\n if (identifierName.toLowerCase() === packageIdentifier) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Retrieves the names of AWS clients specified by the given import declaration.\n *\n * For instance, if the import declaration is:\n *\n * ```ts\n * import { S3, S3Client } from '@aws-sdk/client-s3';\n * ```\n *\n * then this function will return a set containing the strings \"S3\" and \"S3Client\".\n */\nexport function getAwsClientNamesFromImportDeclaration(node: any) {\n const clientNames = new Set<string>();\n\n const importSource = node.source.value;\n if (importSource.startsWith('@aws-sdk/client-')) {\n node.specifiers.forEach((specifier: any) => {\n if (specifier.type === 'ImportSpecifier') {\n const specifierName = specifier.imported.name;\n if (isIdentifierClient(specifierName, importSource)) {\n clientNames.add(specifierName);\n }\n }\n });\n }\n\n return clientNames;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@prairielearn/eslint-plugin",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
+ "type": "module",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "https://github.com/PrairieLearn/PrairieLearn.git",
@@ -19,17 +20,17 @@
19
20
  "eslint": "^10.0.0"
20
21
  },
21
22
  "dependencies": {
22
- "@typescript-eslint/utils": "^8.58.0",
23
- "typescript": "^5.9.3"
23
+ "@typescript-eslint/utils": "^8.59.2",
24
+ "typescript": "^6.0.3"
24
25
  },
25
26
  "devDependencies": {
26
- "@prairielearn/tsconfig": "^2.0.0",
27
+ "@html-eslint/parser": "^0.58.1",
28
+ "@prairielearn/tsconfig": "^2.1.0",
27
29
  "@types/node": "^24.12.2",
28
- "@typescript-eslint/rule-tester": "^8.58.0",
29
- "@typescript-eslint/types": "^8.58.0",
30
- "@typescript/native-preview": "^7.0.0-dev.20260305.1",
31
- "@vitest/coverage-v8": "^4.1.2",
32
- "tsx": "^4.21.0",
33
- "vitest": "^4.1.2"
30
+ "@typescript-eslint/rule-tester": "^8.59.2",
31
+ "@typescript-eslint/types": "^8.59.2",
32
+ "@typescript/native-preview": "beta",
33
+ "@vitest/coverage-v8": "^4.1.5",
34
+ "vitest": "^4.1.5"
34
35
  }
35
36
  }
package/src/index.ts CHANGED
@@ -1,17 +1,23 @@
1
1
  import awsClientMandatoryConfig from './rules/aws-client-mandatory-config.js';
2
2
  import awsClientSharedConfig from './rules/aws-client-shared-config.js';
3
+ import htmlNoDuplicateId from './rules/html-no-duplicate-id.js';
3
4
  import jsxNoDollarInterpolation from './rules/jsx-no-dollar-interpolation.js';
4
5
  import noCurrentTargetInCallback from './rules/no-current-target-in-callback.js';
5
6
  import noHydrateResLocals from './rules/no-hydrate-reslocals.js';
6
7
  import noUnusedSqlBlocks from './rules/no-unused-sql-blocks.js';
8
+ import requireTrpcPermissionMiddleware from './rules/require-trpc-permission-middleware.js';
7
9
  import safeDbTypes from './rules/safe-db-types.js';
8
10
 
9
- export const rules = {
11
+ const rules = {
10
12
  'aws-client-mandatory-config': awsClientMandatoryConfig,
11
13
  'aws-client-shared-config': awsClientSharedConfig,
14
+ 'html-no-duplicate-id': htmlNoDuplicateId,
12
15
  'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,
13
16
  'no-current-target-in-callback': noCurrentTargetInCallback,
14
17
  'no-hydrate-reslocals': noHydrateResLocals,
15
18
  'no-unused-sql-blocks': noUnusedSqlBlocks,
19
+ 'require-trpc-permission-middleware': requireTrpcPermissionMiddleware,
16
20
  'safe-db-types': safeDbTypes,
17
21
  };
22
+
23
+ export default { rules };
@@ -0,0 +1,76 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+
3
+ interface AttributeValueNode {
4
+ type: 'AttributeValue';
5
+ value: string;
6
+ parts?: { type: string }[];
7
+ }
8
+
9
+ interface AttributeKeyNode {
10
+ type: 'AttributeKey';
11
+ value: string;
12
+ }
13
+
14
+ interface AttributeNode {
15
+ type: 'Attribute';
16
+ key: AttributeKeyNode;
17
+ value?: AttributeValueNode;
18
+ }
19
+
20
+ interface TagNode {
21
+ type: 'Tag';
22
+ name: string;
23
+ attributes: AttributeNode[];
24
+ }
25
+
26
+ function findIdAttr(node: TagNode): AttributeNode | undefined {
27
+ return node.attributes.find((attr) => attr.key && attr.key.value.toLowerCase() === 'id');
28
+ }
29
+
30
+ function hasTemplatePart(node: AttributeValueNode): boolean {
31
+ return node.parts?.some((part) => part.type === 'Template') ?? false;
32
+ }
33
+
34
+ /**
35
+ * Variant of `@html-eslint/no-duplicate-id` that ignores tags whose names start
36
+ * with `pl-`. The `id` attribute on PrairieLearn elements (e.g. `pl-sketch-tool
37
+ * id="fd"`) is not a DOM id, it's an element-specific identifier consumed by
38
+ * the parent element, so duplicates across separate parents are legal.
39
+ */
40
+ export default ESLintUtils.RuleCreator.withoutDocs({
41
+ meta: {
42
+ type: 'problem',
43
+ messages: {
44
+ duplicateId: "The id '{{id}}' is duplicated.",
45
+ },
46
+ schema: [],
47
+ },
48
+ defaultOptions: [],
49
+ create(context) {
50
+ const idAttrs = new Map<string, AttributeValueNode[]>();
51
+
52
+ return {
53
+ Tag(node: TagNode) {
54
+ if (node.name.toLowerCase().startsWith('pl-')) return;
55
+ const idAttr = findIdAttr(node);
56
+ if (!idAttr?.value) return;
57
+ if (hasTemplatePart(idAttr.value)) return;
58
+ const list = idAttrs.get(idAttr.value.value) ?? [];
59
+ list.push(idAttr.value);
60
+ idAttrs.set(idAttr.value.value, list);
61
+ },
62
+ 'Document:exit'() {
63
+ for (const attrs of idAttrs.values()) {
64
+ if (attrs.length <= 1) continue;
65
+ for (const attr of attrs) {
66
+ context.report({
67
+ node: attr as never,
68
+ data: { id: attr.value },
69
+ messageId: 'duplicateId',
70
+ });
71
+ }
72
+ }
73
+ },
74
+ };
75
+ },
76
+ });
@@ -0,0 +1,168 @@
1
+ import { ASTUtils, ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils';
2
+
3
+ const { findVariable } = ASTUtils;
4
+
5
+ /**
6
+ * tRPC procedures must enforce their own authorization via a `require*`
7
+ * permission middleware in the chain.
8
+ * Individual procedures often need stricter permissions than the page they're
9
+ * mounted under (e.g. an Edit-only mutation under a route that allows View).
10
+ * Requiring an explicit permission middleware per procedure makes the
11
+ * authz gate visible at the call site and helps prevent missing permission checks.
12
+ *
13
+ * Bad:
14
+ * ```ts
15
+ * const list = t.procedure.query(async ({ ctx }) => { ... });
16
+ * ```
17
+ *
18
+ * Good:
19
+ * ```ts
20
+ * const list = t.procedure.use(requireCoursePermissionEdit).query(...);
21
+ * ```
22
+ *
23
+ * Recognized permission middlewares:
24
+ * - requireCoursePermissionPreview / View / Edit / Own
25
+ * - requireCourseInstancePermissionPreview / View / Edit / Own
26
+ * - requireAdministrator
27
+ *
28
+ * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`
29
+ * don't count — they must be paired with a permission middleware.
30
+ *
31
+ * The rule follows intermediate procedure-base variables so patterns like
32
+ * `const protected = t.procedure.use(...); ...; protected.query(...)` are
33
+ * checked end-to-end.
34
+ */
35
+
36
+ // TODO: This list requires manual maintenance. We should consider making feature gates
37
+ // not use the `require` prefix, and then we can use a more generic pattern here.
38
+ const PERMISSION_MIDDLEWARE_PATTERN =
39
+ /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)$|^requireAdministrator$/;
40
+ const TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);
41
+
42
+ function unwrapMemberObject(expr: TSESTree.Expression): TSESTree.Expression {
43
+ return expr.type === 'TSNonNullExpression' || expr.type === 'TSAsExpression'
44
+ ? unwrapMemberObject(expr.expression)
45
+ : expr;
46
+ }
47
+
48
+ function isTProcedure(expr: TSESTree.Expression): boolean {
49
+ return (
50
+ expr.type === 'MemberExpression' &&
51
+ !expr.computed &&
52
+ expr.property.type === 'Identifier' &&
53
+ expr.property.name === 'procedure' &&
54
+ expr.object.type === 'Identifier' &&
55
+ expr.object.name === 't'
56
+ );
57
+ }
58
+
59
+ interface ChainInfo {
60
+ matchesTProcedure: boolean;
61
+ middlewares: string[];
62
+ }
63
+
64
+ /**
65
+ * Walks back through a chain like `expr.use(A).input(B).use(C)` collecting
66
+ * `.use(...)` argument identifiers. If the base of the chain is `t.procedure`,
67
+ * returns the collected middlewares. If the base is a variable, follows the
68
+ * variable's initializer (e.g. `const protected = t.procedure.use(X)`).
69
+ */
70
+ function getProcedureChainInfo(
71
+ expr: TSESTree.Expression,
72
+ scope: TSESLint.Scope.Scope,
73
+ visited: Set<TSESTree.Node>,
74
+ ): ChainInfo {
75
+ if (visited.has(expr)) return { matchesTProcedure: false, middlewares: [] };
76
+ visited.add(expr);
77
+
78
+ const middlewares: string[] = [];
79
+ let cursor: TSESTree.Expression = expr;
80
+
81
+ while (cursor.type === 'CallExpression') {
82
+ const callee = cursor.callee;
83
+ if (
84
+ callee.type !== 'MemberExpression' ||
85
+ callee.computed ||
86
+ callee.property.type !== 'Identifier'
87
+ ) {
88
+ return { matchesTProcedure: false, middlewares: [] };
89
+ }
90
+ if (callee.property.name === 'use' && cursor.arguments[0]?.type === 'Identifier') {
91
+ middlewares.push(cursor.arguments[0].name);
92
+ }
93
+ cursor = callee.object;
94
+ }
95
+
96
+ cursor = unwrapMemberObject(cursor);
97
+
98
+ if (isTProcedure(cursor)) {
99
+ return { matchesTProcedure: true, middlewares };
100
+ }
101
+
102
+ // Follow an intermediate procedure-base variable defined in the same module:
103
+ // const protectedProcedure = t.procedure.use(...);
104
+ // protectedProcedure.query(...);
105
+ if (cursor.type === 'Identifier') {
106
+ const variable = findVariable(scope, cursor);
107
+ if (variable && variable.defs.length === 1) {
108
+ const def = variable.defs[0];
109
+ if (
110
+ def.node.type === 'VariableDeclarator' &&
111
+ def.node.init &&
112
+ // Conservative: only follow `const` bindings to avoid stale data
113
+ // from let/var rebinding.
114
+ def.parent?.type === 'VariableDeclaration' &&
115
+ def.parent.kind === 'const'
116
+ ) {
117
+ const upstream = getProcedureChainInfo(def.node.init, scope, visited);
118
+ if (upstream.matchesTProcedure) {
119
+ return {
120
+ matchesTProcedure: true,
121
+ middlewares: [...middlewares, ...upstream.middlewares],
122
+ };
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ return { matchesTProcedure: false, middlewares: [] };
129
+ }
130
+
131
+ export default ESLintUtils.RuleCreator.withoutDocs({
132
+ meta: {
133
+ type: 'problem',
134
+ messages: {
135
+ missingPermissionMiddleware:
136
+ 'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',
137
+ },
138
+ schema: [],
139
+ },
140
+ defaultOptions: [],
141
+
142
+ create(context) {
143
+ return {
144
+ CallExpression(node) {
145
+ if (
146
+ node.callee.type !== 'MemberExpression' ||
147
+ node.callee.computed ||
148
+ node.callee.property.type !== 'Identifier' ||
149
+ !TERMINAL_METHODS.has(node.callee.property.name)
150
+ ) {
151
+ return;
152
+ }
153
+
154
+ const scope = context.sourceCode.getScope(node);
155
+ const info = getProcedureChainInfo(node.callee.object, scope, new Set());
156
+
157
+ if (!info.matchesTProcedure) return;
158
+
159
+ const hasPermissionGate = info.middlewares.some((name) =>
160
+ PERMISSION_MIDDLEWARE_PATTERN.test(name),
161
+ );
162
+ if (!hasPermissionGate) {
163
+ context.report({ node, messageId: 'missingPermissionMiddleware' });
164
+ }
165
+ },
166
+ };
167
+ },
168
+ });
@@ -1,7 +1,7 @@
1
1
  import { RuleTester } from '@typescript-eslint/rule-tester';
2
2
  import { afterAll, describe, it } from 'vitest';
3
3
 
4
- import rule from '../rules/aws-client-mandatory-config';
4
+ import rule from '../rules/aws-client-mandatory-config.js';
5
5
 
6
6
  RuleTester.afterAll = afterAll;
7
7
  RuleTester.describe = describe;
@@ -1,7 +1,7 @@
1
1
  import { RuleTester } from '@typescript-eslint/rule-tester';
2
2
  import { afterAll, describe, it } from 'vitest';
3
3
 
4
- import rule from '../rules/aws-client-shared-config';
4
+ import rule from '../rules/aws-client-shared-config.js';
5
5
 
6
6
  RuleTester.afterAll = afterAll;
7
7
  RuleTester.describe = describe;
@@ -0,0 +1,57 @@
1
+ import htmlParser from '@html-eslint/parser';
2
+ import { RuleTester } from '@typescript-eslint/rule-tester';
3
+ import { afterAll, describe, it } from 'vitest';
4
+
5
+ import rule from '../rules/html-no-duplicate-id.js';
6
+
7
+ RuleTester.afterAll = afterAll;
8
+ RuleTester.describe = describe;
9
+ RuleTester.it = it;
10
+
11
+ const ruleTester = new RuleTester({
12
+ languageOptions: {
13
+ parser: htmlParser,
14
+ parserOptions: {
15
+ templateEngineSyntax: htmlParser.TEMPLATE_ENGINE_SYNTAX.HANDLEBAR,
16
+ },
17
+ },
18
+ });
19
+
20
+ ruleTester.run('html-no-duplicate-id', rule, {
21
+ valid: [
22
+ {
23
+ code: '<div id="a"></div><div id="b"></div>',
24
+ },
25
+ // Duplicate `id` on `pl-*` elements is a tool/element identifier, not a
26
+ // DOM id, so it should be ignored.
27
+ {
28
+ code: `
29
+ <pl-sketch>
30
+ <pl-sketch-tool id="fd" type="free-draw"></pl-sketch-tool>
31
+ </pl-sketch>
32
+ <pl-sketch>
33
+ <pl-sketch-tool id="fd" type="free-draw"></pl-sketch-tool>
34
+ </pl-sketch>`,
35
+ },
36
+ {
37
+ code: '<div id="question-{{params.name}}"></div><span id="question-{{params.name}}"></span>',
38
+ },
39
+ ],
40
+ invalid: [
41
+ {
42
+ code: '<div id="x"></div><div id="x"></div>',
43
+ errors: [
44
+ { messageId: 'duplicateId', data: { id: 'x' } },
45
+ { messageId: 'duplicateId', data: { id: 'x' } },
46
+ ],
47
+ },
48
+ // Duplicate `id` on non-`pl-*` elements should be reported.
49
+ {
50
+ code: '<div id="fd"></div><span id="fd"></span>',
51
+ errors: [
52
+ { messageId: 'duplicateId', data: { id: 'fd' } },
53
+ { messageId: 'duplicateId', data: { id: 'fd' } },
54
+ ],
55
+ },
56
+ ],
57
+ });
@@ -1,7 +1,7 @@
1
1
  import { RuleTester } from '@typescript-eslint/rule-tester';
2
2
  import { afterAll, describe, it } from 'vitest';
3
3
 
4
- import rule from '../rules/jsx-no-dollar-interpolation';
4
+ import rule from '../rules/jsx-no-dollar-interpolation.js';
5
5
 
6
6
  RuleTester.afterAll = afterAll;
7
7
  RuleTester.describe = describe;
@@ -1,7 +1,7 @@
1
1
  import { RuleTester } from '@typescript-eslint/rule-tester';
2
2
  import { afterAll, describe, it } from 'vitest';
3
3
 
4
- import rule from '../rules/no-hydrate-reslocals';
4
+ import rule from '../rules/no-hydrate-reslocals.js';
5
5
 
6
6
  RuleTester.afterAll = afterAll;
7
7
  RuleTester.describe = describe;