@neocompose/cli 0.43.1 → 0.44.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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.44.0] - 2026-09-03
4
+
5
+ ### Added
6
+
7
+ - `neo doctor --fix` deletes the rows the document audit's
8
+ `unprojected-value-row` findings name: base value rows no emitted file
9
+ projects and no other record names, which `neo status` reads as CLI-managed
10
+ loose state and never reports. It lists every orphan root with the number of
11
+ rows it owns, confirms, then deletes them under two staleness guards: the CAS
12
+ base the last pull recorded for each row, and the version head each
13
+ transaction expects from the one before it, starting from the head that pull
14
+ left. A project that moved on since the pull therefore fails the commit
15
+ instead of deleting a row that has since gained an owner, and a workspace
16
+ with no recorded head is refused rather than written blind. Sweeps larger
17
+ than one transaction go up in batches sized from the shared cost model.
18
+ `--yes` answers the confirmation for scripted runs; `--fix` has no `--json`
19
+ form.
20
+
21
+ ### Fixed
22
+
23
+ - `neo pull` marked a conflicted record in only the one file the emission
24
+ attributes it to, so a conflict on a member declared by more than one class
25
+ (system world-layer members such as `Name`) left the sibling declarations
26
+ unmarked. Resolving the marked file alone then left the declarations
27
+ disagreeing and the next command refused the workspace. Markers now go in
28
+ every file that declares the record, and `neo resolve` treats those files as
29
+ one conflict: it settles them together and refuses a record whose
30
+ declarations are only partly resolved.
31
+
3
32
  ## [0.43.1] - 2026-09-03
4
33
 
5
34
  ### Changed
package/dist/neo.mjs CHANGED
@@ -1043,7 +1043,9 @@ var init_args = __esm({
1043
1043
  "generate-ids",
1044
1044
  "help",
1045
1045
  "passWithNoTests",
1046
- "no-verify"
1046
+ "no-verify",
1047
+ "fix",
1048
+ "yes"
1047
1049
  ]);
1048
1050
  KNOWN_FLAGS = /* @__PURE__ */ new Set([
1049
1051
  "accept-bump",
@@ -1055,6 +1057,7 @@ var init_args = __esm({
1055
1057
  "dir",
1056
1058
  "dry-run",
1057
1059
  "file",
1060
+ "fix",
1058
1061
  "force",
1059
1062
  "force-recompile",
1060
1063
  "function",
@@ -1087,6 +1090,7 @@ var init_args = __esm({
1087
1090
  "this-value",
1088
1091
  "token-stdin",
1089
1092
  "version",
1093
+ "yes",
1090
1094
  "reporter",
1091
1095
  "outputFile",
1092
1096
  "output-file",
@@ -87879,6 +87883,12 @@ var init_variant_value_graph = __esm({
87879
87883
  });
87880
87884
 
87881
87885
  // ../src/database/project-version-intents.ts
87886
+ var project_version_intents_exports = {};
87887
+ __export(project_version_intents_exports, {
87888
+ ProjectVersionIntentType: () => ProjectVersionIntentType,
87889
+ createProjectVersionIntent: () => createProjectVersionIntent,
87890
+ isKnownProjectVersionIntentType: () => isKnownProjectVersionIntentType
87891
+ });
87882
87892
  function createProjectVersionIntent(type, details) {
87883
87893
  if (details === void 0) {
87884
87894
  return {
@@ -96153,6 +96163,151 @@ function variantRootValueId2(data) {
96153
96163
  if (typeof valueId !== "string" || valueId.length === 0) return null;
96154
96164
  return valueId;
96155
96165
  }
96166
+ function planProjectVersionTransaction(changes, atomicGroups) {
96167
+ if (changes.length === 0) {
96168
+ throw new Error("A project-version transaction plan requires changes.");
96169
+ }
96170
+ const changeCosts = changes.map(estimateProjectVersionChangeCost);
96171
+ const groups = normalizeAtomicGroups(changes.length, atomicGroups);
96172
+ let totalEstimatedCost = changeCosts.reduce(
96173
+ addProjectVersionTransactionBudgets,
96174
+ IMMEDIATE_FIXED_COST
96175
+ );
96176
+ if (changes.some(projectVersionChangeCanTriggerWholeGraphValidation)) {
96177
+ totalEstimatedCost = addProjectVersionTransactionBudgets(
96178
+ totalEstimatedCost,
96179
+ WHOLE_GRAPH_VALIDATION_SURCHARGE
96180
+ );
96181
+ }
96182
+ const limitingResource = firstExceededResource(
96183
+ totalEstimatedCost,
96184
+ PROJECT_TRANSACTION_IMMEDIATE_BUDGET
96185
+ );
96186
+ if (limitingResource === null) {
96187
+ return {
96188
+ path: "immediate",
96189
+ limitingResource: null,
96190
+ totalEstimatedCost,
96191
+ chunks: [
96192
+ {
96193
+ firstChangeIndex: 0,
96194
+ changeCount: changes.length,
96195
+ estimatedCost: totalEstimatedCost
96196
+ }
96197
+ ]
96198
+ };
96199
+ }
96200
+ const chunks = [];
96201
+ let firstChangeIndex = 0;
96202
+ let chunkCost = CHUNK_FIXED_COST;
96203
+ let changeCount = 0;
96204
+ for (const group of groups) {
96205
+ const groupCost = changeCosts.slice(group.firstChangeIndex, group.firstChangeIndex + group.changeCount).reduce(addProjectVersionTransactionBudgets, EMPTY_BUDGET);
96206
+ const nativeExceeded = firstExceededResource(
96207
+ addProjectVersionTransactionBudgets(CHUNK_FIXED_COST, groupCost),
96208
+ PROJECT_TRANSACTION_SINGLE_CHANGE_NATIVE_BUDGET
96209
+ );
96210
+ if (nativeExceeded !== null) {
96211
+ throw new Error(
96212
+ `Project-version atomic group starting at change ${group.firstChangeIndex} (${group.changeCount} changes) exceeds the native worker budget for ${nativeExceeded}.`
96213
+ );
96214
+ }
96215
+ const nextCost = addProjectVersionTransactionBudgets(chunkCost, groupCost);
96216
+ const exceeded = firstExceededResource(
96217
+ nextCost,
96218
+ PROJECT_TRANSACTION_WORKER_BUDGET
96219
+ );
96220
+ if (exceeded !== null && changeCount > 0) {
96221
+ chunks.push({ firstChangeIndex, changeCount, estimatedCost: chunkCost });
96222
+ firstChangeIndex = group.firstChangeIndex;
96223
+ changeCount = 0;
96224
+ chunkCost = CHUNK_FIXED_COST;
96225
+ }
96226
+ chunkCost = addProjectVersionTransactionBudgets(chunkCost, groupCost);
96227
+ changeCount += group.changeCount;
96228
+ }
96229
+ if (changeCount > 0) {
96230
+ chunks.push({ firstChangeIndex, changeCount, estimatedCost: chunkCost });
96231
+ }
96232
+ return {
96233
+ path: "asynchronous",
96234
+ limitingResource,
96235
+ totalEstimatedCost,
96236
+ chunks
96237
+ };
96238
+ }
96239
+ function projectVersionChangeCanTriggerWholeGraphValidation(change) {
96240
+ if (change.recordKind === "enum" || change.recordKind === "member" || change.recordKind === "project" || change.recordKind === "internal-record-relation") {
96241
+ return true;
96242
+ }
96243
+ if (change.recordKind === "class") return change.operation !== "create";
96244
+ if (change.recordKind !== "value") return false;
96245
+ if (change.operation === "delete") return true;
96246
+ return valueChangeCarriesWorldLayerOverride(change);
96247
+ }
96248
+ function valueChangeCarriesWorldLayerOverride(change) {
96249
+ const nextData = change.nextData;
96250
+ if (nextData === null || typeof nextData !== "object") return false;
96251
+ if (Array.isArray(nextData)) return false;
96252
+ const value = nextData.value;
96253
+ if (value === null || typeof value !== "object") return false;
96254
+ if (Array.isArray(value)) return false;
96255
+ return typeof value.layerOverrideValueId === "string";
96256
+ }
96257
+ function normalizeAtomicGroups(changeCount, groups) {
96258
+ if (groups === void 0) {
96259
+ return Array.from({ length: changeCount }, (_, firstChangeIndex) => ({
96260
+ firstChangeIndex,
96261
+ changeCount: 1
96262
+ }));
96263
+ }
96264
+ let nextChangeIndex = 0;
96265
+ for (const group of groups) {
96266
+ if (!Number.isSafeInteger(group.firstChangeIndex) || !Number.isSafeInteger(group.changeCount) || group.firstChangeIndex !== nextChangeIndex || group.changeCount < 1) {
96267
+ throw new Error(
96268
+ "Project-version atomic groups must be positive, ordered, and contiguous."
96269
+ );
96270
+ }
96271
+ nextChangeIndex += group.changeCount;
96272
+ }
96273
+ if (nextChangeIndex !== changeCount) {
96274
+ throw new Error(
96275
+ `Project-version atomic groups cover ${nextChangeIndex}/${changeCount} changes.`
96276
+ );
96277
+ }
96278
+ return [...groups];
96279
+ }
96280
+ function estimateProjectVersionChangeCost(change) {
96281
+ const encoded = canonicalJsonStringify(change);
96282
+ const payloadBytes = new TextEncoder().encode(encoded).byteLength;
96283
+ const hasBase = change.operation !== "create";
96284
+ const counts = changeCountCost(change);
96285
+ return {
96286
+ argumentBytes: payloadBytes + 128,
96287
+ bytesRead: payloadBytes * (hasBase ? 3 : 1) + CHANGE_BYTES_READ_OVERHEAD,
96288
+ bytesWritten: payloadBytes * 2 + CHANGE_BYTES_WRITTEN_OVERHEAD,
96289
+ documentsRead: counts.documentsRead,
96290
+ documentsWritten: counts.documentsWritten,
96291
+ databaseQueries: counts.databaseQueries,
96292
+ // Not metered by Convex: `TransactionMetrics` reports seven dimensions and
96293
+ // this is not one of them, so it cannot be measured the way the others
96294
+ // were. Every index range a change opens is also a database query, so the
96295
+ // measured query count is a strict upper bound on it. Charging exactly
96296
+ // that keeps the dimension a true bound instead of an invented number,
96297
+ // and keeps it dominated by `databaseQueries` rather than silently
96298
+ // becoming the binding constraint on a well-measured plan.
96299
+ indexRanges: counts.databaseQueries,
96300
+ functionsScheduled: 0,
96301
+ scheduledFunctionArgsBytes: 0,
96302
+ returnBytes: payloadBytes + 512
96303
+ };
96304
+ }
96305
+ function changeCountCost(change) {
96306
+ if (change.recordKind === "value") {
96307
+ return VALUE_CHANGE_COUNT_COSTS[change.operation];
96308
+ }
96309
+ return SCHEMA_CHANGE_COUNT_COSTS[change.operation];
96310
+ }
96156
96311
  function addProjectVersionTransactionBudgets(left, right) {
96157
96312
  return {
96158
96313
  argumentBytes: left.argumentBytes + right.argumentBytes,
@@ -96167,7 +96322,15 @@ function addProjectVersionTransactionBudgets(left, right) {
96167
96322
  returnBytes: left.returnBytes + right.returnBytes
96168
96323
  };
96169
96324
  }
96170
- var KIB, MIB, PROJECT_TRANSACTION_CHUNK_PAYLOAD_TARGET_BYTES, PROJECT_TRANSACTION_IMMEDIATE_BUDGET, PROJECT_TRANSACTION_WORKER_BUDGET, PROJECT_TRANSACTION_SINGLE_CHANGE_NATIVE_BUDGET, EMPTY_BUDGET, CHUNK_GRAPH_VALIDATION_RESERVE, CHUNK_FIXED_COST, IMMEDIATE_FIXED_COST, WHOLE_GRAPH_VALIDATION_SURCHARGE, CHANGE_BYTES_READ_OVERHEAD, CHANGE_BYTES_WRITTEN_OVERHEAD;
96325
+ function firstExceededResource(cost, target) {
96326
+ for (const resource of Object.keys(
96327
+ EMPTY_BUDGET
96328
+ )) {
96329
+ if (cost[resource] > target[resource]) return resource;
96330
+ }
96331
+ return null;
96332
+ }
96333
+ var KIB, MIB, PROJECT_TRANSACTION_CHUNK_PAYLOAD_TARGET_BYTES, PROJECT_TRANSACTION_IMMEDIATE_BUDGET, PROJECT_TRANSACTION_WORKER_BUDGET, PROJECT_TRANSACTION_SINGLE_CHANGE_NATIVE_BUDGET, EMPTY_BUDGET, CHUNK_GRAPH_VALIDATION_RESERVE, CHUNK_FIXED_COST, IMMEDIATE_FIXED_COST, WHOLE_GRAPH_VALIDATION_SURCHARGE, VALUE_CHANGE_COUNT_COSTS, SCHEMA_CHANGE_COUNT_COSTS, CHANGE_BYTES_READ_OVERHEAD, CHANGE_BYTES_WRITTEN_OVERHEAD;
96171
96334
  var init_project_version_transaction_planning = __esm({
96172
96335
  "../src/database/project-version-transaction-planning.ts"() {
96173
96336
  "use strict";
@@ -96256,6 +96419,16 @@ var init_project_version_transaction_planning = __esm({
96256
96419
  databaseQueries: 410,
96257
96420
  indexRanges: 410
96258
96421
  };
96422
+ VALUE_CHANGE_COUNT_COSTS = {
96423
+ create: { documentsRead: 3, documentsWritten: 3, databaseQueries: 8 },
96424
+ update: { documentsRead: 6, documentsWritten: 3, databaseQueries: 9 },
96425
+ delete: { documentsRead: 6, documentsWritten: 3, databaseQueries: 14 }
96426
+ };
96427
+ SCHEMA_CHANGE_COUNT_COSTS = {
96428
+ create: { documentsRead: 3, documentsWritten: 5, databaseQueries: 12 },
96429
+ update: { documentsRead: 6, documentsWritten: 5, databaseQueries: 22 },
96430
+ delete: { documentsRead: 6, documentsWritten: 5, databaseQueries: 22 }
96431
+ };
96259
96432
  CHANGE_BYTES_READ_OVERHEAD = 4 * KIB;
96260
96433
  CHANGE_BYTES_WRITTEN_OVERHEAD = 4 * KIB;
96261
96434
  }
@@ -116774,6 +116947,14 @@ var init_api = __esm({
116774
116947
  });
116775
116948
 
116776
116949
  // src/convex.ts
116950
+ var convex_exports = {};
116951
+ __export(convex_exports, {
116952
+ api: () => api2,
116953
+ createConvexClient: () => createConvexClient,
116954
+ createConvexClientForApi: () => createConvexClientForApi,
116955
+ mintConvexJwt: () => mintConvexJwt,
116956
+ resolveConvexUrl: () => resolveConvexUrl
116957
+ });
116777
116958
  import { ConvexHttpClient } from "convex/browser";
116778
116959
  import { Buffer as Buffer2 } from "node:buffer";
116779
116960
  async function mintConvexJwt(apiBaseUrl) {
@@ -117560,6 +117741,7 @@ function acceptSourceEquivalentConflictBases(workspace, mainLocale = workspaceMa
117560
117741
  };
117561
117742
  delete next.conflictServerHash;
117562
117743
  delete next.conflictServerData;
117744
+ delete next.conflictFiles;
117563
117745
  workspace.state.records[key] = next;
117564
117746
  accepted += 1;
117565
117747
  }
@@ -118577,34 +118759,39 @@ async function finishFormat4Pull(args) {
118577
118759
  const serverByPath = new Map(
118578
118760
  (serverResult?.files ?? []).map((file) => [file.path, file])
118579
118761
  );
118762
+ const localRecordFiles = declaringSourcePathsV4(localResult);
118763
+ const serverRecordFiles = serverResult === null ? /* @__PURE__ */ new Map() : declaringSourcePathsV4(serverResult);
118580
118764
  const conflictPaths = /* @__PURE__ */ new Set();
118765
+ const conflictPathsByKey = /* @__PURE__ */ new Map();
118581
118766
  const authoredLocalConflictKeysByPath = /* @__PURE__ */ new Map();
118582
118767
  for (const key of conflictKeys) {
118583
- const localPath = localResult.recordFiles.get(key);
118584
- const serverPath = serverResult?.recordFiles.get(key);
118585
- const path = localPath ?? serverPath ?? workspace.state.records[key]?.file;
118586
- if (path) conflictPaths.add(path);
118587
- if (path === void 0 || localPath !== void 0 || serverPath !== void 0) {
118588
- continue;
118768
+ const paths = [...localRecordFiles.get(key) ?? []];
118769
+ for (const path of serverRecordFiles.get(key) ?? []) {
118770
+ if (!paths.includes(path)) paths.push(path);
118771
+ }
118772
+ if (paths.length === 0) {
118773
+ const previousPath = workspace.state.records[key]?.file;
118774
+ if (previousPath === void 0) continue;
118775
+ paths.push(previousPath);
118776
+ const keys = authoredLocalConflictKeysByPath.get(previousPath) ?? [];
118777
+ keys.push(key);
118778
+ authoredLocalConflictKeysByPath.set(previousPath, keys);
118589
118779
  }
118590
- const keys = authoredLocalConflictKeysByPath.get(path) ?? [];
118591
- keys.push(key);
118592
- authoredLocalConflictKeysByPath.set(path, keys);
118780
+ for (const path of paths) conflictPaths.add(path);
118781
+ conflictPathsByKey.set(key, paths);
118593
118782
  }
118594
118783
  const emittedPaths = /* @__PURE__ */ new Set([
118595
118784
  ...localByPath.keys(),
118596
118785
  ...[...conflictPaths].filter((path) => serverByPath.has(path)),
118597
118786
  ...authoredLocalConflictKeysByPath.keys()
118598
118787
  ]);
118599
- const localRecordFiles = declaringSourcePathsV4(localResult);
118600
118788
  const rewritePaths = projectSourcePathsRequiringRewriteV4({
118601
118789
  destructive,
118602
118790
  localStatus,
118603
118791
  plans,
118604
118792
  localRecordFiles,
118605
- serverRecordFiles: serverResult === null ? /* @__PURE__ */ new Map() : declaringSourcePathsV4(serverResult),
118793
+ serverRecordFiles,
118606
118794
  previousRecords: workspace.state.records,
118607
- conflictPaths,
118608
118795
  emittedPaths,
118609
118796
  mainLocale: workspaceMainLocale(workspace.state.records)
118610
118797
  });
@@ -118612,6 +118799,7 @@ async function finishFormat4Pull(args) {
118612
118799
  for (const path of localRecordFiles.get(key) ?? []) rewritePaths.add(path);
118613
118800
  }
118614
118801
  const resolveSourcePath = createWorkspaceSourcePathResolver(workspace.root);
118802
+ const markedPaths = /* @__PURE__ */ new Set();
118615
118803
  let written = 0;
118616
118804
  for (const path of emittedPaths) {
118617
118805
  const absolute = resolveSourcePath(path);
@@ -118632,8 +118820,10 @@ async function finishFormat4Pull(args) {
118632
118820
  versionId: workspace.config.versionId
118633
118821
  }) : local;
118634
118822
  if (content === void 0) continue;
118823
+ const marked = conflictPaths.has(path) && detectConflictMarkers(content) !== null;
118824
+ if (marked) markedPaths.add(path);
118635
118825
  mkdirSync8(dirname8(absolute), { recursive: true });
118636
- if (existing !== null && !rewritePaths.has(path)) continue;
118826
+ if (existing !== null && !marked && !rewritePaths.has(path)) continue;
118637
118827
  if (existing !== content) {
118638
118828
  writeFileSync8(absolute, content, "utf8");
118639
118829
  written += 1;
@@ -118666,12 +118856,16 @@ async function finishFormat4Pull(args) {
118666
118856
  continue;
118667
118857
  }
118668
118858
  const file = localResult.recordFiles.get(key) ?? serverResult?.recordFiles.get(key) ?? baseState?.file;
118859
+ const conflictFiles = (conflictPathsByKey.get(key) ?? []).filter(
118860
+ (conflictPath) => markedPaths.has(conflictPath)
118861
+ );
118669
118862
  records2[key] = plan.conflicted ? {
118670
118863
  recordKind,
118671
118864
  recordId,
118672
118865
  contentHash: baseState?.contentHash ?? "",
118673
118866
  data: baseState?.data,
118674
118867
  ...file ? { file } : {},
118868
+ ...conflictFiles.length === 0 ? {} : { conflictFiles },
118675
118869
  conflictServerHash: serverRecord?.contentHash ?? null,
118676
118870
  conflictServerData: plan.serverData
118677
118871
  } : {
@@ -118758,9 +118952,9 @@ async function finishFormat4Pull(args) {
118758
118952
  );
118759
118953
  for (const key of conflictKeys) console.log(` ${sym.fail} ${key}`);
118760
118954
  for (const tear of staleConflictTears) {
118761
- const rowLabel = tear.unanchoredValueCount === 1 ? "row" : "rows";
118955
+ const rowLabel2 = tear.unanchoredValueCount === 1 ? "row" : "rows";
118762
118956
  warn(
118763
- `Conflict ${tear.recordKind} "${tear.recordId}" retained a local base that does not anchor ${tear.unanchoredValueCount} newly pulled descendant value ${rowLabel}. Resolve it with "neo resolve --mine" or "neo resolve --theirs" before pushing.`
118957
+ `Conflict ${tear.recordKind} "${tear.recordId}" retained a local base that does not anchor ${tear.unanchoredValueCount} newly pulled descendant value ${rowLabel2}. Resolve it with "neo resolve --mine" or "neo resolve --theirs" before pushing.`
118764
118958
  );
118765
118959
  }
118766
118960
  }
@@ -118934,7 +119128,7 @@ function projectSourcePathsRequiringRewriteV4(args) {
118934
119128
  if (args.destructive || args.localStatus === null) {
118935
119129
  return new Set(args.emittedPaths);
118936
119130
  }
118937
- const result = new Set(args.conflictPaths);
119131
+ const result = /* @__PURE__ */ new Set();
118938
119132
  for (const [key, plan] of args.plans) {
118939
119133
  const paths = args.localRecordFiles.get(key) ?? args.serverRecordFiles.get(key);
118940
119134
  if (paths === void 0) continue;
@@ -126300,7 +126494,7 @@ var init_registry2 = __esm({
126300
126494
  PROJECT_SCHEMA_CONTRACT = Object.freeze({
126301
126495
  formatVersion: 4,
126302
126496
  contractVersion: "4.1",
126303
- cliVersion: "0.43.1",
126497
+ cliVersion: "0.44.0",
126304
126498
  projectFileUploadBatchSize: 32,
126305
126499
  documentRecords: {
126306
126500
  member: {
@@ -129434,6 +129628,7 @@ var init_test = __esm({
129434
129628
  var doctor_exports = {};
129435
129629
  __export(doctor_exports, {
129436
129630
  inspectNeoDoctor: () => inspectNeoDoctor,
129631
+ planUnprojectedValueRowRepair: () => planUnprojectedValueRowRepair,
129437
129632
  runDoctor: () => runDoctor
129438
129633
  });
129439
129634
  import {
@@ -129666,7 +129861,7 @@ function workspacePath(root, path) {
129666
129861
  function errorMessage3(error) {
129667
129862
  return error instanceof Error ? error.message : String(error);
129668
129863
  }
129669
- async function runDoctor(workspace, json) {
129864
+ async function runDoctor(workspace, options) {
129670
129865
  const inspected = inspectNeoDoctor(workspace);
129671
129866
  let errors = [];
129672
129867
  try {
@@ -129685,7 +129880,7 @@ async function runDoctor(workspace, json) {
129685
129880
  compatible: errors.length === 0
129686
129881
  }
129687
129882
  };
129688
- if (json) {
129883
+ if (options.json) {
129689
129884
  console.log(JSON.stringify(report, null, 2));
129690
129885
  if (!report.ok) process.exitCode = 1;
129691
129886
  return;
@@ -129732,23 +129927,213 @@ async function runDoctor(workspace, json) {
129732
129927
  console.log(
129733
129928
  report.ok ? "Project authoring contracts are compatible." : "Project authoring contracts need attention."
129734
129929
  );
129735
- if (!report.document.consistent) {
129930
+ if (!report.document.consistent && !options.fix) {
129736
129931
  console.log(
129737
129932
  `${String(report.document.findings.length)} malformed record${report.document.findings.length === 1 ? "" : "s"} in the pulled document \u2014 see \`--json\` for the repair entries.`
129738
129933
  );
129739
129934
  }
129740
129935
  if (!report.ok) process.exitCode = 1;
129936
+ if (options.fix) {
129937
+ await repairUnprojectedValueRows(
129938
+ workspace,
129939
+ report.document.findings,
129940
+ options.yes
129941
+ );
129942
+ }
129943
+ }
129944
+ function planUnprojectedValueRowRepair(workspace, findings) {
129945
+ const roots = [];
129946
+ const claimed = /* @__PURE__ */ new Set();
129947
+ const unrepairableKinds = /* @__PURE__ */ new Set();
129948
+ for (const finding of findings) {
129949
+ if (finding.kind !== "unprojected-value-row") {
129950
+ unrepairableKinds.add(finding.kind);
129951
+ continue;
129952
+ }
129953
+ const owned = [];
129954
+ for (const valueId of finding.repair.orphanedValueIds) {
129955
+ if (claimed.has(valueId)) continue;
129956
+ claimed.add(valueId);
129957
+ const record3 = workspace.state.records[recordStateKey("value", valueId)];
129958
+ if (record3 === void 0) {
129959
+ throw new Error(
129960
+ `Doctor repair planned value row "${valueId}", which the pulled state does not hold. Run \`neo pull\` and rerun \`neo doctor --fix\`.`
129961
+ );
129962
+ }
129963
+ owned.push(record3);
129964
+ }
129965
+ if (owned.length === 0) continue;
129966
+ roots.push({ valueId: finding.repair.valueId, rows: owned });
129967
+ }
129968
+ return { roots, unrepairableKinds: [...unrepairableKinds].sort() };
129969
+ }
129970
+ function rowLabel(count) {
129971
+ return `${String(count)} value row${count === 1 ? "" : "s"}`;
129741
129972
  }
129742
- var NEO_COMPILER_CONTRACT, NEO_VSCODE_EXTENSION_ID, NEO_VSCODE_EXTENSION_CONTRACT_VERSION, NEO_SOURCE_CONTRACT_VERSION, NEO_FILE_CONTRACT_VERSION, SOURCE_EXTENSIONS, FILE_CAPABILITIES;
129973
+ async function repairUnprojectedValueRows(workspace, findings, yes) {
129974
+ const plan = planUnprojectedValueRowRepair(workspace, findings);
129975
+ if (plan.unrepairableKinds.length > 0) {
129976
+ warn(
129977
+ `No automatic repair exists for ${plan.unrepairableKinds.join(", ")}; repairing unprojected value rows only.`
129978
+ );
129979
+ }
129980
+ if (plan.roots.length === 0) {
129981
+ console.log("Nothing to repair.");
129982
+ return;
129983
+ }
129984
+ const rows = plan.roots.flatMap((root) => root.rows);
129985
+ const pulledHeadTransactionHash = workspace.state.headTransactionHash;
129986
+ if (typeof pulledHeadTransactionHash !== "string") {
129987
+ throw new Error(
129988
+ 'This workspace has no recorded project head, so `neo doctor --fix` cannot prove its deletions land on the state the audit read. Run "neo pull", then rerun.'
129989
+ );
129990
+ }
129991
+ const rootLabel = `${String(plan.roots.length)} orphan root${plan.roots.length === 1 ? "" : "s"}`;
129992
+ console.log(
129993
+ `${sym.info} ${rowLabel(rows.length)} across ${rootLabel} will be deleted:`
129994
+ );
129995
+ for (const root of plan.roots.slice(0, REPAIR_ROOT_PREVIEW_LIMIT)) {
129996
+ console.log(
129997
+ ` ${sym.arrow} ${root.valueId} \u2014 ${rowLabel(root.rows.length)}`
129998
+ );
129999
+ }
130000
+ const hidden = plan.roots.length - REPAIR_ROOT_PREVIEW_LIMIT;
130001
+ if (hidden > 0) {
130002
+ note(
130003
+ ` \u2026and ${String(hidden)} more orphan root${hidden === 1 ? "" : "s"}.`
130004
+ );
130005
+ }
130006
+ if (!yes && !isInteractive()) {
130007
+ throw new Error(
130008
+ "neo doctor --fix needs a terminal to confirm the deletion. Re-run with --yes to delete without confirming."
130009
+ );
130010
+ }
130011
+ const confirmed = yes || await promptConfirm({
130012
+ message: `Delete ${rowLabel(rows.length)}?`,
130013
+ fallback: false,
130014
+ defaultValue: false
130015
+ });
130016
+ if (!confirmed) {
130017
+ console.log("Cancelled. No records were deleted.");
130018
+ return;
130019
+ }
130020
+ const { api: api3, createConvexClient: createConvexClient2 } = await Promise.resolve().then(() => (init_convex(), convex_exports));
130021
+ const { createProjectVersionIntent: createProjectVersionIntent2 } = await Promise.resolve().then(() => (init_project_version_intents(), project_version_intents_exports));
130022
+ const changes = rows.map((row) => ({
130023
+ recordKind: "value",
130024
+ recordId: row.recordId,
130025
+ operation: "delete",
130026
+ deleted: true,
130027
+ expectedBaseContentHash: row.contentHash,
130028
+ intent: createProjectVersionIntent2("value.delete", {
130029
+ source: "neo-cli-doctor"
130030
+ })
130031
+ }));
130032
+ const batchSize = immediateCommitBatchSize(changes);
130033
+ const convex = await createConvexClient2(workspace);
130034
+ const progress = spinner(`Deleting ${rowLabel(rows.length)}\u2026`);
130035
+ let deleted = 0;
130036
+ let expectedHeadTransactionHash = pulledHeadTransactionHash;
130037
+ for (let index = 0; index < changes.length; index += batchSize) {
130038
+ const batch = changes.slice(index, index + batchSize);
130039
+ let transactionHash;
130040
+ try {
130041
+ const result = await convex.mutation(
130042
+ api3.serverProjectRecordOperations.commitFromSession,
130043
+ {
130044
+ projectId: workspace.config.projectId,
130045
+ versionId: workspace.config.versionId,
130046
+ operation: "delete",
130047
+ changes: batch,
130048
+ summary: `neo doctor --fix (${rowLabel(batch.length)})`,
130049
+ expectedHeadTransactionHash
130050
+ }
130051
+ );
130052
+ transactionHash = committedTransactionHash(result);
130053
+ } catch (error) {
130054
+ progress.fail(
130055
+ deleted === 0 ? `Failed to delete ${rowLabel(batch.length)}.` : `Failed after deleting ${rowLabel(deleted)}.`
130056
+ );
130057
+ throw repairFailure(error, deleted);
130058
+ }
130059
+ if (transactionHash === null) continue;
130060
+ expectedHeadTransactionHash = transactionHash;
130061
+ deleted += batch.length;
130062
+ progress.update(
130063
+ `Deleting ${rowLabel(rows.length)}\u2026 ${String(deleted)} done`
130064
+ );
130065
+ }
130066
+ progress.succeed(`Deleted ${rowLabel(deleted)} across ${rootLabel}.`);
130067
+ console.log('Run "neo pull" to refresh the working copy.');
130068
+ }
130069
+ function immediateCommitBatchSize(changes) {
130070
+ let fits = 0;
130071
+ let low = 1;
130072
+ let high = changes.length;
130073
+ while (low <= high) {
130074
+ const size = Math.floor((low + high) / 2);
130075
+ if (planProjectVersionTransaction(changes.slice(0, size)).path === "immediate") {
130076
+ fits = size;
130077
+ low = size + 1;
130078
+ } else {
130079
+ high = size - 1;
130080
+ }
130081
+ }
130082
+ if (fits === 0) {
130083
+ throw new Error(
130084
+ "Deleting a single value row exceeds the immediate transaction budget, so `neo doctor --fix` cannot commit anything. The transaction cost model needs to change before this repair can run."
130085
+ );
130086
+ }
130087
+ return fits;
130088
+ }
130089
+ function committedTransactionHash(result) {
130090
+ if (!isObjectRecord2(result)) {
130091
+ throw new Error(
130092
+ "Doctor repair committed a deletion but the server returned no transaction envelope."
130093
+ );
130094
+ }
130095
+ if (result.transaction === null) return null;
130096
+ const transactionHash = isObjectRecord2(result.transaction) ? result.transaction.transactionHash : void 0;
130097
+ if (typeof transactionHash !== "string") {
130098
+ throw new Error(
130099
+ "Doctor repair committed a transaction that carries no hash, so the remaining deletions have no fast-forward base."
130100
+ );
130101
+ }
130102
+ return transactionHash;
130103
+ }
130104
+ function repairFailure(error, deleted) {
130105
+ const applied = deleted === 0 ? "" : ` ${rowLabel(deleted)} were already deleted.`;
130106
+ const code = convexErrorCode(error);
130107
+ if (code === "non-fast-forward" || code === "base-hash-conflict") {
130108
+ return new Error(
130109
+ `neo doctor --fix was refused: the project moved on since this workspace pulled, so the audit is stale.${applied} Run \`neo pull\` to re-derive it, then rerun.`,
130110
+ { cause: error }
130111
+ );
130112
+ }
130113
+ return new Error(
130114
+ `neo doctor --fix could not delete the audited rows: ${errorMessage3(error)}${applied}`,
130115
+ { cause: error }
130116
+ );
130117
+ }
130118
+ function convexErrorCode(error) {
130119
+ if (!isObjectRecord2(error)) return null;
130120
+ const data = error.data;
130121
+ if (!isObjectRecord2(data)) return null;
130122
+ return typeof data.error === "string" ? data.error : null;
130123
+ }
130124
+ var NEO_COMPILER_CONTRACT, NEO_VSCODE_EXTENSION_ID, NEO_VSCODE_EXTENSION_CONTRACT_VERSION, NEO_SOURCE_CONTRACT_VERSION, NEO_FILE_CONTRACT_VERSION, SOURCE_EXTENSIONS, FILE_CAPABILITIES, REPAIR_ROOT_PREVIEW_LIMIT;
129743
130125
  var init_doctor = __esm({
129744
130126
  "src/commands/doctor.ts"() {
129745
130127
  "use strict";
129746
130128
  init_src();
129747
130129
  init_workspace();
130130
+ init_ui();
129748
130131
  init_workspace_status();
129749
130132
  init_source_diagnostics();
129750
130133
  init_project_documents();
129751
130134
  init_project_integrity();
130135
+ init_project_version_transaction_planning();
130136
+ init_projection();
129752
130137
  NEO_COMPILER_CONTRACT = "ProjectSourceAnalysisV4";
129753
130138
  NEO_VSCODE_EXTENSION_ID = "neocompose.neo-compose-neoscript";
129754
130139
  NEO_VSCODE_EXTENSION_CONTRACT_VERSION = "0.3.0";
@@ -129764,6 +130149,7 @@ var init_doctor = __esm({
129764
130149
  { extension: ".mp3", kind: "audio", mimeType: "audio/mpeg" },
129765
130150
  { extension: ".ogg", kind: "audio", mimeType: "audio/ogg" }
129766
130151
  ];
130152
+ REPAIR_ROOT_PREVIEW_LIMIT = 20;
129767
130153
  }
129768
130154
  });
129769
130155
 
@@ -132781,15 +133167,17 @@ __export(resolve_exports, {
132781
133167
  runResolve: () => runResolve
132782
133168
  });
132783
133169
  import { readFileSync as readFileSync19, rmSync as rmSync8, writeFileSync as writeFileSync14 } from "node:fs";
132784
- import { join as join20 } from "node:path";
133170
+ import { join as join20, relative as relative8 } from "node:path";
132785
133171
  function runResolve(workspace, side) {
133172
+ const marked = readMarkedProjectSourcesV4(workspace.root);
133173
+ assertRecordConflictsResolveTogetherV4(
133174
+ workspace,
133175
+ new Set(marked.map((file) => file.path))
133176
+ );
132786
133177
  const resolvedRecords = adoptServerConflictBases(workspace);
132787
133178
  let resolvedFiles = 0;
132788
- for (const filePath of listProjectSourceFilesV4(workspace.root)) {
132789
- const source = readFileSync19(filePath, "utf8");
132790
- if (detectConflictMarkers(source) === null) continue;
132791
- const resolved = resolveMarkers(source, side);
132792
- writeFileSync14(filePath, resolved, "utf8");
133179
+ for (const file of marked) {
133180
+ writeFileSync14(file.absolutePath, resolveMarkers(file.source, side), "utf8");
132793
133181
  resolvedFiles += 1;
132794
133182
  }
132795
133183
  let resolvedBinaries = 0;
@@ -132828,6 +133216,37 @@ function runResolve(workspace, side) {
132828
133216
  `Resolved ${resolvedFiles} source file(s), ${resolvedRecords} record conflict(s), and ${resolvedBinaries} binary file(s) keeping the "${side}" side. Review with "neo diff", then "neo push".`
132829
133217
  );
132830
133218
  }
133219
+ function readMarkedProjectSourcesV4(root) {
133220
+ const files = [];
133221
+ for (const absolutePath of listProjectSourceFilesV4(root)) {
133222
+ const source = readFileSync19(absolutePath, "utf8");
133223
+ if (detectConflictMarkers(source) === null) continue;
133224
+ files.push({
133225
+ path: normalizeWorkspaceSourcePath(relative8(root, absolutePath)),
133226
+ absolutePath,
133227
+ source
133228
+ });
133229
+ }
133230
+ return files;
133231
+ }
133232
+ function assertRecordConflictsResolveTogetherV4(workspace, markedPaths) {
133233
+ for (const [key, record3] of Object.entries(workspace.state.records)) {
133234
+ const declaringPaths = record3.conflictFiles;
133235
+ if (declaringPaths === void 0) continue;
133236
+ const stillMarked = [];
133237
+ const alreadyResolved = [];
133238
+ for (const declaringPath of declaringPaths) {
133239
+ const path = normalizeWorkspaceSourcePath(declaringPath);
133240
+ if (markedPaths.has(path)) stillMarked.push(path);
133241
+ else alreadyResolved.push(path);
133242
+ }
133243
+ if (stillMarked.length === 0) continue;
133244
+ if (alreadyResolved.length === 0) continue;
133245
+ throw new Error(
133246
+ `The files declaring conflicted record ${key} disagree: ${stillMarked.join(", ")} still holds conflict markers while ${alreadyResolved.join(", ")} does not. Every file that declares one record resolves together \u2014 finish resolving the marked file(s) to the side you already kept, then run "neo resolve".`
133247
+ );
133248
+ }
133249
+ }
132831
133250
  function adoptServerConflictBases(workspace) {
132832
133251
  let adopted = 0;
132833
133252
  for (const [key, record3] of Object.entries(workspace.state.records)) {
@@ -132839,6 +133258,7 @@ function adoptServerConflictBases(workspace) {
132839
133258
  };
132840
133259
  delete next.conflictServerHash;
132841
133260
  delete next.conflictServerData;
133261
+ delete next.conflictFiles;
132842
133262
  workspace.state.records[key] = next;
132843
133263
  adopted += 1;
132844
133264
  }
@@ -132886,6 +133306,7 @@ var init_resolve = __esm({
132886
133306
  init_workspace();
132887
133307
  init_workspace_status();
132888
133308
  init_source_diagnostics();
133309
+ init_workspace_source_path();
132889
133310
  init_project_files();
132890
133311
  }
132891
133312
  });
@@ -132936,7 +133357,7 @@ ${h("Working copy")}
132936
133357
  diff ${d("[--json]")}
132937
133358
  dev ${d("[--push]")}
132938
133359
  resolve ${d("[--mine|--theirs]")}
132939
- doctor ${d("[--json] validate contracts and audit the pulled document")}
133360
+ doctor ${d("[--json] [--fix [--yes]] validate contracts and audit the pulled document")}
132940
133361
 
132941
133362
  ${h("Branches & releases")}
132942
133363
  branch ${d("list | create <name> | switch [ref] | delete <ref> | refresh")}
@@ -133022,11 +133443,14 @@ ${h("NSFunction preview")}
133022
133443
  return `${h("neo doctor")} \u2014 validate project authoring contracts
133023
133444
 
133024
133445
  ${h("Usage")}
133025
- neo doctor ${d("[--json]")}
133446
+ neo doctor ${d("[--json] [--fix [--yes]]")}
133026
133447
 
133027
133448
  Neo validates the format-4 browser compiler, VS Code extension cache envelope,
133028
133449
  tracked source, local .spec.neo compilation, and managed project-file
133029
133450
  capabilities. It does not discover or require .NET.
133451
+
133452
+ ${h("Repair")}
133453
+ ${d("--fix deletes the value rows the pulled document's unprojected-value-row findings name, after listing them and confirming. --yes skips the prompt. --fix cannot be combined with --json.")}
133030
133454
  `;
133031
133455
  }
133032
133456
  if (command === "dialogue") {
@@ -133083,7 +133507,7 @@ There is no --keep-current: preserving current semantic state defines flatten.
133083
133507
  async function main() {
133084
133508
  const args = parseArgs(process.argv.slice(2));
133085
133509
  if (args.command === "--version") {
133086
- console.log("0.43.1");
133510
+ console.log("0.44.0");
133087
133511
  return;
133088
133512
  }
133089
133513
  if (args.command === null || args.command === "help" || args.command === "--help" || args.command === "-h") {
@@ -133168,9 +133592,23 @@ async function main() {
133168
133592
  return;
133169
133593
  }
133170
133594
  case "doctor": {
133595
+ assertAllowedFlags(args, /* @__PURE__ */ new Set(["api", "json", "fix", "yes"]));
133596
+ const json = boolFlag(args, "json");
133597
+ const fix = boolFlag(args, "fix");
133598
+ if (fix && json) {
133599
+ throw new NeoCliUsageError(
133600
+ "neo doctor --fix reports and confirms interactively; it has no --json form. Drop --json, or run `neo doctor --json` to read the findings."
133601
+ );
133602
+ }
133603
+ const yes = boolFlag(args, "yes");
133604
+ if (yes && !fix) {
133605
+ throw new NeoCliUsageError(
133606
+ "neo doctor --yes only answers the --fix confirmation. Add --fix, or drop --yes."
133607
+ );
133608
+ }
133171
133609
  const workspace = loadWorkspaceForCommand(args);
133172
133610
  const { runDoctor: runDoctor2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
133173
- await runDoctor2(workspace, boolFlag(args, "json"));
133611
+ await runDoctor2(workspace, { json, fix, yes });
133174
133612
  return;
133175
133613
  }
133176
133614
  case "branch": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neocompose/cli",
3
- "version": "0.43.1",
3
+ "version": "0.44.0",
4
4
  "description": "Neo Compose native project-source CLI with bidirectional sync.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ description: >-
9
9
  `@neocompose/cli` or `node cli/bin/neo.mjs` in the neo-compose repository.
10
10
  ---
11
11
 
12
- <!-- reviewed-through-cli: 0.43.1 -->
12
+ <!-- reviewed-through-cli: 0.44.0 -->
13
13
 
14
14
  # Neo Compose CLI
15
15
 
@@ -83,7 +83,7 @@ wrappers.
83
83
  The marker near the top of `SKILL.md` must exactly match the package version:
84
84
 
85
85
  ```html
86
- <!-- reviewed-through-cli: 0.43.1 -->
86
+ <!-- reviewed-through-cli: 0.44.0 -->
87
87
  ```
88
88
 
89
89
  The quoted version above is checked too, so this instruction cannot go stale
@@ -86,6 +86,12 @@ Resolve source conflicts by editing the final intended source. Use
86
86
  `neo resolve --mine` or `neo resolve --theirs` only for a deliberate whole-side
87
87
  choice. Pull, inspect, dry-run, and retry; there is no force-CAS path.
88
88
 
89
+ One record is one conflict, however many files declare it. A member declared by
90
+ more than one class is marked in every declaring file, and `neo resolve`
91
+ settles those files together; it refuses a record whose declarations are only
92
+ partly resolved rather than settling the rest to the other side and leaving the
93
+ declarations disagreeing.
94
+
89
95
  Compiled initializer IR is server-owned and does not participate in authored
90
96
  three-way merge decisions. Either `neo resolve` side adopts the server side as
91
97
  the new base for every conflict it settles — `--theirs` then reads clean, and
@@ -185,6 +191,18 @@ row is invisible to `neo status` by construction, which is why `neo doctor` is
185
191
  the only place it surfaces.
186
192
  `neo doctor --json` carries a `repair` entry per finding, which is what a
187
193
  targeted repair is handed instead of scanning a project to rediscover them.
194
+ `neo doctor --fix` consumes those entries for the `unprojected-value-row`
195
+ findings: it lists each orphan root with the number of rows it owns, confirms,
196
+ then deletes them. Two guards make a stale audit fail the commit rather than
197
+ delete a row that has since gained an owner: every row carries the CAS base the
198
+ last pull recorded, and every transaction expects the version head the previous
199
+ one produced, starting from the head that pull left. Adopting an orphan writes
200
+ its new owner rather than the row itself, so only the head guard sees that, and
201
+ a workspace with no recorded head is refused rather than written blind. More
202
+ rows than one transaction budgets for go up in batches sized from the shared
203
+ cost model against the immediate commit path. `--yes` answers the confirmation
204
+ for scripted runs, and `--fix` has no `--json` form. No other finding kind has
205
+ an automatic repair.
188
206
 
189
207
  The Node CLI talks directly to authenticated Convex APIs through the
190
208
  session-gated CAS boundary. Tokens use the OS credential store when available