@neocompose/cli 0.25.0 → 0.25.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/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.2] - 2026-08-09
4
+
5
+ ### Added
6
+
7
+ - `neo doctor` now audits the pulled document itself, not just this working
8
+ copy's authoring contracts. A project can be perfectly clean to push and
9
+ still hold malformed records, and the first audit catches value rows whose
10
+ storage partition disagrees with their placement parent. `--json` carries a
11
+ `repair` entry per finding, which is what lets the matching repair touch only
12
+ the affected records instead of scanning a project to rediscover them.
13
+
14
+ ## [0.25.1] - 2026-08-09
15
+
16
+ ### Changed
17
+
18
+ - Stop `First`, `FirstOrDefault`, and collection `Contains` scans as soon as their result is known. Terminal scans now resolve and charge only visited entries, preserve list/dictionary order and value-reference identity, and avoid intermediate entry arrays (P55).
19
+
20
+ ### Fixed
21
+
22
+ - Seed the owned rows for a field a newly declared member adds to a
23
+ row-backed class default, instead of refusing the push with "no existing
24
+ owned value row". Adding an entry to a row-backed _list_ default has always
25
+ minted its rows; a class field now takes the same path, so replacing a class
26
+ member (`Name = "Blue"` → `Kind = .Blue` across every default that sets it)
27
+ pushes without editing each default in the web editor first. An `@id` on the
28
+ new field is honoured when it names an existing row and, when it does not,
29
+ held to the same UUID v4 identity rule list entries follow.
30
+ - Carry only the new rows and the ancestors that reach them in a member value
31
+ seed. Naming every row the pass touched made the seed depend on which
32
+ constructions the materialization cache preserved that run — so a push and
33
+ its own trusted re-lowering disagreed — and handed back rows stripped of the
34
+ `constructorArgs` a seed row cannot carry, which then failed to project back
35
+ to source. Pushing a new row anywhere under `root.Assets` previously failed
36
+ with "Authored value row … is missing memberId".
37
+ - Drop a stored class body key whose member the push deletes, instead of
38
+ carrying it into a schema that no longer has it while the same push
39
+ tombstones the row it names.
40
+ - Treat a member value seed as pushable work on its own. Adding a row to an
41
+ unordered list moves no stored record — membership is the new row's
42
+ `containerId` — so `neo status` and `neo push` reported a clean working copy
43
+ and silently dropped it. `neo status` now reports pending seeds too.
44
+ - Accept a stored row that spells "no class" as an explicit `null`, and adopt
45
+ the declared storage partition for an unstamped row reached through its
46
+ owning slot, in constructed static value graph validation. Rows materialized
47
+ before their partition was stamped (object-tree `Enabled`, P41 §4.3) sit in
48
+ main under a partitioned parent and failed a push over data it never wrote. A
49
+ row already stamped for a different partition is still rejected.
50
+
3
51
  ## [0.25.0] - 2026-08-07
4
52
 
5
53
  ### Added
package/dist/neo.mjs CHANGED
@@ -61613,11 +61613,16 @@ function evalFunction(fn, scope, ctx) {
61613
61613
  }
61614
61614
  return c.includes(target);
61615
61615
  }
61616
- if (Array.isArray(c) && typeof target === "string" && c.includes(target)) {
61617
- return true;
61618
- }
61619
- const entries = collectionEntries(c, ctx);
61620
- return entries.some((e) => jsEqual(e, target));
61616
+ const targetReferenceId = typeof target === "string" ? target : findKnownRowIdByValueReference(target, ctx);
61617
+ let containsResolvedEntry = false;
61618
+ iterateCollection(c, ctx, (entry, _key, valueId) => {
61619
+ if (valueId !== null && valueId === targetReferenceId || jsEqual(entry, target)) {
61620
+ containsResolvedEntry = true;
61621
+ return 1 /* Break */;
61622
+ }
61623
+ return 0 /* Continue */;
61624
+ });
61625
+ return containsResolvedEntry;
61621
61626
  }
61622
61627
  case "decimalOp" /* decimalOp */: {
61623
61628
  const info = fn.info;
@@ -61772,6 +61777,7 @@ function evalFunction(fn, scope, ctx) {
61772
61777
  out[String(key)] = valueId ?? entry;
61773
61778
  }
61774
61779
  }
61780
+ return 0 /* Continue */;
61775
61781
  });
61776
61782
  return out;
61777
61783
  }
@@ -61783,10 +61789,9 @@ function evalFunction(fn, scope, ctx) {
61783
61789
  const sentinel = /* @__PURE__ */ Symbol("not-found");
61784
61790
  let found = sentinel;
61785
61791
  iterateCollection(c, ctx, (entry, key) => {
61786
- if (found !== sentinel) return;
61787
61792
  if (!innerFn) {
61788
61793
  found = entry;
61789
- return;
61794
+ return 1 /* Break */;
61790
61795
  }
61791
61796
  consumeBudget(ctx, "workUnits", 1, "work unit");
61792
61797
  const innerScope = pushParams(
@@ -61803,7 +61808,9 @@ function evalFunction(fn, scope, ctx) {
61803
61808
  );
61804
61809
  if (result.kind === "return" && result.value === true) {
61805
61810
  found = entry;
61811
+ return 1 /* Break */;
61806
61812
  }
61813
+ return 0 /* Continue */;
61807
61814
  });
61808
61815
  if (found !== sentinel) return found;
61809
61816
  if (fn.type === "first" /* first */) {
@@ -61841,6 +61848,7 @@ function evalFunction(fn, scope, ctx) {
61841
61848
  );
61842
61849
  out.push(result.value);
61843
61850
  }
61851
+ return 0 /* Continue */;
61844
61852
  });
61845
61853
  return out;
61846
61854
  }
@@ -64304,6 +64312,7 @@ function snapshotCollectionMembership(collection, ctx) {
64304
64312
  const valid = forEachRawCollectionEntry(collection, ({ raw }) => {
64305
64313
  consumeBudget(ctx, "collectionVisits", 1, "collection visit");
64306
64314
  membership.push(raw);
64315
+ return 0 /* Continue */;
64307
64316
  });
64308
64317
  if (valid) return membership;
64309
64318
  throw new NSGetterRuntimeError(
@@ -64314,21 +64323,23 @@ function forEachRawCollectionEntry(c, callback) {
64314
64323
  if (Array.isArray(c)) {
64315
64324
  for (let key = 0; key < c.length; key += 1) {
64316
64325
  const raw = c[key];
64317
- callback({
64326
+ const control = callback({
64318
64327
  raw,
64319
64328
  key,
64320
64329
  valueId: typeof raw === "string" ? raw : null
64321
64330
  });
64331
+ if (control === 1 /* Break */) break;
64322
64332
  }
64323
64333
  return true;
64324
64334
  }
64325
64335
  if (typeof c === "object" && c !== null) {
64326
64336
  for (const [key, raw] of Object.entries(c)) {
64327
- callback({
64337
+ const control = callback({
64328
64338
  raw,
64329
64339
  key,
64330
64340
  valueId: typeof raw === "string" ? raw : null
64331
64341
  });
64342
+ if (control === 1 /* Break */) break;
64332
64343
  }
64333
64344
  return true;
64334
64345
  }
@@ -64339,6 +64350,7 @@ function collectionEntries(c, ctx) {
64339
64350
  forEachRawCollectionEntry(c, ({ raw }) => {
64340
64351
  consumeBudget(ctx, "collectionVisits", 1, "collection visit");
64341
64352
  entries.push(resolveValueIfId(raw, ctx));
64353
+ return 0 /* Continue */;
64342
64354
  });
64343
64355
  return entries;
64344
64356
  }
@@ -64346,7 +64358,7 @@ function iterateCollection(c, ctx, callback) {
64346
64358
  forEachRawCollectionEntry(c, ({ raw, key, valueId }) => {
64347
64359
  consumeBudget(ctx, "collectionVisits", 1, "collection visit");
64348
64360
  const entry = resolveValueIfId(raw, ctx);
64349
- callback(entry, key, valueId);
64361
+ return callback(entry, key, valueId);
64350
64362
  });
64351
64363
  }
64352
64364
  function pushParams(parent, parameters, positional, isList) {
@@ -78101,13 +78113,16 @@ var init_project_version_static_value_writes = __esm({
78101
78113
  );
78102
78114
  }
78103
78115
  const normalizedExpected = normalizeMapKey(args.expectedMapKey);
78116
+ const normalizedStored = normalizeMapKey(value.mapKey);
78104
78117
  if (this.createdValueIds.has(value.id)) {
78105
78118
  this.visitedCreatedValueIds.add(value.id);
78106
78119
  if (normalizedExpected === null) delete value.mapKey;
78107
78120
  else value.mapKey = normalizedExpected;
78108
- } else if (normalizeMapKey(value.mapKey) !== normalizedExpected) {
78121
+ } else if (normalizedStored === null && normalizedExpected !== null) {
78122
+ value.mapKey = normalizedExpected;
78123
+ } else if (normalizedStored !== normalizedExpected) {
78109
78124
  throw new Error(
78110
- `${args.path} cannot bind value "${value.id}" from storage partition ${JSON.stringify(normalizeMapKey(value.mapKey) ?? "main")}; expected ${JSON.stringify(normalizedExpected ?? "main")}.`
78125
+ `${args.path} cannot bind value "${value.id}" from storage partition ${JSON.stringify(normalizedStored ?? "main")}; expected ${JSON.stringify(normalizedExpected ?? "main")}.`
78111
78126
  );
78112
78127
  }
78113
78128
  if (args.graphValueIds.has(value.id)) {
@@ -78144,7 +78159,7 @@ var init_project_version_static_value_writes = __esm({
78144
78159
  }
78145
78160
  return;
78146
78161
  }
78147
- if (member.kind !== 7 /* Class */ && value.classId !== void 0) {
78162
+ if (member.kind !== 7 /* Class */ && value.classId !== void 0 && value.classId !== null) {
78148
78163
  throw new Error(
78149
78164
  `${path} value "${value.id}" has classId but the member is not Class.`
78150
78165
  );
@@ -86434,6 +86449,7 @@ function buildValueLowerContext(state, manifest, options = {}) {
86434
86449
  pendingValues: registry.pendingValues,
86435
86450
  pendingLocalizedTexts: registry.pendingLocalizedTexts,
86436
86451
  loweredMemberIdByValueId: /* @__PURE__ */ new Map(),
86452
+ storedClassSchemaMemberIds: /* @__PURE__ */ new Map(),
86437
86453
  pendingBindingMembersByClassId: registry.pendingBindingMembersByClassId,
86438
86454
  declaredConstructors: declaredConstructorIndex,
86439
86455
  initAuthoredRowIds: seedInitAuthoredRowIds(
@@ -86657,14 +86673,12 @@ function lowerMemberDefaultSourcesV4(state, analysis, manifest, options = {}) {
86657
86673
  (row) => !existingPendingValueIds.has(row.id)
86658
86674
  );
86659
86675
  if (pendingRows.length > 0) {
86660
- const existingRows = [...context.reconstructed].filter(
86661
- ([key, record3]) => !existingReconstructedKeys.has(key) && record3.recordKind === "value"
86662
- ).map(
86663
- ([, record3]) => staticValueSeedRow(
86664
- record3.fileFields,
86665
- context.loweredMemberIdByValueId.get(record3.recordId)
86666
- )
86667
- );
86676
+ const existingRows = pendingSeedGraphRows({
86677
+ context,
86678
+ reconstructedBefore: existingReconstructedKeys,
86679
+ pendingRows,
86680
+ rootBody: lowered.value
86681
+ });
86668
86682
  const bindingMembers = [
86669
86683
  ...context.pendingBindingMembersByClassId.values()
86670
86684
  ].filter(
@@ -87247,21 +87261,49 @@ function lowerRowBackedDefault(context, member, backed, baseData3, binding) {
87247
87261
  `Default value ${binding.label}.${assignment.name} is constructor-projected and must be set through its named constructor argument.`
87248
87262
  );
87249
87263
  }
87264
+ const childValueMember = recursivePartialMember(member, childMember);
87265
+ const childSlice = assignmentSlices.get(assignment.name);
87250
87266
  const existingId = isObjectRecord2(baseBody) ? baseBody[assignment.name] : void 0;
87251
- const childId = typeof existingId === "string" ? existingId : annotatedValue(assignment.value).id;
87252
- if (childId === null || childId === void 0) {
87267
+ const annotatedId = annotatedValue(assignment.value).id;
87268
+ const storedId = typeof existingId === "string" ? existingId : annotatedId;
87269
+ if (storedId !== null && storedId !== void 0) {
87270
+ if (typeof existingId === "string" || context.state[`value:${storedId}`] !== void 0) {
87271
+ body[assignment.name] = lowerValueRow(
87272
+ context,
87273
+ childValueMember,
87274
+ assignment.value,
87275
+ storedId,
87276
+ binding,
87277
+ environment,
87278
+ childSlice
87279
+ );
87280
+ continue;
87281
+ }
87282
+ if (!isPendingId(storedId) && !isUuidV4Id(storedId)) {
87283
+ context.loweringFailures.push({
87284
+ message: `Default value ${binding.label}.${assignment.name} is annotated @id("${storedId}"), which names no existing row, and a new row's durable identity must be a UUID v4. Fix the id if it meant an existing row, or drop the @id to mint a fresh identity.`,
87285
+ site: referenceSite(binding, assignment.value)
87286
+ });
87287
+ }
87288
+ }
87289
+ const placedId = sourceValueSymbol(context, assignment.value);
87290
+ if (placedId !== null) {
87253
87291
  throw new Error(
87254
- `Default value ${binding.label}.${assignment.name} has no existing owned value row. Creating new default rows from source is not implemented by this slice.`
87292
+ `Default value ${binding.label}.${assignment.name} cannot place existing value ${placedId} into a new structural slot. Create the nested value inline so the atomic construction transaction can own it.`
87255
87293
  );
87256
87294
  }
87257
- body[assignment.name] = lowerValueRow(
87295
+ body[assignment.name] = lowerSeedChild(
87258
87296
  context,
87259
- recursivePartialMember(member, childMember),
87297
+ childValueMember,
87260
87298
  assignment.value,
87261
- childId,
87262
87299
  binding,
87300
+ `${binding.label}.${assignment.name}`,
87301
+ /* @__PURE__ */ new Map(),
87302
+ /* @__PURE__ */ new Map(),
87303
+ void 0,
87263
87304
  environment,
87264
- assignmentSlices.get(assignment.name)
87305
+ void 0,
87306
+ childSlice
87265
87307
  );
87266
87308
  }
87267
87309
  return {
@@ -87402,14 +87444,13 @@ function lowerStoredBinding(context, binding, memberValueIds, seeds) {
87402
87444
  );
87403
87445
  if (pendingRows.length > 0) {
87404
87446
  const root = reconstructedOrStateValue(context, valueId);
87405
- const existingRows = [...context.reconstructed].filter(
87406
- ([key, record3]) => key !== `value:${valueId}` && !existingReconstructedKeys.has(key) && record3.recordKind === "value"
87407
- ).map(
87408
- ([, record3]) => staticValueSeedRow(
87409
- record3.fileFields,
87410
- context.loweredMemberIdByValueId.get(record3.recordId)
87411
- )
87412
- );
87447
+ const existingRows = pendingSeedGraphRows({
87448
+ context,
87449
+ reconstructedBefore: existingReconstructedKeys,
87450
+ pendingRows,
87451
+ rootBody: root.value,
87452
+ rootValueId: valueId
87453
+ });
87413
87454
  const bindingMembers = [
87414
87455
  ...context.pendingBindingMembersByClassId.values()
87415
87456
  ].filter((pending) => !existingPendingBindingMemberIds.has(pending.id));
@@ -87444,6 +87485,105 @@ function memberValueId(memberId) {
87444
87485
  function reconstructedOrStateValue(context, valueId) {
87445
87486
  return context.reconstructed.get(`value:${valueId}`)?.fileFields ?? valueBase(context, valueId);
87446
87487
  }
87488
+ function pendingSeedGraphRows(args) {
87489
+ const candidates = /* @__PURE__ */ new Map();
87490
+ for (const [key, record3] of args.context.reconstructed) {
87491
+ if (args.reconstructedBefore.has(key)) continue;
87492
+ if (record3.recordKind !== "value") continue;
87493
+ if (record3.recordId === args.rootValueId) continue;
87494
+ candidates.set(record3.recordId, record3.fileFields);
87495
+ }
87496
+ const pendingIds = new Set(args.pendingRows.map((row) => row.id));
87497
+ const bodies = /* @__PURE__ */ new Map();
87498
+ for (const [id2, fields] of candidates) bodies.set(id2, fields.value);
87499
+ for (const row of args.pendingRows) bodies.set(row.id, row.value);
87500
+ const containedIds = /* @__PURE__ */ new Map();
87501
+ const addContained = (containerId, id2) => {
87502
+ if (typeof containerId !== "string") return;
87503
+ const siblings = containedIds.get(containerId);
87504
+ if (siblings === void 0) containedIds.set(containerId, [id2]);
87505
+ else siblings.push(id2);
87506
+ };
87507
+ for (const [id2, fields] of candidates) addContained(fields.containerId, id2);
87508
+ for (const row of args.pendingRows) addContained(row.containerId, row.id);
87509
+ const collectReferenced = (body, into) => {
87510
+ if (typeof body === "string") {
87511
+ if (bodies.has(body)) into.add(body);
87512
+ return;
87513
+ }
87514
+ if (Array.isArray(body)) {
87515
+ for (const entry of body) collectReferenced(entry, into);
87516
+ return;
87517
+ }
87518
+ if (!isObjectRecord2(body)) return;
87519
+ for (const entry of Object.values(body)) collectReferenced(entry, into);
87520
+ };
87521
+ const childIds = (id2) => {
87522
+ const children = new Set(containedIds.get(id2) ?? []);
87523
+ collectReferenced(bodies.get(id2), children);
87524
+ children.delete(id2);
87525
+ return children;
87526
+ };
87527
+ const reachesPending = /* @__PURE__ */ new Map();
87528
+ const visit = (id2) => {
87529
+ const settled = reachesPending.get(id2);
87530
+ if (settled !== void 0) return settled;
87531
+ reachesPending.set(id2, false);
87532
+ let reaches = pendingIds.has(id2);
87533
+ for (const childId of childIds(id2)) {
87534
+ if (visit(childId)) reaches = true;
87535
+ }
87536
+ reachesPending.set(id2, reaches);
87537
+ return reaches;
87538
+ };
87539
+ const roots = new Set(
87540
+ args.rootValueId === void 0 ? [] : containedIds.get(args.rootValueId) ?? []
87541
+ );
87542
+ collectReferenced(args.rootBody, roots);
87543
+ for (const id2 of roots) visit(id2);
87544
+ const rows = [];
87545
+ for (const [id2, fields] of candidates) {
87546
+ if (reachesPending.get(id2) !== true) continue;
87547
+ rows.push(
87548
+ staticValueSeedRow(fields, args.context.loweredMemberIdByValueId.get(id2))
87549
+ );
87550
+ }
87551
+ return rows;
87552
+ }
87553
+ function retainedStoredClassBody(context, storedClassId, baseBody) {
87554
+ const storedMemberIds = storedClassSchemaMemberIds(context, storedClassId);
87555
+ const body = {};
87556
+ for (const [key, childId] of Object.entries(baseBody)) {
87557
+ const storedMemberId = storedMemberIds.get(key);
87558
+ if (storedMemberId !== void 0 && !context.members.has(storedMemberId)) {
87559
+ continue;
87560
+ }
87561
+ body[key] = childId;
87562
+ }
87563
+ return body;
87564
+ }
87565
+ function storedClassSchemaMemberIds(context, classId) {
87566
+ const cached = context.storedClassSchemaMemberIds.get(classId);
87567
+ if (cached !== void 0) return cached;
87568
+ const memberIds = /* @__PURE__ */ new Map();
87569
+ let current = classId;
87570
+ const visited = /* @__PURE__ */ new Set();
87571
+ while (current !== null && !visited.has(current)) {
87572
+ visited.add(current);
87573
+ const data = stateData(context, "class", current);
87574
+ if (data === null) break;
87575
+ if (isObjectRecord2(data.schema)) {
87576
+ for (const [key, memberId] of Object.entries(data.schema)) {
87577
+ if (typeof memberId === "string" && !memberIds.has(key)) {
87578
+ memberIds.set(key, memberId);
87579
+ }
87580
+ }
87581
+ }
87582
+ current = stringOrNull(data.extendsClassId);
87583
+ }
87584
+ context.storedClassSchemaMemberIds.set(classId, memberIds);
87585
+ return memberIds;
87586
+ }
87447
87587
  function staticValueSeedRow(value, loweredMemberId) {
87448
87588
  const id2 = stringOrNull(value.id);
87449
87589
  if (id2 === null) throw new Error("Authored value row is missing id.");
@@ -88195,7 +88335,11 @@ function lowerClassValue(context, member, expression, base, source, outerEnviron
88195
88335
  String(base.id ?? member.name)
88196
88336
  );
88197
88337
  const baseBody = isObjectRecord2(base.value) ? base.value : {};
88198
- const body = { ...baseBody };
88338
+ const body = retainedStoredClassBody(
88339
+ context,
88340
+ currentClassId,
88341
+ baseBody
88342
+ );
88199
88343
  const assignmentSlices = objectInitializerSlices(authoredSlice);
88200
88344
  if (materializedConstruction !== "preserve") {
88201
88345
  lowerConstructorProjections(
@@ -96237,6 +96381,181 @@ var init_scaffold = __esm({
96237
96381
  }
96238
96382
  });
96239
96383
 
96384
+ // src/project-source/project-integrity.ts
96385
+ function auditProjectIntegrity(workspace) {
96386
+ const records2 = workspace.state.records;
96387
+ const values = collectValueRows(records2);
96388
+ const placements = collectPlacements(values);
96389
+ const memberIdBySchemaKey = collectClassSchemas(records2);
96390
+ const storageKeyByMemberId = collectMemberStorageKeys(records2);
96391
+ const findings = [];
96392
+ for (const value of values.values()) {
96393
+ const placement = placements.get(value.id);
96394
+ if (placement === void 0) continue;
96395
+ const declaration = childStorageKeyDeclaration({
96396
+ placement,
96397
+ memberIdBySchemaKey,
96398
+ storageKeyByMemberId
96399
+ });
96400
+ if (declaration === null) continue;
96401
+ const expected = resolveMapKeyForCreatedValue({
96402
+ declaration,
96403
+ parentMapKey: placement.parent.mapKey,
96404
+ parentClassId: placement.parent.classId ?? void 0
96405
+ });
96406
+ const normalizedExpected = normalizePartition(expected);
96407
+ const normalizedActual = normalizePartition(value.mapKey);
96408
+ if (normalizedExpected === normalizedActual) continue;
96409
+ findings.push({
96410
+ kind: "value-storage-partition",
96411
+ recordKind: "value",
96412
+ recordId: value.id,
96413
+ message: `Value "${value.id}" is stored in partition ${describePartition(normalizedActual)} but its placement parent "${placement.parent.id}" puts it in ${describePartition(normalizedExpected)}.`,
96414
+ repair: {
96415
+ valueId: value.id,
96416
+ parentValueId: placement.parent.id,
96417
+ schemaKey: placement.schemaKey,
96418
+ actualMapKey: normalizedActual,
96419
+ expectedMapKey: normalizedExpected
96420
+ }
96421
+ });
96422
+ }
96423
+ return findings.sort(
96424
+ (left, right) => left.recordId.localeCompare(right.recordId)
96425
+ );
96426
+ }
96427
+ function childStorageKeyDeclaration(args) {
96428
+ const { placement } = args;
96429
+ if (placement.schemaKey === UNORDERED_MEMBERSHIP_KEY) {
96430
+ return normalizeStorageKeyDeclaration(void 0);
96431
+ }
96432
+ const parentClassId = placement.parent.classId;
96433
+ if (parentClassId === null) return null;
96434
+ const memberId = args.memberIdBySchemaKey.get(parentClassId)?.get(placement.schemaKey);
96435
+ if (memberId === void 0) return null;
96436
+ const declaration = args.storageKeyByMemberId.get(memberId);
96437
+ if (declaration === void 0) return null;
96438
+ return declaration;
96439
+ }
96440
+ function collectValueRows(records2) {
96441
+ const values = /* @__PURE__ */ new Map();
96442
+ for (const record3 of Object.values(records2)) {
96443
+ if (record3.recordKind !== "value") continue;
96444
+ const data = record3.data;
96445
+ if (!isObjectRecord2(data)) continue;
96446
+ values.set(record3.recordId, {
96447
+ id: record3.recordId,
96448
+ classId: typeof data.classId === "string" ? data.classId : null,
96449
+ mapKey: typeof data.mapKey === "string" ? data.mapKey : null,
96450
+ containerId: typeof data.containerId === "string" ? data.containerId : null,
96451
+ value: data.value
96452
+ });
96453
+ }
96454
+ return values;
96455
+ }
96456
+ function collectPlacements(values) {
96457
+ const placements = /* @__PURE__ */ new Map();
96458
+ const claim = (childId, placement) => {
96459
+ if (values.has(childId) && !placements.has(childId)) {
96460
+ placements.set(childId, placement);
96461
+ }
96462
+ };
96463
+ for (const parent of values.values()) {
96464
+ const body = parent.value;
96465
+ if (isObjectRecord2(body)) {
96466
+ for (const [schemaKey, childId] of Object.entries(body)) {
96467
+ if (typeof childId === "string") claim(childId, { parent, schemaKey });
96468
+ }
96469
+ continue;
96470
+ }
96471
+ if (!Array.isArray(body)) continue;
96472
+ for (const childId of body) {
96473
+ if (typeof childId === "string") {
96474
+ claim(childId, { parent, schemaKey: UNORDERED_MEMBERSHIP_KEY });
96475
+ }
96476
+ }
96477
+ }
96478
+ for (const value of values.values()) {
96479
+ if (value.containerId === null) continue;
96480
+ const container = values.get(value.containerId);
96481
+ if (container === void 0) continue;
96482
+ claim(value.id, { parent: container, schemaKey: UNORDERED_MEMBERSHIP_KEY });
96483
+ }
96484
+ return placements;
96485
+ }
96486
+ function collectClassSchemas(records2) {
96487
+ const schemas = /* @__PURE__ */ new Map();
96488
+ for (const record3 of Object.values(records2)) {
96489
+ if (record3.recordKind !== "class") continue;
96490
+ const data = record3.data;
96491
+ if (!isObjectRecord2(data) || !isObjectRecord2(data.schema)) continue;
96492
+ const schema = /* @__PURE__ */ new Map();
96493
+ for (const [schemaKey, memberId] of Object.entries(data.schema)) {
96494
+ if (typeof memberId === "string") schema.set(schemaKey, memberId);
96495
+ }
96496
+ schemas.set(record3.recordId, schema);
96497
+ }
96498
+ for (const record3 of Object.values(records2)) {
96499
+ if (record3.recordKind !== "class") continue;
96500
+ const own = schemas.get(record3.recordId);
96501
+ if (own === void 0) continue;
96502
+ const seen = /* @__PURE__ */ new Set([record3.recordId]);
96503
+ let data = record3.data;
96504
+ while (isObjectRecord2(data) && typeof data.extendsClassId === "string") {
96505
+ const parentId = data.extendsClassId;
96506
+ if (seen.has(parentId)) break;
96507
+ seen.add(parentId);
96508
+ for (const [schemaKey, memberId] of schemas.get(parentId) ?? []) {
96509
+ if (!own.has(schemaKey)) own.set(schemaKey, memberId);
96510
+ }
96511
+ const parentRecord = records2[`class:${parentId}`];
96512
+ data = parentRecord?.data;
96513
+ }
96514
+ }
96515
+ return schemas;
96516
+ }
96517
+ function collectMemberStorageKeys(records2) {
96518
+ const declarations = /* @__PURE__ */ new Map();
96519
+ for (const record3 of Object.values(records2)) {
96520
+ if (record3.recordKind !== "member") continue;
96521
+ let data = record3.data;
96522
+ const seen = /* @__PURE__ */ new Set([record3.recordId]);
96523
+ let declaration;
96524
+ while (isObjectRecord2(data)) {
96525
+ if (typeof data.storageKey === "string" && data.storageKey.length > 0) {
96526
+ declaration = data.storageKey;
96527
+ break;
96528
+ }
96529
+ if (typeof data.extendsMemberId !== "string") break;
96530
+ const parentId = data.extendsMemberId;
96531
+ if (seen.has(parentId)) break;
96532
+ seen.add(parentId);
96533
+ data = records2[`member:${parentId}`]?.data;
96534
+ }
96535
+ declarations.set(
96536
+ record3.recordId,
96537
+ normalizeStorageKeyDeclaration(declaration)
96538
+ );
96539
+ }
96540
+ return declarations;
96541
+ }
96542
+ function normalizePartition(mapKey) {
96543
+ if (mapKey === null || mapKey.length === 0) return null;
96544
+ return mapKey === MAIN_STORAGE_PARTITION ? null : mapKey;
96545
+ }
96546
+ function describePartition(mapKey) {
96547
+ return mapKey === null ? `"${MAIN_STORAGE_PARTITION}"` : `"${mapKey}"`;
96548
+ }
96549
+ var UNORDERED_MEMBERSHIP_KEY;
96550
+ var init_project_integrity = __esm({
96551
+ "src/project-source/project-integrity.ts"() {
96552
+ "use strict";
96553
+ init_member_storage_key();
96554
+ init_projection();
96555
+ UNORDERED_MEMBERSHIP_KEY = "(container)";
96556
+ }
96557
+ });
96558
+
96240
96559
  // src/neoscript-language-adapter.ts
96241
96560
  function createCliNeoScriptContext(options) {
96242
96561
  const members = [...options.documents.members];
@@ -100954,7 +101273,9 @@ ${blockingErrors.map((error) => ` ${error.message}`).join("\n")}`
100954
101273
  changes: status.changes,
100955
101274
  binaryChanges: status.binaryChanges ?? []
100956
101275
  });
100957
- if (status.changes.length === 0) return status;
101276
+ if (status.changes.length === 0 && status.authoredValueSeeds.size === 0) {
101277
+ return status;
101278
+ }
100958
101279
  const initConversions = status.initConversions ?? [];
100959
101280
  if (initConversions.length > 0 && options.json !== true) {
100960
101281
  for (const line of describeInitConversions(initConversions)) warn(line);
@@ -101059,7 +101380,7 @@ async function runPush(workspace, options, preparationOverride) {
101059
101380
  const status = candidate.status;
101060
101381
  const local = candidate.preparedLocal;
101061
101382
  preparation?.stop();
101062
- if (status.changes.length === 0) {
101383
+ if (status.changes.length === 0 && status.authoredValueSeeds.size === 0) {
101063
101384
  if (options.json === true) {
101064
101385
  console.log(
101065
101386
  JSON.stringify({
@@ -101575,7 +101896,7 @@ async function prepareLocalCandidateV4(workspace, options = {}) {
101575
101896
  let sourceHash;
101576
101897
  let preparedLocal = null;
101577
101898
  let documentStatus = status;
101578
- if (status.changes.length > 0) {
101899
+ if (status.changes.length > 0 || status.authoredValueSeeds.size > 0) {
101579
101900
  try {
101580
101901
  documentStatus = pushOptions.dryRun ? cloneStatusForDryRun(status) : status;
101581
101902
  preparedLocal = await prepareLocalPushArtifactsV4(
@@ -103116,7 +103437,7 @@ var init_registry2 = __esm({
103116
103437
  PROJECT_SCHEMA_CONTRACT = Object.freeze({
103117
103438
  formatVersion: 3,
103118
103439
  contractVersion: "3.9",
103119
- cliVersion: "0.25.0",
103440
+ cliVersion: "0.25.2",
103120
103441
  projectFileUploadBatchSize: 32,
103121
103442
  documentRecords: {
103122
103443
  member: {
@@ -105898,8 +106219,10 @@ function inspectNeoDoctor(workspace) {
105898
106219
  errors: [],
105899
106220
  compatible: true
105900
106221
  };
106222
+ const findings = auditProjectIntegrity(workspace);
105901
106223
  return {
105902
106224
  ok: formatCompatible && compiler.compatible && extension.compatible && source.compatible && files.compatible,
106225
+ document: { findings, consistent: findings.length === 0 },
105903
106226
  format: {
105904
106227
  current: workspace.config.formatVersion,
105905
106228
  expected: CURRENT_FORMAT_VERSION,
@@ -106158,6 +106481,12 @@ async function runDoctor(workspace, json) {
106158
106481
  console.log(
106159
106482
  ` Files: contract v${String(report.files.contractVersion)}, SHA-256, ${String(report.files.trackedBinaryCount)} tracked binar${report.files.trackedBinaryCount === 1 ? "y" : "ies"}, workspace ${report.files.workspaceReadable ? "readable" : "not readable"}/${report.files.workspaceWritable ? "writable" : "not writable"}`
106160
106483
  );
106484
+ console.log(
106485
+ ` Document: ${report.document.consistent ? "no malformed records" : `${String(report.document.findings.length)} malformed record${report.document.findings.length === 1 ? "" : "s"} (from the last pull)`}`
106486
+ );
106487
+ for (const finding of report.document.findings) {
106488
+ console.log(` Document defect (${finding.kind}): ${finding.message}`);
106489
+ }
106161
106490
  if (report.compiler.error)
106162
106491
  console.log(` Compiler error: ${report.compiler.error}`);
106163
106492
  if (report.extension.error)
@@ -106169,6 +106498,11 @@ async function runDoctor(workspace, json) {
106169
106498
  console.log(
106170
106499
  report.ok ? "Project authoring contracts are compatible." : "Project authoring contracts need attention."
106171
106500
  );
106501
+ if (!report.document.consistent) {
106502
+ console.log(
106503
+ `${String(report.document.findings.length)} malformed record${report.document.findings.length === 1 ? "" : "s"} in the pulled document \u2014 see \`--json\` for the repair entries.`
106504
+ );
106505
+ }
106172
106506
  if (!report.ok) process.exitCode = 1;
106173
106507
  }
106174
106508
  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;
@@ -106180,6 +106514,7 @@ var init_doctor = __esm({
106180
106514
  init_workspace_status();
106181
106515
  init_source_diagnostics();
106182
106516
  init_project_documents();
106517
+ init_project_integrity();
106183
106518
  NEO_COMPILER_CONTRACT = "ProjectSourceAnalysisV4";
106184
106519
  NEO_VSCODE_EXTENSION_ID = "neocompose.neo-compose-neoscript";
106185
106520
  NEO_VSCODE_EXTENSION_CONTRACT_VERSION = "0.3.0";
@@ -109156,7 +109491,7 @@ ${h("Working copy")}
109156
109491
  test ${d("[file-or-dir ...] [-t <pattern>] [--reporter=json] [--outputFile <path>]")}
109157
109492
  status ${d("[--json]")} diff ${d("[--json]")}
109158
109493
  dev ${d("[--push]")} resolve ${d("[--mine|--theirs]")}
109159
- doctor ${d("[--json]")} ${d("validate format/compiler/editor/source/file contracts")}
109494
+ doctor ${d("[--json]")} ${d("validate contracts and audit the pulled document")}
109160
109495
 
109161
109496
  ${h("Pending source scaffolds")}
109162
109497
  class ${d("new <Name> [--abstract]")}
@@ -109811,7 +110146,12 @@ async function main() {
109811
110146
  `${paintChangeKind(binary.action === "create" ? "create" : "update")} project-file bytes ${binary.symbol} (${binary.path}) \u2014 ${binary.action}`
109812
110147
  );
109813
110148
  }
109814
- if (status.conflictedFiles.length === 0 && status.parseErrors.length === 0 && status.changes.length === 0 && (status.binaryChanges?.length ?? 0) === 0) {
110149
+ if (status.authoredValueSeeds.size > 0) {
110150
+ console.log(
110151
+ ` ${paintChangeKind("create")} ${status.authoredValueSeeds.size} member value seed(s)`
110152
+ );
110153
+ }
110154
+ if (status.conflictedFiles.length === 0 && status.parseErrors.length === 0 && status.changes.length === 0 && status.authoredValueSeeds.size === 0 && (status.binaryChanges?.length ?? 0) === 0) {
109815
110155
  console.log(`${sym.ok} Working copy is clean.`);
109816
110156
  }
109817
110157
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neocompose/cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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.25.0 -->
12
+ <!-- reviewed-through-cli: 0.25.2 -->
13
13
 
14
14
  # Neo Compose CLI
15
15
 
@@ -41,11 +41,12 @@ Read the relevant specs in full:
41
41
  - [P50 loops](https://github.com/ryanbliss/neo-compose-specs/blob/main/new-features/p50-neoscript-for-and-foreach-loops.md)
42
42
  - [P51 switch](https://github.com/ryanbliss/neo-compose-specs/blob/main/new-features/p51-neoscript-switch-statements.md)
43
43
  - [P52 try/catch](https://github.com/ryanbliss/neo-compose-specs/blob/main/new-features/p52-neoscript-try-catch-blocks.md)
44
+ - [P55 breakable collection iteration](https://github.com/ryanbliss/neo-compose-specs/blob/main/neoscript/p55-breakable-collection-iteration.md)
44
45
  - [P60 delegate parameters and selector targeting](https://github.com/ryanbliss/neo-compose-specs/blob/main/new-features/p60-closure-parameters-and-selector-targeting.md)
45
46
  - [P61 initializer materialization](https://github.com/ryanbliss/neo-compose-specs/blob/main/new-features/complete/p61-initializer-materialization.md)
46
47
  - [P64 NeoScript unit testing and push hooks](https://github.com/ryanbliss/neo-compose-specs/blob/main/new-features/p64-neoscript-unit-testing-and-push-hooks.md)
47
48
 
48
- P38–P44, P47–P52, P60–P61, and the initial P64 test/push-hook vertical slice
49
+ P38–P44, P47–P52, P55, P60–P61, and the initial P64 test/push-hook vertical slice
49
50
  are implemented. P45 remains deliberately deferred; do not
50
51
  build or teach runtime-child provenance. P46 is a hardening proposal, not an
51
52
  authoring capability. P52's document header still says proposed, but try/catch
@@ -82,7 +83,7 @@ wrappers.
82
83
  The marker near the top of `SKILL.md` must exactly match the package version:
83
84
 
84
85
  ```html
85
- <!-- reviewed-through-cli: 0.25.0 -->
86
+ <!-- reviewed-through-cli: 0.25.2 -->
86
87
  ```
87
88
 
88
89
  The quoted version above is checked too, so this instruction cannot go stale
@@ -146,6 +146,13 @@ neo login [--api <url>] [--profile editor|release] [--save-project <id>]
146
146
  neo doctor
147
147
  ```
148
148
 
149
+ `neo doctor` reports two different things. The authoring contracts (format,
150
+ compiler, extension cache, source, files) decide its exit code; the document
151
+ audit below them reports malformed records in the last pull and deliberately
152
+ does not, so a project with records still to repair does not fail the command.
153
+ `neo doctor --json` carries a `repair` entry per finding, which is what a
154
+ targeted repair is handed instead of scanning a project to rediscover them.
155
+
149
156
  The Node CLI talks directly to authenticated Convex APIs through the
150
157
  session-gated CAS boundary. Tokens use the OS credential store when available
151
158
  and a protected file only as fallback. Use `NEO_COMPOSE_TOKEN` or