paperlint 10.0.0 → 10.1.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 (54) hide show
  1. package/CONTRIBUTING.md +16 -4
  2. package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
  3. package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
  4. package/dist/eslint-rules/js-needs-reason.js +35 -0
  5. package/dist/eslint-rules/js-needs-reason.js.map +1 -0
  6. package/dist/package-dirs.d.ts +6 -0
  7. package/dist/package-dirs.d.ts.map +1 -1
  8. package/dist/package-dirs.js +6 -0
  9. package/dist/package-dirs.js.map +1 -1
  10. package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
  11. package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
  12. package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
  13. package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
  14. package/docs/skills.md +2 -0
  15. package/docs/testing.md +13 -0
  16. package/eslint-rules/js-needs-reason.test.ts +141 -0
  17. package/eslint-rules/js-needs-reason.ts +77 -0
  18. package/eslint.config.mjs +22 -2
  19. package/lib/skill-trigger-cases.test.ts +211 -0
  20. package/package.json +8 -4
  21. package/scripts/coverage-config.test.ts +5 -3
  22. package/skills/build-benchmark/SKILL.md +4 -1
  23. package/skills/build-benchmark/SKILL.md.spec.ts +3 -0
  24. package/skills/build-label-page/SKILL.md +3 -1
  25. package/skills/build-label-page/SKILL.md.spec.ts +2 -0
  26. package/skills/label-agent-runs/SKILL.md +103 -0
  27. package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
  28. package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
  29. package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
  30. package/skills/label-agent-runs/references/formats.md +64 -0
  31. package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
  32. package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
  33. package/skills/label-agent-runs/scripts/labels.js +396 -0
  34. package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
  35. package/skills/label-agent-runs/scripts/labels.ts +501 -0
  36. package/skills/paper-pipeline/SKILL.md +8 -1
  37. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -0
  38. package/skills/run-agent-grid/SKILL.md +181 -0
  39. package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
  40. package/skills/run-agent-grid/references/failure-catalog.md +36 -0
  41. package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
  42. package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
  43. package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
  44. package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
  45. package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
  46. package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
  47. package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
  48. package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
  49. package/src/package-dirs.ts +8 -0
  50. package/tsconfig.base.json +22 -0
  51. package/tsconfig.json +6 -24
  52. package/tsconfig.pkg.json +3 -1
  53. package/tsconfig.skills.json +14 -0
  54. package/tsconfig.src.json +12 -0
package/CONTRIBUTING.md CHANGED
@@ -132,13 +132,25 @@ site without `npx eslint . --prune-suppressions` fails lint, and #134 burns the
132
132
  New code is TypeScript (#78). The last block of [`eslint.config.mjs`](eslint.config.mjs) makes any
133
133
  `.js`, `.mjs` or `.cjs` file an error in the directories already converted (`TYPESCRIPT_ONLY`), so
134
134
  `npm run lint` fails on one; a directory that still mixes the two is not listed yet, and the change
135
- that converts it adds it. Code that only this repository runs (tests, `scripts/`, `test/e2e/`) is
135
+ that converts it adds it. Everywhere else, a NEW `.js`, `.mjs` or `.cjs` file fails `local/js-needs-reason`
136
+ unless its top comment says `JavaScript, not TypeScript: evidence|fixture|config-loader — <why>`;
137
+ the files that predate the rule are bulk suppressions in `eslint-suppressions.json`, which only
138
+ shrinks. Code that only this repository runs (tests, `scripts/`, `test/e2e/`) is
136
139
  run by `node` directly; code a consumer runs from `node_modules` needs a build first, because Node
137
140
  does not strip types there.
138
141
 
139
- `npm run build` is one `tsc` over two projects into one `dist/`: `tsconfig.json` builds `src/` to
140
- `dist/*.js`, and `tsconfig.pkg.json` builds `lib/` and `eslint-rules/` to `dist/lib/` and
141
- `dist/eslint-rules/`. Everything else imports those two through the package's subpath imports,
142
+ `npm run build` is `tsc -b`: TypeScript's build mode over the project references in the root
143
+ `tsconfig.json`, which is a solution file and compiles nothing itself. The three projects share
144
+ `tsconfig.base.json`: `tsconfig.pkg.json` builds `lib/` and `eslint-rules/` to `dist/lib/` and
145
+ `dist/eslint-rules/`; `tsconfig.src.json` builds `src/` to `dist/*.js` and references the first,
146
+ whose declarations it reads; `tsconfig.skills.json` compiles a skill's scripts
147
+ (`skills/*/scripts/*.ts`) to a `.js` BESIDE each, gitignored and shipped through `files` (their
148
+ declarations go to `dist/skills/`): a skill names its script as `${CLAUDE_SKILL_DIR}/scripts/<name>.js`,
149
+ and only a path inside the skill's own directory resolves the same through the consumer's
150
+ `.claude/skills/<name>` link and in this checkout. A second `tsc -b` with nothing changed is a no-op;
151
+ it decides that from `dist/.tsbuildinfo/`, so an output deleted by hand is rebuilt only by
152
+ `npx tsc -b --force` (or by deleting `dist/` with it). `tsconfig.test.json` is not part of the build:
153
+ it type-checks every `.ts` without emitting. Everything else imports `lib/` and `eslint-rules/` through the package's subpath imports,
142
154
  `#lib/<name>` and `#eslint-rules/<name>` (`"imports"` in `package.json`): the default target is the
143
155
  build, which is what an install runs; the `paperlint-source` condition points at the `.ts` instead,
144
156
  and `npm test` (so `npm run coverage` and `npm run check` too) and `tsconfig.test.json` set it so
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `local/js-needs-reason` — a NEW JavaScript file says, at its top, why it is not TypeScript.
3
+ *
4
+ * New code is TypeScript (#78). `TYPESCRIPT_ONLY` in `eslint.config.mjs` holds that for the
5
+ * directories already converted; everywhere else a new `.mjs` arrived whenever its neighbours were
6
+ * `.mjs`, which is not a reason. This rule closes the rest: a `.js`, `.mjs` or `.cjs` file that is
7
+ * not in the grandfathered list must open with a comment naming one of the accepted reasons:
8
+ *
9
+ * // JavaScript, not TypeScript: evidence — <why> kept byte for byte as it ran
10
+ * // JavaScript, not TypeScript: fixture — <why> checked AS JavaScript by a test
11
+ * // JavaScript, not TypeScript: config-loader — <why> read by a loader that cannot read TS
12
+ *
13
+ * "Smaller", "faster" and "the neighbours are .mjs" are not on the list. Code a consumer runs from
14
+ * `node_modules` is TypeScript too, compiled (`tsconfig.skills.json`, `tsconfig.pkg.json`).
15
+ *
16
+ * The rule reports EVERY JavaScript file without a reason; it keeps no list of its own. The files
17
+ * that existed when it arrived are ESLint bulk suppressions in `eslint-suppressions.json`, which
18
+ * only shrinks: a suppression that no longer matches fails `eslint .` until
19
+ * `--prune-suppressions` removes it. Not "added since the base branch": a rule may not run git
20
+ * (`GIT_IN_A_RULE` in the config — a shallow CI checkout has no base).
21
+ */
22
+ import type { Program } from "estree";
23
+ import type { RuleContext } from "./rule-context.ts";
24
+ interface Comment {
25
+ readonly type: string;
26
+ readonly value: string;
27
+ readonly range: readonly [number, number];
28
+ }
29
+ /** What this rule reads beyond the shared slice: the comments. */
30
+ type Context = RuleContext & {
31
+ readonly sourceCode: {
32
+ getAllComments(): readonly Comment[];
33
+ };
34
+ };
35
+ declare const _default: {
36
+ rules: {
37
+ "js-needs-reason": {
38
+ meta: {
39
+ type: "problem";
40
+ docs: {
41
+ description: string;
42
+ };
43
+ schema: never[];
44
+ messages: {
45
+ noReason: string;
46
+ };
47
+ };
48
+ create(context: Context): {
49
+ Program(node: Readonly<Program>): void;
50
+ };
51
+ };
52
+ };
53
+ };
54
+ export default _default;
55
+ //# sourceMappingURL=js-needs-reason.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"js-needs-reason.d.ts","sourceRoot":"","sources":["../../eslint-rules/js-needs-reason.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAMrD,UAAU,OAAO;IACf,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,kEAAkE;AAClE,KAAK,OAAO,GAAG,WAAW,GAAG;IAC3B,QAAQ,CAAC,UAAU,EAAE;QAAE,cAAc,IAAI,SAAS,OAAO,EAAE,CAAA;KAAE,CAAC;CAC/D,CAAC;;;;;;;;;;;;;;4BA2BoB,OAAO;8BAEL,QAAQ,CAAC,OAAO,CAAC;;;;;AApBzC,wBA8BE"}
@@ -0,0 +1,35 @@
1
+ /** The comment that excuses a JavaScript file, and the reasons it may give. */
2
+ const REASON = /JavaScript, not TypeScript: (?:evidence|fixture|config-loader)\b/;
3
+ /** Does a comment that names an accepted reason come before the first statement? */
4
+ const excused = (comments, codeStart) => comments.some((c) => c.type !== "Shebang" && c.range[0] < codeStart && REASON.test(c.value));
5
+ export default {
6
+ rules: {
7
+ "js-needs-reason": {
8
+ meta: {
9
+ type: "problem",
10
+ docs: {
11
+ description: "a JavaScript file names why it is not TypeScript at its top",
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ noReason: "New code is TypeScript (#78): write this file as .ts, or open it with " +
16
+ "`// JavaScript, not TypeScript: evidence|fixture|config-loader — <why>` " +
17
+ "(evidence kept exactly as it ran; a fixture checked as JavaScript; a config a loader " +
18
+ "that cannot read TypeScript reads).",
19
+ },
20
+ },
21
+ create(context) {
22
+ return {
23
+ Program(node) {
24
+ const starts = node.body.map((s) => Number(s.range?.[0]));
25
+ const codeStart = Math.min(Infinity, ...starts);
26
+ if (excused(context.sourceCode.getAllComments(), codeStart))
27
+ return;
28
+ context.report({ node, messageId: "noReason" });
29
+ },
30
+ };
31
+ },
32
+ },
33
+ },
34
+ };
35
+ //# sourceMappingURL=js-needs-reason.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"js-needs-reason.js","sourceRoot":"","sources":["../../eslint-rules/js-needs-reason.ts"],"names":[],"mappings":"AAwBA,+EAA+E;AAC/E,MAAM,MAAM,GACV,kEAAkE,CAAC;AAarE,oFAAoF;AACpF,MAAM,OAAO,GAAG,CAAC,QAA4B,EAAE,SAAiB,EAAW,EAAE,CAC3E,QAAQ,CAAC,IAAI,CACX,CAAC,CAAC,EAAE,EAAE,CACJ,CAAC,CAAC,IAAI,KAAK,SAAS,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CACzE,CAAC;AAEJ,eAAe;IACb,KAAK,EAAE;QACL,iBAAiB,EAAE;YACjB,IAAI,EAAE;gBACJ,IAAI,EAAE,SAAkB;gBACxB,IAAI,EAAE;oBACJ,WAAW,EACT,6DAA6D;iBAChE;gBACD,MAAM,EAAE,EAAE;gBACV,QAAQ,EAAE;oBACR,QAAQ,EACN,wEAAwE;wBACxE,0EAA0E;wBAC1E,uFAAuF;wBACvF,qCAAqC;iBACxC;aACF;YACD,MAAM,CAAC,OAAgB;gBACrB,OAAO;oBACL,OAAO,CAAC,IAAuB;wBAC7B,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;wBAC1D,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,MAAM,CAAC,CAAC;wBAChD,IAAI,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,cAAc,EAAE,EAAE,SAAS,CAAC;4BAAE,OAAO;wBACpE,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;oBAClD,CAAC;iBACF,CAAC;YACJ,CAAC;SACF;KACF;CACF,CAAC"}
@@ -1,5 +1,11 @@
1
1
  /** The shipped venue presets and `venue-profile.schema.json`. */
2
2
  export declare const presetsDir: () => string;
3
+ /**
4
+ * This checkout's own Claude Code directory, `.claude/` at the package root: the links to the
5
+ * skills that Claude Code loads while working on this repository. For the harnesses, which run
6
+ * only here; an install has no such directory.
7
+ */
8
+ export declare const claudeDir: () => string;
3
9
  /** The LaTeX inputs a paper `\input`s by name (`paper-guards`, a venue's numbers). */
4
10
  export declare const texInputsDir: () => string;
5
11
  /** Where an `\input` in the paper in `paperDir` is looked for, first to last. */
@@ -1 +1 @@
1
- {"version":3,"file":"package-dirs.d.ts","sourceRoot":"","sources":["../src/package-dirs.ts"],"names":[],"mappings":"AAcA,iEAAiE;AACjE,eAAO,MAAM,UAAU,QAAO,MACyB,CAAC;AAExD,sFAAsF;AACtF,eAAO,MAAM,YAAY,QAAO,MAC2B,CAAC;AAE5D,iFAAiF;AACjF,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,KAAG,SAAS,MAAM,EAG/D,CAAC"}
1
+ {"version":3,"file":"package-dirs.d.ts","sourceRoot":"","sources":["../src/package-dirs.ts"],"names":[],"mappings":"AAcA,iEAAiE;AACjE,eAAO,MAAM,UAAU,QAAO,MACyB,CAAC;AAExD;;;;GAIG;AACH,eAAO,MAAM,SAAS,QAAO,MAC0B,CAAC;AAExD,sFAAsF;AACtF,eAAO,MAAM,YAAY,QAAO,MAC2B,CAAC;AAE5D,iFAAiF;AACjF,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,KAAG,SAAS,MAAM,EAG/D,CAAC"}
@@ -13,6 +13,12 @@
13
13
  import { fileURLToPath } from "node:url";
14
14
  /** The shipped venue presets and `venue-profile.schema.json`. */
15
15
  export const presetsDir = () => fileURLToPath(new URL("../presets", import.meta.url));
16
+ /**
17
+ * This checkout's own Claude Code directory, `.claude/` at the package root: the links to the
18
+ * skills that Claude Code loads while working on this repository. For the harnesses, which run
19
+ * only here; an install has no such directory.
20
+ */
21
+ export const claudeDir = () => fileURLToPath(new URL("../.claude", import.meta.url));
16
22
  /** The LaTeX inputs a paper `\input`s by name (`paper-guards`, a venue's numbers). */
17
23
  export const texInputsDir = () => fileURLToPath(new URL("../presets/tex", import.meta.url));
18
24
  /** Where an `\input` in the paper in `paperDir` is looked for, first to last. */
@@ -1 +1 @@
1
- {"version":3,"file":"package-dirs.js","sourceRoot":"","sources":["../src/package-dirs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,iEAAiE;AACjE,MAAM,CAAC,MAAM,UAAU,GAAG,GAAW,EAAE,CACrC,aAAa,CAAC,IAAI,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAExD,sFAAsF;AACtF,MAAM,CAAC,MAAM,YAAY,GAAG,GAAW,EAAE,CACvC,aAAa,CAAC,IAAI,GAAG,CAAC,gBAAgB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE5D,iFAAiF;AACjF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,QAAgB,EAAqB,EAAE,CAAC;IACpE,QAAQ;IACR,YAAY,EAAE;CACf,CAAC"}
1
+ {"version":3,"file":"package-dirs.js","sourceRoot":"","sources":["../src/package-dirs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,iEAAiE;AACjE,MAAM,CAAC,MAAM,UAAU,GAAG,GAAW,EAAE,CACrC,aAAa,CAAC,IAAI,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAExD;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,GAAW,EAAE,CACpC,aAAa,CAAC,IAAI,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAExD,sFAAsF;AACtF,MAAM,CAAC,MAAM,YAAY,GAAG,GAAW,EAAE,CACvC,aAAa,CAAC,IAAI,GAAG,CAAC,gBAAgB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE5D,iFAAiF;AACjF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,QAAgB,EAAqB,EAAE,CAAC;IACpE,QAAQ;IACR,YAAY,EAAE;CACf,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The subcommands of `labels`, by name. One table, read twice: the dispatcher in `labels.ts`
3
+ * compares against these, and `SKILL.md.spec.ts` interpolates them into every command it
4
+ * instructs (a name that is not here is a type error there). So the skill cannot instruct a
5
+ * subcommand the script does not have.
6
+ *
7
+ * A module of its own, with no side effects: the spec imports it at compile time, and importing
8
+ * `labels.ts` itself would run the command line.
9
+ */
10
+ export declare const COMMANDS: {
11
+ readonly validate: "validate";
12
+ readonly agree: "agree";
13
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The subcommands of `grid-guard`, by name. One table, read twice: the dispatcher in
3
+ * `grid-guard.ts` is keyed by these names (a missing or extra entry is a type error there), and
4
+ * `SKILL.md.spec.ts` interpolates them into every command it instructs (a name that is not here
5
+ * is a type error there). So the skill cannot instruct a subcommand the script does not have.
6
+ *
7
+ * A module of its own, with no side effects: the spec imports it at compile time, and importing
8
+ * `grid-guard.ts` itself would run the command line.
9
+ */
10
+ export declare const COMMANDS: {
11
+ readonly lock: "lock";
12
+ readonly release: "release";
13
+ readonly gate: "gate";
14
+ readonly overlap: "overlap";
15
+ readonly envProbe: "env-probe";
16
+ readonly scanKeys: "scan-keys";
17
+ readonly precommit: "precommit";
18
+ };
19
+ /** A subcommand's name as typed on the command line. */
20
+ export type CommandName = (typeof COMMANDS)[keyof typeof COMMANDS];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/docs/skills.md CHANGED
@@ -41,6 +41,8 @@ Where each stage writes, and the check that guards each place: [`layout.md`](lay
41
41
  | you get | skill |
42
42
  | ------------------------------------------------------------------------------------------ | ------------------ |
43
43
  | a study design with honest statistics, and a reproduction artifact that checks its numbers | `build-benchmark` |
44
+ | the grid of agent runs executed: gated on an approved registry, locked, resumable, audited | `run-agent-grid` |
45
+ | the outcome of every agent run labelled: codebook first, hand labels, a blind second rater | `label-agent-runs` |
44
46
  | a page you label items on from a phone, and the answers exported into the study folder | `build-label-page` |
45
47
 
46
48
  ## 4 · Draft
package/docs/testing.md CHANGED
@@ -61,6 +61,19 @@ When in doubt between unit and integration: if the function takes a port (`Files
61
61
  - **Skills are tested through vigiles**, not a bespoke runner: a home-grown runner here once printed
62
62
  byte-identical "clean" verdicts for three skills that had never loaded.
63
63
 
64
+ ## Paid — a skill's trigger eval, `skills/<name>/<name>.eval.ts`
65
+
66
+ - **Answers:** does the skill's description fire on its own requests, and stay quiet on a
67
+ sibling's? A real model reads every installed description, so it costs money and is not CI.
68
+ - **Lives:** beside the skill, one file. It default-exports
69
+ `defineEval({ measureTriggerRate: { name, skillsDir, prompts, irrelevantPrompts, fired, … } })`
70
+ from vigiles, with the prompts inline; a sibling's own requests may be imported from the
71
+ sibling's eval file. `vigiles eval <file>` discovers and runs it; nothing runs at import.
72
+ - **The old shape is frozen:** older skills still read their prompts from the shared table in
73
+ `lib/skill-trigger-cases.mjs` through `lib/skill-eval-kit.mjs`. `lib/skill-trigger-cases.test.ts`
74
+ fails on a new row there, and on a skill that is in both places; the table goes away in #218.
75
+ - **Example:** `skills/run-agent-grid/run-agent-grid.eval.ts`.
76
+
64
77
  ## End-to-end — `test/e2e/<area>/*.e2e.ts`
65
78
 
66
79
  - **Answers:** does the package work once it is somewhere else? The tarball installed into another
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `local/js-needs-reason`: both halves on planted files, through ESLint's own `Linter`, then the
3
+ * real configuration on a planted new file, then an existing file through the repository's bulk
4
+ * suppressions (`eslint-suppressions.json`), with and without them.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { spawnSync } from "node:child_process";
8
+ import { join } from "node:path";
9
+ import { ESLint, Linter } from "eslint";
10
+ import { test } from "vitest";
11
+ import { z } from "zod";
12
+ import { useTempDir, writeTree } from "../test/support.ts";
13
+ import rules from "./js-needs-reason.ts";
14
+
15
+ const ROOT = join(import.meta.dirname, "..");
16
+
17
+ /** The module as ESLint's `Plugin` type: its rules are typed against `rule-context.ts`, not ESLint's. */
18
+ const isPlugin = (p: unknown): p is ESLint.Plugin =>
19
+ typeof p === "object" && p !== null && "rules" in p;
20
+ const plugin: ESLint.Plugin = isPlugin(rules)
21
+ ? rules
22
+ : assert.fail("js-needs-reason.ts does not export a plugin");
23
+ const MESSAGE =
24
+ "New code is TypeScript (#78): write this file as .ts, or open it with " +
25
+ "`// JavaScript, not TypeScript: evidence|fixture|config-loader — <why>` " +
26
+ "(evidence kept exactly as it ran; a fixture checked as JavaScript; a config a loader " +
27
+ "that cannot read TypeScript reads).";
28
+
29
+ /** The rule's messages for `code` as the file `rel`. */
30
+ const lint = (rel: string, code: string) =>
31
+ new Linter({ cwd: ROOT })
32
+ .verify(
33
+ code,
34
+ [
35
+ {
36
+ files: ["**/*.{js,mjs,cjs}"],
37
+ plugins: { local: plugin },
38
+ rules: {
39
+ "local/js-needs-reason": "error",
40
+ },
41
+ },
42
+ ],
43
+ { filename: join(ROOT, rel) },
44
+ )
45
+ .map((m) => ({ rule: m.ruleId, line: m.line, message: m.message }));
46
+ const FIRES = [{ rule: "local/js-needs-reason", line: 1, message: MESSAGE }];
47
+
48
+ test("a new .mjs without a reason fails; .js and .cjs too", () => {
49
+ assert.deepEqual(lint("scripts/new.mjs", "export const x = 1;\n"), FIRES);
50
+ assert.deepEqual(lint("scripts/new.js", "export const x = 1;\n"), FIRES);
51
+ assert.deepEqual(lint("scripts/new.cjs", "module.exports = 1;\n"), FIRES);
52
+ // An empty file has no statement to come before, and still names no reason.
53
+ assert.deepEqual(lint("scripts/empty.mjs", ""), FIRES);
54
+ });
55
+
56
+ test("a new file that names an accepted reason at its top passes", () => {
57
+ for (const reason of ["evidence", "fixture", "config-loader"])
58
+ assert.deepEqual(
59
+ lint(
60
+ "scripts/new.mjs",
61
+ `// JavaScript, not TypeScript: ${reason} — why\nexport const x = 1;\n`,
62
+ ),
63
+ [],
64
+ );
65
+ assert.deepEqual(
66
+ lint(
67
+ "bin/new.mjs",
68
+ "#!/usr/bin/env node\n/**\n * JavaScript, not TypeScript: evidence — kept as it ran.\n */\nexport const x = 1;\n",
69
+ ),
70
+ [],
71
+ );
72
+ });
73
+
74
+ test("a reason that is not on the list, or that comes after the code, fails", () => {
75
+ assert.deepEqual(
76
+ lint(
77
+ "scripts/new.mjs",
78
+ "// JavaScript, not TypeScript: smaller — fewer bytes\nexport const x = 1;\n",
79
+ ),
80
+ FIRES,
81
+ );
82
+ assert.deepEqual(
83
+ lint(
84
+ "scripts/new.mjs",
85
+ "export const x = 1;\n// JavaScript, not TypeScript: fixture — late\n",
86
+ ),
87
+ FIRES,
88
+ );
89
+ });
90
+
91
+ /** The repository's own `eslint` CLI on one file: exit code and this rule's findings. */
92
+ function cli(
93
+ file: string,
94
+ extra: readonly string[] = [],
95
+ ): { status: number | null; findings: number } {
96
+ const r = spawnSync(
97
+ process.execPath,
98
+ [
99
+ join(ROOT, "node_modules", "eslint", "bin", "eslint.js"),
100
+ "--format",
101
+ "json",
102
+ ...extra,
103
+ file,
104
+ ],
105
+ { cwd: ROOT, encoding: "utf8" },
106
+ );
107
+ const findings = z
108
+ .array(
109
+ z.object({
110
+ messages: z.array(z.object({ ruleId: z.string().nullable() })),
111
+ }),
112
+ )
113
+ .parse(JSON.parse(r.stdout))
114
+ .flatMap((f) => f.messages)
115
+ .filter((m) => m.ruleId === "local/js-needs-reason").length;
116
+ return { status: r.status, findings };
117
+ }
118
+ const WORK = useTempDir("js-needs-reason-");
119
+
120
+ test("an existing JavaScript file is suppressed by eslint-suppressions.json, and only by it", () => {
121
+ const old = join(ROOT, "scripts", "rules-see-files.mjs");
122
+ assert.deepEqual(cli(old), { status: 0, findings: 0 });
123
+ const none = writeTree(WORK, { "none.json": "{}\n" });
124
+ assert.deepEqual(
125
+ cli(old, ["--suppressions-location", join(none, "none.json")]),
126
+ { status: 1, findings: 1 },
127
+ );
128
+ }, 120_000);
129
+
130
+ test("the repository's configuration runs the rule on a new file", async () => {
131
+ const eslint = new ESLint({ cwd: ROOT });
132
+ const found = (
133
+ await eslint.lintText("export const x = 1;\n", {
134
+ filePath: join(ROOT, "scripts", "planted-new-file.mjs"),
135
+ })
136
+ ).flatMap((r) => r.messages.map((m) => m.ruleId));
137
+ assert.deepEqual(
138
+ found.filter((id) => id === "local/js-needs-reason"),
139
+ ["local/js-needs-reason"],
140
+ );
141
+ }, 120_000);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `local/js-needs-reason` — a NEW JavaScript file says, at its top, why it is not TypeScript.
3
+ *
4
+ * New code is TypeScript (#78). `TYPESCRIPT_ONLY` in `eslint.config.mjs` holds that for the
5
+ * directories already converted; everywhere else a new `.mjs` arrived whenever its neighbours were
6
+ * `.mjs`, which is not a reason. This rule closes the rest: a `.js`, `.mjs` or `.cjs` file that is
7
+ * not in the grandfathered list must open with a comment naming one of the accepted reasons:
8
+ *
9
+ * // JavaScript, not TypeScript: evidence — <why> kept byte for byte as it ran
10
+ * // JavaScript, not TypeScript: fixture — <why> checked AS JavaScript by a test
11
+ * // JavaScript, not TypeScript: config-loader — <why> read by a loader that cannot read TS
12
+ *
13
+ * "Smaller", "faster" and "the neighbours are .mjs" are not on the list. Code a consumer runs from
14
+ * `node_modules` is TypeScript too, compiled (`tsconfig.skills.json`, `tsconfig.pkg.json`).
15
+ *
16
+ * The rule reports EVERY JavaScript file without a reason; it keeps no list of its own. The files
17
+ * that existed when it arrived are ESLint bulk suppressions in `eslint-suppressions.json`, which
18
+ * only shrinks: a suppression that no longer matches fails `eslint .` until
19
+ * `--prune-suppressions` removes it. Not "added since the base branch": a rule may not run git
20
+ * (`GIT_IN_A_RULE` in the config — a shallow CI checkout has no base).
21
+ */
22
+ import type { Program } from "estree";
23
+ import type { RuleContext } from "./rule-context.ts";
24
+
25
+ /** The comment that excuses a JavaScript file, and the reasons it may give. */
26
+ const REASON =
27
+ /JavaScript, not TypeScript: (?:evidence|fixture|config-loader)\b/;
28
+
29
+ interface Comment {
30
+ readonly type: string;
31
+ readonly value: string;
32
+ readonly range: readonly [number, number];
33
+ }
34
+
35
+ /** What this rule reads beyond the shared slice: the comments. */
36
+ type Context = RuleContext & {
37
+ readonly sourceCode: { getAllComments(): readonly Comment[] };
38
+ };
39
+
40
+ /** Does a comment that names an accepted reason come before the first statement? */
41
+ const excused = (comments: readonly Comment[], codeStart: number): boolean =>
42
+ comments.some(
43
+ (c) =>
44
+ c.type !== "Shebang" && c.range[0] < codeStart && REASON.test(c.value),
45
+ );
46
+
47
+ export default {
48
+ rules: {
49
+ "js-needs-reason": {
50
+ meta: {
51
+ type: "problem" as const,
52
+ docs: {
53
+ description:
54
+ "a JavaScript file names why it is not TypeScript at its top",
55
+ },
56
+ schema: [],
57
+ messages: {
58
+ noReason:
59
+ "New code is TypeScript (#78): write this file as .ts, or open it with " +
60
+ "`// JavaScript, not TypeScript: evidence|fixture|config-loader — <why>` " +
61
+ "(evidence kept exactly as it ran; a fixture checked as JavaScript; a config a loader " +
62
+ "that cannot read TypeScript reads).",
63
+ },
64
+ },
65
+ create(context: Context) {
66
+ return {
67
+ Program(node: Readonly<Program>) {
68
+ const starts = node.body.map((s) => Number(s.range?.[0]));
69
+ const codeStart = Math.min(Infinity, ...starts);
70
+ if (excused(context.sourceCode.getAllComments(), codeStart)) return;
71
+ context.report({ node, messageId: "noReason" });
72
+ },
73
+ };
74
+ },
75
+ },
76
+ },
77
+ };
package/eslint.config.mjs CHANGED
@@ -14,7 +14,9 @@
14
14
  import { texLanguage } from "#eslint-rules/latex-language";
15
15
  import texBuild from "#eslint-rules/tex-build";
16
16
  import markdown from "@eslint/markdown";
17
- import localRules from "#eslint-rules/temp-root-realpath";
17
+ import tempRootRules from "#eslint-rules/temp-root-realpath";
18
+ import jsReasonRules from "#eslint-rules/js-needs-reason";
19
+
18
20
  import portRules from "#eslint-rules/install-path-literals";
19
21
  import n from "eslint-plugin-n";
20
22
  import tseslint from "typescript-eslint";
@@ -30,6 +32,11 @@ import { nodeFiles } from "#src/adapters/node/index";
30
32
  import { dirname, join } from "node:path";
31
33
  import { fileURLToPath } from "node:url";
32
34
 
35
+ /** This repository's own rules, one plugin: `local/<rule>`. */
36
+ const localRules = {
37
+ rules: { ...tempRootRules.rules, ...jsReasonRules.rules },
38
+ };
39
+
33
40
  // The complexity set, shared by the TypeScript block and the ratchet below. Every function
34
41
  // measured over these limits on 2026-09-24 was either refactored under them (the #59 build code)
35
42
  // or pinned at its current maximum in RATCHET.
@@ -541,13 +548,16 @@ export default [
541
548
  // them invites the next reader to tidy an unused import — and then the file on disk is no longer
542
549
  // the file that produced the number it backs.
543
550
  // `dist/` is tsc's OUTPUT (gitignored): its declaration files are generated from `src/`, so
544
- // linting them would report every finding twice and blame a file nobody edits.
551
+ // linting them would report every finding twice and blame a file nobody edits. The same holds for
552
+ // a skill's compiled scripts (`skills/*/scripts/*.js`, gitignored, from tsconfig.skills.json): the
553
+ // `.ts` beside each is what is linted.
545
554
  {
546
555
  ignores: [
547
556
  ".tmp-stages-src-*/",
548
557
  "fixtures/.tmp-*/",
549
558
  "docs/prior-art/repro/",
550
559
  "dist/",
560
+ "skills/*/scripts/*.js",
551
561
  ],
552
562
  },
553
563
  // 🔴 THE PACKAGE'S TYPESCRIPT, and before 2026-09-24 no block matched it — the same silent
@@ -872,6 +882,16 @@ export default [
872
882
  ],
873
883
  },
874
884
  },
885
+ // A NEW JavaScript file names why it is not TypeScript (#78), anywhere in the repository. The
886
+ // files that existed when the rule arrived are bulk suppressions in eslint-suppressions.json,
887
+ // which only shrinks (eslint-rules/js-needs-reason.ts).
888
+ {
889
+ files: ["**/*.{js,mjs,cjs}"],
890
+ plugins: { local: localRules },
891
+ rules: {
892
+ "local/js-needs-reason": "error",
893
+ },
894
+ },
875
895
  // LAST, so no block above can replace its `no-restricted-syntax` options for these files (none
876
896
  // sets that rule for JavaScript today; being last keeps it that way).
877
897
  {