phasegate 0.335.0 → 0.341.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 +16 -0
- package/README.ja.md +22 -9
- package/README.md +28 -17
- package/docs/ADR/038-config-state-operation-permission-policy.md +5 -2
- package/docs/ADR/041-non-excludable-agent-trust-roots.md +68 -0
- package/docs/folder_management_rules.md +10 -1
- package/docs/guide/antigravity-integration.md +26 -0
- package/docs/guide/cli-reference.md +27 -7
- package/docs/guide/codex-integration.md +44 -107
- package/docs/guide/configuration.md +28 -2
- package/docs/guide/grok-integration.md +26 -0
- package/docs/guide/hooks-integration.md +34 -5
- package/docs/guide/installation.md +4 -2
- package/docs/guide/setup-artifacts.md +4 -4
- package/docs/guide/skills-overview.md +4 -1
- package/package.json +2 -1
- package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +1 -1
- package/scripts/harness/agent-integration/application/dto/normalized-pre-tool-use-request.ts +32 -0
- package/scripts/harness/agent-integration/application/usecases/handle-post-tool-use-usecase.ts +4 -0
- package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +38 -3
- package/scripts/harness/agent-integration/application/usecases/handle-stop-usecase.ts +3 -9
- package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +3 -1
- package/scripts/harness/agent-integration/domain/ports/story-reflection-query-port.ts +2 -0
- package/scripts/harness/agent-integration/domain/services/apply-patch-write-target-extractor.ts +98 -0
- package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +6 -52
- package/scripts/harness/agent-integration/domain/services/hook-to-cli-translator.ts +8 -5
- package/scripts/harness/agent-integration/domain/value-objects/hook-translation-result.ts +2 -2
- package/scripts/harness/agent-integration/domain/value-objects/protected-file-list.ts +35 -18
- package/scripts/harness/agent-integration/domain/value-objects/story-reflection-query-result.ts +10 -2
- package/scripts/harness/agent-integration/domain/value-objects/write-target-scope.ts +12 -0
- package/scripts/harness/agent-integration/infrastructure/adapters/child-process-cli-executor-adapter.ts +64 -13
- package/scripts/harness/agent-integration/infrastructure/adapters/file-system-story-reflection-query-adapter.ts +108 -2
- package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +8 -6
- package/scripts/harness/agent-integration/presentation/hook-skip-event-recorder.ts +3 -0
- package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +1 -1
- package/scripts/harness/agent-integration/presentation/post-tool-use-feedback.ts +39 -0
- package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +21 -27
- package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +126 -70
- package/scripts/harness/agent-integration/presentation/pre-tool-use-payload-normalizer.ts +228 -0
- package/scripts/harness/agent-integration/presentation/pre-tool-use-response-renderer.ts +38 -0
- package/scripts/harness/agent-integration/presentation/stop-hook.ts +1 -3
- package/scripts/harness/biome-ast-engine/infrastructure/adapters/biome-cli-executor-adapter.ts +21 -1
- package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +5 -0
- package/scripts/harness/ci-governance/domain/value-objects/integrity-target.ts +2 -0
- package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +15 -0
- package/scripts/harness/config-foundation/infrastructure/validators/ajv-config-schema-validator.ts +6 -3
- package/scripts/harness/harness-api/domain/ports/biome-lint-port.ts +2 -1
- package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +12 -1
- package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +10 -2
- package/scripts/harness/installation/application/bundled-skill-selection.ts +28 -2
- package/scripts/harness/installation/application/checks/antigravity-hook-missing-check.ts +79 -0
- package/scripts/harness/installation/application/checks/check-utils.ts +2 -2
- package/scripts/harness/installation/application/checks/codex-hook-missing-check.ts +70 -6
- package/scripts/harness/installation/application/checks/grok-hook-missing-check.ts +79 -0
- package/scripts/harness/installation/application/checks/husky-runtime-inactive-check.ts +33 -0
- package/scripts/harness/installation/application/named-hook-json.ts +17 -0
- package/scripts/harness/installation/application/operator-notice.ts +42 -0
- package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +18 -7
- package/scripts/harness/installation/application/usecases/run-install.ts +157 -57
- package/scripts/harness/installation/application/usecases/run-reconcile.ts +148 -31
- package/scripts/harness/installation/application/usecases/run-uninstall.ts +149 -25
- package/scripts/harness/installation/composition-root.ts +10 -0
- package/scripts/harness/installation/domain/agent-target.ts +39 -0
- package/scripts/harness/installation/domain/check-id.ts +5 -0
- package/scripts/harness/installation/domain/husky-runtime-state.ts +34 -0
- package/scripts/harness/installation/domain/ports/git-hooks-runtime-probe.ts +9 -0
- package/scripts/harness/installation/domain/repair-table.ts +5 -0
- package/scripts/harness/installation/infrastructure/adapters/git-hooks-runtime-probe-adapter.ts +62 -0
- package/scripts/harness/installation/presentation/cli/install-handler.ts +25 -5
- package/scripts/harness/installation/presentation/cli/reconcile-handler.ts +7 -1
- package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +27 -0
- package/scripts/harness/main.ts +227 -128
- package/scripts/harness/phase-dependency-model/domain/services/story-reflection-checker.ts +48 -5
- package/scripts/harness/phase-dependency-model/domain/services/work-item-reflection-scope-resolver.ts +64 -0
- package/scripts/harness/phase-dependency-model/domain/values/story-reflection-mapping.ts +8 -2
- package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +6 -1
- package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-work-item-dependency-catalog.ts +73 -0
- package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +5 -1
- package/scripts/harness/quick-mode/application/dto/quick-mode-eligibility-contract.ts +1 -1
- package/scripts/harness/quick-mode/application/ports/change-risk-advisory-port.ts +16 -0
- package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +3 -1
- package/scripts/harness/quick-mode/composition-root.ts +5 -1
- package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +13 -2
- package/scripts/harness/quick-mode/domain/types/rejection-rule.ts +1 -1
- package/scripts/harness/quick-mode/infrastructure/adapters/snapshot-risk-advisory-adapter.ts +118 -0
- package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +7 -0
- package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +12 -1
- package/scripts/harness/regression-suite/infrastructure/adapters/vitest-test-runner-adapter.ts +13 -3
- package/scripts/harness/setup/skill-deployer.ts +3 -45
- package/scripts/harness/skill-quality/application/dto/run-plan-checker-loop-output.ts +1 -0
- package/scripts/harness/skill-quality/application/usecases/apply-cascade-update-usecase.ts +9 -4
- package/scripts/harness/skill-quality/application/usecases/execute-tdd-cycle-usecase.ts +2 -1
- package/scripts/harness/skill-quality/application/usecases/run-plan-checker-loop-usecase.ts +8 -0
- package/scripts/harness/skill-quality/composition-root.ts +14 -7
- package/scripts/harness/skill-quality/domain/ports/plan-check-executor-port.ts +2 -0
- package/scripts/harness/skill-quality/domain/value-objects/cascade-update-target.ts +15 -1
- package/scripts/harness/skill-quality/infrastructure/adapters/l1-biome-validator-adapter.ts +9 -1
- package/scripts/harness/skill-quality/infrastructure/adapters/l2-validator-system-adapter.ts +18 -2
- package/scripts/harness/skill-quality/presentation/handlers/apply-cascade-update-handler.ts +8 -2
- package/scripts/harness/skill-quality/presentation/handlers/execute-tdd-cycle-handler.ts +4 -3
- package/scripts/harness/skill-quality/presentation/handlers/run-plan-checker-loop-handler.ts +11 -4
- package/scripts/harness/traceability-model/infrastructure/parsers/work-item-frontmatter-parser.ts +41 -0
- package/skills/README.md +7 -0
- package/skills/cascade-updater/SKILL.md +37 -109
- package/skills/cascade-updater/references//345/261/244/345/210/245/345/210/244/345/256/232/343/202/254/343/202/244/343/203/211.md +5 -3
- package/skills/release-publisher/SKILL.md +3 -1
- package/skills/skill-creator/SKILL.md +8 -9
- package/templates/.agents/hooks.json +16 -0
- package/templates/.claude/scripts/analyze-errors-hook.sh +2 -2
- package/templates/.claude/settings.json +5 -3
- package/templates/.codex/hooks.json +2 -2
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer domain
|
|
3
3
|
* @unit phase-dependency-model
|
|
4
|
+
* @work-item-id WI-220
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { StoryReflectionFileSystemPort } from "../ports/story-reflection-file-system-port.js";
|
|
7
8
|
import type { StoryReflectionConfig } from "../values/story-reflection-config.js";
|
|
8
9
|
import type { StoryReflectionMapping } from "../values/story-reflection-mapping.js";
|
|
9
10
|
import { StoryReflectionResult, type StoryReflectionViolation } from "../values/story-reflection-result.js";
|
|
11
|
+
import type { WorkItemReflectionScope } from "./work-item-reflection-scope-resolver.js";
|
|
12
|
+
import type { PathRoots } from "../values/artifact.js";
|
|
10
13
|
|
|
11
14
|
const CROSS_WORK_ITEM_PATTERN = /^WI-\d+$/;
|
|
12
15
|
|
|
@@ -18,13 +21,50 @@ interface ResolvedInceptionPath {
|
|
|
18
21
|
export class StoryReflectionChecker {
|
|
19
22
|
constructor(private readonly fsPort: StoryReflectionFileSystemPort) {}
|
|
20
23
|
|
|
21
|
-
async check(unitId: string, config: StoryReflectionConfig): Promise<StoryReflectionResult> {
|
|
24
|
+
async check(unitId: string, config: StoryReflectionConfig, roots?: PathRoots): Promise<StoryReflectionResult> {
|
|
22
25
|
if (!config.enabled) {
|
|
23
26
|
return StoryReflectionResult.pass();
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
const storyIds = await this.fsPort.listStoryDirectories(unitId);
|
|
27
30
|
|
|
31
|
+
return this.checkItems(unitId, storyIds, config, false, roots);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Accept only a complete, caller-resolved scope; this method does not authenticate its origin. */
|
|
35
|
+
async checkResolvedScope(
|
|
36
|
+
scope: Extract<WorkItemReflectionScope, { status: 'complete' }>,
|
|
37
|
+
config: StoryReflectionConfig,
|
|
38
|
+
roots?: PathRoots,
|
|
39
|
+
): Promise<StoryReflectionResult> {
|
|
40
|
+
if (!config.enabled) return StoryReflectionResult.pass();
|
|
41
|
+
if (scope.workItems.length === 0) throw new Error('Resolved reflection scope must not be empty');
|
|
42
|
+
const byUnit = new Map<string, Set<string>>();
|
|
43
|
+
for (const item of scope.workItems) {
|
|
44
|
+
for (const unit of item.unitIds) {
|
|
45
|
+
const ids = byUnit.get(unit) ?? new Set<string>();
|
|
46
|
+
ids.add(item.id);
|
|
47
|
+
byUnit.set(unit, ids);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const violations: StoryReflectionViolation[] = [];
|
|
51
|
+
const warnings: StoryReflectionViolation[] = [];
|
|
52
|
+
for (const [unit, ids] of byUnit) {
|
|
53
|
+
const result = await this.checkItems(unit, [...ids].sort(), config, true, roots);
|
|
54
|
+
violations.push(...result.violations);
|
|
55
|
+
warnings.push(...result.warnings);
|
|
56
|
+
}
|
|
57
|
+
return StoryReflectionResult.create({ violations, warnings });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async checkItems(
|
|
61
|
+
unitId: string,
|
|
62
|
+
storyIds: readonly string[],
|
|
63
|
+
config: StoryReflectionConfig,
|
|
64
|
+
explicitScope: boolean,
|
|
65
|
+
roots?: PathRoots,
|
|
66
|
+
): Promise<StoryReflectionResult> {
|
|
67
|
+
|
|
28
68
|
if (storyIds.length === 0) {
|
|
29
69
|
return StoryReflectionResult.pass();
|
|
30
70
|
}
|
|
@@ -34,22 +74,24 @@ export class StoryReflectionChecker {
|
|
|
34
74
|
|
|
35
75
|
for (const storyId of storyIds) {
|
|
36
76
|
for (const mapping of config.mappings) {
|
|
37
|
-
const { product: productPath } = mapping.resolve({ unitId, storyId });
|
|
77
|
+
const { product: productPath } = mapping.resolve({ unitId, storyId }, roots);
|
|
38
78
|
const resolvedInception = await this.resolveInceptionPath({
|
|
39
79
|
mapping,
|
|
40
80
|
unitId,
|
|
41
81
|
storyId,
|
|
82
|
+
roots,
|
|
42
83
|
});
|
|
43
84
|
|
|
44
85
|
if (resolvedInception === null) {
|
|
45
86
|
continue;
|
|
46
87
|
}
|
|
47
88
|
|
|
48
|
-
if (resolvedInception.isCrossWorkItem && !(await this.fsPort.storyAffectsUnit(storyId, unitId))) {
|
|
89
|
+
if (!explicitScope && resolvedInception.isCrossWorkItem && !(await this.fsPort.storyAffectsUnit(storyId, unitId))) {
|
|
49
90
|
continue;
|
|
50
91
|
}
|
|
51
92
|
|
|
52
93
|
if (
|
|
94
|
+
!explicitScope &&
|
|
53
95
|
resolvedInception.isCrossWorkItem &&
|
|
54
96
|
productPath.endsWith("domain_model.md") &&
|
|
55
97
|
!(await this.fsPort.storyTouchesUnitLayer(storyId, unitId, "domain"))
|
|
@@ -83,11 +125,12 @@ export class StoryReflectionChecker {
|
|
|
83
125
|
readonly mapping: StoryReflectionMapping;
|
|
84
126
|
readonly unitId: string;
|
|
85
127
|
readonly storyId: string;
|
|
128
|
+
readonly roots?: PathRoots;
|
|
86
129
|
}): Promise<ResolvedInceptionPath | null> {
|
|
87
130
|
const normalPath = input.mapping.resolve({
|
|
88
131
|
unitId: input.unitId,
|
|
89
132
|
storyId: input.storyId,
|
|
90
|
-
}).inception;
|
|
133
|
+
}, input.roots).inception;
|
|
91
134
|
|
|
92
135
|
if (await this.fsPort.fileExists(normalPath)) {
|
|
93
136
|
return { path: normalPath, isCrossWorkItem: false };
|
|
@@ -100,7 +143,7 @@ export class StoryReflectionChecker {
|
|
|
100
143
|
const crossPath = input.mapping.resolve({
|
|
101
144
|
unitId: "_cross",
|
|
102
145
|
storyId: input.storyId,
|
|
103
|
-
}).inception;
|
|
146
|
+
}, input.roots).inception;
|
|
104
147
|
|
|
105
148
|
if (await this.fsPort.fileExists(crossPath)) {
|
|
106
149
|
return { path: crossPath, isCrossWorkItem: true };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @unit phase-dependency-model
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-220
|
|
4
|
+
|
|
5
|
+
export interface WorkItemDependencyRecord {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly unitIds: readonly string[];
|
|
8
|
+
/** Missing is unknown; an explicit empty list declares no dependencies. */
|
|
9
|
+
readonly dependsOn?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WorkItemScopeUnknownCode =
|
|
13
|
+
| 'NO_TARGET' | 'INVALID_ID' | 'MISSING_WORK_ITEM' | 'DUPLICATE_ID'
|
|
14
|
+
| 'UNIT_MISMATCH' | 'UNKNOWN_UNITS' | 'UNDECLARED_DEPENDENCIES';
|
|
15
|
+
|
|
16
|
+
export type WorkItemReflectionScope =
|
|
17
|
+
| { readonly status: 'complete'; readonly workItems: readonly WorkItemDependencyRecord[] }
|
|
18
|
+
| { readonly status: 'unknown'; readonly code: WorkItemScopeUnknownCode; readonly workItemId?: string };
|
|
19
|
+
|
|
20
|
+
/** Resolves declared dependencies only; it does not authenticate a session or approve design meaning. */
|
|
21
|
+
export class WorkItemReflectionScopeResolver {
|
|
22
|
+
resolve(
|
|
23
|
+
unitId: string,
|
|
24
|
+
targetIds: readonly string[],
|
|
25
|
+
catalog: readonly WorkItemDependencyRecord[],
|
|
26
|
+
): WorkItemReflectionScope {
|
|
27
|
+
const unknown = (code: WorkItemScopeUnknownCode, workItemId?: string): WorkItemReflectionScope =>
|
|
28
|
+
({ status: 'unknown', code, workItemId });
|
|
29
|
+
if (targetIds.length === 0) return unknown('NO_TARGET');
|
|
30
|
+
|
|
31
|
+
const byId = new Map<string, WorkItemDependencyRecord[]>();
|
|
32
|
+
for (const item of catalog) {
|
|
33
|
+
const matches = byId.get(item.id);
|
|
34
|
+
if (matches) matches.push(item);
|
|
35
|
+
else byId.set(item.id, [item]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const roots = new Set(targetIds);
|
|
39
|
+
const pending = [...roots].sort();
|
|
40
|
+
const visited = new Map<string, WorkItemDependencyRecord>();
|
|
41
|
+
// Iteration, rather than recursive traversal, also bounds work for cycles.
|
|
42
|
+
for (let index = 0; index < pending.length; index++) {
|
|
43
|
+
const id = pending[index];
|
|
44
|
+
if (visited.has(id)) continue;
|
|
45
|
+
if (!/^WI-\d+$/.test(id)) return unknown('INVALID_ID', id);
|
|
46
|
+
const matches = byId.get(id);
|
|
47
|
+
if (!matches) return unknown('MISSING_WORK_ITEM', id);
|
|
48
|
+
if (matches.length !== 1) return unknown('DUPLICATE_ID', id);
|
|
49
|
+
const item = matches[0];
|
|
50
|
+
if (item.unitIds.length === 0 || item.unitIds.some((unit) => !unit.trim() || unit.startsWith('_'))) {
|
|
51
|
+
return unknown('UNKNOWN_UNITS', id);
|
|
52
|
+
}
|
|
53
|
+
if (roots.has(id) && !item.unitIds.includes(unitId)) return unknown('UNIT_MISMATCH', id);
|
|
54
|
+
if (item.dependsOn === undefined) return unknown('UNDECLARED_DEPENDENCIES', id);
|
|
55
|
+
visited.set(id, item);
|
|
56
|
+
for (const dependency of new Set(item.dependsOn)) pending.push(dependency);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
status: 'complete',
|
|
61
|
+
workItems: [...visited.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* @unit phase-dependency-model
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { PathRoots } from './artifact.js';
|
|
7
|
+
|
|
6
8
|
export interface StoryReflectionMappingCreateArgs {
|
|
7
9
|
readonly inception: string;
|
|
8
10
|
readonly product: string;
|
|
@@ -69,7 +71,7 @@ export class StoryReflectionMapping {
|
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
resolve(scope: { unitId: string; storyId: string }): { inception: string; product: string } {
|
|
74
|
+
resolve(scope: { unitId: string; storyId: string }, roots?: PathRoots): { inception: string; product: string } {
|
|
73
75
|
const inception = this.inception
|
|
74
76
|
.replaceAll('{unit}', scope.unitId)
|
|
75
77
|
.replaceAll('{storyId}', scope.storyId);
|
|
@@ -78,7 +80,11 @@ export class StoryReflectionMapping {
|
|
|
78
80
|
.replaceAll('{unit}', scope.unitId)
|
|
79
81
|
.replaceAll('{storyId}', scope.storyId);
|
|
80
82
|
|
|
81
|
-
return { inception, product };
|
|
83
|
+
if (!roots) return { inception, product };
|
|
84
|
+
return {
|
|
85
|
+
inception: inception.replace(/^docs\/inception\//, () => `${roots.inceptionDocsRoot}/`),
|
|
86
|
+
product: product.replace(/^docs\/product\/construction\//, () => `${roots.designDocsRoot}/`),
|
|
87
|
+
};
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
equals(other: StoryReflectionMapping): boolean {
|
|
@@ -27,6 +27,7 @@ export interface FileSystemStoryReflectionAdapterDeps {
|
|
|
27
27
|
readonly rootDir: string;
|
|
28
28
|
/** inception ディレクトリのルート(デフォルト: docs/inception) */
|
|
29
29
|
readonly inceptionRoot?: string;
|
|
30
|
+
readonly designDocsRoot?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -43,11 +44,13 @@ export interface FileSystemStoryReflectionAdapterDeps {
|
|
|
43
44
|
export class FileSystemStoryReflectionAdapter implements StoryReflectionFileSystemPort {
|
|
44
45
|
private readonly rootDir: string;
|
|
45
46
|
private readonly inceptionRoot: string;
|
|
47
|
+
private readonly designDocsRoot: string;
|
|
46
48
|
private readonly changedPathsByStoryId = new Map<string, Promise<ReadonlySet<string>>>();
|
|
47
49
|
|
|
48
50
|
constructor(deps: FileSystemStoryReflectionAdapterDeps) {
|
|
49
51
|
this.rootDir = deps.rootDir;
|
|
50
52
|
this.inceptionRoot = deps.inceptionRoot ?? "docs/inception";
|
|
53
|
+
this.designDocsRoot = deps.designDocsRoot ?? "docs/product/construction";
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
async listStoryDirectories(unitId: string): Promise<readonly string[]> {
|
|
@@ -325,7 +328,9 @@ export class FileSystemStoryReflectionAdapter implements StoryReflectionFileSyst
|
|
|
325
328
|
|
|
326
329
|
private extractProductUnitId(productPath: string): string | null {
|
|
327
330
|
const normalized = productPath.split(path.sep).join("/");
|
|
328
|
-
const
|
|
331
|
+
const prefix = `${this.designDocsRoot}/`;
|
|
332
|
+
if (!normalized.startsWith(prefix)) return null;
|
|
333
|
+
const match = /^([^/]+)\//.exec(normalized.slice(prefix.length));
|
|
329
334
|
return match?.[1] ?? null;
|
|
330
335
|
}
|
|
331
336
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @unit phase-dependency-model
|
|
2
|
+
// @layer infrastructure
|
|
3
|
+
// @work-item-id WI-220
|
|
4
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
5
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import type { WorkItemDependencyRecord } from '../../domain/services/work-item-reflection-scope-resolver.js';
|
|
7
|
+
import type { WorkItemFrontmatter } from '../../../traceability-model/domain/value-objects/work-item-frontmatter.js';
|
|
8
|
+
import { parseWorkItemDependencies, parseWorkItemFrontmatter } from '../../../traceability-model/infrastructure/parsers/work-item-frontmatter-parser.js';
|
|
9
|
+
|
|
10
|
+
export interface WorkItemDependencyCatalogEntry extends WorkItemDependencyRecord {
|
|
11
|
+
readonly descriptionPath: string;
|
|
12
|
+
readonly metadata?: WorkItemFrontmatter;
|
|
13
|
+
readonly diagnostic?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface IndexedDescription {
|
|
17
|
+
readonly owner: string;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Directory index is global, document reads are limited to reachable WI IDs. */
|
|
22
|
+
export class FileSystemWorkItemDependencyCatalog {
|
|
23
|
+
constructor(private readonly options: { rootDir: string; inceptionRoot?: string }) {}
|
|
24
|
+
|
|
25
|
+
async load(targetIds: readonly string[]): Promise<readonly WorkItemDependencyCatalogEntry[]> {
|
|
26
|
+
const root = resolve(this.options.rootDir, this.options.inceptionRoot ?? 'docs/inception');
|
|
27
|
+
const index = new Map<string, IndexedDescription[]>();
|
|
28
|
+
const owners = await readdir(root, { withFileTypes: true });
|
|
29
|
+
for (const owner of owners.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
30
|
+
if (!owner.isDirectory() || owner.name.startsWith('.') || (owner.name.startsWith('_') && owner.name !== '_cross')) continue;
|
|
31
|
+
const entries = await readdir(join(root, owner.name), { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory() || !/^WI-\d+$/.test(entry.name)) continue;
|
|
34
|
+
const descriptions = index.get(entry.name) ?? [];
|
|
35
|
+
descriptions.push({ owner: owner.name, path: join(root, owner.name, entry.name, 'description.md') });
|
|
36
|
+
index.set(entry.name, descriptions);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pending = [...new Set(targetIds)].sort();
|
|
41
|
+
const visited = new Set<string>();
|
|
42
|
+
const result: WorkItemDependencyCatalogEntry[] = [];
|
|
43
|
+
for (let cursor = 0; cursor < pending.length; cursor++) {
|
|
44
|
+
const id = pending[cursor];
|
|
45
|
+
if (visited.has(id)) continue;
|
|
46
|
+
visited.add(id);
|
|
47
|
+
// Only indexed paths are read; an input ID is never used to construct a path.
|
|
48
|
+
for (const description of index.get(id) ?? []) {
|
|
49
|
+
const entry = await this.readEntry(id, description);
|
|
50
|
+
result.push(entry);
|
|
51
|
+
for (const dependency of entry.dependsOn ?? []) pending.push(dependency);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private async readEntry(id: string, description: IndexedDescription): Promise<WorkItemDependencyCatalogEntry> {
|
|
58
|
+
const descriptionPath = relative(this.options.rootDir, description.path).split(sep).join('/');
|
|
59
|
+
try {
|
|
60
|
+
const content = await readFile(description.path, 'utf8');
|
|
61
|
+
const metadata = parseWorkItemFrontmatter(content);
|
|
62
|
+
if (metadata === null || metadata.id !== id) throw new Error('WI frontmatter id must match its directory');
|
|
63
|
+
const dependsOn = parseWorkItemDependencies(content);
|
|
64
|
+
const unitIds = metadata.affects ?? (description.owner === '_cross' ? [] : [description.owner]);
|
|
65
|
+
if (unitIds.length === 0 || unitIds.some((unit) => !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(unit))) {
|
|
66
|
+
throw new Error('WI owner/affects must identify actual Units');
|
|
67
|
+
}
|
|
68
|
+
return { id, unitIds, dependsOn, descriptionPath, metadata };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { id, unitIds: [], descriptionPath, diagnostic: error instanceof Error ? error.message : String(error) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// @unit quick-mode
|
|
2
2
|
// @layer application
|
|
3
3
|
// @story H10-05
|
|
4
|
+
// @work-item-id WI-220
|
|
5
|
+
|
|
6
|
+
import type { ChangeRiskAdvice } from '../ports/change-risk-advisory-port.js';
|
|
4
7
|
|
|
5
8
|
export interface ChangeCategoryPerFile {
|
|
6
9
|
readonly path: string;
|
|
@@ -8,9 +11,10 @@ export interface ChangeCategoryPerFile {
|
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export interface ChangeCategoryClassificationContract {
|
|
14
|
+
readonly riskAdvice?: readonly ChangeRiskAdvice[];
|
|
11
15
|
readonly dominantCategory: string | null;
|
|
12
16
|
readonly perFile: readonly ChangeCategoryPerFile[];
|
|
13
17
|
readonly fullModeRequired: boolean;
|
|
14
|
-
readonly rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
|
|
18
|
+
readonly rejectionRule?: 'CATEGORY_NOT_ALLOWED' | 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
|
|
15
19
|
readonly rejectionReason?: string;
|
|
16
20
|
}
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
export interface QuickModeEligibilityContract {
|
|
9
9
|
readonly eligible: boolean;
|
|
10
10
|
readonly reason: string;
|
|
11
|
-
readonly rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
|
|
11
|
+
readonly rejectionRule?: 'CATEGORY_NOT_ALLOWED' | 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
|
|
12
12
|
readonly rejectedFiles?: readonly { filePath: string; changeKind: string }[];
|
|
13
13
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @unit quick-mode
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-220
|
|
4
|
+
export interface ChangeRiskAdvice {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly kind: 'module-surface-change' | 'behavior-review' | 'unknown' | 'no-content-change';
|
|
7
|
+
readonly source: 'caller-snapshot';
|
|
8
|
+
readonly enforcement: 'none';
|
|
9
|
+
readonly beforeHash: string | null;
|
|
10
|
+
readonly afterHash: string | null;
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChangeRiskAdvisoryPort {
|
|
15
|
+
assess(paths: readonly string[], snapshotFile: string): Promise<readonly ChangeRiskAdvice[]>;
|
|
16
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* @layer application
|
|
3
3
|
* @unit quick-mode
|
|
4
4
|
* @story H10-05
|
|
5
|
+
* @work-item-id WI-384
|
|
5
6
|
*
|
|
6
7
|
* paths から変更カテゴリを分類し fullModeRequired 判定と理由を返す UseCase
|
|
7
8
|
*/
|
|
@@ -20,6 +21,7 @@ export interface ClassifyChangeCategoryUseCaseInput {
|
|
|
20
21
|
readonly paths: readonly string[];
|
|
21
22
|
readonly targetChanges?: readonly {
|
|
22
23
|
readonly filePath: string;
|
|
24
|
+
readonly changeKind?: ChangeKind;
|
|
23
25
|
readonly beforeContent?: string | null;
|
|
24
26
|
readonly afterContent?: string | null;
|
|
25
27
|
}[];
|
|
@@ -94,7 +96,7 @@ export class ClassifyChangeCategoryUseCase {
|
|
|
94
96
|
// 変更前の内容が無く変更後の内容がある場合は新規作成 (CREATE) とみなす。
|
|
95
97
|
// 以前は無条件で MODIFY 固定だったため、新規 domain/ ファイルが
|
|
96
98
|
// NEW_DOMAIN 判定を回避して quick mode をすり抜けていた。
|
|
97
|
-
changeKind: beforeContent === null && afterContent !== null ? "CREATE" : "MODIFY",
|
|
99
|
+
changeKind: targetChange?.changeKind ?? (beforeContent === null && afterContent !== null ? "CREATE" : "MODIFY"),
|
|
98
100
|
beforeContent,
|
|
99
101
|
afterContent,
|
|
100
102
|
});
|
|
@@ -13,6 +13,7 @@ import { JudgeQuickModeEligibilityUseCase } from "./application/usecases/judge-q
|
|
|
13
13
|
import { QuickModeJudgmentEngine } from "./domain/services/quick-mode-judgment-engine.js";
|
|
14
14
|
import { ValidatorRelaxationService } from "./domain/services/validator-relaxation-service.js";
|
|
15
15
|
import { FsFileExistenceAdapter } from "./infrastructure/adapters/fs-file-existence-adapter.js";
|
|
16
|
+
import { SnapshotRiskAdvisoryAdapter } from "./infrastructure/adapters/snapshot-risk-advisory-adapter.js";
|
|
16
17
|
import { GitDiffChangedFilesAdapter } from "./infrastructure/adapters/git-diff-changed-files-adapter.js";
|
|
17
18
|
import { HarnessConfigQuickModeConfigAdapter } from "./infrastructure/adapters/harness-config-quick-mode-config-adapter.js";
|
|
18
19
|
import { ValidatorSystemQuickModeExecutionAdapter } from "./infrastructure/adapters/validator-system-quick-mode-execution-adapter.js";
|
|
@@ -86,7 +87,10 @@ export function createQuickModeCompositionRoot(
|
|
|
86
87
|
|
|
87
88
|
// Presentation
|
|
88
89
|
const handler = new CiCheckQuickModeHandler({ useCase: executeUseCase });
|
|
89
|
-
const checkChangeCategoryHandler = new CheckChangeCategoryHandler({
|
|
90
|
+
const checkChangeCategoryHandler = new CheckChangeCategoryHandler({
|
|
91
|
+
useCase: classifyUseCase,
|
|
92
|
+
riskAdvisoryPort: new SnapshotRiskAdvisoryAdapter(options.rootDir),
|
|
93
|
+
});
|
|
90
94
|
|
|
91
95
|
return {
|
|
92
96
|
handler,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @work-item-id WI-349
|
|
6
6
|
* @work-item-id WI-352
|
|
7
7
|
* @work-item-id WI-372
|
|
8
|
+
* @work-item-id WI-390
|
|
8
9
|
*
|
|
9
10
|
* ChangedFile[]をChangeClassificationに変換し、3拒否ルールを評価してQuickModeEligibilityを返すドメインサービス
|
|
10
11
|
*/
|
|
@@ -92,6 +93,15 @@ function categorizeFileByBuiltInRules(file: ChangedFile): ChangeCategory {
|
|
|
92
93
|
return ChangeCategory.fromString("test");
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
// Markdown は配置場所にかかわらず文書として扱う。config / API / test の
|
|
97
|
+
// 明示ルールは優先しつつ、domain ディレクトリや CREATE フォールバックより
|
|
98
|
+
// 前に評価して README.md / *.mdx の新規作成を feature/domain に誤分類しない。
|
|
99
|
+
// 信頼境界となる root agent instructions は protected-file 側で別途保護する。
|
|
100
|
+
// @work-item-id WI-390
|
|
101
|
+
if (filePath.endsWith(".md") || filePath.endsWith(".mdx")) {
|
|
102
|
+
return ChangeCategory.fromString("docs");
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
// domain: domain/ 配下
|
|
96
106
|
if (filePath.includes("/domain/") || filePath.startsWith("domain/")) {
|
|
97
107
|
return ChangeCategory.fromString("domain");
|
|
@@ -202,7 +212,7 @@ export class QuickModeJudgmentEngine {
|
|
|
202
212
|
judge(changedFiles: readonly ChangedFile[], config: QuickModeConfig): QuickModeEligibility {
|
|
203
213
|
const classification = this.classify(changedFiles, config);
|
|
204
214
|
|
|
205
|
-
// 1.
|
|
215
|
+
// 1. カテゴリ評価: 不許可カテゴリを含む単一カテゴリ変更と、複数カテゴリ変更を区別する。
|
|
206
216
|
if (config.isFullModeRequiredFor("mixedCategories")) {
|
|
207
217
|
const notAllowedFiles: ChangedFile[] = [];
|
|
208
218
|
const notAllowedDescriptions: string[] = [];
|
|
@@ -216,8 +226,9 @@ export class QuickModeJudgmentEngine {
|
|
|
216
226
|
});
|
|
217
227
|
|
|
218
228
|
if (notAllowedFiles.length > 0) {
|
|
229
|
+
const rejectionRule = classification.categorizedFiles.size >= 2 ? "MIXED_CHANGES" : "CATEGORY_NOT_ALLOWED";
|
|
219
230
|
return QuickModeEligibility.rejected(
|
|
220
|
-
|
|
231
|
+
rejectionRule,
|
|
221
232
|
notAllowedFiles,
|
|
222
233
|
`allowedCategories外のファイルが含まれています: ${notAllowedDescriptions.join(", ")}`,
|
|
223
234
|
);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @unit quick-mode
|
|
2
|
+
// @layer infrastructure
|
|
3
|
+
// @work-item-id WI-220
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import type * as TypeScript from 'typescript';
|
|
8
|
+
import type { ChangeRiskAdvice, ChangeRiskAdvisoryPort } from '../../application/ports/change-risk-advisory-port.js';
|
|
9
|
+
|
|
10
|
+
interface Snapshot {
|
|
11
|
+
filePath: string;
|
|
12
|
+
beforeContent: string | null;
|
|
13
|
+
afterContent: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isSnapshot(value: unknown): value is Snapshot {
|
|
17
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
18
|
+
const item = value as Record<string, unknown>;
|
|
19
|
+
return typeof item.filePath === 'string' && item.filePath.length > 0
|
|
20
|
+
&& (typeof item.beforeContent === 'string' || item.beforeContent === null)
|
|
21
|
+
&& (typeof item.afterContent === 'string' || item.afterContent === null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hash(content: string | null): string | null {
|
|
25
|
+
return content === null ? null : createHash('sha256').update(content).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function classSurface(ts: typeof TypeScript, declaration: TypeScript.ClassDeclaration): TypeScript.ClassDeclaration | null {
|
|
29
|
+
const decorated = (node: TypeScript.Node) => ts.canHaveDecorators(node) && (ts.getDecorators(node)?.length ?? 0) > 0;
|
|
30
|
+
if (decorated(declaration)) return null;
|
|
31
|
+
const members: TypeScript.ClassElement[] = [];
|
|
32
|
+
for (const member of declaration.members) {
|
|
33
|
+
if (decorated(member)) return null;
|
|
34
|
+
if (ts.isMethodDeclaration(member) || ts.isConstructorDeclaration(member)) {
|
|
35
|
+
if (member.parameters.some(parameter => !parameter.type || parameter.initializer || decorated(parameter))) return null;
|
|
36
|
+
if (ts.isMethodDeclaration(member)) {
|
|
37
|
+
if (!member.type || member.asteriskToken) return null;
|
|
38
|
+
members.push(ts.factory.updateMethodDeclaration(member, member.modifiers, member.asteriskToken,
|
|
39
|
+
member.name, member.questionToken, member.typeParameters, member.parameters, member.type, undefined));
|
|
40
|
+
} else members.push(ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined));
|
|
41
|
+
} else if (ts.isPropertyDeclaration(member)) {
|
|
42
|
+
if (!member.type) return null;
|
|
43
|
+
members.push(ts.factory.updatePropertyDeclaration(member, member.modifiers, member.name,
|
|
44
|
+
member.questionToken ?? member.exclamationToken, member.type, undefined));
|
|
45
|
+
} else return null;
|
|
46
|
+
}
|
|
47
|
+
return ts.factory.updateClassDeclaration(declaration, declaration.modifiers, declaration.name,
|
|
48
|
+
declaration.typeParameters, declaration.heritageClauses, members);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A deliberately bounded module-surface observation, not an API or behavior proof.
|
|
52
|
+
function surface(ts: typeof TypeScript, path: string, content: string): string | null {
|
|
53
|
+
const diagnostics = ts.transpileModule(content, { fileName: path, reportDiagnostics: true }).diagnostics ?? [];
|
|
54
|
+
if (diagnostics.some((item) => item.category === ts.DiagnosticCategory.Error)) return null;
|
|
55
|
+
const file = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true);
|
|
56
|
+
const printer = ts.createPrinter({ removeComments: true });
|
|
57
|
+
const declarations: string[] = [];
|
|
58
|
+
for (const statement of file.statements) {
|
|
59
|
+
if (ts.isExportDeclaration(statement) || ts.isExportAssignment(statement)) return null;
|
|
60
|
+
const exported = ts.canHaveModifiers(statement)
|
|
61
|
+
&& ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
62
|
+
if (!exported) continue;
|
|
63
|
+
let declaration: TypeScript.Node = statement;
|
|
64
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
65
|
+
if (!statement.type || statement.parameters.some((parameter) => !parameter.type || parameter.initializer)
|
|
66
|
+
|| statement.asteriskToken) return null;
|
|
67
|
+
declaration = ts.factory.updateFunctionDeclaration(statement, statement.modifiers, statement.asteriskToken,
|
|
68
|
+
statement.name, statement.typeParameters, statement.parameters, statement.type, undefined);
|
|
69
|
+
} else if (ts.isClassDeclaration(statement)) {
|
|
70
|
+
const observed = classSurface(ts, statement);
|
|
71
|
+
if (observed === null) return null;
|
|
72
|
+
declaration = observed;
|
|
73
|
+
} else if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
declarations.push(printer.printNode(ts.EmitHint.Unspecified, declaration, file));
|
|
77
|
+
}
|
|
78
|
+
return declarations.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class SnapshotRiskAdvisoryAdapter implements ChangeRiskAdvisoryPort {
|
|
82
|
+
constructor(private readonly rootDir: string = process.cwd()) {}
|
|
83
|
+
|
|
84
|
+
async assess(paths: readonly string[], snapshotFile: string): Promise<readonly ChangeRiskAdvice[]> {
|
|
85
|
+
const raw: unknown = JSON.parse(await readFile(resolve(this.rootDir, snapshotFile), 'utf8'));
|
|
86
|
+
if (!Array.isArray(raw) || !raw.every(isSnapshot)) {
|
|
87
|
+
throw new Error('Risk snapshots must be an array of { filePath, beforeContent: string|null, afterContent: string|null }.');
|
|
88
|
+
}
|
|
89
|
+
// No TypeScript runtime loading or source parsing occurs on the default classification path.
|
|
90
|
+
const ts = await import('typescript');
|
|
91
|
+
return paths.map((path) => {
|
|
92
|
+
const matches = raw.filter((snapshot) => snapshot.filePath === path);
|
|
93
|
+
const snapshot = matches.length === 1 ? matches[0] : undefined;
|
|
94
|
+
const before = snapshot?.beforeContent ?? null;
|
|
95
|
+
const after = snapshot?.afterContent ?? null;
|
|
96
|
+
const base = { path, source: 'caller-snapshot' as const, enforcement: 'none' as const,
|
|
97
|
+
beforeHash: hash(before), afterHash: hash(after) };
|
|
98
|
+
if (!snapshot || before === null || after === null) {
|
|
99
|
+
return { ...base, kind: 'unknown', reason: 'Missing, duplicate, creation or deletion snapshot: inspect the actual revision and affected contracts.' };
|
|
100
|
+
}
|
|
101
|
+
if (before === after) {
|
|
102
|
+
return { ...base, kind: 'no-content-change', reason: 'Only the supplied snapshots are identical; this is not approval or verification of the current revision.' };
|
|
103
|
+
}
|
|
104
|
+
if (!/\.[cm]?tsx?$/.test(path) || /\.d\.[cm]?ts$/.test(path)) {
|
|
105
|
+
return { ...base, kind: 'unknown', reason: 'Unsupported source language: inspect the contract and behavior changes.' };
|
|
106
|
+
}
|
|
107
|
+
const beforeSurface = surface(ts, path, before);
|
|
108
|
+
const afterSurface = surface(ts, path, after);
|
|
109
|
+
if (beforeSurface === null || afterSurface === null) {
|
|
110
|
+
return { ...base, kind: 'unknown', reason: 'Invalid or unsupported declaration: inspect exported contracts, authorization, invariants and persisted formats.' };
|
|
111
|
+
}
|
|
112
|
+
if (beforeSurface !== afterSurface) {
|
|
113
|
+
return { ...base, kind: 'module-surface-change', reason: 'Declared module surface changed. Confirm public consumers and escalate changes to agreed contracts before dependent work.' };
|
|
114
|
+
}
|
|
115
|
+
return { ...base, kind: 'behavior-review', reason: 'Declared module surface is unchanged, but behavior equivalence is unproven. Review authorization, business invariants and persisted formats; internal extraction is not automatically approved.' };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -37,6 +37,13 @@ export class ChangeCategoryFormatter {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
if (contract.riskAdvice !== undefined) {
|
|
41
|
+
lines.push('riskAdvice (caller snapshots; advisory only, existing gate unchanged):');
|
|
42
|
+
for (const advice of contract.riskAdvice) {
|
|
43
|
+
lines.push(` ${advice.path} -> ${advice.kind}: ${advice.reason}`);
|
|
44
|
+
lines.push(` before=${advice.beforeHash ?? 'unknown'} after=${advice.afterHash ?? 'unknown'}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
40
47
|
return lines.join('\n') + '\n';
|
|
41
48
|
}
|
|
42
49
|
}
|
|
@@ -7,17 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ClassifyChangeCategoryUseCase } from '../../application/usecases/classify-change-category-usecase.js';
|
|
10
|
+
import type { ChangeRiskAdvisoryPort } from '../../application/ports/change-risk-advisory-port.js';
|
|
10
11
|
import { ChangeCategoryFormatter, type ChangeCategoryOutputFormat } from '../formatters/change-category-formatter.js';
|
|
11
12
|
|
|
12
13
|
export interface CheckChangeCategoryHandlerDeps {
|
|
13
14
|
useCase: Pick<ClassifyChangeCategoryUseCase, 'execute'>;
|
|
14
15
|
writer?: (s: string) => void;
|
|
16
|
+
riskAdvisoryPort?: ChangeRiskAdvisoryPort;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface CheckChangeCategoryHandlerOptions {
|
|
18
20
|
paths?: string;
|
|
19
21
|
format?: ChangeCategoryOutputFormat;
|
|
20
22
|
failOnFullRequired?: boolean;
|
|
23
|
+
riskSnapshots?: string;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface CheckChangeCategoryHandlerResult {
|
|
@@ -28,9 +31,11 @@ export class CheckChangeCategoryHandler {
|
|
|
28
31
|
private readonly useCase: Pick<ClassifyChangeCategoryUseCase, 'execute'>;
|
|
29
32
|
private readonly writer: (s: string) => void;
|
|
30
33
|
private readonly formatter = new ChangeCategoryFormatter();
|
|
34
|
+
private readonly riskAdvisoryPort?: ChangeRiskAdvisoryPort;
|
|
31
35
|
|
|
32
36
|
constructor(deps: CheckChangeCategoryHandlerDeps) {
|
|
33
37
|
this.useCase = deps.useCase;
|
|
38
|
+
this.riskAdvisoryPort = deps.riskAdvisoryPort;
|
|
34
39
|
this.writer = deps.writer ?? ((s: string) => process.stdout.write(s));
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -41,7 +46,13 @@ export class CheckChangeCategoryHandler {
|
|
|
41
46
|
? pathsRaw.split(',').map((p) => p.trim()).filter((p) => p.length > 0)
|
|
42
47
|
: [];
|
|
43
48
|
|
|
44
|
-
const
|
|
49
|
+
const classification = await this.useCase.execute({ paths });
|
|
50
|
+
if (options.riskSnapshots !== undefined && this.riskAdvisoryPort === undefined) {
|
|
51
|
+
throw new Error('Risk advisory is not available in this handler configuration.');
|
|
52
|
+
}
|
|
53
|
+
const contract = options.riskSnapshots !== undefined
|
|
54
|
+
? { ...classification, riskAdvice: await this.riskAdvisoryPort!.assess(paths, options.riskSnapshots) }
|
|
55
|
+
: classification;
|
|
45
56
|
this.writer(this.formatter.format(contract, format));
|
|
46
57
|
|
|
47
58
|
const exitCode = failOnFullRequired && contract.fullModeRequired ? 1 : 0;
|