@cosmicdrift/kumiko-guards 0.356.0 → 0.357.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/package.json +2 -2
- package/src/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
package/src/check-complexity.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Complexity
|
|
3
|
+
* Complexity check for handler hotspots with a baseline regression guard.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Computes cyclomatic complexity per function/method (base 1, +1 per decision
|
|
6
|
+
* point: if, for, while, case, catch, &&, ||, ??, ternary). Above the
|
|
7
|
+
* threshold -> hotspot.
|
|
8
8
|
*
|
|
9
|
-
* Baseline
|
|
10
|
-
* baseline.json`
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* warning-only (
|
|
16
|
-
*
|
|
17
|
-
* in
|
|
9
|
+
* Baseline regression guard like guard-comment-lang.ts: `.kumiko-complexity-
|
|
10
|
+
* baseline.json` in the repo root pins the frozen hotspot count per file.
|
|
11
|
+
* - current <= baseline per file: PASS
|
|
12
|
+
* - current > baseline per file: FAIL
|
|
13
|
+
* Reductions do NOT update the baseline automatically — run `--write-baseline`
|
|
14
|
+
* after refactor commits. Without a baseline file the check stays
|
|
15
|
+
* warning-only (bootstrap: run `--write-baseline` once).
|
|
16
|
+
* The baseline covers only files of the repo itself; sibling hotspots belong
|
|
17
|
+
* in their own baseline (see localHotspots).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Deliberate gap: hotspots are counted per file, not their height — an
|
|
20
|
+
* already listed function may get more complex without the guard failing.
|
|
21
21
|
*
|
|
22
|
-
* Opt-out: `// kumiko-lint-ignore complexity-budget <
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Opt-out: `// kumiko-lint-ignore complexity-budget <reason>` on the function
|
|
23
|
+
* line, the line above, or (for JSDoc-documented functions) the line above
|
|
24
|
+
* the JSDoc block.
|
|
25
25
|
*
|
|
26
26
|
* Usage:
|
|
27
|
-
* bun guards/check-complexity.ts #
|
|
28
|
-
* bun guards/check-complexity.ts --write-baseline #
|
|
29
|
-
* bun guards/check-complexity.ts --no-baseline #
|
|
27
|
+
* bun guards/check-complexity.ts # compare against baseline
|
|
28
|
+
* bun guards/check-complexity.ts --write-baseline # rewrite baseline
|
|
29
|
+
* bun guards/check-complexity.ts --no-baseline # skip comparison
|
|
30
30
|
*
|
|
31
|
-
*
|
|
31
|
+
* Rule: issue kumiko-framework#1282
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import * as path from "node:path";
|
|
@@ -114,43 +114,42 @@ function hasBudgetTag(fn: Node): boolean {
|
|
|
114
114
|
return lineHasIgnoreTag(lines[jsDocStartLine - 2] ?? "", BUDGET_TAG);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
const BRANCH_KINDS: ReadonlySet<SyntaxKind> = new Set([
|
|
118
|
+
SyntaxKind.IfStatement,
|
|
119
|
+
SyntaxKind.ForStatement,
|
|
120
|
+
SyntaxKind.ForInStatement,
|
|
121
|
+
SyntaxKind.ForOfStatement,
|
|
122
|
+
SyntaxKind.WhileStatement,
|
|
123
|
+
SyntaxKind.DoStatement,
|
|
124
|
+
SyntaxKind.CatchClause,
|
|
125
|
+
SyntaxKind.ConditionalExpression,
|
|
126
|
+
SyntaxKind.CaseClause,
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const SHORT_CIRCUIT_OPERATORS: ReadonlySet<SyntaxKind> = new Set([
|
|
130
|
+
SyntaxKind.AmpersandAmpersandToken,
|
|
131
|
+
SyntaxKind.BarBarToken,
|
|
132
|
+
SyntaxKind.QuestionQuestionToken,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
function isDecisionPoint(node: Node): boolean {
|
|
136
|
+
const kind = node.getKind();
|
|
137
|
+
if (BRANCH_KINDS.has(kind)) return true;
|
|
138
|
+
if (kind !== SyntaxKind.BinaryExpression) return false;
|
|
139
|
+
const operator = node.asKindOrThrow(SyntaxKind.BinaryExpression).getOperatorToken().getKind();
|
|
140
|
+
return SHORT_CIRCUIT_OPERATORS.has(operator);
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
export function computeComplexity(fn: Node): number {
|
|
118
144
|
let complexity = 1;
|
|
119
145
|
fn.forEachDescendant((node, traversal) => {
|
|
120
|
-
//
|
|
121
|
-
//
|
|
146
|
+
// skip: decision points inside a nested function-like node belong to its
|
|
147
|
+
// own Hotspot entry
|
|
122
148
|
if (node !== fn && (FUNCTION_KINDS as readonly SyntaxKind[]).includes(node.getKind())) {
|
|
123
149
|
traversal.skip();
|
|
124
150
|
return;
|
|
125
151
|
}
|
|
126
|
-
|
|
127
|
-
case SyntaxKind.IfStatement:
|
|
128
|
-
case SyntaxKind.ForStatement:
|
|
129
|
-
case SyntaxKind.ForInStatement:
|
|
130
|
-
case SyntaxKind.ForOfStatement:
|
|
131
|
-
case SyntaxKind.WhileStatement:
|
|
132
|
-
case SyntaxKind.DoStatement:
|
|
133
|
-
case SyntaxKind.CatchClause:
|
|
134
|
-
case SyntaxKind.ConditionalExpression:
|
|
135
|
-
complexity++;
|
|
136
|
-
break;
|
|
137
|
-
case SyntaxKind.CaseClause:
|
|
138
|
-
complexity++;
|
|
139
|
-
break;
|
|
140
|
-
case SyntaxKind.BinaryExpression: {
|
|
141
|
-
const op = node.asKindOrThrow(SyntaxKind.BinaryExpression).getOperatorToken().getKind();
|
|
142
|
-
if (
|
|
143
|
-
op === SyntaxKind.AmpersandAmpersandToken ||
|
|
144
|
-
op === SyntaxKind.BarBarToken ||
|
|
145
|
-
op === SyntaxKind.QuestionQuestionToken
|
|
146
|
-
) {
|
|
147
|
-
complexity++;
|
|
148
|
-
}
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
default:
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
152
|
+
if (isDecisionPoint(node)) complexity++;
|
|
154
153
|
});
|
|
155
154
|
return complexity;
|
|
156
155
|
}
|
|
@@ -220,18 +219,18 @@ function report(hotspots: readonly Hotspot[], scanned: number): void {
|
|
|
220
219
|
console.log(` Hotspots (complexity >= ${COMPLEXITY_THRESHOLD}): ${hotspots.length}`);
|
|
221
220
|
if (hotspots.length === 0) {
|
|
222
221
|
console.log(" Nothing to report.");
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
222
|
+
} else {
|
|
223
|
+
const shown = hotspots.slice(0, MAX_REPORTED);
|
|
224
|
+
for (const h of shown) {
|
|
225
|
+
console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
|
|
226
|
+
}
|
|
227
|
+
if (hotspots.length > shown.length) {
|
|
228
|
+
console.log(` ... ${hotspots.length - shown.length} more`);
|
|
229
|
+
}
|
|
230
|
+
console.log(
|
|
231
|
+
`\n Rule: split the function, or deliberately allow it with "// ${BUDGET_TAG} <reason>".`,
|
|
232
|
+
);
|
|
231
233
|
}
|
|
232
|
-
console.log(
|
|
233
|
-
`\n Rule: split the function, or deliberately allow it with "// ${BUDGET_TAG} <reason>".`,
|
|
234
|
-
);
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
const complexityBaseline = baselineRatchet({
|
|
@@ -276,9 +275,9 @@ function analyse(
|
|
|
276
275
|
export const guard: AstGuard = {
|
|
277
276
|
name: "Complexity Check",
|
|
278
277
|
scan: SCAN,
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
278
|
+
// No remediation text here: reportResults appends the hint to EVERY failure,
|
|
279
|
+
// including format drift, where "split the function" would mislead. The
|
|
280
|
+
// concrete advice lives in the respective violation message.
|
|
282
281
|
hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
|
|
283
282
|
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
284
283
|
writeBaseline: (files) => {
|
|
@@ -288,9 +287,9 @@ export const guard: AstGuard = {
|
|
|
288
287
|
},
|
|
289
288
|
};
|
|
290
289
|
|
|
291
|
-
// Flags
|
|
292
|
-
//
|
|
293
|
-
//
|
|
290
|
+
// Flags are read ONLY here, not in run() — the shared runner (run-guards.ts)
|
|
291
|
+
// drives 18 guards with the same argv, and a --write-baseline there must not
|
|
292
|
+
// silently rewrite the baseline.
|
|
294
293
|
if (import.meta.main) {
|
|
295
294
|
const args = process.argv.slice(2);
|
|
296
295
|
if (args.includes("--write-baseline")) {
|
package/src/check-predicates.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Predicate-
|
|
3
|
+
* Predicate-extraction check (WARNING, no fail).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* 1. Fat
|
|
7
|
-
*
|
|
8
|
-
* 2.
|
|
9
|
-
*
|
|
5
|
+
* Finds two kinds of candidates for predicate extraction:
|
|
6
|
+
* 1. Fat predicates — `if`/`while`/ternary conditions with >=3 logical
|
|
7
|
+
* operators (&&/||) OR condition text >80 characters.
|
|
8
|
+
* 2. Duplicates — the same (normalized) condition >=2x in the scan scope,
|
|
9
|
+
* with >=2 operators (to exclude trivial `!x` checks).
|
|
10
10
|
*
|
|
11
|
-
* Output:
|
|
11
|
+
* Output: warning with file:line + a pointer to the rule. Exit code is always 0.
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
14
|
* bun guards/check-predicates.ts
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* Rule: ~/.claude/rules/coding-standards.md → "Predicate Extraction"
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import * as path from "node:path";
|
|
@@ -91,11 +91,13 @@ function collectConditions(sf: SourceFile): Site[] {
|
|
|
91
91
|
const file = path.relative(ROOT, sf.getFilePath());
|
|
92
92
|
|
|
93
93
|
const push = (node: Node, conditionNode: Node | undefined): void => {
|
|
94
|
+
// skip: a missing condition (e.g. `for (;;)`) has nothing to extract
|
|
94
95
|
if (!conditionNode) return;
|
|
95
96
|
const raw = conditionNode.getText();
|
|
96
97
|
const text = normalize(raw);
|
|
97
98
|
const { ands, ors } = countOperators(text);
|
|
98
99
|
const operators = ands + ors;
|
|
100
|
+
// skip: a short condition without logical operators needs no named predicate
|
|
99
101
|
if (operators === 0 && text.length < LENGTH_THRESHOLD) return;
|
|
100
102
|
sites.push({
|
|
101
103
|
file,
|
|
@@ -119,8 +121,83 @@ function collectConditions(sf: SourceFile): Site[] {
|
|
|
119
121
|
return sites;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
type TextDuplicateGroup = [Site, Site, ...Site[]];
|
|
125
|
+
type ShapeDuplicateGroup = { shape: string; sites: Site[] };
|
|
126
|
+
|
|
127
|
+
function bucketDuplicateCandidates(allSites: readonly Site[]): {
|
|
128
|
+
byText: Map<string, Site[]>;
|
|
129
|
+
byShape: Map<string, Site[]>;
|
|
130
|
+
} {
|
|
131
|
+
const byText = new Map<string, Site[]>();
|
|
132
|
+
const byShape = new Map<string, Site[]>();
|
|
133
|
+
for (const s of allSites) {
|
|
134
|
+
// Count as duplicate candidate if either >=2 operators OR text long enough
|
|
135
|
+
// that repetition is still worth extracting (covers 1-operator but lengthy
|
|
136
|
+
// domain checks like kebab-case validation).
|
|
137
|
+
if (s.operators < DUP_MIN_OPERATORS && s.text.length < LENGTH_THRESHOLD) continue;
|
|
138
|
+
const textBucket = byText.get(s.text) ?? [];
|
|
139
|
+
textBucket.push(s);
|
|
140
|
+
byText.set(s.text, textBucket);
|
|
141
|
+
const shape = structuralShape(s.text);
|
|
142
|
+
const shapeBucket = byShape.get(shape) ?? [];
|
|
143
|
+
shapeBucket.push(s);
|
|
144
|
+
byShape.set(shape, shapeBucket);
|
|
145
|
+
}
|
|
146
|
+
return { byText, byShape };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findTextDuplicates(byText: ReadonlyMap<string, Site[]>): TextDuplicateGroup[] {
|
|
150
|
+
const textDups: TextDuplicateGroup[] = [];
|
|
151
|
+
for (const bucket of byText.values()) {
|
|
152
|
+
if (bucket.length < 2) continue;
|
|
153
|
+
const [first, second, ...rest] = bucket;
|
|
154
|
+
if (first !== undefined && second !== undefined) textDups.push([first, second, ...rest]);
|
|
155
|
+
}
|
|
156
|
+
return textDups;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findShapeDuplicates(byShape: ReadonlyMap<string, Site[]>): ShapeDuplicateGroup[] {
|
|
160
|
+
const shapeDups: ShapeDuplicateGroup[] = [];
|
|
161
|
+
for (const [shape, bucket] of byShape.entries()) {
|
|
162
|
+
if (bucket.length < 2) continue;
|
|
163
|
+
// Skip when sites are already covered by exact-text duplicate group.
|
|
164
|
+
const firstText = bucket[0]?.text;
|
|
165
|
+
const allSame = bucket.every((s) => s.text === firstText);
|
|
166
|
+
if (allSame) continue;
|
|
167
|
+
shapeDups.push({ shape, sites: bucket });
|
|
168
|
+
}
|
|
169
|
+
return shapeDups;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const snip = (text: string): string => (text.length > 90 ? `${text.slice(0, 87)}...` : text);
|
|
173
|
+
|
|
174
|
+
function printFatPredicates(fatSites: readonly Site[]): void {
|
|
175
|
+
console.log(`\n Fat-predicate candidates:`);
|
|
176
|
+
for (const s of fatSites) {
|
|
177
|
+
const hint = s.ands === 0 && s.ors >= 3 ? " (Array.includes/Set?)" : "";
|
|
178
|
+
console.log(` ${s.file}:${s.line} [${s.ands}&& ${s.ors}||] ${snip(s.text)}${hint}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function printTextDuplicates(textDups: readonly TextDuplicateGroup[]): void {
|
|
183
|
+
console.log(`\n Exact duplicates:`);
|
|
184
|
+
for (const group of textDups) {
|
|
185
|
+
const [first] = group;
|
|
186
|
+
console.log(` ${group.length}x ${snip(first.text)}`);
|
|
187
|
+
for (const s of group) console.log(` - ${s.file}:${s.line}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function printShapeDuplicates(shapeDups: readonly ShapeDuplicateGroup[]): void {
|
|
192
|
+
console.log(`\n Structural duplicates:`);
|
|
193
|
+
for (const { shape, sites } of shapeDups) {
|
|
194
|
+
console.log(` ${sites.length}x shape: ${snip(shape)}`);
|
|
195
|
+
for (const s of sites) console.log(` - ${s.file}:${s.line} ${snip(s.text)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Warning-only check: never blocks (always returns 0 violations). The
|
|
200
|
+
// findings are printed via console; the runner shows `✓` as long as nothing throws.
|
|
124
201
|
export const guard: AstGuard = {
|
|
125
202
|
name: "Predicate Extraction Check",
|
|
126
203
|
scan: SCAN,
|
|
@@ -136,37 +213,9 @@ export const guard: AstGuard = {
|
|
|
136
213
|
const fatSites = allSites.filter(
|
|
137
214
|
(s) => s.operators >= OPERATOR_THRESHOLD || s.text.length >= LENGTH_THRESHOLD,
|
|
138
215
|
);
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
for (const s of allSites) {
|
|
143
|
-
// Count as duplicate candidate if either >=2 operators OR text long enough
|
|
144
|
-
// that repetition is still worth extracting (covers 1-operator but lengthy
|
|
145
|
-
// domain checks like kebab-case validation).
|
|
146
|
-
if (s.operators < DUP_MIN_OPERATORS && s.text.length < LENGTH_THRESHOLD) continue;
|
|
147
|
-
const textBucket = byText.get(s.text) ?? [];
|
|
148
|
-
textBucket.push(s);
|
|
149
|
-
byText.set(s.text, textBucket);
|
|
150
|
-
const shape = structuralShape(s.text);
|
|
151
|
-
const shapeBucket = byShape.get(shape) ?? [];
|
|
152
|
-
shapeBucket.push(s);
|
|
153
|
-
byShape.set(shape, shapeBucket);
|
|
154
|
-
}
|
|
155
|
-
const textDups: Array<[Site, Site, ...Site[]]> = [];
|
|
156
|
-
for (const bucket of byText.values()) {
|
|
157
|
-
if (bucket.length < 2) continue;
|
|
158
|
-
const [first, second, ...rest] = bucket;
|
|
159
|
-
if (first !== undefined && second !== undefined) textDups.push([first, second, ...rest]);
|
|
160
|
-
}
|
|
161
|
-
const shapeDups: Array<{ shape: string; sites: Site[] }> = [];
|
|
162
|
-
for (const [shape, bucket] of byShape.entries()) {
|
|
163
|
-
if (bucket.length < 2) continue;
|
|
164
|
-
// Skip when sites are already covered by exact-text duplicate group.
|
|
165
|
-
const firstText = bucket[0]?.text;
|
|
166
|
-
const allSame = bucket.every((s) => s.text === firstText);
|
|
167
|
-
if (allSame) continue;
|
|
168
|
-
shapeDups.push({ shape, sites: bucket });
|
|
169
|
-
}
|
|
216
|
+
const { byText, byShape } = bucketDuplicateCandidates(allSites);
|
|
217
|
+
const textDups = findTextDuplicates(byText);
|
|
218
|
+
const shapeDups = findShapeDuplicates(byShape);
|
|
170
219
|
|
|
171
220
|
console.log(`Predicate-Extraction Check: ${scanned} files checked.`);
|
|
172
221
|
console.log(
|
|
@@ -180,32 +229,9 @@ export const guard: AstGuard = {
|
|
|
180
229
|
return { violations: [] };
|
|
181
230
|
}
|
|
182
231
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
console.log(`\n Fat-predicate candidates:`);
|
|
187
|
-
for (const s of fatSites) {
|
|
188
|
-
const hint = s.ands === 0 && s.ors >= 3 ? " (Array.includes/Set?)" : "";
|
|
189
|
-
console.log(` ${s.file}:${s.line} [${s.ands}&& ${s.ors}||] ${snip(s.text)}${hint}`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (textDups.length > 0) {
|
|
194
|
-
console.log(`\n Exact duplicates:`);
|
|
195
|
-
for (const group of textDups) {
|
|
196
|
-
const [first] = group;
|
|
197
|
-
console.log(` ${group.length}x ${snip(first.text)}`);
|
|
198
|
-
for (const s of group) console.log(` - ${s.file}:${s.line}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (shapeDups.length > 0) {
|
|
203
|
-
console.log(`\n Structural duplicates:`);
|
|
204
|
-
for (const { shape, sites } of shapeDups) {
|
|
205
|
-
console.log(` ${sites.length}x shape: ${snip(shape)}`);
|
|
206
|
-
for (const s of sites) console.log(` - ${s.file}:${s.line} ${snip(s.text)}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
232
|
+
if (fatSites.length > 0) printFatPredicates(fatSites);
|
|
233
|
+
if (textDups.length > 0) printTextDuplicates(textDups);
|
|
234
|
+
if (shapeDups.length > 0) printShapeDuplicates(shapeDups);
|
|
209
235
|
|
|
210
236
|
console.log(
|
|
211
237
|
"\n Rule: extract as a named function (isX/hasY/canZ) when the condition has a stable name.",
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Secret-
|
|
3
|
+
* Secret-literal guard (stack-agnostic).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Flags hardcoded secret fallbacks in server code:
|
|
6
6
|
* const s = env.JWT_SECRET ?? "hardcoded-prod-secret"; // ✗
|
|
7
7
|
* hmacSecret: config.X ?? "cashcolt-mailer-hmac-secret", // ✗
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Secure
|
|
13
|
-
*
|
|
9
|
+
* A `?? "<literal>"` fallback on something secret-like means: if the env
|
|
10
|
+
* variable is missing, the server keeps running with a secret visible in the
|
|
11
|
+
* repo — exactly the class that becomes a leaked prod secret after a deploy
|
|
12
|
+
* mistake. Secure by default: a missing secret → fail hard, never fall back to
|
|
13
|
+
* a literal.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
* (runDevApp).
|
|
17
|
-
* `bin/main.ts` (runProdApp)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* ACCEPTED EXCEPTION: `bin/server.ts` is the designated DEV entrypoint
|
|
16
|
+
* (runDevApp). Dev secret fallbacks are intended there — the PROD entrypoint
|
|
17
|
+
* `bin/main.ts` (runProdApp) reads the same secrets from the validated env
|
|
18
|
+
* and fails hard. The guard therefore accepts literals only in `bin/server.ts`
|
|
19
|
+
* and flags them everywhere else (prod entrypoint, config modules, handlers, lib).
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Scans its own checkout (the `roots` the runner resolves) —
|
|
22
|
+
* every repo runs this check against itself in its own CI, instead of a
|
|
23
|
+
* central scan reaching into foreign sibling checkouts.
|
|
24
24
|
*/
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
26
|
import { join } from "node:path";
|
|
@@ -81,11 +81,11 @@ function maskComments(source: string): string {
|
|
|
81
81
|
const children = node.getChildren(sourceFile).filter((child) => !isJsDocKind(child.kind));
|
|
82
82
|
if (children.length > 0) {
|
|
83
83
|
for (const child of children) visit(child);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
} else {
|
|
85
|
+
const trivia = source.slice(node.pos, node.getStart(sourceFile));
|
|
86
|
+
for (const m of trivia.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)) {
|
|
87
|
+
blank(node.pos + m.index, node.pos + m.index + m[0].length);
|
|
88
|
+
}
|
|
89
89
|
}
|
|
90
90
|
};
|
|
91
91
|
visit(sourceFile);
|
|
@@ -105,6 +105,33 @@ const SECRET_LITERAL_TOKEN = /hmac|private[_-]?key|signing[_-]?key/i;
|
|
|
105
105
|
const LHS_NAME = /([\w$]+)\s*[:=][^:=]*$/;
|
|
106
106
|
const UNNAMED_PLACEHOLDER = "<unnamed>";
|
|
107
107
|
|
|
108
|
+
type SecretFallback = { readonly name: string; readonly literalLength: number };
|
|
109
|
+
|
|
110
|
+
function secretNameFor(left: string, literal: string): string | undefined {
|
|
111
|
+
return (
|
|
112
|
+
SECRET_NAME.exec(left)?.[0] ??
|
|
113
|
+
(SECRET_LITERAL_TOKEN.test(literal)
|
|
114
|
+
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
115
|
+
: undefined)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Every fallback on the line is inspected; the name is read only from the text since the
|
|
120
|
+
// previous fallback, so an earlier assignee cannot lend its name to a later literal.
|
|
121
|
+
function firstSecretFallback(code: string): SecretFallback | undefined {
|
|
122
|
+
let previousEnd = 0;
|
|
123
|
+
for (const match of code.matchAll(STRING_FALLBACK)) {
|
|
124
|
+
const left = code.slice(previousEnd, match.index);
|
|
125
|
+
previousEnd = match.index + match[0].length;
|
|
126
|
+
const literal = match[2];
|
|
127
|
+
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
128
|
+
const name = secretNameFor(left, literal);
|
|
129
|
+
if (name === undefined) continue;
|
|
130
|
+
return { name, literalLength: literal.length };
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
/**
|
|
109
136
|
* Per-file scan on comment-masked source (see maskComments), so only comment
|
|
110
137
|
* text is skipped and a wrapped code line that starts with `*` is still
|
|
@@ -116,26 +143,10 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
|
|
|
116
143
|
const maskedLines = maskComments(lines.join("\n")).split("\n");
|
|
117
144
|
for (let i = 0; i < lines.length; i++) {
|
|
118
145
|
const raw = lines[i] ?? "";
|
|
119
|
-
const code = maskedLines[i] ?? "";
|
|
120
146
|
if (lineHasIgnoreTag(raw, IGNORE_TAG) || lineHasIgnoreTag(lines[i - 1] ?? "", IGNORE_TAG))
|
|
121
147
|
continue;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let previousEnd = 0;
|
|
125
|
-
for (const match of code.matchAll(STRING_FALLBACK)) {
|
|
126
|
-
const left = code.slice(previousEnd, match.index);
|
|
127
|
-
previousEnd = match.index + match[0].length;
|
|
128
|
-
const literal = match[2];
|
|
129
|
-
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
130
|
-
const name =
|
|
131
|
-
SECRET_NAME.exec(left)?.[0] ??
|
|
132
|
-
(SECRET_LITERAL_TOKEN.test(literal)
|
|
133
|
-
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
134
|
-
: undefined);
|
|
135
|
-
if (name === undefined) continue;
|
|
136
|
-
hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
148
|
+
const fallback = firstSecretFallback(maskedLines[i] ?? "");
|
|
149
|
+
if (fallback) hits.push({ lineNumber: i + 1, ...fallback });
|
|
139
150
|
}
|
|
140
151
|
return hits;
|
|
141
152
|
}
|
|
@@ -103,7 +103,7 @@ export const check: RepoCheck = {
|
|
|
103
103
|
violations.push({
|
|
104
104
|
file: "bun.lock",
|
|
105
105
|
line: 1,
|
|
106
|
-
message: `bun.lock could not be parsed (${root.name}): ${lock.
|
|
106
|
+
message: `bun.lock could not be parsed (${root.name}): ${lock.detail}`,
|
|
107
107
|
});
|
|
108
108
|
continue;
|
|
109
109
|
}
|
package/src/cli.ts
CHANGED
|
@@ -37,8 +37,11 @@ function printHelp(): void {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
function parseInvocation(argv: readonly string[]): {
|
|
41
|
+
subcommand: Subcommand | undefined;
|
|
42
|
+
flags: string[];
|
|
43
|
+
} {
|
|
44
|
+
const subcommand = argv[2];
|
|
42
45
|
if (subcommand === "--help" || subcommand === "-h") {
|
|
43
46
|
printHelp();
|
|
44
47
|
process.exit(0);
|
|
@@ -49,27 +52,37 @@ async function main(): Promise<void> {
|
|
|
49
52
|
);
|
|
50
53
|
process.exit(1);
|
|
51
54
|
}
|
|
52
|
-
const flags =
|
|
55
|
+
const flags = argv.slice(3);
|
|
53
56
|
if (subcommand !== undefined && (flags.includes("--help") || flags.includes("-h"))) {
|
|
54
57
|
printHelp();
|
|
55
58
|
process.exit(0);
|
|
56
59
|
}
|
|
60
|
+
return { subcommand, flags };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printInventory(flags: readonly string[]): void {
|
|
64
|
+
const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
|
|
65
|
+
if (flagsError !== undefined) {
|
|
66
|
+
console.error(flagsError);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
// Registration inventory only — no scan, no project, no guard.run(). What
|
|
70
|
+
// CI checks against instead of running the guards: a guard dropped from a
|
|
71
|
+
// suite's array goes missing here too, not just silently from a run.
|
|
72
|
+
const inventory = buildGuardKitInventory({
|
|
73
|
+
guards: GUARDS,
|
|
74
|
+
uiGuards: UI_GUARDS,
|
|
75
|
+
checks: REPO_CHECKS,
|
|
76
|
+
});
|
|
77
|
+
console.log(JSON.stringify(inventory));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main(): Promise<void> {
|
|
81
|
+
const { subcommand, flags } = parseInvocation(process.argv);
|
|
57
82
|
|
|
58
83
|
if (subcommand === "list") {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
console.error(flagsError);
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
64
|
-
// Registration inventory only — no scan, no project, no guard.run(). What
|
|
65
|
-
// CI checks against instead of running the guards: a guard dropped from a
|
|
66
|
-
// suite's array goes missing here too, not just silently from a run.
|
|
67
|
-
const inventory = buildGuardKitInventory({
|
|
68
|
-
guards: GUARDS,
|
|
69
|
-
uiGuards: UI_GUARDS,
|
|
70
|
-
checks: REPO_CHECKS,
|
|
71
|
-
});
|
|
72
|
-
console.log(JSON.stringify(inventory));
|
|
84
|
+
printInventory(flags);
|
|
85
|
+
// skip: the inventory subcommand only prints and must not run any guard
|
|
73
86
|
return;
|
|
74
87
|
}
|
|
75
88
|
|