frontend-project-context 1.0.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +4 -0
  4. package/PROJECT_STATE.json +176 -0
  5. package/README.md +148 -0
  6. package/RTK.md +13 -0
  7. package/UPGRADING.md +15 -0
  8. package/bin/project-context.mjs +7 -0
  9. package/docs/00-PRODUCT-CONSTITUTION.md +166 -0
  10. package/docs/01-PRODUCT-CORE.md +143 -0
  11. package/docs/02-MARKET-BOUNDARY.md +88 -0
  12. package/docs/03-FINAL-SOLUTION.md +203 -0
  13. package/docs/04-PROGRAM-DESIGN.md +428 -0
  14. package/docs/05-ACCEPTANCE-CONTRACT.md +348 -0
  15. package/docs/06-HISTORICAL-PROTOTYPE.md +55 -0
  16. package/docs/07-REAL-TASK-EVIDENCE.md +52 -0
  17. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +199 -0
  18. package/docs/09-B0-DTG-TMC-MOBILE.md +173 -0
  19. package/docs/10-B0-DTG-TMC-PC.md +118 -0
  20. package/docs/11-V1-AUTHORING-CLOSURE-DESIGN.md +312 -0
  21. package/docs/12-KNOWLEDGE-MAINTENANCE-CLOSURE-ROADMAP.md +350 -0
  22. package/docs/13-READ-ONLY-GOVERNANCE-DASHBOARD-DESIGN.md +489 -0
  23. package/docs/14-FORMAL-RELEASE-READINESS.md +61 -0
  24. package/docs/15-SOURCE-LIFECYCLE-CLOSURE-DESIGN.md +260 -0
  25. package/docs/README.md +74 -0
  26. package/examples/README.md +17 -0
  27. package/examples/package.json +11 -0
  28. package/examples/project-context-check.yml +22 -0
  29. package/package.json +40 -0
  30. package/src/project-context/approver.mjs +177 -0
  31. package/src/project-context/authoring.mjs +190 -0
  32. package/src/project-context/canonical-json.mjs +55 -0
  33. package/src/project-context/checker.mjs +132 -0
  34. package/src/project-context/cli.mjs +409 -0
  35. package/src/project-context/contract-schema.mjs +316 -0
  36. package/src/project-context/dashboard-model.mjs +278 -0
  37. package/src/project-context/dashboard-renderer.mjs +637 -0
  38. package/src/project-context/discovery.mjs +251 -0
  39. package/src/project-context/errors.mjs +13 -0
  40. package/src/project-context/io.mjs +93 -0
  41. package/src/project-context/maintenance.mjs +400 -0
  42. package/src/project-context/path-policy.mjs +155 -0
  43. package/src/project-context/project-store.mjs +138 -0
  44. package/src/project-context/projection-store.mjs +107 -0
  45. package/src/project-context/renderer.mjs +135 -0
  46. package/src/project-context/scope-compiler.mjs +132 -0
  47. package/src/project-context/source-reader.mjs +124 -0
@@ -0,0 +1,400 @@
1
+ import { digestJson } from "./canonical-json.mjs";
2
+ import { buildItemProposal } from "./authoring.mjs";
3
+ import { sourceStatus, validateContract, validateSourceLock } from "./contract-schema.mjs";
4
+ import { fail } from "./errors.mjs";
5
+ import { writeProjectState } from "./project-store.mjs";
6
+ import { readSourceDigest } from "./source-reader.mjs";
7
+ import { findConflicts, validateOverrides } from "./scope-compiler.mjs";
8
+
9
+ const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
10
+
11
+ function sameStrings(left, right) {
12
+ return left.length === right.length && left.every((value, index) => value === right[index]);
13
+ }
14
+
15
+ function itemSummary(item, reasons = []) {
16
+ return {
17
+ id: item.id,
18
+ kind: item.kind,
19
+ subject: item.subject,
20
+ scope: structuredClone(item.scope),
21
+ itemDigest: digestJson(item),
22
+ ...(reasons.length > 0 ? { reasons: [...reasons].sort() } : {}),
23
+ };
24
+ }
25
+
26
+ function reverseOverrideDependents(contract, initialIds) {
27
+ const approved = contract.items.filter((item) => item.status === "approved");
28
+ const affected = new Set(initialIds);
29
+ let changed = true;
30
+ while (changed) {
31
+ changed = false;
32
+ for (const item of approved) {
33
+ if (affected.has(item.id) || !item.overrides.some((id) => affected.has(id))) continue;
34
+ affected.add(item.id);
35
+ changed = true;
36
+ }
37
+ }
38
+ return affected;
39
+ }
40
+
41
+ function relationDetails(contract, sourceId) {
42
+ const approved = contract.items.filter((item) => item.status === "approved");
43
+ const directReasons = new Map();
44
+ for (const item of approved) {
45
+ const reasons = [];
46
+ if (item.sources.includes(sourceId)) reasons.push("source");
47
+ if (item.verification?.source === sourceId) reasons.push("verification");
48
+ if (reasons.length > 0) directReasons.set(item.id, reasons);
49
+ }
50
+ const affected = reverseOverrideDependents(contract, directReasons.keys());
51
+ const byId = new Map(approved.map((item) => [item.id, item]));
52
+ const items = [...affected]
53
+ .map((id) => {
54
+ const item = byId.get(id);
55
+ const reasons = directReasons.get(id) ?? ["override-dependent"];
56
+ return itemSummary(item, reasons);
57
+ })
58
+ .sort((left, right) => left.id.localeCompare(right.id));
59
+ const fallbackIds = new Set();
60
+ for (const id of affected) {
61
+ for (const target of byId.get(id)?.overrides ?? []) {
62
+ if (!affected.has(target) && byId.has(target)) fallbackIds.add(target);
63
+ }
64
+ }
65
+ const fallbackItems = [...fallbackIds]
66
+ .map((id) => itemSummary(byId.get(id)))
67
+ .sort((left, right) => left.id.localeCompare(right.id));
68
+ return { items, fallbackItems };
69
+ }
70
+
71
+ export function sourceImpact(project, sourceId) {
72
+ const relations = relationDetails(project.contract, sourceId);
73
+ const affectedIds = new Set(relations.items.map((item) => item.id));
74
+ const directProjectionPaths = project.projectionsLock.projections
75
+ .filter((entry) => entry.itemIds.some((id) => affectedIds.has(id)))
76
+ .map((entry) => entry.path)
77
+ .sort();
78
+ const staleProjectionPaths = project.projectionsLock.projections.map((entry) => entry.path).sort();
79
+ return { ...relations, directProjectionPaths, staleProjectionPaths };
80
+ }
81
+
82
+ export async function reviewSource(root, project, sourceId) {
83
+ const source = project.contract.sources.find((entry) => entry.id === sourceId);
84
+ if (!source) fail("source-not-found", `source is not registered: ${sourceId}`, { details: { source: sourceId } });
85
+ const sourceObjectDigest = digestJson(source);
86
+ if (sourceStatus(source) === "deprecated") {
87
+ return {
88
+ source: structuredClone(source),
89
+ sourceObjectDigest,
90
+ contractDigest: project.contractDigest,
91
+ lockedDigest: null,
92
+ currentDigest: null,
93
+ status: "deprecated",
94
+ impact: sourceImpact(project, sourceId),
95
+ };
96
+ }
97
+ if (!LOCAL_SOURCE_KINDS.has(source.kind)) {
98
+ fail("source-review-unsupported", `source kind cannot be reviewed for local drift: ${source.kind}`, {
99
+ details: { source: sourceId, kind: source.kind },
100
+ });
101
+ }
102
+ const lockedDigest = project.sourcesLock.sources.find((entry) => entry.id === sourceId)?.digest ?? null;
103
+ let currentDigest = null;
104
+ let status;
105
+ let reason;
106
+ try {
107
+ currentDigest = await readSourceDigest(root, source);
108
+ if (lockedDigest === null || lockedDigest !== source.digest) {
109
+ status = "unreadable";
110
+ reason = lockedDigest === null ? "source-lock-missing" : "source-lock-mismatch";
111
+ } else {
112
+ status = currentDigest === lockedDigest ? "unchanged" : "changed";
113
+ }
114
+ } catch (error) {
115
+ status = error.code === "source-missing" ? "missing" : "unreadable";
116
+ reason = error.code ?? "source-unreadable";
117
+ }
118
+ return {
119
+ source: structuredClone(source),
120
+ sourceObjectDigest,
121
+ contractDigest: project.contractDigest,
122
+ lockedDigest,
123
+ currentDigest,
124
+ status,
125
+ ...(reason ? { reason } : {}),
126
+ impact: sourceImpact(project, sourceId),
127
+ };
128
+ }
129
+
130
+ export async function acceptSourceChange(root, project, input, options = {}) {
131
+ const review = await reviewSource(root, project, input.id);
132
+ if (review.status === "deprecated") {
133
+ fail("source-already-deprecated", `source is already deprecated: ${input.id}`, {
134
+ details: { source: input.id },
135
+ });
136
+ }
137
+ if (review.status === "unchanged") {
138
+ fail("source-not-changed", `source has not changed: ${input.id}`, { details: { source: input.id } });
139
+ }
140
+ if (review.status !== "changed" || review.currentDigest === null) {
141
+ fail("source-change-not-acceptable", `source state cannot be accepted: ${review.status}`, {
142
+ details: { source: input.id, status: review.status, reason: review.reason },
143
+ });
144
+ }
145
+ if (input.expectedDigest !== review.currentDigest) {
146
+ fail("source-accept-digest-mismatch", "the reviewed source digest no longer matches", {
147
+ exitCode: 1,
148
+ details: { source: input.id, expected: input.expectedDigest, actual: review.currentDigest },
149
+ });
150
+ }
151
+ const expectedItems = review.impact.items.map((item) => item.id);
152
+ const acknowledgedItems = [...new Set(input.affectedItems ?? [])].sort();
153
+ if (!sameStrings(acknowledgedItems, expectedItems)) {
154
+ fail("source-impact-changed", "the acknowledged affected item set is incomplete or stale", {
155
+ exitCode: 1,
156
+ details: { source: input.id, expected: expectedItems, actual: acknowledgedItems },
157
+ });
158
+ }
159
+
160
+ const nextContract = structuredClone(project.contract);
161
+ const source = nextContract.sources.find((entry) => entry.id === input.id);
162
+ source.digest = review.currentDigest;
163
+ const affected = new Set(expectedItems);
164
+ nextContract.items = nextContract.items.map((item) => {
165
+ if (!affected.has(item.id) || item.status !== "approved") return item;
166
+ const pending = { ...item, status: "proposed" };
167
+ delete pending.approval;
168
+ return pending;
169
+ });
170
+ validateContract(nextContract);
171
+
172
+ const nextSourcesLock = structuredClone(project.sourcesLock);
173
+ const lockEntry = nextSourcesLock.sources.find((entry) => entry.id === input.id);
174
+ if (!lockEntry) fail("source-lock-missing", `source lock entry is missing: ${input.id}`, { exitCode: 1 });
175
+ lockEntry.digest = review.currentDigest;
176
+ nextSourcesLock.sources.sort((left, right) => left.id.localeCompare(right.id));
177
+ validateSourceLock(nextSourcesLock);
178
+
179
+ if (options.write) {
180
+ let actual;
181
+ try {
182
+ actual = await readSourceDigest(root, source);
183
+ } catch (error) {
184
+ fail("source-accept-digest-mismatch", "source became unavailable while acceptance was being prepared", {
185
+ exitCode: 1,
186
+ details: { source: input.id, expected: input.expectedDigest, actual: null, reason: error.code ?? "unreadable" },
187
+ });
188
+ }
189
+ if (actual !== input.expectedDigest) {
190
+ fail("source-accept-digest-mismatch", "source changed while acceptance was being prepared", {
191
+ exitCode: 1,
192
+ details: { source: input.id, expected: input.expectedDigest, actual },
193
+ });
194
+ }
195
+ await writeProjectState(project, nextContract, nextSourcesLock, options.storeOptions);
196
+ }
197
+ return {
198
+ action: "accept-source-change",
199
+ source: input.id,
200
+ previousDigest: review.lockedDigest,
201
+ acceptedDigest: review.currentDigest,
202
+ affectedItems: expectedItems,
203
+ staleProjectionPaths: review.impact.staleProjectionPaths,
204
+ nextContractDigest: digestJson(nextContract),
205
+ written: Boolean(options.write),
206
+ };
207
+ }
208
+
209
+ function currentSourceReferences(contract, sourceId) {
210
+ return contract.items
211
+ .filter((item) => item.status !== "deprecated")
212
+ .map((item) => {
213
+ const reasons = [];
214
+ if (item.sources.includes(sourceId)) reasons.push("source");
215
+ if (item.verification?.source === sourceId) reasons.push("verification");
216
+ return reasons.length > 0 ? itemSummary(item, reasons) : null;
217
+ })
218
+ .filter(Boolean)
219
+ .sort((left, right) => left.id.localeCompare(right.id));
220
+ }
221
+
222
+ export async function deprecateSource(project, input, options = {}) {
223
+ if (typeof input.by !== "string" || input.by.length === 0) {
224
+ fail("argument-invalid", "source deprecation requires a non-empty maintainer name", {
225
+ details: { source: input.id },
226
+ });
227
+ }
228
+ if (typeof input.rationale !== "string" || input.rationale.length === 0) {
229
+ fail("argument-invalid", "source deprecation requires a non-empty rationale", {
230
+ details: { source: input.id },
231
+ });
232
+ }
233
+ const current = project.contract.sources.find((source) => source.id === input.id);
234
+ if (!current) fail("source-not-found", `source is not registered: ${input.id}`, { details: { source: input.id } });
235
+ if (sourceStatus(current) === "deprecated") {
236
+ fail("source-already-deprecated", `source is already deprecated: ${input.id}`, {
237
+ details: { source: input.id },
238
+ });
239
+ }
240
+
241
+ const currentSourceDigest = digestJson(current);
242
+ if (options.write && input.expectedSourceDigest !== currentSourceDigest) {
243
+ fail("source-baseline-changed", "source changed after the deprecation preview", {
244
+ exitCode: 1,
245
+ details: { source: input.id, expected: input.expectedSourceDigest, actual: currentSourceDigest },
246
+ });
247
+ }
248
+ const blockingItems = currentSourceReferences(project.contract, input.id);
249
+ if (options.write && blockingItems.length > 0) {
250
+ fail("source-still-referenced", "source is still referenced by proposed or approved items", {
251
+ details: { source: input.id, items: blockingItems },
252
+ });
253
+ }
254
+ if (blockingItems.length > 0) {
255
+ return {
256
+ action: "deprecate-source",
257
+ currentSource: structuredClone(current),
258
+ currentSourceDigest,
259
+ blockingItems,
260
+ impact: sourceImpact(project, input.id),
261
+ nextContractDigest: null,
262
+ nextSourcesLockDigest: null,
263
+ written: false,
264
+ };
265
+ }
266
+
267
+ const timestamp = input.at ?? new Date().toISOString();
268
+ const nextContract = structuredClone(project.contract);
269
+ nextContract.schemaVersion = 2;
270
+ nextContract.sources = nextContract.sources.map((source) => {
271
+ const active = source.status === undefined ? { ...source, status: "active" } : source;
272
+ if (source.id !== input.id) return active;
273
+ return {
274
+ ...active,
275
+ status: "deprecated",
276
+ deprecation: { by: input.by, at: timestamp, rationale: input.rationale },
277
+ };
278
+ });
279
+ validateContract(nextContract);
280
+
281
+ const nextSourcesLock = {
282
+ ...structuredClone(project.sourcesLock),
283
+ sources: project.sourcesLock.sources.filter((entry) => entry.id !== input.id),
284
+ };
285
+ validateSourceLock(nextSourcesLock);
286
+ if (options.write) await writeProjectState(project, nextContract, nextSourcesLock, options.storeOptions);
287
+ const deprecatedSource = nextContract.sources.find((source) => source.id === input.id);
288
+ return {
289
+ action: "deprecate-source",
290
+ currentSource: structuredClone(current),
291
+ currentSourceDigest,
292
+ deprecatedSource: structuredClone(deprecatedSource),
293
+ blockingItems,
294
+ impact: sourceImpact(project, input.id),
295
+ nextContractDigest: digestJson(nextContract),
296
+ nextSourcesLockDigest: digestJson(nextSourcesLock),
297
+ written: Boolean(options.write),
298
+ };
299
+ }
300
+
301
+ export async function reviseItem(root, project, input, options = {}) {
302
+ const current = project.contract.items.find((item) => item.id === input.id);
303
+ if (!current) fail("item-not-found", `contract item does not exist: ${input.id}`, { details: { item: input.id } });
304
+ if (current.status === "deprecated") {
305
+ fail("item-already-deprecated", `deprecated item cannot be revised: ${input.id}`, { details: { item: input.id } });
306
+ }
307
+ const revisionProject = {
308
+ ...project,
309
+ contract: { ...project.contract, items: project.contract.items.filter((item) => item.id !== input.id) },
310
+ };
311
+ const replacement = buildItemProposal(revisionProject, input).items[0];
312
+ if (replacement.kind !== current.kind || replacement.subject !== current.subject) {
313
+ fail("item-identity-change", "revision must preserve item id, kind, and subject", {
314
+ details: {
315
+ item: input.id,
316
+ current: { kind: current.kind, subject: current.subject },
317
+ proposed: { kind: replacement.kind, subject: replacement.subject },
318
+ },
319
+ });
320
+ }
321
+ const currentItemDigest = digestJson(current);
322
+ if (options.write && input.expectedItemDigest !== currentItemDigest) {
323
+ fail("item-baseline-changed", "item changed after the revision preview", {
324
+ exitCode: 1,
325
+ details: { item: input.id, expected: input.expectedItemDigest, actual: currentItemDigest },
326
+ });
327
+ }
328
+ const nextContract = structuredClone(project.contract);
329
+ nextContract.items[nextContract.items.findIndex((item) => item.id === input.id)] = replacement;
330
+ validateContract(nextContract);
331
+ if (options.write) await writeProjectState(project, nextContract, project.sourcesLock, options.storeOptions);
332
+ return {
333
+ action: "revise",
334
+ currentItem: structuredClone(current),
335
+ currentItemDigest,
336
+ proposedItem: structuredClone(replacement),
337
+ nextContractDigest: digestJson(nextContract),
338
+ written: Boolean(options.write),
339
+ };
340
+ }
341
+
342
+ function itemRelationships(contract, itemId) {
343
+ const dependentIds = reverseOverrideDependents(contract, [itemId]);
344
+ dependentIds.delete(itemId);
345
+ const approvedById = new Map(contract.items.filter((item) => item.status === "approved").map((item) => [item.id, item]));
346
+ const item = contract.items.find((entry) => entry.id === itemId);
347
+ const fallbackIds = (item?.overrides ?? []).filter((id) => approvedById.has(id));
348
+ return {
349
+ overrideDependents: [...dependentIds].map((id) => itemSummary(approvedById.get(id))).sort((a, b) => a.id.localeCompare(b.id)),
350
+ fallbackItems: fallbackIds.map((id) => itemSummary(approvedById.get(id))).sort((a, b) => a.id.localeCompare(b.id)),
351
+ };
352
+ }
353
+
354
+ export async function deprecateItem(project, input, options = {}) {
355
+ if (typeof input.rationale !== "string" || input.rationale.length === 0) {
356
+ fail("argument-invalid", "deprecation requires a non-empty rationale", { details: { item: input.id } });
357
+ }
358
+ const current = project.contract.items.find((item) => item.id === input.id);
359
+ if (!current) fail("item-not-found", `contract item does not exist: ${input.id}`, { details: { item: input.id } });
360
+ if (current.status === "deprecated") {
361
+ fail("item-already-deprecated", `item is already deprecated: ${input.id}`, { details: { item: input.id } });
362
+ }
363
+ const currentItemDigest = digestJson(current);
364
+ if (options.write && input.expectedItemDigest !== currentItemDigest) {
365
+ fail("item-baseline-changed", "item changed after the deprecation preview", {
366
+ exitCode: 1,
367
+ details: { item: input.id, expected: input.expectedItemDigest, actual: currentItemDigest },
368
+ });
369
+ }
370
+ const deprecatedItem = {
371
+ ...structuredClone(current),
372
+ status: "deprecated",
373
+ approval: {
374
+ by: input.by,
375
+ at: input.at ?? new Date().toISOString(),
376
+ rationale: input.rationale,
377
+ },
378
+ };
379
+ const nextContract = structuredClone(project.contract);
380
+ nextContract.items[nextContract.items.findIndex((item) => item.id === input.id)] = deprecatedItem;
381
+ validateContract(nextContract);
382
+ const findings = [...validateOverrides(nextContract.items), ...findConflicts(nextContract.items)];
383
+ if (options.write && findings.length > 0) {
384
+ fail("maintenance-preflight-failed", "deprecation would leave invalid approved overrides or conflicts", {
385
+ exitCode: 1,
386
+ details: { item: input.id, findings },
387
+ });
388
+ }
389
+ if (options.write) await writeProjectState(project, nextContract, project.sourcesLock, options.storeOptions);
390
+ return {
391
+ action: "deprecate",
392
+ currentItem: structuredClone(current),
393
+ currentItemDigest,
394
+ deprecatedItem,
395
+ relationships: itemRelationships(project.contract, input.id),
396
+ preflightFindings: findings,
397
+ nextContractDigest: digestJson(nextContract),
398
+ written: Boolean(options.write),
399
+ };
400
+ }
@@ -0,0 +1,155 @@
1
+ import { lstat, mkdir, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fail } from "./errors.mjs";
4
+
5
+ function hasParentSegment(value) {
6
+ return value.split(/[\\/]+/u).includes("..");
7
+ }
8
+
9
+ export function normalizeRelativePath(value, options = {}) {
10
+ const { allowRoot = false, label = "path" } = options;
11
+ if (typeof value !== "string" || value.length === 0 || value.includes("\0")) {
12
+ fail("invalid-path", `${label} must be a non-empty relative path`);
13
+ }
14
+ if (path.isAbsolute(value) || hasParentSegment(value)) {
15
+ fail("path-outside-project", `${label} must stay inside the project`, {
16
+ details: { path: value },
17
+ });
18
+ }
19
+ const normalized = path.posix.normalize(value.replaceAll("\\", "/")).replace(/^\.\//u, "");
20
+ if ((!allowRoot && (normalized === "." || normalized === "")) || normalized.startsWith("../")) {
21
+ fail("invalid-path", `${label} is not a valid project-relative path`, {
22
+ details: { path: value },
23
+ });
24
+ }
25
+ return normalized;
26
+ }
27
+
28
+ export async function resolveProjectRoot(projectPath) {
29
+ if (typeof projectPath !== "string" || projectPath.length === 0) {
30
+ fail("invalid-project", "--project must name an existing directory");
31
+ }
32
+ let root;
33
+ try {
34
+ root = await realpath(projectPath);
35
+ const info = await lstat(root);
36
+ if (!info.isDirectory()) fail("invalid-project", "project path is not a directory");
37
+ } catch (error) {
38
+ if (error?.code === "invalid-project") throw error;
39
+ fail("invalid-project", `cannot access project directory: ${projectPath}`, { cause: error });
40
+ }
41
+ return root;
42
+ }
43
+
44
+ function isInside(root, target) {
45
+ const relative = path.relative(root, target);
46
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
47
+ }
48
+
49
+ export async function resolveExistingInside(root, relativePath, options = {}) {
50
+ const normalized = normalizeRelativePath(relativePath, options);
51
+ const candidate = path.join(root, normalized);
52
+ let resolved;
53
+ try {
54
+ resolved = await realpath(candidate);
55
+ } catch (error) {
56
+ fail("source-missing", `project path does not exist: ${normalized}`, {
57
+ cause: error,
58
+ details: { path: normalized },
59
+ });
60
+ }
61
+ if (!isInside(root, resolved)) {
62
+ fail("path-outside-project", `resolved path leaves the project: ${normalized}`, {
63
+ details: { path: normalized },
64
+ });
65
+ }
66
+ return { normalized, absolute: resolved };
67
+ }
68
+
69
+ export async function resolveWritableInside(root, relativePath, options = {}) {
70
+ const normalized = normalizeRelativePath(relativePath, options);
71
+ const absolute = path.join(root, normalized);
72
+ const parent = path.dirname(absolute);
73
+ let existingParent = parent;
74
+ let resolvedParent;
75
+ while (resolvedParent === undefined) {
76
+ try {
77
+ resolvedParent = await realpath(existingParent);
78
+ } catch (error) {
79
+ if (error?.code !== "ENOENT" || existingParent === root) {
80
+ fail("invalid-output-parent", `cannot resolve output parent: ${path.posix.dirname(normalized)}`, {
81
+ cause: error,
82
+ details: { path: normalized },
83
+ });
84
+ }
85
+ existingParent = path.dirname(existingParent);
86
+ }
87
+ }
88
+ if (!isInside(root, resolvedParent)) {
89
+ fail("path-outside-project", `output parent leaves the project: ${normalized}`, {
90
+ details: { path: normalized },
91
+ });
92
+ }
93
+ if (options.createParent) {
94
+ const missingSegments = path.relative(existingParent, parent).split(path.sep).filter(Boolean);
95
+ let current = existingParent;
96
+ for (const segment of missingSegments) {
97
+ current = path.join(current, segment);
98
+ try {
99
+ await mkdir(current);
100
+ } catch (error) {
101
+ if (error?.code !== "EEXIST") {
102
+ fail("invalid-output-parent", `cannot create output parent: ${path.posix.dirname(normalized)}`, {
103
+ cause: error,
104
+ details: { path: normalized },
105
+ });
106
+ }
107
+ }
108
+ let resolved;
109
+ try {
110
+ resolved = await realpath(current);
111
+ } catch (error) {
112
+ fail("invalid-output-parent", `cannot resolve output parent: ${path.posix.dirname(normalized)}`, {
113
+ cause: error,
114
+ details: { path: normalized },
115
+ });
116
+ }
117
+ if (!isInside(root, resolved)) {
118
+ fail("path-outside-project", `output parent leaves the project: ${normalized}`, {
119
+ details: { path: normalized },
120
+ });
121
+ }
122
+ }
123
+ }
124
+ try {
125
+ const info = await lstat(absolute);
126
+ if (info.isSymbolicLink()) {
127
+ fail("output-symlink", `output cannot be a symlink: ${normalized}`, {
128
+ details: { path: normalized },
129
+ });
130
+ }
131
+ } catch (error) {
132
+ if (error?.code !== "ENOENT") throw error;
133
+ }
134
+ return { normalized, absolute };
135
+ }
136
+
137
+ export function assertProjectionPath(target, normalized) {
138
+ if (target === "agents") {
139
+ if (path.posix.basename(normalized) !== "AGENTS.md") {
140
+ fail("invalid-projection-path", "agents projections must target an AGENTS.md file", {
141
+ details: { path: normalized, target },
142
+ });
143
+ }
144
+ return;
145
+ }
146
+ if (target === "ruler") {
147
+ if (!normalized.startsWith(".ruler/") || path.posix.extname(normalized) !== ".md") {
148
+ fail("invalid-projection-path", "ruler projections must be Markdown files inside .ruler/", {
149
+ details: { path: normalized, target },
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ fail("invalid-target", `unsupported projection target: ${target}`);
155
+ }
@@ -0,0 +1,138 @@
1
+ import { access, mkdir, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { canonicalJson, digestJson } from "./canonical-json.mjs";
5
+ import { validateContract, validateProjectionLock, validateSourceLock } from "./contract-schema.mjs";
6
+ import { fail } from "./errors.mjs";
7
+ import { atomicWriteJson, readJsonFile } from "./io.mjs";
8
+
9
+ export const CONTEXT_DIRECTORY = ".project-context";
10
+ export const CONTRACT_FILE = "contract.json";
11
+ export const SOURCES_LOCK_FILE = "sources.lock.json";
12
+ export const PROJECTIONS_LOCK_FILE = "projections.lock.json";
13
+
14
+ export function projectFiles(root) {
15
+ const directory = path.join(root, CONTEXT_DIRECTORY);
16
+ return {
17
+ directory,
18
+ contract: path.join(directory, CONTRACT_FILE),
19
+ sourcesLock: path.join(directory, SOURCES_LOCK_FILE),
20
+ projectionsLock: path.join(directory, PROJECTIONS_LOCK_FILE),
21
+ };
22
+ }
23
+
24
+ export function initialProjectFiles(id, name) {
25
+ return {
26
+ contract: {
27
+ schemaVersion: 1,
28
+ project: { id, name, root: "." },
29
+ sources: [],
30
+ items: [],
31
+ },
32
+ sourcesLock: { schemaVersion: 1, sources: [] },
33
+ projectionsLock: { schemaVersion: 1, projections: [] },
34
+ };
35
+ }
36
+
37
+ async function exists(filePath) {
38
+ try {
39
+ await access(filePath);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export async function initializeProject(root, id, name, write) {
47
+ const files = projectFiles(root);
48
+ const initial = initialProjectFiles(id, name);
49
+ validateContract(initial.contract);
50
+ validateSourceLock(initial.sourcesLock);
51
+ validateProjectionLock(initial.projectionsLock);
52
+ const existing = [];
53
+ if (await exists(files.directory)) existing.push(CONTEXT_DIRECTORY);
54
+ for (const filePath of [files.contract, files.sourcesLock, files.projectionsLock]) {
55
+ if (await exists(filePath)) existing.push(path.relative(root, filePath));
56
+ }
57
+ if (existing.length > 0) {
58
+ fail("project-already-initialized", "project context files already exist", { details: { paths: existing } });
59
+ }
60
+ if (write) {
61
+ const temporaryDirectory = `${files.directory}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
62
+ try {
63
+ await mkdir(temporaryDirectory, { recursive: false });
64
+ await atomicWriteJson(path.join(temporaryDirectory, CONTRACT_FILE), initial.contract);
65
+ await atomicWriteJson(path.join(temporaryDirectory, SOURCES_LOCK_FILE), initial.sourcesLock);
66
+ await atomicWriteJson(path.join(temporaryDirectory, PROJECTIONS_LOCK_FILE), initial.projectionsLock);
67
+ await rename(temporaryDirectory, files.directory);
68
+ } catch (error) {
69
+ try {
70
+ await rm(temporaryDirectory, { recursive: true, force: true });
71
+ } catch {
72
+ // A failed cleanup is limited to this command's uniquely named temporary directory.
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ return { files, preview: initial, written: Boolean(write) };
78
+ }
79
+
80
+ export async function loadProject(root) {
81
+ const files = projectFiles(root);
82
+ const contract = validateContract(await readJsonFile(files.contract, CONTRACT_FILE));
83
+ const sourcesLock = validateSourceLock(await readJsonFile(files.sourcesLock, SOURCES_LOCK_FILE));
84
+ const projectionsLock = validateProjectionLock(await readJsonFile(files.projectionsLock, PROJECTIONS_LOCK_FILE));
85
+ return {
86
+ files,
87
+ contract,
88
+ sourcesLock,
89
+ projectionsLock,
90
+ contractDigest: digestJson(contract),
91
+ sourcesLockDigest: digestJson(sourcesLock),
92
+ projectionsLockDigest: digestJson(projectionsLock),
93
+ };
94
+ }
95
+
96
+ export async function writeProjectState(project, nextContractInput, nextSourcesLockInput, options = {}) {
97
+ const writeJson = options.writeJson ?? atomicWriteJson;
98
+ const readJson = options.readJson ?? readJsonFile;
99
+ const nextContract = validateContract(structuredClone(nextContractInput));
100
+ const nextSourcesLock = validateSourceLock(structuredClone(nextSourcesLockInput));
101
+ const currentContract = validateContract(await readJson(project.files.contract, CONTRACT_FILE));
102
+ const currentSourcesLock = validateSourceLock(await readJson(project.files.sourcesLock, SOURCES_LOCK_FILE));
103
+ const currentProjectionsLock = validateProjectionLock(await readJson(project.files.projectionsLock, PROJECTIONS_LOCK_FILE));
104
+ if (
105
+ digestJson(currentContract) !== project.contractDigest ||
106
+ digestJson(currentSourcesLock) !== project.sourcesLockDigest ||
107
+ digestJson(currentProjectionsLock) !== project.projectionsLockDigest
108
+ ) {
109
+ fail("project-state-changed", "contract, source lock, or projection lock changed while the update was being prepared", { exitCode: 1 });
110
+ }
111
+
112
+ const contractChanged = canonicalJson(nextContract) !== canonicalJson(project.contract);
113
+ const sourcesLockChanged = canonicalJson(nextSourcesLock) !== canonicalJson(project.sourcesLock);
114
+ if (!contractChanged && !sourcesLockChanged) return;
115
+
116
+ if (sourcesLockChanged) await writeJson(project.files.sourcesLock, nextSourcesLock);
117
+ try {
118
+ if (contractChanged) await writeJson(project.files.contract, nextContract);
119
+ } catch (error) {
120
+ if (sourcesLockChanged) {
121
+ try {
122
+ const recoveryCurrent = validateSourceLock(await readJson(project.files.sourcesLock, SOURCES_LOCK_FILE));
123
+ if (canonicalJson(recoveryCurrent) !== canonicalJson(nextSourcesLock)) {
124
+ if (error && typeof error === "object") {
125
+ error.details = { ...(error.details ?? {}), recovery: "sources-lock-restore-skipped-concurrent-change" };
126
+ }
127
+ } else {
128
+ await writeJson(project.files.sourcesLock, project.sourcesLock);
129
+ }
130
+ } catch (recoveryError) {
131
+ if (error && typeof error === "object") {
132
+ error.details = { ...(error.details ?? {}), recovery: "sources-lock-restore-failed", recoveryMessage: recoveryError.message };
133
+ }
134
+ }
135
+ }
136
+ throw error;
137
+ }
138
+ }