phasegate 0.141.0 → 0.142.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 +13 -0
- package/README.ja.md +5 -2
- package/README.md +5 -2
- package/package.json +1 -1
- package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +1 -0
- package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +1 -0
- package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +1 -0
- package/scripts/harness/harness-api/domain/value-objects/drift-report-summary.ts +1 -0
- package/scripts/harness/harness-api/domain/value-objects/layer-health.ts +1 -0
- package/scripts/harness/main.ts +29 -0
- package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +30 -0
- package/scripts/harness/traceability-model/application/usecases/derive-work-item-status-usecase.ts +27 -0
- package/scripts/harness/traceability-model/composition-root.ts +20 -0
- package/scripts/harness/traceability-model/domain/ports/work-item-status-port.ts +16 -0
- package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts +121 -0
- package/scripts/harness/traceability-model/domain/value-objects/work-item-status-report.ts +47 -0
- package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +240 -0
- package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts +103 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.142.0] - 2026-05-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **WI-126 — work item status derivation** — `work-items:status --dry-run|--apply` を追加し、WI frontmatter `status` を inception / product reflection / implementation / test evidence から導出できるようにした。
|
|
15
|
+
- `--id WI-XXX` で単一 WI に絞り込み、`--fail-on-stale` で stale status を exit code 1 として検出できる。
|
|
16
|
+
- `--apply` は stale な `description.md` の YAML frontmatter `status:` 行だけを書き戻す。
|
|
17
|
+
- `fix` / `chore` の shortcut path は README / README.ja の説明と一致する。
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- publish 前回帰として、標準 L2 validator set に `L2-013` を含む現行契約と `preCommit.implementationExtensions` default をテスト期待値に反映した。
|
|
22
|
+
|
|
10
23
|
## [0.141.0] - 2026-05-09
|
|
11
24
|
|
|
12
25
|
### Fixed
|
package/README.ja.md
CHANGED
|
@@ -293,6 +293,7 @@ npx phasegate <command> [options]
|
|
|
293
293
|
| `baseline` | retrofit grandfather snapshot 生成(`--dry-run`, `--force`, `--paths <glob>`, `--json`) |
|
|
294
294
|
| `scaffold-design --unit <id> --phase <logical\|domain\|uiux\|unit-test\|it-test>` | 最小構成の設計文書を `templates/` から生成 |
|
|
295
295
|
| `phasegate:status` | 全体の健全性サマリ |
|
|
296
|
+
| `work-items:status --dry-run` / `--apply` | 成果物から WI status を導出し、必要に応じて `description.md` frontmatter を更新 |
|
|
296
297
|
| `phasegate:check-phase --unit <id>` | 指定 Unit の現在フェーズ |
|
|
297
298
|
| `phasegate:detect-drift` | 設計-コード乖離レポート |
|
|
298
299
|
| `migrate work-items --dry-run` / `--apply` | 既存リポジトリの旧 `ISSUE-XXX` / `H{NN}-{NN}` directory を WI 統一レイアウト(`_cross/{WI-XXX}/` / `{unit}/{WI-XXX}/`)へ移行。frontmatter(`type` / `severity` / `legacy_id` / `affects`)を自動注入。冪等。`--json` で CI/スクリプト連携可。詳細: [Work Item Migration](docs/guide/cli-reference.md#work-item-migration) |
|
|
@@ -406,6 +407,8 @@ TESTED (@work-item-id 付きテストあり / green)
|
|
|
406
407
|
|
|
407
408
|
`type: chore` は DRAFTED で完結。`type: fix` は DRAFTED → REFLECTED → IMPLEMENTED の簡略パス。`status` は PhaseGate が自動更新します。
|
|
408
409
|
|
|
410
|
+
`phasegate work-items:status --dry-run` で current status / derived status / reason / next action を確認できます。単一 WI に絞る場合は `--id WI-XXX`、CI 風に stale status を検出する場合は `--fail-on-stale`、`description.md` frontmatter の `status:` 行だけを書き戻す場合は `--apply` を指定します。
|
|
411
|
+
|
|
409
412
|
詳細仕様: [`docs/folder_management_rules.md`](docs/folder_management_rules.md)
|
|
410
413
|
|
|
411
414
|
---
|
|
@@ -456,9 +459,9 @@ reports/
|
|
|
456
459
|
|
|
457
460
|
| Work Item | 内容 |
|
|
458
461
|
|---|---|
|
|
459
|
-
| **[WI-
|
|
462
|
+
| **[WI-128](docs/inception/_cross/WI-128/description.md)** | L4 運用ロールアウトの仕上げ。`doc-freshness` / `pointer-validation` は L4-004 / L4-005 として登録済みで、`p2:*` 互換コマンドも維持。WI-033 は完了済みとして閉じ、残りの scheduling / default / 運用 docs は後続 WI で扱う。@work-item-id WI-128 |
|
|
460
463
|
|
|
461
|
-
L3 Nyquist Validation の `requirement-test-matrix.json`
|
|
464
|
+
L3 Nyquist Validation の `requirement-test-matrix.json` 自動生成はまだ未自動化です。[WI-125](docs/inception/_cross/WI-125/description.md) と L3 guide を参照してください。現時点では手動セットアップで利用できます。
|
|
462
465
|
|
|
463
466
|
---
|
|
464
467
|
|
package/README.md
CHANGED
|
@@ -247,6 +247,8 @@ TESTED (test files annotated with @work-item-id, all green)
|
|
|
247
247
|
|
|
248
248
|
`type: chore` ends at DRAFTED. `type: fix` shortcuts via DRAFTED → REFLECTED → IMPLEMENTED. PhaseGate auto-updates `status`.
|
|
249
249
|
|
|
250
|
+
Use `phasegate work-items:status --dry-run` to inspect current status, derived status, reason, and next action. Add `--id WI-XXX` to scope the report, `--fail-on-stale` for CI-style stale status detection, or `--apply` to update only the `status:` line in each stale `description.md` frontmatter.
|
|
251
|
+
|
|
250
252
|
Full spec: [`docs/folder_management_rules.md`](docs/folder_management_rules.md)
|
|
251
253
|
|
|
252
254
|
---
|
|
@@ -474,6 +476,7 @@ npx phasegate <command> [options]
|
|
|
474
476
|
| `p2:check-agent-context` | Check AGENTS.md / CLAUDE.md freshness |
|
|
475
477
|
| `update-skills` | Update skills to latest version |
|
|
476
478
|
| `phasegate:status` | Display overall harness health summary |
|
|
479
|
+
| `work-items:status --dry-run` / `--apply` | Derive WI status from artifacts and optionally update stale `description.md` frontmatter |
|
|
477
480
|
| `phasegate:check-phase --unit <id>` | Check current phase for a Unit |
|
|
478
481
|
| `check-change-category --paths <csv>` | Classify changed files into Quick Mode categories and report whether Full Mode is required (`--format json`, `--fail-on-full-required`) |
|
|
479
482
|
| `baseline` | Create `.phasegate/baseline.json` snapshot for Phase A-2 retrofit grandfather (`--dry-run`, `--force`, `--paths <glob,glob,...>`, `--json`). `baseline.enabled` defaults to `true` since v0.71.0. |
|
|
@@ -517,9 +520,9 @@ The main path is ready for project use, but a few documented behaviors still req
|
|
|
517
520
|
|
|
518
521
|
| Work Item | Title | Why it matters |
|
|
519
522
|
|---|---|---|
|
|
520
|
-
| **[WI-
|
|
523
|
+
| **[WI-128](docs/inception/_cross/WI-128/description.md)** | Finish L4 operational rollout | `doc-freshness` and `pointer-validation` are registered as L4-004/L4-005 and also remain available through `p2:*` compatibility commands. WI-033 is closed; remaining rollout polish around scheduling, defaults, and operational docs is tracked separately. @work-item-id WI-128 |
|
|
521
524
|
|
|
522
|
-
`requirement-test-matrix.json` auto-generation for L3 Nyquist Validation is not automated yet; see the L3 guide for the current manual setup.
|
|
525
|
+
`requirement-test-matrix.json` auto-generation for L3 Nyquist Validation is not automated yet; see [WI-125](docs/inception/_cross/WI-125/description.md) and the L3 guide for the current manual setup.
|
|
523
526
|
|
|
524
527
|
---
|
|
525
528
|
|
package/package.json
CHANGED
package/scripts/harness/main.ts
CHANGED
|
@@ -98,6 +98,7 @@ Commands:
|
|
|
98
98
|
disable-feature <name> Disable a harness feature
|
|
99
99
|
list-features List available features
|
|
100
100
|
migrate Migrate phasegate.config.json (--schema v3, --config <path>)
|
|
101
|
+
work-items:status Report or apply derived WI frontmatter status (--dry-run|--apply, --id, --fail-on-stale, --json)
|
|
101
102
|
|
|
102
103
|
render-errors Render harness errors (--format human|agent|ci)
|
|
103
104
|
validate-fix Validate fix examples (--code <code>)
|
|
@@ -306,6 +307,17 @@ Options:
|
|
|
306
307
|
"phasegate:status": `Usage: phasegate phasegate:status
|
|
307
308
|
|
|
308
309
|
Display harness status (enabled validators, schema version, hook deployment).`,
|
|
310
|
+
"work-items:status": `Usage: phasegate work-items:status (--dry-run|--apply) [options]
|
|
311
|
+
|
|
312
|
+
Derive WI frontmatter status from inception, product reflection, implementation, and test evidence.
|
|
313
|
+
|
|
314
|
+
Options:
|
|
315
|
+
--dry-run Print current status, derived status, reason, and next action.
|
|
316
|
+
--apply Update only the status line in each stale description.md frontmatter.
|
|
317
|
+
--id <WI-XXX> Limit report/apply to one work item.
|
|
318
|
+
--fail-on-stale Return exit code 1 when dry-run finds stale status.
|
|
319
|
+
--json Output machine-readable JSON.
|
|
320
|
+
--help, -h Show this help`,
|
|
309
321
|
"phasegate:detect-drift": `Usage: phasegate phasegate:detect-drift [options]
|
|
310
322
|
|
|
311
323
|
Run L4-001 drift detection between design documents and source code. WARNING: scans the project filesystem.
|
|
@@ -962,6 +974,23 @@ async function main(): Promise<void> {
|
|
|
962
974
|
break;
|
|
963
975
|
}
|
|
964
976
|
|
|
977
|
+
case "work-items:status": {
|
|
978
|
+
const mod = createTraceabilityModelModule(
|
|
979
|
+
rootDir,
|
|
980
|
+
toTraceabilityModelOptions(resolvedConfig),
|
|
981
|
+
);
|
|
982
|
+
const result = await mod.workItemStatusCommandHandler.execute({
|
|
983
|
+
dryRun: hasFlag(args, "--dry-run"),
|
|
984
|
+
apply: hasFlag(args, "--apply"),
|
|
985
|
+
failOnStale: hasFlag(args, "--fail-on-stale"),
|
|
986
|
+
id: parseFlag(args, "--id"),
|
|
987
|
+
json,
|
|
988
|
+
});
|
|
989
|
+
console.log(result.text);
|
|
990
|
+
process.exit(result.exitCode);
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
|
|
965
994
|
// ── phase-dependency-model ──
|
|
966
995
|
case "check-phase-gate": {
|
|
967
996
|
const phaseConfig = resolvedConfig ? toPhaseConfigSection(resolvedConfig) : undefined;
|
package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import type { WorkItemStatusPort } from "../../domain/ports/work-item-status-port.js";
|
|
6
|
+
import type { WorkItemStatusApplyResult } from "../../domain/value-objects/work-item-status-report.js";
|
|
7
|
+
import type { DeriveWorkItemStatusUseCase } from "./derive-work-item-status-usecase.js";
|
|
8
|
+
|
|
9
|
+
export interface ApplyWorkItemStatusUseCaseDeps {
|
|
10
|
+
readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
|
|
11
|
+
readonly workItemStatusPort: WorkItemStatusPort;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ApplyWorkItemStatusUseCase {
|
|
15
|
+
private readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
|
|
16
|
+
private readonly workItemStatusPort: WorkItemStatusPort;
|
|
17
|
+
|
|
18
|
+
constructor(deps: ApplyWorkItemStatusUseCaseDeps) {
|
|
19
|
+
this.deriveWorkItemStatusUseCase = deps.deriveWorkItemStatusUseCase;
|
|
20
|
+
this.workItemStatusPort = deps.workItemStatusPort;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async execute(input: { readonly id?: string } = {}): Promise<WorkItemStatusApplyResult> {
|
|
24
|
+
const reports = await this.deriveWorkItemStatusUseCase.execute();
|
|
25
|
+
const targetReports = input.id
|
|
26
|
+
? reports.filter((report) => report.id === input.id)
|
|
27
|
+
: reports;
|
|
28
|
+
return this.workItemStatusPort.applyDerivedStatuses(targetReports);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/scripts/harness/traceability-model/application/usecases/derive-work-item-status-usecase.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import type { WorkItemStatusPort } from "../../domain/ports/work-item-status-port.js";
|
|
6
|
+
import { WorkItemStatusDerivationService } from "../../domain/services/work-item-status-derivation-service.js";
|
|
7
|
+
import type { WorkItemStatusReport } from "../../domain/value-objects/work-item-status-report.js";
|
|
8
|
+
|
|
9
|
+
export interface DeriveWorkItemStatusUseCaseDeps {
|
|
10
|
+
readonly workItemStatusPort: WorkItemStatusPort;
|
|
11
|
+
readonly derivationService: WorkItemStatusDerivationService;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class DeriveWorkItemStatusUseCase {
|
|
15
|
+
private readonly workItemStatusPort: WorkItemStatusPort;
|
|
16
|
+
private readonly derivationService: WorkItemStatusDerivationService;
|
|
17
|
+
|
|
18
|
+
constructor(deps: DeriveWorkItemStatusUseCaseDeps) {
|
|
19
|
+
this.workItemStatusPort = deps.workItemStatusPort;
|
|
20
|
+
this.derivationService = deps.derivationService;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async execute(): Promise<readonly WorkItemStatusReport[]> {
|
|
24
|
+
const inputs = await this.workItemStatusPort.listWorkItemStatusInputs();
|
|
25
|
+
return Object.freeze(inputs.map((input) => this.derivationService.derive(input)));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ApplyWorkItemMigrationUseCase } from "./application/usecases/apply-work-item-migration-usecase.js";
|
|
10
|
+
import { ApplyWorkItemStatusUseCase } from "./application/usecases/apply-work-item-status-usecase.js";
|
|
11
|
+
import { DeriveWorkItemStatusUseCase } from "./application/usecases/derive-work-item-status-usecase.js";
|
|
10
12
|
import { PlanWorkItemMigrationUseCase } from "./application/usecases/plan-work-item-migration-usecase.js";
|
|
11
13
|
import { ValidateDesignStoryAnnotationsUseCase } from "./application/usecases/validate-design-story-annotations-usecase.js";
|
|
12
14
|
import { ValidateImplementationMetadataUseCase } from "./application/usecases/validate-implementation-metadata-usecase.js";
|
|
@@ -14,17 +16,20 @@ import { ValidateTestStoryMetadataUseCase } from "./application/usecases/validat
|
|
|
14
16
|
import { MetadataValidator } from "./domain/services/metadata-validator.js";
|
|
15
17
|
import { StoryIdAliasResolver } from "./domain/services/story-id-alias-resolver.js";
|
|
16
18
|
import { TraceabilityChainBuilder } from "./domain/services/traceability-chain-builder.js";
|
|
19
|
+
import { WorkItemStatusDerivationService } from "./domain/services/work-item-status-derivation-service.js";
|
|
17
20
|
import { ProjectRelativePath } from "./domain/value-objects/project-relative-path.js";
|
|
18
21
|
import { FileSystemInceptionPlanGateway } from "./infrastructure/gateways/file-system-inception-plan-gateway.js";
|
|
19
22
|
import { FileSystemMetadataReader } from "./infrastructure/gateways/file-system-metadata-reader.js";
|
|
20
23
|
import { FileSystemWorkItemMigrationApplyGateway } from "./infrastructure/gateways/file-system-work-item-migration-apply-gateway.js";
|
|
21
24
|
import { FileSystemWorkItemMigrationSourceGateway } from "./infrastructure/gateways/file-system-work-item-migration-source-gateway.js";
|
|
22
25
|
import { FileSystemWorkItemIdentityGateway } from "./infrastructure/gateways/file-system-work-item-identity-gateway.js";
|
|
26
|
+
import { FileSystemWorkItemStatusGateway } from "./infrastructure/gateways/file-system-work-item-status-gateway.js";
|
|
23
27
|
import { MarkdownDesignDocumentGateway } from "./infrastructure/gateways/markdown-design-document-gateway.js";
|
|
24
28
|
import { MarkdownStoryCatalogGateway } from "./infrastructure/gateways/markdown-story-catalog-gateway.js";
|
|
25
29
|
import { MarkdownUnitDefinitionGateway } from "./infrastructure/gateways/markdown-unit-definition-gateway.js";
|
|
26
30
|
import { MigrateWorkItemsCommandHandler } from "./presentation/cli/migrate-work-items-command-handler.js";
|
|
27
31
|
import { ValidateMetadataCommandHandler } from "./presentation/cli/validate-metadata-command-handler.js";
|
|
32
|
+
import { WorkItemStatusCommandHandler } from "./presentation/cli/work-item-status-command-handler.js";
|
|
28
33
|
|
|
29
34
|
export interface TraceabilityModelPathRoots {
|
|
30
35
|
readonly designDocsRoot: string;
|
|
@@ -63,6 +68,7 @@ export function createTraceabilityModelModule(
|
|
|
63
68
|
const workItemMigrationSource = new FileSystemWorkItemMigrationSourceGateway({ rootDir });
|
|
64
69
|
const workItemMigrationApply = new FileSystemWorkItemMigrationApplyGateway({ rootDir });
|
|
65
70
|
const workItemIdentity = new FileSystemWorkItemIdentityGateway({ rootDir });
|
|
71
|
+
const workItemStatus = new FileSystemWorkItemStatusGateway({ rootDir });
|
|
66
72
|
|
|
67
73
|
// Domain services
|
|
68
74
|
const metadataValidator = new MetadataValidator({
|
|
@@ -78,6 +84,7 @@ export function createTraceabilityModelModule(
|
|
|
78
84
|
storyCatalogPath,
|
|
79
85
|
});
|
|
80
86
|
const storyIdAliasResolver = new StoryIdAliasResolver(storyCatalog);
|
|
87
|
+
const workItemStatusDerivationService = new WorkItemStatusDerivationService();
|
|
81
88
|
|
|
82
89
|
// Usecases
|
|
83
90
|
const validateImplementationMetadataUseCase = new ValidateImplementationMetadataUseCase({
|
|
@@ -100,6 +107,14 @@ export function createTraceabilityModelModule(
|
|
|
100
107
|
planWorkItemMigrationUseCase,
|
|
101
108
|
applyPort: workItemMigrationApply,
|
|
102
109
|
});
|
|
110
|
+
const deriveWorkItemStatusUseCase = new DeriveWorkItemStatusUseCase({
|
|
111
|
+
workItemStatusPort: workItemStatus,
|
|
112
|
+
derivationService: workItemStatusDerivationService,
|
|
113
|
+
});
|
|
114
|
+
const applyWorkItemStatusUseCase = new ApplyWorkItemStatusUseCase({
|
|
115
|
+
deriveWorkItemStatusUseCase,
|
|
116
|
+
workItemStatusPort: workItemStatus,
|
|
117
|
+
});
|
|
103
118
|
|
|
104
119
|
// Presentation handlers
|
|
105
120
|
const validateMetadataCommandHandler = new ValidateMetadataCommandHandler({
|
|
@@ -112,10 +127,15 @@ export function createTraceabilityModelModule(
|
|
|
112
127
|
planWorkItemMigrationUseCase,
|
|
113
128
|
applyWorkItemMigrationUseCase,
|
|
114
129
|
});
|
|
130
|
+
const workItemStatusCommandHandler = new WorkItemStatusCommandHandler({
|
|
131
|
+
deriveWorkItemStatusUseCase,
|
|
132
|
+
applyWorkItemStatusUseCase,
|
|
133
|
+
});
|
|
115
134
|
|
|
116
135
|
return {
|
|
117
136
|
validateMetadataCommandHandler,
|
|
118
137
|
migrateWorkItemsCommandHandler,
|
|
138
|
+
workItemStatusCommandHandler,
|
|
119
139
|
// expose key services for cross-unit use
|
|
120
140
|
storyCatalog,
|
|
121
141
|
traceabilityChainBuilder,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
WorkItemStatusApplyResult,
|
|
7
|
+
WorkItemStatusInput,
|
|
8
|
+
WorkItemStatusReport,
|
|
9
|
+
} from "../value-objects/work-item-status-report.js";
|
|
10
|
+
|
|
11
|
+
export interface WorkItemStatusPort {
|
|
12
|
+
listWorkItemStatusInputs(): Promise<readonly WorkItemStatusInput[]>;
|
|
13
|
+
applyDerivedStatuses(
|
|
14
|
+
reports: readonly WorkItemStatusReport[],
|
|
15
|
+
): Promise<WorkItemStatusApplyResult>;
|
|
16
|
+
}
|
package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import type { WorkItemStatus } from "../value-objects/work-item-frontmatter.js";
|
|
6
|
+
import type {
|
|
7
|
+
WorkItemStatusInput,
|
|
8
|
+
WorkItemStatusReport,
|
|
9
|
+
} from "../value-objects/work-item-status-report.js";
|
|
10
|
+
|
|
11
|
+
const ORDER: Record<WorkItemStatus, number> = {
|
|
12
|
+
drafted: 0,
|
|
13
|
+
reflected: 1,
|
|
14
|
+
implemented: 2,
|
|
15
|
+
tested: 3,
|
|
16
|
+
completed: 3,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class WorkItemStatusDerivationService {
|
|
20
|
+
derive(input: WorkItemStatusInput): WorkItemStatusReport {
|
|
21
|
+
const derivedStatus = this.deriveStatus(input);
|
|
22
|
+
const currentStatus = input.frontmatter.status ?? "drafted";
|
|
23
|
+
const reason = this.reasonFor(input, derivedStatus);
|
|
24
|
+
const nextAction = this.nextActionFor(input, derivedStatus);
|
|
25
|
+
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
id: input.frontmatter.id,
|
|
28
|
+
type: input.frontmatter.type,
|
|
29
|
+
descriptionPath: input.descriptionPath,
|
|
30
|
+
currentStatus,
|
|
31
|
+
derivedStatus,
|
|
32
|
+
stale: currentStatus !== derivedStatus,
|
|
33
|
+
reason,
|
|
34
|
+
nextAction,
|
|
35
|
+
evidence: Object.freeze({
|
|
36
|
+
hasRequiredInceptionArtifacts: this.hasRequiredInceptionArtifacts(input),
|
|
37
|
+
reflectedUnits: Object.freeze([...this.reflectedUnits(input)]),
|
|
38
|
+
missingReflectionUnits: Object.freeze([...this.missingReflectionUnits(input)]),
|
|
39
|
+
implementationPaths: Object.freeze([...input.implementationPaths]),
|
|
40
|
+
testPaths: Object.freeze([...input.testPaths]),
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private deriveStatus(input: WorkItemStatusInput): WorkItemStatus {
|
|
46
|
+
const type = input.frontmatter.type;
|
|
47
|
+
if (type === "chore") return "drafted";
|
|
48
|
+
|
|
49
|
+
const reflected = this.missingReflectionUnits(input).length === 0;
|
|
50
|
+
const implemented = input.implementationPaths.length > 0;
|
|
51
|
+
const tested = input.testPaths.length > 0;
|
|
52
|
+
|
|
53
|
+
if (type === "fix") {
|
|
54
|
+
if (implemented) return "implemented";
|
|
55
|
+
if (reflected) return "reflected";
|
|
56
|
+
return "drafted";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (tested) return "tested";
|
|
60
|
+
if (implemented) return "implemented";
|
|
61
|
+
if (reflected) return "reflected";
|
|
62
|
+
return "drafted";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private reasonFor(input: WorkItemStatusInput, status: WorkItemStatus): string {
|
|
66
|
+
if (!this.hasRequiredInceptionArtifacts(input)) {
|
|
67
|
+
return `missing inception artifacts: ${this.missingInceptionArtifacts(input).join(", ")}`;
|
|
68
|
+
}
|
|
69
|
+
if (status === "tested") return "test evidence with @work-item-id exists";
|
|
70
|
+
if (status === "implemented") return "implementation evidence with @work-item-id exists";
|
|
71
|
+
if (status === "reflected") return "all affected units have product reflection";
|
|
72
|
+
if (input.frontmatter.type === "chore") return "chore work items complete at drafted";
|
|
73
|
+
return "product reflection is not complete";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private nextActionFor(input: WorkItemStatusInput, status: WorkItemStatus): string {
|
|
77
|
+
const missingArtifacts = this.missingInceptionArtifacts(input);
|
|
78
|
+
if (missingArtifacts.length > 0) {
|
|
79
|
+
return `create inception artifacts: ${missingArtifacts.join(", ")}`;
|
|
80
|
+
}
|
|
81
|
+
const missingUnits = this.missingReflectionUnits(input);
|
|
82
|
+
if (missingUnits.length > 0) {
|
|
83
|
+
return `reflect @work-item-id ${input.frontmatter.id} in product docs for: ${missingUnits.join(", ")}`;
|
|
84
|
+
}
|
|
85
|
+
if (input.frontmatter.type === "chore") return "no further status transition required";
|
|
86
|
+
if (status === "reflected") return `add implementation annotated with @work-item-id ${input.frontmatter.id}`;
|
|
87
|
+
if (status === "implemented" && input.frontmatter.type !== "fix") {
|
|
88
|
+
return `add tests annotated with @work-item-id ${input.frontmatter.id}`;
|
|
89
|
+
}
|
|
90
|
+
return "status is up to date";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private hasRequiredInceptionArtifacts(input: WorkItemStatusInput): boolean {
|
|
94
|
+
return this.missingInceptionArtifacts(input).length === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private missingInceptionArtifacts(input: WorkItemStatusInput): readonly string[] {
|
|
98
|
+
const existing = new Set(input.existingInceptionArtifacts);
|
|
99
|
+
return input.requiredInceptionArtifacts.filter((artifact) => !existing.has(artifact));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private reflectedUnits(input: WorkItemStatusInput): readonly string[] {
|
|
103
|
+
const reflected = new Set<string>();
|
|
104
|
+
for (const filePath of input.productReflectionPaths) {
|
|
105
|
+
const unit = this.extractConstructionUnit(filePath);
|
|
106
|
+
if (unit) reflected.add(unit);
|
|
107
|
+
}
|
|
108
|
+
return input.affectedUnits.filter((unit) => reflected.has(unit));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private missingReflectionUnits(input: WorkItemStatusInput): readonly string[] {
|
|
112
|
+
if (input.frontmatter.type === "chore") return [];
|
|
113
|
+
const reflected = new Set(this.reflectedUnits(input));
|
|
114
|
+
return input.affectedUnits.filter((unit) => !reflected.has(unit));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private extractConstructionUnit(filePath: string): string | null {
|
|
118
|
+
const match = /^docs\/product\/construction\/([^/]+)\//.exec(filePath);
|
|
119
|
+
return match?.[1] ?? null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
WorkItemFrontmatter,
|
|
7
|
+
WorkItemStatus,
|
|
8
|
+
WorkItemType,
|
|
9
|
+
} from "./work-item-frontmatter.js";
|
|
10
|
+
|
|
11
|
+
export interface WorkItemStatusEvidence {
|
|
12
|
+
readonly hasRequiredInceptionArtifacts: boolean;
|
|
13
|
+
readonly reflectedUnits: readonly string[];
|
|
14
|
+
readonly missingReflectionUnits: readonly string[];
|
|
15
|
+
readonly implementationPaths: readonly string[];
|
|
16
|
+
readonly testPaths: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkItemStatusInput {
|
|
20
|
+
readonly descriptionPath: string;
|
|
21
|
+
readonly directoryId: string;
|
|
22
|
+
readonly ownerUnit: string | null;
|
|
23
|
+
readonly frontmatter: WorkItemFrontmatter;
|
|
24
|
+
readonly requiredInceptionArtifacts: readonly string[];
|
|
25
|
+
readonly existingInceptionArtifacts: readonly string[];
|
|
26
|
+
readonly affectedUnits: readonly string[];
|
|
27
|
+
readonly productReflectionPaths: readonly string[];
|
|
28
|
+
readonly implementationPaths: readonly string[];
|
|
29
|
+
readonly testPaths: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkItemStatusReport {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly type: WorkItemType;
|
|
35
|
+
readonly descriptionPath: string;
|
|
36
|
+
readonly currentStatus: WorkItemStatus;
|
|
37
|
+
readonly derivedStatus: WorkItemStatus;
|
|
38
|
+
readonly stale: boolean;
|
|
39
|
+
readonly reason: string;
|
|
40
|
+
readonly nextAction: string;
|
|
41
|
+
readonly evidence: WorkItemStatusEvidence;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorkItemStatusApplyResult {
|
|
45
|
+
readonly updated: readonly WorkItemStatusReport[];
|
|
46
|
+
readonly unchanged: readonly WorkItemStatusReport[];
|
|
47
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer infrastructure
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import type { WorkItemStatusPort } from "../../domain/ports/work-item-status-port.js";
|
|
8
|
+
import type { WorkItemFrontmatter } from "../../domain/value-objects/work-item-frontmatter.js";
|
|
9
|
+
import type {
|
|
10
|
+
WorkItemStatusApplyResult,
|
|
11
|
+
WorkItemStatusInput,
|
|
12
|
+
WorkItemStatusReport,
|
|
13
|
+
} from "../../domain/value-objects/work-item-status-report.js";
|
|
14
|
+
import { parseWorkItemFrontmatter } from "../parsers/work-item-frontmatter-parser.js";
|
|
15
|
+
|
|
16
|
+
const WI_DIR_PATTERN = /^WI-\d+$/;
|
|
17
|
+
const MARKDOWN_PATTERN = /\.mdx?$/;
|
|
18
|
+
const SOURCE_PATTERN = /\.(?:ts|tsx|js|jsx)$/;
|
|
19
|
+
const SKIPPED_DIRS = new Set(["archive", "_shared", "_operation", "share", "node_modules", ".git"]);
|
|
20
|
+
|
|
21
|
+
export interface FileSystemWorkItemStatusGatewayDeps {
|
|
22
|
+
readonly rootDir: string;
|
|
23
|
+
readonly inceptionRoot?: string;
|
|
24
|
+
readonly productRoot?: string;
|
|
25
|
+
readonly sourceRoot?: string;
|
|
26
|
+
readonly testRoot?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FileSystemWorkItemStatusGateway implements WorkItemStatusPort {
|
|
30
|
+
private readonly rootDir: string;
|
|
31
|
+
private readonly inceptionRoot: string;
|
|
32
|
+
private readonly productRoot: string;
|
|
33
|
+
private readonly sourceRoot: string;
|
|
34
|
+
private readonly testRoot: string;
|
|
35
|
+
|
|
36
|
+
constructor(deps: FileSystemWorkItemStatusGatewayDeps) {
|
|
37
|
+
this.rootDir = deps.rootDir;
|
|
38
|
+
this.inceptionRoot = deps.inceptionRoot ?? "docs/inception";
|
|
39
|
+
this.productRoot = deps.productRoot ?? "docs/product";
|
|
40
|
+
this.sourceRoot = deps.sourceRoot ?? "scripts/harness";
|
|
41
|
+
this.testRoot = deps.testRoot ?? "scripts/harness/__tests__";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async listWorkItemStatusInputs(): Promise<readonly WorkItemStatusInput[]> {
|
|
45
|
+
const entries = await this.listDescriptions(this.inceptionRoot);
|
|
46
|
+
const productFiles = await this.listFiles(this.productRoot, MARKDOWN_PATTERN);
|
|
47
|
+
const sourceFiles = (await this.listFiles(this.sourceRoot, SOURCE_PATTERN)).filter(
|
|
48
|
+
(filePath) => !filePath.startsWith(this.testRoot),
|
|
49
|
+
);
|
|
50
|
+
const testFiles = await this.listFiles(this.testRoot, SOURCE_PATTERN);
|
|
51
|
+
|
|
52
|
+
const inputs: WorkItemStatusInput[] = [];
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const content = await readFile(path.join(this.rootDir, entry.descriptionPath), "utf8");
|
|
55
|
+
const frontmatter = parseWorkItemFrontmatter(content);
|
|
56
|
+
if (frontmatter === null) continue;
|
|
57
|
+
|
|
58
|
+
const aliases = this.aliasesFor(frontmatter);
|
|
59
|
+
const productReflectionPaths = await this.filesContainingAny(productFiles, aliases);
|
|
60
|
+
const implementationPaths = await this.filesContainingAny(sourceFiles, aliases);
|
|
61
|
+
const testPaths = await this.filesContainingAny(testFiles, aliases);
|
|
62
|
+
const wiDir = path.posix.dirname(entry.descriptionPath);
|
|
63
|
+
const existingInceptionArtifacts = await this.existingInceptionArtifacts(wiDir);
|
|
64
|
+
|
|
65
|
+
inputs.push(Object.freeze({
|
|
66
|
+
descriptionPath: entry.descriptionPath,
|
|
67
|
+
directoryId: entry.directoryId,
|
|
68
|
+
ownerUnit: entry.ownerUnit,
|
|
69
|
+
frontmatter,
|
|
70
|
+
requiredInceptionArtifacts: this.requiredInceptionArtifacts(frontmatter),
|
|
71
|
+
existingInceptionArtifacts,
|
|
72
|
+
affectedUnits: this.affectedUnits(entry.ownerUnit, frontmatter),
|
|
73
|
+
productReflectionPaths,
|
|
74
|
+
implementationPaths,
|
|
75
|
+
testPaths,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze(inputs.sort((a, b) => a.descriptionPath.localeCompare(b.descriptionPath)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async applyDerivedStatuses(
|
|
82
|
+
reports: readonly WorkItemStatusReport[],
|
|
83
|
+
): Promise<WorkItemStatusApplyResult> {
|
|
84
|
+
const updated: WorkItemStatusReport[] = [];
|
|
85
|
+
const unchanged: WorkItemStatusReport[] = [];
|
|
86
|
+
for (const report of reports) {
|
|
87
|
+
if (!report.stale) {
|
|
88
|
+
unchanged.push(report);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const absolutePath = path.join(this.rootDir, report.descriptionPath);
|
|
92
|
+
const content = await readFile(absolutePath, "utf8");
|
|
93
|
+
const nextContent = this.replaceStatusLine(content, report.derivedStatus);
|
|
94
|
+
await writeFile(absolutePath, nextContent, "utf8");
|
|
95
|
+
updated.push(report);
|
|
96
|
+
}
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
updated: Object.freeze(updated),
|
|
99
|
+
unchanged: Object.freeze(unchanged),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async listDescriptions(relativeDir: string): Promise<readonly {
|
|
104
|
+
readonly descriptionPath: string;
|
|
105
|
+
readonly directoryId: string;
|
|
106
|
+
readonly ownerUnit: string | null;
|
|
107
|
+
}[]> {
|
|
108
|
+
const results: {
|
|
109
|
+
descriptionPath: string;
|
|
110
|
+
directoryId: string;
|
|
111
|
+
ownerUnit: string | null;
|
|
112
|
+
}[] = [];
|
|
113
|
+
await this.collectDescriptions(relativeDir, results);
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async collectDescriptions(
|
|
118
|
+
relativeDir: string,
|
|
119
|
+
results: { descriptionPath: string; directoryId: string; ownerUnit: string | null }[],
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(path.join(this.rootDir, relativeDir), { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isDirectory() || SKIPPED_DIRS.has(entry.name)) continue;
|
|
130
|
+
const childDir = path.posix.join(relativeDir, entry.name);
|
|
131
|
+
if (WI_DIR_PATTERN.test(entry.name)) {
|
|
132
|
+
results.push({
|
|
133
|
+
descriptionPath: path.posix.join(childDir, "description.md"),
|
|
134
|
+
directoryId: entry.name,
|
|
135
|
+
ownerUnit: this.ownerUnitFor(childDir),
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
await this.collectDescriptions(childDir, results);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private ownerUnitFor(wiDir: string): string | null {
|
|
144
|
+
const relative = wiDir.slice(this.inceptionRoot.length).replace(/^\/+/, "");
|
|
145
|
+
const first = relative.split("/")[0];
|
|
146
|
+
if (!first || first === "_cross") return null;
|
|
147
|
+
return first;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async listFiles(relativeDir: string, pattern: RegExp): Promise<readonly string[]> {
|
|
151
|
+
const results: string[] = [];
|
|
152
|
+
await this.collectFiles(relativeDir, pattern, results);
|
|
153
|
+
return Object.freeze(results.sort());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async collectFiles(relativeDir: string, pattern: RegExp, results: string[]): Promise<void> {
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = await readdir(path.join(this.rootDir, relativeDir), { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
165
|
+
const childPath = path.posix.join(relativeDir, entry.name);
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
await this.collectFiles(childPath, pattern, results);
|
|
168
|
+
} else if (entry.isFile() && pattern.test(entry.name)) {
|
|
169
|
+
results.push(childPath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async filesContainingAny(files: readonly string[], aliases: readonly string[]): Promise<readonly string[]> {
|
|
175
|
+
const results: string[] = [];
|
|
176
|
+
for (const filePath of files) {
|
|
177
|
+
const content = await readFile(path.join(this.rootDir, filePath), "utf8");
|
|
178
|
+
if (aliases.some((alias) => this.containsWorkItemAnnotation(content, alias))) {
|
|
179
|
+
results.push(filePath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return Object.freeze(results);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private containsWorkItemAnnotation(content: string, alias: string): boolean {
|
|
186
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
187
|
+
return new RegExp(`@(work-item-id|story-id|issue-id|story)\\s+[^\\n]*\\b${escaped}\\b`).test(content);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private aliasesFor(frontmatter: WorkItemFrontmatter): readonly string[] {
|
|
191
|
+
return Object.freeze([frontmatter.id, frontmatter.legacyId].filter((value): value is string => Boolean(value)));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private requiredInceptionArtifacts(frontmatter: WorkItemFrontmatter): readonly string[] {
|
|
195
|
+
switch (frontmatter.type) {
|
|
196
|
+
case "story":
|
|
197
|
+
return Object.freeze(["description.md", "logical_design.md", "domain_model.md", "unit_test_design.md"]);
|
|
198
|
+
case "issue":
|
|
199
|
+
return Object.freeze(["description.md", "logical_design.md", "domain_model.md"]);
|
|
200
|
+
case "refactor":
|
|
201
|
+
return Object.freeze(["description.md", "logical_design.md"]);
|
|
202
|
+
case "fix":
|
|
203
|
+
case "chore":
|
|
204
|
+
return Object.freeze(["description.md"]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async existingInceptionArtifacts(wiDir: string): Promise<readonly string[]> {
|
|
209
|
+
try {
|
|
210
|
+
const entries = await readdir(path.join(this.rootDir, wiDir), { withFileTypes: true });
|
|
211
|
+
return Object.freeze(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
212
|
+
} catch {
|
|
213
|
+
return Object.freeze([]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private affectedUnits(ownerUnit: string | null, frontmatter: WorkItemFrontmatter): readonly string[] {
|
|
218
|
+
if (frontmatter.affects && frontmatter.affects.length > 0) {
|
|
219
|
+
return Object.freeze([...frontmatter.affects]);
|
|
220
|
+
}
|
|
221
|
+
if (ownerUnit) return Object.freeze([ownerUnit]);
|
|
222
|
+
return Object.freeze([]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private replaceStatusLine(content: string, status: string): string {
|
|
226
|
+
if (!content.startsWith("---")) {
|
|
227
|
+
throw new Error("description.md frontmatter is missing");
|
|
228
|
+
}
|
|
229
|
+
const frontmatterEnd = content.indexOf("\n---", 4);
|
|
230
|
+
if (frontmatterEnd === -1) {
|
|
231
|
+
throw new Error("description.md frontmatter is not closed");
|
|
232
|
+
}
|
|
233
|
+
const frontmatter = content.slice(0, frontmatterEnd);
|
|
234
|
+
if (!/(\nstatus:\s*)[^\n]+/.test(frontmatter)) {
|
|
235
|
+
throw new Error("description.md frontmatter status is missing");
|
|
236
|
+
}
|
|
237
|
+
const nextFrontmatter = frontmatter.replace(/(\nstatus:\s*)[^\n]+/, `$1${status}`);
|
|
238
|
+
return nextFrontmatter + content.slice(frontmatterEnd);
|
|
239
|
+
}
|
|
240
|
+
}
|
package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @unit traceability-model
|
|
2
|
+
// @layer presentation
|
|
3
|
+
// @work-item-id WI-126
|
|
4
|
+
|
|
5
|
+
import type { ApplyWorkItemStatusUseCase } from "../../application/usecases/apply-work-item-status-usecase.js";
|
|
6
|
+
import type { DeriveWorkItemStatusUseCase } from "../../application/usecases/derive-work-item-status-usecase.js";
|
|
7
|
+
import type {
|
|
8
|
+
WorkItemStatusApplyResult,
|
|
9
|
+
WorkItemStatusReport,
|
|
10
|
+
} from "../../domain/value-objects/work-item-status-report.js";
|
|
11
|
+
|
|
12
|
+
export interface WorkItemStatusCommandInput {
|
|
13
|
+
readonly dryRun?: boolean;
|
|
14
|
+
readonly apply?: boolean;
|
|
15
|
+
readonly json?: boolean;
|
|
16
|
+
readonly failOnStale?: boolean;
|
|
17
|
+
readonly id?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkItemStatusCommandOutput {
|
|
21
|
+
readonly exitCode: 0 | 1 | 2;
|
|
22
|
+
readonly text: string;
|
|
23
|
+
readonly reports: readonly WorkItemStatusReport[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkItemStatusCommandHandlerDeps {
|
|
27
|
+
readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
|
|
28
|
+
readonly applyWorkItemStatusUseCase: Pick<ApplyWorkItemStatusUseCase, "execute">;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class WorkItemStatusCommandHandler {
|
|
32
|
+
private readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
|
|
33
|
+
private readonly applyWorkItemStatusUseCase: Pick<ApplyWorkItemStatusUseCase, "execute">;
|
|
34
|
+
|
|
35
|
+
constructor(deps: WorkItemStatusCommandHandlerDeps) {
|
|
36
|
+
this.deriveWorkItemStatusUseCase = deps.deriveWorkItemStatusUseCase;
|
|
37
|
+
this.applyWorkItemStatusUseCase = deps.applyWorkItemStatusUseCase;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async execute(input: WorkItemStatusCommandInput): Promise<Readonly<WorkItemStatusCommandOutput>> {
|
|
41
|
+
if (input.apply && input.dryRun) {
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
exitCode: 2,
|
|
44
|
+
text: "Error: --dry-run and --apply cannot be used together",
|
|
45
|
+
reports: Object.freeze([]),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (!input.apply && !input.dryRun) {
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
exitCode: 2,
|
|
51
|
+
text: "Error: either --dry-run or --apply is required",
|
|
52
|
+
reports: Object.freeze([]),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (input.apply) {
|
|
57
|
+
const result = await this.applyWorkItemStatusUseCase.execute({ id: input.id });
|
|
58
|
+
const reports = Object.freeze([...result.updated, ...result.unchanged]);
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
exitCode: 0,
|
|
61
|
+
text: input.json ? JSON.stringify(result, null, 2) : this.formatApply(result),
|
|
62
|
+
reports,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const allReports = await this.deriveWorkItemStatusUseCase.execute();
|
|
67
|
+
const reports = input.id
|
|
68
|
+
? allReports.filter((report) => report.id === input.id)
|
|
69
|
+
: allReports;
|
|
70
|
+
const stale = reports.some((report) => report.stale);
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
exitCode: stale && input.failOnStale ? 1 : 0,
|
|
73
|
+
text: input.json ? JSON.stringify({ reports }, null, 2) : this.formatReports(reports),
|
|
74
|
+
reports,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private formatReports(reports: readonly WorkItemStatusReport[]): string {
|
|
79
|
+
const lines = ["Work item status report"];
|
|
80
|
+
for (const report of reports) {
|
|
81
|
+
const marker = report.stale ? "STALE" : "OK";
|
|
82
|
+
lines.push(
|
|
83
|
+
`[${marker}] ${report.id}: current=${report.currentStatus} derived=${report.derivedStatus}`,
|
|
84
|
+
` path: ${report.descriptionPath}`,
|
|
85
|
+
` reason: ${report.reason}`,
|
|
86
|
+
` next: ${report.nextAction}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private formatApply(result: WorkItemStatusApplyResult): string {
|
|
93
|
+
const lines = ["Work item status apply"];
|
|
94
|
+
for (const report of result.updated) {
|
|
95
|
+
lines.push(`updated ${report.id}: ${report.currentStatus} -> ${report.derivedStatus}`);
|
|
96
|
+
}
|
|
97
|
+
if (result.updated.length === 0) {
|
|
98
|
+
lines.push("no updates required");
|
|
99
|
+
}
|
|
100
|
+
lines.push(`unchanged: ${result.unchanged.length}`);
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
}
|