@hublo/sentinel 1.5.0-alpha.9 → 1.5.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 +9 -7
- package/dist/bin/sentinel.js +61 -16
- package/dist/{chunk-KLEXJFJ6.js → chunk-43HQ6BVJ.js} +61 -15
- package/dist/{chunk-YQRDKWSH.js → chunk-IL5PPLXE.js} +11 -1
- package/dist/chunk-IL5PPLXE.js.map +1 -0
- package/dist/chunk-MOELAQPC.js +10365 -0
- package/dist/chunk-WBDBRGLV.js +214 -0
- package/dist/chunk-WBDBRGLV.js.map +1 -0
- package/dist/{chunk-AMCGMLPS.js → chunk-Y57KA4QS.js} +2 -2
- package/dist/index.d.ts +28 -1
- package/dist/index.js +4 -4
- package/dist/roles/build/nest/toolchain.js +68 -18
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/roles/static-analysis/config.d.ts +15 -1
- package/dist/roles/static-analysis/config.js +1 -1
- package/dist/roles/test/setup/jest-parity.js +2 -2
- package/dist/roles/test/setup/workspace-entry.js +2 -2
- package/dist/{validate-AWWMBSH5.js → validate-FRMENKHM.js} +2 -2
- package/docs/bundle-analysis-spec.md +272 -0
- package/docs/sql-verification-study.md +401 -0
- package/docs/static-analysis-adoption.md +285 -0
- package/docs/static-analysis-study.md +824 -18
- package/docs/typescript-adoption.md +1 -1
- package/docs/typescript-traces.md +27 -12
- package/docs/using-sentinel.md +22 -12
- package/docs/validating-a-change.md +185 -0
- package/lint/nest.json +98 -18
- package/lint/node.json +83 -3
- package/lint/react-lib.json +121 -31
- package/lint/react.json +101 -2
- package/package.json +27 -6
- package/plugins/hublo/no-palette-common.js +76 -0
- package/plugins/hublo/no-regex-lookbehind.js +70 -0
- package/plugins/hublo/require-reload-document.js +70 -0
- package/plugins/hublo.js +20 -145
- package/plugins/sonar-cognitive.js +108 -0
- package/dist/chunk-EDFIVLUA.js +0 -100
- package/dist/chunk-EDFIVLUA.js.map +0 -1
- package/dist/chunk-OZZICSSZ.js +0 -6469
- package/dist/chunk-YQRDKWSH.js.map +0 -1
- /package/dist/{chunk-AMCGMLPS.js.map → chunk-Y57KA4QS.js.map} +0 -0
package/plugins/hublo.js
CHANGED
|
@@ -1,156 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local oxlint plugin: Hublo house rules that have no equivalent anywhere else.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* It ships INSIDE sentinel rather than being copied into each module. A house rule is code, not
|
|
5
|
+
* configuration: a per-module copy drifts, and drifting house rules are the problem sentinel
|
|
6
|
+
* exists to remove. The cost is that `@hublo/sentinel` carries a rule about Hublo's colour
|
|
7
|
+
* palette, which is honest for a package named after the company.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* ⛔ One file per rule, assembled here, and this file holds NO rule logic. It was one 220-line
|
|
10
|
+
* object with three rules inside it, which is the shape that invites a fourth: the rules share
|
|
11
|
+
* nothing, each carries its own constants and its own reasoning, and a reader looking for one of
|
|
12
|
+
* them had to scroll past the other two. Composition is the point here rather than speed: this
|
|
13
|
+
* plugin has no dependencies and loads in about 1 ms, so there is no sonarjs-style cost to cut.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* rule is code, not configuration — and because lint is the only layer left that can catch it.
|
|
18
|
-
*
|
|
19
|
-
* The original selectors, and where each is handled below:
|
|
20
|
-
* 1-2 MemberExpression[object.property.name='palette'][property.name='common'] theme.palette.common
|
|
21
|
-
* …[computed=true][property.value='common'] theme.palette['common']
|
|
22
|
-
* 3-4 MemberExpression[object.name='palette']… palette.common
|
|
23
|
-
* 5-6 Property[key.name|key.value=/<colour props>/] > Literal[value=/^common\./] { color: 'common.white' }
|
|
24
|
-
* 7-8 JSXAttribute[name.name=/<colour props>/] > Literal | JSXExpressionContainer color="common.white"
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
const MESSAGE =
|
|
28
|
-
'Do not use palette.common or common.* color paths in product code. Use semantic palette tokens instead.'
|
|
29
|
-
|
|
30
|
-
/** The MUI props that take a colour path, so `common.*` in them is the same mistake. */
|
|
31
|
-
const COLOUR_PROPS =
|
|
32
|
-
/^(color|bgcolor|background|backgroundColor|borderColor|borderTopColor|borderRightColor|borderBottomColor|borderLeftColor|outlineColor|fill|stroke|textDecorationColor|caretColor|accentColor)$/
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Lookbehind, positive or negative. NOT named groups `(?<name>)`, which Safari has had since
|
|
36
|
-
* 11.3: the class is exactly `(?<` followed by `=` or `!`.
|
|
15
|
+
* ⚠️ The build copies a role's whole `plugins/` DIRECTORY, so `plugins/hublo/` ships beside
|
|
16
|
+
* `plugins/hublo.js` and the relative imports below resolve the same way in this repository and
|
|
17
|
+
* in the published package. The generated preset still names `../plugins/hublo.js`, so nothing an
|
|
18
|
+
* adopted module extends has moved.
|
|
37
19
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'Regex lookbehind ((?<=) / (?<!)) is Safari 16.4+ and can be neither transpiled nor polyfilled: on older iOS the literal is a parse-time SyntaxError that takes the whole chunk with it. Rewrite the pattern. Named groups (?<name>) are fine.'
|
|
42
|
-
|
|
43
|
-
/** `common.something` as a string value, e.g. `{ color: 'common.white' }`. */
|
|
44
|
-
const isCommonPath = (node) =>
|
|
45
|
-
node?.type === 'Literal' && typeof node.value === 'string' && /^common\./.test(node.value)
|
|
20
|
+
import { rule as noPaletteCommon } from './hublo/no-palette-common.js'
|
|
21
|
+
import { rule as noRegexLookbehind } from './hublo/no-regex-lookbehind.js'
|
|
22
|
+
import { rule as requireReloadDocument } from './hublo/require-reload-document.js'
|
|
46
23
|
|
|
47
|
-
|
|
24
|
+
export default {
|
|
48
25
|
meta: { name: 'hublo' },
|
|
49
26
|
rules: {
|
|
50
|
-
'no-palette-common':
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
description: 'Ban palette.common and common.* colour paths in product code.',
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
create(context) {
|
|
57
|
-
const report = (node) => context.report({ node, message: MESSAGE })
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
// 1-4: reaching `common` off a palette, by property or by computed key.
|
|
61
|
-
MemberExpression(node) {
|
|
62
|
-
const onPalette =
|
|
63
|
-
node.object?.property?.name === 'palette' || node.object?.name === 'palette'
|
|
64
|
-
if (!onPalette) {
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
const reachesCommon =
|
|
68
|
-
node.property?.name === 'common' ||
|
|
69
|
-
(node.computed && node.property?.value === 'common')
|
|
70
|
-
if (reachesCommon) {
|
|
71
|
-
report(node)
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
// 5-6: `{ color: 'common.white' }` in an object literal, key written either way.
|
|
76
|
-
Property(node) {
|
|
77
|
-
const key = node.key?.name ?? node.key?.value
|
|
78
|
-
if (typeof key === 'string' && COLOUR_PROPS.test(key) && isCommonPath(node.value)) {
|
|
79
|
-
report(node.value)
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
// 7-8: `color="common.white"` and `color={'common.white'}` on a JSX element.
|
|
84
|
-
JSXAttribute(node) {
|
|
85
|
-
const name = node.name?.name
|
|
86
|
-
if (typeof name !== 'string' || !COLOUR_PROPS.test(name)) {
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
const value = node.value
|
|
90
|
-
if (isCommonPath(value)) {
|
|
91
|
-
return report(value)
|
|
92
|
-
}
|
|
93
|
-
if (value?.type === 'JSXExpressionContainer' && isCommonPath(value.expression)) {
|
|
94
|
-
report(value.expression)
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
'no-regex-lookbehind': {
|
|
102
|
-
meta: {
|
|
103
|
-
docs: {
|
|
104
|
-
description: 'Ban regex lookbehind, unsupported by Safari below 16.4.',
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
create(context) {
|
|
108
|
-
const report = (node) => context.report({ node, message: LOOKBEHIND_MESSAGE })
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* `new RegExp(...)` and `RegExp(...)`, when the pattern is readable statically: a
|
|
112
|
-
* string, or a template whose fixed parts carry the lookbehind.
|
|
113
|
-
*
|
|
114
|
-
* Each template chunk is tested on its own rather than joined. Joining invents a
|
|
115
|
-
* `(?<=` that the runtime string never has, across an interpolation boundary:
|
|
116
|
-
* `` `(?<${op}=x)` `` would read as one.
|
|
117
|
-
*/
|
|
118
|
-
const isStaticLookbehind = (pattern) => {
|
|
119
|
-
if (pattern?.type === 'Literal') {
|
|
120
|
-
return typeof pattern.value === 'string' && LOOKBEHIND.test(pattern.value)
|
|
121
|
-
}
|
|
122
|
-
if (pattern?.type === 'TemplateLiteral') {
|
|
123
|
-
return (pattern.quasis ?? []).some((quasi) =>
|
|
124
|
-
LOOKBEHIND.test(quasi.value?.cooked ?? quasi.value?.raw ?? ''),
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
return false
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const checkRegExpCall = (node) => {
|
|
131
|
-
if (node.callee?.name !== 'RegExp') {
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
const pattern = node.arguments?.[0]
|
|
135
|
-
if (isStaticLookbehind(pattern)) {
|
|
136
|
-
report(pattern)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
// The case that actually breaks a page: a literal is an EARLY error, so the whole
|
|
142
|
-
// chunk fails to parse rather than the one call site failing.
|
|
143
|
-
Literal(node) {
|
|
144
|
-
if (node.regex && LOOKBEHIND.test(node.regex.pattern)) {
|
|
145
|
-
report(node)
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
NewExpression: checkRegExpCall,
|
|
149
|
-
CallExpression: checkRegExpCall,
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
},
|
|
27
|
+
'no-palette-common': noPaletteCommon,
|
|
28
|
+
'no-regex-lookbehind': noRegexLookbehind,
|
|
29
|
+
'require-reload-document': requireReloadDocument,
|
|
153
30
|
},
|
|
154
31
|
}
|
|
155
|
-
|
|
156
|
-
export default plugin
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cognitive complexity, measured by Sonar's own rule, loaded into oxlint.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ Source file. It is COPIED to `plugins/` at build time by `scripts/build-presets.ts`, which
|
|
5
|
+
* wipes that directory first: `plugins/` is git-ignored, so a file written straight into it lasts
|
|
6
|
+
* until the next build. Edit this one.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a bridge and not an implementation
|
|
9
|
+
*
|
|
10
|
+
* Cognitive complexity is Campbell 2018, and its author works at SonarSource, so their
|
|
11
|
+
* implementation is not one reading of the specification among several: it is the specification's
|
|
12
|
+
* own house, with the language-specific decisions that come with it. The clearest of those is that
|
|
13
|
+
* `||` and `??` sequences do NOT increment in JavaScript and TypeScript, which looks like a defect
|
|
14
|
+
* against the generic paper and is deliberate: in this language those two operators are
|
|
15
|
+
* overwhelmingly default-value shorthand rather than control flow. SonarSource say so themselves,
|
|
16
|
+
* "we tried to adjust the cognitive complexity rule to accommodate common idioms", and made the
|
|
17
|
+
* exclusion universal in SonarQube 10.8 after it had been inconsistent between contexts.
|
|
18
|
+
*
|
|
19
|
+
* Measured here before it was believed, same file and same parser on both:
|
|
20
|
+
*
|
|
21
|
+
* | | sonarjs 0.25.1 | sonarjs 4.2.2 |
|
|
22
|
+
* | `if (a \|\| b \|\| c)` | 2 | 1 |
|
|
23
|
+
* | `if (a && b && c)` | 2 | 2 |
|
|
24
|
+
* | `const x = a \|\| b \|\| c`| 1 | 0 |
|
|
25
|
+
*
|
|
26
|
+
* ⛔ **The rule is USED, never copied.** `eslint-plugin-sonarjs` ships under the Sonar
|
|
27
|
+
* Source-Available License, whose grant is limited to a "Non-competitive Purpose", and sentinel is
|
|
28
|
+
* published under MIT. Depending on a package is not preparing a derivative work of it; copying its
|
|
29
|
+
* code into this repository would be, and would also mean granting rights we never received. So
|
|
30
|
+
* this file re-exposes their rule and contains none of their code.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it works at all, which is not obvious
|
|
33
|
+
*
|
|
34
|
+
* This is an ESLint rule running inside oxlint. Measured before it was adopted:
|
|
35
|
+
*
|
|
36
|
+
* | | |
|
|
37
|
+
* | the same values as ESLint, on `.js` and `.ts`| identical on every fixture, including the 7 of Campbell's own published example |
|
|
38
|
+
* | on a real module, `contract`, 516 files | 765 ms, exit 0, no error |
|
|
39
|
+
* | `eslint` itself | NOT installed: pnpm leaves the peer unmet and oxlint provides the context |
|
|
40
|
+
* | advisories the dependency adds | 0 at every level, 94 packages |
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ It does pull `typescript` as a hard dependency of the plugin, which nothing of ours imports.
|
|
43
|
+
*/
|
|
44
|
+
import { createRequire } from 'node:module'
|
|
45
|
+
|
|
46
|
+
const require = createRequire(import.meta.url)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ⛔ The deep path, and not the package name.
|
|
50
|
+
*
|
|
51
|
+
* `eslint-plugin-sonarjs` declares `main: ./cjs/plugin.js` and no `exports` map, so a bare ESM
|
|
52
|
+
* `import` of the package name fails to resolve under Node's ESM rules. Measured: it throws
|
|
53
|
+
* `ERR_MODULE_NOT_FOUND` before oxlint has read a single file, and oxlint reports it as
|
|
54
|
+
* "Failed to parse oxlint configuration file", which points at the wrong thing entirely.
|
|
55
|
+
*/
|
|
56
|
+
/*
|
|
57
|
+
* ## ⛔ The ONE rule, not the 295 the package ships
|
|
58
|
+
*
|
|
59
|
+
* `cjs/plugin.js` is the public door and it loads every rule to hand back one. Measured on this
|
|
60
|
+
* machine, cold, three runs each:
|
|
61
|
+
*
|
|
62
|
+
* cjs/plugin.js 399 ms 295 rules we use 1
|
|
63
|
+
* cjs/S3776/index.js 159 ms 1 rule the same object
|
|
64
|
+
*
|
|
65
|
+
* That difference is paid ONCE PER MODULE, because oxlint runs as a process per invocation. Over
|
|
66
|
+
* the whole repository, 441 modules, it is the difference between a sweep that is mostly loading
|
|
67
|
+
* rules nobody asked for and one that is not. The rule's own work is untouched: same `create`,
|
|
68
|
+
* same `meta.schema`, same figures.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ S3776 is Sonar's own identifier for cognitive complexity, and the package lays out ONE
|
|
71
|
+
* directory per rule, 295 of them. It also declares no `exports` field, so this path is reachable
|
|
72
|
+
* by the package's own layout rather than by accident.
|
|
73
|
+
*
|
|
74
|
+
* ⛔ It falls BACK rather than failing, and says so on stderr. A future version that moves the
|
|
75
|
+
* file must not cost the figure, and must not cost it silently either: a fallback nobody can see
|
|
76
|
+
* is how a 240 ms regression per module comes back unnoticed.
|
|
77
|
+
*/
|
|
78
|
+
function cognitiveRule() {
|
|
79
|
+
try {
|
|
80
|
+
const only = require('eslint-plugin-sonarjs/cjs/S3776/index.js')
|
|
81
|
+
const rule = only.rule ?? only.default?.rule
|
|
82
|
+
if (typeof rule?.create === 'function') return rule
|
|
83
|
+
} catch {
|
|
84
|
+
// falls through to the public door below
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sonarjs = require('eslint-plugin-sonarjs/cjs/plugin.js')
|
|
88
|
+
/** CJS interop: the plugin is reached either directly or behind `default`. */
|
|
89
|
+
const rule = (sonarjs.rules ?? sonarjs.default?.rules)?.['cognitive-complexity']
|
|
90
|
+
if (typeof rule?.create !== 'function') {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'sentinel: eslint-plugin-sonarjs exposes `cognitive-complexity` from neither ' +
|
|
93
|
+
'cjs/S3776/index.js nor cjs/plugin.js. The cognitive complexity figure cannot be ' +
|
|
94
|
+
'produced; see this file.',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
'sentinel static-analysis: eslint-plugin-sonarjs no longer exposes cjs/S3776/index.js, so the ' +
|
|
99
|
+
'whole package was loaded for one rule. The figures are unchanged and the size pass is about ' +
|
|
100
|
+
'240 ms slower per module; see plugins/sonar-cognitive.js.\n',
|
|
101
|
+
)
|
|
102
|
+
return rule
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default {
|
|
106
|
+
meta: { name: 'sonar' },
|
|
107
|
+
rules: { 'cognitive-complexity': cognitiveRule() },
|
|
108
|
+
}
|
package/dist/chunk-EDFIVLUA.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// src/shared/deep-merge.ts
|
|
2
|
-
function isPlainObject(value) {
|
|
3
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
// src/roles/static-analysis/presets/shared.json
|
|
7
|
-
var shared_default = {
|
|
8
|
-
entry: ["**/*.integration-spec.ts", "**/*.steps.ts"],
|
|
9
|
-
ignore: [
|
|
10
|
-
"**/node_modules/**",
|
|
11
|
-
"**/dist/**",
|
|
12
|
-
"**/build/**",
|
|
13
|
-
"**/coverage/**",
|
|
14
|
-
"**/.next/**",
|
|
15
|
-
"**/__mocks__/**",
|
|
16
|
-
"**/mocks/**",
|
|
17
|
-
"**/_mock_/**",
|
|
18
|
-
"**/**Mock**/**",
|
|
19
|
-
"**/__fixtures__/**",
|
|
20
|
-
"**/fixtures/**",
|
|
21
|
-
"**/public/**",
|
|
22
|
-
"**/.husky/**",
|
|
23
|
-
"**/.vscode/**",
|
|
24
|
-
"**/.idea/**",
|
|
25
|
-
"**/.github/**",
|
|
26
|
-
"**/*.d.ts",
|
|
27
|
-
"**/*.gen.ts",
|
|
28
|
-
"**/*.generated.ts"
|
|
29
|
-
],
|
|
30
|
-
languages: ["javascript", "typescript", "jsx", "tsx", "css", "scss"],
|
|
31
|
-
journeys: {},
|
|
32
|
-
duplication: {
|
|
33
|
-
minTokens: 50,
|
|
34
|
-
minLines: 5,
|
|
35
|
-
kinds: ["exact"],
|
|
36
|
-
tests: [
|
|
37
|
-
"*.{test,spec,steps,e2e-spec,integration-spec}.{ts,tsx,js,jsx}",
|
|
38
|
-
"{tests,__tests__}/**/*.{ts,tsx,js,jsx}"
|
|
39
|
-
]
|
|
40
|
-
},
|
|
41
|
-
deadCode: {
|
|
42
|
-
minConfidence: 60,
|
|
43
|
-
categories: ["unused-file", "unused-export", "unused-symbol", "unused-import"]
|
|
44
|
-
},
|
|
45
|
-
tools: {
|
|
46
|
-
jscpd: {}
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
// src/roles/static-analysis/preset-data.ts
|
|
51
|
-
var REPORT_DIR = "static-analysis-report";
|
|
52
|
-
var defaults = shared_default;
|
|
53
|
-
|
|
54
|
-
// src/roles/static-analysis/configs/define.ts
|
|
55
|
-
function mergeInto(base, patch) {
|
|
56
|
-
if (!isPlainObject(patch)) return base;
|
|
57
|
-
if (!isPlainObject(base)) return patch;
|
|
58
|
-
const out = { ...base };
|
|
59
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
60
|
-
out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value;
|
|
61
|
-
}
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
function leafPaths(patch, prefix = "") {
|
|
65
|
-
if (!isPlainObject(patch)) return prefix ? [prefix] : [];
|
|
66
|
-
return Object.entries(patch).flatMap(
|
|
67
|
-
([key, value]) => leafPaths(value, prefix ? `${prefix}.${key}` : key)
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
function unique(values) {
|
|
71
|
-
return [...new Set(values)];
|
|
72
|
-
}
|
|
73
|
-
function withoutVendorKeys(overrides) {
|
|
74
|
-
if (!isPlainObject(overrides) || !("tools" in overrides)) return overrides;
|
|
75
|
-
const { tools: _vendor, ...rest } = overrides;
|
|
76
|
-
process.stderr.write(
|
|
77
|
-
"sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath the role is not part of a module's surface; if a setting is missing, it needs a key of its own.\n"
|
|
78
|
-
);
|
|
79
|
-
return rest;
|
|
80
|
-
}
|
|
81
|
-
function defineStaticAnalysis(input = {}) {
|
|
82
|
-
const declared = {
|
|
83
|
-
...defaults,
|
|
84
|
-
entry: unique([...defaults.entry, ...input.entry ?? []]),
|
|
85
|
-
journeys: { ...defaults.journeys, ...input.journeys ?? {} }
|
|
86
|
-
};
|
|
87
|
-
const overrides = withoutVendorKeys(input.overrides);
|
|
88
|
-
return {
|
|
89
|
-
...mergeInto(declared, overrides),
|
|
90
|
-
root: input.root ?? null,
|
|
91
|
-
moved: leafPaths(overrides).sort()
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export {
|
|
96
|
-
isPlainObject,
|
|
97
|
-
REPORT_DIR,
|
|
98
|
-
defineStaticAnalysis
|
|
99
|
-
};
|
|
100
|
-
//# sourceMappingURL=chunk-EDFIVLUA.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/shared/deep-merge.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","{\n \"entry\": [\"**/*.integration-spec.ts\", \"**/*.steps.ts\"],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": {\n \"minTokens\": 50,\n \"minLines\": 5,\n \"kinds\": [\"exact\"],\n \"tests\": [\n \"*.{test,spec,steps,e2e-spec,integration-spec}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__}/**/*.{ts,tsx,js,jsx}\"\n ]\n },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"tools\": {\n \"jscpd\": {}\n }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * ⛔ There are no thresholds here, and their absence is a decision rather than an omission.\n *\n * A threshold was written first, at 5% of duplicated lines, and then removed. Measured over all 228\n * nx projects: 5% flags 65 of the 201 measurable modules, a third of the repository, and moving the\n * figure until the count felt manageable is not calibration, it is fitting the instrument to the\n * answer. Calibration is legitimate when a bench is polluted and the noise is removed, or when the\n * reference frame changes; neither applies to a share of duplicated lines.\n *\n * And there is nothing to calibrate AGAINST: of the sources in `docs/static-analysis-study.md`\n * section 5, NDepend, Lattix, ArchUnit, CodeScene and Arcan, none publishes a threshold for\n * duplicated lines. One invented here would travel as if it were published.\n *\n * So the role reports the measurement. What makes a single figure readable is the distribution it\n * sits in, which only a workspace-wide run can know, so that belongs to the sweep and to the\n * migration page, not to a module's own report. The first defensible warning will not be an absolute\n * number either: a module that got WORSE than its own previous measurement is a signal that needs no\n * invented bar, which is what a baseline gives, and that was already deferred for its own reasons.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `duplication.tests` is the glob that splits the pass in two, and the split is the finding that\n * forced it. Measured across 8 modules of the three families, **71% of all duplicated lines are\n * between two test files**: `libs/nest/auth` reports 35.77% and its product code is at 5.96%,\n * `apps/front/host-admin` 7.91% against 3.59%. A single blended figure is therefore mostly a\n * measure of spec duplication wearing the name of the module's duplication, and the number\n * travels while that nuance does not. So the role reports the two side by side, the way it\n * already refuses to blend languages, and neither is hidden: a module whose specs carry 93% of\n * its clones is saying something about its specs that excluding them would erase.\n *\n * ⚠️ The two passes PARTITION the files and therefore lose a clone that spans them: a fragment\n * shared between a test and a product file has no partner in either scan. Measured on the same\n * 8 modules, that is 42 entries of 5117, 0.8%, 650 lines. Stated in the caveat rather than\n * silently accepted, and it is the price of the split.\n *\n * Two patterns and not one, because a suffix convention does not cover a DIRECTORY convention,\n * and the directory one is not a quirk: 635 files across 26 modules live under `tests/` or\n * `__tests__/` without a name this role's suffixes recognise, `apps/nest/microservices/recruitment`\n * alone holding 160. They are step definitions, in-memory doubles and stubs, which is test-side\n * code by any reading. A role default rather than 26 identical overrides.\n *\n * ⚠️ The patterns are RELATIVE here, and the adapter prefixes and composes them, because the\n * tool's `--pattern` takes exactly one glob. Verified on four modules that the two passes\n * partition exactly: console 479 + 87 = 566, contract 399 + 151 = 550, backends-for-frontends/admin\n * 2062 + 773 = 2835, recruitment 936 + 621 = 1557.\n *\n * ⚠️ And a trap that cost a wrong table before it was caught: these globs contain commas, so they\n * survive the config FILE and are destroyed by the `-i` command-line flag, which splits on\n * commas and cuts `{test,spec}` in half. The adapter writes a file, which is one more reason it\n * does.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `tools.jscpd` is passed to the tool verbatim, and it is **sentinel's own** escape hatch, not a\n * module's: `defineStaticAnalysis` excludes it from what a module can override. Everything above\n * uses OUR key names, translated on the way out, so that replacing the tool one day does not\n * oblige a single module to edit its config file, and a module only ever knows the ROLE. This\n * block is where that guarantee stops, which is why it stays on this side and why it is empty:\n * the first thing to put in it would be an option a single preset needs, not a workaround.\n */\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[]; tests: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows. It reaches every setting EXCEPT the vendor\n * passthrough, for the reason stated on the field itself.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /** Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones. */\n journeys?: Record<string, string[]>\n /**\n * The seam. Replaces whatever it names, and every path it sets lands in `moved`.\n *\n * ⛔ `tools` is excluded, and the reason is the whole point of translating the vocabulary: a\n * module knows the ROLE, not the tool underneath it. A key named after the vendor would mean\n * nothing to the person reading their own config file, and showing it would invite them to learn\n * a name we are working to keep out of their world. The passthrough still exists, on sentinel's\n * side, for a preset that needs a vendor option; a module that genuinely needs one gets a key in\n * OUR vocabulary, which is the right friction because it goes through a review instead of\n * spreading silently.\n */\n overrides?: DeepPartial<Omit<StaticDefaults, 'tools'>>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Drop the vendor passthrough from what a module sent.\n *\n * ⚠️ Enforced here and not by the type, because the type cannot: a module's config file is `.mjs`,\n * so nothing type-checks it, and `Omit` on the input would be documentation that compiles away. A\n * `tools` key written there is dropped and NAMED on stderr rather than ignored, so a developer who\n * found the key somewhere learns why it did nothing instead of concluding the role is broken.\n */\nfunction withoutVendorKeys(overrides: unknown): unknown {\n if (!isPlainObject(overrides) || !('tools' in overrides)) return overrides\n const { tools: _vendor, ...rest } = overrides\n process.stderr.write(\n 'sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath ' +\n \"the role is not part of a module's surface; if a setting is missing, it needs a key of \" +\n 'its own.\\n',\n )\n return rest\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n const overrides = withoutVendorKeys(input.overrides)\n\n return {\n ...mergeInto(declared, overrides),\n root: input.root ?? null,\n moved: leafPaths(overrides).sort(),\n }\n}\n"],"mappings":";AAcO,SAAS,cAAc,OAAkD;AAC9E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;;;AChBA;AAAA,EACE,OAAS,CAAC,4BAA4B,eAAe;AAAA,EACrD,QAAU;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,WAAa,CAAC,cAAc,cAAc,OAAO,OAAO,OAAO,MAAM;AAAA,EACrE,UAAY,CAAC;AAAA,EACb,aAAe;AAAA,IACb,WAAa;AAAA,IACb,UAAY;AAAA,IACZ,OAAS,CAAC,OAAO;AAAA,IACjB,OAAS;AAAA,MACP;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA,EACA,UAAY;AAAA,IACV,eAAiB;AAAA,IACjB,YAAc,CAAC,eAAe,iBAAiB,iBAAiB,eAAe;AAAA,EACjF;AAAA,EACA,OAAS;AAAA,IACP,OAAS,CAAC;AAAA,EACZ;AACF;;;AC2FO,IAAM,aAAa;AAanB,IAAM,WAA2B;;;ACzDxC,SAAS,UAAa,MAAS,OAAmB;AAChD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AACjC,QAAM,MAA+B,EAAE,GAAG,KAAK;AAC/C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,GAAG,IAAI,cAAc,KAAK,IAAI,UAAU,IAAI,GAAG,GAAG,KAAK,IAAI;AAAA,EACjE;AACA,SAAO;AACT;AAGA,SAAS,UAAU,OAAgB,SAAS,IAAc;AACxD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO,SAAS,CAAC,MAAM,IAAI,CAAC;AACvD,SAAO,OAAO,QAAQ,KAAK,EAAE;AAAA,IAAQ,CAAC,CAAC,KAAK,KAAK,MAC/C,UAAU,OAAO,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK,GAAG;AAAA,EACpD;AACF;AAGA,SAAS,OAAO,QAA4B;AAC1C,SAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAC5B;AAUA,SAAS,kBAAkB,WAA6B;AACtD,MAAI,CAAC,cAAc,SAAS,KAAK,EAAE,WAAW,WAAY,QAAO;AACjE,QAAM,EAAE,OAAO,SAAS,GAAG,KAAK,IAAI;AACpC,UAAQ,OAAO;AAAA,IACb;AAAA,EAGF;AACA,SAAO;AACT;AAGO,SAAS,qBAAqB,QAA6B,CAAC,GAAyB;AAC1F,QAAM,WAA2B;AAAA,IAC/B,GAAG;AAAA,IACH,OAAO,OAAO,CAAC,GAAG,SAAS,OAAO,GAAI,MAAM,SAAS,CAAC,CAAE,CAAC;AAAA,IACzD,UAAU,EAAE,GAAG,SAAS,UAAU,GAAI,MAAM,YAAY,CAAC,EAAG;AAAA,EAC9D;AACA,QAAM,YAAY,kBAAkB,MAAM,SAAS;AAEnD,SAAO;AAAA,IACL,GAAG,UAAU,UAAU,SAAS;AAAA,IAChC,MAAM,MAAM,QAAQ;AAAA,IACpB,OAAO,UAAU,SAAS,EAAE,KAAK;AAAA,EACnC;AACF;","names":[]}
|