@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.
- package/README.md +20 -16
- package/bin/commands/check.sh +348 -117
- package/bin/typescript.sh +55 -0
- package/lib/check-architecture.js +89 -0
- package/lib/check-baseline.js +144 -0
- package/lib/check-docs.js +4 -3
- package/lib/check-drift.js +209 -0
- package/lib/check-gitignore.js +4 -4
- package/lib/check-markdown.js +279 -0
- package/lib/check-names.js +125 -0
- package/lib/check-publish.js +150 -0
- package/lib/check-secrets.js +115 -0
- package/lib/check-suppressions.js +355 -0
- package/lib/doctor.js +185 -0
- package/lib/merge-knip-config.js +57 -25
- package/lib/tracked-files.js +165 -0
- package/lib/workspace-members.js +5 -6
- package/package.json +19 -8
- package/presets/oxfmt/index.js +49 -5
- package/presets/oxlint/profiles/astro.js +10 -0
- package/presets/oxlint/profiles/bun.js +7 -0
- package/presets/oxlint/profiles/expo.js +7 -0
- package/presets/oxlint/profiles/library.js +16 -0
- package/presets/oxlint/profiles/next.js +7 -0
- package/presets/oxlint/profiles/node.js +7 -0
- package/presets/prettier/astro.json +6 -0
- package/presets/tsconfig/expo.json +16 -6
- package/presets/tsconfig/library.json +18 -0
- package/presets/tsconfig/next.json +12 -2
- package/presets/tsconfig/node.json +18 -4
- package/rules/README.md +23 -0
- package/rules/_contract.js +191 -0
- package/rules/_contract.test.ts +81 -0
- package/rules/a11y.js +51 -0
- package/rules/architecture/hexagonal.js +56 -0
- package/rules/architecture/layers.js +75 -0
- package/rules/astro.js +49 -0
- package/rules/catalog.js +134 -0
- package/rules/catalog.test.ts +84 -0
- package/rules/compile.js +125 -0
- package/rules/core/eslint.js +234 -0
- package/rules/core/import.js +107 -0
- package/rules/core/jsdoc.js +52 -0
- package/rules/core/node.js +36 -0
- package/rules/core/oxc.js +54 -0
- package/rules/core/promise.js +39 -0
- package/rules/core/typescript.js +204 -0
- package/rules/core/unicorn.js +200 -0
- package/rules/next.js +53 -0
- package/rules/profiles.js +89 -0
- package/rules/react-native.js +48 -0
- package/rules/react.js +148 -0
- package/rules/sorted.js +41 -0
- package/rules/vitest.js +153 -0
- package/src/docs.d.ts +4 -4
- package/src/docs.js +75 -47
- package/src/docs.test.ts +136 -29
- package/src/index.d.ts +13 -9
- package/src/index.js +15 -8
- package/src/oxfmt.d.ts +15 -2
- package/src/oxfmt.test.ts +10 -0
- package/src/oxlint.d.ts +57 -10
- package/src/oxlint.js +35 -50
- package/src/oxlint.test.ts +82 -28
- package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
- package/presets/oxlint/architectures/hexagonal.js +0 -13
- package/presets/oxlint/base.js +0 -145
- package/presets/oxlint/expo.js +0 -36
- package/presets/oxlint/next.js +0 -43
- package/presets/oxlint/node.js +0 -14
- 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
|
+
});
|
package/rules/sorted.js
ADDED
|
@@ -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
|
+
});
|
package/rules/vitest.js
ADDED
|
@@ -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
|
|
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
|
|
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-]*:/
|
|
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\/[^/]
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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),
|