phasegate 0.151.1 → 0.152.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/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/scripts/harness/biome-ast-engine/domain/value-objects/architecture-spec.ts +95 -0
- package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +45 -3
- package/scripts/harness/config-foundation/domain/services/architecture-resolution-service.ts +60 -0
- package/scripts/harness/config-foundation/domain/value-objects/architecture-config.ts +47 -0
- package/scripts/harness/config-foundation/domain/value-objects/architecture-preset-catalog.ts +75 -1
- package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +46 -0
- package/scripts/harness/validator-system/application/use-cases/run-l4-validators-usecase.ts +9 -2
- package/scripts/harness/validator-system/composition-root.ts +30 -0
- package/scripts/harness/validator-system/domain/ports/source-analysis-port.ts +2 -2
- package/scripts/harness/validator-system/domain/services/l4/architecture-semantic-analysis-service.ts +124 -0
- package/scripts/harness/validator-system/domain/services/l4/dead-code-detection-service.ts +2 -2
- package/scripts/harness/validator-system/infrastructure/adapters/ast-performance-scanner-adapter.ts +55 -3
- package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-source-code-analyzer-adapter.ts +5 -2
- package/scripts/harness/validator-system/infrastructure/adapters/file-system-architecture-semantic-source-adapter.ts +115 -0
- package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +44 -9
- package/scripts/harness/validator-system/infrastructure/adapters/import-graph-source-analysis-adapter.ts +120 -8
|
@@ -35,9 +35,11 @@ import { MarkdownDesignDocumentAdapter } from './infrastructure/adapters/markdow
|
|
|
35
35
|
import { BiomeAstSourceCodeAnalyzerAdapter } from './infrastructure/adapters/biome-ast-source-code-analyzer-adapter.js';
|
|
36
36
|
import { AdrFoundationReferenceAdapter } from './infrastructure/adapters/adr-foundation-reference-adapter.js';
|
|
37
37
|
import { ImportGraphSourceAnalysisAdapter } from './infrastructure/adapters/import-graph-source-analysis-adapter.js';
|
|
38
|
+
import { FileSystemArchitectureSemanticSourceAdapter } from './infrastructure/adapters/file-system-architecture-semantic-source-adapter.js';
|
|
38
39
|
import { DriftDetectionService } from './domain/services/l4/drift-detection-service.js';
|
|
39
40
|
import { ConsistencyCheckService } from './domain/services/l4/consistency-check-service.js';
|
|
40
41
|
import { DeadCodeDetectionService } from './domain/services/l4/dead-code-detection-service.js';
|
|
42
|
+
import { ArchitectureSemanticAnalysisService, type ArchitectureSemanticPolicy } from './domain/services/l4/architecture-semantic-analysis-service.js';
|
|
41
43
|
import { buildPhase2Extensions } from '../phase2-extensions/composition-root.js';
|
|
42
44
|
import { RunValidatorsHandler } from './presentation/handlers/run-validators-handler.js';
|
|
43
45
|
import { RunQuickModeHandler } from './presentation/handlers/run-quick-mode-handler.js';
|
|
@@ -53,6 +55,20 @@ const DEFAULT_CONFIG = {
|
|
|
53
55
|
L4: { enabled: true, validators: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005'] },
|
|
54
56
|
},
|
|
55
57
|
validate: { failOnWarning: false },
|
|
58
|
+
architecture: {
|
|
59
|
+
capabilityPolicies: {
|
|
60
|
+
domain: { allowed: [], denied: ['filesystem', 'network', 'database', 'process-env', 'subprocess', 'user-io'] },
|
|
61
|
+
application: { allowed: ['time', 'random'], denied: ['filesystem', 'network', 'database', 'subprocess'] },
|
|
62
|
+
infrastructure: { allowed: ['filesystem', 'network', 'database', 'process-env', 'time', 'random', 'subprocess', 'user-io'], denied: [] },
|
|
63
|
+
presentation: { allowed: ['user-io', 'time'], denied: ['database', 'subprocess'] },
|
|
64
|
+
},
|
|
65
|
+
decisionPolicies: {
|
|
66
|
+
domain: { expected: ['business-rule-branch', 'validation-rule', 'state-transition'], advisoryOnly: true },
|
|
67
|
+
application: { expected: ['policy-selection', 'error-construction'], advisoryOnly: true },
|
|
68
|
+
infrastructure: { expected: ['error-construction'], advisoryOnly: true },
|
|
69
|
+
presentation: { expected: ['validation-rule', 'error-construction'], advisoryOnly: true },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
56
72
|
};
|
|
57
73
|
|
|
58
74
|
/** バリデータ定義カタログ */
|
|
@@ -171,6 +187,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
|
|
|
171
187
|
const sourceCodeAnalyzerAdapter = new BiomeAstSourceCodeAnalyzerAdapter();
|
|
172
188
|
const adrReferencePort = new AdrFoundationReferenceAdapter();
|
|
173
189
|
const sourceAnalysisPort = new ImportGraphSourceAnalysisAdapter();
|
|
190
|
+
const architectureSemanticSourcePort = new FileSystemArchitectureSemanticSourceAdapter();
|
|
174
191
|
|
|
175
192
|
const driftDetectionService = new DriftDetectionService({
|
|
176
193
|
designDocumentPort: markdownDesignDocumentPort,
|
|
@@ -183,6 +200,10 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
|
|
|
183
200
|
const deadCodeDetectionService = new DeadCodeDetectionService({
|
|
184
201
|
sourceAnalysisPort,
|
|
185
202
|
});
|
|
203
|
+
const architectureSemanticAnalysisService = new ArchitectureSemanticAnalysisService({
|
|
204
|
+
sourcePort: architectureSemanticSourcePort,
|
|
205
|
+
policy: toArchitectureSemanticPolicy(configData),
|
|
206
|
+
});
|
|
186
207
|
const phase2Extensions = buildPhase2Extensions(process.cwd(), configData as never);
|
|
187
208
|
|
|
188
209
|
const runL4ValidatorsUseCase = new RunL4ValidatorsUseCase({
|
|
@@ -193,6 +214,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
|
|
|
193
214
|
driftDetectionService,
|
|
194
215
|
consistencyCheckService,
|
|
195
216
|
deadCodeDetectionService,
|
|
217
|
+
architectureSemanticAnalysisService,
|
|
196
218
|
checkDocFreshnessUseCase: phase2Extensions.checkDocFreshnessUseCase,
|
|
197
219
|
validateDocPointersUseCase: phase2Extensions.validateDocPointersUseCase,
|
|
198
220
|
});
|
|
@@ -247,3 +269,11 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
|
|
|
247
269
|
handlers,
|
|
248
270
|
};
|
|
249
271
|
}
|
|
272
|
+
|
|
273
|
+
function toArchitectureSemanticPolicy(configData: typeof DEFAULT_CONFIG): ArchitectureSemanticPolicy {
|
|
274
|
+
const architecture = configData.architecture;
|
|
275
|
+
return {
|
|
276
|
+
capabilityPolicies: architecture?.capabilityPolicies ?? DEFAULT_CONFIG.architecture.capabilityPolicies,
|
|
277
|
+
decisionPolicies: architecture?.decisionPolicies ?? DEFAULT_CONFIG.architecture.decisionPolicies,
|
|
278
|
+
} as ArchitectureSemanticPolicy;
|
|
279
|
+
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// @layer domain
|
|
3
3
|
|
|
4
4
|
export interface ImportGraphData {
|
|
5
|
-
readonly nodes: readonly { filePath: string; exports: readonly string[] }[];
|
|
6
|
-
readonly edges: readonly { from: string; to: string; importedNames: readonly string[] }[];
|
|
5
|
+
readonly nodes: readonly { filePath: string; exports: readonly string[]; excludedReason?: string }[];
|
|
6
|
+
readonly edges: readonly { from: string; to: string; importedNames: readonly string[]; kind?: string }[];
|
|
7
7
|
readonly unusedExports?: readonly string[];
|
|
8
8
|
readonly unreachableCode?: readonly { filePath: string; range: { startLine: number; endLine: number } }[];
|
|
9
9
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer domain
|
|
3
|
+
* @unit validator-system
|
|
4
|
+
* @work-item-id WI-134, WI-135
|
|
5
|
+
*
|
|
6
|
+
* ArchitectureSemanticAnalysisService
|
|
7
|
+
* Architecture preset の capability / decision policy と source semantic signal を照合する advisory service.
|
|
8
|
+
*/
|
|
9
|
+
import type { HarnessErrorLike } from '../../value-objects/validation-result.js';
|
|
10
|
+
|
|
11
|
+
export type EffectCapability =
|
|
12
|
+
| 'filesystem'
|
|
13
|
+
| 'network'
|
|
14
|
+
| 'database'
|
|
15
|
+
| 'process-env'
|
|
16
|
+
| 'time'
|
|
17
|
+
| 'random'
|
|
18
|
+
| 'subprocess'
|
|
19
|
+
| 'user-io';
|
|
20
|
+
|
|
21
|
+
export type DecisionSignal =
|
|
22
|
+
| 'business-rule-branch'
|
|
23
|
+
| 'validation-rule'
|
|
24
|
+
| 'error-construction'
|
|
25
|
+
| 'state-transition'
|
|
26
|
+
| 'policy-selection';
|
|
27
|
+
|
|
28
|
+
export interface ArchitectureCapabilityPolicy {
|
|
29
|
+
readonly allowed: readonly EffectCapability[];
|
|
30
|
+
readonly denied: readonly EffectCapability[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ArchitectureDecisionPolicy {
|
|
34
|
+
readonly expected: readonly DecisionSignal[];
|
|
35
|
+
readonly advisoryOnly: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ArchitectureSemanticPolicy {
|
|
39
|
+
readonly capabilityPolicies: Readonly<Record<string, ArchitectureCapabilityPolicy>>;
|
|
40
|
+
readonly decisionPolicies: Readonly<Record<string, ArchitectureDecisionPolicy>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SourceSemanticSignal {
|
|
44
|
+
readonly kind: EffectCapability | DecisionSignal;
|
|
45
|
+
readonly evidence: string;
|
|
46
|
+
readonly confidence: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SourceSemanticFile {
|
|
50
|
+
readonly filePath: string;
|
|
51
|
+
readonly zone: string;
|
|
52
|
+
readonly effects: readonly SourceSemanticSignal[];
|
|
53
|
+
readonly decisions: readonly SourceSemanticSignal[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ArchitectureSemanticSourcePort {
|
|
57
|
+
collectSourceSemantics(): Promise<readonly SourceSemanticFile[]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ArchitectureSemanticAnalysisServiceDeps {
|
|
61
|
+
readonly sourcePort: ArchitectureSemanticSourcePort;
|
|
62
|
+
readonly policy: ArchitectureSemanticPolicy;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_DECISION_OWNER: Readonly<Record<DecisionSignal, string>> = Object.freeze({
|
|
66
|
+
'business-rule-branch': 'domain',
|
|
67
|
+
'validation-rule': 'domain',
|
|
68
|
+
'error-construction': 'application',
|
|
69
|
+
'state-transition': 'domain',
|
|
70
|
+
'policy-selection': 'application',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export class ArchitectureSemanticAnalysisService {
|
|
74
|
+
private readonly sourcePort: ArchitectureSemanticSourcePort;
|
|
75
|
+
private readonly policy: ArchitectureSemanticPolicy;
|
|
76
|
+
|
|
77
|
+
constructor(deps: ArchitectureSemanticAnalysisServiceDeps) {
|
|
78
|
+
this.sourcePort = deps.sourcePort;
|
|
79
|
+
this.policy = deps.policy;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async analyze(): Promise<readonly HarnessErrorLike[]> {
|
|
83
|
+
const files = await this.sourcePort.collectSourceSemantics();
|
|
84
|
+
return files.flatMap((file) => [
|
|
85
|
+
...this.toCapabilityFindings(file),
|
|
86
|
+
...this.toDecisionFindings(file),
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private toCapabilityFindings(file: SourceSemanticFile): HarnessErrorLike[] {
|
|
91
|
+
const zonePolicy = this.policy.capabilityPolicies[file.zone];
|
|
92
|
+
if (!zonePolicy) return [];
|
|
93
|
+
const denied = new Set(zonePolicy.denied);
|
|
94
|
+
|
|
95
|
+
return file.effects
|
|
96
|
+
.filter((effect) => denied.has(effect.kind as EffectCapability))
|
|
97
|
+
.map((effect) => this.toHarnessError(
|
|
98
|
+
`Side effect capability denied: ${effect.kind} in ${file.zone} at ${file.filePath}`,
|
|
99
|
+
`confidence=${effect.confidence}; evidence=${effect.evidence}; suggested owner zone=infrastructure/adapters`,
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private toDecisionFindings(file: SourceSemanticFile): HarnessErrorLike[] {
|
|
104
|
+
const zonePolicy = this.policy.decisionPolicies[file.zone];
|
|
105
|
+
if (!zonePolicy) return [];
|
|
106
|
+
const expected = new Set(zonePolicy.expected);
|
|
107
|
+
|
|
108
|
+
return file.decisions
|
|
109
|
+
.filter((decision) => !expected.has(decision.kind as DecisionSignal))
|
|
110
|
+
.map((decision) => this.toHarnessError(
|
|
111
|
+
`Decision placement advisory: ${decision.kind} observed in ${file.zone} at ${file.filePath}`,
|
|
112
|
+
`confidence=${decision.confidence}; evidence=${decision.evidence}; suggested owner zone=${DEFAULT_DECISION_OWNER[decision.kind as DecisionSignal] ?? 'domain'}; rollout=advisory`,
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private toHarnessError(message: string, suggestion: string): HarnessErrorLike {
|
|
117
|
+
return {
|
|
118
|
+
code: { value: 'L4-002', toString: () => 'L4-002' },
|
|
119
|
+
severity: { value: 'warning', toString: () => 'warning' },
|
|
120
|
+
message,
|
|
121
|
+
suggestion,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -9,8 +9,8 @@ import { DeadCodeReport } from '../../value-objects/dead-code-report.js';
|
|
|
9
9
|
|
|
10
10
|
export interface DeadCodeSourceAnalysisPort {
|
|
11
11
|
getImportGraph(): Promise<{
|
|
12
|
-
nodes?: readonly { filePath: string; exports: readonly string[] }[];
|
|
13
|
-
edges?: readonly { from: string; to: string; importedNames: readonly string[] }[];
|
|
12
|
+
nodes?: readonly { filePath: string; exports: readonly string[]; excludedReason?: string }[];
|
|
13
|
+
edges?: readonly { from: string; to: string; importedNames: readonly string[]; kind?: string }[];
|
|
14
14
|
unusedExports?: readonly string[];
|
|
15
15
|
unreachableCode?: readonly { filePath: string; range: { startLine: number; endLine: number } }[];
|
|
16
16
|
}>;
|
package/scripts/harness/validator-system/infrastructure/adapters/ast-performance-scanner-adapter.ts
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
import * as ts from 'typescript';
|
|
9
9
|
import type { PerformanceScannerPort } from '../../domain/ports/performance-scanner-port.js';
|
|
10
10
|
import type { HarnessErrorLike } from '../../domain/value-objects/validation-result.js';
|
|
11
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
11
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
|
|
14
|
+
const SUPPRESSION_MARKER = 'phasegate-ignore-performance';
|
|
15
|
+
|
|
14
16
|
export class AstPerformanceScannerAdapter implements PerformanceScannerPort {
|
|
15
17
|
async scan(targetPaths: readonly string[], thresholds: Record<string, number>): Promise<{
|
|
16
18
|
passed: boolean;
|
|
@@ -33,12 +35,15 @@ export class AstPerformanceScannerAdapter implements PerformanceScannerPort {
|
|
|
33
35
|
|
|
34
36
|
for (const filePath of filePaths) {
|
|
35
37
|
try {
|
|
36
|
-
const [fileStat] = await Promise.all([stat(filePath)]);
|
|
38
|
+
const [fileStat, content] = await Promise.all([stat(filePath), readFile(filePath, 'utf8')]);
|
|
39
|
+
if (content.includes(SUPPRESSION_MARKER)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
37
42
|
|
|
38
43
|
// Bundle size check (file-level size)
|
|
39
44
|
const bundleSizeLimit = thresholds.bundleSizeLimit;
|
|
40
45
|
if (typeof bundleSizeLimit === 'number' && fileStat.size > bundleSizeLimit) {
|
|
41
|
-
findings.push(createFinding('L3-002', `bundleSizeLimit を超過しました: ${filePath}
|
|
46
|
+
findings.push(createFinding('L3-002', `bundleSizeLimit を超過しました: ${filePath} metric=file-size actual=${fileStat.size} threshold=${bundleSizeLimit}`));
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
// AST-based await-in-loop detection
|
|
@@ -46,6 +51,13 @@ export class AstPerformanceScannerAdapter implements PerformanceScannerPort {
|
|
|
46
51
|
if (sourceFile && hasAwaitInLoop(sourceFile)) {
|
|
47
52
|
findings.push(createFinding('L3-002', `ループ内 await を検出しました: ${filePath}`));
|
|
48
53
|
}
|
|
54
|
+
if (sourceFile && hasSyncIoCall(sourceFile)) {
|
|
55
|
+
findings.push(createFinding('L3-002', `同期I/O呼び出しを検出しました: ${filePath} metric=sync-io threshold=0`));
|
|
56
|
+
}
|
|
57
|
+
const largeLiteralThreshold = thresholds.largeLiteralEntries ?? 80;
|
|
58
|
+
if (sourceFile && hasLargeLiteral(sourceFile, largeLiteralThreshold)) {
|
|
59
|
+
findings.push(createFinding('L3-002', `largeLiteralEntries を超過しました: ${filePath} metric=literal-entries threshold=${largeLiteralThreshold}`));
|
|
60
|
+
}
|
|
49
61
|
} catch {
|
|
50
62
|
continue;
|
|
51
63
|
}
|
|
@@ -56,6 +68,46 @@ export class AstPerformanceScannerAdapter implements PerformanceScannerPort {
|
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
function hasSyncIoCall(root: ts.Node): boolean {
|
|
72
|
+
let found = false;
|
|
73
|
+
|
|
74
|
+
function visit(node: ts.Node): void {
|
|
75
|
+
if (found) return;
|
|
76
|
+
if (ts.isCallExpression(node)) {
|
|
77
|
+
const expressionText = node.expression.getText(root.getSourceFile());
|
|
78
|
+
if (/\b(?:readFileSync|writeFileSync|appendFileSync|readdirSync|statSync|existsSync|execFileSync|execSync|spawnSync)\b/.test(expressionText)) {
|
|
79
|
+
found = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ts.forEachChild(node, visit);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
visit(root);
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasLargeLiteral(root: ts.Node, threshold: number): boolean {
|
|
91
|
+
let found = false;
|
|
92
|
+
|
|
93
|
+
function visit(node: ts.Node): void {
|
|
94
|
+
if (found) return;
|
|
95
|
+
const literalSize = ts.isObjectLiteralExpression(node)
|
|
96
|
+
? node.properties.length
|
|
97
|
+
: ts.isArrayLiteralExpression(node)
|
|
98
|
+
? node.elements.length
|
|
99
|
+
: 0;
|
|
100
|
+
if (literalSize > threshold) {
|
|
101
|
+
found = true;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ts.forEachChild(node, visit);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
visit(root);
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
|
|
59
111
|
/**
|
|
60
112
|
* ループノード(for/while/do/for-in/for-of)の直下に await が存在するか検出する。
|
|
61
113
|
* ネストされた関数・アロー関数境界は越えない。
|
|
@@ -131,8 +131,11 @@ function extractExports(sourceFile: ts.SourceFile): SourceAnalysisResult['export
|
|
|
131
131
|
exports.push({ name: element.name.text, type: 'type' });
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
} else if (ts.isExportDeclaration(node) && !node.exportClause
|
|
135
|
-
|
|
134
|
+
} else if (ts.isExportDeclaration(node) && !node.exportClause) {
|
|
135
|
+
const moduleSpecifier = node.moduleSpecifier;
|
|
136
|
+
if (moduleSpecifier !== undefined && ts.isStringLiteral(moduleSpecifier)) {
|
|
137
|
+
exports.push({ name: `* from ${moduleSpecifier.text}`, type: 'type' });
|
|
138
|
+
}
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
const hasDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit validator-system
|
|
4
|
+
* @work-item-id WI-134, WI-135
|
|
5
|
+
*
|
|
6
|
+
* FileSystemArchitectureSemanticSourceAdapter
|
|
7
|
+
* TypeScript source から side-effect capability / decision placement signal を軽量抽出する。
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type {
|
|
12
|
+
ArchitectureSemanticSourcePort,
|
|
13
|
+
DecisionSignal,
|
|
14
|
+
EffectCapability,
|
|
15
|
+
SourceSemanticFile,
|
|
16
|
+
SourceSemanticSignal,
|
|
17
|
+
} from '../../domain/services/l4/architecture-semantic-analysis-service.js';
|
|
18
|
+
|
|
19
|
+
export class FileSystemArchitectureSemanticSourceAdapter implements ArchitectureSemanticSourcePort {
|
|
20
|
+
private readonly sourceRoot: string;
|
|
21
|
+
|
|
22
|
+
constructor(sourceRoot: string = join(process.cwd(), 'scripts/harness')) {
|
|
23
|
+
this.sourceRoot = sourceRoot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async collectSourceSemantics(): Promise<readonly SourceSemanticFile[]> {
|
|
27
|
+
const filePaths = await walkTsFiles(this.sourceRoot);
|
|
28
|
+
const files: SourceSemanticFile[] = [];
|
|
29
|
+
|
|
30
|
+
for (const filePath of filePaths) {
|
|
31
|
+
if (isExcluded(filePath)) continue;
|
|
32
|
+
try {
|
|
33
|
+
const content = await readFile(filePath, 'utf8');
|
|
34
|
+
files.push({
|
|
35
|
+
filePath,
|
|
36
|
+
zone: detectZone(filePath, content),
|
|
37
|
+
effects: detectEffects(content),
|
|
38
|
+
decisions: detectDecisions(content),
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function walkTsFiles(root: string): Promise<string[]> {
|
|
50
|
+
try {
|
|
51
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
52
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
53
|
+
const fullPath = join(root, entry.name);
|
|
54
|
+
if (entry.isDirectory()) return walkTsFiles(fullPath);
|
|
55
|
+
return fullPath.endsWith('.ts') ? [fullPath] : [];
|
|
56
|
+
}));
|
|
57
|
+
return files.flat();
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isExcluded(filePath: string): boolean {
|
|
64
|
+
const normalized = filePath.replaceAll('\\', '/');
|
|
65
|
+
return /(^|\/)__tests__\//.test(normalized) || /\.test\.ts$/.test(normalized) || /(^|\/)fixtures?\//.test(normalized);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function detectZone(filePath: string, content: string): string {
|
|
69
|
+
const tagMatch = content.match(/@layer\s+([a-z][a-z0-9-]*)/);
|
|
70
|
+
if (tagMatch?.[1]) return tagMatch[1];
|
|
71
|
+
|
|
72
|
+
const normalized = filePath.replaceAll('\\', '/');
|
|
73
|
+
for (const zone of ['domain', 'application', 'infrastructure', 'presentation', 'controller', 'service', 'repository', 'core', 'ports', 'adapters']) {
|
|
74
|
+
if (normalized.includes(`/${zone}/`)) return zone;
|
|
75
|
+
}
|
|
76
|
+
return 'application';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function detectEffects(content: string): SourceSemanticSignal[] {
|
|
80
|
+
const checks: Array<{ kind: EffectCapability; pattern: RegExp; evidence: string }> = [
|
|
81
|
+
{ kind: 'filesystem', pattern: /\bnode:fs\b|\bfrom\s+['"]fs['"]|\b(?:readFileSync|writeFileSync|readdirSync|statSync)\b/, evidence: 'filesystem API reference' },
|
|
82
|
+
{ kind: 'network', pattern: /\bfetch\s*\(|\bnode:https?\b|\bfrom\s+['"]https?['"]/, evidence: 'network API reference' },
|
|
83
|
+
{ kind: 'database', pattern: /\b(?:prisma|sequelize|knex|sql`|\.query\s*\()/i, evidence: 'database API reference' },
|
|
84
|
+
{ kind: 'process-env', pattern: /\bprocess\.env\b/, evidence: 'process.env reference' },
|
|
85
|
+
{ kind: 'time', pattern: /\bDate\.now\s*\(|\bnew\s+Date\s*\(/, evidence: 'time source reference' },
|
|
86
|
+
{ kind: 'random', pattern: /\bMath\.random\s*\(|\brandomUUID\s*\(/, evidence: 'random source reference' },
|
|
87
|
+
{ kind: 'subprocess', pattern: /\bnode:child_process\b|\b(?:execSync|execFileSync|spawnSync)\b/, evidence: 'subprocess API reference' },
|
|
88
|
+
{ kind: 'user-io', pattern: /\bnode:readline\b|\bprompt\s*\(/, evidence: 'user I/O reference' },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
return checks
|
|
92
|
+
.filter((check) => check.pattern.test(content))
|
|
93
|
+
.map((check) => ({ kind: check.kind, evidence: check.evidence, confidence: 0.9 }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function detectDecisions(content: string): SourceSemanticSignal[] {
|
|
97
|
+
const signals: SourceSemanticSignal[] = [];
|
|
98
|
+
const branchCount = (content.match(/\bif\s*\(|\bswitch\s*\(/g) ?? []).length;
|
|
99
|
+
if (branchCount >= 3) {
|
|
100
|
+
signals.push({ kind: 'business-rule-branch', evidence: `branch-count=${branchCount}`, confidence: 0.72 });
|
|
101
|
+
}
|
|
102
|
+
if (/\b(?:validate|isValid|required|invalid)\b/i.test(content) && /\bif\s*\(/.test(content)) {
|
|
103
|
+
signals.push({ kind: 'validation-rule', evidence: 'validation keyword with branch', confidence: 0.7 });
|
|
104
|
+
}
|
|
105
|
+
if (/\b(?:new\s+Error|HarnessError|throw\s+new)\b/.test(content)) {
|
|
106
|
+
signals.push({ kind: 'error-construction', evidence: 'error construction expression', confidence: 0.86 });
|
|
107
|
+
}
|
|
108
|
+
if (/\b(?:state|status)\s*=|transition[A-Z]\w*\s*\(/.test(content)) {
|
|
109
|
+
signals.push({ kind: 'state-transition', evidence: 'state/status transition expression', confidence: 0.78 });
|
|
110
|
+
}
|
|
111
|
+
if (/\bswitch\s*\(|\bselect[A-Z]\w*\s*\(/.test(content)) {
|
|
112
|
+
signals.push({ kind: 'policy-selection', evidence: 'policy selection branch', confidence: 0.68 });
|
|
113
|
+
}
|
|
114
|
+
return signals;
|
|
115
|
+
}
|
|
@@ -8,11 +8,26 @@ import type { SecurityPatternScannerPort } from '../../domain/ports/security-pat
|
|
|
8
8
|
import type { HarnessErrorLike } from '../../domain/value-objects/validation-result.js';
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
const ALLOWLIST_MARKER = 'phasegate-allow-secret-fixture';
|
|
12
|
+
|
|
13
|
+
interface SecurityPattern {
|
|
14
|
+
readonly ruleId: string;
|
|
15
|
+
readonly pattern: RegExp;
|
|
16
|
+
readonly description: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SECURITY_PATTERNS: readonly SecurityPattern[] = Object.freeze([
|
|
20
|
+
{ ruleId: 'secret.openai', pattern: /\b(?:sk|rk|sess)-[a-zA-Z0-9_-]{20,}\b/g, description: 'OpenAI token family' },
|
|
21
|
+
{ ruleId: 'secret.github', pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, description: 'GitHub token family' },
|
|
22
|
+
{ ruleId: 'secret.aws-access-key', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, description: 'AWS access key id' },
|
|
23
|
+
{ ruleId: 'secret.npm', pattern: /\bnpm_[A-Za-z0-9]{24,}\b/g, description: 'npm token family' },
|
|
24
|
+
{ ruleId: 'secret.slack', pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}\b/g, description: 'Slack token family' },
|
|
25
|
+
{
|
|
26
|
+
ruleId: 'secret.keyword-context',
|
|
27
|
+
pattern: /\b(?:API_KEY|api_key|apikey|password|PASSWORD|passwd|secret|token)\b\s*[:=]\s*["'][^"']{8,}["']/g,
|
|
28
|
+
description: 'keyword-context secret',
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
16
31
|
|
|
17
32
|
export class FileSystemSecurityPatternScannerAdapter implements SecurityPatternScannerPort {
|
|
18
33
|
async scan(targetPaths: readonly string[]): Promise<{
|
|
@@ -24,15 +39,24 @@ export class FileSystemSecurityPatternScannerAdapter implements SecurityPatternS
|
|
|
24
39
|
for (const filePath of targetPaths) {
|
|
25
40
|
try {
|
|
26
41
|
const content = await readFile(filePath, 'utf-8');
|
|
42
|
+
if (content.includes(ALLOWLIST_MARKER)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
27
45
|
const lines = content.split('\n');
|
|
28
46
|
lines.forEach((line, idx) => {
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
if (isAllowlisted(line, content)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const { pattern, description, ruleId } of SECURITY_PATTERNS) {
|
|
51
|
+
pattern.lastIndex = 0;
|
|
52
|
+
const matches = [...line.matchAll(pattern)];
|
|
53
|
+
for (const match of matches) {
|
|
54
|
+
const secretValue = match[0] ?? '';
|
|
31
55
|
findings.push({
|
|
32
56
|
code: { value: 'L3-001', toString: () => 'L3-001' },
|
|
33
57
|
severity: { value: 'error', toString: () => 'error' },
|
|
34
|
-
message: `セキュリティ問題: ${description} at ${filePath}:${idx + 1}`,
|
|
35
|
-
suggestion:
|
|
58
|
+
message: `セキュリティ問題: ${description} (${ruleId}) at ${filePath}:${idx + 1} value=${redactSecret(secretValue)}`,
|
|
59
|
+
suggestion: `${ruleId}: 秘密情報は環境変数または秘密管理サービスを使用してください。fixture/docs のダミー値は ${ALLOWLIST_MARKER} を明示してください。`,
|
|
36
60
|
});
|
|
37
61
|
}
|
|
38
62
|
}
|
|
@@ -45,3 +69,14 @@ export class FileSystemSecurityPatternScannerAdapter implements SecurityPatternS
|
|
|
45
69
|
return { passed: findings.length === 0, findings };
|
|
46
70
|
}
|
|
47
71
|
}
|
|
72
|
+
|
|
73
|
+
function isAllowlisted(line: string, content: string): boolean {
|
|
74
|
+
if (line.includes(ALLOWLIST_MARKER)) return true;
|
|
75
|
+
return /@example|dummy|placeholder/i.test(line) && content.includes(ALLOWLIST_MARKER);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function redactSecret(secretValue: string): string {
|
|
79
|
+
const value = secretValue.trim();
|
|
80
|
+
if (value.length <= 8) return '<redacted>';
|
|
81
|
+
return `${value.slice(0, 3)}...<redacted:${value.length}>`;
|
|
82
|
+
}
|