@blundergoat/gruff-ts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// Config loading and YAML-subset parsing for the analyzer's zero-dependency rule defaults.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import type { AnalysisOptions, Config, Severity } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
type RuleOverride = Config["rules"] extends Map<string, infer RuleOverrideValue> ? RuleOverrideValue : never;
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG_FILES = [".gruff-ts.yaml", ".gruff.json", ".gruff.yaml", ".gruff.yml"] as const;
|
|
9
|
+
const YAML_KEYWORD_SCALARS = new Map<string, boolean | null>([
|
|
10
|
+
["true", true],
|
|
11
|
+
["false", false],
|
|
12
|
+
["null", null],
|
|
13
|
+
["~", null],
|
|
14
|
+
]);
|
|
15
|
+
const YAML_NUMBER_SCALAR = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
16
|
+
|
|
17
|
+
// Result of one scalar parser attempt. `isMatched: false` lets the dispatcher fall through to the
|
|
18
|
+
// next parser instead of mistaking `undefined` for a successfully parsed null value.
|
|
19
|
+
interface ParsedYamlScalar {
|
|
20
|
+
isMatched: boolean;
|
|
21
|
+
value: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const UNMATCHED_YAML_SCALAR: ParsedYamlScalar = { isMatched: false, value: undefined };
|
|
25
|
+
|
|
26
|
+
// Built-in defaults applied before any user config overlays. The string lists here (accepted
|
|
27
|
+
// abbreviations, banned generic names, boolean prefixes, …) are the stable rule contract - they
|
|
28
|
+
// shape what every gruff scan emits by default and changing them shifts the public rule surface.
|
|
29
|
+
function defaultConfig(): Config {
|
|
30
|
+
return {
|
|
31
|
+
ignoredPaths: [],
|
|
32
|
+
acceptedAbbreviations: new Set(["id", "db", "fs", "io", "ui", "tx", "rx"]),
|
|
33
|
+
secretPreviews: new Set(),
|
|
34
|
+
bannedGenericNames: new Set(["process", "handle", "doit", "run", "execute", "manage"]),
|
|
35
|
+
booleanPrefixes: new Set(["is", "has", "can", "should", "does", "did", "was", "will", "may", "in", "scan", "supports", "requires"]),
|
|
36
|
+
hungarianPrefixes: new Set(["str", "obj", "arr", "bool", "int", "num"]),
|
|
37
|
+
placeholderNames: new Set(["foo", "bar", "baz", "tmp", "temp", "thing", "stuff", "data", "value", "item"]),
|
|
38
|
+
abbreviationDenylist: new Set(["ctx", "pkg", "opts", "fn", "idx", "cb"]),
|
|
39
|
+
negativeBooleanAllowed: new Set(["nostore", "nofollow", "noreferrer", "noscript", "noindex"]),
|
|
40
|
+
knownAcronyms: new Set(["url", "http", "https", "id", "xml", "json", "html", "css", "api", "sql", "db", "io", "ui", "uuid", "ip", "tcp", "udp", "ast", "cli", "npm"]),
|
|
41
|
+
rules: new Map(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Anchors a relative CLI argument against the project root; absolute paths pass through unchanged.
|
|
46
|
+
function absolutize(projectRoot: string, path: string): string {
|
|
47
|
+
return isAbsolute(path) ? path : join(projectRoot, path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reads the YAML config from disk (if any) and overlays user values onto `defaultConfig`. `shouldSkipConfig`
|
|
51
|
+
// is the explicit opt-out; missing default file is silent (returns defaults) so projects without
|
|
52
|
+
// `.gruff-ts.yaml` work zero-config. Throws on malformed YAML - the caller surfaces it as a fatal CLI error.
|
|
53
|
+
function loadConfig(projectRoot: string, options: AnalysisOptions): Config {
|
|
54
|
+
const config = defaultConfig();
|
|
55
|
+
if (options.shouldSkipConfig) {
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
const path = selectedConfigPath(projectRoot, options);
|
|
59
|
+
if (!path) {
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
applyConfigValues(config, parseConfigFile(path));
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Explicit `--config` wins; otherwise look for the first supported config at the project root. Returning
|
|
68
|
+
// undefined means "no config" - callers must treat that as "use defaults", not as an error.
|
|
69
|
+
function selectedConfigPath(projectRoot: string, options: AnalysisOptions): string | undefined {
|
|
70
|
+
return options.config ? absolutize(projectRoot, options.config) : defaultConfigPath(projectRoot);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Three top-level sections of the config schema applied in a fixed order: paths → allowlists →
|
|
74
|
+
// rules. Order does not affect correctness today but the stable application order keeps later
|
|
75
|
+
// overrides predictable if interdependencies are added.
|
|
76
|
+
function applyConfigValues(config: Config, raw: Record<string, unknown>): void {
|
|
77
|
+
applyPathConfig(config, raw);
|
|
78
|
+
applyAllowlistConfig(config, raw);
|
|
79
|
+
applyRuleConfig(config, raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Replaces `ignoredPaths` with the user list. Non-string entries are silently dropped - invalid
|
|
83
|
+
// YAML shapes should not abort the analysis run.
|
|
84
|
+
function applyPathConfig(config: Config, raw: Record<string, unknown>): void {
|
|
85
|
+
const paths = objectValue(raw.paths);
|
|
86
|
+
config.ignoredPaths = arrayValue(paths?.ignore).filter(isString);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The allowlist section is the main lever users have for tuning gruff to their conventions.
|
|
90
|
+
// `acceptedAbbreviations` and the seven naming lists are lowercased on import so case-insensitive
|
|
91
|
+
// matching is the stable behaviour regardless of how users write entries.
|
|
92
|
+
function applyAllowlistConfig(config: Config, raw: Record<string, unknown>): void {
|
|
93
|
+
const allowlists = objectValue(raw.allowlists);
|
|
94
|
+
const abbreviations = arrayValue(allowlists?.acceptedAbbreviations).filter(isString);
|
|
95
|
+
if (allowlists && "acceptedAbbreviations" in allowlists) {
|
|
96
|
+
config.acceptedAbbreviations = new Set(abbreviations.map((value) => value.toLowerCase()));
|
|
97
|
+
}
|
|
98
|
+
config.secretPreviews = new Set(arrayValue(allowlists?.secretPreviews).filter(isString));
|
|
99
|
+
applyNamingAllowlist(config, allowlists, "bannedGenericNames");
|
|
100
|
+
applyNamingAllowlist(config, allowlists, "booleanPrefixes");
|
|
101
|
+
applyNamingAllowlist(config, allowlists, "hungarianPrefixes");
|
|
102
|
+
applyNamingAllowlist(config, allowlists, "placeholderNames");
|
|
103
|
+
applyNamingAllowlist(config, allowlists, "abbreviationDenylist");
|
|
104
|
+
applyNamingAllowlist(config, allowlists, "negativeBooleanAllowed");
|
|
105
|
+
applyNamingAllowlist(config, allowlists, "knownAcronyms");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Replaces the entire list when the user provides that key - there is no merge with defaults.
|
|
109
|
+
// The "set the whole list" semantic is intentional so users can deliberately empty a list.
|
|
110
|
+
function applyNamingAllowlist(config: Config, allowlists: Record<string, unknown> | undefined, key: "bannedGenericNames" | "booleanPrefixes" | "hungarianPrefixes" | "placeholderNames" | "abbreviationDenylist" | "negativeBooleanAllowed" | "knownAcronyms"): void {
|
|
111
|
+
if (!allowlists || !(key in allowlists)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
config[key] = new Set(arrayValue(allowlists[key]).filter(isString).map((value) => value.toLowerCase()));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Per-rule overrides under the `rules:` key. Each entry can carry enabled / threshold / severity /
|
|
118
|
+
// options - only the keys the user actually sets become overrides; unset keys fall through to defaults.
|
|
119
|
+
function applyRuleConfig(config: Config, raw: Record<string, unknown>): void {
|
|
120
|
+
const rules = objectValue(raw.rules);
|
|
121
|
+
if (!rules) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const [ruleId, value] of Object.entries(rules)) {
|
|
125
|
+
const rule = objectValue(value);
|
|
126
|
+
if (!rule) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
config.rules.set(ruleId, ruleConfigValue(rule));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Builds one rule's override entry after `assertRuleThresholdConfig` has thrown on malformed input.
|
|
134
|
+
// Validation happens before extraction so users see a useful error rather than a silently dropped key.
|
|
135
|
+
function ruleConfigValue(rule: Record<string, unknown>): RuleOverride {
|
|
136
|
+
assertRuleThresholdConfig(rule);
|
|
137
|
+
const ruleOverride: RuleOverride = { options: numericConfigMap(rule.options) };
|
|
138
|
+
applyRuleEnabledConfig(ruleOverride, rule);
|
|
139
|
+
applyRuleThresholdConfig(ruleOverride, rule);
|
|
140
|
+
applyRuleSeverityConfig(ruleOverride, rule);
|
|
141
|
+
return ruleOverride;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Validates the public threshold/severity pair contract and throws before malformed rule options are parsed. */
|
|
145
|
+
function assertRuleThresholdConfig(rule: Record<string, unknown>): void {
|
|
146
|
+
const hasThreshold = "threshold" in rule;
|
|
147
|
+
const hasSeverity = "severity" in rule;
|
|
148
|
+
if (hasThreshold && typeof rule.threshold !== "number") {
|
|
149
|
+
throw new Error('Rule config key "threshold" must be numeric.');
|
|
150
|
+
}
|
|
151
|
+
if (hasSeverity && !isSeverity(rule.severity)) {
|
|
152
|
+
throw new Error('Rule config key "severity" must be "advisory", "warning", or "error".');
|
|
153
|
+
}
|
|
154
|
+
if (hasThreshold !== hasSeverity) {
|
|
155
|
+
throw new Error('Rule config keys "threshold" and "severity" must be configured together.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Copies an explicit enabled override while preserving absent config keys. */
|
|
160
|
+
function applyRuleEnabledConfig(ruleOverride: RuleOverride, rule: Record<string, unknown>): void {
|
|
161
|
+
if (typeof rule.enabled === "boolean") {
|
|
162
|
+
ruleOverride.enabled = rule.enabled;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Copies a numeric threshold after validation has proved the value is safe. */
|
|
167
|
+
function applyRuleThresholdConfig(ruleOverride: RuleOverride, rule: Record<string, unknown>): void {
|
|
168
|
+
if (typeof rule.threshold === "number") {
|
|
169
|
+
ruleOverride.threshold = rule.threshold;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Copies a severity override after validation has proved the value is supported. */
|
|
174
|
+
function applyRuleSeverityConfig(ruleOverride: RuleOverride, rule: Record<string, unknown>): void {
|
|
175
|
+
if (isSeverity(rule.severity)) {
|
|
176
|
+
ruleOverride.severity = rule.severity;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Rule options are typed as numeric (thresholds, line counts, etc.). Non-numeric entries are
|
|
181
|
+
// silently dropped rather than thrown because allowing malformed YAML to abort a scan is too aggressive.
|
|
182
|
+
function numericConfigMap(optionsValue: unknown): Map<string, number> {
|
|
183
|
+
const options = new Map<string, number>();
|
|
184
|
+
const rawOptions = objectValue(optionsValue);
|
|
185
|
+
if (!rawOptions) {
|
|
186
|
+
return options;
|
|
187
|
+
}
|
|
188
|
+
for (const [name, option] of Object.entries(rawOptions)) {
|
|
189
|
+
if (typeof option === "number") {
|
|
190
|
+
options.set(name, option);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return options;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Returns the first supported config path at the project root; undefined otherwise so
|
|
197
|
+
// callers can fall back to defaults without distinguishing "no config" from a real error.
|
|
198
|
+
function defaultConfigPath(projectRoot: string): string | undefined {
|
|
199
|
+
for (const fileName of DEFAULT_CONFIG_FILES) {
|
|
200
|
+
const candidate = join(projectRoot, fileName);
|
|
201
|
+
if (existsSync(candidate)) {
|
|
202
|
+
return candidate;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* Reads YAML or JSON config and rejects unknown extensions. Throws on malformed input so the user
|
|
210
|
+
* sees a concrete error rather than a silently-empty config.
|
|
211
|
+
*/
|
|
212
|
+
function parseConfigFile(path: string): Record<string, unknown> {
|
|
213
|
+
const source = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
|
|
214
|
+
const extension = extname(path).toLowerCase();
|
|
215
|
+
const parsed = extension === ".yaml" || extension === ".yml" ? parseYamlConfig(source) : extension === ".json" ? (JSON.parse(source) as unknown) : undefined;
|
|
216
|
+
const config = objectValue(parsed);
|
|
217
|
+
if (!config) {
|
|
218
|
+
throw new Error(`Config file must contain an object with .yaml, .yml, or .json extension: ${path}`);
|
|
219
|
+
}
|
|
220
|
+
return config;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// One non-blank, comment-stripped YAML line. `indent` is the column count (spaces only - tabs are
|
|
224
|
+
// rejected upstream) and is the sole signal used to nest blocks. `content` is whitespace-trimmed.
|
|
225
|
+
interface YamlLine {
|
|
226
|
+
indent: number;
|
|
227
|
+
content: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Mutable cursor through `lines`. Stored on the heap so recursive `parseYamlBlock` calls share
|
|
231
|
+
// position state instead of threading an index argument.
|
|
232
|
+
interface YamlParser {
|
|
233
|
+
lines: YamlLine[];
|
|
234
|
+
index: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Custom YAML parser that intentionally supports only a documented subset (mappings, arrays,
|
|
239
|
+
* scalars, inline `[]`/`{}`) - keeps gruff free of a yaml dependency. Throws on malformed input
|
|
240
|
+
* because silently misparsing config would produce wrong findings and a stable but broken baseline.
|
|
241
|
+
*/
|
|
242
|
+
function parseYamlConfig(source: string): Record<string, unknown> {
|
|
243
|
+
const parser = { lines: yamlLines(source), index: 0 };
|
|
244
|
+
const parsedDocument = parser.lines.length === 0 ? {} : parseYamlBlock(parser, parser.lines[0]?.indent ?? 0);
|
|
245
|
+
const config = objectValue(parsedDocument);
|
|
246
|
+
if (!config) {
|
|
247
|
+
throw new Error("Config YAML must contain a mapping object.");
|
|
248
|
+
}
|
|
249
|
+
return config;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Dispatch entry for a block. Empty (no lines at this indent) returns `{}`; sequence lines lead
|
|
253
|
+
// into the array parser; everything else is an object/mapping.
|
|
254
|
+
function parseYamlBlock(parser: YamlParser, indent: number): unknown {
|
|
255
|
+
const line = parser.lines[parser.index];
|
|
256
|
+
if (!line || line.indent < indent) {
|
|
257
|
+
return {};
|
|
258
|
+
}
|
|
259
|
+
return isYamlArrayLine(line) ? parseYamlArray(parser, line.indent) : parseYamlObject(parser, line.indent);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Walks mapping entries at one indent level, stopping when the next line dedents or switches to
|
|
263
|
+
// sequence form. Throws on unexpected indent so a missed key isn't silently swallowed.
|
|
264
|
+
function parseYamlObject(parser: YamlParser, indent: number): Record<string, unknown> {
|
|
265
|
+
const result: Record<string, unknown> = {};
|
|
266
|
+
while (parser.index < parser.lines.length) {
|
|
267
|
+
const line = parser.lines[parser.index];
|
|
268
|
+
if (!line || line.indent < indent || isYamlArrayLine(line)) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
assertYamlIndent(line, indent);
|
|
272
|
+
addYamlObjectEntry(parser, indent, line, result);
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Inline scalar after the colon → leaf value; bare key → recurse into nested block. Keys are
|
|
278
|
+
// unquoted so `"foo"` and `foo` produce the same result key.
|
|
279
|
+
function addYamlObjectEntry(parser: YamlParser, indent: number, line: YamlLine, result: Record<string, unknown>): void {
|
|
280
|
+
const [rawKey, rawValue] = yamlKeyValuePair(line.content);
|
|
281
|
+
const scalarText = rawValue.trim();
|
|
282
|
+
parser.index += 1;
|
|
283
|
+
result[unquoteYaml(rawKey.trim())] = scalarText.length > 0 ? parseYamlScalar(scalarText) : parseNestedYamlValue(parser, indent, {});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Block-style YAML sequence. Inline `[...]` arrays are handled by `parseYamlCollectionScalar`;
|
|
287
|
+
// this walks the `- item` lines and recurses into nested values for empty `-` openers.
|
|
288
|
+
function parseYamlArray(parser: YamlParser, indent: number): unknown[] {
|
|
289
|
+
const result: unknown[] = [];
|
|
290
|
+
while (parser.index < parser.lines.length) {
|
|
291
|
+
const line = parser.lines[parser.index];
|
|
292
|
+
if (!line || line.indent < indent || !isYamlArrayLine(line)) {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
assertYamlIndent(line, indent);
|
|
296
|
+
result.push(parseYamlArrayItem(parser, indent, line));
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Three branches: bare `-` (recurse for nested), `- key: value` (mapping item), `- scalar`.
|
|
302
|
+
// Bare-dash items return null when the nested block is empty so the array entry isn't elided.
|
|
303
|
+
function parseYamlArrayItem(parser: YamlParser, indent: number, line: YamlLine): unknown {
|
|
304
|
+
const itemText = line.content === "-" ? "" : line.content.slice(2).trim();
|
|
305
|
+
parser.index += 1;
|
|
306
|
+
if (itemText.length === 0) {
|
|
307
|
+
return parseNestedYamlValue(parser, indent, null);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const pair = splitYamlKeyValue(itemText);
|
|
311
|
+
return pair ? parseYamlArrayMappingItem(parser, indent, pair) : parseYamlScalar(itemText);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// `- key: value` form: the first key is on the dash line, subsequent keys live as a nested object.
|
|
315
|
+
// Mirrors the inline-vs-nested split of `addYamlObjectEntry`.
|
|
316
|
+
function parseYamlArrayMappingItem(parser: YamlParser, indent: number, pair: [string, string]): Record<string, unknown> {
|
|
317
|
+
const [rawKey, rawValue] = pair;
|
|
318
|
+
const scalarText = rawValue.trim();
|
|
319
|
+
return {
|
|
320
|
+
[unquoteYaml(rawKey.trim())]: scalarText.length > 0 ? parseYamlScalar(scalarText) : parseNestedYamlValue(parser, indent, {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Returns the nested block if the next line indents further, otherwise the caller's `fallback`.
|
|
325
|
+
// `fallback` is `{}` for mappings (empty value → empty object) and `null` for sequences.
|
|
326
|
+
function parseNestedYamlValue(parser: YamlParser, indent: number, fallback: unknown): unknown {
|
|
327
|
+
const nestedIndent = parser.lines[parser.index]?.indent;
|
|
328
|
+
return nestedIndent !== undefined && nestedIndent > indent ? parseYamlBlock(parser, nestedIndent) : fallback;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/*
|
|
332
|
+
* Throws when a mapping line has no `:` separator - the parser cannot recover, and a silent skip
|
|
333
|
+
* would hide a real config typo from the user.
|
|
334
|
+
*/
|
|
335
|
+
function yamlKeyValuePair(content: string): [string, string] {
|
|
336
|
+
const pair = splitYamlKeyValue(content);
|
|
337
|
+
if (!pair) {
|
|
338
|
+
throw new Error(`Invalid YAML mapping line: "${content}".`);
|
|
339
|
+
}
|
|
340
|
+
return pair;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// `- ` or bare `-` only - two-dash openers and ambiguous variants are not sequence entries.
|
|
344
|
+
function isYamlArrayLine(line: YamlLine): boolean {
|
|
345
|
+
return line.content.startsWith("- ") || line.content === "-";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/*
|
|
349
|
+
* Throws when a line is indented more than expected at this scope. Without this guard, a stray
|
|
350
|
+
* indent would silently produce a sub-mapping and the user's config would mean something different.
|
|
351
|
+
*/
|
|
352
|
+
function assertYamlIndent(line: YamlLine, indent: number): void {
|
|
353
|
+
if (line.indent > indent) {
|
|
354
|
+
throw new Error(`Invalid YAML indentation near "${line.content}".`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/*
|
|
359
|
+
* Pre-pass that drops blank/comment lines and rejects tab-indented input. Tabs are forbidden
|
|
360
|
+
* because mixing them with spaces is the canonical YAML footgun, so the parser throws rather than
|
|
361
|
+
* guess at the user's intent.
|
|
362
|
+
*/
|
|
363
|
+
function yamlLines(source: string): YamlLine[] {
|
|
364
|
+
const lines: YamlLine[] = [];
|
|
365
|
+
for (const rawLine of source.replace(/\r\n/g, "\n").split("\n")) {
|
|
366
|
+
const withoutComment = stripYamlComment(rawLine).trimEnd();
|
|
367
|
+
if (withoutComment.trim().length === 0) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const indentText = withoutComment.match(/^\s*/)?.[0] ?? "";
|
|
371
|
+
if (indentText.includes("\t")) {
|
|
372
|
+
throw new Error("Tabs are not supported in gruff YAML config indentation.");
|
|
373
|
+
}
|
|
374
|
+
lines.push({ indent: indentText.length, content: withoutComment.trimStart() });
|
|
375
|
+
}
|
|
376
|
+
return lines;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Drops `# comment` text but only when the `#` is not inside quotes - `foo: "a # b"` keeps the
|
|
380
|
+
// comment-like substring as part of the string value, matching YAML semantics.
|
|
381
|
+
function stripYamlComment(line: string): string {
|
|
382
|
+
const commentIndex = firstUnquotedIndex(line, (character, index) => character === "#" && (index === 0 || /\s/.test(line[index - 1] ?? "")));
|
|
383
|
+
return commentIndex === undefined ? line : line.slice(0, commentIndex);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Finds the first `:<space>` outside quotes. Required because a quoted value can legitimately
|
|
387
|
+
// contain `:` (`url: "https://..."`) and the parser must not split at the first occurrence.
|
|
388
|
+
function splitYamlKeyValue(mappingText: string): [string, string] | undefined {
|
|
389
|
+
const separatorIndex = firstUnquotedIndex(mappingText, (character, index) => {
|
|
390
|
+
const next = mappingText[index + 1];
|
|
391
|
+
return character === ":" && (!next || /\s/.test(next));
|
|
392
|
+
});
|
|
393
|
+
return separatorIndex === undefined ? undefined : [mappingText.slice(0, separatorIndex), mappingText.slice(separatorIndex + 1)];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Tries each scalar parser in order; the first to claim a match wins. Bare strings are the
|
|
397
|
+
// fallback so callers never end up with `undefined` for a non-empty scalar.
|
|
398
|
+
function parseYamlScalar(scalarText: string): unknown {
|
|
399
|
+
const trimmed = scalarText.trim();
|
|
400
|
+
for (const parser of [parseYamlCollectionScalar, parseYamlQuotedScalar, parseYamlKeywordScalar, parseYamlNumberScalar]) {
|
|
401
|
+
const parsedScalar = parser(trimmed);
|
|
402
|
+
if (parsedScalar.isMatched) {
|
|
403
|
+
return parsedScalar.value;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return trimmed;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Parses YAML collection scalar from source text.
|
|
410
|
+
function parseYamlCollectionScalar(trimmed: string): ParsedYamlScalar {
|
|
411
|
+
if (trimmed === "[]") {
|
|
412
|
+
return matchedYamlScalar([]);
|
|
413
|
+
}
|
|
414
|
+
if (trimmed === "{}") {
|
|
415
|
+
return matchedYamlScalar({});
|
|
416
|
+
}
|
|
417
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
418
|
+
return matchedYamlScalar(parseYamlInlineArray(trimmed));
|
|
419
|
+
}
|
|
420
|
+
return UNMATCHED_YAML_SCALAR;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// `"..."` or `'...'` wrapped. Calls `unquoteYaml` so the resulting value matches what a real YAML
|
|
424
|
+
// engine would produce; matched-state flag separates "empty quoted string" from "not a quoted scalar".
|
|
425
|
+
function parseYamlQuotedScalar(trimmed: string): ParsedYamlScalar {
|
|
426
|
+
if (isQuotedYaml(trimmed)) {
|
|
427
|
+
return matchedYamlScalar(unquoteYaml(trimmed));
|
|
428
|
+
}
|
|
429
|
+
return UNMATCHED_YAML_SCALAR;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Recognises `true`/`false`/`null`/`~` (case-insensitive). The keyword table is shared so the
|
|
433
|
+
// parser produces consistent JS values (`null` instead of the string "null").
|
|
434
|
+
function parseYamlKeywordScalar(trimmed: string): ParsedYamlScalar {
|
|
435
|
+
const normalized = trimmed.toLowerCase();
|
|
436
|
+
if (YAML_KEYWORD_SCALARS.has(normalized)) {
|
|
437
|
+
return matchedYamlScalar(YAML_KEYWORD_SCALARS.get(normalized) ?? null);
|
|
438
|
+
}
|
|
439
|
+
return UNMATCHED_YAML_SCALAR;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Strict numeric pattern - no octals, no special floats. Numbers that don't fit fall through to
|
|
443
|
+
// bare-string handling, so `0xFF` or `1e10` stay as strings rather than producing surprising values.
|
|
444
|
+
function parseYamlNumberScalar(trimmed: string): ParsedYamlScalar {
|
|
445
|
+
if (YAML_NUMBER_SCALAR.test(trimmed)) {
|
|
446
|
+
return matchedYamlScalar(Number(trimmed));
|
|
447
|
+
}
|
|
448
|
+
return UNMATCHED_YAML_SCALAR;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Returns a "matched" result with the parsed value, distinguishing real matches from the shared
|
|
452
|
+
// `UNMATCHED_YAML_SCALAR` sentinel used by every parser that failed.
|
|
453
|
+
function matchedYamlScalar(scalarValue: unknown): ParsedYamlScalar {
|
|
454
|
+
return { isMatched: true, value: scalarValue };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Inline `[a, b, c]` arrays. Items are split on unquoted commas and each item recurses through
|
|
458
|
+
// `parseYamlScalar` so nested types resolve correctly.
|
|
459
|
+
function parseYamlInlineArray(arrayText: string): unknown[] {
|
|
460
|
+
const inner = arrayText.slice(1, -1).trim();
|
|
461
|
+
if (inner.length === 0) {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
return splitYamlInlineItems(inner).map((item) => parseYamlScalar(item));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Quote-aware split on commas. Treating a quoted comma as a separator would corrupt entries like
|
|
468
|
+
// `["a, b", "c"]` - same reason `splitYamlKeyValue` is also quote-aware.
|
|
469
|
+
function splitYamlInlineItems(itemsText: string): string[] {
|
|
470
|
+
const items: string[] = [];
|
|
471
|
+
let start = 0;
|
|
472
|
+
for (const index of unquotedIndexes(itemsText, ",")) {
|
|
473
|
+
items.push(itemsText.slice(start, index).trim());
|
|
474
|
+
start = index + 1;
|
|
475
|
+
}
|
|
476
|
+
items.push(itemsText.slice(start).trim());
|
|
477
|
+
return items;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Lexer state for the YAML quote walkers. `isEscaped` only matters inside `"..."` quotes because
|
|
481
|
+
// single-quoted YAML strings use `''` doubling rather than backslash escapes.
|
|
482
|
+
interface QuoteScanState {
|
|
483
|
+
quote: string | undefined;
|
|
484
|
+
isEscaped: boolean;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Walks `value` calling `predicate` only at quote-free positions. Used by both the comment
|
|
488
|
+
// stripper and the colon splitter to keep quoted text inert.
|
|
489
|
+
function firstUnquotedIndex(sourceText: string, predicate: (character: string, index: number) => boolean): number | undefined {
|
|
490
|
+
for (const index of unquotedIndexes(sourceText)) {
|
|
491
|
+
const character = sourceText[index] ?? "";
|
|
492
|
+
if (predicate(character, index)) {
|
|
493
|
+
return index;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// All quote-free positions in `value`, optionally filtered by a specific character. The two-mode
|
|
500
|
+
// shape lets the same lexer feed both `firstUnquotedIndex` and the inline-array splitter.
|
|
501
|
+
function unquotedIndexes(sourceText: string, expectedCharacter?: string): number[] {
|
|
502
|
+
const indexes: number[] = [];
|
|
503
|
+
const state: QuoteScanState = { quote: undefined, isEscaped: false };
|
|
504
|
+
for (let index = 0; index < sourceText.length; index += 1) {
|
|
505
|
+
const character = sourceText[index] ?? "";
|
|
506
|
+
if (consumeQuotedCharacter(character, state)) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (isYamlQuote(character)) {
|
|
510
|
+
state.quote = character;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (!expectedCharacter || character === expectedCharacter) {
|
|
514
|
+
indexes.push(index);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return indexes;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Returns true and mutates `state` when the character was inside a quoted region. The escape
|
|
521
|
+
// handling is double-quote-only because YAML single-quote strings have no `\` escapes.
|
|
522
|
+
function consumeQuotedCharacter(character: string, state: QuoteScanState): boolean {
|
|
523
|
+
if (!state.quote) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
if (state.quote === "\"" && character === "\\" && !state.isEscaped) {
|
|
527
|
+
state.isEscaped = true;
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
if (character === state.quote && !state.isEscaped) {
|
|
531
|
+
state.quote = undefined;
|
|
532
|
+
}
|
|
533
|
+
state.isEscaped = false;
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Single and double quotes only - YAML allows backticks elsewhere but they're not part of the
|
|
538
|
+
// supported subset here, and the parser would have to reject them anyway.
|
|
539
|
+
function isYamlQuote(character: string): boolean {
|
|
540
|
+
return character === "\"" || character === "'";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Both endpoints must use the same quote character; mismatched openers/closers are treated as
|
|
544
|
+
// plain text rather than a parse error so a single stray quote in user prose doesn't fail the config.
|
|
545
|
+
function isQuotedYaml(scalarText: string): boolean {
|
|
546
|
+
return scalarText.length >= 2 && ((scalarText.startsWith("\"") && scalarText.endsWith("\"")) || (scalarText.startsWith("'") && scalarText.endsWith("'")));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Removes quotes and decodes the supported escapes: `''` doubling for single quotes, and `\n`,
|
|
550
|
+
// `\r`, `\t`, `\"`, `\\` for double quotes. Anything else passes through unchanged so the parser
|
|
551
|
+
// reports the user's exact intent rather than mangling unknown escape sequences.
|
|
552
|
+
function unquoteYaml(scalarText: string): string {
|
|
553
|
+
if (!isQuotedYaml(scalarText)) {
|
|
554
|
+
return scalarText;
|
|
555
|
+
}
|
|
556
|
+
const quote = scalarText[0];
|
|
557
|
+
const body = scalarText.slice(1, -1);
|
|
558
|
+
if (quote === "'") {
|
|
559
|
+
return body.replace(/''/g, "'");
|
|
560
|
+
}
|
|
561
|
+
return body.replace(/\\(["\\nrt])/g, (_match, escaped: string) => {
|
|
562
|
+
if (escaped === "n") {
|
|
563
|
+
return "\n";
|
|
564
|
+
}
|
|
565
|
+
if (escaped === "r") {
|
|
566
|
+
return "\r";
|
|
567
|
+
}
|
|
568
|
+
if (escaped === "t") {
|
|
569
|
+
return "\t";
|
|
570
|
+
}
|
|
571
|
+
return escaped;
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Rules are enabled by default - the absence of a config entry means "use the descriptor default",
|
|
576
|
+
// which is the documented contract for how unset rules behave.
|
|
577
|
+
function ruleEnabled(config: Config, ruleId: string): boolean {
|
|
578
|
+
return config.rules.get(ruleId)?.enabled ?? true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Resolves a threshold for the rule. Callers pass the descriptor default so this helper alone
|
|
582
|
+
// determines whether config can override - keeps every rule's threshold lookup uniform.
|
|
583
|
+
function threshold(config: Config, ruleId: string, defaultValue: number): number {
|
|
584
|
+
return config.rules.get(ruleId)?.threshold ?? defaultValue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Same shape as `threshold`. The descriptor default is the single source of truth for what severity
|
|
588
|
+
// a rule emits when the user has no config entry.
|
|
589
|
+
function ruleSeverity(config: Config, ruleId: string, defaultSeverity: Severity): Severity {
|
|
590
|
+
return config.rules.get(ruleId)?.severity ?? defaultSeverity;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Rule-specific numeric options (e.g., minLength for sensitive-data rules). Same default-fallback
|
|
594
|
+
// pattern as `threshold` so rules can be configured without forcing every field to be set.
|
|
595
|
+
function optionNumber(config: Config, ruleId: string, name: string, defaultValue: number): number {
|
|
596
|
+
return config.rules.get(ruleId)?.options.get(name) ?? defaultValue;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Type narrowing for `Record<string, unknown>`. Returns undefined (not null) for non-objects so
|
|
600
|
+
// the caller can use the standard optional-chaining pattern through the rest of the config layer.
|
|
601
|
+
function objectValue(configValue: unknown): Record<string, unknown> | undefined {
|
|
602
|
+
return typeof configValue === "object" && configValue !== null && !Array.isArray(configValue) ? (configValue as Record<string, unknown>) : undefined;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Returns the array or an empty array - never undefined - so callers can iterate without a guard.
|
|
606
|
+
function arrayValue(configValue: unknown): unknown[] {
|
|
607
|
+
return Array.isArray(configValue) ? configValue : [];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// String type narrowing. Exported so other modules can share a single string-test that matches
|
|
611
|
+
// the config-side semantics (rejects non-string truthy values like numbers).
|
|
612
|
+
function isString(configValue: unknown): configValue is string {
|
|
613
|
+
return typeof configValue === "string";
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Whitelist check used both by config validation (throws on bad values) and by `applyRuleSeverityConfig`.
|
|
617
|
+
// The set of accepted strings is the public severity vocabulary; adding entries is a schema change.
|
|
618
|
+
function isSeverity(configValue: unknown): configValue is Severity {
|
|
619
|
+
return configValue === "advisory" || configValue === "warning" || configValue === "error";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export { isString, loadConfig, objectValue, optionNumber, ruleEnabled, ruleSeverity, threshold };
|
package/src/constants.ts
ADDED