promptex-js 1.0.0 → 1.1.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/cli/main.js CHANGED
@@ -62,13 +62,17 @@ import { homedir } from 'node:os';
62
62
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
63
63
  import { pathToFileURL } from 'node:url';
64
64
  // 診斷的人讀格式化以靜態 import 取得,不像本檔其餘 SDK 用法那樣延後到用時
65
- // 才 `await import`:`reportOpsError` 的呼叫點多在同步函式內(如 `invokeCore`
65
+ // 才 `await import`:`failOps` 的呼叫點多在同步函式內(如 `invokeCore`
66
66
  // 的 catch),非同步載入會逼那條錯誤路徑跟著改成非同步。本檔的每個動詞本來
67
67
  // 就會載入 `dist/`,提前到模組載入時不多付一次成本。
68
68
  import { declaredSchema, formatDiagnostic, readConfig, REFUSAL_MESSAGES, RefusalError, setLocale, t, writeConfig } from '../index.js';
69
69
  import { collectSourceFiles, compile, describeLoadFailure, dumpHandoff, FRAMEWORK_VERSION, parseProvenanceEnv, SourceDirFailure } from './compile-engine.js';
70
70
  import { runInit as runInitViaCore } from './init.js';
71
71
  import { loadNativeCore as loadNative } from '../native.js';
72
+ import { configureErrors, failWith, scanArgv } from './error-output.js';
73
+ import { FORMAT_VERSION } from '../cli-contract.generated.js';
74
+ import { firstRejectedOutside, firstViolatedRule, flagSpec, isStandaloneVerb, projectVerbs } from './flag-table.js';
75
+ import { runVersion } from './version-command.js';
72
76
  import { scratchDir } from './scratch.js';
73
77
  import { writeStructuredLine } from './stdout.js';
74
78
  import { resolveWatchTarget, startWatchLoop } from './watch.js';
@@ -78,7 +82,7 @@ import { CLI_SHIM } from '../paths.js';
78
82
  // 是它的旗標;`manifest`(落點查詢)與 `init`(專案初始化)各為獨立指令,兩者
79
83
  // 呼叫的框架能力與建置的任一旗標組合互不重疊,故不構成別名(AC-132)。舊的
80
84
  // `install`/`plan`/`watch` 不再是指令名,一律當未知指令拒絕。
81
- const COMMANDS = ['build', 'manifest', 'init'];
85
+ // 認得哪些指令由旗標定義表決定(見 `flag-table.ts` 的 `projectVerbs`),這裡不再另存一份清單。
82
86
  /**
83
87
  * 命令列的本次呼叫狀態:全部由 `main()` 依 argv 賦值,模組層只宣告。
84
88
  *
@@ -196,10 +200,10 @@ const configDeclareTargetMissing = () => t('cli.config.declare_target_missing');
196
200
  * 定義位置在 `config` 分派之前:那條路徑在動詞分派前提前結束行程(見
197
201
  * `dispatchConfig`),常數若留在原本的位置會落在暫時性死區。
198
202
  */
199
- const PLATFORM_UNUSED_KEYS = {
200
- configMode: 'cli.platform_unused.units',
201
- init: 'cli.platform_unused.init',
202
- config: 'cli.platform_unused.config',
203
+ const PLATFORM_UNUSED_CODES = {
204
+ configMode: 'USAGE_PLATFORM_UNUSED_IN_UNIT_MODE',
205
+ init: 'USAGE_PLATFORM_UNUSED_BY_INIT',
206
+ config: 'USAGE_PLATFORM_UNUSED_BY_CONFIG',
203
207
  };
204
208
  /** 尚未解析時的預設值:本檔被 import 而不呼叫 `main()` 時,`parsedArgs` 停在這裡。 */
205
209
  const EMPTY_ARGS = {
@@ -219,6 +223,31 @@ const EMPTY_ARGS = {
219
223
  ops: null,
220
224
  apply: false,
221
225
  locale: null,
226
+ given: new Set(),
227
+ };
228
+ /**
229
+ * 旗標的值寫進 `CliArgs` 的哪個欄位。旗標認不認得、帶不帶值由定義表決定
230
+ * (`flag-table.ts`),表不管欄位;表新增旗標時這裡也要補一筆,`tests/cli-flag-table.test.ts`
231
+ * 會核對每個旗標都有對應。
232
+ */
233
+ export const VALUE_FIELDS = {
234
+ '--out': 'out',
235
+ '--platform': 'platform',
236
+ '--unit': 'unit',
237
+ '--lang': 'lang',
238
+ '--ops': 'ops',
239
+ '--locale': 'locale',
240
+ '--export': 'exportDir',
241
+ };
242
+ /** 不帶值的旗標寫進 `CliArgs` 的哪個布林欄位。 */
243
+ export const SWITCH_FIELDS = {
244
+ '--yes': 'assumeYes',
245
+ '--json': 'json',
246
+ '--content': 'content',
247
+ '--install': 'install',
248
+ '--dry-run': 'dryRun',
249
+ '--watch': 'watch',
250
+ '--apply': 'apply',
222
251
  };
223
252
  /**
224
253
  * 解析 `<動詞> <目錄> [--out o] [--platform p] [--unit u] [--lang l] [--yes]`。
@@ -239,34 +268,30 @@ const EMPTY_ARGS = {
239
268
  * 會被監看的求值子行程 import 而不呼叫 `main()`。是否結束由呼叫端決定。
240
269
  */
241
270
  function parseCliArgs(argv) {
242
- const args = { ...EMPTY_ARGS };
243
- const VALUED = { '--out': 'out', '--platform': 'platform', '--unit': 'unit', '--lang': 'lang', '--ops': 'ops', '--locale': 'locale', '--export': 'exportDir' };
271
+ const given = new Set();
272
+ const args = { ...EMPTY_ARGS, given };
244
273
  let i = 0;
245
274
  while (i < argv.length) {
246
275
  const arg = argv[i];
247
276
  i += 1;
248
- if (Object.hasOwn(VALUED, arg)) {
249
- if (i >= argv.length)
250
- return { args, error: t('cli.flag.missing_value', { flag: arg }) };
251
- args[VALUED[arg]] = argv[i];
252
- i += 1;
277
+ // 旗標一律先解析、不分動詞:對不適用的動詞靜默接受是 1.0.x 的行為,1.x 照舊,
278
+ // 只有定義表標了 `rejectOutside` 的旗標由 `settleBuildMode` 擋下。
279
+ // compat(remove-in=2.0): silent-inapplicable-flag
280
+ const spec = flagSpec(arg);
281
+ if (spec !== undefined) {
282
+ if (spec.takesValue) {
283
+ if (i >= argv.length)
284
+ return { args, error: { code: 'USAGE_FLAG_MISSING_VALUE', details: { flag: arg } } };
285
+ args[VALUE_FIELDS[arg]] = argv[i];
286
+ i += 1;
287
+ }
288
+ else {
289
+ args[SWITCH_FIELDS[arg]] = true;
290
+ }
291
+ given.add(arg);
253
292
  }
254
- else if (arg === '--yes')
255
- args.assumeYes = true;
256
- else if (arg === '--json')
257
- args.json = true;
258
- else if (arg === '--content')
259
- args.content = true;
260
- else if (arg === '--install')
261
- args.install = true;
262
- else if (arg === '--dry-run')
263
- args.dryRun = true;
264
- else if (arg === '--watch')
265
- args.watch = true;
266
- else if (arg === '--apply')
267
- args.apply = true;
268
293
  else if (arg.startsWith('--'))
269
- return { args, error: `${t('cli.flag.unknown', { flag: arg })}\n${usage()}` };
294
+ return { args, error: { code: 'USAGE_UNKNOWN_FLAG', details: { flag: arg } } };
270
295
  else if (args.positional === null)
271
296
  args.positional = arg;
272
297
  // 只有 `config declare` 收第二個位置參數(套件名):它不是旗標,而是那個子動作的
@@ -275,7 +300,7 @@ function parseCliArgs(argv) {
275
300
  else if (configAction === 'declare' && args.declareTarget === null)
276
301
  args.declareTarget = arg;
277
302
  else
278
- return { args, error: `${t('cli.positional.surplus', { arg })}\n${usage()}` };
303
+ return { args, error: { code: 'USAGE_SURPLUS_ARGUMENT', details: { arg } } };
279
304
  }
280
305
  return { args: settleDeclarePositionals(args), error: null };
281
306
  }
@@ -290,25 +315,15 @@ function parseCliArgs(argv) {
290
315
  * 只有一個落點;`--dry-run` 搭 `--watch` 阻斷:試跑的輸出在監看迴圈裡沒有讀者。
291
316
  */
292
317
  function settleBuildMode(commandName, args) {
293
- if (commandName !== 'build') {
294
- const given = [
295
- [args.install, '--install'],
296
- [args.dryRun, '--dry-run'],
297
- [args.exportDir !== null, '--export'],
298
- [args.watch, '--watch'],
299
- ];
300
- for (const [present, flag] of given) {
301
- if (present)
302
- return { mode: commandName, error: t('cli.flags.build_only', { flag }) };
303
- }
318
+ // 哪些旗標只屬於 build、哪些規則成立,都由定義表回答(`flag-table.ts`)。
319
+ const rejected = firstRejectedOutside(args.given, commandName);
320
+ if (rejected !== null)
321
+ return { mode: commandName, error: { code: 'USAGE_FLAG_BUILD_ONLY', details: { flag: rejected } } };
322
+ if (commandName !== 'build')
304
323
  return { mode: commandName, error: null };
305
- }
306
- if (args.dryRun && !args.install)
307
- return { mode: 'build', error: t('cli.flags.dry_run_requires_install') };
308
- if (args.exportDir !== null && args.install)
309
- return { mode: 'build', error: t('cli.flags.export_conflicts_install') };
310
- if (args.dryRun && args.watch)
311
- return { mode: 'build', error: t('cli.flags.dry_run_with_watch') };
324
+ const violated = firstViolatedRule(args.given, commandName);
325
+ if (violated !== null)
326
+ return { mode: 'build', error: { code: violated, details: {} } };
312
327
  const mode = args.exportDir !== null ? 'export' : args.install && args.dryRun ? 'dry-run' : args.install ? 'install' : 'build';
313
328
  return { mode, error: null };
314
329
  }
@@ -384,47 +399,31 @@ let platformSpecified = false;
384
399
  * dry-run/manifest 共用的求值前置對它全無意義。
385
400
  */
386
401
  function dispatchConfig() {
387
- if (configAction === null) {
388
- console.error(`${configActionMissing()}\n${usage()}`);
389
- process.exit(1);
390
- }
391
- if (!CONFIG_ACTIONS.includes(configAction)) {
392
- console.error(`${configActionUnknown(configAction)}\n${usage()}`);
393
- process.exit(1);
394
- }
395
- if (parseError !== null) {
396
- console.error(parseError);
397
- process.exit(1);
398
- }
402
+ if (configAction === null)
403
+ failWith('USAGE_CONFIG_ACTION_MISSING');
404
+ if (!CONFIG_ACTIONS.includes(configAction))
405
+ failWith('USAGE_CONFIG_ACTION_UNKNOWN', { action: configAction });
406
+ if (parseError !== null)
407
+ failWith(parseError.code, parseError.details);
399
408
  // 沒帶操作即報錯,不當成空操作:靜默跑完會與「改完了但沒有差異」無法分辨。
400
- if (configAction === 'write' && parsedArgs.ops === null) {
401
- console.error(`${configOpsMissing()}\n${usage()}`);
402
- process.exit(1);
403
- }
409
+ if (configAction === 'write' && parsedArgs.ops === null)
410
+ failWith('USAGE_CONFIG_OPS_MISSING');
404
411
  // 沒帶套件名即報錯,理由同上:`declaredSchema` 對空名字答 `{"schema": null}`
405
412
  // 並以結束碼 0 收場,那與「這個套件沒有宣告參數」的正常答案分不開。
406
- if (configAction === 'declare' && parsedArgs.declareTarget === null) {
407
- console.error(`${configDeclareTargetMissing()}\n${usage()}`);
408
- process.exit(1);
409
- }
413
+ if (configAction === 'declare' && parsedArgs.declareTarget === null)
414
+ failWith('USAGE_CONFIG_DECLARE_TARGET_MISSING');
410
415
  // `--platform` 對 config 沒有消費者:這條路徑不投影也不產出任何平台產物。
411
416
  // 順序比照 Rust 側(把關排在全部解析錯誤之後,見 `cli.rs` 的 `run`)。
412
- if (platformSpecified) {
413
- console.error(t(PLATFORM_UNUSED_KEYS.config));
414
- process.exit(1);
415
- }
417
+ if (platformSpecified)
418
+ failWith(PLATFORM_UNUSED_CODES.config);
416
419
  // `--content` 對 config 同樣沒有消費者,且這條路徑在此即結束行程,趕不上
417
420
  // `assertContentUsed`(那一支排在指令白名單檢查之後)。
418
- if (parsedArgs.content) {
419
- console.error(t('cli.content_unused.verb'));
420
- process.exit(1);
421
- }
421
+ if (parsedArgs.content)
422
+ failWith('USAGE_CONTENT_REQUIRES_DRY_RUN');
422
423
  // 寫入旗標同理:config 不走建置的寫入路徑。
423
424
  const settled = settleBuildMode('config', parsedArgs);
424
- if (settled.error !== null) {
425
- console.error(settled.error);
426
- process.exit(1);
427
- }
425
+ if (settled.error !== null)
426
+ failWith(settled.error.code, settled.error.details);
428
427
  process.exit(runConfig(configAction, realResolve(parsedArgs.positional ?? '.'), parsedArgs));
429
428
  }
430
429
  /**
@@ -445,6 +444,13 @@ function jsonTypeName(value) {
445
444
  const primitive = { string: 'string', number: 'number', boolean: 'boolean' };
446
445
  return primitive[typeof value] ?? 'object';
447
446
  }
447
+ /**
448
+ * `config` 輸出在最前面加 `formatVersion`,值取自生成模組(來源是核心的常數,
449
+ * `gen-cli-contract.mjs --check` 擋下不一致),不經原生綁定。其餘欄位與形狀不變。
450
+ */
451
+ function withFormatVersion(body) {
452
+ return { formatVersion: FORMAT_VERSION, ...body };
453
+ }
448
454
  /**
449
455
  * 配置檔的程式化改寫(ADR-024)。
450
456
  *
@@ -456,14 +462,15 @@ function runConfig(action, projectRoot, args) {
456
462
  // 正是在「挑一個擴充、剛裝好、還沒寫進配置」那一刻問它。
457
463
  if (action === 'declare') {
458
464
  const schema = declaredSchema(projectRoot, args.declareTarget ?? '');
459
- writeStructuredLine(JSON.stringify({ schema }, null, 2));
465
+ writeStructuredLine(JSON.stringify(withFormatVersion({ schema }), null, 2));
460
466
  return 0;
461
467
  }
462
468
  const configPath = join(projectRoot, 'promptex.config.ts');
463
469
  if (!existsSync(configPath)) {
464
- console.error(`✗ ${REFUSAL_MESSAGES['no-config-file']}`);
465
- console.error(t('cli.config.looked_at', { path: configPath }));
466
- return 1;
470
+ failWith('RUN_CONFIG_FILE_NOT_FOUND', { path: configPath }, [
471
+ `✗ ${REFUSAL_MESSAGES['no-config-file']}`,
472
+ t('cli.config.looked_at', { path: configPath }),
473
+ ]);
467
474
  }
468
475
  const source = readFileSync(configPath, 'utf8');
469
476
  if (action === 'read') {
@@ -480,10 +487,15 @@ function runConfig(action, projectRoot, args) {
480
487
  catch (e) {
481
488
  if (!(e instanceof RefusalError))
482
489
  throw e;
483
- console.error(`✗ ${e.message}`);
484
- return 1;
490
+ // 配置檔本身壞掉的拒絕理由各有代碼;沒有專屬代碼的理由歸 `RUN_INTERNAL`,把原文放 `detail`。
491
+ const human = `✗ ${e.message}`;
492
+ if (e.code === 'unbalanced')
493
+ failWith('RUN_CONFIG_UNBALANCED', {}, human);
494
+ if (e.code === 'no-unit-array')
495
+ failWith('RUN_CONFIG_NO_UNIT_ARRAY', {}, human);
496
+ failWith('RUN_INTERNAL', { detail: e.message }, human);
485
497
  }
486
- writeStructuredLine(JSON.stringify(view, null, 2));
498
+ writeStructuredLine(JSON.stringify(withFormatVersion(view), null, 2));
487
499
  return 0;
488
500
  }
489
501
  // 呼叫端已擋下 `--ops` 缺席的情形(見 `dispatchConfig`),此處補一次收窄
@@ -494,27 +506,29 @@ function runConfig(action, projectRoot, args) {
494
506
  ops = JSON.parse(rawOps);
495
507
  }
496
508
  catch {
497
- console.error(t('cli.config.ops_not_json', { raw: rawOps }));
498
- return 1;
499
- }
500
- if (!Array.isArray(ops)) {
501
- console.error(t('cli.config.ops_not_array', { got: jsonTypeName(ops) }));
502
- return 1;
509
+ failWith('USAGE_CONFIG_OPS_NOT_JSON', { raw: rawOps });
503
510
  }
511
+ if (!Array.isArray(ops))
512
+ failWith('USAGE_CONFIG_OPS_NOT_ARRAY', { got: jsonTypeName(ops) });
504
513
  const outcome = writeConfig(source, ops);
505
514
  if (outcome.refusal !== null) {
506
515
  console.error(`✗ ${outcome.refusal.message}`);
507
516
  if (outcome.refusal.detail !== undefined)
508
517
  console.error(` ${outcome.refusal.detail}`);
509
- writeStructuredLine(JSON.stringify(outcome, null, 2));
518
+ writeStructuredLine(JSON.stringify(withFormatVersion(outcome), null, 2));
510
519
  return 1;
511
520
  }
512
521
  // 落檔在最後一步,且只在明說要落檔時發生:不帶旗標時磁碟上一個字都沒動。
513
522
  if (args.apply && outcome.after !== source) {
514
- writeFileSync(configPath, outcome.after);
523
+ try {
524
+ writeFileSync(configPath, outcome.after);
525
+ }
526
+ catch {
527
+ failWith('RUN_CONFIG_WRITE_FAILED', { path: configPath });
528
+ }
515
529
  outcome.applied = true;
516
530
  }
517
- writeStructuredLine(JSON.stringify(outcome, null, 2));
531
+ writeStructuredLine(JSON.stringify(withFormatVersion(outcome), null, 2));
518
532
  return 0;
519
533
  }
520
534
  let srcDir = '';
@@ -548,12 +562,11 @@ let unit = 'default';
548
562
  function assertPlatformUnused(situation) {
549
563
  if (!platformSpecified || command === 'manifest')
550
564
  return;
551
- console.error(t(PLATFORM_UNUSED_KEYS[situation]));
552
- process.exit(1);
565
+ failWith(PLATFORM_UNUSED_CODES[situation]);
553
566
  }
554
- const OUT_UNUSED_KEYS = {
555
- configMode: 'cli.out_unused.units',
556
- init: 'cli.out_unused.init',
567
+ const OUT_UNUSED_CODES = {
568
+ configMode: 'USAGE_OUT_UNUSED_IN_UNIT_MODE',
569
+ init: 'USAGE_OUT_UNUSED_BY_INIT',
557
570
  };
558
571
  /**
559
572
  * `--out` 沒有消費者時一律報錯,不靜默忽略。
@@ -570,8 +583,7 @@ const OUT_UNUSED_KEYS = {
570
583
  function assertOutUnused(situation) {
571
584
  if (!outSpecified)
572
585
  return;
573
- console.error(t(OUT_UNUSED_KEYS[situation]));
574
- process.exit(1);
586
+ failWith(OUT_UNUSED_CODES[situation]);
575
587
  }
576
588
  /**
577
589
  * `--content` 沒有消費者時一律報錯,理由同 `--out` 與 `--platform`:靜默忽略的
@@ -585,14 +597,10 @@ function assertOutUnused(situation) {
585
597
  function assertContentUsed() {
586
598
  if (!CONTENT_REQUESTED)
587
599
  return;
588
- if (MODE !== 'dry-run') {
589
- console.error(t('cli.content_unused.verb'));
590
- process.exit(1);
591
- }
592
- if (!STRUCTURED_OUTPUT) {
593
- console.error(t('cli.content_unused.json'));
594
- process.exit(1);
595
- }
600
+ if (MODE !== 'dry-run')
601
+ failWith('USAGE_CONTENT_REQUIRES_DRY_RUN');
602
+ if (!STRUCTURED_OUTPUT)
603
+ failWith('USAGE_CONTENT_REQUIRES_JSON');
596
604
  }
597
605
  // init 不進入下方的求值/投影/安裝流程(它本身就是要產出待求值的源碼,
598
606
  // 此刻目標目錄還沒有可求值的內容),在這裡提前分派並結束行程。把關要先於
@@ -605,14 +613,17 @@ function loadNativeCore() {
605
613
  return loadNative();
606
614
  }
607
615
  catch (e) {
608
- console.error(e instanceof Error ? e.message : String(e));
609
- process.exit(1);
616
+ const message = e instanceof Error ? e.message : String(e);
617
+ failWith('RUN_BINDING_NOT_FOUND', { platform: `${process.platform}-${process.arch}` }, message);
610
618
  }
611
619
  }
612
620
  /**
613
621
  * 專案初始化(新增動詞,非四個既有日常動作之一):在目標目錄產出配置檔、
614
622
  * 源碼目錄與可獨立編譯的範例資源(AC-126 至 AC-130)。
615
623
  *
624
+ * compat(remove-in=2.0): cli-init-verb(1.1 棄用、2.0 移除,改用各語言的 create;
625
+ * 行為凍結,不再加功能)
626
+ *
616
627
  * 骨架內容、語言別名的收斂、衝突判定與「全有全無」的處置全住核心
617
628
  * (`promptex_core::init`),版面同樣住核心(`present` 的 `init` 請求):
618
629
  * 三個生態的 `init` 因此既產出同一份位元組、也印出同一份版面。本層只做
@@ -631,9 +642,11 @@ async function runInit() {
631
642
  outcome = runInitViaCore(targetDir, parsedArgs.lang ?? 'ts', parsedArgs.assumeYes, LOCALE);
632
643
  }
633
644
  catch (e) {
634
- console.error(`✗ ${opsErrorText(e instanceof Error ? e.message : String(e))}`);
635
- process.exit(1);
645
+ failOps(e instanceof Error ? e.message : String(e), '✗ ');
636
646
  }
647
+ // 帶 `--json` 且中止時,stdout 輸出錯誤 JSON,不再印人讀版面(stderr 只留一行 `message`)。
648
+ if (outcome.aborted && STRUCTURED_OUTPUT)
649
+ failWith('RUN_INIT_ABORTED', { files: outcome.conflicts });
637
650
  present({ kind: 'init', outcome });
638
651
  if (outcome.aborted)
639
652
  process.exit(1);
@@ -679,33 +692,42 @@ function opsErrorText(message) {
679
692
  return lines.map((line) => line.text).join('\n');
680
693
  }
681
694
  /**
682
- * 把綁定回傳的失敗印到 stderr。
683
- *
684
- * 診斷逐筆走 `formatDiagnostic`,與求值期失敗(`cli/compile-engine.ts`)及
685
- * 另兩個生態的同一路徑逐行同構:此前這裡只印代碼與訊息,位置與提示在
686
- * TypeScript 的命令列輸出中看不到,同一份宣告在求值期失敗與投影期失敗時
687
- * 作者看到的詳略不同。收尾那一行取核心的版面(`present` 的 `diagnosticAbort`
688
- * 請求):逐筆格式化刻意保留三份(求值期不得依賴原生綁定),收尾句不受那個
689
- * 限制,宿主各寫一份只是純字面重複。
690
- *
691
- * 其餘種類整批交給核心的 `present`:措辭與該印到哪個流都由核心決定,本檔不
692
- * 依 `kind` 分支。分支就是重組文字的入口,而重組出來的那份與核心是否同文
693
- * 沒有任何測試在比對。
695
+ * 把綁定回傳的失敗輸出並結束行程。
696
+ *
697
+ * 錯誤代碼與 `details` 由核心決定(失敗 payload 的 `code`、`details`,見核心的
698
+ * `OpsError::code_and_details`),這裡原樣交給 `failWith`,不依 `kind` 自己分類。
699
+ *
700
+ * 不帶 `--json` 時的人讀輸出:診斷逐筆走 `formatDiagnostic`,與求值期失敗(`cli/compile-engine.ts`)
701
+ * 及另兩個生態的同一路徑逐行同構:此前這裡只印代碼與訊息,位置與提示在
702
+ * TypeScript 的命令列輸出中看不到,同一份宣告在求值期失敗與投影期失敗時作者看到的
703
+ * 詳略不同。收尾那一行取核心的版面(`present` 的 `diagnosticAbort` 請求):逐筆格式化刻意
704
+ * 保留三份(求值期不得依賴原生綁定),收尾句不受那個限制,宿主各寫一份只是純字面重複。
705
+ * 其餘種類整批交給核心的 `present`:措辭與該印到哪個流都由核心決定,本檔不依 `kind` 分支。
706
+ * 分支就是重組文字的入口,而重組出來的那份與核心是否同文沒有任何測試在比對。
707
+ *
708
+ * `prefix` 讓呼叫端在人讀文字前加記號(如 `init` 的 `✗ `),改走 `opsErrorText` 取文字,只作用在非診斷的種類。
694
709
  */
695
- function reportOpsError(message) {
710
+ function failOps(message, prefix = '') {
696
711
  const payload = parseOpsFailure(message);
697
- if (payload === null) {
698
- console.error(message);
699
- return;
700
- }
701
- if (payload.kind === 'diagnostics') {
702
- for (const d of payload.diagnostics)
703
- for (const line of formatDiagnostic(d))
704
- console.error(line);
705
- present({ kind: 'diagnosticAbort' });
706
- return;
707
- }
708
- present({ kind: 'failure', failure: payload });
712
+ if (payload === null)
713
+ failWith('RUN_INTERNAL', { detail: message }, message);
714
+ const code = typeof payload.code === 'string' ? payload.code : 'RUN_INTERNAL';
715
+ const details = (payload.details ?? { detail: message });
716
+ const human = () => {
717
+ if (payload.kind === 'diagnostics') {
718
+ for (const d of payload.diagnostics)
719
+ for (const line of formatDiagnostic(d))
720
+ console.error(line);
721
+ present({ kind: 'diagnosticAbort' });
722
+ }
723
+ else if (prefix === '') {
724
+ present({ kind: 'failure', failure: payload });
725
+ }
726
+ else {
727
+ console.error(`${prefix}${opsErrorText(message)}`);
728
+ }
729
+ };
730
+ failWith(code, details, human, { projectRoot: evaluationProjectRoot || undefined });
709
731
  }
710
732
  /**
711
733
  * 依核心給的版面印出:核心說要印什麼、印到哪個流(見核心的 `present` 模組),
@@ -749,7 +771,7 @@ function present(request) {
749
771
  */
750
772
  function printManifestQuery({ platform, unit, installRoot, supported, nodes = [], files = [], }) {
751
773
  const native = loadNativeCore();
752
- const query = native.manifestQuery(JSON.stringify({ platform, engineVersion: ENGINE_VERSION, unit, installRoot, supported, nodes, files }), LOCALE);
774
+ const query = native.manifestQuery(JSON.stringify({ platform, engineVersion: engineVersion(), unit, installRoot, supported, nodes, files }), LOCALE);
753
775
  // 取結構化輸出時這一份改走錯誤通道:它本來就是給另一個專案解析的 JSON,
754
776
  // 但標準輸出此刻由 `emitStructured` 的單一 JSON 文件佔用,兩份都印會讓
755
777
  // 整體不再是一個 JSON 值,AC-507 的「可被結構化解析」隨之落空。
@@ -818,7 +840,7 @@ function emitStructured(out, targetPlatform, queryMeta, contents) {
818
840
  // 信封交給核心組(`orchestrate::structured_envelope_json`):欄位集合、順序、
819
841
  // 分隔符與診斷位置的相對化都是跨生態契約,三側各寫一份只靠註解互相指認,攔不住
820
842
  // 任何漏欄;`diagnostics` 就是這樣一度只加在本檔的。
821
- const { structuredEnvelope } = loadNative();
843
+ const { structuredEnvelope } = loadNativeCore();
822
844
  writeStructuredLine(structuredEnvelope(JSON.stringify({
823
845
  // 動詞取自命令列而非 `outcome`:後者不承載自己是哪個動詞的結果
824
846
  // (build 的 outcome 是 written/report/degraded/unsupported/
@@ -870,8 +892,7 @@ function readNativeInputs(irPath, settingsPath, externalsPath) {
870
892
  function invokeCore({ irPath, settingsPath, externalsPath, root, platform, sourceRoot, unitName, mode = MODE, scope = 'project', queryMeta }) {
871
893
  const result = coreOutcome({ irPath, settingsPath, externalsPath, root, platform, sourceRoot, unitName, mode, scope });
872
894
  if (!result.ok) {
873
- reportOpsError(result.message);
874
- return 1;
895
+ failOps(result.message);
875
896
  }
876
897
  // 沒有擴充的目標,核心的投影樹即最終落地的那一棵,取它就是安裝結果本身。
877
898
  let contents;
@@ -881,8 +902,7 @@ function invokeCore({ irPath, settingsPath, externalsPath, root, platform, sourc
881
902
  contents = JSON.parse(loadNativeCore().projectionTree(mode, root, irJson, platform, sourceRoot ?? '', settingsJson, externalsJson, unitName, LOCALE, scope));
882
903
  }
883
904
  catch (e) {
884
- reportOpsError(e instanceof Error ? e.message : String(e));
885
- return 1;
905
+ failOps(e instanceof Error ? e.message : String(e));
886
906
  }
887
907
  }
888
908
  printCoreOutcome(result.outcome, platform, queryMeta, contents);
@@ -940,7 +960,7 @@ function coreOutcome({ irPath, settingsPath, externalsPath, root, platform, sour
940
960
  *
941
961
  * 階梯方向不成立時核心以診斷回傳、一個檔案都不寫出(它排在核心內任何寫入之
942
962
  * 前),與內建平台在投影期被攔下同樣是全有全無;本檔把那批診斷走既有的
943
- * `reportOpsError` 路徑印出。
963
+ * `failOps` 路徑印出。
944
964
  *
945
965
  * 報告只在 `build`/`install` 印出:`plan` 與 `manifest` 是零副作用動詞,兩者的
946
966
  * 結果本來就沒有 `report` 欄位,內建平台在這兩個動詞下同樣不印報告,查詢指令的
@@ -968,8 +988,7 @@ async function runThirdPartyEmit(target, mode, irPath, unitOutDir, unitName, que
968
988
  catch (e) {
969
989
  // 階梯方向不成立與落地失敗都走既有的診斷輸出路徑,與內建平台的求值期診斷
970
990
  // 同一形態;不讓綁定的 JSON 錯誤物件原樣冒到頂層變成堆疊追蹤。
971
- reportOpsError(e instanceof Error ? e.message : String(e));
972
- process.exit(1);
991
+ failOps(e instanceof Error ? e.message : String(e));
973
992
  }
974
993
  // 放行的逃逸路徑警告走本檔的診斷格式化(那一段刻意保留三份,見核心的
975
994
  // `present` 模組文件),印完才接核心給的逐行輸出。
@@ -1038,8 +1057,7 @@ function thirdPartyFilesJson(files) {
1038
1057
  text[rel] = decoder.decode(content);
1039
1058
  }
1040
1059
  catch {
1041
- console.error(t('cli.third_party_not_utf8', { path: rel }));
1042
- process.exit(1);
1060
+ failWith('RUN_THIRD_PARTY_NOT_UTF8', { path: rel });
1043
1061
  }
1044
1062
  }
1045
1063
  return JSON.stringify(text);
@@ -1097,8 +1115,7 @@ async function runExtendedTarget(target, mode, coreArgs, unitOutDir, unitName, q
1097
1115
  const native = loadNativeCore();
1098
1116
  const base = coreOutcome({ ...coreArgs, root: unitOutDir, mode });
1099
1117
  if (!base.ok) {
1100
- reportOpsError(base.message);
1101
- return 1;
1118
+ failOps(base.message);
1102
1119
  }
1103
1120
  // 兩支綁定共用的三個引數先接成綁定那側的參數名(`root`/`unit`/`sourceRoot`)
1104
1121
  // 再傳:位置引數排錯順序在無型別的 .mjs 沒有任何訊號,同名接一次讓它落在靜態
@@ -1112,8 +1129,7 @@ async function runExtendedTarget(target, mode, coreArgs, unitOutDir, unitName, q
1112
1129
  beforeJson = native.projectionTree(mode, root, irJson, target.platform, sourceRoot ?? '', settingsJson, externalsJson, unit, LOCALE, coreArgs.scope ?? 'project');
1113
1130
  }
1114
1131
  catch (e) {
1115
- reportOpsError(e instanceof Error ? e.message : String(e));
1116
- return 1;
1132
+ failOps(e instanceof Error ? e.message : String(e));
1117
1133
  }
1118
1134
  const files = decodeTree(beforeJson);
1119
1135
  const ctx = createAdapterContext(target.platform, select);
@@ -1126,8 +1142,7 @@ async function runExtendedTarget(target, mode, coreArgs, unitOutDir, unitName, q
1126
1142
  reportText = ctx.hasNotes() ? formatReport(ctx.report(target.platform, [...files.keys()].sort())) : null;
1127
1143
  }
1128
1144
  catch (e) {
1129
- reportOpsError(e instanceof Error ? e.message : String(e));
1130
- return 1;
1145
+ failOps(e instanceof Error ? e.message : String(e));
1131
1146
  }
1132
1147
  const afterJson = encodeTree(files);
1133
1148
  const baselineJson = JSON.stringify(base.outcome);
@@ -1136,8 +1151,7 @@ async function runExtendedTarget(target, mode, coreArgs, unitOutDir, unitName, q
1136
1151
  landed = JSON.parse(native.extensionApply(mode, root, unit, target.platform, beforeJson, afterJson, baselineJson, LOCALE));
1137
1152
  }
1138
1153
  catch (e) {
1139
- reportOpsError(e instanceof Error ? e.message : String(e));
1140
- return 1;
1154
+ failOps(e instanceof Error ? e.message : String(e));
1141
1155
  }
1142
1156
  // 逐檔加工的訊息只在 build/install 印出:dry-run/manifest 的輸出是整份「會變成
1143
1157
  // 什麼」的預覽清單,再疊一批逐檔訊息等於同一件事說兩次,且該清單本身已把擴充
@@ -1190,25 +1204,32 @@ function homeBasis() {
1190
1204
  * 出處可由環境注入(見 cli/compile-engine.ts 的 `parseProvenanceEnv`):三個欄位
1191
1205
  * 都隨環境變動。
1192
1206
  *
1193
- * 解析失敗在此收成人讀訊息再結束行程,不讓例外冒到頂層:這一行在模組載入時
1194
- * 執行,未接住的例外印出的是 Node 的堆疊,而作者要的資訊只有「哪個環境變數
1195
- * 的值不合法」。
1207
+ * 延後到第一次用到才解析,而且 `main()` 在參數驗證通過、進入建置與查詢流程之前先呼叫
1208
+ * 一次:解析要排在 `--json` 掃描之後,不合法時才能輸出 `USAGE_PROVENANCE_INVALID` 的錯誤 JSON;
1209
+ * `version`、`init` 與 `config` 不用出處,不受這個環境變數影響。此前它在模組載入時解析,
1210
+ * 比任何掃描都早,連 `version` 都被擋住。
1211
+ *
1212
+ * 解析失敗收成錯誤輸出再結束行程,不讓例外冒到頂層:未接住的例外印出的是 Node 的堆疊,
1213
+ * 而作者要的資訊只有「哪個環境變數的值不合法」。
1196
1214
  */
1197
- const PROVENANCE = (() => {
1215
+ let provenanceCache = null;
1216
+ function provenance() {
1217
+ if (provenanceCache !== null)
1218
+ return provenanceCache.value;
1198
1219
  try {
1199
- return parseProvenanceEnv(process.env.PROMPTEX_PROVENANCE);
1220
+ provenanceCache = { value: parseProvenanceEnv(process.env.PROMPTEX_PROVENANCE) };
1200
1221
  }
1201
1222
  catch (e) {
1202
- console.error(e instanceof Error ? e.message : String(e));
1203
- process.exit(1);
1223
+ failWith('USAGE_PROVENANCE_INVALID', {}, e instanceof Error ? e.message : String(e));
1204
1224
  }
1205
- })();
1225
+ return provenanceCache.value;
1226
+ }
1206
1227
  /**
1207
1228
  * 查詢輸出與落點條目回報的引擎版本。取已安裝的框架版本,但出處注入時以注入
1208
1229
  * 值為準:這個值會進旁置落點表與查詢輸出,逐字比對產物的呼叫端(情境回歸、
1209
1230
  * CI)需要它固定,否則每次升版都讓一批與版本無關的比對失敗。
1210
1231
  */
1211
- const ENGINE_VERSION = PROVENANCE?.engineVersion ?? FRAMEWORK_VERSION;
1232
+ const engineVersion = () => provenance()?.engineVersion ?? FRAMEWORK_VERSION;
1212
1233
  /**
1213
1234
  * 求值宿主原語(ADR-008 界線二):載入核心指名的那個單元的源碼、產出中介
1214
1235
  * 表示。這是核心做不到的兩件事之一,因此留在宿主側;本函式不做任何判斷,
@@ -1264,7 +1285,7 @@ async function runEvaluatePrimitive(primitive, unitDef, projectRoot) {
1264
1285
  kind: 'evaluate',
1265
1286
  id: primitive.id,
1266
1287
  ok: true,
1267
- ir: JSON.stringify(engine.toIR(PROVENANCE, unitDef.select)),
1288
+ ir: JSON.stringify(engine.toIR(provenance(), unitDef.select)),
1268
1289
  };
1269
1290
  }
1270
1291
  catch (e) {
@@ -1380,8 +1401,7 @@ async function orchestrate({ verb, configDir, configPath, units, unitFilter, })
1380
1401
  }), LOCALE));
1381
1402
  }
1382
1403
  catch (e) {
1383
- reportOpsError(e instanceof Error ? e.message : String(e));
1384
- process.exit(1);
1404
+ failOps(e instanceof Error ? e.message : String(e));
1385
1405
  }
1386
1406
  // `configDir` 即專案根:配置單元模式為配置檔所在目錄(PDR-003),單一目錄
1387
1407
  // 模式由 `runSingleDir` 傳入該模式的第一個位置參數,兩者都是本次求值的
@@ -1397,8 +1417,7 @@ async function orchestrate({ verb, configDir, configPath, units, unitFilter, })
1397
1417
  return { ...resolution, config };
1398
1418
  }
1399
1419
  catch (e) {
1400
- reportOpsError(e instanceof Error ? e.message : String(e));
1401
- process.exit(1);
1420
+ failOps(e instanceof Error ? e.message : String(e));
1402
1421
  }
1403
1422
  }
1404
1423
  /**
@@ -1549,6 +1568,11 @@ export async function distributeTarget({ mode, scope = 'project', target, target
1549
1568
  }
1550
1569
  return invokeCore({ ...coreArgs, root: unitOutDir, mode, queryMeta });
1551
1570
  }
1571
+ /** 配置檔載入失敗:人讀輸出是框架層的診斷行,錯誤 JSON 的 `detail` 放原始例外訊息。 */
1572
+ function failConfigLoad(configPath, error) {
1573
+ const detail = error instanceof Error ? error.message : String(error);
1574
+ failWith('RUN_CONFIG_LOAD_FAILED', { path: configPath, detail }, describeLoadFailure(configPath, error));
1575
+ }
1552
1576
  /**
1553
1577
  * 配置單元模式(PDR-005):把求值後的配置交給核心算出編排計畫,執行計畫指名
1554
1578
  * 的原語(求值兄弟單元、查詢另一個專案),再依核心解析出的結果逐單元求值自己
@@ -1574,17 +1598,13 @@ async function runConfigUnits(configPath) {
1574
1598
  mod = (await import(pathToFileURL(configPath).href));
1575
1599
  }
1576
1600
  catch (e) {
1577
- for (const line of describeLoadFailure(configPath, e))
1578
- console.error(line);
1579
- process.exit(1);
1601
+ failConfigLoad(configPath, e);
1580
1602
  }
1581
1603
  // 先以 `unknown` 收再驗、驗過才具名:`Array.isArray` 對唯讀陣列的收窄會把
1582
1604
  // 型別退成 `any[]`,直接對已具名的值做這個檢查等於把整條下游的型別關掉。
1583
1605
  const defaultExport = mod.default;
1584
- if (!Array.isArray(defaultExport)) {
1585
- console.error(t('cli.config.default_export_invalid', { path: configPath }));
1586
- process.exit(1);
1587
- }
1606
+ if (!Array.isArray(defaultExport))
1607
+ failWith('RUN_CONFIG_EXPORT_INVALID', { path: configPath });
1588
1608
  const units = defaultExport;
1589
1609
  const resolution = await orchestrate({
1590
1610
  verb: command,
@@ -1620,11 +1640,12 @@ async function runConfigUnits(configPath) {
1620
1640
  // 結構版本問核心(`graphVersion()`),不寫死字面:版本正是消費端用來分辨
1621
1641
  // 新舊結構的那個欄位,核心改版而這裡的字面停在舊值時沒有任何訊號,症狀是
1622
1642
  // 同一份專案從 Rust 與從 TypeScript 問到兩種版本。
1643
+ // compat(remove-in=2.0): legacy-version-field(graph.version)
1623
1644
  const empty = {
1624
1645
  files: [],
1625
1646
  nodes: [],
1626
1647
  metadata: [],
1627
- graph: { version: native.graphVersion(), nodes: [], edges: [] },
1648
+ graph: { formatVersion: FORMAT_VERSION, version: native.graphVersion(), nodes: [], edges: [] },
1628
1649
  };
1629
1650
  emitStructured(empty, platform, { unit: unitName, installRoot: resolved.installRoot });
1630
1651
  }
@@ -1713,6 +1734,13 @@ async function runConfigUnits(configPath) {
1713
1734
  export async function main(argv) {
1714
1735
  command = argv[2] ?? '';
1715
1736
  rest = argv.slice(3);
1737
+ // 解析參數之前先掃一次有沒有 `--json`:用法錯誤也要輸出錯誤 JSON。`config` 不帶 `--json`
1738
+ // 也輸出 JSON,所以它的失敗一律輸出錯誤 JSON。
1739
+ const scanned = scanArgv(rest);
1740
+ configureErrors({ verb: command, explicitJson: scanned.json, stdoutJson: scanned.json || command === 'config', localeFlag: scanned.locale });
1741
+ // 獨立動詞(`version`)不載入專案、不需要原生綁定,在任何解析與載入之前就結束。
1742
+ if (isStandaloneVerb(command))
1743
+ process.exit(runVersion(rest));
1716
1744
  isConfig = command === 'config';
1717
1745
  configAction = isConfig ? (rest[0] ?? null) : null;
1718
1746
  configArgv = isConfig ? rest.slice(1) : rest;
@@ -1720,15 +1748,13 @@ export async function main(argv) {
1720
1748
  platformSpecified = rest.includes('--platform');
1721
1749
  if (isConfig)
1722
1750
  dispatchConfig();
1723
- if ((!COMMANDS.includes(command) || parseError !== null)) {
1724
- console.error(COMMANDS.includes(command) ? parseError : usage());
1725
- process.exit(1);
1726
- }
1751
+ if (!projectVerbs().includes(command))
1752
+ failWith('USAGE_UNKNOWN_VERB', { verb: command });
1753
+ if (parseError !== null)
1754
+ failWith(parseError.code, parseError.details);
1727
1755
  const settled = settleBuildMode(command, parsedArgs);
1728
- if (settled.error !== null) {
1729
- console.error(settled.error);
1730
- process.exit(1);
1731
- }
1756
+ if (settled.error !== null)
1757
+ failWith(settled.error.code, settled.error.details);
1732
1758
  MODE = settled.mode;
1733
1759
  WATCH = parsedArgs.watch;
1734
1760
  // 匯出目錄以呼叫當下的工作目錄為基準轉成絕對路徑:核心收到的是每個單元的
@@ -1756,17 +1782,23 @@ export async function main(argv) {
1756
1782
  // 原生綁定」。代價是 config 那條路徑的訊息一律走預設語系而不理會 `--locale`
1757
1783
  // ——Python 側沒有這個落差(`cli.py` 的設定點在 config 分派之前),要補齊得先
1758
1784
  // 讓語系在不載入綁定的前提下解得出來,不是把這一行往前搬。
1759
- LOCALE = loadNative().resolveLocale(parsedArgs.locale, JSON.stringify(process.env), Intl.DateTimeFormat().resolvedOptions().locale ?? null);
1785
+ LOCALE = loadNativeCore().resolveLocale(parsedArgs.locale, JSON.stringify(process.env), Intl.DateTimeFormat().resolvedOptions().locale ?? null);
1760
1786
  setLocale(LOCALE);
1787
+ configureErrors({ locale: LOCALE });
1761
1788
  assertContentUsed();
1762
1789
  unitSpecified = parsedArgs.unit !== null;
1763
1790
  unit = parsedArgs.unit ?? 'default';
1764
1791
  if (command === 'init') {
1792
+ // compat(remove-in=2.0): cli-init-verb(1.1 棄用、2.0 移除,改用各語言的 create)
1793
+ // 棄用提示只寫 stderr,stdout 與結束碼和棄用前相同,`--json` 的輸出也不受影響。
1794
+ process.stderr.write(`${t('cli.init.deprecated')}\n`);
1765
1795
  assertOutUnused('init');
1766
1796
  assertPlatformUnused('init');
1767
1797
  await runInit();
1768
1798
  process.exit(0);
1769
1799
  }
1800
+ // 出處由環境注入,建置與查詢都要用它。解析排在 `--json` 掃描與參數驗證之後,不合法時輸出用法錯誤。
1801
+ provenance();
1770
1802
  const configPath = join(srcDir, 'promptex.config.ts');
1771
1803
  if (WATCH) {
1772
1804
  // 監看的對象是「一個來源目錄產出到一個落點」這件事。無配置檔時那就是
@@ -1805,9 +1837,7 @@ export async function main(argv) {
1805
1837
  units = (await import(pathToFileURL(configPath).href)).default ?? [];
1806
1838
  }
1807
1839
  catch (e) {
1808
- for (const line of describeLoadFailure(configPath, e))
1809
- console.error(line);
1810
- process.exit(1);
1840
+ failConfigLoad(configPath, e);
1811
1841
  }
1812
1842
  // 外部落點條目在進入迴圈前解一次:監看的是本專案的源碼(ADR-010「重新
1813
1843
  // 求值:全量」指的是本單元的聚合檔),別的專案改了什麼不在監看範圍內,
@@ -1824,8 +1854,7 @@ export async function main(argv) {
1824
1854
  if (target === null) {
1825
1855
  // 判定不成立要印什麼與判定本身同住核心:判定收斂了而訊息留在外面,
1826
1856
  // 三個宿主仍各持一份逐字相同的字串,改一處漏兩處的形態原封不動。
1827
- present({ kind: 'watch', event: 'targetRejected' });
1828
- process.exit(1);
1857
+ failWith('RUN_WATCH_TARGET_REJECTED', {}, () => present({ kind: 'watch', event: 'targetRejected' }));
1829
1858
  }
1830
1859
  ;
1831
1860
  ({ promptDir: watchPromptDir, root: watchRoot, platform: watchPlatform, unitName: watchUnit,