@abelxiaoxing/cadence 1.5.1 → 1.5.2

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/README.md CHANGED
@@ -31,6 +31,9 @@ node /absolute/path/to/cadence/src/operator-cli.mjs runs /path/to/consumer
31
31
  ```
32
32
 
33
33
  `doctor` 检查 Git、Node、OpenSpec 入口、实际 Bubblewrap 启动、请求预算和常用脚本静态能力;不调用模型、不执行产品测试、不保证所有集成服务均可用。
34
+ 包管理器优先采用 `package.json.packageManager`,其次推断唯一锁文件所属的管理器;没有声明和锁文件时明确标为 npm 默认值。
35
+ 输出包含 `selectedPackageManager`、`selectionSource`、`lockfiles` 和 `ambiguousLockfiles`;多个管理器锁文件且无明确声明时阻塞脚本探测,不静默选择 Bun。
36
+ Doctor 不读取私有计划,计划中的显式 runner 仍由正式预检检查;请让项目声明与计划保持一致。
34
37
  `runs` 只读列出该项目最多 1,000 个 run 和有界存储占用;不会创建、迁移或删除数据库,不自动清理暂停任务。
35
38
 
36
39
  ### 可信本地项目:显式复用宿主环境
@@ -87,6 +90,7 @@ Gate、交付校验和执行状态转换由控制面代码保证。
87
90
  当前任务的 Gate 回答与续轮保持阶段有效;用户结束工作流或转向无关任务时,父模型先调用 `{"action":"finish"}`,扩展等待活动操作停止并恢复原工具,保留可恢复进度。
88
91
  此退出同样适用于 Implement,不表示完成或丢弃。
89
92
  显式调用 Init 也会先退出已有阶段。
93
+ Design 同任务续轮重新收紧允许的工具集合,预检失败或暂停不自动退出;会话重载/恢复不继承阶段激活权限,需重新显式调用对应命令,已保存的 run 不会因此完成或丢弃。
90
94
 
91
95
  Implement 只暴露 `start`、`status`、`resume`、`rebind`、`cancel` 和 `discard` 控制命令。
92
96
  `status` 完全本地可用;相同 operation id 幂等重放,进程、会话或 Worker 更换后仍从 durable checkpoint 继续。
@@ -99,6 +103,12 @@ Design 从第一步起统一使用 `action: "design"`:新需求通过 `start(r
99
103
  内联原子验证也可省略 `id` / `classification`;任务公共 `read` 与阶段读取合并,固定 baseline/repair 保护字段由编译器补齐,恢复次数和 AGENTS 影响仍需作者明确选择。
100
104
  [多任务示例](config/plan-draft.multiple-tasks.example.json) 保留显式依赖和产物 producer;ordered steps 仍使用完整内联合同。
101
105
  `validate-plan-draft` 返回有界的最终权限、验证用途、恢复次数和实际派生来源摘要;摘要不充当批准或封存证明。
106
+ 编译结果的 `checks` 分开报告 `structure`、`verificationCapability`、`contractCoverage` 和 `sealing`;静态 `closure.executable: true` 不代表产品测试已运行或 `READY_TO_IMPLEMENT`。
107
+ 独立 `compileImplementPlan` 未传合同则 `contractCoverage: "not-checked"`;传入合同也不证明该合同已获批准。
108
+ 正式预检从私有 journal 注入当前 Gate A 合同并报告 `gateA: "current"`,但仍是 `sealing: "not-performed"`;只有正式控制面可以批准和封存交付。
109
+ 验收缺失诊断列出 acceptance ID;仅当验证 ID 唯一对应一个不同义务时返回字段差异,不猜测最接近的验证器,也不回显命令或参数。
110
+ `changedSurfaces` 仅接受 `none`、`route-authorization`、`page-state`、`api-response`、`public-html`;公共影响不得改标为 `none` 来绕过闭包。
111
+ `verification.change.affected` 是固定标记 `"task-affected-contracts"`,不能使用 `{ "use": "..." }`;`affectedSuite` 中公共影响测试必须在 `relatedTests` 声明证据和实际 owner。
102
112
  推导有歧义时拒绝编译,显式错误仍会报错;代码不会据此扩大路径、依赖或产物权限,封存计划保持完整、严格的结构。
103
113
  预检集中返回不同任务的独立结构错误,并提供字段位置、期望/实际路径和修正提示;工具反馈与 TUI 共用有界脱敏投影。
104
114
  Design status 将可调用的 `legalOperations` 与顶层 `packetActions` 分开;显式退出只能发送 `{"action":"finish"}`,不能伪装成 `operation: "finish"`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Four explicit Abel workflow prompts with stage-isolated design and diagnosis, durable resumable implementation, and three package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,7 +6,10 @@ import {
6
6
  validateVerificationContract,
7
7
  verificationInputPaths,
8
8
  } from "./contracts.ts";
9
- import { DesignPlanValidationError } from "./design-diagnostics.ts";
9
+ import {
10
+ type DesignPlanDiagnostic,
11
+ DesignPlanValidationError,
12
+ } from "./design-diagnostics.ts";
10
13
  import { canonicalJson } from "./run-state.ts";
11
14
 
12
15
  export interface ChangeContract {
@@ -151,25 +154,73 @@ export function assertPlanWithinChangeContract(
151
154
  })[],
152
155
  verifications: readonly StructuredVerificationContract[],
153
156
  ): void {
154
- const available = new Set(
155
- [
156
- ...verifications,
157
- ...tasks.flatMap((task) => [
158
- task.affectedVerification,
159
- ...Object.entries(task.phases)
160
- .filter(
161
- ([phase]) =>
162
- phase !== "red" ||
163
- !task.verificationMode ||
164
- task.verificationMode === "behavior",
165
- )
166
- .map(([, phase]) => phase.verification),
167
- ]),
168
- ].map(verificationObligation),
169
- );
170
- for (const accepted of contract.acceptance)
171
- if (!available.has(verificationObligation(accepted.verification)))
172
- throw new Error("change-contract-acceptance-missing");
157
+ const candidates = [
158
+ ...verifications,
159
+ ...tasks.flatMap((task) => [
160
+ task.affectedVerification,
161
+ ...Object.entries(task.phases)
162
+ .filter(
163
+ ([phase]) =>
164
+ phase !== "red" ||
165
+ !task.verificationMode ||
166
+ task.verificationMode === "behavior",
167
+ )
168
+ .map(([, phase]) => phase.verification),
169
+ ]),
170
+ ];
171
+ const available = new Set(candidates.map(verificationObligation));
172
+ const diagnostics: DesignPlanDiagnostic[] = [];
173
+ for (const [index, accepted] of contract.acceptance.entries()) {
174
+ if (available.has(verificationObligation(accepted.verification))) continue;
175
+ // Only attribute a mismatch when the display ID selects one distinct
176
+ // obligation. Never guess a "closest" verifier or echo command/argument bytes.
177
+ const matching = [
178
+ ...new Set(
179
+ candidates
180
+ .filter((candidate) => candidate.id === accepted.verification.id)
181
+ .map(verificationObligation),
182
+ ),
183
+ ];
184
+ const expected = JSON.parse(verificationObligation(accepted.verification));
185
+ const actual =
186
+ matching.length === 1 && matching[0]
187
+ ? JSON.parse(matching[0])
188
+ : undefined;
189
+ diagnostics.push({
190
+ code: "change-contract-acceptance-missing",
191
+ acceptanceId: accepted.id,
192
+ verificationId: accepted.verification.id,
193
+ field: `changeContract.acceptance.${index}.verification`,
194
+ ...(actual
195
+ ? {
196
+ mismatchedFields: [
197
+ "kind",
198
+ "runner",
199
+ "packageManager",
200
+ "script",
201
+ "command",
202
+ "args",
203
+ "testFiles",
204
+ "minTests",
205
+ "classification",
206
+ "expectedFailure",
207
+ "steps",
208
+ ]
209
+ .filter(
210
+ (key) =>
211
+ canonicalJson(expected[key] ?? null) !==
212
+ canonicalJson(actual[key] ?? null),
213
+ )
214
+ .sort(),
215
+ }
216
+ : {}),
217
+ });
218
+ }
219
+ if (diagnostics.length)
220
+ throw new DesignPlanValidationError(
221
+ "change-contract-acceptance-missing",
222
+ diagnostics,
223
+ );
173
224
  for (const task of tasks) {
174
225
  if (
175
226
  !contract.policy.verificationModes.includes(
package/src/contracts.ts CHANGED
@@ -1233,6 +1233,7 @@ export type ImplementGraphContractDiagnostic =
1233
1233
  path?: string;
1234
1234
  expectedPaths?: string[];
1235
1235
  actualPaths?: string[];
1236
+ allowedValues?: string[];
1236
1237
  }
1237
1238
  | {
1238
1239
  code: "invalid-output-path";
@@ -1518,6 +1519,88 @@ function taskBoundaryContractDiagnostic(
1518
1519
  : diagnostic("task", "contract");
1519
1520
  }
1520
1521
 
1522
+ /** Refine rejected author input; this never admits a partial or repaired task. */
1523
+ function taskFieldDiagnostics(
1524
+ task: Record<string, unknown>,
1525
+ ): ImplementGraphContractDiagnostic[] {
1526
+ const diagnostics: ImplementGraphContractDiagnostic[] = [];
1527
+ const add = (
1528
+ detail: Omit<
1529
+ Extract<
1530
+ ImplementGraphContractDiagnostic,
1531
+ { code: "invalid-implement-graph" }
1532
+ >,
1533
+ "code"
1534
+ >,
1535
+ ) => {
1536
+ if (diagnostics.length < 64)
1537
+ diagnostics.push({
1538
+ code: "invalid-implement-graph",
1539
+ ...(validIdentifier(task.taskId) ? { taskId: task.taskId } : {}),
1540
+ ...detail,
1541
+ });
1542
+ };
1543
+ if (task.phases && typeof task.phases === "object") {
1544
+ const phases = task.phases as Record<string, unknown>;
1545
+ for (const phase of IMPLEMENTATION_PHASES) {
1546
+ const boundary = phases[phase];
1547
+ if (!boundary || typeof boundary !== "object") continue;
1548
+ for (const field of ["read", "write", "delete"] as const) {
1549
+ const paths = (boundary as Record<string, unknown>)[field];
1550
+ if (!Array.isArray(paths)) continue;
1551
+ const seen = new Set<string>();
1552
+ for (const [index, file] of paths.entries()) {
1553
+ if (!isValidRelativePath(file)) continue;
1554
+ if (seen.has(file))
1555
+ add({
1556
+ phase,
1557
+ field: `phases.${phase}.${field}.${index}`,
1558
+ category: "duplicate-path",
1559
+ path: file,
1560
+ });
1561
+ seen.add(file);
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+ if (task.impactClosure && typeof task.impactClosure === "object") {
1567
+ const closure = task.impactClosure as Record<string, unknown>;
1568
+ if (Array.isArray(closure.changedSurfaces)) {
1569
+ for (const [index, surface] of closure.changedSurfaces.entries()) {
1570
+ if (!(IMPACT_SURFACES as readonly unknown[]).includes(surface))
1571
+ add({
1572
+ field: `impactClosure.changedSurfaces.${index}`,
1573
+ category: "enum",
1574
+ allowedValues: [...IMPACT_SURFACES],
1575
+ });
1576
+ }
1577
+ if (
1578
+ closure.changedSurfaces.some(
1579
+ (surface) =>
1580
+ surface !== "none" &&
1581
+ (IMPACT_SURFACES as readonly unknown[]).includes(surface),
1582
+ ) &&
1583
+ Array.isArray(closure.affectedSuite) &&
1584
+ Array.isArray(closure.relatedTests)
1585
+ ) {
1586
+ const related = new Set(
1587
+ closure.relatedTests.flatMap((item) =>
1588
+ item && typeof item === "object" ? [item.path] : [],
1589
+ ),
1590
+ );
1591
+ for (const [index, file] of closure.affectedSuite.entries())
1592
+ if (isValidRelativePath(file) && !related.has(file))
1593
+ add({
1594
+ field: `impactClosure.affectedSuite.${index}`,
1595
+ category: "related-test-missing",
1596
+ path: file,
1597
+ });
1598
+ }
1599
+ }
1600
+ }
1601
+ return diagnostics;
1602
+ }
1603
+
1521
1604
  export function validateImplementGraphBoundary(value: unknown):
1522
1605
  | { ok: true; value: ImplementGraphBoundary }
1523
1606
  | {
@@ -1573,11 +1656,24 @@ export function validateImplementGraphBoundary(value: unknown):
1573
1656
  { changeId: value.changeId, ...task },
1574
1657
  undefined,
1575
1658
  );
1576
- if (diagnostic)
1577
- failures.push({
1578
- reason: "invalid Implement task boundary",
1579
- diagnostic: { code: "invalid-implement-graph", ...diagnostic },
1580
- });
1659
+ if (diagnostic) {
1660
+ const fields = taskFieldDiagnostics(task);
1661
+ // Replace a broad diagnostic only when these details refine that field;
1662
+ // retain unrelated shape/authority failures in the same batch.
1663
+ if (
1664
+ !fields.some(
1665
+ (detail) =>
1666
+ diagnostic.field &&
1667
+ detail.field?.startsWith(`${diagnostic.field}.`),
1668
+ )
1669
+ )
1670
+ fields.push({ code: "invalid-implement-graph", ...diagnostic });
1671
+ for (const detail of fields)
1672
+ failures.push({
1673
+ reason: "invalid Implement task boundary",
1674
+ diagnostic: detail,
1675
+ });
1676
+ }
1581
1677
  }
1582
1678
  for (const candidate of value.outputs) {
1583
1679
  if (
@@ -264,6 +264,13 @@ export type {
264
264
  export type { PlanDraft, PlanPhaseDraft, PlanTaskInput } from "./plan-draft.ts";
265
265
 
266
266
  export interface CompiledDelivery {
267
+ /** Static checks only: neither compilation nor preflight seals a delivery. */
268
+ checks: {
269
+ structure: "passed";
270
+ verificationCapability: "passed";
271
+ contractCoverage: "passed" | "not-checked";
272
+ sealing: "not-performed";
273
+ };
267
274
  plan: ImplementPlan;
268
275
  bytes: Uint8Array;
269
276
  rawSha256: string;
@@ -945,6 +952,20 @@ function normalizeAgentsCheckpointOperations(
945
952
  }
946
953
 
947
954
  function normalizeVerificationPlan(value: unknown): PlanVerification {
955
+ if (
956
+ isRecord(value) &&
957
+ isRecord(value.change) &&
958
+ value.change.affected !== "task-affected-contracts"
959
+ ) {
960
+ throw new DesignPlanValidationError("delivery-verification-plan-invalid", [
961
+ {
962
+ code: "delivery-verification-plan-invalid",
963
+ field: "verification.change.affected",
964
+ category: "enum",
965
+ allowedValues: ["task-affected-contracts"],
966
+ },
967
+ ]);
968
+ }
948
969
  if (
949
970
  !isRecord(value) ||
950
971
  !exactKeys(value, [
@@ -1289,6 +1310,12 @@ export function compileImplementPlan(
1289
1310
  const planHash = hashCanonicalValue(plan);
1290
1311
  const closure = structuredClone(readiness.closure);
1291
1312
  return {
1313
+ checks: {
1314
+ structure: "passed",
1315
+ verificationCapability: "passed",
1316
+ contractCoverage: plan.changeContract ? "passed" : "not-checked",
1317
+ sealing: "not-performed",
1318
+ },
1292
1319
  plan,
1293
1320
  bytes,
1294
1321
  rawSha256,
@@ -978,6 +978,7 @@ export class DesignController {
978
978
  operation: request.operation,
979
979
  runId: request.runId,
980
980
  valid: true,
981
+ checks: { ...compiled.checks, gateA: "current" },
981
982
  summary: planAuthoringSummary(compiled.plan, authoredDraft),
982
983
  plan: {
983
984
  taskCount: compiled.plan.tasks.length,
@@ -11,6 +11,7 @@ const IDENTITIES = new Set([
11
11
  "category",
12
12
  "owner",
13
13
  "verificationId",
14
+ "acceptanceId",
14
15
  "outputId",
15
16
  "dependencyTaskId",
16
17
  "producerTaskId",
@@ -22,6 +23,11 @@ const IDENTITIES = new Set([
22
23
  ]);
23
24
  const HINTS = new Map<string, string>(
24
25
  Object.entries({
26
+ "duplicate-path":
27
+ "Remove the repeated path at the indicated index; keep the existing declaration and do not widen phase permissions.",
28
+ enum: "Use one of allowedValues at the indicated field. For public impact, choose the actual changed surface; do not substitute none to bypass closure checks.",
29
+ "related-test-missing":
30
+ "Add this affected-suite test to relatedTests with evidence and its actual owner. Do not remove public impact or widen writes to satisfy ownership.",
25
31
  "change-contract-acceptance-missing":
26
32
  "Read the current approved ChangeContract from Design status and include each accepted verification obligation in the plan's phase, affected, full-suite or post-apply verification. Command, arguments, inputs and classification must match; copying only its ID is insufficient. Keep accepted IDs, statements and scope unchanged.",
27
33
  "script-command-mismatch":
@@ -96,6 +102,18 @@ export function projectDesignDiagnostic(
96
102
  : entry;
97
103
  else if (key === "path" && safePath(entry)) result.path = entry;
98
104
  else if (
105
+ (key === "allowedValues" || key === "mismatchedFields") &&
106
+ Array.isArray(entry)
107
+ ) {
108
+ result[key] = [
109
+ ...new Set(
110
+ entry.filter(
111
+ (item): item is string =>
112
+ typeof item === "string" && CODE.test(item),
113
+ ),
114
+ ),
115
+ ].slice(0, 32);
116
+ } else if (
99
117
  (key === "expectedPaths" || key === "actualPaths") &&
100
118
  Array.isArray(entry)
101
119
  ) {
@@ -120,6 +138,9 @@ export interface DesignPlanDiagnostic {
120
138
  category?: string;
121
139
  owner?: string;
122
140
  verificationId?: string;
141
+ acceptanceId?: string;
142
+ allowedValues?: string[];
143
+ mismatchedFields?: string[];
123
144
  outputId?: string;
124
145
  dependencyTaskId?: string;
125
146
  producerTaskId?: string;
package/src/index.ts CHANGED
@@ -887,6 +887,8 @@ export function registerWorkflowControl(
887
887
  pi.registerTool({
888
888
  name: DISPATCH_TOOL,
889
889
  label: "Abel Control",
890
+ promptSnippet:
891
+ "Control the explicitly activated Abel stage; retain paused work across same-task follow-ups.",
890
892
  description:
891
893
  kind === "command"
892
894
  ? 'Private stage-bound Abel workflow control. Accepts durable Implement commands, batch-bound {"action":"amend"} artifact revisions, or {"action":"finish"} to leave and preserve resumable work.'
@@ -1394,7 +1396,8 @@ export function registerWorkflowControl(
1394
1396
  activePrompt = prompt;
1395
1397
  }
1396
1398
  if (prompt) activateDispatcher(pi, activation, prompt, event.prompt);
1397
- if (verified && prompt === "abel-design") enforceDesignTools();
1399
+ if (activation.isActive() && activePrompt === "abel-design")
1400
+ enforceDesignTools();
1398
1401
  const boundary = activePrompt
1399
1402
  ? `Abel stage ${activePrompt} is active only for the invoked task and its direct follow-ups. If the user ends it or requests an unrelated task, first call abel_dispatch with {"action":"finish"}, then handle that task normally with the restored tools. A successful finish ends stage authority immediately, including within this turn. Do not extend Gates or workflow rules to that task. A direct Gate answer or same-task continuation stays in this stage. Never invoke another Abel stage automatically.`
1400
1403
  : init
@@ -1239,6 +1239,26 @@ function validateAcceptedVerificationCapability(root, verification, dependencyOw
1239
1239
  }
1240
1240
 
1241
1241
  // src/operator-tools.ts
1242
+ var PACKAGE_LOCKS = {
1243
+ bun: ["bun.lock", "bun.lockb"],
1244
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
1245
+ pnpm: ["pnpm-lock.yaml"],
1246
+ yarn: ["yarn.lock"]
1247
+ };
1248
+ function packageManagerSelection(root, declaration) {
1249
+ const managers = Object.keys(PACKAGE_LOCKS);
1250
+ const lockfiles = managers.flatMap((manager) => PACKAGE_LOCKS[manager].filter((file) => lstatSync6(path5.join(root, file), { throwIfNoEntry: false })?.isFile()));
1251
+ const inferred = managers.filter((manager) => PACKAGE_LOCKS[manager].some((file) => lockfiles.includes(file)));
1252
+ const declared = typeof declaration === "string" ? managers.find((manager) => new RegExp(`^${manager}@[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][a-z0-9._+-]+)?$`, "iu").test(declaration)) : undefined;
1253
+ const selectedPackageManager = declaration !== undefined ? declared ?? null : inferred.length === 1 ? inferred[0] ?? null : inferred.length > 1 ? null : "npm";
1254
+ const selectionSource = declaration !== undefined ? declared ? "package.json" : "invalid-declaration" : inferred.length === 1 ? "lockfile" : inferred.length > 1 ? "ambiguous" : "default";
1255
+ return {
1256
+ selectedPackageManager,
1257
+ selectionSource,
1258
+ lockfiles,
1259
+ ambiguousLockfiles: inferred.length > 1
1260
+ };
1261
+ }
1242
1262
  function inspectConsumer(root) {
1243
1263
  root = realpathSync4(root);
1244
1264
  const checks = [];
@@ -1317,6 +1337,7 @@ function inspectConsumer(root) {
1317
1337
  return `${links} repository workspace link(s); their targets must be included in the candidate baseline`;
1318
1338
  }, "Repair dangling/external workspace links and ensure local package sources are tracked or admitted in the candidate.");
1319
1339
  const manifest = path5.join(root, "package.json");
1340
+ let selection = packageManagerSelection(root, undefined);
1320
1341
  if (lstatSync6(manifest, { throwIfNoEntry: false })?.isFile()) {
1321
1342
  check("manifest", () => {
1322
1343
  const parsed2 = JSON.parse(readFileSync4(manifest, "utf8"));
@@ -1326,19 +1347,23 @@ function inspectConsumer(root) {
1326
1347
  }, "Repair package.json.");
1327
1348
  const parsed = (() => {
1328
1349
  try {
1329
- return JSON.parse(readFileSync4(manifest, "utf8"));
1350
+ const value = JSON.parse(readFileSync4(manifest, "utf8"));
1351
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1330
1352
  } catch {
1331
1353
  return {};
1332
1354
  }
1333
1355
  })();
1356
+ selection = packageManagerSelection(root, parsed.packageManager);
1357
+ check("package-manager", () => {
1358
+ if (!selection.selectedPackageManager)
1359
+ throw new Error(`package-manager-${selection.selectionSource}`);
1360
+ return `${selection.selectedPackageManager} (${selection.selectionSource})`;
1361
+ }, "Declare the intended packageManager in package.json; doctor does not read private plans, install runners or remove lockfiles.");
1334
1362
  for (const script of ["test", "check", "build"].filter((name) => typeof parsed.scripts?.[name] === "string")) {
1335
1363
  check(`script:${script}`, () => {
1336
- const manager = ["bun", "pnpm", "yarn", "npm"].find((name) => lstatSync6(path5.join(root, {
1337
- bun: "bun.lock",
1338
- pnpm: "pnpm-lock.yaml",
1339
- yarn: "yarn.lock",
1340
- npm: "package-lock.json"
1341
- }[name]), { throwIfNoEntry: false })) ?? "npm";
1364
+ const manager = selection.selectedPackageManager;
1365
+ if (!manager)
1366
+ throw new Error(`package-manager-${selection.selectionSource}`);
1342
1367
  const result = validateVerificationAdapterCapability(root, {
1343
1368
  kind: "package-script",
1344
1369
  id: `doctor-${script}`,
@@ -1349,12 +1374,12 @@ function inspectConsumer(root) {
1349
1374
  classification: "expected-green"
1350
1375
  });
1351
1376
  if (!result.ok)
1352
- throw new Error(result.diagnostic.code);
1377
+ throw new Error(`${manager}:${result.diagnostic.code}`);
1353
1378
  return "statically admitted; tests not executed";
1354
1379
  }, "Check the runner, installed dependencies and execution configuration; this probe does not execute your tests.");
1355
1380
  }
1356
1381
  }
1357
- return { root, ok: checks.every((entry) => entry.ok), checks };
1382
+ return { root, ...selection, ok: checks.every((entry) => entry.ok), checks };
1358
1383
  }
1359
1384
  function storageUsage(root, maximum = 1e5) {
1360
1385
  let entries = 0;
@@ -14,6 +14,59 @@ export interface DoctorCheck {
14
14
  detail: string;
15
15
  nextStep?: string;
16
16
  }
17
+ const PACKAGE_LOCKS = {
18
+ bun: ["bun.lock", "bun.lockb"],
19
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
20
+ pnpm: ["pnpm-lock.yaml"],
21
+ yarn: ["yarn.lock"],
22
+ } as const;
23
+ type PackageManager = keyof typeof PACKAGE_LOCKS;
24
+
25
+ function packageManagerSelection(root: string, declaration: unknown) {
26
+ const managers = Object.keys(PACKAGE_LOCKS) as PackageManager[];
27
+ const lockfiles = managers.flatMap((manager) =>
28
+ PACKAGE_LOCKS[manager].filter((file) =>
29
+ lstatSync(path.join(root, file), { throwIfNoEntry: false })?.isFile(),
30
+ ),
31
+ );
32
+ const inferred = managers.filter((manager) =>
33
+ PACKAGE_LOCKS[manager].some((file) => lockfiles.includes(file)),
34
+ );
35
+ const declared =
36
+ typeof declaration === "string"
37
+ ? managers.find((manager) =>
38
+ new RegExp(
39
+ `^${manager}@[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][a-z0-9._+-]+)?$`,
40
+ "iu",
41
+ ).test(declaration),
42
+ )
43
+ : undefined;
44
+ const selectedPackageManager =
45
+ declaration !== undefined
46
+ ? (declared ?? null)
47
+ : inferred.length === 1
48
+ ? (inferred[0] ?? null)
49
+ : inferred.length > 1
50
+ ? null
51
+ : "npm";
52
+ const selectionSource =
53
+ declaration !== undefined
54
+ ? declared
55
+ ? "package.json"
56
+ : "invalid-declaration"
57
+ : inferred.length === 1
58
+ ? "lockfile"
59
+ : inferred.length > 1
60
+ ? "ambiguous"
61
+ : "default";
62
+ return {
63
+ selectedPackageManager,
64
+ selectionSource,
65
+ lockfiles,
66
+ ambiguousLockfiles: inferred.length > 1,
67
+ };
68
+ }
69
+
17
70
  export function inspectConsumer(root: string) {
18
71
  root = realpathSync(root);
19
72
  const checks: DoctorCheck[] = [];
@@ -136,6 +189,7 @@ export function inspectConsumer(root: string) {
136
189
  "Repair dangling/external workspace links and ensure local package sources are tracked or admitted in the candidate.",
137
190
  );
138
191
  const manifest = path.join(root, "package.json");
192
+ let selection = packageManagerSelection(root, undefined);
139
193
  if (lstatSync(manifest, { throwIfNoEntry: false })?.isFile()) {
140
194
  check(
141
195
  "manifest",
@@ -151,51 +205,51 @@ export function inspectConsumer(root: string) {
151
205
  );
152
206
  const parsed = (() => {
153
207
  try {
154
- return JSON.parse(readFileSync(manifest, "utf8"));
208
+ const value = JSON.parse(readFileSync(manifest, "utf8"));
209
+ return value && typeof value === "object" && !Array.isArray(value)
210
+ ? value
211
+ : {};
155
212
  } catch {
156
213
  return {};
157
214
  }
158
215
  })();
216
+ selection = packageManagerSelection(root, parsed.packageManager);
217
+ check(
218
+ "package-manager",
219
+ () => {
220
+ if (!selection.selectedPackageManager)
221
+ throw new Error(`package-manager-${selection.selectionSource}`);
222
+ return `${selection.selectedPackageManager} (${selection.selectionSource})`;
223
+ },
224
+ "Declare the intended packageManager in package.json; doctor does not read private plans, install runners or remove lockfiles.",
225
+ );
159
226
  for (const script of ["test", "check", "build"].filter(
160
227
  (name) => typeof parsed.scripts?.[name] === "string",
161
228
  )) {
162
229
  check(
163
230
  `script:${script}`,
164
231
  () => {
165
- const manager =
166
- ["bun", "pnpm", "yarn", "npm"].find((name) =>
167
- lstatSync(
168
- path.join(
169
- root,
170
- (
171
- {
172
- bun: "bun.lock",
173
- pnpm: "pnpm-lock.yaml",
174
- yarn: "yarn.lock",
175
- npm: "package-lock.json",
176
- } as Record<string, string>
177
- )[name],
178
- ),
179
- { throwIfNoEntry: false },
180
- ),
181
- ) ?? "npm";
232
+ const manager = selection.selectedPackageManager;
233
+ if (!manager)
234
+ throw new Error(`package-manager-${selection.selectionSource}`);
182
235
  const result = validateVerificationAdapterCapability(root, {
183
236
  kind: "package-script",
184
237
  id: `doctor-${script}`,
185
- packageManager: manager as "bun" | "pnpm" | "yarn" | "npm",
238
+ packageManager: manager,
186
239
  script,
187
240
  command: parsed.scripts[script],
188
241
  args: [],
189
242
  classification: "expected-green",
190
243
  });
191
- if (!result.ok) throw new Error(result.diagnostic.code);
244
+ if (!result.ok)
245
+ throw new Error(`${manager}:${result.diagnostic.code}`);
192
246
  return "statically admitted; tests not executed";
193
247
  },
194
248
  "Check the runner, installed dependencies and execution configuration; this probe does not execute your tests.",
195
249
  );
196
250
  }
197
251
  }
198
- return { root, ok: checks.every((entry) => entry.ok), checks };
252
+ return { root, ...selection, ok: checks.every((entry) => entry.ok), checks };
199
253
  }
200
254
 
201
255
  /** Bounded metadata traversal, no symlink following and no automatic deletion. */