@starci/eslint-canon-be 3.0.10 → 3.2.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/CHANGELOG.md +7 -1
- package/README.md +2 -1
- package/builders.mjs +1 -1
- package/comments.mjs +1 -1
- package/config-owner.mjs +1 -1
- package/index.mjs +2 -0
- package/lib/config.mjs +4 -1
- package/lib/doors.mjs +3 -1
- package/module-shape.mjs +1 -2
- package/package.json +1 -1
- package/resilience.mjs +1 -1
- package/runtime/config.example.yaml +194 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +222 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/release-config.mjs +28 -0
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/rules.yaml +161 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +126 -265
- package/runtime/modules/models/tiers.yaml +66 -0
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +164 -104
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/project-graph.mjs +4 -1
- package/runtime/scripts/hfs/repo-identity.mjs +21 -4
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +74 -695
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sonar-syntax-rules.mjs +225 -0
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/runtime/scripts/lib/yaml-cached.mjs +14 -0
- package/schema-authority.mjs +1 -1
- package/service-deps.mjs +1 -1
- package/sonar-parity.mjs +19 -0
- package/suppression.mjs +2 -6
- package/test-world.mjs +2 -10
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Trailing-slash trimming for slot paths and generated roots, linear in the length of the text.
|
|
2
|
+
|
|
3
|
+
/** `text` without its trailing `/` characters. */
|
|
4
|
+
export function trimTrailingSlashes(text) {
|
|
5
|
+
let end = text.length;
|
|
6
|
+
while (end > 0 && text.codePointAt(end - 1) === 47) end -= 1;
|
|
7
|
+
return text.slice(0, end);
|
|
8
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
2
|
import path from 'node:path';
|
|
3
|
+
import { byCodeUnit } from '../lib/list.mjs';
|
|
4
4
|
|
|
5
5
|
// One TypeScript program per (compiler, root names, compiler options, project references) inside a program run, and
|
|
6
6
|
// one value per (kind, compiler, input) for what callers derive from those programs (the architecture context).
|
|
@@ -27,7 +27,7 @@ function keyOf(value) {
|
|
|
27
27
|
}
|
|
28
28
|
if (typeof value !== 'object' || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) return null;
|
|
29
29
|
const entries = [];
|
|
30
|
-
for (const name of Object.keys(value).sort()) {
|
|
30
|
+
for (const name of Object.keys(value).sort(byCodeUnit)) {
|
|
31
31
|
const item = keyOf(value[name]);
|
|
32
32
|
if (item === null) return null;
|
|
33
33
|
entries.push(`${JSON.stringify(name)}:${item}`);
|
|
@@ -44,7 +44,10 @@ export function sharedInProgramRun(kind, ts, input, build) {
|
|
|
44
44
|
const key = values ? keyOf(input) : null;
|
|
45
45
|
if (key === null) return build();
|
|
46
46
|
let byKey = values.get(ts);
|
|
47
|
-
if (!byKey)
|
|
47
|
+
if (!byKey) {
|
|
48
|
+
byKey = new Map();
|
|
49
|
+
values.set(ts, byKey);
|
|
50
|
+
}
|
|
48
51
|
const id = `${kind}\0${key}`;
|
|
49
52
|
if (!byKey.has(id)) byKey.set(id, build());
|
|
50
53
|
return byKey.get(id);
|
|
@@ -74,4 +77,3 @@ export function typeScriptProjectReferencePath(ts, reference) {
|
|
|
74
77
|
export function resolveTypeScriptModule(ts, specifier, from, options, host = ts.sys) {
|
|
75
78
|
return ts.resolveModuleName(specifier, from, options, host).resolvedModule?.resolvedFileName;
|
|
76
79
|
}
|
|
77
|
-
|
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { allowsFile } from './allows.mjs';
|
|
7
7
|
import { openHfs } from './slots.mjs';
|
|
8
8
|
|
|
9
|
-
const posix = (p) => String(p).
|
|
9
|
+
const posix = (p) => String(p).replaceAll('\\', '/');
|
|
10
10
|
const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -58,8 +58,8 @@ export function shellCommands(body) {
|
|
|
58
58
|
} else if (char === '"' || char === "'") {
|
|
59
59
|
quote = char;
|
|
60
60
|
current += char;
|
|
61
|
-
} else if (
|
|
62
|
-
|
|
61
|
+
} else if (isCommandSeparator(body, i, char)) {
|
|
62
|
+
i += commandSeparatorAdvance(body, i, char);
|
|
63
63
|
flush();
|
|
64
64
|
} else if (char === '\\' && body[i + 1] === '\n') {
|
|
65
65
|
i += 1;
|
|
@@ -71,43 +71,17 @@ export function shellCommands(body) {
|
|
|
71
71
|
return commands;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
const isCommandSeparator = (body, index, char) => char === ';' || char === '|' || (char === '&' && body[index + 1] === '&');
|
|
75
|
+
const commandSeparatorAdvance = (body, index, char) => char !== ';' && body[index + 1] === char ? 1 : 0;
|
|
76
|
+
|
|
74
77
|
/**
|
|
75
78
|
* Parse a Dockerfile: { instructions: [{ keyword, text, line, stage }], stages: [{ index, name, image, line, instructions }],
|
|
76
79
|
* comments: [{ text, line }], preamble: [instruction before the first FROM (ARG)] }. `keyword` is upper-case; `text` is the
|
|
77
80
|
* argument text of the logical line; `stage` is the index of the stage the instruction belongs to (-1 before the first FROM).
|
|
78
81
|
*/
|
|
79
82
|
export function parseDockerfile(source) {
|
|
80
|
-
const lines = String(source).
|
|
81
|
-
const instructions =
|
|
82
|
-
const comments = [];
|
|
83
|
-
let escape = '\\';
|
|
84
|
-
let directives = true;
|
|
85
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
86
|
-
const raw = lines[i];
|
|
87
|
-
const trimmed = raw.trim();
|
|
88
|
-
if (trimmed === '') { directives = false; continue; }
|
|
89
|
-
if (trimmed.startsWith('#')) {
|
|
90
|
-
const body = trimmed.slice(1).trim();
|
|
91
|
-
const directive = directives ? /^(syntax|escape|check)\s*=\s*(\S+)$/i.exec(body) : null;
|
|
92
|
-
if (directive) { if (directive[1].toLowerCase() === 'escape') escape = directive[2]; continue; }
|
|
93
|
-
directives = false;
|
|
94
|
-
comments.push({ text: body, line: i + 1 });
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
directives = false;
|
|
98
|
-
const start = i + 1;
|
|
99
|
-
let logical = trimmed;
|
|
100
|
-
while (logical.endsWith(escape) && i + 1 < lines.length) {
|
|
101
|
-
logical = logical.slice(0, -1).trimEnd();
|
|
102
|
-
i += 1;
|
|
103
|
-
while (i < lines.length && lines[i].trim().startsWith('#')) i += 1;
|
|
104
|
-
if (i >= lines.length) break;
|
|
105
|
-
logical = `${logical} ${lines[i].trim()}`;
|
|
106
|
-
}
|
|
107
|
-
const split = logical.search(/\s/);
|
|
108
|
-
const keyword = (split < 0 ? logical : logical.slice(0, split)).toUpperCase();
|
|
109
|
-
instructions.push({ keyword, text: split < 0 ? '' : logical.slice(split).trim(), line: start, stage: -1 });
|
|
110
|
-
}
|
|
83
|
+
const lines = String(source).replaceAll('\r\n', '\n').split('\n');
|
|
84
|
+
const { instructions, comments } = parseInstructions(lines);
|
|
111
85
|
const stages = [];
|
|
112
86
|
const preamble = [];
|
|
113
87
|
for (const instruction of instructions) {
|
|
@@ -122,3 +96,52 @@ export function parseDockerfile(source) {
|
|
|
122
96
|
}
|
|
123
97
|
return { instructions, stages, comments, preamble };
|
|
124
98
|
}
|
|
99
|
+
|
|
100
|
+
function parseInstructions(lines) {
|
|
101
|
+
const instructions = [];
|
|
102
|
+
const comments = [];
|
|
103
|
+
const state = { escape: '\\', directives: true };
|
|
104
|
+
let i = 0;
|
|
105
|
+
while (i < lines.length) {
|
|
106
|
+
const parsed = parseInstructionLine(lines, i, state);
|
|
107
|
+
if (parsed.comment) comments.push(parsed.comment);
|
|
108
|
+
if (parsed.instruction) instructions.push(parsed.instruction);
|
|
109
|
+
i = parsed.nextLine + 1;
|
|
110
|
+
}
|
|
111
|
+
return { instructions, comments };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseInstructionLine(lines, index, state) {
|
|
115
|
+
const trimmed = lines[index].trim();
|
|
116
|
+
if (trimmed === '') { state.directives = false; return { nextLine: index }; }
|
|
117
|
+
if (trimmed.startsWith('#')) return parseCommentLine(trimmed, index, state);
|
|
118
|
+
state.directives = false;
|
|
119
|
+
const { logical, nextLine } = logicalInstructionOf(lines, index, state.escape);
|
|
120
|
+
const split = logical.search(/\s/);
|
|
121
|
+
const keyword = (split < 0 ? logical : logical.slice(0, split)).toUpperCase();
|
|
122
|
+
return { nextLine, instruction: { keyword, text: split < 0 ? '' : logical.slice(split).trim(), line: index + 1, stage: -1 } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseCommentLine(trimmed, index, state) {
|
|
126
|
+
const body = trimmed.slice(1).trim();
|
|
127
|
+
const directive = state.directives ? /^(syntax|escape|check)\s*=\s*(\S+)$/i.exec(body) : null;
|
|
128
|
+
if (directive) {
|
|
129
|
+
if (directive[1].toLowerCase() === 'escape') state.escape = directive[2];
|
|
130
|
+
return { nextLine: index };
|
|
131
|
+
}
|
|
132
|
+
state.directives = false;
|
|
133
|
+
return { nextLine: index, comment: { text: body, line: index + 1 } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logicalInstructionOf(lines, index, escape) {
|
|
137
|
+
let logical = lines[index].trim();
|
|
138
|
+
let nextLine = index;
|
|
139
|
+
while (logical.endsWith(escape) && nextLine + 1 < lines.length) {
|
|
140
|
+
logical = logical.slice(0, -1).trimEnd();
|
|
141
|
+
nextLine += 1;
|
|
142
|
+
while (nextLine < lines.length && lines[nextLine].trim().startsWith('#')) nextLine += 1;
|
|
143
|
+
if (nextLine >= lines.length) break;
|
|
144
|
+
logical = `${logical} ${lines[nextLine].trim()}`;
|
|
145
|
+
}
|
|
146
|
+
return { logical, nextLine };
|
|
147
|
+
}
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export const unquoteDiffPath = (value) =>
|
|
9
9
|
/^".*"$/.test(value)
|
|
10
|
-
? JSON.parse(value.replace(/\\([0-7]{3})/g, (_, octal) =>
|
|
10
|
+
? JSON.parse(value.replace(/\\([0-7]{3})/g, (_, octal) => String.raw`\u00${Number.parseInt(octal, 8).toString(16).padStart(2, '0')}`))
|
|
11
11
|
: value;
|
|
12
12
|
|
|
13
13
|
// git's repository-local variables (git rev-parse --local-env-vars). A hook or alias run in a linked worktree exports
|
|
14
14
|
// GIT_DIR=<main>/.git/worktrees/<wt>; a spec inheriting it pointed every fixture git at the LIVE .claude repo and re-inited
|
|
15
15
|
// it core.bare=true (2026-09-29, a6f60352c). tests/setup/isolated-registry.mjs drops the same list.
|
|
16
|
-
export const GIT_LOCAL_ENV_VARS = ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_IMPLICIT_WORK_TREE', 'GIT_PREFIX', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_GRAFT_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_SHALLOW_FILE'];
|
|
16
|
+
export const GIT_LOCAL_ENV_VARS = ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_IMPLICIT_WORK_TREE', 'GIT_PREFIX', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_GRAFT_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_SHALLOW_FILE', 'GIT_INTERNAL_SUPER_PREFIX'];
|
|
17
17
|
/** `parent` (the caller's env) without git's repository-local variables (GIT_CONFIG_KEY_n/VALUE_n go with GIT_CONFIG_COUNT). Pure. */
|
|
18
18
|
export function withoutGitLocalEnv(parent) {
|
|
19
19
|
const env = { ...parent };
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// A placeholder is `{name}`; the English and the Vietnamese of one entry carry exactly the same placeholders
|
|
12
12
|
// (scripts/checks/check-i18n-catalog.mjs). An English source with no entry is returned as is: a missing translation degrades
|
|
13
13
|
// to English, never to a blank.
|
|
14
|
+
import { byCodeUnit } from './list.mjs';
|
|
14
15
|
import { escapeRegExp } from './regex.mjs';
|
|
15
16
|
import fs from 'node:fs';
|
|
16
17
|
import path from 'node:path';
|
|
@@ -19,7 +20,7 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
19
20
|
import { DEFAULT_OWNER_LANGUAGE, loadConfig } from '../../engine/config.mjs';
|
|
20
21
|
|
|
21
22
|
export const CATALOG_DIR = 'modules/i18n/messages';
|
|
22
|
-
export const PLACEHOLDER = /\{([A-Za-z_]
|
|
23
|
+
export const PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
|
|
23
24
|
|
|
24
25
|
const cache = new Map();
|
|
25
26
|
|
|
@@ -54,7 +55,7 @@ export const resetCatalogCache = () => cache.clear();
|
|
|
54
55
|
/** The names each `pattern` match captures (group 1) in `text`, in match order (`sort` sorts them). */
|
|
55
56
|
export const captureNames = (text, pattern, { sort = false } = {}) => {
|
|
56
57
|
const names = [...String(text).matchAll(pattern)].map((m) => m[1]);
|
|
57
|
-
return sort ? names.sort() : names;
|
|
58
|
+
return sort ? names.sort(byCodeUnit) : names;
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
/** The names of the placeholders of a message. */
|
|
@@ -50,13 +50,13 @@ export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
|
|
|
50
50
|
'modules/ops/_labels.yaml': Object.freeze({ flowFields: Object.freeze(['vi']) }),
|
|
51
51
|
'modules/goal/archetypes.yaml': Object.freeze({
|
|
52
52
|
fields: Object.freeze([
|
|
53
|
-
'buildIntent', 'canonIntent', 'e2eIntent', 'uatIntent', 'proofNegation', '
|
|
53
|
+
'buildIntent', 'canonIntent', 'e2eIntent', 'uatIntent', 'proofNegation', 'proofStateCue', 'integrationIntent', 'brandIntent',
|
|
54
54
|
'phrases', 'requires', 'excludes', 'backend', 'frontend', 'package',
|
|
55
55
|
]),
|
|
56
56
|
}),
|
|
57
57
|
'modules/goal/source-phrases.yaml': Object.freeze({
|
|
58
58
|
fields: Object.freeze([
|
|
59
|
-
'prefer', 'avoid', 'negation', 'refused', 'accept', 'golden',
|
|
59
|
+
'prefer', 'avoid', 'only', 'negation', 'refused', 'accept', 'golden',
|
|
60
60
|
'missingPaths', 'grantTooNarrow', 'toolTimeout', 'testGap', 'checkerUnavailable',
|
|
61
61
|
'supervisorVerbs', 'ownerWord', 'orWord', 'beyondAuthority', 'ownerOnly', 'workerDied', 'contractConflict', 'decision',
|
|
62
62
|
'product', 'grammar', 'knowledge', 'sourceLabel', 'internalVocabulary', 'actor', 'success',
|
|
@@ -124,7 +124,7 @@ export function documentLanguageHits(rel, text) {
|
|
|
124
124
|
if (declared.fields?.includes(keys[hit.line - 1])) return false;
|
|
125
125
|
if (!declared.flowFields) return true;
|
|
126
126
|
// an inline flow map: drop the declared fields' values and judge what is left of the line
|
|
127
|
-
const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(
|
|
127
|
+
const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(String.raw`\b${field}\s*:\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^,}]*)`, 'g'), ''), lines[hit.line - 1]);
|
|
128
128
|
return hasSecondLanguage(rest);
|
|
129
129
|
});
|
|
130
130
|
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
export const list = (v) => (Array.isArray(v) ? v : []);
|
|
5
5
|
|
|
6
6
|
/** `v` as an array: itself when already one, [] for null/undefined, else [v]. */
|
|
7
|
-
export const asList = (v) =>
|
|
7
|
+
export const asList = (v) => {
|
|
8
|
+
if (Array.isArray(v)) return v;
|
|
9
|
+
if (v == null) return [];
|
|
10
|
+
return [v];
|
|
11
|
+
};
|
|
8
12
|
|
|
9
13
|
/** The object entries of `v` (a non-array reads as []). */
|
|
10
14
|
export const objectList = (v) => (Array.isArray(v) ? v : []).filter((item) => item && typeof item === 'object');
|
|
@@ -18,3 +22,6 @@ export const splitList = (v, { sep = ',', dedupe = false } = {}) => {
|
|
|
18
22
|
const items = String(v ?? '').split(sep).map((s) => s.trim()).filter(Boolean);
|
|
19
23
|
return dedupe ? [...new Set(items)] : items;
|
|
20
24
|
};
|
|
25
|
+
|
|
26
|
+
// The comparator of a bare `sort()` (UTF-16 code-unit order) is owned by the engine, which cannot import scripts/lib.
|
|
27
|
+
export { byCodeUnit } from '../../engine/by-code-unit.mjs';
|
|
@@ -6,6 +6,17 @@ const WIN = process.platform === 'win32';
|
|
|
6
6
|
|
|
7
7
|
/** `p` with every backslash turned into a forward slash; null and undefined read as ''. */
|
|
8
8
|
export const slash = (p) => String(p ?? '').replaceAll('\\', '/');
|
|
9
|
+
/** `text` without its trailing '/' run (a loop: a trailing-run pattern backtracks super-linearly). */
|
|
10
|
+
export const trimTrailingSlashes = (text) => {
|
|
11
|
+
for (let end = text.length; ; end -= 1) if (end === 0 || text[end - 1] !== '/') return text.slice(0, end);
|
|
12
|
+
};
|
|
13
|
+
// A trailing '*' run with the one '/' before it (the glob suffix of 'dir/**'); text without a trailing '*' is unchanged.
|
|
14
|
+
const stripTrailingStars = (text) => {
|
|
15
|
+
let end = text.length;
|
|
16
|
+
while (end > 0 && text[end - 1] === '*') end -= 1;
|
|
17
|
+
if (end === text.length) return text;
|
|
18
|
+
return text.slice(0, end > 0 && text[end - 1] === '/' ? end - 1 : end);
|
|
19
|
+
};
|
|
9
20
|
/** A relative path in git's spelling: forward slashes, no leading './'. */
|
|
10
21
|
export const posixPath = (p) => slash(p).replace(/^\.\//, '');
|
|
11
22
|
/** Whether posix `file` is `prefix` itself or under it (`file === prefix || file` starts with `prefix/`). */
|
|
@@ -19,7 +30,7 @@ export const samePath = (a, b) => foldCase(a) === foldCase(b);
|
|
|
19
30
|
* `fold: true` folds case on every platform (a comparison against a lowercase-stored root needs it).
|
|
20
31
|
*/
|
|
21
32
|
export const pathKey = (p, { fold = WIN } = {}) => {
|
|
22
|
-
const key = slash(path.resolve(p))
|
|
33
|
+
const key = trimTrailingSlashes(slash(path.resolve(p)));
|
|
23
34
|
return fold ? key.toLowerCase() : key;
|
|
24
35
|
};
|
|
25
36
|
/** `a` is `b` or under it, or the reverse: two path spellings whose trees touch. */
|
|
@@ -36,19 +47,28 @@ export const normPath = (p, { trim = false, collapse = false, dot = false, glob
|
|
|
36
47
|
s = slash(s);
|
|
37
48
|
if (collapse) s = s.replace(/\/{2,}/g, '/');
|
|
38
49
|
if (dot) s = s.replace(/^\.\//, '');
|
|
39
|
-
if (glob === 'star') s = s
|
|
50
|
+
if (glob === 'star') s = stripTrailingStars(s);
|
|
40
51
|
else if (glob === 'double') s = s.replace(/\/\*\*$/, '');
|
|
41
|
-
s = s
|
|
52
|
+
s = trimTrailingSlashes(s);
|
|
42
53
|
return fold ? s.toLowerCase() : s;
|
|
43
54
|
};
|
|
44
55
|
/** A work-relative path in one spelling: forward slashes, no leading ./, no trailing slash. */
|
|
45
|
-
export const normWork = (p) => String(p ?? '').trim().replaceAll('\\', '/').replace(/^\.\/+/, '')
|
|
56
|
+
export const normWork = (p) => trimTrailingSlashes(String(p ?? '').trim().replaceAll('\\', '/').replace(/^\.\/+/, ''));
|
|
46
57
|
|
|
47
58
|
/** `p` resolved and dereferenced (realpath where the path exists) — the canonical spelling of a checkout dir. */
|
|
48
59
|
export const realPath = (p) => {
|
|
49
60
|
const resolved = path.resolve(p);
|
|
50
61
|
try { return fs.realpathSync.native(resolved); } catch { return resolved; }
|
|
51
62
|
};
|
|
63
|
+
/** `p` with its nearest existing ancestor dereferenced and the not-yet-created rest appended: a root's canonical spelling before it exists. */
|
|
64
|
+
export const canonicalPath = (p) => {
|
|
65
|
+
const rest = [];
|
|
66
|
+
for (let at = path.resolve(p); ; at = path.dirname(at)) {
|
|
67
|
+
try { return path.join(fs.realpathSync.native(at), ...rest); } catch { /* not created yet */ }
|
|
68
|
+
if (path.dirname(at) === at) return path.resolve(p);
|
|
69
|
+
rest.unshift(path.basename(at));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
52
72
|
/** `p` resolved and case-folded where the filesystem ignores case — the Map key of an absolute path. */
|
|
53
73
|
export const resolvedKey = (p) => foldCase(path.resolve(p));
|
|
54
74
|
/** Whether `a` and `b` resolve to the same path (always case-folded, so differently-cased spellings count). */
|
|
@@ -61,12 +81,25 @@ export const insidePath = (root, file, { key = (p) => p, includeSelf = false } =
|
|
|
61
81
|
const rel = path.relative(key(root), key(file));
|
|
62
82
|
return (includeSelf || Boolean(rel)) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
63
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* `candidate` (absolute, or relative to `base`) resolved and dereferenced, required to sit under `base` (or be it):
|
|
86
|
+
* the boundary check for a path an operator, an agent or another process hands in. `base` is canonicalised once and the
|
|
87
|
+
* candidate is judged below it, so `..` segments and links leading out are refused; returns the canonical path, or throws
|
|
88
|
+
* a PATH_OUTSIDE_BASE refusal (`label` names the argument in the message).
|
|
89
|
+
*/
|
|
90
|
+
export const containedPath = (base, candidate, { label = 'path' } = {}) => {
|
|
91
|
+
const root = canonicalPath(base);
|
|
92
|
+
const resolved = canonicalPath(path.resolve(root, String(candidate ?? '')));
|
|
93
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
94
|
+
if (!insidePath(root, resolved, { includeSelf: true }) || (resolved !== root && !resolved.startsWith(prefix))) throw Object.assign(new Error(`${label} ${String(candidate)} is outside ${root} [PATH_OUTSIDE_BASE]`), { code: 'PATH_OUTSIDE_BASE' });
|
|
95
|
+
return resolved;
|
|
96
|
+
};
|
|
64
97
|
/**
|
|
65
98
|
* A path-ish value (a string or a {path} entry) in relative compare form: trimmed, forward slashes,
|
|
66
99
|
* no trailing '/**' glob or slashes, no leading './'; `fold` lowercases for a case-insensitive key.
|
|
67
100
|
*/
|
|
68
101
|
export const normRel = (p, { fold = false } = {}) => {
|
|
69
|
-
const s = String(typeof p === 'string' ? p : p?.path ?? '').trim().
|
|
102
|
+
const s = trimTrailingSlashes(String(typeof p === 'string' ? p : p?.path ?? '').trim().replaceAll('\\', '/').replace(/\/\*\*$/, '')).replace(/^\.\//, '');
|
|
70
103
|
return fold ? s.toLowerCase() : s;
|
|
71
104
|
};
|
|
72
105
|
/** Whether `file` equals or sits under one of `prefixes` (posix spellings; `dot` counts a '.' prefix as covering all). */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// regex.mjs - building a RegExp from text.
|
|
2
2
|
|
|
3
3
|
/** `text` with every regular-expression metacharacter escaped, so it matches itself literally. */
|
|
4
|
-
export const escapeRegExp = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g,
|
|
4
|
+
export const escapeRegExp = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
@@ -15,7 +15,7 @@ export const FORBIDDEN_FILES = [
|
|
|
15
15
|
export const SECRET_PATTERNS = [
|
|
16
16
|
{ name: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
17
17
|
{ name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
18
|
-
{ name: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_
|
|
18
|
+
{ name: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_\w{50,}\b/ },
|
|
19
19
|
{ name: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
20
20
|
{ name: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
21
21
|
{ name: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
@@ -28,7 +28,11 @@ export const SECRET_PATTERNS = [
|
|
|
28
28
|
// candidate; only this heuristic takes the exemption, never a provider-shaped token above.
|
|
29
29
|
// A spec file's keyword-assigned values are test inputs (a spec's sign-in
|
|
30
30
|
// form password and a mocked accessToken once refused a push): the heuristic skips spec files too.
|
|
31
|
-
{ name: 'assigned-secret', re:
|
|
31
|
+
{ name: 'assigned-secret', re: new RegExp([
|
|
32
|
+
String.raw`\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)`,
|
|
33
|
+
String.raw`\b\s*[:=]\s*['"](?!env\([A-Z_][A-Z0-9_]*\)['"])([^'"\s$<{]{12,})`,
|
|
34
|
+
`['"]`,
|
|
35
|
+
].join(''), 'i'), placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
|
|
32
36
|
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
|
|
33
37
|
];
|
|
34
38
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// sonar-syntax-rules.mjs - the ESLint rules that keep a TypeScript app clear of the smells SonarCloud flagged in the example apps, judged on
|
|
2
|
+
// the syntax tree alone (no type information, no dependency). Both lint canons (@starci/eslint-canon-be and -fe) publish them from their
|
|
3
|
+
// bundled runtime copy under the names below, so a product inherits them and `starci app lint` refuses the finding before the scan sees it.
|
|
4
|
+
// Each message names the Sonar rule it stands for, and a rule judges the files the example scans judge: a unit or end-to-end spec is outside the
|
|
5
|
+
// scan (`sonar.exclusions`), so it is outside these rules. The rules that need the type checker (S1874, S6551, S7503) are borrowed from
|
|
6
|
+
// typescript-eslint by the canon factories; the one that needs a regex ambiguity analysis (S8786) runs in the runtime's own `sonar-rules` check.
|
|
7
|
+
|
|
8
|
+
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
9
|
+
const REPEATED = Object.freeze({
|
|
10
|
+
ForStatement: ['body', 'test', 'update'],
|
|
11
|
+
ForInStatement: ['body'],
|
|
12
|
+
ForOfStatement: ['body'],
|
|
13
|
+
WhileStatement: ['body', 'test'],
|
|
14
|
+
DoWhileStatement: ['body', 'test'],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** The first ancestor of `node` that is `type`-matching per `test`, stopping at a function or class boundary. */
|
|
18
|
+
function ancestorWithin(node, test) {
|
|
19
|
+
for (let child = node, parent = node.parent; parent; child = parent, parent = parent.parent) {
|
|
20
|
+
if (FUNCTIONS.has(parent.type) || parent.type === 'StaticBlock') return null;
|
|
21
|
+
if (test(parent, child)) return parent;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const repeatsChild = (loop, child) => (REPEATED[loop.type] ?? []).some((key) => loop[key] === child);
|
|
27
|
+
|
|
28
|
+
/** A loop with no condition (`for (;;)`, `while (true)`) walks pages or polls: each turn needs the one before, so it cannot be run together. */
|
|
29
|
+
const isEndless = (loop) => (loop.type === 'ForStatement' && loop.test === null) || (['WhileStatement', 'DoWhileStatement'].includes(loop.type) && loop.test.type === 'Literal' && loop.test.value === true);
|
|
30
|
+
|
|
31
|
+
/** S9382: an `await` inside a loop body runs one step after the other; say the sequence by name or run the independent steps together. */
|
|
32
|
+
const noAwaitInLoop = {
|
|
33
|
+
meta: {
|
|
34
|
+
type: 'suggestion',
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: { loop: 'S9382: do not `await` inside a loop. Run independent steps together with `Promise.all`, or state the sequence by name with a helper that chains the promises (`eachInOrder` of the back end\'s `platform/primitives`).' },
|
|
37
|
+
},
|
|
38
|
+
create: (context) => ({
|
|
39
|
+
AwaitExpression: (node) => {
|
|
40
|
+
if (ancestorWithin(node, (loop, child) => repeatsChild(loop, child) && !isEndless(loop))) context.report({ node, messageId: 'loop' });
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** S7758: `codePointAt` and `fromCodePoint` handle every code point; `charCodeAt` and `fromCharCode` stop at 16 bits. */
|
|
46
|
+
const preferCodePoint = {
|
|
47
|
+
meta: { type: 'suggestion', schema: [], messages: { codePoint: 'S7758: use `{{ better }}` instead of `{{ worse }}`; it reads whole code points, not 16-bit halves.' } },
|
|
48
|
+
create: (context) => ({
|
|
49
|
+
CallExpression: (node) => {
|
|
50
|
+
const callee = node.callee;
|
|
51
|
+
if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return;
|
|
52
|
+
const name = callee.property.name;
|
|
53
|
+
if (name === 'charCodeAt') context.report({ node, messageId: 'codePoint', data: { better: 'codePointAt', worse: 'charCodeAt' } });
|
|
54
|
+
else if (name === 'fromCharCode' && callee.object.type === 'Identifier' && callee.object.name === 'String') context.report({ node, messageId: 'codePoint', data: { better: 'String.fromCodePoint', worse: 'String.fromCharCode' } });
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const BACKSLASH_PAIR = '\\\\';
|
|
60
|
+
|
|
61
|
+
/** Whether the string literal only escapes backslashes: its value is its raw text with each pair read as one. */
|
|
62
|
+
function onlyBackslashEscapes(node) {
|
|
63
|
+
const raw = node.raw;
|
|
64
|
+
if (typeof node.value !== 'string' || !raw.includes(BACKSLASH_PAIR) || raw.at(-2) === '\\') return false;
|
|
65
|
+
if (node.value.includes('`') || node.value.includes('${')) return false;
|
|
66
|
+
return raw.slice(1, -1).replaceAll(BACKSLASH_PAIR, '\\') === node.value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const KEEPS_ITS_ESCAPES = new Set(['ImportDeclaration', 'ExportAllDeclaration', 'ExportNamedDeclaration', 'TSLiteralType', 'TSExternalModuleReference', 'JSXAttribute']);
|
|
70
|
+
|
|
71
|
+
/** A property named `matcher` is read statically by Next (a template literal there kills the build), so its text keeps the escape. */
|
|
72
|
+
const isMatcherValue = (node) => (node.parent?.type === 'Property' && node.parent.value === node && node.parent.key?.name === 'matcher')
|
|
73
|
+
|| (node.parent?.type === 'ArrayExpression' && isMatcherValue(node.parent));
|
|
74
|
+
|
|
75
|
+
/** S7780: a string whose only escapes are backslashes reads better as `String.raw`. */
|
|
76
|
+
const preferStringRaw = {
|
|
77
|
+
meta: { type: 'suggestion', schema: [], messages: { raw: 'S7780: write this string with `String.raw` so its backslashes are not escaped.' } },
|
|
78
|
+
create: (context) => ({
|
|
79
|
+
Literal: (node) => {
|
|
80
|
+
if (KEEPS_ITS_ESCAPES.has(node.parent?.type) || node.parent?.type === 'ExpressionStatement' || isMatcherValue(node)) return;
|
|
81
|
+
if (onlyBackslashEscapes(node)) context.report({ node, messageId: 'raw' });
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** S3358: a conditional expression inside another conditional expression is read inside out; name the inner one. */
|
|
87
|
+
const noNestedConditional = {
|
|
88
|
+
meta: { type: 'suggestion', schema: [], messages: { nested: 'S3358: extract this nested conditional expression into its own statement or a named function.' } },
|
|
89
|
+
create: (context) => ({
|
|
90
|
+
ConditionalExpression: (node) => {
|
|
91
|
+
if (node.parent?.type === 'ConditionalExpression') context.report({ node, messageId: 'nested' });
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Whether `void` is used on a call: the explicit discard of a promise that Sonar and no-floating-promises both accept. */
|
|
97
|
+
const discardsACall = (argument) => argument.type === 'CallExpression' || argument.type === 'AwaitExpression' || (argument.type === 'ChainExpression' && argument.expression.type === 'CallExpression');
|
|
98
|
+
|
|
99
|
+
/** S3735: `void` on anything but a call hides what the code does; `void principal` says nothing, `undefined` or no statement says it. */
|
|
100
|
+
const noVoidOperator = {
|
|
101
|
+
meta: { type: 'suggestion', schema: [], messages: { void: 'S3735: remove this `void`; it discards a value the code never needed. Only `void call()` (an explicit discard of a promise) is kept.' } },
|
|
102
|
+
create: (context) => ({
|
|
103
|
+
UnaryExpression: (node) => {
|
|
104
|
+
if (node.operator === 'void' && !discardsACall(node.argument)) context.report({ node, messageId: 'void' });
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** S1128: an import no reference reads is removed. */
|
|
110
|
+
const noUnusedImport = {
|
|
111
|
+
meta: { type: 'suggestion', schema: [], messages: { unused: 'S1128: remove the unused import of `{{ name }}`.' } },
|
|
112
|
+
create: (context) => ({
|
|
113
|
+
ImportDeclaration: (node) => {
|
|
114
|
+
for (const variable of context.sourceCode.getDeclaredVariables(node)) {
|
|
115
|
+
if (variable.references.length === 0) context.report({ node: variable.identifiers[0] ?? node, messageId: 'unused', data: { name: variable.name } });
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Whether `identifier` names an import of the file that nothing else reads. */
|
|
122
|
+
function onlyReExported(sourceCode, identifier) {
|
|
123
|
+
let scope = sourceCode.getScope(identifier);
|
|
124
|
+
while (scope?.set.has(identifier.name) === false) scope = scope.upper;
|
|
125
|
+
const variable = scope?.set.get(identifier.name);
|
|
126
|
+
const definition = variable?.defs[0];
|
|
127
|
+
return definition?.type === 'ImportBinding' && variable.references.length === 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** S7763: an import that is only handed on is re-exported with `export ... from`. */
|
|
131
|
+
const preferExportFrom = {
|
|
132
|
+
meta: { type: 'suggestion', schema: [], messages: { reexport: 'S7763: use `export ... from` to re-export `{{ name }}` instead of importing it first.' } },
|
|
133
|
+
create: (context) => {
|
|
134
|
+
const { sourceCode } = context;
|
|
135
|
+
const check = (identifier, node) => {
|
|
136
|
+
if (identifier?.type === 'Identifier' && onlyReExported(sourceCode, identifier)) context.report({ node, messageId: 'reexport', data: { name: identifier.name } });
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
ExportNamedDeclaration: (node) => {
|
|
140
|
+
if (node.source) return;
|
|
141
|
+
for (const specifier of node.specifiers) check(specifier.local, specifier);
|
|
142
|
+
for (const declarator of node.declaration?.declarations ?? []) if (!declarator.id.typeAnnotation) check(declarator.init, declarator);
|
|
143
|
+
},
|
|
144
|
+
ExportDefaultDeclaration: (node) => check(node.declaration, node),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The members a props type declares: [{name, node}] from a type literal, an interface body, `Readonly<...>` or an intersection of those declared in the file. */
|
|
150
|
+
function declaredMembers(program, type) {
|
|
151
|
+
if (!type) return [];
|
|
152
|
+
if (type.type === 'TSTypeLiteral') return type.members.filter((member) => !member.computed && member.key).map((member) => ({ name: member.key.name ?? member.key.value, node: member.key }));
|
|
153
|
+
if (type.type === 'TSIntersectionType') return type.types.flatMap((part) => declaredMembers(program, part));
|
|
154
|
+
if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return [];
|
|
155
|
+
if (type.typeName.name === 'Readonly') return declaredMembers(program, type.typeArguments?.params?.[0]);
|
|
156
|
+
const declaration = program.body.map((statement) => statement.declaration ?? statement).find((statement) => (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') && statement.id.name === type.typeName.name);
|
|
157
|
+
if (declaration?.type === 'TSInterfaceDeclaration') return declaredMembers(program, { type: 'TSTypeLiteral', members: declaration.body.body });
|
|
158
|
+
return declaredMembers(program, declaration?.typeAnnotation);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const keyName = (key) => (key.type === 'Identifier' ? key.name : key.value);
|
|
162
|
+
|
|
163
|
+
/** The prop names a use of the parameter variable reads, or null when it escapes whole (passed on, spread, indexed dynamically). */
|
|
164
|
+
function readNames(variable) {
|
|
165
|
+
const names = new Set();
|
|
166
|
+
for (const reference of variable.references) {
|
|
167
|
+
const parent = reference.identifier.parent;
|
|
168
|
+
if (parent.type === 'MemberExpression' && parent.object === reference.identifier && (!parent.computed || parent.property.type === 'Literal')) names.add(keyName(parent.property));
|
|
169
|
+
else if (parent.type === 'VariableDeclarator' && parent.init === reference.identifier && parent.id.type === 'ObjectPattern' && parent.id.properties.every((property) => property.type === 'Property' && !property.computed)) parent.id.properties.forEach((property) => names.add(keyName(property.key)));
|
|
170
|
+
else return null;
|
|
171
|
+
}
|
|
172
|
+
return names;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The names a parameter reads: an object pattern's own keys, or what its variable's uses read; null means "all of them". */
|
|
176
|
+
function readByParameter(sourceCode, fn, parameter) {
|
|
177
|
+
if (parameter.type === 'ObjectPattern') {
|
|
178
|
+
if (parameter.properties.some((property) => property.type !== 'Property' || property.computed)) return null;
|
|
179
|
+
return new Set(parameter.properties.map((property) => keyName(property.key)));
|
|
180
|
+
}
|
|
181
|
+
if (parameter.type !== 'Identifier') return null;
|
|
182
|
+
const variable = sourceCode.getDeclaredVariables(fn).find((candidate) => candidate.name === parameter.name);
|
|
183
|
+
return variable ? readNames(variable) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** S6767: a member of a component's props type that the component never reads is removed from the type. */
|
|
187
|
+
const noUnusedPropTypes = {
|
|
188
|
+
meta: { type: 'suggestion', schema: [], messages: { unused: 'S6767: the prop `{{ name }}` is declared in the props type but the component never reads it; remove it.' } },
|
|
189
|
+
create: (context) => {
|
|
190
|
+
const { sourceCode } = context;
|
|
191
|
+
const frames = [];
|
|
192
|
+
const enter = (node) => frames.push({ node, jsx: false });
|
|
193
|
+
const markJsx = () => frames.forEach((frame) => { frame.jsx = true; });
|
|
194
|
+
const exit = () => {
|
|
195
|
+
const { node, jsx } = frames.pop();
|
|
196
|
+
const parameter = node.params[0];
|
|
197
|
+
const annotation = parameter?.typeAnnotation?.typeAnnotation;
|
|
198
|
+
if (!jsx || !annotation) return;
|
|
199
|
+
const read = readByParameter(sourceCode, node, parameter);
|
|
200
|
+
if (read === null) return;
|
|
201
|
+
for (const member of declaredMembers(sourceCode.ast, annotation)) {
|
|
202
|
+
if (!read.has(member.name)) context.report({ node: member.node, messageId: 'unused', data: { name: member.name } });
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return { FunctionDeclaration: enter, FunctionExpression: enter, ArrowFunctionExpression: enter, 'FunctionDeclaration:exit': exit, 'FunctionExpression:exit': exit, 'ArrowFunctionExpression:exit': exit, JSXElement: markJsx, JSXFragment: markJsx };
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** The files the example scans leave out: a unit spec and an end-to-end spec. */
|
|
210
|
+
const OUTSIDE_THE_SCAN = /\.(?:e2e-)?spec\.[cm]?[jt]sx?$/;
|
|
211
|
+
|
|
212
|
+
/** The rule judging only the files the scan judges. */
|
|
213
|
+
const scanned = (rule) => ({ ...rule, create: (context) => (OUTSIDE_THE_SCAN.test(context.filename) ? {} : rule.create(context)) });
|
|
214
|
+
|
|
215
|
+
/** The rules both canons publish, by published name. */
|
|
216
|
+
export const SONAR_SYNTAX_RULES = Object.freeze({
|
|
217
|
+
'no-await-in-loop': scanned(noAwaitInLoop),
|
|
218
|
+
'prefer-code-point': scanned(preferCodePoint),
|
|
219
|
+
'prefer-string-raw': scanned(preferStringRaw),
|
|
220
|
+
'no-nested-conditional': scanned(noNestedConditional),
|
|
221
|
+
'no-void-operator': scanned(noVoidOperator),
|
|
222
|
+
'no-unused-import': scanned(noUnusedImport),
|
|
223
|
+
'prefer-export-from': scanned(preferExportFrom),
|
|
224
|
+
'no-unused-prop-types': scanned(noUnusedPropTypes),
|
|
225
|
+
});
|