phasegate 0.151.1 → 0.152.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 +10 -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 +1 -1
- 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/domain/ports/source-analysis-port.ts +2 -2
- 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-security-pattern-scanner-adapter.ts +44 -9
- package/scripts/harness/validator-system/infrastructure/adapters/import-graph-source-analysis-adapter.ts +120 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.152.0] - 2026-05-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **G5 / WI-119 / WI-120 / WI-121 / WI-134 / WI-135 — architecture semantic analysis** — strengthens code semantic analysis across L3/L4 validators and architecture presets.
|
|
15
|
+
- L4-003 dead-code detection now builds a real import/export/re-export/dynamic-import graph and reports unused export candidates with reviewable reasons while preserving public/test/generated boundaries.
|
|
16
|
+
- L3-001 security scanning detects representative OpenAI, GitHub, AWS, npm, Slack, and keyword-context token families, supports explicit fixture allowlisting, and redacts secret values from findings.
|
|
17
|
+
- L3-002 performance scanning defines practical static scope with file-size thresholds, await-in-loop, synchronous I/O, large literal checks, and inline suppression for accepted migration/batch cases.
|
|
18
|
+
- Architecture presets now expose side-effect capability policies and advisory decision-placement responsibilities separately from dependency-direction checks.
|
|
19
|
+
|
|
10
20
|
## [0.151.1] - 2026-05-12
|
|
11
21
|
|
|
12
22
|
### Fixed
|
package/package.json
CHANGED
|
@@ -7,6 +7,8 @@ export type ArchitectureSpec = {
|
|
|
7
7
|
readonly layers: readonly string[];
|
|
8
8
|
readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
|
|
9
9
|
readonly metadataTags: ArchitectureMetadataTags;
|
|
10
|
+
readonly capabilityPolicies: Readonly<Record<string, ArchitectureCapabilityPolicy>>;
|
|
11
|
+
readonly decisionPolicies: Readonly<Record<string, ArchitectureDecisionPolicy>>;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export type ArchitectureMetadataTags = {
|
|
@@ -14,10 +16,39 @@ export type ArchitectureMetadataTags = {
|
|
|
14
16
|
readonly layer: string;
|
|
15
17
|
};
|
|
16
18
|
|
|
19
|
+
export type EffectCapability =
|
|
20
|
+
| 'filesystem'
|
|
21
|
+
| 'network'
|
|
22
|
+
| 'database'
|
|
23
|
+
| 'process-env'
|
|
24
|
+
| 'time'
|
|
25
|
+
| 'random'
|
|
26
|
+
| 'subprocess'
|
|
27
|
+
| 'user-io';
|
|
28
|
+
|
|
29
|
+
export type DecisionSignal =
|
|
30
|
+
| 'business-rule-branch'
|
|
31
|
+
| 'validation-rule'
|
|
32
|
+
| 'error-construction'
|
|
33
|
+
| 'state-transition'
|
|
34
|
+
| 'policy-selection';
|
|
35
|
+
|
|
36
|
+
export type ArchitectureCapabilityPolicy = {
|
|
37
|
+
readonly allowed: readonly EffectCapability[];
|
|
38
|
+
readonly denied: readonly EffectCapability[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ArchitectureDecisionPolicy = {
|
|
42
|
+
readonly expected: readonly DecisionSignal[];
|
|
43
|
+
readonly advisoryOnly: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
17
46
|
export type ArchitectureSpecInput = {
|
|
18
47
|
readonly layers: readonly string[];
|
|
19
48
|
readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
|
|
20
49
|
readonly metadataTags?: Partial<ArchitectureMetadataTags>;
|
|
50
|
+
readonly capabilityPolicies?: Readonly<Record<string, ArchitectureCapabilityPolicy>>;
|
|
51
|
+
readonly decisionPolicies?: Readonly<Record<string, ArchitectureDecisionPolicy>>;
|
|
21
52
|
};
|
|
22
53
|
|
|
23
54
|
export const DEFAULT_METADATA_TAGS: ArchitectureMetadataTags = Object.freeze({
|
|
@@ -37,6 +68,32 @@ const freezeDependencyMap = (
|
|
|
37
68
|
return Object.freeze(frozen);
|
|
38
69
|
};
|
|
39
70
|
|
|
71
|
+
const freezeCapabilityPolicies = (
|
|
72
|
+
policies: Readonly<Record<string, ArchitectureCapabilityPolicy>> = {},
|
|
73
|
+
): Readonly<Record<string, ArchitectureCapabilityPolicy>> => {
|
|
74
|
+
const frozen: Record<string, ArchitectureCapabilityPolicy> = {};
|
|
75
|
+
for (const [zone, policy] of Object.entries(policies)) {
|
|
76
|
+
frozen[zone] = Object.freeze({
|
|
77
|
+
allowed: Object.freeze([...policy.allowed]),
|
|
78
|
+
denied: Object.freeze([...policy.denied]),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return Object.freeze(frozen);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const freezeDecisionPolicies = (
|
|
85
|
+
policies: Readonly<Record<string, ArchitectureDecisionPolicy>> = {},
|
|
86
|
+
): Readonly<Record<string, ArchitectureDecisionPolicy>> => {
|
|
87
|
+
const frozen: Record<string, ArchitectureDecisionPolicy> = {};
|
|
88
|
+
for (const [zone, policy] of Object.entries(policies)) {
|
|
89
|
+
frozen[zone] = Object.freeze({
|
|
90
|
+
expected: Object.freeze([...policy.expected]),
|
|
91
|
+
advisoryOnly: policy.advisoryOnly,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(frozen);
|
|
95
|
+
};
|
|
96
|
+
|
|
40
97
|
export const freezeArchitectureSpec = (spec: ArchitectureSpecInput): ArchitectureSpec => {
|
|
41
98
|
return Object.freeze({
|
|
42
99
|
layers: Object.freeze([...spec.layers]),
|
|
@@ -45,6 +102,8 @@ export const freezeArchitectureSpec = (spec: ArchitectureSpecInput): Architectur
|
|
|
45
102
|
...DEFAULT_METADATA_TAGS,
|
|
46
103
|
...spec.metadataTags,
|
|
47
104
|
}),
|
|
105
|
+
capabilityPolicies: freezeCapabilityPolicies(spec.capabilityPolicies),
|
|
106
|
+
decisionPolicies: freezeDecisionPolicies(spec.decisionPolicies),
|
|
48
107
|
});
|
|
49
108
|
};
|
|
50
109
|
|
|
@@ -56,4 +115,40 @@ export const CLEAN_PRESET_SPEC: ArchitectureSpec = freezeArchitectureSpec({
|
|
|
56
115
|
infrastructure: ['infrastructure', 'application', 'domain'],
|
|
57
116
|
presentation: ['presentation', 'application', 'domain'],
|
|
58
117
|
},
|
|
118
|
+
capabilityPolicies: {
|
|
119
|
+
domain: {
|
|
120
|
+
allowed: [],
|
|
121
|
+
denied: ['filesystem', 'network', 'database', 'process-env', 'subprocess', 'user-io'],
|
|
122
|
+
},
|
|
123
|
+
application: {
|
|
124
|
+
allowed: ['time', 'random'],
|
|
125
|
+
denied: ['filesystem', 'network', 'database', 'subprocess'],
|
|
126
|
+
},
|
|
127
|
+
infrastructure: {
|
|
128
|
+
allowed: ['filesystem', 'network', 'database', 'process-env', 'time', 'random', 'subprocess', 'user-io'],
|
|
129
|
+
denied: [],
|
|
130
|
+
},
|
|
131
|
+
presentation: {
|
|
132
|
+
allowed: ['user-io', 'time'],
|
|
133
|
+
denied: ['database', 'subprocess'],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
decisionPolicies: {
|
|
137
|
+
domain: {
|
|
138
|
+
expected: ['business-rule-branch', 'validation-rule', 'state-transition'],
|
|
139
|
+
advisoryOnly: true,
|
|
140
|
+
},
|
|
141
|
+
application: {
|
|
142
|
+
expected: ['policy-selection', 'error-construction'],
|
|
143
|
+
advisoryOnly: true,
|
|
144
|
+
},
|
|
145
|
+
infrastructure: {
|
|
146
|
+
expected: ['error-construction'],
|
|
147
|
+
advisoryOnly: true,
|
|
148
|
+
},
|
|
149
|
+
presentation: {
|
|
150
|
+
expected: ['validation-rule', 'error-construction'],
|
|
151
|
+
advisoryOnly: true,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
59
154
|
});
|
package/scripts/harness/config-foundation/domain/services/architecture-resolution-service.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
freezeArchitectureDocument,
|
|
9
9
|
type ArchitectureConfigDocument,
|
|
10
10
|
type ArchitectureConfigSource,
|
|
11
|
+
type CapabilityPolicy,
|
|
12
|
+
type DecisionPolicy,
|
|
11
13
|
type ArchitectureLayerDetection,
|
|
12
14
|
type ArchitectureMetadataTags,
|
|
13
15
|
type ArchitecturePresetId,
|
|
@@ -48,6 +50,32 @@ const cloneDependencies = (
|
|
|
48
50
|
return cloned;
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
const cloneCapabilityPolicies = (
|
|
54
|
+
source: Readonly<Record<string, CapabilityPolicy>>
|
|
55
|
+
): Record<string, CapabilityPolicy> => {
|
|
56
|
+
const cloned: Record<string, CapabilityPolicy> = {};
|
|
57
|
+
for (const [key, value] of Object.entries(source)) {
|
|
58
|
+
cloned[key] = {
|
|
59
|
+
allowed: [...value.allowed],
|
|
60
|
+
denied: [...value.denied],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return cloned;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const cloneDecisionPolicies = (
|
|
67
|
+
source: Readonly<Record<string, DecisionPolicy>>
|
|
68
|
+
): Record<string, DecisionPolicy> => {
|
|
69
|
+
const cloned: Record<string, DecisionPolicy> = {};
|
|
70
|
+
for (const [key, value] of Object.entries(source)) {
|
|
71
|
+
cloned[key] = {
|
|
72
|
+
expected: [...value.expected],
|
|
73
|
+
advisoryOnly: value.advisoryOnly,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return cloned;
|
|
77
|
+
};
|
|
78
|
+
|
|
51
79
|
const lookupPresetDefinition = (
|
|
52
80
|
preset: ArchitecturePresetId
|
|
53
81
|
): ArchitecturePresetDefinition | null => {
|
|
@@ -92,6 +120,28 @@ const mergeDependencies = (
|
|
|
92
120
|
return preset !== null ? cloneDependencies(preset.allowedDependencies) : {};
|
|
93
121
|
};
|
|
94
122
|
|
|
123
|
+
const mergeCapabilityPolicies = (
|
|
124
|
+
preset: ArchitecturePresetDefinition | null,
|
|
125
|
+
override: Readonly<Record<string, CapabilityPolicy>> | undefined
|
|
126
|
+
): Record<string, CapabilityPolicy> => {
|
|
127
|
+
if (override !== undefined) {
|
|
128
|
+
return cloneCapabilityPolicies(override);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return preset !== null ? cloneCapabilityPolicies(preset.capabilityPolicies) : {};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const mergeDecisionPolicies = (
|
|
135
|
+
preset: ArchitecturePresetDefinition | null,
|
|
136
|
+
override: Readonly<Record<string, DecisionPolicy>> | undefined
|
|
137
|
+
): Record<string, DecisionPolicy> => {
|
|
138
|
+
if (override !== undefined) {
|
|
139
|
+
return cloneDecisionPolicies(override);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return preset !== null ? cloneDecisionPolicies(preset.decisionPolicies) : {};
|
|
143
|
+
};
|
|
144
|
+
|
|
95
145
|
const mergeMetadataTags = (
|
|
96
146
|
override: Partial<ArchitectureMetadataTags> | undefined
|
|
97
147
|
): ArchitectureMetadataTags => {
|
|
@@ -230,6 +280,14 @@ export class ArchitectureResolutionService {
|
|
|
230
280
|
presetDefinition,
|
|
231
281
|
effectiveSource.allowedDependencies
|
|
232
282
|
);
|
|
283
|
+
const capabilityPolicies = mergeCapabilityPolicies(
|
|
284
|
+
presetDefinition,
|
|
285
|
+
effectiveSource.capabilityPolicies,
|
|
286
|
+
);
|
|
287
|
+
const decisionPolicies = mergeDecisionPolicies(
|
|
288
|
+
presetDefinition,
|
|
289
|
+
effectiveSource.decisionPolicies,
|
|
290
|
+
);
|
|
233
291
|
|
|
234
292
|
validateDependencyKeys(layers, allowedDependencies);
|
|
235
293
|
validateDependencyValues(layers, allowedDependencies);
|
|
@@ -247,6 +305,8 @@ export class ArchitectureResolutionService {
|
|
|
247
305
|
allowedDependencies: freezeDependencies(allowedDependencies),
|
|
248
306
|
metadataTags: mergeMetadataTags(effectiveSource.metadataTags),
|
|
249
307
|
layerDetection,
|
|
308
|
+
capabilityPolicies,
|
|
309
|
+
decisionPolicies,
|
|
250
310
|
});
|
|
251
311
|
|
|
252
312
|
return Object.freeze({
|
|
@@ -25,12 +25,41 @@ export interface ArchitectureLayerDetection {
|
|
|
25
25
|
readonly byTag: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export type EffectCapability =
|
|
29
|
+
| 'filesystem'
|
|
30
|
+
| 'network'
|
|
31
|
+
| 'database'
|
|
32
|
+
| 'process-env'
|
|
33
|
+
| 'time'
|
|
34
|
+
| 'random'
|
|
35
|
+
| 'subprocess'
|
|
36
|
+
| 'user-io';
|
|
37
|
+
|
|
38
|
+
export type DecisionSignal =
|
|
39
|
+
| 'business-rule-branch'
|
|
40
|
+
| 'validation-rule'
|
|
41
|
+
| 'error-construction'
|
|
42
|
+
| 'state-transition'
|
|
43
|
+
| 'policy-selection';
|
|
44
|
+
|
|
45
|
+
export interface CapabilityPolicy {
|
|
46
|
+
readonly allowed: readonly EffectCapability[];
|
|
47
|
+
readonly denied: readonly EffectCapability[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DecisionPolicy {
|
|
51
|
+
readonly expected: readonly DecisionSignal[];
|
|
52
|
+
readonly advisoryOnly: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
export interface ArchitectureConfigSource {
|
|
29
56
|
readonly preset: ArchitecturePresetId;
|
|
30
57
|
readonly layers?: readonly string[];
|
|
31
58
|
readonly allowedDependencies?: Readonly<Record<string, readonly string[]>>;
|
|
32
59
|
readonly metadataTags?: Partial<ArchitectureMetadataTags>;
|
|
33
60
|
readonly layerDetection?: Partial<ArchitectureLayerDetection>;
|
|
61
|
+
readonly capabilityPolicies?: Readonly<Record<string, CapabilityPolicy>>;
|
|
62
|
+
readonly decisionPolicies?: Readonly<Record<string, DecisionPolicy>>;
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
export interface ArchitectureConfigDocument {
|
|
@@ -39,6 +68,8 @@ export interface ArchitectureConfigDocument {
|
|
|
39
68
|
readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
|
|
40
69
|
readonly metadataTags: ArchitectureMetadataTags;
|
|
41
70
|
readonly layerDetection: ArchitectureLayerDetection;
|
|
71
|
+
readonly capabilityPolicies: Readonly<Record<string, CapabilityPolicy>>;
|
|
72
|
+
readonly decisionPolicies: Readonly<Record<string, DecisionPolicy>>;
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
export const isArchitecturePresetId = (value: unknown): value is ArchitecturePresetId => {
|
|
@@ -51,10 +82,24 @@ export const freezeArchitectureDocument = (
|
|
|
51
82
|
document: ArchitectureConfigDocument
|
|
52
83
|
): ArchitectureConfigDocument => {
|
|
53
84
|
const frozenDependencies: Record<string, readonly string[]> = {};
|
|
85
|
+
const frozenCapabilityPolicies: Record<string, CapabilityPolicy> = {};
|
|
86
|
+
const frozenDecisionPolicies: Record<string, DecisionPolicy> = {};
|
|
54
87
|
|
|
55
88
|
for (const [key, value] of Object.entries(document.allowedDependencies)) {
|
|
56
89
|
frozenDependencies[key] = Object.freeze([...value]);
|
|
57
90
|
}
|
|
91
|
+
for (const [key, value] of Object.entries(document.capabilityPolicies)) {
|
|
92
|
+
frozenCapabilityPolicies[key] = Object.freeze({
|
|
93
|
+
allowed: Object.freeze([...value.allowed]),
|
|
94
|
+
denied: Object.freeze([...value.denied]),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const [key, value] of Object.entries(document.decisionPolicies)) {
|
|
98
|
+
frozenDecisionPolicies[key] = Object.freeze({
|
|
99
|
+
expected: Object.freeze([...value.expected]),
|
|
100
|
+
advisoryOnly: value.advisoryOnly,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
58
103
|
|
|
59
104
|
return Object.freeze({
|
|
60
105
|
preset: document.preset,
|
|
@@ -62,5 +107,7 @@ export const freezeArchitectureDocument = (
|
|
|
62
107
|
allowedDependencies: Object.freeze(frozenDependencies),
|
|
63
108
|
metadataTags: Object.freeze({ ...document.metadataTags }),
|
|
64
109
|
layerDetection: Object.freeze({ ...document.layerDetection }),
|
|
110
|
+
capabilityPolicies: Object.freeze(frozenCapabilityPolicies),
|
|
111
|
+
decisionPolicies: Object.freeze(frozenDecisionPolicies),
|
|
65
112
|
});
|
|
66
113
|
};
|
package/scripts/harness/config-foundation/domain/value-objects/architecture-preset-catalog.ts
CHANGED
|
@@ -3,23 +3,41 @@
|
|
|
3
3
|
* @unit config-foundation
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { ArchitecturePresetId } from './architecture-config.js';
|
|
6
|
+
import type { ArchitecturePresetId, CapabilityPolicy, DecisionPolicy } from './architecture-config.js';
|
|
7
7
|
|
|
8
8
|
export interface ArchitecturePresetDefinition {
|
|
9
9
|
readonly layers: readonly string[];
|
|
10
10
|
readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
|
|
11
|
+
readonly capabilityPolicies: Readonly<Record<string, CapabilityPolicy>>;
|
|
12
|
+
readonly decisionPolicies: Readonly<Record<string, DecisionPolicy>>;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const freeze = (definition: ArchitecturePresetDefinition): ArchitecturePresetDefinition => {
|
|
14
16
|
const dependencies: Record<string, readonly string[]> = {};
|
|
17
|
+
const capabilityPolicies: Record<string, CapabilityPolicy> = {};
|
|
18
|
+
const decisionPolicies: Record<string, DecisionPolicy> = {};
|
|
15
19
|
|
|
16
20
|
for (const [key, value] of Object.entries(definition.allowedDependencies)) {
|
|
17
21
|
dependencies[key] = Object.freeze([...value]);
|
|
18
22
|
}
|
|
23
|
+
for (const [key, value] of Object.entries(definition.capabilityPolicies)) {
|
|
24
|
+
capabilityPolicies[key] = Object.freeze({
|
|
25
|
+
allowed: Object.freeze([...value.allowed]),
|
|
26
|
+
denied: Object.freeze([...value.denied]),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
for (const [key, value] of Object.entries(definition.decisionPolicies)) {
|
|
30
|
+
decisionPolicies[key] = Object.freeze({
|
|
31
|
+
expected: Object.freeze([...value.expected]),
|
|
32
|
+
advisoryOnly: value.advisoryOnly,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
19
35
|
|
|
20
36
|
return Object.freeze({
|
|
21
37
|
layers: Object.freeze([...definition.layers]),
|
|
22
38
|
allowedDependencies: Object.freeze(dependencies),
|
|
39
|
+
capabilityPolicies: Object.freeze(capabilityPolicies),
|
|
40
|
+
decisionPolicies: Object.freeze(decisionPolicies),
|
|
23
41
|
});
|
|
24
42
|
};
|
|
25
43
|
|
|
@@ -34,6 +52,18 @@ export const ARCHITECTURE_PRESET_CATALOG: Readonly<
|
|
|
34
52
|
infrastructure: ['infrastructure', 'application', 'domain'],
|
|
35
53
|
presentation: ['presentation', 'application', 'domain'],
|
|
36
54
|
},
|
|
55
|
+
capabilityPolicies: {
|
|
56
|
+
domain: { allowed: [], denied: ['filesystem', 'network', 'database', 'process-env', 'subprocess', 'user-io'] },
|
|
57
|
+
application: { allowed: ['time', 'random'], denied: ['filesystem', 'network', 'database', 'subprocess'] },
|
|
58
|
+
infrastructure: { allowed: ['filesystem', 'network', 'database', 'process-env', 'time', 'random', 'subprocess', 'user-io'], denied: [] },
|
|
59
|
+
presentation: { allowed: ['user-io', 'time'], denied: ['database', 'subprocess'] },
|
|
60
|
+
},
|
|
61
|
+
decisionPolicies: {
|
|
62
|
+
domain: { expected: ['business-rule-branch', 'validation-rule', 'state-transition'], advisoryOnly: true },
|
|
63
|
+
application: { expected: ['policy-selection', 'error-construction'], advisoryOnly: true },
|
|
64
|
+
infrastructure: { expected: ['error-construction'], advisoryOnly: true },
|
|
65
|
+
presentation: { expected: ['validation-rule', 'error-construction'], advisoryOnly: true },
|
|
66
|
+
},
|
|
37
67
|
}),
|
|
38
68
|
'strict-ddd': freeze({
|
|
39
69
|
layers: ['domain', 'application', 'infrastructure', 'presentation'],
|
|
@@ -43,6 +73,18 @@ export const ARCHITECTURE_PRESET_CATALOG: Readonly<
|
|
|
43
73
|
infrastructure: ['infrastructure', 'application', 'domain'],
|
|
44
74
|
presentation: ['presentation', 'application'],
|
|
45
75
|
},
|
|
76
|
+
capabilityPolicies: {
|
|
77
|
+
domain: { allowed: [], denied: ['filesystem', 'network', 'database', 'process-env', 'subprocess', 'user-io'] },
|
|
78
|
+
application: { allowed: ['time'], denied: ['filesystem', 'network', 'database', 'random', 'subprocess'] },
|
|
79
|
+
infrastructure: { allowed: ['filesystem', 'network', 'database', 'process-env', 'time', 'random', 'subprocess', 'user-io'], denied: [] },
|
|
80
|
+
presentation: { allowed: ['user-io'], denied: ['database', 'subprocess'] },
|
|
81
|
+
},
|
|
82
|
+
decisionPolicies: {
|
|
83
|
+
domain: { expected: ['business-rule-branch', 'validation-rule', 'state-transition'], advisoryOnly: true },
|
|
84
|
+
application: { expected: ['policy-selection'], advisoryOnly: true },
|
|
85
|
+
infrastructure: { expected: [], advisoryOnly: true },
|
|
86
|
+
presentation: { expected: ['error-construction'], advisoryOnly: true },
|
|
87
|
+
},
|
|
46
88
|
}),
|
|
47
89
|
onion: freeze({
|
|
48
90
|
layers: ['domain', 'application', 'interface'],
|
|
@@ -51,6 +93,16 @@ export const ARCHITECTURE_PRESET_CATALOG: Readonly<
|
|
|
51
93
|
application: ['application', 'domain'],
|
|
52
94
|
interface: ['interface', 'application', 'domain'],
|
|
53
95
|
},
|
|
96
|
+
capabilityPolicies: {
|
|
97
|
+
domain: { allowed: [], denied: ['filesystem', 'network', 'database', 'process-env', 'subprocess', 'user-io'] },
|
|
98
|
+
application: { allowed: ['time', 'random'], denied: ['filesystem', 'network', 'database', 'subprocess'] },
|
|
99
|
+
interface: { allowed: ['filesystem', 'network', 'database', 'process-env', 'time', 'random', 'subprocess', 'user-io'], denied: [] },
|
|
100
|
+
},
|
|
101
|
+
decisionPolicies: {
|
|
102
|
+
domain: { expected: ['business-rule-branch', 'validation-rule', 'state-transition'], advisoryOnly: true },
|
|
103
|
+
application: { expected: ['policy-selection', 'error-construction'], advisoryOnly: true },
|
|
104
|
+
interface: { expected: ['error-construction'], advisoryOnly: true },
|
|
105
|
+
},
|
|
54
106
|
}),
|
|
55
107
|
hexagonal: freeze({
|
|
56
108
|
layers: ['core', 'ports', 'adapters'],
|
|
@@ -59,6 +111,16 @@ export const ARCHITECTURE_PRESET_CATALOG: Readonly<
|
|
|
59
111
|
ports: ['ports', 'core'],
|
|
60
112
|
adapters: ['adapters', 'ports', 'core'],
|
|
61
113
|
},
|
|
114
|
+
capabilityPolicies: {
|
|
115
|
+
core: { allowed: [], denied: ['filesystem', 'network', 'database', 'process-env', 'subprocess', 'user-io'] },
|
|
116
|
+
ports: { allowed: [], denied: ['filesystem', 'network', 'database', 'subprocess'] },
|
|
117
|
+
adapters: { allowed: ['filesystem', 'network', 'database', 'process-env', 'time', 'random', 'subprocess', 'user-io'], denied: [] },
|
|
118
|
+
},
|
|
119
|
+
decisionPolicies: {
|
|
120
|
+
core: { expected: ['business-rule-branch', 'validation-rule', 'state-transition'], advisoryOnly: true },
|
|
121
|
+
ports: { expected: [], advisoryOnly: true },
|
|
122
|
+
adapters: { expected: ['error-construction'], advisoryOnly: true },
|
|
123
|
+
},
|
|
62
124
|
}),
|
|
63
125
|
layered: freeze({
|
|
64
126
|
layers: ['controller', 'service', 'repository'],
|
|
@@ -67,9 +129,21 @@ export const ARCHITECTURE_PRESET_CATALOG: Readonly<
|
|
|
67
129
|
service: ['service', 'repository'],
|
|
68
130
|
repository: ['repository'],
|
|
69
131
|
},
|
|
132
|
+
capabilityPolicies: {
|
|
133
|
+
controller: { allowed: ['user-io'], denied: ['database', 'filesystem', 'subprocess'] },
|
|
134
|
+
service: { allowed: ['time', 'random'], denied: ['filesystem', 'network', 'database', 'subprocess'] },
|
|
135
|
+
repository: { allowed: ['database', 'filesystem', 'network', 'process-env'], denied: ['user-io'] },
|
|
136
|
+
},
|
|
137
|
+
decisionPolicies: {
|
|
138
|
+
controller: { expected: ['validation-rule', 'error-construction'], advisoryOnly: true },
|
|
139
|
+
service: { expected: ['business-rule-branch', 'policy-selection', 'state-transition'], advisoryOnly: true },
|
|
140
|
+
repository: { expected: ['error-construction'], advisoryOnly: true },
|
|
141
|
+
},
|
|
70
142
|
}),
|
|
71
143
|
flat: freeze({
|
|
72
144
|
layers: [],
|
|
73
145
|
allowedDependencies: {},
|
|
146
|
+
capabilityPolicies: {},
|
|
147
|
+
decisionPolicies: {},
|
|
74
148
|
}),
|
|
75
149
|
});
|
package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json
CHANGED
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
"paths",
|
|
13
13
|
"reporting"
|
|
14
14
|
],
|
|
15
|
+
"$defs": {
|
|
16
|
+
"effectCapability": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"enum": ["filesystem", "network", "database", "process-env", "time", "random", "subprocess", "user-io"]
|
|
19
|
+
},
|
|
20
|
+
"decisionSignal": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": ["business-rule-branch", "validation-rule", "error-construction", "state-transition", "policy-selection"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
15
25
|
"properties": {
|
|
16
26
|
"project": {
|
|
17
27
|
"type": "object",
|
|
@@ -565,6 +575,42 @@
|
|
|
565
575
|
"byPath": { "type": "boolean" },
|
|
566
576
|
"byTag": { "type": "boolean" }
|
|
567
577
|
}
|
|
578
|
+
},
|
|
579
|
+
"capabilityPolicies": {
|
|
580
|
+
"type": "object",
|
|
581
|
+
"additionalProperties": {
|
|
582
|
+
"type": "object",
|
|
583
|
+
"additionalProperties": false,
|
|
584
|
+
"required": ["allowed", "denied"],
|
|
585
|
+
"properties": {
|
|
586
|
+
"allowed": {
|
|
587
|
+
"type": "array",
|
|
588
|
+
"items": { "$ref": "#/$defs/effectCapability" },
|
|
589
|
+
"uniqueItems": true
|
|
590
|
+
},
|
|
591
|
+
"denied": {
|
|
592
|
+
"type": "array",
|
|
593
|
+
"items": { "$ref": "#/$defs/effectCapability" },
|
|
594
|
+
"uniqueItems": true
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
"decisionPolicies": {
|
|
600
|
+
"type": "object",
|
|
601
|
+
"additionalProperties": {
|
|
602
|
+
"type": "object",
|
|
603
|
+
"additionalProperties": false,
|
|
604
|
+
"required": ["expected", "advisoryOnly"],
|
|
605
|
+
"properties": {
|
|
606
|
+
"expected": {
|
|
607
|
+
"type": "array",
|
|
608
|
+
"items": { "$ref": "#/$defs/decisionSignal" },
|
|
609
|
+
"uniqueItems": true
|
|
610
|
+
},
|
|
611
|
+
"advisoryOnly": { "type": "boolean" }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
568
614
|
}
|
|
569
615
|
},
|
|
570
616
|
"allOf": [
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -6,39 +6,82 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { SourceAnalysisPort, ImportGraphData } from '../../domain/ports/source-analysis-port.js';
|
|
8
8
|
import { readdir, readFile } from 'node:fs/promises';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { dirname, extname, join, normalize, resolve } from 'node:path';
|
|
10
10
|
|
|
11
11
|
const HARNESS_ROOT = join(process.cwd(), 'scripts', 'harness');
|
|
12
12
|
const IMPORT_PATTERN = /import\s+(?:type\s+)?(.+?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
13
|
-
const
|
|
13
|
+
const SIDE_EFFECT_IMPORT_PATTERN = /import\s+['"]([^'"]+)['"]/g;
|
|
14
|
+
const DYNAMIC_IMPORT_PATTERN = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
15
|
+
const RE_EXPORT_NAMED_PATTERN = /export\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
|
|
16
|
+
const RE_EXPORT_ALL_PATTERN = /export\s+\*\s+from\s+['"]([^'"]+)['"]/g;
|
|
17
|
+
const EXPORT_PATTERN = /export\s+(?:declare\s+)?(?:abstract\s+)?(?:class|interface|type|function|const|let|var|enum)\s+(\w+)/g;
|
|
18
|
+
const EXPORT_LIST_PATTERN = /export\s+(?:type\s+)?\{([^}]+)\}/g;
|
|
14
19
|
|
|
15
20
|
export class ImportGraphSourceAnalysisAdapter implements SourceAnalysisPort {
|
|
21
|
+
private readonly root: string;
|
|
22
|
+
|
|
23
|
+
constructor(root: string = HARNESS_ROOT) {
|
|
24
|
+
this.root = root;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
async getImportGraph(): Promise<ImportGraphData> {
|
|
17
|
-
const filePaths = await walkTsFiles(
|
|
18
|
-
const
|
|
19
|
-
const
|
|
28
|
+
const filePaths = await walkTsFiles(this.root);
|
|
29
|
+
const fileSet = new Set(filePaths.map((filePath) => normalize(filePath)));
|
|
30
|
+
const nodes: Array<{ filePath: string; exports: readonly string[]; excludedReason?: string }> = [];
|
|
31
|
+
const edges: Array<{ from: string; to: string; importedNames: readonly string[]; kind: string }> = [];
|
|
20
32
|
|
|
21
33
|
for (const filePath of filePaths) {
|
|
22
34
|
try {
|
|
23
35
|
const content = await readFile(filePath, 'utf8');
|
|
36
|
+
const exports = extractExports(content);
|
|
37
|
+
const excludedReason = classifyDeadCodeExclusion(filePath);
|
|
24
38
|
nodes.push({
|
|
25
39
|
filePath,
|
|
26
|
-
exports
|
|
40
|
+
exports,
|
|
41
|
+
...(excludedReason ? { excludedReason } : {}),
|
|
27
42
|
});
|
|
28
43
|
|
|
29
44
|
for (const match of content.matchAll(IMPORT_PATTERN)) {
|
|
45
|
+
const target = resolveImportTarget(filePath, match[2] ?? '', fileSet);
|
|
46
|
+
if (!target) continue;
|
|
47
|
+
edges.push({
|
|
48
|
+
from: filePath,
|
|
49
|
+
to: target,
|
|
50
|
+
importedNames: normalizeImportNames(match[1] ?? ''),
|
|
51
|
+
kind: 'static-import',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const match of content.matchAll(SIDE_EFFECT_IMPORT_PATTERN)) {
|
|
55
|
+
const target = resolveImportTarget(filePath, match[1] ?? '', fileSet);
|
|
56
|
+
if (!target) continue;
|
|
57
|
+
edges.push({ from: filePath, to: target, importedNames: ['*'], kind: 'side-effect-import' });
|
|
58
|
+
}
|
|
59
|
+
for (const match of content.matchAll(DYNAMIC_IMPORT_PATTERN)) {
|
|
60
|
+
const target = resolveImportTarget(filePath, match[1] ?? '', fileSet);
|
|
61
|
+
if (!target) continue;
|
|
62
|
+
edges.push({ from: filePath, to: target, importedNames: ['*'], kind: 'dynamic-import' });
|
|
63
|
+
}
|
|
64
|
+
for (const match of content.matchAll(RE_EXPORT_NAMED_PATTERN)) {
|
|
65
|
+
const target = resolveImportTarget(filePath, match[2] ?? '', fileSet);
|
|
66
|
+
if (!target) continue;
|
|
30
67
|
edges.push({
|
|
31
68
|
from: filePath,
|
|
32
|
-
to:
|
|
69
|
+
to: target,
|
|
33
70
|
importedNames: normalizeImportNames(match[1] ?? ''),
|
|
71
|
+
kind: 're-export',
|
|
34
72
|
});
|
|
35
73
|
}
|
|
74
|
+
for (const match of content.matchAll(RE_EXPORT_ALL_PATTERN)) {
|
|
75
|
+
const target = resolveImportTarget(filePath, match[1] ?? '', fileSet);
|
|
76
|
+
if (!target) continue;
|
|
77
|
+
edges.push({ from: filePath, to: target, importedNames: ['*'], kind: 're-export-all' });
|
|
78
|
+
}
|
|
36
79
|
} catch {
|
|
37
80
|
continue;
|
|
38
81
|
}
|
|
39
82
|
}
|
|
40
83
|
|
|
41
|
-
return { nodes, edges, unusedExports:
|
|
84
|
+
return { nodes, edges, unusedExports: detectUnusedExports(nodes, edges), unreachableCode: [] };
|
|
42
85
|
}
|
|
43
86
|
}
|
|
44
87
|
|
|
@@ -62,6 +105,7 @@ function normalizeImportNames(clause: string): string[] {
|
|
|
62
105
|
const cleaned = clause
|
|
63
106
|
.replace(/\btype\s+/g, '')
|
|
64
107
|
.replace(/\s+as\s+\w+/g, '')
|
|
108
|
+
.replace(/\*\s+as\s+(\w+)/g, '*')
|
|
65
109
|
.replace(/[{}]/g, ',');
|
|
66
110
|
|
|
67
111
|
return cleaned
|
|
@@ -69,3 +113,71 @@ function normalizeImportNames(clause: string): string[] {
|
|
|
69
113
|
.map((part) => part.trim())
|
|
70
114
|
.filter((part) => part.length > 0);
|
|
71
115
|
}
|
|
116
|
+
|
|
117
|
+
function extractExports(content: string): string[] {
|
|
118
|
+
const names = new Set<string>();
|
|
119
|
+
for (const match of content.matchAll(EXPORT_PATTERN)) {
|
|
120
|
+
if (match[1]) names.add(match[1]);
|
|
121
|
+
}
|
|
122
|
+
for (const match of content.matchAll(EXPORT_LIST_PATTERN)) {
|
|
123
|
+
for (const name of normalizeImportNames(match[1] ?? '')) {
|
|
124
|
+
const [exportName] = name.split(/\s+as\s+/).map((part) => part.trim()).reverse();
|
|
125
|
+
if (exportName) names.add(exportName);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (/export\s+default\b/.test(content)) {
|
|
129
|
+
names.add('default');
|
|
130
|
+
}
|
|
131
|
+
return [...names];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveImportTarget(fromFile: string, specifier: string, fileSet: ReadonlySet<string>): string | null {
|
|
135
|
+
if (!specifier.startsWith('.')) return null;
|
|
136
|
+
const base = resolve(dirname(fromFile), specifier);
|
|
137
|
+
const candidates = extname(base)
|
|
138
|
+
? [base]
|
|
139
|
+
: [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx')];
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
const normalized = normalize(candidate);
|
|
142
|
+
if (fileSet.has(normalized)) return normalized;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function classifyDeadCodeExclusion(filePath: string): string | undefined {
|
|
148
|
+
const normalized = filePath.replaceAll('\\', '/');
|
|
149
|
+
if (/(^|\/)__tests__\//.test(normalized) || /\.test\.ts$/.test(normalized) || /\.it\.test\.ts$/.test(normalized)) {
|
|
150
|
+
return 'test';
|
|
151
|
+
}
|
|
152
|
+
if (/(^|\/)fixtures?\//.test(normalized)) return 'fixture';
|
|
153
|
+
if (/(^|\/)(templates|generated)\//.test(normalized)) return 'generated';
|
|
154
|
+
if (/\/(index|main)\.ts$/.test(normalized) || /\/bin\//.test(normalized) || /\/presentation\/(cli|handlers)\//.test(normalized)) {
|
|
155
|
+
return 'entrypoint';
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function detectUnusedExports(
|
|
161
|
+
nodes: readonly { filePath: string; exports: readonly string[]; excludedReason?: string }[],
|
|
162
|
+
edges: readonly { to: string; importedNames: readonly string[] }[],
|
|
163
|
+
): string[] {
|
|
164
|
+
const usedByFile = new Map<string, Set<string>>();
|
|
165
|
+
for (const edge of edges) {
|
|
166
|
+
const names = usedByFile.get(edge.to) ?? new Set<string>();
|
|
167
|
+
for (const importedName of edge.importedNames) {
|
|
168
|
+
names.add(importedName);
|
|
169
|
+
}
|
|
170
|
+
usedByFile.set(edge.to, names);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const unused: string[] = [];
|
|
174
|
+
for (const node of nodes) {
|
|
175
|
+
if (node.excludedReason) continue;
|
|
176
|
+
const used = usedByFile.get(normalize(node.filePath)) ?? new Set<string>();
|
|
177
|
+
for (const exportName of node.exports) {
|
|
178
|
+
if (used.has('*') || used.has(exportName)) continue;
|
|
179
|
+
unused.push(`${node.filePath}::${exportName} (reason: no import/export graph reference)`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return unused;
|
|
183
|
+
}
|