phasegate 0.151.0 → 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 CHANGED
@@ -7,6 +7,22 @@ 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
+
20
+ ## [0.151.1] - 2026-05-12
21
+
22
+ ### Fixed
23
+
24
+ - **G4 post-publish dogfood** — fixes `npx phasegate@0.151.0` failing with `Error: tsx not found` by letting the bin wrapper execute the packaged `tsx` loader via `node --import` when dependency binaries are not linked into PATH.
25
+
10
26
  ## [0.151.0] - 2026-05-12
11
27
 
12
28
  ### Added
package/bin/phasegate CHANGED
@@ -7,8 +7,13 @@ SCRIPT_PATH="$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo
7
7
  PACKAGE_DIR="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"
8
8
  MAIN_TS="$PACKAGE_DIR/scripts/harness/main.ts"
9
9
 
10
- # tsx を探す: npx経由の場合はnode_modules/.binがPATHに入っているので command -v で見つかる
11
- if command -v tsx >/dev/null 2>&1; then
10
+ # tsx を探す。npm/npx の一時インストールでは dependency bin PATH ../.bin
11
+ # 出ないことがあるため、tsx loader を直接 node --import できる経路も見る。
12
+ if [ -f "$PACKAGE_DIR/node_modules/tsx/dist/loader.mjs" ]; then
13
+ exec node --import "$PACKAGE_DIR/node_modules/tsx/dist/loader.mjs" "$MAIN_TS" "$@"
14
+ elif [ -f "$PACKAGE_DIR/../tsx/dist/loader.mjs" ]; then
15
+ exec node --import "$PACKAGE_DIR/../tsx/dist/loader.mjs" "$MAIN_TS" "$@"
16
+ elif command -v tsx >/dev/null 2>&1; then
12
17
  exec tsx "$MAIN_TS" "$@"
13
18
  elif [ -f "$PACKAGE_DIR/node_modules/.bin/tsx" ]; then
14
19
  exec "$PACKAGE_DIR/node_modules/.bin/tsx" "$MAIN_TS" "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.151.0",
3
+ "version": "0.152.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -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
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @layer application
3
3
  * @unit config-foundation
4
- * @work-item-id WI-092 / WI-094 / WI-033 / WI-140
4
+ * @work-item-id WI-133
5
5
  */
6
6
  import type { HarnessConfigV2 } from '../../domain/harness-config.js';
7
7
 
@@ -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
  };
@@ -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
  });
@@ -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
  }>;
@@ -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} (${fileStat.size} bytes)`));
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 && ts.isStringLiteral(node.moduleSpecifier)) {
135
- exports.push({ name: `* from ${node.moduleSpecifier.text}`, type: 'type' });
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 SECURITY_PATTERNS = [
12
- { pattern: /(?:API_KEY|api_key|apikey)\s*=\s*["'][^"']{8,}["']/gi, description: 'ハードコードAPIキー' },
13
- { pattern: /(?:password|PASSWORD|passwd)\s*=\s*["'][^"']{4,}["']/gi, description: 'ハードコードパスワード' },
14
- { pattern: /sk-[a-zA-Z0-9]{20,}/g, description: 'OpenAI APIキー形式' },
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
- for (const { pattern, description } of SECURITY_PATTERNS) {
30
- if (pattern.test(line)) {
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 EXPORT_PATTERN = /export\s+(?:class|interface|type|function|const)\s+(\w+)/g;
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(HARNESS_ROOT);
18
- const nodes: Array<{ filePath: string; exports: readonly string[] }> = [];
19
- const edges: Array<{ from: string; to: string; importedNames: readonly string[] }> = [];
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: Array.from(content.matchAll(EXPORT_PATTERN), (match) => match[1]),
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: match[2],
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: [], unreachableCode: [] };
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
+ }