@hongmaple0820/scale-engine 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +13 -5
- package/README.md +13 -5
- package/dist/api/cli.js +239 -3
- package/dist/api/cli.js.map +1 -1
- package/dist/api/doctor.d.ts +2 -0
- package/dist/api/doctor.js +83 -0
- package/dist/api/doctor.js.map +1 -1
- package/dist/api/mcp.js +2 -1
- package/dist/api/mcp.js.map +1 -1
- package/dist/capabilities/InstalledSkillsIntegration.d.ts +3 -0
- package/dist/capabilities/InstalledSkillsIntegration.js +41 -17
- package/dist/capabilities/InstalledSkillsIntegration.js.map +1 -1
- package/dist/cli/phaseCommands.js +63 -5
- package/dist/cli/phaseCommands.js.map +1 -1
- package/dist/core/logger.d.ts +2 -0
- package/dist/core/logger.js +33 -1
- package/dist/core/logger.js.map +1 -1
- package/dist/output/HTMLDocumentRenderer.js +3 -2
- package/dist/output/HTMLDocumentRenderer.js.map +1 -1
- package/dist/skills/ExternalSkills.js +9 -4
- package/dist/skills/ExternalSkills.js.map +1 -1
- package/dist/skills/SkillDiscovery.js +5 -3
- package/dist/skills/SkillDiscovery.js.map +1 -1
- package/dist/skills/SkillDoctor.js +178 -1
- package/dist/skills/SkillDoctor.js.map +1 -1
- package/dist/skills/SkillInstaller.js +5 -0
- package/dist/skills/SkillInstaller.js.map +1 -1
- package/dist/skills/routing/SkillPolicy.js +168 -5
- package/dist/skills/routing/SkillPolicy.js.map +1 -1
- package/dist/version.d.ts +3 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/dist/workflow/EngineeringStandards.d.ts +143 -0
- package/dist/workflow/EngineeringStandards.js +679 -0
- package/dist/workflow/EngineeringStandards.js.map +1 -0
- package/dist/workflow/GovernanceTemplatePacks.d.ts +1 -1
- package/dist/workflow/GovernanceTemplatePacks.js +99 -18
- package/dist/workflow/GovernanceTemplatePacks.js.map +1 -1
- package/dist/workflow/GovernanceTemplates.d.ts +1 -1
- package/dist/workflow/GovernanceTemplates.js +211 -34
- package/dist/workflow/GovernanceTemplates.js.map +1 -1
- package/dist/workflow/ResourceGovernance.d.ts +120 -0
- package/dist/workflow/ResourceGovernance.js +512 -0
- package/dist/workflow/ResourceGovernance.js.map +1 -0
- package/dist/workflow/TaskArtifactScaffolder.js +3 -0
- package/dist/workflow/TaskArtifactScaffolder.js.map +1 -1
- package/dist/workflow/VerificationProfile.d.ts +2 -0
- package/dist/workflow/VerificationProfile.js +7 -0
- package/dist/workflow/VerificationProfile.js.map +1 -1
- package/dist/workflow/index.d.ts +2 -0
- package/dist/workflow/index.js +2 -0
- package/dist/workflow/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
const DEFAULT_SOURCE_DIRECTORIES = ['src', 'app', 'packages', 'services', 'cmd', 'internal', 'pkg'];
|
|
4
|
+
const DEFAULT_IGNORED_DIRECTORIES = [
|
|
5
|
+
'.git',
|
|
6
|
+
'.scale',
|
|
7
|
+
'node_modules',
|
|
8
|
+
'dist',
|
|
9
|
+
'build',
|
|
10
|
+
'coverage',
|
|
11
|
+
'test-results',
|
|
12
|
+
'playwright-report',
|
|
13
|
+
'tmp',
|
|
14
|
+
'temp',
|
|
15
|
+
'docs',
|
|
16
|
+
'tests',
|
|
17
|
+
'__tests__',
|
|
18
|
+
'e2e',
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_ALLOWED_CONSOLE_DIRECTORIES = ['src/api', 'src/cli', 'scripts'];
|
|
21
|
+
const DEFAULT_ALLOWED_CONSOLE_FILES = ['src/dashboard/DashboardServer.ts'];
|
|
22
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
23
|
+
'password',
|
|
24
|
+
'passwd',
|
|
25
|
+
'token',
|
|
26
|
+
'accessToken',
|
|
27
|
+
'refreshToken',
|
|
28
|
+
'secret',
|
|
29
|
+
'authorization',
|
|
30
|
+
'cookie',
|
|
31
|
+
'apiKey',
|
|
32
|
+
'credential',
|
|
33
|
+
'privateKey',
|
|
34
|
+
];
|
|
35
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
36
|
+
'.ts',
|
|
37
|
+
'.tsx',
|
|
38
|
+
'.js',
|
|
39
|
+
'.jsx',
|
|
40
|
+
'.mjs',
|
|
41
|
+
'.cjs',
|
|
42
|
+
'.go',
|
|
43
|
+
'.py',
|
|
44
|
+
'.java',
|
|
45
|
+
'.cs',
|
|
46
|
+
'.kt',
|
|
47
|
+
'.php',
|
|
48
|
+
'.rb',
|
|
49
|
+
'.rs',
|
|
50
|
+
'.vue',
|
|
51
|
+
'.svelte',
|
|
52
|
+
]);
|
|
53
|
+
export function engineeringStandardsPolicyPath(projectDir = process.cwd(), scaleDir = '.scale') {
|
|
54
|
+
return join(projectDir, scaleDir, 'engineering-standards.json');
|
|
55
|
+
}
|
|
56
|
+
export function frameworksCatalogPath(projectDir = process.cwd(), scaleDir = '.scale') {
|
|
57
|
+
return join(projectDir, scaleDir, 'frameworks.json');
|
|
58
|
+
}
|
|
59
|
+
export function engineeringStandardsPolicyTemplate() {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
version: 1,
|
|
62
|
+
mode: 'warn',
|
|
63
|
+
sourceDirectories: DEFAULT_SOURCE_DIRECTORIES,
|
|
64
|
+
ignoredDirectories: DEFAULT_IGNORED_DIRECTORIES,
|
|
65
|
+
allowedConsoleDirectories: DEFAULT_ALLOWED_CONSOLE_DIRECTORIES,
|
|
66
|
+
allowedConsoleFiles: DEFAULT_ALLOWED_CONSOLE_FILES,
|
|
67
|
+
maxFileLines: 500,
|
|
68
|
+
logging: {
|
|
69
|
+
approvedLoggers: ['pino', 'winston', 'zap', 'zerolog', 'logrus', 'slog'],
|
|
70
|
+
sensitiveFields: DEFAULT_SENSITIVE_FIELDS,
|
|
71
|
+
},
|
|
72
|
+
architecture: {
|
|
73
|
+
enforceLayering: true,
|
|
74
|
+
},
|
|
75
|
+
blockingRules: [],
|
|
76
|
+
allowedFindingPatterns: [],
|
|
77
|
+
baselineFindings: [],
|
|
78
|
+
}, null, 2) + '\n';
|
|
79
|
+
}
|
|
80
|
+
export function frameworksCatalogTemplate() {
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
version: 1,
|
|
83
|
+
lastReviewedAt: '',
|
|
84
|
+
reviewIntervalDays: 90,
|
|
85
|
+
frameworks: [],
|
|
86
|
+
orm: [],
|
|
87
|
+
ui: {
|
|
88
|
+
designSystem: '',
|
|
89
|
+
componentLibrary: '',
|
|
90
|
+
visualReviewRequired: true,
|
|
91
|
+
},
|
|
92
|
+
architecture: {
|
|
93
|
+
layers: ['api', 'service', 'domain', 'repository', 'infrastructure'],
|
|
94
|
+
dependencyRule: 'outer layers depend inward through explicit interfaces',
|
|
95
|
+
},
|
|
96
|
+
bannedImports: [],
|
|
97
|
+
}, null, 2) + '\n';
|
|
98
|
+
}
|
|
99
|
+
export function loadEngineeringStandardsPolicy(projectDir = process.cwd(), scaleDir = '.scale') {
|
|
100
|
+
const path = engineeringStandardsPolicyPath(projectDir, scaleDir);
|
|
101
|
+
const warnings = [];
|
|
102
|
+
let parsed = {};
|
|
103
|
+
if (existsSync(path)) {
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
warnings.push(`Failed to read ${path}: ${error.message}; using built-in defaults.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
warnings.push(`No engineering standards policy found at ${path}; using built-in defaults.`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
version: typeof parsed.version === 'number' ? parsed.version : 1,
|
|
116
|
+
mode: parsed.mode === 'block' ? 'block' : 'warn',
|
|
117
|
+
sourceDirectories: parsed.sourceDirectories ?? DEFAULT_SOURCE_DIRECTORIES,
|
|
118
|
+
ignoredDirectories: parsed.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES,
|
|
119
|
+
allowedConsoleDirectories: parsed.allowedConsoleDirectories ?? DEFAULT_ALLOWED_CONSOLE_DIRECTORIES,
|
|
120
|
+
allowedConsoleFiles: parsed.allowedConsoleFiles ?? DEFAULT_ALLOWED_CONSOLE_FILES,
|
|
121
|
+
maxFileLines: parsed.maxFileLines ?? 500,
|
|
122
|
+
logging: {
|
|
123
|
+
approvedLoggers: parsed.logging?.approvedLoggers ?? ['pino', 'winston', 'zap', 'zerolog', 'logrus', 'slog'],
|
|
124
|
+
sensitiveFields: parsed.logging?.sensitiveFields ?? DEFAULT_SENSITIVE_FIELDS,
|
|
125
|
+
},
|
|
126
|
+
architecture: {
|
|
127
|
+
enforceLayering: parsed.architecture?.enforceLayering ?? true,
|
|
128
|
+
},
|
|
129
|
+
blockingRules: Array.isArray(parsed.blockingRules)
|
|
130
|
+
? parsed.blockingRules.filter(ruleId => typeof ruleId === 'string' && ruleId.length > 0)
|
|
131
|
+
: [],
|
|
132
|
+
allowedFindingPatterns: resolveAllowedFindingPatterns(parsed, warnings),
|
|
133
|
+
baselineFindings: Array.isArray(parsed.baselineFindings)
|
|
134
|
+
? parsed.baselineFindings
|
|
135
|
+
.filter(item => typeof item.ruleId === 'string' && typeof item.path === 'string')
|
|
136
|
+
.map(item => ({
|
|
137
|
+
...item,
|
|
138
|
+
line: typeof item.line === 'number' ? item.line : undefined,
|
|
139
|
+
}))
|
|
140
|
+
: [],
|
|
141
|
+
warnings,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function resolveAllowedFindingPatterns(parsed, warnings) {
|
|
145
|
+
if (!Array.isArray(parsed.allowedFindingPatterns))
|
|
146
|
+
return [];
|
|
147
|
+
const patterns = [];
|
|
148
|
+
for (const item of parsed.allowedFindingPatterns) {
|
|
149
|
+
if (!item || typeof item !== 'object')
|
|
150
|
+
continue;
|
|
151
|
+
if (typeof item.ruleId !== 'string' || item.ruleId.length === 0)
|
|
152
|
+
continue;
|
|
153
|
+
if (typeof item.evidencePattern !== 'string' && typeof item.messagePattern !== 'string')
|
|
154
|
+
continue;
|
|
155
|
+
const validEvidencePattern = typeof item.evidencePattern !== 'string' || isValidRegex(item.evidencePattern);
|
|
156
|
+
const validMessagePattern = typeof item.messagePattern !== 'string' || isValidRegex(item.messagePattern);
|
|
157
|
+
if (!validEvidencePattern || !validMessagePattern) {
|
|
158
|
+
warnings.push(`Invalid allowedFindingPatterns entry for ${item.ruleId}; regex could not be compiled.`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
patterns.push({
|
|
162
|
+
ruleId: item.ruleId,
|
|
163
|
+
path: typeof item.path === 'string' ? item.path : undefined,
|
|
164
|
+
evidencePattern: typeof item.evidencePattern === 'string' ? item.evidencePattern : undefined,
|
|
165
|
+
messagePattern: typeof item.messagePattern === 'string' ? item.messagePattern : undefined,
|
|
166
|
+
reason: typeof item.reason === 'string' ? item.reason : undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return patterns;
|
|
170
|
+
}
|
|
171
|
+
function isValidRegex(pattern) {
|
|
172
|
+
try {
|
|
173
|
+
new RegExp(pattern);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function loadFrameworksCatalog(projectDir = process.cwd(), scaleDir = '.scale', now = new Date()) {
|
|
181
|
+
const path = frameworksCatalogPath(projectDir, scaleDir);
|
|
182
|
+
const warnings = [];
|
|
183
|
+
let parsed = {};
|
|
184
|
+
if (existsSync(path)) {
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
warnings.push({
|
|
190
|
+
ruleId: 'frameworks-catalog-warning',
|
|
191
|
+
message: `Failed to read ${path}: ${error.message}; using empty framework catalog.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const lastReviewedAt = typeof parsed.lastReviewedAt === 'string' ? parsed.lastReviewedAt : undefined;
|
|
196
|
+
const reviewIntervalDays = typeof parsed.reviewIntervalDays === 'number' ? parsed.reviewIntervalDays : undefined;
|
|
197
|
+
if (lastReviewedAt && reviewIntervalDays && isFrameworkCatalogStale(lastReviewedAt, reviewIntervalDays, now)) {
|
|
198
|
+
warnings.push({
|
|
199
|
+
ruleId: 'frameworks-catalog-stale',
|
|
200
|
+
message: `Framework catalog was last reviewed at ${lastReviewedAt}; review interval is ${reviewIntervalDays} days.`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
version: typeof parsed.version === 'number' ? parsed.version : 1,
|
|
205
|
+
lastReviewedAt,
|
|
206
|
+
reviewIntervalDays,
|
|
207
|
+
bannedImports: Array.isArray(parsed.bannedImports)
|
|
208
|
+
? parsed.bannedImports
|
|
209
|
+
.filter(rule => typeof rule.source === 'string' && rule.source.length > 0)
|
|
210
|
+
.map(rule => ({
|
|
211
|
+
source: rule.source,
|
|
212
|
+
severity: rule.severity === 'info' || rule.severity === 'warn' || rule.severity === 'fail'
|
|
213
|
+
? rule.severity
|
|
214
|
+
: 'fail',
|
|
215
|
+
reason: typeof rule.reason === 'string' ? rule.reason : undefined,
|
|
216
|
+
replacement: typeof rule.replacement === 'string' ? rule.replacement : undefined,
|
|
217
|
+
}))
|
|
218
|
+
: [],
|
|
219
|
+
warnings,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export function scanEngineeringStandards(options = {}) {
|
|
223
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
224
|
+
const scaleDir = options.scaleDir ?? '.scale';
|
|
225
|
+
const policy = loadEngineeringStandardsPolicy(projectDir, scaleDir);
|
|
226
|
+
const frameworks = loadFrameworksCatalog(projectDir, scaleDir, options.now);
|
|
227
|
+
const files = findSourceFiles(projectDir, policy);
|
|
228
|
+
const findings = files
|
|
229
|
+
.flatMap(file => scanFile(projectDir, file, policy, frameworks))
|
|
230
|
+
.map(finding => applyRuleSeverityPolicy(finding, policy))
|
|
231
|
+
.filter(finding => !isAllowedFindingPattern(finding, policy) && !isBaselineFinding(finding, policy));
|
|
232
|
+
return {
|
|
233
|
+
projectDir,
|
|
234
|
+
policyPath: engineeringStandardsPolicyPath(projectDir, scaleDir),
|
|
235
|
+
frameworksPath: frameworksCatalogPath(projectDir, scaleDir),
|
|
236
|
+
policy,
|
|
237
|
+
frameworks,
|
|
238
|
+
findings,
|
|
239
|
+
summary: summarizeStandards(files.length, findings),
|
|
240
|
+
warnings: [...policy.warnings, ...frameworks.warnings.map(warning => warning.message)],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export function doctorEngineeringStandards(options = {}) {
|
|
244
|
+
const scan = scanEngineeringStandards(options);
|
|
245
|
+
const policyWarningFindings = scan.policy.warnings.map(message => ({
|
|
246
|
+
severity: 'warn',
|
|
247
|
+
category: 'framework',
|
|
248
|
+
ruleId: 'standards-policy-warning',
|
|
249
|
+
path: scan.policyPath,
|
|
250
|
+
message,
|
|
251
|
+
fix: 'Run scale init or add .scale/engineering-standards.json.',
|
|
252
|
+
}));
|
|
253
|
+
const frameworkWarningFindings = scan.frameworks.warnings.map(warning => ({
|
|
254
|
+
severity: 'warn',
|
|
255
|
+
category: 'framework',
|
|
256
|
+
ruleId: warning.ruleId,
|
|
257
|
+
path: scan.frameworksPath,
|
|
258
|
+
message: warning.message,
|
|
259
|
+
fix: 'Fix .scale/frameworks.json or regenerate it with scale init.',
|
|
260
|
+
}));
|
|
261
|
+
const findings = [...policyWarningFindings, ...frameworkWarningFindings, ...scan.findings];
|
|
262
|
+
return {
|
|
263
|
+
ok: !findings.some(finding => finding.severity === 'fail'),
|
|
264
|
+
projectDir: scan.projectDir,
|
|
265
|
+
findings,
|
|
266
|
+
scan: { ...scan, findings },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export function settleEngineeringStandards(options = {}) {
|
|
270
|
+
const doctor = doctorEngineeringStandards(options);
|
|
271
|
+
const standardsImpactPath = options.artifactsDir
|
|
272
|
+
? appendStandardsImpact({
|
|
273
|
+
projectDir: options.projectDir ?? process.cwd(),
|
|
274
|
+
artifactsDir: options.artifactsDir,
|
|
275
|
+
taskId: options.taskId,
|
|
276
|
+
doctor,
|
|
277
|
+
})
|
|
278
|
+
: undefined;
|
|
279
|
+
return {
|
|
280
|
+
ok: doctor.ok,
|
|
281
|
+
taskId: options.taskId,
|
|
282
|
+
standardsImpactPath,
|
|
283
|
+
doctor,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function scanFile(projectDir, absolutePath, policy, frameworks) {
|
|
287
|
+
const path = normalizePath(relative(projectDir, absolutePath));
|
|
288
|
+
const content = readFileSync(absolutePath, 'utf-8');
|
|
289
|
+
const lines = content.split(/\r?\n/);
|
|
290
|
+
const findings = [];
|
|
291
|
+
if (lines.length > policy.maxFileLines) {
|
|
292
|
+
findings.push({
|
|
293
|
+
severity: 'warn',
|
|
294
|
+
category: 'architecture',
|
|
295
|
+
ruleId: 'large-source-file',
|
|
296
|
+
path,
|
|
297
|
+
message: `Source file has ${lines.length} lines, above ${policy.maxFileLines}.`,
|
|
298
|
+
fix: 'Split responsibilities or document why this file is intentionally large.',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
302
|
+
const line = lines[index];
|
|
303
|
+
if (isNonExecutablePatternLine(line))
|
|
304
|
+
continue;
|
|
305
|
+
const lineNumber = index + 1;
|
|
306
|
+
findings.push(...scanLine(path, line, lineNumber, policy, frameworks));
|
|
307
|
+
}
|
|
308
|
+
findings.push(...findEmptyCatchBlocks(path, lines));
|
|
309
|
+
return dedupeFindings(findings);
|
|
310
|
+
}
|
|
311
|
+
function scanLine(path, line, lineNumber, policy, frameworks) {
|
|
312
|
+
const findings = [];
|
|
313
|
+
const sensitiveMatcher = sensitiveFieldPattern(policy);
|
|
314
|
+
const evidence = line.trim().slice(0, 160);
|
|
315
|
+
findings.push(...scanBannedImports(path, line, lineNumber, evidence, frameworks));
|
|
316
|
+
if (isHardcodedSecret(line, policy)) {
|
|
317
|
+
findings.push({
|
|
318
|
+
severity: 'fail',
|
|
319
|
+
category: 'security',
|
|
320
|
+
ruleId: 'hardcoded-secret',
|
|
321
|
+
path,
|
|
322
|
+
line: lineNumber,
|
|
323
|
+
message: 'Secret-like value appears to be hardcoded in source.',
|
|
324
|
+
evidence,
|
|
325
|
+
fix: 'Move secrets to approved secret storage or environment configuration and keep placeholders non-sensitive.',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if ((isLogCall(line) || isAdHocOutputCall(line)) && sensitiveMatcher.test(line)) {
|
|
329
|
+
findings.push({
|
|
330
|
+
severity: 'fail',
|
|
331
|
+
category: 'logging',
|
|
332
|
+
ruleId: 'sensitive-log',
|
|
333
|
+
path,
|
|
334
|
+
line: lineNumber,
|
|
335
|
+
message: 'Sensitive field appears in a log statement.',
|
|
336
|
+
evidence,
|
|
337
|
+
fix: 'Remove the field, mask it, or use an approved redaction helper before logging.',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else if (isAdHocOutputCall(line) && !isConsoleAllowed(path, policy)) {
|
|
341
|
+
findings.push({
|
|
342
|
+
severity: 'warn',
|
|
343
|
+
category: 'logging',
|
|
344
|
+
ruleId: 'ad-hoc-console-log',
|
|
345
|
+
path,
|
|
346
|
+
line: lineNumber,
|
|
347
|
+
message: 'Ad-hoc console logging was found outside approved CLI or script paths.',
|
|
348
|
+
evidence,
|
|
349
|
+
fix: 'Use the project logger, remove the debug print, or add an explicit policy exception.',
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (isRawSqlConstruction(line)) {
|
|
353
|
+
findings.push({
|
|
354
|
+
severity: 'fail',
|
|
355
|
+
category: 'database',
|
|
356
|
+
ruleId: 'raw-sql-construction',
|
|
357
|
+
path,
|
|
358
|
+
line: lineNumber,
|
|
359
|
+
message: 'SQL appears to be constructed with dynamic input.',
|
|
360
|
+
evidence,
|
|
361
|
+
fix: 'Use parameterized queries, ORM bind parameters, or a query builder with placeholders.',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (/dangerouslySetInnerHTML|\.innerHTML\s*=|document\.write\s*\(/.test(line)) {
|
|
365
|
+
findings.push({
|
|
366
|
+
severity: 'fail',
|
|
367
|
+
category: 'security',
|
|
368
|
+
ruleId: 'unsafe-html-sink',
|
|
369
|
+
path,
|
|
370
|
+
line: lineNumber,
|
|
371
|
+
message: 'Unsafe HTML sink can create XSS risk.',
|
|
372
|
+
evidence,
|
|
373
|
+
fix: 'Use text rendering or sanitize trusted HTML with an approved sanitizer.',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (/\beval\s*\(|new\s+Function\s*\(/.test(line)) {
|
|
377
|
+
findings.push({
|
|
378
|
+
severity: 'fail',
|
|
379
|
+
category: 'security',
|
|
380
|
+
ruleId: 'unsafe-code-execution',
|
|
381
|
+
path,
|
|
382
|
+
line: lineNumber,
|
|
383
|
+
message: 'Dynamic code execution was found.',
|
|
384
|
+
evidence,
|
|
385
|
+
fix: 'Replace eval or Function with a typed parser, dispatch table, or safe interpreter.',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (/^\s*(?:\/\/|\/\*)\s*@ts-ignore\b/.test(line)) {
|
|
389
|
+
findings.push({
|
|
390
|
+
severity: 'fail',
|
|
391
|
+
category: 'code-quality',
|
|
392
|
+
ruleId: 'ts-ignore',
|
|
393
|
+
path,
|
|
394
|
+
line: lineNumber,
|
|
395
|
+
message: 'TypeScript errors are suppressed with @ts-ignore.',
|
|
396
|
+
evidence,
|
|
397
|
+
fix: 'Fix the type boundary or use a narrow typed adapter with a documented reason.',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (/\bas\s+any\b|:\s*any\b|<any\b|Array<any>|Promise<any>|Record<[^>]+,\s*any>/.test(line)) {
|
|
401
|
+
findings.push({
|
|
402
|
+
severity: 'warn',
|
|
403
|
+
category: 'code-quality',
|
|
404
|
+
ruleId: 'type-escape',
|
|
405
|
+
path,
|
|
406
|
+
line: lineNumber,
|
|
407
|
+
message: 'New any-based type escape weakens interface contracts.',
|
|
408
|
+
evidence,
|
|
409
|
+
fix: 'Model the real type or isolate unknown input at the boundary.',
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (/Math\.random\s*\(\)/.test(line) && /\b(token|secret|session|password|credential|nonce)\b/i.test(line)) {
|
|
413
|
+
findings.push({
|
|
414
|
+
severity: 'fail',
|
|
415
|
+
category: 'security',
|
|
416
|
+
ruleId: 'weak-random-security-token',
|
|
417
|
+
path,
|
|
418
|
+
line: lineNumber,
|
|
419
|
+
message: 'Math.random is used for security-sensitive data.',
|
|
420
|
+
evidence,
|
|
421
|
+
fix: 'Use a cryptographically secure random source.',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (policy.architecture.enforceLayering && isOuterLayerPath(path) && importsInnerPersistence(line)) {
|
|
425
|
+
findings.push({
|
|
426
|
+
severity: 'warn',
|
|
427
|
+
category: 'architecture',
|
|
428
|
+
ruleId: 'layer-boundary-bypass',
|
|
429
|
+
path,
|
|
430
|
+
line: lineNumber,
|
|
431
|
+
message: 'Outer layer appears to import persistence internals directly.',
|
|
432
|
+
evidence,
|
|
433
|
+
fix: 'Route through service/usecase interfaces and keep persistence behind a repository boundary.',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return findings;
|
|
437
|
+
}
|
|
438
|
+
function findEmptyCatchBlocks(path, lines) {
|
|
439
|
+
const findings = [];
|
|
440
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
441
|
+
const line = lines[index];
|
|
442
|
+
if (/catch\s*(?:\([^)]*\))?\s*\{\s*(?:\/\*.*?\*\/|\/\/.*)?\s*\}/.test(line)) {
|
|
443
|
+
findings.push(emptyCatchFinding(path, index + 1, line));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!/catch\s*(?:\([^)]*\))?\s*\{\s*$/.test(line))
|
|
447
|
+
continue;
|
|
448
|
+
for (const next of lines.slice(index + 1, index + 8)) {
|
|
449
|
+
const trimmed = next.trim();
|
|
450
|
+
if (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'))
|
|
451
|
+
continue;
|
|
452
|
+
if (/^}\s*[),;]?$/.test(trimmed))
|
|
453
|
+
findings.push(emptyCatchFinding(path, index + 1, line));
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return findings;
|
|
458
|
+
}
|
|
459
|
+
function emptyCatchFinding(path, line, text) {
|
|
460
|
+
return {
|
|
461
|
+
severity: 'fail',
|
|
462
|
+
category: 'code-quality',
|
|
463
|
+
ruleId: 'empty-catch',
|
|
464
|
+
path,
|
|
465
|
+
line,
|
|
466
|
+
message: 'Empty or comment-only catch block hides failures.',
|
|
467
|
+
evidence: text.trim().slice(0, 160),
|
|
468
|
+
fix: 'Handle the error, return a typed failure, or log through the approved redacted logger.',
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function findSourceFiles(projectDir, policy) {
|
|
472
|
+
const files = [];
|
|
473
|
+
for (const sourceDir of policy.sourceDirectories) {
|
|
474
|
+
const absolute = join(projectDir, sourceDir);
|
|
475
|
+
if (!existsSync(absolute))
|
|
476
|
+
continue;
|
|
477
|
+
walk(absolute, projectDir, policy, files);
|
|
478
|
+
}
|
|
479
|
+
return files;
|
|
480
|
+
}
|
|
481
|
+
function walk(dir, projectDir, policy, files) {
|
|
482
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
483
|
+
const fullPath = join(dir, entry.name);
|
|
484
|
+
const rel = normalizePath(relative(projectDir, fullPath));
|
|
485
|
+
if (entry.isDirectory()) {
|
|
486
|
+
if (policy.ignoredDirectories.some(ignored => rel === normalizePath(ignored) || rel.startsWith(`${normalizePath(ignored)}/`) || entry.name === ignored))
|
|
487
|
+
continue;
|
|
488
|
+
walk(fullPath, projectDir, policy, files);
|
|
489
|
+
}
|
|
490
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
491
|
+
if (statSync(fullPath).size <= 1024 * 1024)
|
|
492
|
+
files.push(fullPath);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function appendStandardsImpact(options) {
|
|
497
|
+
const dir = isAbsolute(options.artifactsDir)
|
|
498
|
+
? options.artifactsDir
|
|
499
|
+
: resolve(options.projectDir, options.artifactsDir);
|
|
500
|
+
if (!existsSync(dir))
|
|
501
|
+
mkdirSync(dir, { recursive: true });
|
|
502
|
+
const path = join(dir, 'standards-impact.md');
|
|
503
|
+
if (!existsSync(path))
|
|
504
|
+
writeFileSync(path, '# Standards Impact\n\n', 'utf-8');
|
|
505
|
+
appendFileSync(path, standardsSettlementMarkdown(options.taskId, options.doctor), 'utf-8');
|
|
506
|
+
return path;
|
|
507
|
+
}
|
|
508
|
+
function standardsSettlementMarkdown(taskId, doctor) {
|
|
509
|
+
const findings = doctor.findings.length
|
|
510
|
+
? doctor.findings.map(finding => `| ${finding.severity.toUpperCase()} | ${finding.ruleId} | ${escapeCell(finding.path)} | ${finding.line ?? ''} | ${escapeCell(finding.message)} |`).join('\n')
|
|
511
|
+
: '| OK | no-findings | | | No engineering standards findings. |';
|
|
512
|
+
return `
|
|
513
|
+
## SCALE Engineering Standards Settlement - ${new Date().toISOString()}
|
|
514
|
+
|
|
515
|
+
Task: ${taskId ?? 'unspecified'}
|
|
516
|
+
Status: ${doctor.ok ? 'passed' : 'blocked'}
|
|
517
|
+
|
|
518
|
+
| Metric | Value |
|
|
519
|
+
| --- | ---: |
|
|
520
|
+
| Files scanned | ${doctor.scan.summary.filesScanned} |
|
|
521
|
+
| Total findings | ${doctor.scan.summary.totalFindings} |
|
|
522
|
+
| Blocking findings | ${doctor.scan.summary.blockingFindings} |
|
|
523
|
+
|
|
524
|
+
| Severity | Rule | Path | Line | Message |
|
|
525
|
+
| --- | --- | --- | ---: | --- |
|
|
526
|
+
${findings}
|
|
527
|
+
`;
|
|
528
|
+
}
|
|
529
|
+
function summarizeStandards(filesScanned, findings) {
|
|
530
|
+
const bySeverity = { info: 0, warn: 0, fail: 0 };
|
|
531
|
+
const byCategory = emptyCategorySummary();
|
|
532
|
+
for (const finding of findings) {
|
|
533
|
+
bySeverity[finding.severity] += 1;
|
|
534
|
+
byCategory[finding.category] += 1;
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
filesScanned,
|
|
538
|
+
totalFindings: findings.length,
|
|
539
|
+
blockingFindings: findings.filter(finding => finding.severity === 'fail').length,
|
|
540
|
+
bySeverity,
|
|
541
|
+
byCategory,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function emptyCategorySummary() {
|
|
545
|
+
return {
|
|
546
|
+
logging: 0,
|
|
547
|
+
security: 0,
|
|
548
|
+
database: 0,
|
|
549
|
+
architecture: 0,
|
|
550
|
+
'code-quality': 0,
|
|
551
|
+
framework: 0,
|
|
552
|
+
testing: 0,
|
|
553
|
+
uiux: 0,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function applyRuleSeverityPolicy(finding, policy) {
|
|
557
|
+
if (finding.severity === 'fail' || !policy.blockingRules.includes(finding.ruleId))
|
|
558
|
+
return finding;
|
|
559
|
+
return {
|
|
560
|
+
...finding,
|
|
561
|
+
severity: 'fail',
|
|
562
|
+
message: `${finding.message} This rule is configured as blocking.`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
function scanBannedImports(path, line, lineNumber, evidence, frameworks) {
|
|
566
|
+
if (!/\bimport\b|\brequire\s*\(/.test(line))
|
|
567
|
+
return [];
|
|
568
|
+
return frameworks.bannedImports
|
|
569
|
+
.filter(rule => importsSource(line, rule.source))
|
|
570
|
+
.map(rule => ({
|
|
571
|
+
severity: rule.severity ?? 'fail',
|
|
572
|
+
category: 'framework',
|
|
573
|
+
ruleId: 'banned-import',
|
|
574
|
+
path,
|
|
575
|
+
line: lineNumber,
|
|
576
|
+
message: `Import from "${rule.source}" violates the framework catalog.${rule.reason ? ` ${rule.reason}` : ''}`,
|
|
577
|
+
evidence,
|
|
578
|
+
fix: rule.replacement
|
|
579
|
+
? `Use ${rule.replacement} instead.`
|
|
580
|
+
: 'Use the project-approved framework, ORM, component, or boundary from .scale/frameworks.json.',
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
function importsSource(line, source) {
|
|
584
|
+
const escaped = escapeRegex(source);
|
|
585
|
+
const sourceBoundary = `(?:['"]|/)`;
|
|
586
|
+
return new RegExp(`\\bfrom\\s+['"]${escaped}${sourceBoundary}|\\brequire\\s*\\(\\s*['"]${escaped}${sourceBoundary}`).test(line);
|
|
587
|
+
}
|
|
588
|
+
function isFrameworkCatalogStale(lastReviewedAt, reviewIntervalDays, now) {
|
|
589
|
+
const reviewedAt = new Date(`${lastReviewedAt}T00:00:00Z`);
|
|
590
|
+
if (Number.isNaN(reviewedAt.getTime()) || reviewIntervalDays <= 0)
|
|
591
|
+
return false;
|
|
592
|
+
return now.getTime() - reviewedAt.getTime() > reviewIntervalDays * 24 * 60 * 60 * 1000;
|
|
593
|
+
}
|
|
594
|
+
function sensitiveFieldPattern(policy) {
|
|
595
|
+
const fields = policy.logging.sensitiveFields.map(escapeRegex).join('|');
|
|
596
|
+
return new RegExp(`\\b(?:${fields})\\b`, 'i');
|
|
597
|
+
}
|
|
598
|
+
function isLogCall(line) {
|
|
599
|
+
return /\b(?:console\.(?:log|debug|info|warn|error)|logger\.(?:trace|debug|info|warn|error|fatal)|log(?:ger)?\.(?:trace|debug|info|warn|error|fatal)|log[A-Za-z0-9_]*\s*\()\b/.test(line);
|
|
600
|
+
}
|
|
601
|
+
function isAdHocOutputCall(line) {
|
|
602
|
+
return /\bconsole\.(?:log|debug|info|warn|error)\s*\(|\bfmt\.Print(?:f|ln)?\s*\(|\bprint(?:ln)?\s*\(|\bSystem\.out\.print(?:ln)?\s*\(|\bConsole\.Write(?:Line)?\s*\(|\bprintln!\s*\(/.test(line);
|
|
603
|
+
}
|
|
604
|
+
function isHardcodedSecret(line, policy) {
|
|
605
|
+
const fields = policy.logging.sensitiveFields.map(escapeRegex).join('|');
|
|
606
|
+
const match = new RegExp(`\\b\\w*(?:${fields})\\w*\\b\\s*[:=]\\s*(['"\`])([^'"\`]{12,})\\1`, 'i').exec(line);
|
|
607
|
+
if (!match)
|
|
608
|
+
return false;
|
|
609
|
+
return !/\b(example|sample|placeholder|changeme|replace-me|dummy|test-value)\b/i.test(match[2]);
|
|
610
|
+
}
|
|
611
|
+
function isRawSqlConstruction(line) {
|
|
612
|
+
return /\b(?:query|execute|exec|raw|rawQuery)\s*\(/i.test(line) &&
|
|
613
|
+
/\b(?:SELECT|INSERT|UPDATE|DELETE|DROP|TRUNCATE)\b/i.test(line) &&
|
|
614
|
+
(line.includes('+') || line.includes('${') || /\breq\./.test(line));
|
|
615
|
+
}
|
|
616
|
+
function isConsoleAllowed(path, policy) {
|
|
617
|
+
const normalized = normalizePath(path);
|
|
618
|
+
if (policy.allowedConsoleFiles.map(normalizePath).includes(normalized))
|
|
619
|
+
return true;
|
|
620
|
+
return policy.allowedConsoleDirectories
|
|
621
|
+
.map(normalizePath)
|
|
622
|
+
.some(prefix => normalized === prefix || normalized.startsWith(`${prefix}/`));
|
|
623
|
+
}
|
|
624
|
+
function isOuterLayerPath(path) {
|
|
625
|
+
return /(^|\/)(api|controller|controllers|handler|handlers|routes|pages)(\/|$)/i.test(path);
|
|
626
|
+
}
|
|
627
|
+
function importsInnerPersistence(line) {
|
|
628
|
+
return /\bimport\b.*(?:repository|repositories|dao|model|models|entity|entities|infra|infrastructure)|\bfrom\s+['"].*(?:repository|repositories|dao|model|models|entity|entities|infra|infrastructure)/i.test(line);
|
|
629
|
+
}
|
|
630
|
+
function isNonExecutablePatternLine(line) {
|
|
631
|
+
const trimmed = line.trim();
|
|
632
|
+
if (trimmed.includes('String.raw`') || trimmed.startsWith('templateBody:'))
|
|
633
|
+
return true;
|
|
634
|
+
return /^\/.*\/[dgimsuy]*,?$/.test(trimmed) ||
|
|
635
|
+
/^\/.*\/[dgimsuy]*,?\s*\/\/.*$/.test(trimmed) ||
|
|
636
|
+
/^\/.*\/[dgimsuy]*\.(?:test|exec)\(/.test(trimmed) ||
|
|
637
|
+
/^return\s+\/.*\/[dgimsuy]*\.(?:test|exec)\(/.test(trimmed) ||
|
|
638
|
+
/=\s*\/.*\/[dgimsuy]*\s*(?:[),;]|$)/.test(trimmed) ||
|
|
639
|
+
/^pattern:\s*\/.*\/[dgimsuy]*,?$/.test(trimmed) ||
|
|
640
|
+
/\(\s*\/.*\/[dgimsuy]*\.(?:test|exec)\(/.test(trimmed);
|
|
641
|
+
}
|
|
642
|
+
function isAllowedFindingPattern(finding, policy) {
|
|
643
|
+
return policy.allowedFindingPatterns.some(item => {
|
|
644
|
+
if (item.ruleId !== finding.ruleId)
|
|
645
|
+
return false;
|
|
646
|
+
if (item.path && normalizePath(item.path) !== normalizePath(finding.path))
|
|
647
|
+
return false;
|
|
648
|
+
if (item.evidencePattern && !new RegExp(item.evidencePattern).test(finding.evidence ?? ''))
|
|
649
|
+
return false;
|
|
650
|
+
if (item.messagePattern && !new RegExp(item.messagePattern).test(finding.message))
|
|
651
|
+
return false;
|
|
652
|
+
return true;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
function isBaselineFinding(finding, policy) {
|
|
656
|
+
return policy.baselineFindings.some(item => item.ruleId === finding.ruleId &&
|
|
657
|
+
normalizePath(item.path) === normalizePath(finding.path) &&
|
|
658
|
+
(item.line === undefined || item.line === finding.line));
|
|
659
|
+
}
|
|
660
|
+
function dedupeFindings(findings) {
|
|
661
|
+
const seen = new Set();
|
|
662
|
+
return findings.filter(finding => {
|
|
663
|
+
const key = `${finding.ruleId}:${finding.path}:${finding.line ?? 0}`;
|
|
664
|
+
if (seen.has(key))
|
|
665
|
+
return false;
|
|
666
|
+
seen.add(key);
|
|
667
|
+
return true;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
function normalizePath(path) {
|
|
671
|
+
return path.split(sep).join('/').replace(/^\.\//, '');
|
|
672
|
+
}
|
|
673
|
+
function escapeRegex(value) {
|
|
674
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
675
|
+
}
|
|
676
|
+
function escapeCell(value) {
|
|
677
|
+
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
678
|
+
}
|
|
679
|
+
//# sourceMappingURL=EngineeringStandards.js.map
|