@jterrazz/typescript 9.2.1 → 10.0.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 (71) hide show
  1. package/README.md +20 -16
  2. package/bin/commands/check.sh +348 -117
  3. package/bin/typescript.sh +55 -0
  4. package/lib/check-architecture.js +89 -0
  5. package/lib/check-baseline.js +144 -0
  6. package/lib/check-docs.js +4 -3
  7. package/lib/check-drift.js +209 -0
  8. package/lib/check-gitignore.js +4 -4
  9. package/lib/check-markdown.js +279 -0
  10. package/lib/check-names.js +125 -0
  11. package/lib/check-publish.js +150 -0
  12. package/lib/check-secrets.js +115 -0
  13. package/lib/check-suppressions.js +355 -0
  14. package/lib/doctor.js +185 -0
  15. package/lib/merge-knip-config.js +57 -25
  16. package/lib/tracked-files.js +165 -0
  17. package/lib/workspace-members.js +5 -6
  18. package/package.json +19 -8
  19. package/presets/oxfmt/index.js +49 -5
  20. package/presets/oxlint/profiles/astro.js +10 -0
  21. package/presets/oxlint/profiles/bun.js +7 -0
  22. package/presets/oxlint/profiles/expo.js +7 -0
  23. package/presets/oxlint/profiles/library.js +16 -0
  24. package/presets/oxlint/profiles/next.js +7 -0
  25. package/presets/oxlint/profiles/node.js +7 -0
  26. package/presets/prettier/astro.json +6 -0
  27. package/presets/tsconfig/expo.json +16 -6
  28. package/presets/tsconfig/library.json +18 -0
  29. package/presets/tsconfig/next.json +12 -2
  30. package/presets/tsconfig/node.json +18 -4
  31. package/rules/README.md +23 -0
  32. package/rules/_contract.js +191 -0
  33. package/rules/_contract.test.ts +81 -0
  34. package/rules/a11y.js +51 -0
  35. package/rules/architecture/hexagonal.js +56 -0
  36. package/rules/architecture/layers.js +75 -0
  37. package/rules/astro.js +49 -0
  38. package/rules/catalog.js +134 -0
  39. package/rules/catalog.test.ts +84 -0
  40. package/rules/compile.js +125 -0
  41. package/rules/core/eslint.js +234 -0
  42. package/rules/core/import.js +107 -0
  43. package/rules/core/jsdoc.js +52 -0
  44. package/rules/core/node.js +36 -0
  45. package/rules/core/oxc.js +54 -0
  46. package/rules/core/promise.js +39 -0
  47. package/rules/core/typescript.js +204 -0
  48. package/rules/core/unicorn.js +200 -0
  49. package/rules/next.js +53 -0
  50. package/rules/profiles.js +89 -0
  51. package/rules/react-native.js +48 -0
  52. package/rules/react.js +148 -0
  53. package/rules/sorted.js +41 -0
  54. package/rules/vitest.js +153 -0
  55. package/src/docs.d.ts +4 -4
  56. package/src/docs.js +75 -47
  57. package/src/docs.test.ts +136 -29
  58. package/src/index.d.ts +13 -9
  59. package/src/index.js +15 -8
  60. package/src/oxfmt.d.ts +15 -2
  61. package/src/oxfmt.test.ts +10 -0
  62. package/src/oxlint.d.ts +57 -10
  63. package/src/oxlint.js +35 -50
  64. package/src/oxlint.test.ts +82 -28
  65. package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
  66. package/presets/oxlint/architectures/hexagonal.js +0 -13
  67. package/presets/oxlint/base.js +0 -145
  68. package/presets/oxlint/expo.js +0 -36
  69. package/presets/oxlint/next.js +0 -43
  70. package/presets/oxlint/node.js +0 -14
  71. package/presets/oxlint/plugins/codestyle.js +0 -231
@@ -0,0 +1,48 @@
1
+ import { fragment, on } from './_contract.js';
2
+ import { EXTENSIONS_NEVER } from './core/import.js';
3
+
4
+ /*
5
+ * React Native. oxlint 1.83 ships no `react-native` plugin, so this fragment
6
+ * is not a plugin roster: it is the three decisions the platform forces, plus
7
+ * the globals its runtime defines.
8
+ *
9
+ * Metro resolves an import, so a specifier carries no extension — except an
10
+ * asset, which Metro resolves BY its extension, and `require()` is how an
11
+ * asset is named in a React Native tree.
12
+ */
13
+ export default fragment({
14
+ id: 'react-native',
15
+ globals: {
16
+ __DEV__: 'readonly',
17
+ ErrorUtils: 'readonly',
18
+ FormData: 'readonly',
19
+ XMLHttpRequest: 'readonly',
20
+ fetch: 'readonly',
21
+ requestAnimationFrame: 'readonly',
22
+ },
23
+ ignorePatterns: ['.expo/**', 'assets/**', 'ios/**', 'android/**'],
24
+ rules: {
25
+ 'import/extensions': EXTENSIONS_NEVER,
26
+ 'no-restricted-imports': on([
27
+ {
28
+ patterns: [
29
+ {
30
+ group: ['react-dom', 'react-dom/*', 'next', 'next/*'],
31
+ message: 'this is a React Native tree — the DOM and Next are not on it',
32
+ },
33
+ ],
34
+ },
35
+ ]),
36
+ 'typescript/no-require-imports': on([
37
+ {
38
+ allow: [
39
+ String.raw`\.gif$`,
40
+ String.raw`\.jpeg$`,
41
+ String.raw`\.jpg$`,
42
+ String.raw`\.png$`,
43
+ String.raw`\.webp$`,
44
+ ],
45
+ },
46
+ ]),
47
+ },
48
+ });
package/rules/react.js ADDED
@@ -0,0 +1,148 @@
1
+ import { allOn, fragment, off, on } from './_contract.js';
2
+
3
+ /*
4
+ * The `react` and `react-perf` plugins, all 88 non-nursery rules decided by
5
+ * name — the hooks rules, the React Compiler rules (purity, immutability,
6
+ * static-components, preserve-manual-memoization…) and the class-era
7
+ * correctness rules alike.
8
+ */
9
+ export default fragment({
10
+ id: 'react',
11
+ plugins: ['react', 'react-perf'],
12
+ rules: {
13
+ ...allOn(
14
+ [
15
+ 'button-has-type',
16
+ 'capitalized-calls',
17
+ 'checked-requires-onchange-or-readonly',
18
+ 'display-name',
19
+ 'error-boundaries',
20
+ 'exhaustive-deps',
21
+ 'exhaustive-effect-dependencies',
22
+ 'forbid-dom-props',
23
+ 'forbid-elements',
24
+ 'forward-ref-uses-ref',
25
+ 'globals',
26
+ 'hook-use-state',
27
+ 'hooks',
28
+ 'iframe-missing-sandbox',
29
+ 'immutability',
30
+ 'incompatible-library',
31
+ 'invariant',
32
+ 'jsx-boolean-value',
33
+ 'jsx-curly-brace-presence',
34
+ 'jsx-fragments',
35
+ 'jsx-handler-names',
36
+ 'jsx-key',
37
+ 'jsx-no-comment-textnodes',
38
+ 'jsx-no-constructed-context-values',
39
+ 'jsx-no-duplicate-props',
40
+ 'jsx-no-script-url',
41
+ 'jsx-no-target-blank',
42
+ 'jsx-no-undef',
43
+ 'jsx-no-useless-fragment',
44
+ 'jsx-pascal-case',
45
+ 'jsx-props-no-spread-multi',
46
+ 'memo-dependencies',
47
+ 'no-array-index-key',
48
+ 'no-children-prop',
49
+ 'no-clone-element',
50
+ 'no-danger',
51
+ 'no-danger-with-children',
52
+ 'no-deriving-state-in-effects',
53
+ 'no-did-mount-set-state',
54
+ 'no-did-update-set-state',
55
+ 'no-direct-mutation-state',
56
+ 'no-find-dom-node',
57
+ 'no-is-mounted',
58
+ 'no-namespace',
59
+ 'no-object-type-as-default-prop',
60
+ 'no-react-children',
61
+ 'no-redundant-should-component-update',
62
+ 'no-render-return-value',
63
+ 'no-set-state',
64
+ 'no-string-refs',
65
+ 'no-this-in-sfc',
66
+ 'no-unescaped-entities',
67
+ 'no-unsafe',
68
+ 'no-unstable-nested-components',
69
+ 'no-will-update-set-state',
70
+ 'prefer-es6-class',
71
+ 'prefer-function-component',
72
+ 'preserve-manual-memoization',
73
+ 'purity',
74
+ 'refs',
75
+ 'rule-suppression',
76
+ 'rules-of-hooks',
77
+ 'self-closing-comp',
78
+ 'set-state-in-effect',
79
+ 'set-state-in-render',
80
+ 'state-in-constructor',
81
+ 'static-components',
82
+ 'style-prop-object',
83
+ 'syntax',
84
+ 'todo',
85
+ 'unsupported-syntax',
86
+ 'use-memo',
87
+ 'void-dom-elements-no-children',
88
+ 'void-use-memo',
89
+ ].map((rule) => `react/${rule}`),
90
+ ),
91
+
92
+ // -- On, at the value the estate's own shape asks for -------------------
93
+ 'react/function-component-definition': on([
94
+ { namedComponents: 'function-declaration', unnamedComponents: 'arrow-function' },
95
+ ]),
96
+ 'react/jsx-filename-extension': on([{ extensions: ['.jsx', '.tsx'] }]),
97
+
98
+ // -- Off, each with its one reason -------------------------------------
99
+ 'react-perf/jsx-no-jsx-as-prop': off({
100
+ by: 'react/static-components, react/use-memo — the React Compiler rules decide memoisation, and an inline prop is the idiom they are written for',
101
+ kind: 'covered',
102
+ }),
103
+ 'react-perf/jsx-no-new-array-as-prop': off({
104
+ by: 'react/static-components, react/use-memo — the React Compiler rules decide memoisation, and an inline prop is the idiom they are written for',
105
+ kind: 'covered',
106
+ }),
107
+ 'react-perf/jsx-no-new-function-as-prop': off({
108
+ by: 'react/static-components, react/use-memo — the React Compiler rules decide memoisation, and an inline prop is the idiom they are written for',
109
+ kind: 'covered',
110
+ }),
111
+ 'react-perf/jsx-no-new-object-as-prop': off({
112
+ by: 'react/static-components, react/use-memo — the React Compiler rules decide memoisation, and an inline prop is the idiom they are written for',
113
+ kind: 'covered',
114
+ }),
115
+ 'react/forbid-component-props': off({
116
+ by: 'docs/07-lint-presets.md — className is how a component takes its styling, and the rule forbids it by default',
117
+ kind: 'convention',
118
+ }),
119
+ 'react/jsx-max-depth': off({
120
+ by: 'max-depth (4) — nesting depth is one property, and one rule owns it',
121
+ kind: 'covered',
122
+ }),
123
+ 'react/jsx-no-literals': off({
124
+ by: 'docs/07-lint-presets.md — the estate has no translation-extraction convention that would need every string hoisted',
125
+ kind: 'convention',
126
+ }),
127
+ 'react/jsx-props-no-spreading': off({
128
+ by: 'docs/07-lint-presets.md — a wrapper component forwards its props by spread',
129
+ kind: 'convention',
130
+ }),
131
+ 'react/no-multi-comp': off({
132
+ by: 'docs/07-lint-presets.md — a file holds a component and the small private pieces only it uses',
133
+ kind: 'convention',
134
+ }),
135
+ 'react/no-unknown-property': off({
136
+ by: 'TypeScript — a JSX attribute is checked against the element props type, which knows the framework attributes this rule does not',
137
+ kind: 'covered',
138
+ }),
139
+ 'react/only-export-components': off({
140
+ by: 'docs/07-lint-presets.md — a Next route file exports its metadata beside its component',
141
+ kind: 'convention',
142
+ }),
143
+ 'react/react-in-jsx-scope': off({
144
+ by: 'presets/tsconfig/next.json — the automatic JSX runtime (jsx: react-jsx) imports it',
145
+ kind: 'covered',
146
+ }),
147
+ },
148
+ });
@@ -0,0 +1,41 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import { fragment, off, on } from './_contract.js';
4
+
5
+ /*
6
+ * Sorting is formatting. oxfmt owns import order (`sortImports`), package.json
7
+ * key order (`sortPackageJson`) and Tailwind class order (`sortTailwindcss`);
8
+ * what is left is what oxfmt does not sort, and that is this fragment.
9
+ *
10
+ * Measured against oxfmt 0.68 on a fixture: `sortImports` reorders import
11
+ * STATEMENTS and leaves the named specifiers inside one statement alone, and
12
+ * it touches no type union, no JSX attribute and no heritage clause. Those
13
+ * five are perfectionist's, and nothing else is.
14
+ *
15
+ * perfectionist reaches oxlint through the JS-plugin bridge, so the consumer
16
+ * never declares it — `createRequire` resolves it from THIS package, where it
17
+ * is a real dependency ([Developing](../docs/02-developing.md)).
18
+ */
19
+
20
+ const require = createRequire(import.meta.url);
21
+
22
+ /** Natural order: `item2` before `item10`, which alphabetical order gets wrong. */
23
+ const NATURAL = [{ type: 'natural' }];
24
+
25
+ export default fragment({
26
+ id: 'sorted',
27
+ jsPlugins: [require.resolve('eslint-plugin-perfectionist')],
28
+ rules: {
29
+ 'perfectionist/sort-heritage-clauses': on(NATURAL),
30
+ 'perfectionist/sort-intersection-types': on(NATURAL),
31
+ 'perfectionist/sort-jsx-props': on(NATURAL),
32
+ 'perfectionist/sort-named-exports': on(NATURAL),
33
+ 'perfectionist/sort-named-imports': on(NATURAL),
34
+ 'perfectionist/sort-union-types': on(NATURAL),
35
+
36
+ 'perfectionist/sort-imports': off({
37
+ by: 'oxfmt sortImports — the formatter reorders import statements, and two tools rewriting the same bytes fight',
38
+ kind: 'formatter',
39
+ }),
40
+ },
41
+ });
@@ -0,0 +1,153 @@
1
+ import { allOn, fragment, off, on, scoped } from './_contract.js';
2
+
3
+ /*
4
+ * The `vitest` plugin, all 73 rules decided by name — inside an `overrides`
5
+ * block, because every one of them reads a test file and says nothing about
6
+ * anything else. The globs are the test shapes the estate writes:
7
+ * `<name>.test.ts`, `<name>.spec.ts`, their type-test variants, and every file
8
+ * under a `specs/` or `__tests__/` tree.
9
+ *
10
+ * Four of the offs are one half of an exclusive pair; the other half is on, and
11
+ * `exclusive-pairs.test.ts` proves no profile ever arms both.
12
+ */
13
+
14
+ /** Where a vitest rule applies. Nothing outside these globs is a test. */
15
+ export const TEST_FILES = Object.freeze([
16
+ '**/*.{test,spec,test-d,spec-d}.{ts,tsx,js,jsx}',
17
+ '**/specs/**/*.{ts,tsx}',
18
+ '**/__tests__/**/*.{ts,tsx,js,jsx}',
19
+ ]);
20
+
21
+ /** Every vitest rule that is on. The list is the roster minus the eight offs below. */
22
+ const ON_IN_TESTS = [
23
+ 'consistent-each-for',
24
+ 'consistent-test-filename',
25
+ 'consistent-vitest-vi',
26
+ 'expect-expect',
27
+ 'hoisted-apis-on-top',
28
+ 'max-expects',
29
+ 'max-nested-describe',
30
+ 'no-alias-methods',
31
+ 'no-commented-out-tests',
32
+ 'no-conditional-expect',
33
+ 'no-conditional-tests',
34
+ 'no-disabled-tests',
35
+ 'no-duplicate-hooks',
36
+ 'no-focused-tests',
37
+ 'no-identical-title',
38
+ 'no-import-node-test',
39
+ 'no-interpolation-in-snapshots',
40
+ 'no-large-snapshots',
41
+ 'no-mocks-import',
42
+ 'no-restricted-matchers',
43
+ 'no-restricted-vi-methods',
44
+ 'no-standalone-expect',
45
+ 'no-test-prefixes',
46
+ 'no-test-return-statement',
47
+ 'no-unneeded-async-expect-function',
48
+ 'padding-around-after-all-blocks',
49
+ 'padding-around-test-blocks',
50
+ 'prefer-called-exactly-once-with',
51
+ 'prefer-called-once',
52
+ 'prefer-called-with',
53
+ 'prefer-comparison-matcher',
54
+ 'prefer-each',
55
+ 'prefer-equality-matcher',
56
+ 'prefer-expect-resolves',
57
+ 'prefer-expect-type-of',
58
+ 'prefer-hooks-in-order',
59
+ 'prefer-hooks-on-top',
60
+ 'prefer-import-in-mock',
61
+ 'prefer-importing-vitest-globals',
62
+ 'prefer-mock-promise-shorthand',
63
+ 'prefer-mock-return-shorthand',
64
+ 'prefer-snapshot-hint',
65
+ 'prefer-spy-on',
66
+ 'prefer-strict-equal',
67
+ 'prefer-to-be',
68
+ 'prefer-to-be-falsy',
69
+ 'prefer-to-be-object',
70
+ 'prefer-to-be-truthy',
71
+ 'prefer-to-contain',
72
+ 'prefer-to-have-been-called-times',
73
+ 'prefer-to-have-length',
74
+ 'prefer-todo',
75
+ 'require-awaited-expect-poll',
76
+ 'require-local-test-context-for-concurrent-snapshots',
77
+ 'require-mock-type-parameters',
78
+ 'require-to-throw-message',
79
+ 'valid-describe-callback',
80
+ 'valid-expect',
81
+ 'valid-expect-in-promise',
82
+ 'valid-title',
83
+ 'warn-todo',
84
+ ].map((rule) => `vitest/${rule}`);
85
+
86
+ export default fragment({
87
+ id: 'vitest',
88
+ plugins: ['vitest'],
89
+ overrides: [
90
+ scoped({
91
+ files: [...TEST_FILES],
92
+ rules: {
93
+ ...allOn(ON_IN_TESTS),
94
+
95
+ 'vitest/consistent-test-it': on([{ fn: 'test' }]),
96
+ /* No `allowedPrefixes`. The rule is stricter than the `j5` rule
97
+ * @jterrazz/test retires for it: it also refuses a title opening
98
+ * on an all-caps identifier (`HTTP 404 …`, `DI …`). Strictest
99
+ * sensible wins, and an existing title is a rename, not a case
100
+ * for an estate-specific escape hatch. */
101
+ 'vitest/prefer-lowercase-title': on(),
102
+
103
+ 'vitest/no-conditional-in-test': off({
104
+ by: "vitest/no-conditional-expect — the defect is an assertion that may not run, and that rule names it; this one also refuses a golden suite's TEST_UPDATE branch and every comparator",
105
+ kind: 'covered',
106
+ }),
107
+ 'vitest/no-hooks': off({
108
+ by: 'vitest/prefer-hooks-in-order, vitest/prefer-hooks-on-top — both describe where a hook goes',
109
+ kind: 'exclusive',
110
+ }),
111
+ 'vitest/no-importing-vitest-globals': off({
112
+ by: 'vitest/prefer-importing-vitest-globals',
113
+ kind: 'exclusive',
114
+ }),
115
+ 'vitest/prefer-called-times': off({
116
+ by: 'vitest/prefer-called-once',
117
+ kind: 'exclusive',
118
+ }),
119
+ 'vitest/prefer-describe-function-title': off({
120
+ by: 'vitest/valid-title — a describe of this estate names the behaviour it claims, not the function it calls',
121
+ kind: 'exclusive',
122
+ }),
123
+ 'vitest/prefer-expect-assertions': off({
124
+ by: 'docs/07-lint-presets.md — a spec states its assertions; counting them is bookkeeping the reader does not need',
125
+ kind: 'convention',
126
+ }),
127
+ 'vitest/prefer-strict-boolean-matchers': off({
128
+ by: 'vitest/prefer-to-be-truthy, vitest/prefer-to-be-falsy',
129
+ kind: 'exclusive',
130
+ }),
131
+ 'vitest/require-top-level-describe': off({
132
+ by: 'vitest/consistent-test-filename — the file name is the subject, and a describe that repeats it adds a level without adding meaning',
133
+ kind: 'covered',
134
+ }),
135
+ 'vitest/require-hook': off({
136
+ by: 'docs/07-lint-presets.md — the Given narration of a spec is the test body, not a hook',
137
+ kind: 'convention',
138
+ }),
139
+ 'vitest/require-test-timeout': off({
140
+ by: 'docs/07-lint-presets.md — vitest.config.ts owns the timeout, once, for every suite',
141
+ kind: 'convention',
142
+ }),
143
+
144
+ // A test double is an empty function by definition.
145
+ 'no-empty-function': off({
146
+ by: 'docs/07-lint-presets.md — a test double with no behaviour is an empty function',
147
+ kind: 'convention',
148
+ }),
149
+ },
150
+ }),
151
+ ],
152
+ rules: {},
153
+ });
package/src/docs.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The `docs/` tree of one repository, as a rule engine needs to see it. */
2
- export interface DocsTree {
2
+ export type DocsTree = {
3
3
  /** The root `AGENTS.md`, its whole text, or `null` when the repository has none. */
4
4
  readonly agents: null | string;
5
5
  /**
@@ -26,14 +26,14 @@ export interface DocsTree {
26
26
  /** A `package.json` at either that is not `"private": true`. */
27
27
  readonly publishable: boolean;
28
28
  };
29
- }
29
+ };
30
30
 
31
31
  /** One broken rule, naming the path it is about and the sentence the gate prints. */
32
- export interface DocsViolation {
32
+ export type DocsViolation = {
33
33
  readonly message: string;
34
34
  readonly path: string;
35
35
  readonly rule: string;
36
- }
36
+ };
37
37
 
38
38
  /** How many opening lines of a file a rule may read. */
39
39
  export declare const HEAD_LINES: number;
package/src/docs.js CHANGED
@@ -52,28 +52,28 @@ const STATUSES = new Set(['Proposed', 'Accepted', 'Deprecated']);
52
52
  * The fourth status carries the record that replaced it, as a link a reader
53
53
  * can follow — a citation is a place a human can look, not just a number.
54
54
  */
55
- const SUPERSEDED = /^Superseded by \[ADR-\d{3}\]\([^)]+\)$/;
55
+ const SUPERSEDED = /^Superseded by \[ADR-\d{3}\]\([^)]+\)$/u;
56
56
 
57
57
  /** The same status named but not linked — the successor exists, the citation does not. */
58
- const BARE_SUPERSEDED = /^Superseded by ADR-\d{3}$/;
58
+ const BARE_SUPERSEDED = /^Superseded by ADR-\d{3}$/u;
59
59
 
60
60
  /** What a chapter's file name must be: two digits, lowercase words, single hyphens. */
61
- const CHAPTER_NAME = /^\d{2}-[a-z\d]+(?:-[a-z\d]+)*\.md$/;
61
+ const CHAPTER_NAME = /^\d{2}-[a-z\d]+(?:-[a-z\d]+)*\.md$/u;
62
62
 
63
63
  /** What a decision record's file name must be: three digits, then the same words. */
64
- const DECISION_NAME = /^\d{3}-[a-z\d]+(?:-[a-z\d]+)*\.md$/;
64
+ const DECISION_NAME = /^\d{3}-[a-z\d]+(?:-[a-z\d]+)*\.md$/u;
65
65
 
66
66
  /** A decision record's first heading, carrying the number the file claims. */
67
- const DECISION_HEADING = /^# ADR-(?<number>\d{3}): \S/;
67
+ const DECISION_HEADING = /^# ADR-(?<number>\d{3}): \S/u;
68
68
 
69
69
  /** The `**Status:**` line of a decision record, wherever it sits in the head. */
70
- const DECISION_STATUS = /^\*\*Status:\*\*\s*(?<status>.+?)\s*$/;
70
+ const DECISION_STATUS = /^\*\*Status:\*\*\s*(?<status>.+?)\s*$/u;
71
71
 
72
72
  /** The marker every file under `reference/` carries — it is generated, never authored. */
73
73
  const GENERATED = 'GENERATED';
74
74
 
75
75
  /** A link with a scheme (`https:`, `mailto:`) cites; it never reaches into a tree. */
76
- const SCHEME = /^[a-z][\d+.a-z-]*:/i;
76
+ const SCHEME = /^[a-z][\d+.a-z-]*:/iu;
77
77
 
78
78
  /** What each of the three presence facts means, in the sentence `04` is asked for. */
79
79
  const SHIPPING_REASONS = [
@@ -84,7 +84,7 @@ const SHIPPING_REASONS = [
84
84
 
85
85
  /** Directly under `docs/` — a file, or a directory with its trailing slash. */
86
86
  function directChildren(files) {
87
- return files.filter((path) => /^docs\/[^/]+\/?$/.test(path));
87
+ return files.filter((path) => /^docs\/[^/]+\/?$/u.test(path));
88
88
  }
89
89
 
90
90
  /** Everything under a folder of `docs/`, named relative to that folder. */
@@ -103,12 +103,12 @@ function padded(value) {
103
103
 
104
104
  /** The link target itself, without the anchor a reader lands on. */
105
105
  function targetPath(link) {
106
- return link.split('#')[0].replace(/^\.\//, '');
106
+ return link.split('#')[0].replace(/^\.\//u, '');
107
107
  }
108
108
 
109
109
  /** A link that names a chapter of the same folder — `03-testing.md`, no slash. */
110
110
  function isChapterLink(target) {
111
- return /^\d/.test(target) && !target.includes('/');
111
+ return /^\d/u.test(target) && !target.includes('/');
112
112
  }
113
113
 
114
114
  /**
@@ -198,20 +198,7 @@ function auditChapters(report, { chapters, ships }) {
198
198
  }
199
199
  }
200
200
 
201
- const numbers = chapters.map((chapter) => chapter.number).sort((a, b) => a - b);
202
- const hasOperating = numbers.includes(4);
203
- // 04 is the one number the spine never requires (`docs-operating-missing`
204
- // Asks for it on its own terms), so a run missing it is still contiguous —
205
- // Every number from 05 on shifts down one slot to close the gap.
206
- const expected = (index) => (!hasOperating && index + 1 >= 4 ? index + 2 : index + 1);
207
- const contiguous = numbers.every((number, index) => number === expected(index));
208
- if (numbers.length > 0 && !contiguous) {
209
- report(
210
- 'docs-chapter-numbering',
211
- 'docs/',
212
- `chapter numbers run ${numbers.map(padded).join(', ')}: they are contiguous from 01, one file per number, except that 04 may be absent`,
213
- );
214
- }
201
+ auditChapterNumbering(report, chapters);
215
202
 
216
203
  for (const chapter of chapters) {
217
204
  const reserved = SPINE[chapter.number - 1];
@@ -244,8 +231,33 @@ function auditChapters(report, { chapters, ships }) {
244
231
  );
245
232
  }
246
233
 
234
+ auditChapterWords(report, chapters);
235
+ }
236
+
237
+ /** The run of numbers: contiguous from 01, one file per number, 04 the one that may be absent. */
238
+ function auditChapterNumbering(report, chapters) {
239
+ const numbers = chapters.map((chapter) => chapter.number).toSorted((a, b) => a - b);
240
+ const hasOperating = numbers.includes(4);
241
+ // 04 is the one number the spine never requires (`docs-operating-missing`
242
+ // Asks for it on its own terms), so a run missing it is still contiguous —
243
+ // Every number from 05 on shifts down one slot to close the gap.
244
+ const expected = (index) => (!hasOperating && index + 1 >= 4 ? index + 2 : index + 1);
245
+ const contiguous = numbers.every((number, index) => number === expected(index));
246
+ if (numbers.length === 0 || contiguous) {
247
+ return;
248
+ }
249
+
250
+ report(
251
+ 'docs-chapter-numbering',
252
+ 'docs/',
253
+ `chapter numbers run ${numbers.map(padded).join(', ')}: they are contiguous from 01, one file per number, except that 04 may be absent`,
254
+ );
255
+ }
256
+
257
+ /** A chapter named for a journal rather than a subject — the record is an ADR. */
258
+ function auditChapterWords(report, chapters) {
247
259
  for (const chapter of chapters) {
248
- const word = chapter.name.split(/[.-]/).find((segment) => JOURNAL_WORDS.has(segment));
260
+ const word = chapter.name.split(/[.-]/u).find((segment) => JOURNAL_WORDS.has(segment));
249
261
  if (word !== undefined) {
250
262
  report(
251
263
  'docs-journal-chapter',
@@ -256,6 +268,37 @@ function auditChapters(report, { chapters, ships }) {
256
268
  }
257
269
  }
258
270
 
271
+ /** The run of record numbers: from 001, with no gap, and no number claimed twice. */
272
+ function auditDecisionNumbers(report, records) {
273
+ const numbers = [...new Set(records.map((record) => record.name.slice(0, 3)))].toSorted(
274
+ (left, right) => left.localeCompare(right),
275
+ );
276
+ const sequential = numbers.every((number, index) => Number.parseInt(number, 10) === index + 1);
277
+ if (numbers.length > 0 && !sequential) {
278
+ report(
279
+ 'docs-decision-sequence',
280
+ 'docs/decisions/',
281
+ `docs/decisions/ numbers run ${numbers.join(', ')}: they run from 001 with no gap — a decision that moved folders takes the next number where it lands`,
282
+ );
283
+ }
284
+
285
+ const claimed = new Map();
286
+ for (const record of records) {
287
+ const number = record.name.slice(0, 3);
288
+ const first = claimed.get(number);
289
+
290
+ if (first === undefined) {
291
+ claimed.set(number, record.name);
292
+ } else {
293
+ report(
294
+ 'docs-decision-number',
295
+ 'docs/decisions/',
296
+ `ADR-${number} is claimed by ${first} and ${record.name}`,
297
+ );
298
+ }
299
+ }
300
+ }
301
+
259
302
  /** What sits directly under `docs/` and is neither the map, a chapter, nor one of the three folders. */
260
303
  function auditFolder(report, { children }) {
261
304
  for (const path of children) {
@@ -269,7 +312,7 @@ function auditFolder(report, { children }) {
269
312
  `${path} is not one of decisions/, reference/, _assets/`,
270
313
  );
271
314
  }
272
- } else if (name !== 'README.md' && !/^\d/.test(name)) {
315
+ } else if (name !== 'README.md' && !/^\d/u.test(name)) {
273
316
  report(
274
317
  'docs-loose-file',
275
318
  path,
@@ -312,33 +355,18 @@ function auditDecisions(report, { files, heads }) {
312
355
  const entries = under(files, 'decisions/');
313
356
 
314
357
  for (const entry of entries) {
315
- if (entry.name !== '_template.md' && entry.name !== 'README.md') {
316
- if (!DECISION_NAME.test(entry.name)) {
317
- report('docs-decision-name', entry.path, `${entry.path} is not NNN-kebab.md`);
318
- }
358
+ const mold = entry.name === '_template.md' || entry.name === 'README.md';
359
+ if (!mold && !DECISION_NAME.test(entry.name)) {
360
+ report('docs-decision-name', entry.path, `${entry.path} is not NNN-kebab.md`);
319
361
  }
320
362
  }
321
363
 
322
- const records = entries.filter((entry) => /^\d{3}-/.test(entry.name));
364
+ const records = entries.filter((entry) => /^\d{3}-/u.test(entry.name));
323
365
  for (const record of records) {
324
366
  auditRecord(report, record, heads);
325
367
  }
326
368
 
327
- const claimed = new Map();
328
- for (const record of records) {
329
- const number = record.name.slice(0, 3);
330
- const first = claimed.get(number);
331
-
332
- if (first === undefined) {
333
- claimed.set(number, record.name);
334
- } else {
335
- report(
336
- 'docs-decision-number',
337
- 'docs/decisions/',
338
- `ADR-${number} is claimed by ${first} and ${record.name}`,
339
- );
340
- }
341
- }
369
+ auditDecisionNumbers(report, records);
342
370
 
343
371
  if (files.includes('docs/decisions/README.md')) {
344
372
  report(
@@ -417,7 +445,7 @@ export function auditDocs(tree) {
417
445
 
418
446
  const children = directChildren(files);
419
447
  const chapters = children
420
- .filter((path) => !path.endsWith('/') && /^\d/.test(path.slice('docs/'.length)))
448
+ .filter((path) => !path.endsWith('/') && /^\d/u.test(path.slice('docs/'.length)))
421
449
  .map((path) => ({
422
450
  name: path.slice('docs/'.length),
423
451
  number: Number.parseInt(path.slice('docs/'.length), 10),