@davidorex/pi-context 0.26.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 +23 -0
- package/README.md +195 -0
- package/dist/block-api.d.ts +328 -0
- package/dist/block-api.d.ts.map +1 -0
- package/dist/block-api.js +1167 -0
- package/dist/block-api.js.map +1 -0
- package/dist/block-validation.d.ts +33 -0
- package/dist/block-validation.d.ts.map +1 -0
- package/dist/block-validation.js +213 -0
- package/dist/block-validation.js.map +1 -0
- package/dist/context-dir.d.ts +93 -0
- package/dist/context-dir.d.ts.map +1 -0
- package/dist/context-dir.js +224 -0
- package/dist/context-dir.js.map +1 -0
- package/dist/context-sdk.d.ts +428 -0
- package/dist/context-sdk.d.ts.map +1 -0
- package/dist/context-sdk.js +1327 -0
- package/dist/context-sdk.js.map +1 -0
- package/dist/context.d.ts +497 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +1060 -0
- package/dist/context.js.map +1 -0
- package/dist/dispatch-context.d.ts +112 -0
- package/dist/dispatch-context.d.ts.map +1 -0
- package/dist/dispatch-context.js +119 -0
- package/dist/dispatch-context.js.map +1 -0
- package/dist/execution-context.d.ts +138 -0
- package/dist/execution-context.d.ts.map +1 -0
- package/dist/execution-context.js +151 -0
- package/dist/execution-context.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1655 -0
- package/dist/index.js.map +1 -0
- package/dist/lens-validator.d.ts +50 -0
- package/dist/lens-validator.d.ts.map +1 -0
- package/dist/lens-validator.js +46 -0
- package/dist/lens-validator.js.map +1 -0
- package/dist/lens-view.d.ts +75 -0
- package/dist/lens-view.d.ts.map +1 -0
- package/dist/lens-view.js +253 -0
- package/dist/lens-view.js.map +1 -0
- package/dist/rename-canonical-id.d.ts +64 -0
- package/dist/rename-canonical-id.d.ts.map +1 -0
- package/dist/rename-canonical-id.js +378 -0
- package/dist/rename-canonical-id.js.map +1 -0
- package/dist/roadmap-plan.d.ts +186 -0
- package/dist/roadmap-plan.d.ts.map +1 -0
- package/dist/roadmap-plan.js +534 -0
- package/dist/roadmap-plan.js.map +1 -0
- package/dist/samples-catalog.d.ts +48 -0
- package/dist/samples-catalog.d.ts.map +1 -0
- package/dist/samples-catalog.js +128 -0
- package/dist/samples-catalog.js.map +1 -0
- package/dist/schema-migrations.d.ts +82 -0
- package/dist/schema-migrations.d.ts.map +1 -0
- package/dist/schema-migrations.js +106 -0
- package/dist/schema-migrations.js.map +1 -0
- package/dist/schema-validator.d.ts +76 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +219 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/schema-write.d.ts +122 -0
- package/dist/schema-write.d.ts.map +1 -0
- package/dist/schema-write.js +210 -0
- package/dist/schema-write.js.map +1 -0
- package/dist/status-vocab.d.ts +60 -0
- package/dist/status-vocab.d.ts.map +1 -0
- package/dist/status-vocab.js +109 -0
- package/dist/status-vocab.js.map +1 -0
- package/dist/test-helpers.d.ts +12 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +28 -0
- package/dist/test-helpers.js.map +1 -0
- package/dist/topo.d.ts +19 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +99 -0
- package/dist/topo.js.map +1 -0
- package/dist/update-check.d.ts +6 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +82 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +115 -0
- package/samples/blocks/context-contracts.json +3 -0
- package/samples/blocks/conventions.json +3 -0
- package/samples/blocks/decisions.json +3 -0
- package/samples/blocks/features.json +3 -0
- package/samples/blocks/framework-gaps.json +3 -0
- package/samples/blocks/issues.json +3 -0
- package/samples/blocks/layer-plans.json +3 -0
- package/samples/blocks/phase.json +3 -0
- package/samples/blocks/rationale.json +3 -0
- package/samples/blocks/requirements.json +3 -0
- package/samples/blocks/research.json +3 -0
- package/samples/blocks/spec-reviews.json +3 -0
- package/samples/blocks/story.json +3 -0
- package/samples/blocks/tasks.json +3 -0
- package/samples/blocks/verification.json +3 -0
- package/samples/conception.json +467 -0
- package/samples/schemas/context-contracts.schema.json +89 -0
- package/samples/schemas/conventions.schema.json +34 -0
- package/samples/schemas/decisions.schema.json +87 -0
- package/samples/schemas/features.schema.json +53 -0
- package/samples/schemas/framework-gaps.schema.json +64 -0
- package/samples/schemas/issues.schema.json +39 -0
- package/samples/schemas/layer-plans.schema.json +92 -0
- package/samples/schemas/phase.schema.json +118 -0
- package/samples/schemas/rationale.schema.json +24 -0
- package/samples/schemas/requirements.schema.json +34 -0
- package/samples/schemas/research.schema.json +134 -0
- package/samples/schemas/spec-reviews.schema.json +62 -0
- package/samples/schemas/story.schema.json +73 -0
- package/samples/schemas/tasks.schema.json +34 -0
- package/samples/schemas/verification.schema.json +37 -0
- package/schemas/bootstrap.schema.json +24 -0
- package/schemas/config.schema.json +193 -0
- package/schemas/layer.schema.json +9 -0
- package/schemas/priority.schema.json +9 -0
- package/schemas/relations.schema.json +31 -0
- package/schemas/severity.schema.json +9 -0
- package/schemas/source.schema.json +9 -0
- package/schemas/status.schema.json +9 -0
- package/schemas/verification-method.schema.json +9 -0
- package/skill-narrative.md +130 -0
- package/skills/pi-context/SKILL.md +684 -0
- package/skills/pi-context/references/bundled-resources.md +47 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized read/write API for .project/*.json project block files.
|
|
3
|
+
* Validates data against schemas before writing; uses atomic writes (tmp + rename).
|
|
4
|
+
* Read-modify-write operations (append, update) use file-level locking via proper-lockfile
|
|
5
|
+
* to prevent data loss from concurrent workflow steps targeting the same block.
|
|
6
|
+
*
|
|
7
|
+
* DispatchContext (FGAP-004): every write function accepts an optional final
|
|
8
|
+
* argument `ctx?: DispatchContext`. When provided AND the target block's
|
|
9
|
+
* schema declares any of {created_by, created_at, modified_by, modified_at},
|
|
10
|
+
* items are stamped via `stampItem` from `./dispatch-context` before AJV
|
|
11
|
+
* validation. When `ctx` is undefined, behavior is byte-identical to the
|
|
12
|
+
* pre-step-3 surface — the parameter is purely additive. The has-author-fields
|
|
13
|
+
* decision is mtime-cached per (cwd, blockName) to avoid re-reading the
|
|
14
|
+
* schema on every write; mirrors the `loadContext` cache pattern from
|
|
15
|
+
* `context.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Future extraction seam for pi-project extension.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import _lockfile from "proper-lockfile";
|
|
22
|
+
import { assertSubstrateName, resolveContextDir, schemaPath, tryResolveContextDir } from "./context-dir.js";
|
|
23
|
+
import { stampItem } from "./dispatch-context.js";
|
|
24
|
+
import { validateFromFile } from "./schema-validator.js";
|
|
25
|
+
// Node16 module resolution + CJS interop: default import may be wrapped
|
|
26
|
+
const lockfile = _lockfile.default ?? _lockfile;
|
|
27
|
+
/**
|
|
28
|
+
* Acquire a file-level lock, run fn(), release lock in finally.
|
|
29
|
+
* Skips locking if the target file does not yet exist (first write — no contention possible).
|
|
30
|
+
* Uses proper-lockfile's lockSync/unlockSync for synchronous read-modify-write safety.
|
|
31
|
+
*/
|
|
32
|
+
function withBlockLock(filePath, fn) {
|
|
33
|
+
if (!fs.existsSync(filePath)) {
|
|
34
|
+
return fn();
|
|
35
|
+
}
|
|
36
|
+
lockfile.lockSync(filePath, { stale: 10000 });
|
|
37
|
+
try {
|
|
38
|
+
return fn();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
lockfile.unlockSync(filePath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function blockFilePath(cwd, blockName) {
|
|
45
|
+
assertSubstrateName(blockName);
|
|
46
|
+
return path.join(resolveContextDir(cwd), `${blockName}.json`);
|
|
47
|
+
}
|
|
48
|
+
function blockSchemaPath(cwd, blockName) {
|
|
49
|
+
return schemaPath(cwd, blockName);
|
|
50
|
+
}
|
|
51
|
+
// ── Schema introspection cache (DispatchContext support, FGAP-004) ───────────
|
|
52
|
+
/**
|
|
53
|
+
* Author fields recognized by `stampItem`. If the target schema's
|
|
54
|
+
* (top-level array item, or top-level object) `properties` declares any of
|
|
55
|
+
* these, ctx-stamping runs before AJV validation; otherwise stamping is
|
|
56
|
+
* skipped so an `additionalProperties: false` schema does not fail
|
|
57
|
+
* validation on injected fields.
|
|
58
|
+
*/
|
|
59
|
+
const AUTHOR_FIELDS = ["created_by", "created_at", "modified_by", "modified_at"];
|
|
60
|
+
const schemaCache = new Map();
|
|
61
|
+
function safeStat(p) {
|
|
62
|
+
try {
|
|
63
|
+
return fs.statSync(p);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Subset of `AUTHOR_FIELDS` that the schema's TOP-LEVEL `properties` object
|
|
71
|
+
* declares. Used by `writeBlock`'s envelope-stamp question — whole-block
|
|
72
|
+
* writes stamp on the envelope, not on every nested item. Empty set = "no
|
|
73
|
+
* envelope-level author fields declared, skip stamping."
|
|
74
|
+
*/
|
|
75
|
+
function schemaTopLevelDeclaredAuthorFields(schema) {
|
|
76
|
+
const out = new Set();
|
|
77
|
+
if (!schema || typeof schema !== "object")
|
|
78
|
+
return out;
|
|
79
|
+
const s = schema;
|
|
80
|
+
const props = s.properties;
|
|
81
|
+
if (!props)
|
|
82
|
+
return out;
|
|
83
|
+
for (const f of AUTHOR_FIELDS) {
|
|
84
|
+
if (Object.hasOwn(props, f))
|
|
85
|
+
out.add(f);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Walk every `items.properties` reachable from a schema by recursing through
|
|
91
|
+
* `properties.*` and `items.*`. For each visited array property, record
|
|
92
|
+
* whether its items' properties declare any author field. The result map is
|
|
93
|
+
* keyed by the array property name; nested arrays (e.g.
|
|
94
|
+
* `properties.reviews.items.properties.findings`) appear under their own key
|
|
95
|
+
* (`findings`) so the caller's question — "do the items of array
|
|
96
|
+
* '<arrayKey>' declare author fields?" — gets the right answer regardless of
|
|
97
|
+
* nesting depth. Mutates `into` in place.
|
|
98
|
+
*
|
|
99
|
+
* Recursion is bounded by the schema's structural depth — there is no risk
|
|
100
|
+
* of cycles unless the schema uses `$ref` cycles (which AJV would reject as
|
|
101
|
+
* malformed during data validation, and step-3 schemas use no $ref).
|
|
102
|
+
*/
|
|
103
|
+
function collectArrayItemAuthorDecisions(schema, into) {
|
|
104
|
+
if (!schema || typeof schema !== "object")
|
|
105
|
+
return;
|
|
106
|
+
const s = schema;
|
|
107
|
+
const props = s.properties;
|
|
108
|
+
if (!props)
|
|
109
|
+
return;
|
|
110
|
+
for (const [propKey, propSpecRaw] of Object.entries(props)) {
|
|
111
|
+
if (!propSpecRaw || typeof propSpecRaw !== "object")
|
|
112
|
+
continue;
|
|
113
|
+
const spec = propSpecRaw;
|
|
114
|
+
if (spec.type === "array") {
|
|
115
|
+
const items = spec.items;
|
|
116
|
+
if (items && typeof items === "object") {
|
|
117
|
+
const itemProps = items.properties;
|
|
118
|
+
if (itemProps) {
|
|
119
|
+
const declared = new Set();
|
|
120
|
+
for (const f of AUTHOR_FIELDS) {
|
|
121
|
+
if (Object.hasOwn(itemProps, f))
|
|
122
|
+
declared.add(f);
|
|
123
|
+
}
|
|
124
|
+
// "Union" if the same key appears at multiple nesting
|
|
125
|
+
// depths — the recorded set is the union of declared
|
|
126
|
+
// fields across all reachable shapes for the key. This
|
|
127
|
+
// preserves the earlier "stamp on positive declaration
|
|
128
|
+
// if any reachable shape carries the field" behavior
|
|
129
|
+
// without losing the per-field grain.
|
|
130
|
+
const prior = into.get(propKey);
|
|
131
|
+
if (prior) {
|
|
132
|
+
const merged = new Set(prior);
|
|
133
|
+
for (const f of declared)
|
|
134
|
+
merged.add(f);
|
|
135
|
+
into.set(propKey, merged);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
into.set(propKey, declared);
|
|
139
|
+
}
|
|
140
|
+
// Recurse into items so deeper nested arrays are catalogued too.
|
|
141
|
+
collectArrayItemAuthorDecisions(items, into);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
// Recurse into nested object properties (covers `type: "object"`
|
|
147
|
+
// without a literal `type` field — JSON Schema permits omitting
|
|
148
|
+
// `type` so long as `properties` is present).
|
|
149
|
+
collectArrayItemAuthorDecisions(spec, into);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Load the cached schema-introspection answer for a schema file, refreshing
|
|
155
|
+
* the cache when the file's mtime changes (or it appears / disappears).
|
|
156
|
+
* Returns `null` when no schema exists at the path — callers treat that as
|
|
157
|
+
* "no author fields declared" and skip ctx-stamping silently.
|
|
158
|
+
*
|
|
159
|
+
* Cache key is the absolute schema path (globally unique across cwds and
|
|
160
|
+
* block names), enabling reuse for arbitrary (filePath, schemaPath) pairs
|
|
161
|
+
* outside `.project/` — e.g., monitor side-car list schemas resolved from
|
|
162
|
+
* `import.meta.url` in pi-behavior-monitors.
|
|
163
|
+
*/
|
|
164
|
+
function getSchemaCacheEntry(schemaPath) {
|
|
165
|
+
if (!schemaPath)
|
|
166
|
+
return null;
|
|
167
|
+
const key = path.resolve(schemaPath);
|
|
168
|
+
const stat = safeStat(key);
|
|
169
|
+
if (!stat) {
|
|
170
|
+
// Missing schema — drop any stale cache entry and signal absence.
|
|
171
|
+
schemaCache.delete(key);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const mtimeMs = stat.mtimeMs;
|
|
175
|
+
const hit = schemaCache.get(key);
|
|
176
|
+
if (hit && hit.mtimeMs === mtimeMs)
|
|
177
|
+
return hit;
|
|
178
|
+
let schema;
|
|
179
|
+
try {
|
|
180
|
+
schema = JSON.parse(fs.readFileSync(key, "utf-8"));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Unreadable / invalid JSON — treat as "no author fields declared" so
|
|
184
|
+
// stamping is skipped; the existing AJV validation path will surface the
|
|
185
|
+
// real problem on the next write.
|
|
186
|
+
const entry = {
|
|
187
|
+
mtimeMs,
|
|
188
|
+
hasAuthorFields: false,
|
|
189
|
+
envelopeDeclares: new Set(),
|
|
190
|
+
perArrayKey: new Map(),
|
|
191
|
+
};
|
|
192
|
+
schemaCache.set(key, entry);
|
|
193
|
+
return entry;
|
|
194
|
+
}
|
|
195
|
+
const perArrayKey = new Map();
|
|
196
|
+
collectArrayItemAuthorDecisions(schema, perArrayKey);
|
|
197
|
+
const envelopeDeclares = schemaTopLevelDeclaredAuthorFields(schema);
|
|
198
|
+
let anyArrayItemDeclares = false;
|
|
199
|
+
for (const v of perArrayKey.values()) {
|
|
200
|
+
if (v.size > 0) {
|
|
201
|
+
anyArrayItemDeclares = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const entry = {
|
|
206
|
+
mtimeMs,
|
|
207
|
+
hasAuthorFields: envelopeDeclares.size > 0 || anyArrayItemDeclares,
|
|
208
|
+
envelopeDeclares,
|
|
209
|
+
perArrayKey,
|
|
210
|
+
};
|
|
211
|
+
schemaCache.set(key, entry);
|
|
212
|
+
return entry;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Subset of `AUTHOR_FIELDS` declared at the schema's top-level envelope.
|
|
216
|
+
* Used by `writeTypedFile` to decide which envelope fields to stamp on a
|
|
217
|
+
* whole-file write — distinct from per-item stamping which uses
|
|
218
|
+
* `declaredAuthorFieldsForArray`. Empty set = "no envelope-level stamping."
|
|
219
|
+
*/
|
|
220
|
+
function declaredAuthorFieldsForEnvelope(schemaPath) {
|
|
221
|
+
const entry = getSchemaCacheEntry(schemaPath);
|
|
222
|
+
return entry?.envelopeDeclares ?? new Set();
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Subset of `AUTHOR_FIELDS` declared on the items of the array reached by
|
|
226
|
+
* `arrayKey` — at any nesting depth. Used by the array-grained writers to
|
|
227
|
+
* thread the per-field stamping decision into `stampItem`. An array key not
|
|
228
|
+
* catalogued by the schema returns the empty set — stamping a key the schema
|
|
229
|
+
* does not describe is the path that triggers `additionalProperties: false`
|
|
230
|
+
* AJV failures, so the safe default is "skip" rather than "trust the envelope
|
|
231
|
+
* answer." Empty set = "no per-item stamping."
|
|
232
|
+
*/
|
|
233
|
+
function declaredAuthorFieldsForArray(schemaPath, arrayKey) {
|
|
234
|
+
const entry = getSchemaCacheEntry(schemaPath);
|
|
235
|
+
if (!entry)
|
|
236
|
+
return new Set();
|
|
237
|
+
return entry.perArrayKey.get(arrayKey) ?? new Set();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Conditionally stamp `item` when `ctx` is provided AND the schema declares
|
|
241
|
+
* author fields on the relevant array items. Returns the (possibly stamped)
|
|
242
|
+
* item — callers downstream feed it to AJV validation as usual.
|
|
243
|
+
*
|
|
244
|
+
* `mode` tracks "is this a fresh insertion or a mutation of an existing
|
|
245
|
+
* item?" so `created_by` / `created_at` are not refreshed on update.
|
|
246
|
+
*/
|
|
247
|
+
function maybeStampItem(schemaPath, arrayKey, item, ctx, mode) {
|
|
248
|
+
if (!ctx)
|
|
249
|
+
return item;
|
|
250
|
+
const declared = declaredAuthorFieldsForArray(schemaPath, arrayKey);
|
|
251
|
+
if (declared.size === 0)
|
|
252
|
+
return item;
|
|
253
|
+
return stampItem(item, ctx, mode, declared);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Read and parse a .project/{blockName}.json file.
|
|
257
|
+
* Throws if the file does not exist or contains invalid JSON.
|
|
258
|
+
*
|
|
259
|
+
* Optional filter: when provided, returns a shallow copy of the block with only
|
|
260
|
+
* matching items in the specified array key. Non-array or missing keys return the
|
|
261
|
+
* block unchanged. The filter is applied after parsing, before returning.
|
|
262
|
+
*/
|
|
263
|
+
export function readBlock(cwd, blockName, filter) {
|
|
264
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
265
|
+
let content;
|
|
266
|
+
try {
|
|
267
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
throw new Error(`Block file not found: .project/${blockName}.json`);
|
|
271
|
+
}
|
|
272
|
+
let data;
|
|
273
|
+
try {
|
|
274
|
+
data = JSON.parse(content);
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
throw new Error(`Invalid JSON in block file: .project/${blockName}.json: ${msg}`);
|
|
279
|
+
}
|
|
280
|
+
if (filter) {
|
|
281
|
+
const record = data;
|
|
282
|
+
const arr = record[filter.arrayKey];
|
|
283
|
+
if (Array.isArray(arr)) {
|
|
284
|
+
return { ...record, [filter.arrayKey]: arr.filter(filter.predicate) };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return data;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Resolve the existing schema path for a block (or null when no schema
|
|
291
|
+
* file is present at the conventional location). Used by every wrapper
|
|
292
|
+
* that delegates to the typed-file primitives.
|
|
293
|
+
*/
|
|
294
|
+
function existingBlockSchemaPath(cwd, blockName) {
|
|
295
|
+
const schemaFile = blockSchemaPath(cwd, blockName);
|
|
296
|
+
return fs.existsSync(schemaFile) ? schemaFile : null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Read raw file content as a parsed value, throwing labeled errors
|
|
300
|
+
* matching the prior `readBlock` semantics. Generalised so the typed-file
|
|
301
|
+
* primitives can read arbitrary paths (not only `.project/<name>.json`).
|
|
302
|
+
* `errorLabel` typically ends up like `block file '<name>.json'` or
|
|
303
|
+
* `monitor '<name>' patterns`.
|
|
304
|
+
*/
|
|
305
|
+
function readTypedFile(filePath, errorLabel) {
|
|
306
|
+
let content;
|
|
307
|
+
try {
|
|
308
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new Error(`File not found: ${errorLabel} (${filePath})`);
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(content);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
318
|
+
throw new Error(`Invalid JSON in ${errorLabel} (${filePath}): ${msg}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Validated whole-file write to an arbitrary `(filePath, schemaPath)` pair.
|
|
323
|
+
* The `.project/`-targeting `writeBlock` becomes a thin wrapper over this.
|
|
324
|
+
* `schemaPath = null` skips AJV validation entirely (matches `writeBlock`'s
|
|
325
|
+
* "no schema file present" semantic).
|
|
326
|
+
*
|
|
327
|
+
* `ctx` (FGAP-004): when provided AND the schema declares author fields at
|
|
328
|
+
* the top-level envelope (and `data` is object-shaped, NOT array-shaped),
|
|
329
|
+
* the envelope is stamped before AJV runs. Top-level array files (e.g.
|
|
330
|
+
* monitor pattern lists) skip envelope-stamping silently — the envelope
|
|
331
|
+
* lookup returns an empty set for array schemas because their top-level
|
|
332
|
+
* `properties` is undefined.
|
|
333
|
+
*
|
|
334
|
+
* Atomic write via tmp + rename. Does NOT itself acquire a `withBlockLock`
|
|
335
|
+
* — whole-file overwrite has no read-modify-write race that locking would
|
|
336
|
+
* protect against. Callers performing read-modify-write (e.g.
|
|
337
|
+
* `appendToTypedFile`, `appendToBlock`, …) wrap the surrounding critical
|
|
338
|
+
* section in `withBlockLock` themselves; `writeTypedFile` is then called
|
|
339
|
+
* from inside that section. This matches the prior `writeBlock` /
|
|
340
|
+
* `appendToBlock` split exactly — preserves byte-identical lock semantics
|
|
341
|
+
* for existing callers.
|
|
342
|
+
*/
|
|
343
|
+
export function writeTypedFile(filePath, schemaPath, data, ctx, errorLabel) {
|
|
344
|
+
const label = errorLabel ?? filePath;
|
|
345
|
+
// Optional ctx-stamping: only when the schema declares author fields at
|
|
346
|
+
// the top-level envelope, and only on object-shaped data. Per-field
|
|
347
|
+
// declared subset is threaded into `stampItem` so partial declarations
|
|
348
|
+
// (e.g. envelope declares `created_by` only) do not inject the other
|
|
349
|
+
// three fields and trip `additionalProperties: false`.
|
|
350
|
+
let toWrite = data;
|
|
351
|
+
if (ctx && schemaPath && data && typeof data === "object" && !Array.isArray(data)) {
|
|
352
|
+
const declared = declaredAuthorFieldsForEnvelope(schemaPath);
|
|
353
|
+
if (declared.size > 0) {
|
|
354
|
+
toWrite = stampItem(data, ctx, "create", declared);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Validate before write (if a schema is supplied)
|
|
358
|
+
if (schemaPath) {
|
|
359
|
+
validateFromFile(schemaPath, toWrite, label);
|
|
360
|
+
}
|
|
361
|
+
// Ensure directory exists
|
|
362
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
363
|
+
// Atomic write: tmp + rename. Callers needing read-modify-write atomicity
|
|
364
|
+
// hold `withBlockLock(filePath, ...)` around the broader critical section.
|
|
365
|
+
const tmpPath = `${filePath}.block-api-${process.pid}.tmp`;
|
|
366
|
+
try {
|
|
367
|
+
fs.writeFileSync(tmpPath, JSON.stringify(toWrite, null, 2), "utf-8");
|
|
368
|
+
fs.renameSync(tmpPath, filePath);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
try {
|
|
372
|
+
fs.unlinkSync(tmpPath);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
/* ignore cleanup failure */
|
|
376
|
+
}
|
|
377
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
378
|
+
throw new Error(`Failed to write ${label}: ${msg}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Validated atomic append to an array within a `(filePath, schemaPath)` pair.
|
|
383
|
+
* `arrayPath = null` means "the file IS the array" (top-level array shape,
|
|
384
|
+
* e.g. monitor patterns / instructions); `arrayPath = string` means
|
|
385
|
+
* "data[arrayPath] is the target array" (object-with-array-field shape, the
|
|
386
|
+
* `.project/` block convention).
|
|
387
|
+
*
|
|
388
|
+
* `ctx` (FGAP-004): when provided AND the schema declares author fields on
|
|
389
|
+
* the items reached by `arrayPath` (or, for the flat-array case, on the
|
|
390
|
+
* array's `items.properties.*`), the appended item is stamped via
|
|
391
|
+
* `stampItem` in create-mode before AJV validation. For the flat-array
|
|
392
|
+
* case the array key passed to the per-array-key cache lookup is the
|
|
393
|
+
* arbitrary token `__top__` — `collectArrayItemAuthorDecisions` only
|
|
394
|
+
* traverses `properties.*` paths and never visits a top-level array
|
|
395
|
+
* schema, so the lookup will always miss; flat-array stamping is
|
|
396
|
+
* intentionally a no-op until a schema actually declares author fields
|
|
397
|
+
* on a top-level array shape (no current consumer does so).
|
|
398
|
+
*/
|
|
399
|
+
export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, errorLabel) {
|
|
400
|
+
const label = errorLabel ?? filePath;
|
|
401
|
+
withBlockLock(filePath, () => {
|
|
402
|
+
const data = readTypedFile(filePath, label);
|
|
403
|
+
if (arrayPath === null) {
|
|
404
|
+
// Flat top-level array shape: file content IS the array.
|
|
405
|
+
if (!Array.isArray(data)) {
|
|
406
|
+
throw new Error(`${label}: expected top-level array, got ${typeof data}`);
|
|
407
|
+
}
|
|
408
|
+
const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
409
|
+
? maybeStampItem(schemaPath, "__top__", item, ctx, "create")
|
|
410
|
+
: item;
|
|
411
|
+
const next = [...data, itemToAppend];
|
|
412
|
+
// Validate the WHOLE array against schemaPath, then write.
|
|
413
|
+
writeTypedFile(filePath, schemaPath, next, undefined, label);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Object-with-array-field shape (the .project/ block convention).
|
|
417
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
418
|
+
throw new Error(`${label}: expected object with array field '${arrayPath}'`);
|
|
419
|
+
}
|
|
420
|
+
const record = data;
|
|
421
|
+
if (!(arrayPath in record)) {
|
|
422
|
+
throw new Error(`${label} has no key '${arrayPath}'`);
|
|
423
|
+
}
|
|
424
|
+
if (!Array.isArray(record[arrayPath])) {
|
|
425
|
+
throw new Error(`${label} key '${arrayPath}' is not an array`);
|
|
426
|
+
}
|
|
427
|
+
const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
428
|
+
? maybeStampItem(schemaPath, arrayPath, item, ctx, "create")
|
|
429
|
+
: item;
|
|
430
|
+
record[arrayPath] = [...record[arrayPath], itemToAppend];
|
|
431
|
+
writeTypedFile(filePath, schemaPath, record, undefined, label);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Validated atomic bulk append-if-absent to an array within a
|
|
436
|
+
* `(filePath, schemaPath)` pair. Each candidate in `items` is appended only
|
|
437
|
+
* when no element already present (on-disk OR earlier in this same batch)
|
|
438
|
+
* shares its `matchKey`. Dedup is keyed solely on `matchKey(item)` — the
|
|
439
|
+
* write surface does no semantic validation beyond the whole-array AJV check
|
|
440
|
+
* and this exact-duplicate-no-op; richer integrity (referential, etc.) is
|
|
441
|
+
* deferred to callers / downstream validators.
|
|
442
|
+
*
|
|
443
|
+
* `arrayPath` mirrors `appendToTypedFile`: `null` ⇒ the file content IS the
|
|
444
|
+
* array (flat top-level array shape); `string` ⇒ `data[arrayPath]` is the
|
|
445
|
+
* target array (object-with-array-field shape, the `.project/` convention).
|
|
446
|
+
* The branching is identical to `appendToTypedFile`'s.
|
|
447
|
+
*
|
|
448
|
+
* The whole read-find-write critical section runs inside `withBlockLock`, so
|
|
449
|
+
* concurrent batches against the same file serialise. When `appended === 0`
|
|
450
|
+
* nothing is written (the file is left byte-identical). The first write
|
|
451
|
+
* against an absent file works: `withBlockLock` skips locking when the file is
|
|
452
|
+
* absent (no contention possible), and the absent file is treated as an empty
|
|
453
|
+
* array for the flat-array shape — only that shape is creatable from absence;
|
|
454
|
+
* the object-with-array-field shape requires an existing envelope and so still
|
|
455
|
+
* throws via `readTypedFile`.
|
|
456
|
+
*
|
|
457
|
+
* `ctx` is threaded to `writeTypedFile` for attestation parity; per the
|
|
458
|
+
* top-level-array stamping semantics documented above, flat-array shapes with
|
|
459
|
+
* no declared envelope author fields treat it as a structural no-op.
|
|
460
|
+
*/
|
|
461
|
+
export function appendManyToTypedFileIfAbsent(filePath, schemaPath, arrayPath, items, matchKey, ctx, errorLabel) {
|
|
462
|
+
const label = errorLabel ?? filePath;
|
|
463
|
+
return withBlockLock(filePath, () => {
|
|
464
|
+
// Absent file is the empty-array starting point for the flat-array shape
|
|
465
|
+
// (enables first-write file creation); the object-with-array-field shape
|
|
466
|
+
// requires an existing envelope, so it reads through and throws below.
|
|
467
|
+
const data = arrayPath === null && !fs.existsSync(filePath) ? [] : readTypedFile(filePath, label);
|
|
468
|
+
if (arrayPath === null) {
|
|
469
|
+
// Flat top-level array shape: file content IS the array.
|
|
470
|
+
if (!Array.isArray(data)) {
|
|
471
|
+
throw new Error(`${label}: expected top-level array, got ${typeof data}`);
|
|
472
|
+
}
|
|
473
|
+
const arr = [...data];
|
|
474
|
+
const seen = new Set(arr.map((existing) => matchKey(existing)));
|
|
475
|
+
let appended = 0;
|
|
476
|
+
let skipped = 0;
|
|
477
|
+
for (const candidate of items) {
|
|
478
|
+
const key = matchKey(candidate);
|
|
479
|
+
if (seen.has(key)) {
|
|
480
|
+
skipped++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
seen.add(key);
|
|
484
|
+
arr.push(candidate);
|
|
485
|
+
appended++;
|
|
486
|
+
}
|
|
487
|
+
if (appended > 0) {
|
|
488
|
+
writeTypedFile(filePath, schemaPath, arr, ctx, label);
|
|
489
|
+
}
|
|
490
|
+
return { appended, skipped };
|
|
491
|
+
}
|
|
492
|
+
// Object-with-array-field shape (the .project/ block convention).
|
|
493
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
494
|
+
throw new Error(`${label}: expected object with array field '${arrayPath}'`);
|
|
495
|
+
}
|
|
496
|
+
const record = data;
|
|
497
|
+
if (!(arrayPath in record)) {
|
|
498
|
+
throw new Error(`${label} has no key '${arrayPath}'`);
|
|
499
|
+
}
|
|
500
|
+
if (!Array.isArray(record[arrayPath])) {
|
|
501
|
+
throw new Error(`${label} key '${arrayPath}' is not an array`);
|
|
502
|
+
}
|
|
503
|
+
const arr = [...record[arrayPath]];
|
|
504
|
+
const seen = new Set(arr.map((existing) => matchKey(existing)));
|
|
505
|
+
let appended = 0;
|
|
506
|
+
let skipped = 0;
|
|
507
|
+
for (const candidate of items) {
|
|
508
|
+
const key = matchKey(candidate);
|
|
509
|
+
if (seen.has(key)) {
|
|
510
|
+
skipped++;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
seen.add(key);
|
|
514
|
+
arr.push(candidate);
|
|
515
|
+
appended++;
|
|
516
|
+
}
|
|
517
|
+
if (appended > 0) {
|
|
518
|
+
record[arrayPath] = arr;
|
|
519
|
+
writeTypedFile(filePath, schemaPath, record, ctx, label);
|
|
520
|
+
}
|
|
521
|
+
return { appended, skipped };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
// ── Internal helpers shared by the typed-file find-or-merge primitives ─────
|
|
525
|
+
/**
|
|
526
|
+
* Resolve the array reachable by `arrayPath` against `data` after the
|
|
527
|
+
* standard read step, throwing labeled errors for the three structural
|
|
528
|
+
* invariants common to every find-or-merge primitive:
|
|
529
|
+
* - `arrayPath === null` → `data` itself must be a plain array
|
|
530
|
+
* - `arrayPath === string` → `data` must be a non-array object with that
|
|
531
|
+
* key present and the value must be an array
|
|
532
|
+
*
|
|
533
|
+
* Returns the resolved array PLUS a writer thunk that puts the (possibly
|
|
534
|
+
* new) array reference back into the parent shape so callers can hand the
|
|
535
|
+
* mutated parent to `writeTypedFile`. The writer thunk is what isolates
|
|
536
|
+
* the "file IS the array" case from the "object with array field" case
|
|
537
|
+
* inside primitive bodies — every primitive uses the same shape-resolution
|
|
538
|
+
* preamble and the same final `writeTypedFile` call regardless of the
|
|
539
|
+
* top-level shape.
|
|
540
|
+
*
|
|
541
|
+
* Multi-match warnings, predicate-not-found semantics, and ctx-stamping
|
|
542
|
+
* are NOT part of this helper — they vary per primitive (update throws on
|
|
543
|
+
* miss, remove is idempotent, upsert decides append-vs-replace, etc.).
|
|
544
|
+
*/
|
|
545
|
+
function resolveTypedArrayShape(data, arrayPath, label) {
|
|
546
|
+
if (arrayPath === null) {
|
|
547
|
+
if (!Array.isArray(data)) {
|
|
548
|
+
throw new Error(`${label}: expected top-level array, got ${typeof data}`);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
arr: data,
|
|
552
|
+
rewriteParent: (next) => next,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
556
|
+
throw new Error(`${label}: expected object with array field '${arrayPath}'`);
|
|
557
|
+
}
|
|
558
|
+
const record = data;
|
|
559
|
+
if (!(arrayPath in record)) {
|
|
560
|
+
throw new Error(`${label} has no key '${arrayPath}'`);
|
|
561
|
+
}
|
|
562
|
+
const candidate = record[arrayPath];
|
|
563
|
+
if (!Array.isArray(candidate)) {
|
|
564
|
+
throw new Error(`${label} key '${arrayPath}' is not an array`);
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
arr: candidate,
|
|
568
|
+
rewriteParent: (next) => ({ ...record, [arrayPath]: next }),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Per-item ctx-stamping for the typed-file find-or-merge primitives.
|
|
573
|
+
* Mirrors the existing `maybeStampItem` semantics but keys the schema
|
|
574
|
+
* lookup by `arrayPath`. For the flat-array case (`arrayPath === null`)
|
|
575
|
+
* the lookup uses the `__top__` sentinel which `collectArrayItemAuthorDecisions`
|
|
576
|
+
* never populates — flat-array stamping is intentionally a no-op until a
|
|
577
|
+
* schema actually declares author fields on a top-level array shape (no
|
|
578
|
+
* current consumer does so). Documented in the FGAP-019 closure commit.
|
|
579
|
+
*/
|
|
580
|
+
function maybeStampTypedItem(schemaPath, arrayPath, item, ctx, mode) {
|
|
581
|
+
const key = arrayPath ?? "__top__";
|
|
582
|
+
return maybeStampItem(schemaPath, key, item, ctx, mode);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Validated atomic find-by-predicate update of one item inside an array
|
|
586
|
+
* reachable from `(filePath, schemaPath, arrayPath)`. Generalises
|
|
587
|
+
* `updateItemInBlock` to arbitrary file paths and supports both top-level
|
|
588
|
+
* array files (`arrayPath === null`) and object-with-array-field files
|
|
589
|
+
* (`arrayPath === string`). Throws on predicate miss; AJV validates the
|
|
590
|
+
* whole file after mutation.
|
|
591
|
+
*
|
|
592
|
+
* Multi-match warning emits on stderr with the established `[block-api]`
|
|
593
|
+
* prefix (grep-discoverable across legacy log lines).
|
|
594
|
+
*
|
|
595
|
+
* `ctx`: when supplied AND the schema declares author fields on the items
|
|
596
|
+
* reachable by `arrayPath`, the merged item is stamped in update-mode
|
|
597
|
+
* before AJV runs. Flat-array stamping is a no-op (see
|
|
598
|
+
* `maybeStampTypedItem`).
|
|
599
|
+
*/
|
|
600
|
+
export function updateItemInTypedFile(filePath, schemaPath, arrayPath, predicate, updates, ctx, errorLabel) {
|
|
601
|
+
const label = errorLabel ?? filePath;
|
|
602
|
+
withBlockLock(filePath, () => {
|
|
603
|
+
const data = readTypedFile(filePath, label);
|
|
604
|
+
const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, arrayPath, label);
|
|
605
|
+
const arr = rawArr;
|
|
606
|
+
const idx = arr.findIndex(predicate);
|
|
607
|
+
if (idx === -1) {
|
|
608
|
+
throw new Error(`No matching item in ${label}${arrayPath !== null ? ` key '${arrayPath}'` : ""}`);
|
|
609
|
+
}
|
|
610
|
+
let matchCount = 1;
|
|
611
|
+
for (let i = idx + 1; i < arr.length; i++) {
|
|
612
|
+
if (predicate(arr[i]))
|
|
613
|
+
matchCount++;
|
|
614
|
+
}
|
|
615
|
+
if (matchCount > 1) {
|
|
616
|
+
console.error(`[block-api] updateItemInBlock: ${matchCount} items matched predicate, only first updated`);
|
|
617
|
+
}
|
|
618
|
+
const merged = { ...arr[idx], ...updates };
|
|
619
|
+
const updated = ctx ? maybeStampTypedItem(schemaPath, arrayPath, merged, ctx, "update") : merged;
|
|
620
|
+
const patched = [...arr];
|
|
621
|
+
patched[idx] = updated;
|
|
622
|
+
writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Validated atomic find-or-append. Generalises `upsertItemInBlock` to
|
|
627
|
+
* arbitrary `(filePath, schemaPath, arrayPath)` triples, including
|
|
628
|
+
* top-level array files via `arrayPath === null`.
|
|
629
|
+
*
|
|
630
|
+
* FGAP-018 fix lives here (was in `upsertItemInBlock` prior to Step 6.3):
|
|
631
|
+
* on the update branch, declared create-time attestation fields are
|
|
632
|
+
* pre-merged from the existing on-disk item onto the supplied item if
|
|
633
|
+
* absent, so attestation integrity (FGAP-004) holds across replacement.
|
|
634
|
+
* The wrapper `upsertItemInBlock` inherits the fix structurally.
|
|
635
|
+
*
|
|
636
|
+
* For the flat-array case the pre-merge is a structural no-op — the
|
|
637
|
+
* `declaredAuthorFieldsForArray` lookup keyed by `__top__` returns the
|
|
638
|
+
* empty set (top-level array schemas are never visited by
|
|
639
|
+
* `collectArrayItemAuthorDecisions`), so no fields are carried. This is
|
|
640
|
+
* intentional: no current consumer declares author fields on a top-level
|
|
641
|
+
* array shape, and the unconditional pre-merge code path simply finds
|
|
642
|
+
* nothing to merge. If a future consumer lands such a schema, the
|
|
643
|
+
* stamping cataloguer needs an extension (filed-or-future work) — this
|
|
644
|
+
* primitive will then start preserving attestation on flat-array upserts
|
|
645
|
+
* automatically.
|
|
646
|
+
*/
|
|
647
|
+
export function upsertItemInTypedFile(filePath, schemaPath, arrayPath, item, idField, ctx, errorLabel) {
|
|
648
|
+
const label = errorLabel ?? filePath;
|
|
649
|
+
const idValue = item[idField];
|
|
650
|
+
if (idValue === undefined || idValue === null || idValue === "") {
|
|
651
|
+
throw new Error(`upsertItemInTypedFile: item is missing required idField '${idField}' (got: ${JSON.stringify(idValue)}) for ${label}`);
|
|
652
|
+
}
|
|
653
|
+
return withBlockLock(filePath, () => {
|
|
654
|
+
const data = readTypedFile(filePath, label);
|
|
655
|
+
const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, arrayPath, label);
|
|
656
|
+
const arr = rawArr;
|
|
657
|
+
const idx = arr.findIndex((existing) => existing && existing[idField] === idValue);
|
|
658
|
+
const mode = idx === -1 ? "appended" : "updated";
|
|
659
|
+
const stampMode = mode === "appended" ? "create" : "update";
|
|
660
|
+
// FGAP-018 fix: on update branch, pre-merge create-time attestation fields
|
|
661
|
+
// from the existing on-disk item onto the supplied item if absent. stampItem
|
|
662
|
+
// in update-mode does not touch created_*; this carries them forward across
|
|
663
|
+
// replacement so attestation integrity (FGAP-004) holds. For the flat-array
|
|
664
|
+
// case (arrayPath === null) the declared-fields lookup returns an empty set
|
|
665
|
+
// and no carry happens — see the function-doc note.
|
|
666
|
+
let itemForStamp = item;
|
|
667
|
+
if (idx !== -1 && schemaPath) {
|
|
668
|
+
const declared = declaredAuthorFieldsForArray(schemaPath, arrayPath ?? "__top__");
|
|
669
|
+
const existing = arr[idx];
|
|
670
|
+
const carriedFields = {};
|
|
671
|
+
for (const field of ["created_by", "created_at"]) {
|
|
672
|
+
if (declared.has(field) && !(field in item) && existing && field in existing) {
|
|
673
|
+
carriedFields[field] = existing[field];
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (Object.keys(carriedFields).length > 0) {
|
|
677
|
+
itemForStamp = { ...carriedFields, ...item };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const stamped = ctx ? maybeStampTypedItem(schemaPath, arrayPath, itemForStamp, ctx, stampMode) : itemForStamp;
|
|
681
|
+
const patched = [...arr];
|
|
682
|
+
if (idx === -1) {
|
|
683
|
+
patched.push(stamped);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
patched[idx] = stamped;
|
|
687
|
+
}
|
|
688
|
+
writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
|
|
689
|
+
return { mode };
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Validated atomic predicate-based remove. Generalises `removeFromBlock`
|
|
694
|
+
* to arbitrary `(filePath, schemaPath, arrayPath)` triples including
|
|
695
|
+
* top-level array files. Idempotent on miss (returns `{ removed: 0 }`
|
|
696
|
+
* without throwing or writing). AJV validates whole file after mutation
|
|
697
|
+
* (so e.g. a `minItems` violation surfaces).
|
|
698
|
+
*/
|
|
699
|
+
export function removeFromTypedFile(filePath, schemaPath, arrayPath, predicate, ctx, errorLabel) {
|
|
700
|
+
// See note in removeFromBlock: ctx is accepted for surface parity; no items
|
|
701
|
+
// remain to stamp on removal.
|
|
702
|
+
void ctx;
|
|
703
|
+
const label = errorLabel ?? filePath;
|
|
704
|
+
return withBlockLock(filePath, () => {
|
|
705
|
+
const data = readTypedFile(filePath, label);
|
|
706
|
+
const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, arrayPath, label);
|
|
707
|
+
const arr = rawArr;
|
|
708
|
+
const remaining = arr.filter((it) => !predicate(it));
|
|
709
|
+
const removed = arr.length - remaining.length;
|
|
710
|
+
if (removed === 0) {
|
|
711
|
+
return { removed: 0 };
|
|
712
|
+
}
|
|
713
|
+
if (removed > 1) {
|
|
714
|
+
console.error(`[block-api] removeFromBlock: ${removed} items matched predicate, all removed`);
|
|
715
|
+
}
|
|
716
|
+
writeTypedFile(filePath, schemaPath, rewriteParent(remaining), undefined, label);
|
|
717
|
+
return { removed };
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Validated atomic append to a nested array inside a parent-array item.
|
|
722
|
+
* Generalises `appendToNestedArray` to arbitrary `(filePath, schemaPath,
|
|
723
|
+
* parentArrayKey, nestedArrayKey)`. Nesting requires object-with-array-field
|
|
724
|
+
* shape — a top-level array file cannot host nested arrays at the same
|
|
725
|
+
* structural level — so `parentArrayKey` is `string` (no `null` form).
|
|
726
|
+
*
|
|
727
|
+
* Throws on missing parent key, no parent match, missing nested key, or
|
|
728
|
+
* AJV failure. Multi-match warning emits at parent level via stderr with
|
|
729
|
+
* the established `[block-api]` prefix.
|
|
730
|
+
*/
|
|
731
|
+
export function appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, predicate, nestedArrayKey, item, ctx, errorLabel) {
|
|
732
|
+
const label = errorLabel ?? filePath;
|
|
733
|
+
withBlockLock(filePath, () => {
|
|
734
|
+
const data = readTypedFile(filePath, label);
|
|
735
|
+
const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, parentArrayKey, label);
|
|
736
|
+
const arr = rawArr;
|
|
737
|
+
const idx = arr.findIndex(predicate);
|
|
738
|
+
if (idx === -1) {
|
|
739
|
+
throw new Error(`No matching item in ${label} key '${parentArrayKey}'`);
|
|
740
|
+
}
|
|
741
|
+
let matchCount = 1;
|
|
742
|
+
for (let i = idx + 1; i < arr.length; i++) {
|
|
743
|
+
if (predicate(arr[i]))
|
|
744
|
+
matchCount++;
|
|
745
|
+
}
|
|
746
|
+
if (matchCount > 1) {
|
|
747
|
+
console.error(`[block-api] appendToNestedArray: ${matchCount} items matched predicate, only first updated`);
|
|
748
|
+
}
|
|
749
|
+
const parent = arr[idx];
|
|
750
|
+
if (!(nestedArrayKey in parent)) {
|
|
751
|
+
throw new Error(`Matched item in ${label} key '${parentArrayKey}' has no nested key '${nestedArrayKey}'`);
|
|
752
|
+
}
|
|
753
|
+
if (!Array.isArray(parent[nestedArrayKey])) {
|
|
754
|
+
throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
|
|
755
|
+
}
|
|
756
|
+
const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
757
|
+
? maybeStampItem(schemaPath, nestedArrayKey, item, ctx, "create")
|
|
758
|
+
: item;
|
|
759
|
+
const updatedParent = {
|
|
760
|
+
...parent,
|
|
761
|
+
[nestedArrayKey]: [...parent[nestedArrayKey], itemToAppend],
|
|
762
|
+
};
|
|
763
|
+
const patched = [...arr];
|
|
764
|
+
patched[idx] = updatedParent;
|
|
765
|
+
writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Validated atomic update of a single item inside a nested array on a
|
|
770
|
+
* parent-array item. Generalises `updateNestedArrayItem` to arbitrary
|
|
771
|
+
* `(filePath, schemaPath, parentArrayKey, nestedArrayKey)`. Object-shape
|
|
772
|
+
* file required (see `appendToNestedTypedFile` doc).
|
|
773
|
+
*
|
|
774
|
+
* Throws on missing parent key, no parent/nested match, missing nested
|
|
775
|
+
* key, or AJV failure. Multi-match warnings emit at both parent and
|
|
776
|
+
* nested levels via stderr with the `[block-api]` prefix.
|
|
777
|
+
*/
|
|
778
|
+
export function updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx, errorLabel) {
|
|
779
|
+
const label = errorLabel ?? filePath;
|
|
780
|
+
withBlockLock(filePath, () => {
|
|
781
|
+
const data = readTypedFile(filePath, label);
|
|
782
|
+
const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, parentArrayKey, label);
|
|
783
|
+
const arr = rawArr;
|
|
784
|
+
const parentIdx = arr.findIndex(parentPredicate);
|
|
785
|
+
if (parentIdx === -1) {
|
|
786
|
+
throw new Error(`No matching item in ${label} key '${parentArrayKey}'`);
|
|
787
|
+
}
|
|
788
|
+
let parentMatchCount = 1;
|
|
789
|
+
for (let i = parentIdx + 1; i < arr.length; i++) {
|
|
790
|
+
if (parentPredicate(arr[i]))
|
|
791
|
+
parentMatchCount++;
|
|
792
|
+
}
|
|
793
|
+
if (parentMatchCount > 1) {
|
|
794
|
+
console.error(`[block-api] updateNestedArrayItem: ${parentMatchCount} parent items matched predicate, only first updated`);
|
|
795
|
+
}
|
|
796
|
+
const parent = arr[parentIdx];
|
|
797
|
+
if (!(nestedArrayKey in parent)) {
|
|
798
|
+
throw new Error(`Matched item in ${label} key '${parentArrayKey}' has no nested key '${nestedArrayKey}'`);
|
|
799
|
+
}
|
|
800
|
+
if (!Array.isArray(parent[nestedArrayKey])) {
|
|
801
|
+
throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
|
|
802
|
+
}
|
|
803
|
+
const nestedArr = parent[nestedArrayKey];
|
|
804
|
+
const nestedIdx = nestedArr.findIndex(nestedPredicate);
|
|
805
|
+
if (nestedIdx === -1) {
|
|
806
|
+
throw new Error(`No matching nested item in ${label} key '${parentArrayKey}[${parentIdx}].${nestedArrayKey}'`);
|
|
807
|
+
}
|
|
808
|
+
let nestedMatchCount = 1;
|
|
809
|
+
for (let i = nestedIdx + 1; i < nestedArr.length; i++) {
|
|
810
|
+
if (nestedPredicate(nestedArr[i]))
|
|
811
|
+
nestedMatchCount++;
|
|
812
|
+
}
|
|
813
|
+
if (nestedMatchCount > 1) {
|
|
814
|
+
console.error(`[block-api] updateNestedArrayItem: ${nestedMatchCount} nested items matched predicate, only first updated`);
|
|
815
|
+
}
|
|
816
|
+
const mergedNested = { ...nestedArr[nestedIdx], ...updates };
|
|
817
|
+
const updatedNested = ctx ? maybeStampItem(schemaPath, nestedArrayKey, mergedNested, ctx, "update") : mergedNested;
|
|
818
|
+
const patchedNested = [...nestedArr];
|
|
819
|
+
patchedNested[nestedIdx] = updatedNested;
|
|
820
|
+
const updatedParent = { ...parent, [nestedArrayKey]: patchedNested };
|
|
821
|
+
const patchedParents = [...arr];
|
|
822
|
+
patchedParents[parentIdx] = updatedParent;
|
|
823
|
+
writeTypedFile(filePath, schemaPath, rewriteParent(patchedParents), undefined, label);
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Validated atomic remove from a nested array. Generalises
|
|
828
|
+
* `removeFromNestedArray` to arbitrary `(filePath, schemaPath,
|
|
829
|
+
* parentArrayKey, nestedArrayKey)`. Object-shape file required (see
|
|
830
|
+
* `appendToNestedTypedFile` doc). Idempotent on nested-miss (returns
|
|
831
|
+
* `{ removed: 0 }`); throws on parent-miss to surface a malformed
|
|
832
|
+
* caller, mirroring the wrapper's prior semantics.
|
|
833
|
+
*/
|
|
834
|
+
export function removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx, errorLabel) {
|
|
835
|
+
void ctx;
|
|
836
|
+
const label = errorLabel ?? filePath;
|
|
837
|
+
return withBlockLock(filePath, () => {
|
|
838
|
+
const data = readTypedFile(filePath, label);
|
|
839
|
+
const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, parentArrayKey, label);
|
|
840
|
+
const arr = rawArr;
|
|
841
|
+
const parentIdx = arr.findIndex(parentPredicate);
|
|
842
|
+
if (parentIdx === -1) {
|
|
843
|
+
throw new Error(`No matching item in ${label} key '${parentArrayKey}'`);
|
|
844
|
+
}
|
|
845
|
+
let parentMatchCount = 1;
|
|
846
|
+
for (let i = parentIdx + 1; i < arr.length; i++) {
|
|
847
|
+
if (parentPredicate(arr[i]))
|
|
848
|
+
parentMatchCount++;
|
|
849
|
+
}
|
|
850
|
+
if (parentMatchCount > 1) {
|
|
851
|
+
console.error(`[block-api] removeFromNestedArray: ${parentMatchCount} parent items matched predicate, only first targeted`);
|
|
852
|
+
}
|
|
853
|
+
const parent = arr[parentIdx];
|
|
854
|
+
if (!(nestedArrayKey in parent)) {
|
|
855
|
+
throw new Error(`Matched item in ${label} key '${parentArrayKey}' has no nested key '${nestedArrayKey}'`);
|
|
856
|
+
}
|
|
857
|
+
if (!Array.isArray(parent[nestedArrayKey])) {
|
|
858
|
+
throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
|
|
859
|
+
}
|
|
860
|
+
const nestedArr = parent[nestedArrayKey];
|
|
861
|
+
const nestedRemaining = nestedArr.filter((it) => !nestedPredicate(it));
|
|
862
|
+
const removed = nestedArr.length - nestedRemaining.length;
|
|
863
|
+
if (removed === 0) {
|
|
864
|
+
return { removed: 0 };
|
|
865
|
+
}
|
|
866
|
+
if (removed > 1) {
|
|
867
|
+
console.error(`[block-api] removeFromNestedArray: ${removed} nested items matched predicate, all removed`);
|
|
868
|
+
}
|
|
869
|
+
const updatedParent = { ...parent, [nestedArrayKey]: nestedRemaining };
|
|
870
|
+
const patched = [...arr];
|
|
871
|
+
patched[parentIdx] = updatedParent;
|
|
872
|
+
writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
|
|
873
|
+
return { removed };
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Validate `data` against its schema (if one exists) and write atomically
|
|
878
|
+
* to `.project/{blockName}.json`. Throws `ValidationError` on schema failure.
|
|
879
|
+
* Files without a corresponding schema are written without validation.
|
|
880
|
+
*
|
|
881
|
+
* Thin wrapper over `writeTypedFile` — see that function for full semantics.
|
|
882
|
+
* `ctx` (FGAP-004): whole-block writes are treated as create-mode envelope
|
|
883
|
+
* stamping; callers wanting per-item attribution should prefer the
|
|
884
|
+
* array-grained writers.
|
|
885
|
+
*/
|
|
886
|
+
export function writeBlock(cwd, blockName, data, ctx) {
|
|
887
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
888
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
889
|
+
writeTypedFile(filePath, schemaPath, data, ctx, `block file '${blockName}.json'`);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Read current file, push item onto data[arrayKey], validate whole file
|
|
893
|
+
* against schema, write atomically. Throws if file doesn't exist, if
|
|
894
|
+
* arrayKey is missing or not an array, or if validation fails.
|
|
895
|
+
*
|
|
896
|
+
* `ctx` (FGAP-004): when provided AND the schema declares author fields on
|
|
897
|
+
* `properties.<arrayKey>.items.properties.*`, the appended item is stamped
|
|
898
|
+
* via `stampItem` in create-mode before AJV validation. Schemas that don't
|
|
899
|
+
* declare author fields fall through unstamped — guards against
|
|
900
|
+
* `additionalProperties: false` AJV failures on blocks whose item shape
|
|
901
|
+
* doesn't carry author markers yet.
|
|
902
|
+
*/
|
|
903
|
+
export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
|
|
904
|
+
withBlockLock(blockFilePath(cwd, blockName), () => {
|
|
905
|
+
const data = readBlock(cwd, blockName);
|
|
906
|
+
if (!data || typeof data !== "object") {
|
|
907
|
+
throw new Error(`Block '${blockName}' is not an object`);
|
|
908
|
+
}
|
|
909
|
+
const record = data;
|
|
910
|
+
if (!(arrayKey in record)) {
|
|
911
|
+
throw new Error(`Block '${blockName}' has no key '${arrayKey}'`);
|
|
912
|
+
}
|
|
913
|
+
if (!Array.isArray(record[arrayKey])) {
|
|
914
|
+
throw new Error(`Block '${blockName}' key '${arrayKey}' is not an array`);
|
|
915
|
+
}
|
|
916
|
+
// Optional ctx-stamping for object-shaped items (skipped silently for
|
|
917
|
+
// scalar items even when the schema technically permits author fields —
|
|
918
|
+
// stamping a string / number is meaningless).
|
|
919
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
920
|
+
const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
921
|
+
? maybeStampItem(schemaPath, arrayKey, item, ctx, "create")
|
|
922
|
+
: item;
|
|
923
|
+
record[arrayKey] = [...record[arrayKey], itemToAppend];
|
|
924
|
+
writeBlock(cwd, blockName, record);
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Find an item in data[arrayKey] by predicate, shallow-merge updates onto it,
|
|
929
|
+
* validate whole file against schema, write atomically. Throws if no item
|
|
930
|
+
* matches, if arrayKey is missing or not an array, or if validation fails.
|
|
931
|
+
*
|
|
932
|
+
* `ctx` (FGAP-004): when provided AND the schema declares author fields on
|
|
933
|
+
* the array's items, the merged item is run through `stampItem` in
|
|
934
|
+
* update-mode after the shallow merge — `created_by` / `created_at` are
|
|
935
|
+
* preserved, `modified_by` / `modified_at` refresh.
|
|
936
|
+
*/
|
|
937
|
+
export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates, ctx) {
|
|
938
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
939
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
940
|
+
updateItemInTypedFile(filePath, schemaPath, arrayKey, predicate, updates, ctx, `block file '${blockName}.json'`);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Atomically find-or-append a single item in `data[arrayKey]` keyed by
|
|
944
|
+
* `idField`. Acquires the block lock; reads the array; locates the first
|
|
945
|
+
* existing item where `existingItem[idField] === item[idField]`; if found
|
|
946
|
+
* the item at that index is REPLACED (not shallow-merged) by the supplied
|
|
947
|
+
* `item` and stamping runs in `"update"` mode; otherwise `item` is pushed
|
|
948
|
+
* onto the array and stamping runs in `"create"` mode. AJV validates the
|
|
949
|
+
* whole file against the schema after mutation, before write.
|
|
950
|
+
*
|
|
951
|
+
* Composing existing `updateItemInBlock` + `appendToBlock` from a caller
|
|
952
|
+
* would release the block lock between the read-check and the mutating
|
|
953
|
+
* write, which is race-prone for concurrent monitor/LLM writes against the
|
|
954
|
+
* same block — this primitive holds the lock for both halves of the
|
|
955
|
+
* find-or-append decision in one atomic critical section.
|
|
956
|
+
*
|
|
957
|
+
* Throws when `item[idField]` is missing or empty (defensive — surfaces a
|
|
958
|
+
* malformed call site early instead of silently appending a duplicate
|
|
959
|
+
* that would never match on a subsequent upsert). Throws on the usual
|
|
960
|
+
* block / arrayKey / not-array invariants and on AJV validation failure.
|
|
961
|
+
*
|
|
962
|
+
* Replacement semantics (vs. updateItemInBlock's shallow-merge): upsert is
|
|
963
|
+
* the call surface for monitor write-actions where the template produces
|
|
964
|
+
* the FULL item shape per classification — there is no prior partial state
|
|
965
|
+
* to merge against. Callers that need merge-on-update should continue to
|
|
966
|
+
* use `updateItemInBlock`.
|
|
967
|
+
*/
|
|
968
|
+
export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx) {
|
|
969
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
970
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
971
|
+
return upsertItemInTypedFile(filePath, schemaPath, arrayKey, item, idField, ctx, `block file '${blockName}.json'`);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Atomically append an item to a nested array inside a parent-array item.
|
|
975
|
+
*
|
|
976
|
+
* Read current file, locate parent-array item by predicate, push `item` onto
|
|
977
|
+
* `data[parentArrayKey][matchedIndex][nestedArrayKey]`, validate whole file
|
|
978
|
+
* against schema, write atomically. Throws if file doesn't exist; if
|
|
979
|
+
* parentArrayKey is missing or not an array; if no parent item matches the
|
|
980
|
+
* predicate; if the matched parent item has no `nestedArrayKey` or it is not
|
|
981
|
+
* an array; or if validation fails. Mirrors updateItemInBlock's structure:
|
|
982
|
+
* file lock, predicate findIndex, multi-match warning, clone-before-write so
|
|
983
|
+
* the original array remains unmodified if writeBlock throws.
|
|
984
|
+
*/
|
|
985
|
+
export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
|
|
986
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
987
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
988
|
+
appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, predicate, nestedArrayKey, item, ctx, `block file '${blockName}.json'`);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Atomically update a single item inside a nested array on a parent-array
|
|
992
|
+
* item: locate parent by parentPredicate, locate nested by nestedPredicate,
|
|
993
|
+
* shallow-merge `updates` onto the matched nested item, validate the whole
|
|
994
|
+
* file against schema, write atomically. Throws on missing block / missing
|
|
995
|
+
* parent key / parent key not array / no parent match / matched parent
|
|
996
|
+
* missing nestedKey / nested key not array / no nested match / validation
|
|
997
|
+
* failure. Multi-match warnings emit at both parent and nested levels via
|
|
998
|
+
* console.error (mirrors the established appendToNestedArray /
|
|
999
|
+
* updateItemInBlock convention). Clone-then-write keeps the original arrays
|
|
1000
|
+
* unmodified if writeBlock throws.
|
|
1001
|
+
*/
|
|
1002
|
+
export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
|
|
1003
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
1004
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
1005
|
+
updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx, `block file '${blockName}.json'`);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Atomically remove all items matching `predicate` from a top-level array
|
|
1009
|
+
* inside `data[arrayKey]`, validate the whole file against schema, write
|
|
1010
|
+
* atomically. Returns `{ removed: <count> }`. Throws on missing block /
|
|
1011
|
+
* missing key / key not array / validation failure (e.g., schema requires
|
|
1012
|
+
* minItems and removal violates it). Returns `{ removed: 0 }` on no match
|
|
1013
|
+
* without throwing — removal of a non-existent item is treated as an
|
|
1014
|
+
* idempotent successful no-op, distinct from update which throws on miss.
|
|
1015
|
+
*/
|
|
1016
|
+
export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
|
|
1017
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
1018
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
1019
|
+
return removeFromTypedFile(filePath, schemaPath, arrayKey, predicate, ctx, `block file '${blockName}.json'`);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Atomically remove all items matching `nestedPredicate` from a nested array
|
|
1023
|
+
* inside the parent-array item matched by `parentPredicate`. Validates and
|
|
1024
|
+
* writes atomically. Returns `{ removed: <count> }`. Throws on missing block
|
|
1025
|
+
* / missing parent key / parent key not array / no parent match / matched
|
|
1026
|
+
* parent missing nestedKey / nested key not array / validation failure.
|
|
1027
|
+
* Returns `{ removed: 0 }` on no nested match without throwing (idempotent,
|
|
1028
|
+
* mirrors removeFromBlock semantics). Multi-match warning at parent level
|
|
1029
|
+
* via console.error.
|
|
1030
|
+
*/
|
|
1031
|
+
export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
|
|
1032
|
+
const filePath = blockFilePath(cwd, blockName);
|
|
1033
|
+
const schemaPath = existingBlockSchemaPath(cwd, blockName);
|
|
1034
|
+
return removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx, `block file '${blockName}.json'`);
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Read all `.json` files in a `.project/<subdir>/` directory and return the
|
|
1038
|
+
* parsed contents as a sorted array. Sort order is filesystem-name ascending
|
|
1039
|
+
* (matches Array.sort default on the basename strings). Missing directories
|
|
1040
|
+
* return `[]` — on-demand `.project/` subdirectories are valid and represent
|
|
1041
|
+
* "no items yet". Throws on filesystem read failure of a present file or on
|
|
1042
|
+
* invalid JSON, with file-relative path in the error message. Behavior must
|
|
1043
|
+
* match the previous private `executeReadDir` in pi-workflows step-block.ts
|
|
1044
|
+
* byte-identically; both pi-workflows and the `read-block-dir` registered
|
|
1045
|
+
* tool consume this single export.
|
|
1046
|
+
*/
|
|
1047
|
+
export function readBlockDir(cwd, subdir) {
|
|
1048
|
+
const root = tryResolveContextDir(cwd);
|
|
1049
|
+
if (root === null)
|
|
1050
|
+
return []; // no .pi-context.json pointer → no items (consistent with missing-dir → [])
|
|
1051
|
+
const dirPath = path.join(root, subdir);
|
|
1052
|
+
let entries;
|
|
1053
|
+
try {
|
|
1054
|
+
entries = fs
|
|
1055
|
+
.readdirSync(dirPath)
|
|
1056
|
+
.filter((f) => f.endsWith(".json"))
|
|
1057
|
+
.sort();
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
// Missing directory = "no items yet" for on-demand substrate-dir subdirectories
|
|
1061
|
+
return [];
|
|
1062
|
+
}
|
|
1063
|
+
const results = [];
|
|
1064
|
+
for (const filename of entries) {
|
|
1065
|
+
const filePath = path.join(dirPath, filename);
|
|
1066
|
+
let content;
|
|
1067
|
+
try {
|
|
1068
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
throw new Error(`Cannot read file: .project/${subdir}/${filename}`);
|
|
1072
|
+
}
|
|
1073
|
+
try {
|
|
1074
|
+
results.push(JSON.parse(content));
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
throw new Error(`Invalid JSON in: .project/${subdir}/${filename}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return results;
|
|
1081
|
+
}
|
|
1082
|
+
// ── Item-schema resolution + id allocation (FGAP-083 / FGAP-084) ──────────────
|
|
1083
|
+
/**
|
|
1084
|
+
* Resolve the item subschema for a block schema: find the first array property
|
|
1085
|
+
* carrying `items`, dereferencing a single-level `$ref` to `#/definitions/<x>`
|
|
1086
|
+
* or `#/$defs/<x>` (the shape FK-stripped block schemas use, e.g. features /
|
|
1087
|
+
* spec-reviews). Returns the array key + the resolved item schema object (with
|
|
1088
|
+
* its `properties` / `required` / `id`). Throws when no array property is found
|
|
1089
|
+
* or the `$ref` cannot be resolved.
|
|
1090
|
+
*
|
|
1091
|
+
* FGAP-083: callers that read `items.properties.id.pattern` / `items.required`
|
|
1092
|
+
* straight off `props[arrayKey].items` get `undefined` for `$ref` items; this
|
|
1093
|
+
* one dereference is the fix shared by auto-id, author-field auto-stamp, and
|
|
1094
|
+
* whole-file validation.
|
|
1095
|
+
*/
|
|
1096
|
+
export function resolveBlockItemSchema(schema) {
|
|
1097
|
+
const props = (schema.properties ?? {});
|
|
1098
|
+
let arrayKey;
|
|
1099
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1100
|
+
if (v && v.type === "array" && v.items) {
|
|
1101
|
+
arrayKey = k;
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (!arrayKey) {
|
|
1106
|
+
throw new Error("resolveBlockItemSchema: no array property with items found in schema");
|
|
1107
|
+
}
|
|
1108
|
+
let items = props[arrayKey].items;
|
|
1109
|
+
const ref = typeof items.$ref === "string" ? items.$ref : undefined;
|
|
1110
|
+
if (ref) {
|
|
1111
|
+
const m = /^#\/(definitions|\$defs)\/(.+)$/.exec(ref);
|
|
1112
|
+
if (!m) {
|
|
1113
|
+
throw new Error(`resolveBlockItemSchema: unsupported $ref '${ref}' (only #/definitions/* or #/$defs/*)`);
|
|
1114
|
+
}
|
|
1115
|
+
const bag = (schema[m[1]] ?? {});
|
|
1116
|
+
const target = bag[m[2]];
|
|
1117
|
+
if (!target) {
|
|
1118
|
+
throw new Error(`resolveBlockItemSchema: $ref '${ref}' does not resolve in schema`);
|
|
1119
|
+
}
|
|
1120
|
+
items = target;
|
|
1121
|
+
}
|
|
1122
|
+
return { arrayKey, itemSchema: items };
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Allocate the next id for a block from its schema's id pattern — canonical and
|
|
1126
|
+
* `$ref`-aware (FGAP-084 / FGAP-083). Reads the block schema, resolves the item
|
|
1127
|
+
* subschema, parses the `id` pattern's prefix + minimum digit width
|
|
1128
|
+
* (`^FGAP-\d{3}$` → `FGAP-`/3; `^TASK-\d{3,}$` → `TASK-`/3), scans existing item
|
|
1129
|
+
* ids for the max numeric suffix, and returns `prefix` + zero-padded (maxN+1).
|
|
1130
|
+
* Throws when the schema is missing or the id pattern is absent / not
|
|
1131
|
+
* prefix+width parseable. Orchestrator CLIs + the in-pi append tool route
|
|
1132
|
+
* through this instead of re-implementing allocation.
|
|
1133
|
+
*/
|
|
1134
|
+
export function nextId(cwd, blockName) {
|
|
1135
|
+
const schemaFile = blockSchemaPath(cwd, blockName);
|
|
1136
|
+
if (!fs.existsSync(schemaFile)) {
|
|
1137
|
+
throw new Error(`nextId: schema not found for block '${blockName}' at ${schemaFile}`);
|
|
1138
|
+
}
|
|
1139
|
+
const schema = JSON.parse(fs.readFileSync(schemaFile, "utf-8"));
|
|
1140
|
+
const { arrayKey, itemSchema } = resolveBlockItemSchema(schema);
|
|
1141
|
+
const idProp = (itemSchema.properties ?? {}).id;
|
|
1142
|
+
const pattern = idProp && typeof idProp.pattern === "string" ? idProp.pattern : undefined;
|
|
1143
|
+
if (!pattern) {
|
|
1144
|
+
throw new Error(`nextId: block '${blockName}' item schema has no id.pattern`);
|
|
1145
|
+
}
|
|
1146
|
+
const m = /^\^([A-Za-z_-]+)\\d\{(\d+)(?:,\d*)?\}\$$/.exec(pattern);
|
|
1147
|
+
if (!m) {
|
|
1148
|
+
throw new Error(`nextId: id pattern '${pattern}' for block '${blockName}' is not prefix+width parseable`);
|
|
1149
|
+
}
|
|
1150
|
+
const prefix = m[1];
|
|
1151
|
+
const width = Number.parseInt(m[2], 10);
|
|
1152
|
+
const data = readBlock(cwd, blockName);
|
|
1153
|
+
const items = (data[arrayKey] ?? []);
|
|
1154
|
+
const re = new RegExp(`^${prefix}(\\d+)$`);
|
|
1155
|
+
let maxN = 0;
|
|
1156
|
+
for (const it of items) {
|
|
1157
|
+
const id = typeof it.id === "string" ? it.id : "";
|
|
1158
|
+
const mm = re.exec(id);
|
|
1159
|
+
if (mm) {
|
|
1160
|
+
const n = Number.parseInt(mm[1], 10);
|
|
1161
|
+
if (n > maxN)
|
|
1162
|
+
maxN = n;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return `${prefix}${String(maxN + 1).padStart(width, "0")}`;
|
|
1166
|
+
}
|
|
1167
|
+
//# sourceMappingURL=block-api.js.map
|