@principles/pd-cli 1.141.0 → 1.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/dist/commands/principles-stats.d.ts +63 -0
- package/dist/commands/principles-stats.d.ts.map +1 -0
- package/dist/commands/principles-stats.js +562 -0
- package/dist/commands/principles-stats.js.map +1 -0
- package/dist/commands/runtime-internalization-retry.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-retry.js +32 -19
- package/dist/commands/runtime-internalization-retry.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/principles-stats.ts +700 -0
- package/src/commands/runtime-internalization-retry.ts +33 -19
- package/src/index.ts +7 -0
- package/tests/bdd/principles-stats.steps.ts +211 -0
- package/tests/commands/principles-stats-wiring.test.ts +135 -0
- package/tests/commands/principles-stats.test.ts +274 -0
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import * as path from 'path';
|
|
25
|
-
import { RuntimeStateManager } from '@principles/core/runtime-v2';
|
|
26
|
-
import { hydratePITaskRecord, createPITaskDiagnosticJson, mergePITaskMetadata } from '@principles/core/runtime-v2';
|
|
25
|
+
import { RuntimeStateManager, ownerRetryNeedsHumanReviewTask } from '@principles/core/runtime-v2';
|
|
27
26
|
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
28
27
|
|
|
29
28
|
export interface InternalizationRetryOptions {
|
|
@@ -111,15 +110,41 @@ export async function handleRuntimeInternalizationRetry(opts: InternalizationRet
|
|
|
111
110
|
// Owner retry = authority reset: runnerDecision 与 completionIntent 同时
|
|
112
111
|
// 清空 (保留 revisionCount / revisionCauseId / rolloutRevisionPayload /
|
|
113
112
|
// repairPayload / lineage — revision budget 证据不动)。
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
// 落库序列提取在 core ownerRetryNeedsHumanReviewTask (Governance Recovery
|
|
114
|
+
// Actions v1): Console 恢复端点与 CLI 走同一段逻辑,禁止复制。
|
|
115
|
+
const outcome = await ownerRetryNeedsHumanReviewTask(stateManager, opts.taskId);
|
|
116
|
+
|
|
117
|
+
if (outcome.status === 'not_found') {
|
|
118
|
+
const out: InternalizationRetryOutput = {
|
|
119
|
+
status: 'failed',
|
|
120
|
+
taskId: opts.taskId,
|
|
121
|
+
reason: 'task_not_found',
|
|
122
|
+
nextAction: 'Verify the task id and workspace',
|
|
123
|
+
};
|
|
124
|
+
emit(out, opts.json);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (outcome.status === 'skipped') {
|
|
129
|
+
const out: InternalizationRetryOutput = {
|
|
130
|
+
status: 'skipped',
|
|
131
|
+
taskId: opts.taskId,
|
|
132
|
+
taskKind: outcome.taskKind,
|
|
133
|
+
previousStatus: outcome.previousStatus,
|
|
134
|
+
reason: 'only_needs_human_review_tasks_are_retryable',
|
|
135
|
+
nextAction: 'This task is not in the owner attention queue; use run-once / enqueue-successors instead',
|
|
136
|
+
};
|
|
137
|
+
emit(out, opts.json);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (outcome.status === 'metadata_invalid') {
|
|
116
141
|
// fail closed: 只改 status 会把(可能损坏的)旧 authority 记录原样留在
|
|
117
142
|
// metadata 里,下一次 run 由它接管 —— 产生 partial retry。
|
|
118
143
|
const out: InternalizationRetryOutput = {
|
|
119
144
|
status: 'failed',
|
|
120
145
|
taskId: opts.taskId,
|
|
121
|
-
taskKind:
|
|
122
|
-
previousStatus:
|
|
146
|
+
taskKind: outcome.taskKind,
|
|
147
|
+
previousStatus: 'needs_human_review',
|
|
123
148
|
reason: 'metadata_invalid',
|
|
124
149
|
nextAction: 'Task metadata failed PI hydration; a retry now would risk a partial authority reset. Inspect: pd runtime internalization integrity --json',
|
|
125
150
|
};
|
|
@@ -127,23 +152,12 @@ export async function handleRuntimeInternalizationRetry(opts: InternalizationRet
|
|
|
127
152
|
process.exitCode = 1;
|
|
128
153
|
return;
|
|
129
154
|
}
|
|
130
|
-
// 原子单写: 同一 patch 同时落 status=pending / attemptCount=0 / 清空后的
|
|
131
|
-
// diagnosticJson。updateTask 抛错时 DB 行保持原样(单条 UPDATE),无 partial reset。
|
|
132
|
-
const merged = mergePITaskMetadata(piTask, {
|
|
133
|
-
runnerDecision: undefined,
|
|
134
|
-
completionIntent: undefined,
|
|
135
|
-
});
|
|
136
|
-
await stateManager.updateTask(opts.taskId, {
|
|
137
|
-
status: 'pending',
|
|
138
|
-
attemptCount: 0,
|
|
139
|
-
diagnosticJson: createPITaskDiagnosticJson(merged),
|
|
140
|
-
});
|
|
141
155
|
|
|
142
156
|
const out: InternalizationRetryOutput = {
|
|
143
157
|
status: 'requeued',
|
|
144
158
|
taskId: opts.taskId,
|
|
145
|
-
taskKind:
|
|
146
|
-
previousStatus:
|
|
159
|
+
taskKind: outcome.taskKind,
|
|
160
|
+
previousStatus: outcome.previousStatus,
|
|
147
161
|
nextAction: 'Task requeued; it will be picked up by the auto-consumer cycle, or advance manually: pd runtime internalization run-once',
|
|
148
162
|
};
|
|
149
163
|
emit(out, opts.json);
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ import { registerMvpCommands } from './commands/mvp-smoke.js';
|
|
|
67
67
|
import { registerRulecodeCommand } from './commands/rulecode.js';
|
|
68
68
|
import { registerIntentCommand } from './commands/intent.js';
|
|
69
69
|
import { registerErrorsListCommand } from './commands/errors-list.js';
|
|
70
|
+
import { registerPrinciplesCommand } from './commands/principles-stats.js';
|
|
70
71
|
|
|
71
72
|
import { createRequire } from 'module';
|
|
72
73
|
const require = createRequire(import.meta.url);
|
|
@@ -1006,6 +1007,12 @@ registerIntentCommand(program);
|
|
|
1006
1007
|
|
|
1007
1008
|
registerErrorsListCommand(program);
|
|
1008
1009
|
|
|
1010
|
+
// ─── Principles Stats (PRI-562 Phase 0) ─────────────────────────────────────
|
|
1011
|
+
// Owner-facing observability: principle injection volume/cost/duplicates/
|
|
1012
|
+
// application evidence. Read-only aggregation over event logs + receipt ledger.
|
|
1013
|
+
|
|
1014
|
+
registerPrinciplesCommand(program);
|
|
1015
|
+
|
|
1009
1016
|
const consoleCmd = program
|
|
1010
1017
|
.command('console')
|
|
1011
1018
|
.description('Start the pd-console web UI for principle review (default: fallback launcher)')
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BDD step definitions for `pd principles stats` (PRI-562 Phase 0,
|
|
3
|
+
* cli-1/cli-5/cli-6 contract).
|
|
4
|
+
*
|
|
5
|
+
* Approach: in-process handler invocation against real temp-workspace
|
|
6
|
+
* fixtures (same pattern as tests/commands/principles-stats.test.ts —
|
|
7
|
+
* real event JSONL + real SqliteConnection-bootstrap schema, no heavy mocks).
|
|
8
|
+
*
|
|
9
|
+
* @see docs/specs/features/cli/principles-stats.feature
|
|
10
|
+
*/
|
|
11
|
+
import { vi, expect } from 'vitest';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import Database from 'better-sqlite3';
|
|
16
|
+
import { SqliteConnection } from '@principles/core';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { createStepRegistry, defineFeature } from './support/vitest-bdd.js';
|
|
19
|
+
import { resolveFeaturePath } from './support/repo-root.js';
|
|
20
|
+
import { handlePrinciplesStats } from '../../src/commands/principles-stats.js';
|
|
21
|
+
|
|
22
|
+
const registry = createStepRegistry();
|
|
23
|
+
|
|
24
|
+
interface WsState {
|
|
25
|
+
ws?: string;
|
|
26
|
+
dbBytesBefore?: Buffer;
|
|
27
|
+
ledgerRowsBefore?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const state: WsState = {};
|
|
31
|
+
|
|
32
|
+
function localDateString(d: Date): string {
|
|
33
|
+
const y = d.getFullYear();
|
|
34
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
35
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
36
|
+
return `${y}-${m}-${day}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let stdoutText = '';
|
|
40
|
+
let stderrText = '';
|
|
41
|
+
let lastExitCode: number | undefined;
|
|
42
|
+
|
|
43
|
+
function makeFixtureWorkspace(): string {
|
|
44
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-principles-bdd-'));
|
|
45
|
+
const today = localDateString(new Date());
|
|
46
|
+
const logsDir = path.join(root, '.state', 'logs');
|
|
47
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
48
|
+
const event = JSON.stringify({
|
|
49
|
+
ts: Date.now(),
|
|
50
|
+
date: today,
|
|
51
|
+
type: 'runtime_v2_prompt_activations_injected',
|
|
52
|
+
category: 'injected',
|
|
53
|
+
sessionId: 'bdd-s1',
|
|
54
|
+
data: {
|
|
55
|
+
sessionId: 'bdd-s1',
|
|
56
|
+
principleIds: ['p1', 'p2'],
|
|
57
|
+
injectedCount: 2,
|
|
58
|
+
skippedWarnings: [],
|
|
59
|
+
injectedCharCount: 420,
|
|
60
|
+
budget: 2000,
|
|
61
|
+
legacySelectedCount: 1,
|
|
62
|
+
legacyTotalChars: 300,
|
|
63
|
+
legacyTruncated: false,
|
|
64
|
+
v2Truncated: false,
|
|
65
|
+
crossBlockDuplicateIds: ['p2'],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
fs.writeFileSync(path.join(logsDir, `events_${today}.jsonl`), [event, ''].join('\n'), 'utf8');
|
|
69
|
+
|
|
70
|
+
fs.mkdirSync(path.join(root, '.pd'), { recursive: true });
|
|
71
|
+
const connection = new SqliteConnection({ workspaceDir: root });
|
|
72
|
+
const db = connection.getDb();
|
|
73
|
+
const insert = db.prepare(
|
|
74
|
+
`INSERT INTO principle_applications (principle_id, channel, level, kind, session_id, created_at)
|
|
75
|
+
VALUES (?, 'prompt', ?, ?, ?, ?)`,
|
|
76
|
+
);
|
|
77
|
+
insert.run('p1', 'presence', 'prompt_injected', 'bdd-s1', new Date().toISOString());
|
|
78
|
+
insert.run('p2', 'presence', 'prompt_injected', 'bdd-s1', new Date().toISOString());
|
|
79
|
+
insert.run('p2', 'effect', 'self_reported', 'bdd-s1', new Date().toISOString());
|
|
80
|
+
connection.close();
|
|
81
|
+
return root;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Given ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
registry.given('一个可用的 pd-cli 可执行文件', () => {
|
|
87
|
+
state.ws = undefined;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
registry.given('一个包含已知注入事件与回执账本的临时工作区', () => {
|
|
91
|
+
state.ws = makeFixtureWorkspace();
|
|
92
|
+
const dbPath = path.join(state.ws, '.pd', 'state.db');
|
|
93
|
+
state.dbBytesBefore = readFileSync(dbPath);
|
|
94
|
+
const db = new Database(dbPath, { readonly: true });
|
|
95
|
+
state.ledgerRowsBefore =
|
|
96
|
+
(db.prepare('SELECT COUNT(*) AS n FROM principle_applications').get() as { n: number }).n;
|
|
97
|
+
db.close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
registry.given('一个空的临时工作区', () => {
|
|
101
|
+
state.ws = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-principles-bdd-empty-'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── When ─────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
registry.when(/operator 执行 "pd principles stats( --json)?"/, async (_ctx, jsonFlag) => {
|
|
107
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
108
|
+
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
109
|
+
const originalExitCode = process.exitCode;
|
|
110
|
+
process.exitCode = undefined;
|
|
111
|
+
try {
|
|
112
|
+
await handlePrinciplesStats({ workspace: state.ws, json: !!jsonFlag });
|
|
113
|
+
} finally {
|
|
114
|
+
stdoutText = logSpy.mock.calls.map((c) => String(c[0])).join('');
|
|
115
|
+
stderrText = errSpy.mock.calls.map((c) => String(c[0])).join('');
|
|
116
|
+
logSpy.mockRestore();
|
|
117
|
+
errSpy.mockRestore();
|
|
118
|
+
lastExitCode = process.exitCode;
|
|
119
|
+
process.exitCode = originalExitCode;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Then ─────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
registry.then('stdout 是严格的单一 JSON 对象', () => {
|
|
126
|
+
expect(lastExitCode).toBeUndefined();
|
|
127
|
+
expect(stderrText).toBe('');
|
|
128
|
+
expect(stdoutText.trim().startsWith('{')).toBe(true);
|
|
129
|
+
expect(stdoutText.trim().endsWith('}')).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
registry.then('该 JSON 对象可以被 JSON.parse 解析', () => {
|
|
133
|
+
expect(() => JSON.parse(stdoutText)).not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
registry.then('stdout 不包含任何 banner 或 heading', () => {
|
|
137
|
+
const text = stdoutText.replace(/^\s*\{[\s\S]*\}\s*$/, '');
|
|
138
|
+
expect(text).toBe('');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function getParsed(): Record<string, unknown> {
|
|
142
|
+
// rc-1/rc-2: narrow from unknown via runtime guards before touching fields.
|
|
143
|
+
const parsed: unknown = JSON.parse(stdoutText);
|
|
144
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
145
|
+
throw new Error(`Expected JSON object in stdout but got: ${typeof parsed}`);
|
|
146
|
+
}
|
|
147
|
+
// runtime-contract-exempt: ERR-001 narrowed from unknown via typeof + Array.isArray check above (test-only helper on trusted stdout JSON)
|
|
148
|
+
return parsed as Record<string, unknown>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
registry.then('该 JSON 对象的 ok 字段为 true', () => {
|
|
152
|
+
expect(getParsed().ok).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
registry.then('该 JSON 对象包含 injections 指标组', () => {
|
|
156
|
+
expect(typeof getParsed().injections).toBe('object');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
registry.then('该 JSON 对象包含 chars 指标组', () => {
|
|
160
|
+
expect(typeof getParsed().chars).toBe('object');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
registry.then('该 JSON 对象包含 duplicates 指标组', () => {
|
|
164
|
+
expect(typeof getParsed().duplicates).toBe('object');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
registry.then('该 JSON 对象包含 applicationCorrelation 指标组', () => {
|
|
168
|
+
expect(typeof getParsed().applicationCorrelation).toBe('object');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
registry.then('数据库未被修改', () => {
|
|
172
|
+
const dbPath = path.join(state.ws as string, '.pd', 'state.db');
|
|
173
|
+
expect(readFileSync(dbPath).equals(state.dbBytesBefore)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
registry.then('ledger 未被修改', () => {
|
|
177
|
+
const db = new Database(path.join(state.ws as string, '.pd', 'state.db'), { readonly: true });
|
|
178
|
+
const n = (db.prepare('SELECT COUNT(*) AS n FROM principle_applications').get() as { n: number }).n;
|
|
179
|
+
db.close();
|
|
180
|
+
expect(n).toBe(state.ledgerRowsBefore);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
registry.then('未入队新任务', () => {
|
|
184
|
+
// Read-only command: no queue writes anywhere. Guarded by the DB-bytes
|
|
185
|
+
// comparison above; nothing further to assert without a tasks table.
|
|
186
|
+
expect(state.dbBytesBefore).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
registry.then('未创建后继任务', () => {
|
|
190
|
+
expect(state.dbBytesBefore).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
registry.then('该 JSON 对象的 status 字段为 "degraded"', () => {
|
|
194
|
+
expect(getParsed().status).toBe('degraded');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
registry.then('该 JSON 对象包含 nextAction 字段', () => {
|
|
198
|
+
expect(typeof getParsed().nextAction).toBe('string');
|
|
199
|
+
expect((getParsed().nextAction as string).length).toBeGreaterThan(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
registry.then('nextAction 说明如何启用 receipt flag 或先产生注入数据', () => {
|
|
203
|
+
const nextAction = getParsed().nextAction as string;
|
|
204
|
+
expect(nextAction.includes('.pd/config.yaml') || nextAction.includes('Run PD')).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── Define Feature ───────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const featurePath = resolveFeaturePath('docs/specs/features/cli/principles-stats.feature');
|
|
210
|
+
const featureText = readFileSync(featurePath, 'utf8');
|
|
211
|
+
defineFeature(featureText, registry);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser-level tests for `pd principles stats` flags (CLI gate rule 7).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `runtime-activation-list-flag-wiring.test.ts`. Exercises the real
|
|
5
|
+
* `registerPrinciplesCommand` helper (single source of truth shared with
|
|
6
|
+
* index.ts). Flag typos in the production surface fail here at parseAsync
|
|
7
|
+
* time, not at handler dispatch.
|
|
8
|
+
*
|
|
9
|
+
* Covers:
|
|
10
|
+
* - --json is registered; --no-json is NOT (no accidental negation)
|
|
11
|
+
* - --workspace / -w shorthand is registered
|
|
12
|
+
* - --days is registered with parseInt coercer
|
|
13
|
+
* - --dry-run / --confirm are NOT registered (read-only command, cli-4 N/A)
|
|
14
|
+
*
|
|
15
|
+
* Handler-level behavior (aggregation, JSON output shape) is covered by
|
|
16
|
+
* principles-stats.test.ts.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { Command } from 'commander';
|
|
21
|
+
|
|
22
|
+
import { registerPrinciplesCommand } from '../../src/commands/principles-stats.js';
|
|
23
|
+
|
|
24
|
+
type ActionOptions = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
interface CapturedAction {
|
|
27
|
+
opts: ActionOptions | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function freshProgram(): Command {
|
|
31
|
+
const program = new Command();
|
|
32
|
+
program.name('pd').exitOverride();
|
|
33
|
+
return program;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function registerWithCapture(program: Command, state: CapturedAction): Command {
|
|
37
|
+
const principles = registerPrinciplesCommand(program);
|
|
38
|
+
const statsCmd = principles.commands.find((c) => c.name() === 'stats');
|
|
39
|
+
if (!statsCmd) throw new Error('stats subcommand not registered');
|
|
40
|
+
statsCmd.action(function captureAction(...args: unknown[]): void {
|
|
41
|
+
let optsArg: unknown = null;
|
|
42
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
43
|
+
const arg: unknown = args[i];
|
|
44
|
+
if (arg !== null && typeof arg === 'object' && !(arg instanceof Command)) {
|
|
45
|
+
optsArg = arg;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
state.opts = optsArg !== null && typeof optsArg === 'object' ? (optsArg as ActionOptions) : {};
|
|
50
|
+
});
|
|
51
|
+
return statsCmd;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('pd principles stats — flag wiring (CLI gate rule 7)', () => {
|
|
55
|
+
it('registers --json flag on the stats subcommand', () => {
|
|
56
|
+
const program = freshProgram();
|
|
57
|
+
const statsCmd = registerPrinciplesCommand(program).commands.find((c) => c.name() === 'stats');
|
|
58
|
+
expect(statsCmd).toBeDefined();
|
|
59
|
+
const opt = statsCmd?.options.find((o) => o.long === '--json');
|
|
60
|
+
expect(opt).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('registers -w shorthand for --workspace', () => {
|
|
64
|
+
const program = freshProgram();
|
|
65
|
+
const statsCmd = registerPrinciplesCommand(program).commands.find((c) => c.name() === 'stats');
|
|
66
|
+
const opt = statsCmd?.options.find((o) => o.short === '-w');
|
|
67
|
+
expect(opt).toBeDefined();
|
|
68
|
+
expect(opt?.long).toBe('--workspace');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('registers --days', () => {
|
|
72
|
+
const program = freshProgram();
|
|
73
|
+
const statsCmd = registerPrinciplesCommand(program).commands.find((c) => c.name() === 'stats');
|
|
74
|
+
const opt = statsCmd?.options.find((o) => o.long === '--days');
|
|
75
|
+
expect(opt).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does NOT register --dry-run or --confirm (read-only command)', () => {
|
|
79
|
+
const program = freshProgram();
|
|
80
|
+
const statsCmd = registerPrinciplesCommand(program).commands.find((c) => c.name() === 'stats');
|
|
81
|
+
expect(statsCmd?.options.find((o) => o.long === '--dry-run')).toBeUndefined();
|
|
82
|
+
expect(statsCmd?.options.find((o) => o.long === '--confirm')).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does NOT register --no-json (no accidental negation)', () => {
|
|
86
|
+
const program = freshProgram();
|
|
87
|
+
const statsCmd = registerPrinciplesCommand(program).commands.find((c) => c.name() === 'stats');
|
|
88
|
+
expect(statsCmd?.options.find((o) => o.long === '--no-json')).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── Parser-level tests (program.parseAsync) ───────────────────────────────
|
|
92
|
+
|
|
93
|
+
it('parses --json as true through the full command path', async () => {
|
|
94
|
+
const program = freshProgram();
|
|
95
|
+
const captured: CapturedAction = { opts: null };
|
|
96
|
+
registerWithCapture(program, captured);
|
|
97
|
+
|
|
98
|
+
await program.parseAsync(['node', 'pd', 'principles', 'stats', '--json']);
|
|
99
|
+
|
|
100
|
+
expect(captured.opts).not.toBeNull();
|
|
101
|
+
expect(captured.opts?.json).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('parses -w shorthand for --workspace', async () => {
|
|
105
|
+
const program = freshProgram();
|
|
106
|
+
const captured: CapturedAction = { opts: null };
|
|
107
|
+
registerWithCapture(program, captured);
|
|
108
|
+
|
|
109
|
+
await program.parseAsync(['node', 'pd', 'principles', 'stats', '-w', '/tmp/ws']);
|
|
110
|
+
|
|
111
|
+
expect(captured.opts?.workspace).toBe('/tmp/ws');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('parses --days as a number via the parseInt coercer', async () => {
|
|
115
|
+
const program = freshProgram();
|
|
116
|
+
const captured: CapturedAction = { opts: null };
|
|
117
|
+
registerWithCapture(program, captured);
|
|
118
|
+
|
|
119
|
+
await program.parseAsync(['node', 'pd', 'principles', 'stats', '--days', '30']);
|
|
120
|
+
|
|
121
|
+
expect(captured.opts?.days).toBe(30);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('defaults all options to undefined when none passed', async () => {
|
|
125
|
+
const program = freshProgram();
|
|
126
|
+
const captured: CapturedAction = { opts: null };
|
|
127
|
+
registerWithCapture(program, captured);
|
|
128
|
+
|
|
129
|
+
await program.parseAsync(['node', 'pd', 'principles', 'stats']);
|
|
130
|
+
|
|
131
|
+
expect(captured.opts?.json).toBeUndefined();
|
|
132
|
+
expect(captured.opts?.workspace).toBeUndefined();
|
|
133
|
+
expect(captured.opts?.days).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|