frontend-project-context 1.3.1 → 1.7.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 +51 -2
- package/README.md +156 -40
- package/UPGRADING.md +55 -1
- package/docs/04-PROGRAM-DESIGN.md +34 -4
- package/docs/05-ACCEPTANCE-CONTRACT.md +40 -3
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +67 -22
- package/docs/14-FORMAL-RELEASE-READINESS.md +30 -1
- package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +2 -2
- package/docs/19-POST-1.3.1-AI-TAKEOVER-EVIDENCE-AND-UPGRADE-PLAN.md +579 -0
- package/docs/20-PHASE-A-AI-TAKEOVER-AND-HEALTH-CLOSURE-DESIGN.md +535 -0
- package/docs/21-PHASE-B-EVIDENCE-FEEDBACK-PROTOCOL-DESIGN.md +347 -0
- package/docs/22-PHASE-C-TARGET-UPGRADE-PROTOCOL-DESIGN.md +398 -0
- package/docs/23-ADAPTIVE-BOUNDED-TASK-CONTEXT-DESIGN.md +432 -0
- package/docs/24-A130-REAL-HOST-TARGET-PROJECT-COMPARISON.md +210 -0
- package/docs/25-REAL-PROJECT-SOURCE-OF-TRUTH-MAINTENANCE-DESIGN.md +409 -0
- package/docs/26-A130-QUALITY-CLOSURE-AND-ADAPTIVE-DELIVERY-REPAIR-DESIGN.md +609 -0
- package/docs/README.md +38 -6
- package/docs/USER-AND-AI-OPERATION-MANUAL.md +840 -0
- package/examples/README.md +29 -2
- package/examples/package.json +6 -2
- package/migration-manifest.json +110 -0
- package/package.json +3 -2
- package/schemas/action-plan.schema.json +31 -3
- package/schemas/adaptive-context-bundle.schema.json +70 -0
- package/schemas/capabilities.schema.json +64 -18
- package/schemas/context-query.schema.json +69 -0
- package/schemas/coverage-audit.schema.json +32 -0
- package/schemas/evidence-bundle.schema.json +64 -0
- package/schemas/evidence-input.schema.json +82 -0
- package/schemas/host-promotion-evidence.schema.json +33 -0
- package/schemas/migration-manifest.schema.json +29 -0
- package/schemas/migration-plan.schema.json +32 -0
- package/schemas/project-status.schema.json +75 -0
- package/schemas/projection-lock.schema.json +48 -0
- package/schemas/review-bundle.schema.json +3 -3
- package/schemas/routing-index.schema.json +58 -0
- package/schemas/truth-reconciliation-input.schema.json +60 -0
- package/schemas/truth-reconciliation-review-bundle.schema.json +155 -0
- package/schemas/upgrade-assessment.schema.json +48 -0
- package/schemas/upgrade-result-bundle.schema.json +35 -0
- package/src/project-context/a130-evaluation.mjs +91 -0
- package/src/project-context/adaptive-context-schema.mjs +392 -0
- package/src/project-context/adaptive-context.mjs +547 -0
- package/src/project-context/ai-entry.mjs +320 -0
- package/src/project-context/assist.mjs +4 -2
- package/src/project-context/capabilities.mjs +62 -17
- package/src/project-context/checker.mjs +24 -6
- package/src/project-context/cli.mjs +113 -3
- package/src/project-context/contract-schema.mjs +30 -16
- package/src/project-context/dashboard-model.mjs +4 -4
- package/src/project-context/dashboard-renderer.mjs +3 -3
- package/src/project-context/discovery.mjs +13 -8
- package/src/project-context/evidence-schema.mjs +209 -0
- package/src/project-context/evidence.mjs +99 -0
- package/src/project-context/exchange-schema.mjs +23 -12
- package/src/project-context/exchange.mjs +26 -4
- package/src/project-context/maintenance.mjs +4 -4
- package/src/project-context/migration-manifest.mjs +168 -0
- package/src/project-context/project-status.mjs +157 -0
- package/src/project-context/projection-store.mjs +8 -1
- package/src/project-context/renderer.mjs +75 -1
- package/src/project-context/source-reader.mjs +63 -30
- package/src/project-context/task-context.mjs +14 -2
- package/src/project-context/truth-reconciliation-schema.mjs +488 -0
- package/src/project-context/truth-reconciliation.mjs +543 -0
- package/src/project-context/upgrade-schema.mjs +219 -0
- package/src/project-context/upgrade.mjs +494 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { canonicalJson, digestJson, sha256 } from "./canonical-json.mjs";
|
|
4
|
+
import { sourceStatus, validateContract, validateProjectionLock } from "./contract-schema.mjs";
|
|
5
|
+
import { fail } from "./errors.mjs";
|
|
6
|
+
import { atomicWriteFile, atomicWriteJson, readJsonFile } from "./io.mjs";
|
|
7
|
+
import { normalizeRelativePath, resolveWritableInside } from "./path-policy.mjs";
|
|
8
|
+
|
|
9
|
+
export const AI_ENTRY_RENDERER_VERSION = 3;
|
|
10
|
+
export const AI_ENTRY_REGION_ID = "project-context-ai-entry";
|
|
11
|
+
export const AI_ENTRY_START = "<!-- project-context:ai-entry:start -->";
|
|
12
|
+
export const AI_ENTRY_END = "<!-- project-context:ai-entry:end -->";
|
|
13
|
+
|
|
14
|
+
const ENTRY_LINES = [
|
|
15
|
+
AI_ENTRY_START,
|
|
16
|
+
"<!-- project-context:ai-entry; schema-version: 1; renderer-version: 3 -->",
|
|
17
|
+
"## Project Context 启动流程",
|
|
18
|
+
"",
|
|
19
|
+
"开始项目工作前,使用项目本地安装的 `frontend-project-context` CLI,不要临时下载其他版本。",
|
|
20
|
+
"",
|
|
21
|
+
"1. 运行 `npm exec --offline -- project-context status --project . --json`;如果本地依赖不存在,停止并报告,不要临时下载同名包。",
|
|
22
|
+
"2. 如果状态为 `partial` 或 `invalid`,停止写入并报告准确的恢复证据。",
|
|
23
|
+
"3. 如果尚未初始化,先预览 `setup`;如果已初始化但需要处理,使用 `sync` 工作单元;如果为 `clean`,继续遵守本文件其余仓库规则,并执行已获授权的用户任务。",
|
|
24
|
+
"4. 任何 plan、bundle、receipt、review 或 AI 建议都不代表人工批准。",
|
|
25
|
+
"5. 声明完成前,将 Project Context 恢复为 `clean`,否则报告准确的阻断项。",
|
|
26
|
+
AI_ENTRY_END,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function entryOutput(output) {
|
|
30
|
+
let normalized;
|
|
31
|
+
try {
|
|
32
|
+
normalized = normalizeRelativePath(output, { label: "AI Entry output" });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
fail("ai-entry-output-invalid", "AI Entry output must stay inside the project", {
|
|
35
|
+
exitCode: 2,
|
|
36
|
+
cause: error,
|
|
37
|
+
details: { path: output },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (path.posix.basename(normalized) !== "AGENTS.md") {
|
|
41
|
+
fail("ai-entry-output-invalid", "AI Entry output must be an AGENTS.md file", {
|
|
42
|
+
exitCode: 2,
|
|
43
|
+
details: { path: normalized },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveEntryOutput(root, output, write) {
|
|
50
|
+
const normalized = entryOutput(output);
|
|
51
|
+
try {
|
|
52
|
+
return await resolveWritableInside(root, normalized, { createParent: write });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
fail("ai-entry-output-invalid", "AI Entry output is not safely writable inside the project", {
|
|
55
|
+
exitCode: 2,
|
|
56
|
+
cause: error,
|
|
57
|
+
details: { path: normalized, reason: error.code ?? "invalid-output" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readIfPresent(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
return await readFile(filePath, "utf8");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === "ENOENT") return null;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function count(content, needle) {
|
|
72
|
+
return content.split(needle).length - 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderAiEntry(newline = "\n") {
|
|
76
|
+
return ENTRY_LINES.join(newline);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseAiEntryRegion(content) {
|
|
80
|
+
if (typeof content !== "string") return { state: "absent", region: null };
|
|
81
|
+
const starts = count(content, AI_ENTRY_START);
|
|
82
|
+
const ends = count(content, AI_ENTRY_END);
|
|
83
|
+
if (starts === 0 && ends === 0) return { state: "absent", region: null };
|
|
84
|
+
if (starts !== 1 || ends !== 1) return { state: "conflict", reason: "marker-count", region: null };
|
|
85
|
+
const start = content.indexOf(AI_ENTRY_START);
|
|
86
|
+
const endStart = content.indexOf(AI_ENTRY_END);
|
|
87
|
+
if (endStart <= start) return { state: "conflict", reason: "marker-order", region: null };
|
|
88
|
+
const end = endStart + AI_ENTRY_END.length;
|
|
89
|
+
const between = content.slice(start + AI_ENTRY_START.length, endStart);
|
|
90
|
+
if (between.includes(AI_ENTRY_START) || between.includes(AI_ENTRY_END)) {
|
|
91
|
+
return { state: "conflict", reason: "nested-marker", region: null };
|
|
92
|
+
}
|
|
93
|
+
return { state: "present", start, end, region: content.slice(start, end) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function newlineFor(content) {
|
|
97
|
+
return content?.includes("\r\n") ? "\r\n" : "\n";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function appendRegion(content, region, newline) {
|
|
101
|
+
if (content === null || content.length === 0) return region;
|
|
102
|
+
const trailingNewline = content.endsWith("\r\n") || content.endsWith("\n");
|
|
103
|
+
return `${content}${trailingNewline ? newline : `${newline}${newline}`}${region}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function schema2FileEntry(entry) {
|
|
107
|
+
return entry.ownership ? structuredClone(entry) : { ...structuredClone(entry), ownership: "file" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sourceImpact(project, output, afterDigest) {
|
|
111
|
+
const sources = project.contract.sources
|
|
112
|
+
.filter((source) => sourceStatus(source) === "active" && ["file", "path", "json-pointer"].includes(source.kind) && source.path === output)
|
|
113
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
114
|
+
const sourceIds = sources.map((source) => source.id);
|
|
115
|
+
const selected = new Set(sourceIds);
|
|
116
|
+
const itemIds = project.contract.items
|
|
117
|
+
.filter((item) => item.sources.some((id) => selected.has(id)) || selected.has(item.verification?.source))
|
|
118
|
+
.map((item) => item.id)
|
|
119
|
+
.sort((left, right) => left.localeCompare(right));
|
|
120
|
+
return { sourceIds, itemIds, afterDigest: sourceIds.length > 0 ? afterDigest : null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ownershipConflict(pathname, reason) {
|
|
124
|
+
fail("ai-entry-ownership-conflict", `AI Entry ownership is not trustworthy: ${pathname}`, {
|
|
125
|
+
exitCode: 3,
|
|
126
|
+
details: { path: pathname, reason },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function entryLock(project, output) {
|
|
131
|
+
return project.projectionsLock.projections.find((entry) => entry.path === output) ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertEntryOwnership(output, content, lockEntry, parsed) {
|
|
135
|
+
if (lockEntry && lockEntry.ownership !== "region") {
|
|
136
|
+
fail("ai-entry-path-conflict", `AI Entry conflicts with a whole-file projection: ${output}`, {
|
|
137
|
+
exitCode: 3,
|
|
138
|
+
details: { path: output },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (parsed.state === "conflict") ownershipConflict(output, parsed.reason);
|
|
142
|
+
if (parsed.state === "present" && !lockEntry) ownershipConflict(output, "marker-without-lock");
|
|
143
|
+
if (parsed.state === "absent" && lockEntry) ownershipConflict(output, "lock-without-marker");
|
|
144
|
+
if (lockEntry && sha256(parsed.region) !== lockEntry.regionDigest) ownershipConflict(output, "region-digest-mismatch");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function nextProjectionLock(project, output, nextEntry) {
|
|
148
|
+
return validateProjectionLock({
|
|
149
|
+
schemaVersion: 2,
|
|
150
|
+
projections: [
|
|
151
|
+
...project.projectionsLock.projections
|
|
152
|
+
.filter((entry) => entry.path !== output)
|
|
153
|
+
.map(schema2FileEntry),
|
|
154
|
+
...(nextEntry ? [nextEntry] : []),
|
|
155
|
+
].sort((left, right) => left.path.localeCompare(right.path)),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function commitEntry(project, resolved, existing, nextContent, nextLock, dependencies) {
|
|
160
|
+
const readJson = dependencies.readJson ?? readJsonFile;
|
|
161
|
+
const readContent = dependencies.readFile ?? readIfPresent;
|
|
162
|
+
const writeJson = dependencies.writeJson ?? atomicWriteJson;
|
|
163
|
+
const writeFile = dependencies.writeFile ?? atomicWriteFile;
|
|
164
|
+
if (dependencies.beforeCommit) await dependencies.beforeCommit();
|
|
165
|
+
const currentContract = validateContract(await readJson(project.files.contract, "contract.json"));
|
|
166
|
+
const currentLock = validateProjectionLock(await readJson(project.files.projectionsLock, "projections.lock.json"));
|
|
167
|
+
if (digestJson(currentContract) !== project.contractDigest) {
|
|
168
|
+
fail("ai-entry-state-changed", "contract changed while the AI Entry was being prepared", { exitCode: 1 });
|
|
169
|
+
}
|
|
170
|
+
if (digestJson(currentLock) !== project.projectionsLockDigest) {
|
|
171
|
+
fail("ai-entry-state-changed", "projection lock changed while the AI Entry was being prepared", { exitCode: 1 });
|
|
172
|
+
}
|
|
173
|
+
if (await readContent(resolved.absolute) !== existing) {
|
|
174
|
+
fail("ai-entry-state-changed", "AGENTS.md changed while the AI Entry was being prepared", {
|
|
175
|
+
exitCode: 1,
|
|
176
|
+
details: { path: resolved.normalized },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
await writeJson(project.files.projectionsLock, nextLock);
|
|
180
|
+
try {
|
|
181
|
+
await writeFile(resolved.absolute, nextContent);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
try {
|
|
184
|
+
const recoveryCurrent = validateProjectionLock(await readJson(project.files.projectionsLock, "projections.lock.json"));
|
|
185
|
+
if (canonicalJson(recoveryCurrent) === canonicalJson(nextLock)) {
|
|
186
|
+
await writeJson(project.files.projectionsLock, project.projectionsLock);
|
|
187
|
+
} else if (error && typeof error === "object") {
|
|
188
|
+
error.details = { ...(error.details ?? {}), recovery: "projection-lock-restore-skipped-concurrent-change" };
|
|
189
|
+
}
|
|
190
|
+
} catch (recoveryError) {
|
|
191
|
+
if (error && typeof error === "object") {
|
|
192
|
+
error.details = { ...(error.details ?? {}), recovery: "projection-lock-restore-failed", recoveryMessage: recoveryError.message };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function inspectAiEntry(root, project, output = "AGENTS.md") {
|
|
200
|
+
const resolved = await resolveEntryOutput(root, output, false);
|
|
201
|
+
const content = await readIfPresent(resolved.absolute);
|
|
202
|
+
const parsed = parseAiEntryRegion(content);
|
|
203
|
+
const lockEntry = entryLock(project, resolved.normalized);
|
|
204
|
+
if (lockEntry && lockEntry.ownership !== "region") {
|
|
205
|
+
return { state: "absent", path: null, rendererVersion: null };
|
|
206
|
+
}
|
|
207
|
+
if (content === null && lockEntry?.ownership === "region") {
|
|
208
|
+
return { state: "absent", path: resolved.normalized, rendererVersion: lockEntry.rendererVersion };
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
assertEntryOwnership(resolved.normalized, content, lockEntry, parsed);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error?.code === "ai-entry-ownership-conflict" || error?.code === "ai-entry-path-conflict") {
|
|
214
|
+
return { state: "conflict", path: resolved.normalized, rendererVersion: lockEntry?.rendererVersion ?? null, reason: error.details?.reason ?? error.code };
|
|
215
|
+
}
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
if (!lockEntry) return { state: "absent", path: null, rendererVersion: null };
|
|
219
|
+
return {
|
|
220
|
+
state: lockEntry.rendererVersion === AI_ENTRY_RENDERER_VERSION ? "current" : "stale",
|
|
221
|
+
path: resolved.normalized,
|
|
222
|
+
rendererVersion: lockEntry.rendererVersion,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function publishAiEntry(root, project, options, dependencies = {}) {
|
|
227
|
+
const write = Boolean(options.write);
|
|
228
|
+
const resolved = await resolveEntryOutput(root, options.output, write);
|
|
229
|
+
const readContent = dependencies.readFile ?? readIfPresent;
|
|
230
|
+
const existing = await readContent(resolved.absolute);
|
|
231
|
+
const parsed = parseAiEntryRegion(existing);
|
|
232
|
+
const currentEntry = entryLock(project, resolved.normalized);
|
|
233
|
+
assertEntryOwnership(resolved.normalized, existing, currentEntry, parsed);
|
|
234
|
+
const newline = newlineFor(existing);
|
|
235
|
+
const region = renderAiEntry(newline);
|
|
236
|
+
const nextContent = parsed.state === "present"
|
|
237
|
+
? `${existing.slice(0, parsed.start)}${region}${existing.slice(parsed.end)}`
|
|
238
|
+
: appendRegion(existing, region, newline);
|
|
239
|
+
const nextEntry = {
|
|
240
|
+
path: resolved.normalized,
|
|
241
|
+
target: "ai-entry",
|
|
242
|
+
ownership: "region",
|
|
243
|
+
regionId: AI_ENTRY_REGION_ID,
|
|
244
|
+
regionDigest: sha256(region),
|
|
245
|
+
rendererVersion: AI_ENTRY_RENDERER_VERSION,
|
|
246
|
+
createdFile: existing === null || currentEntry?.createdFile === true,
|
|
247
|
+
};
|
|
248
|
+
const nextLock = nextProjectionLock(project, resolved.normalized, nextEntry);
|
|
249
|
+
const action = currentEntry && existing === nextContent && canonicalJson(currentEntry) === canonicalJson(nextEntry) ? "unchanged" : currentEntry ? "update" : "create";
|
|
250
|
+
const impact = sourceImpact(project, resolved.normalized, sha256(nextContent));
|
|
251
|
+
const result = {
|
|
252
|
+
action,
|
|
253
|
+
current: {
|
|
254
|
+
entry: currentEntry ? structuredClone(currentEntry) : null,
|
|
255
|
+
contentDigest: existing === null ? null : sha256(existing),
|
|
256
|
+
region: parsed.region,
|
|
257
|
+
},
|
|
258
|
+
proposed: {
|
|
259
|
+
entry: nextEntry,
|
|
260
|
+
contentDigest: sha256(nextContent),
|
|
261
|
+
region,
|
|
262
|
+
},
|
|
263
|
+
baselines: {
|
|
264
|
+
contract: project.contractDigest,
|
|
265
|
+
projectionsLock: project.projectionsLockDigest,
|
|
266
|
+
content: existing === null ? null : sha256(existing),
|
|
267
|
+
},
|
|
268
|
+
migration: {
|
|
269
|
+
required: project.projectionsLock.schemaVersion === 1,
|
|
270
|
+
from: project.projectionsLock.schemaVersion,
|
|
271
|
+
to: 2,
|
|
272
|
+
trigger: "publish-entry",
|
|
273
|
+
},
|
|
274
|
+
impact: { itemIds: impact.itemIds, sourceIds: impact.sourceIds, paths: [resolved.normalized], projectionPaths: [resolved.normalized], afterSourceDigest: impact.afterDigest },
|
|
275
|
+
written: false,
|
|
276
|
+
};
|
|
277
|
+
if (write && action !== "unchanged") {
|
|
278
|
+
await commitEntry(project, resolved, existing, nextContent, nextLock, dependencies);
|
|
279
|
+
result.written = true;
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function removeAiEntry(root, project, options, dependencies = {}) {
|
|
285
|
+
const write = Boolean(options.write);
|
|
286
|
+
const resolved = await resolveEntryOutput(root, options.output, false);
|
|
287
|
+
const readContent = dependencies.readFile ?? readIfPresent;
|
|
288
|
+
const existing = await readContent(resolved.absolute);
|
|
289
|
+
const parsed = parseAiEntryRegion(existing);
|
|
290
|
+
const currentEntry = entryLock(project, resolved.normalized);
|
|
291
|
+
assertEntryOwnership(resolved.normalized, existing, currentEntry, parsed);
|
|
292
|
+
if (!currentEntry) {
|
|
293
|
+
return {
|
|
294
|
+
action: "unchanged",
|
|
295
|
+
current: { entry: null, contentDigest: existing === null ? null : sha256(existing), region: null },
|
|
296
|
+
proposed: { entry: null, contentDigest: existing === null ? null : sha256(existing), region: null },
|
|
297
|
+
baselines: { contract: project.contractDigest, projectionsLock: project.projectionsLockDigest, content: existing === null ? null : sha256(existing) },
|
|
298
|
+
migration: { required: false, from: project.projectionsLock.schemaVersion, to: project.projectionsLock.schemaVersion, trigger: null },
|
|
299
|
+
impact: { itemIds: [], sourceIds: [], paths: [resolved.normalized], projectionPaths: [resolved.normalized], afterSourceDigest: null },
|
|
300
|
+
written: false,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const nextContent = `${existing.slice(0, parsed.start)}${existing.slice(parsed.end)}`;
|
|
304
|
+
const nextLock = nextProjectionLock(project, resolved.normalized, null);
|
|
305
|
+
const impact = sourceImpact(project, resolved.normalized, sha256(nextContent));
|
|
306
|
+
const result = {
|
|
307
|
+
action: "remove",
|
|
308
|
+
current: { entry: structuredClone(currentEntry), contentDigest: sha256(existing), region: parsed.region },
|
|
309
|
+
proposed: { entry: null, contentDigest: sha256(nextContent), region: null },
|
|
310
|
+
baselines: { contract: project.contractDigest, projectionsLock: project.projectionsLockDigest, content: sha256(existing) },
|
|
311
|
+
migration: { required: false, from: project.projectionsLock.schemaVersion, to: 2, trigger: null },
|
|
312
|
+
impact: { itemIds: impact.itemIds, sourceIds: impact.sourceIds, paths: [resolved.normalized], projectionPaths: [resolved.normalized], afterSourceDigest: impact.afterDigest },
|
|
313
|
+
written: false,
|
|
314
|
+
};
|
|
315
|
+
if (write) {
|
|
316
|
+
await commitEntry(project, resolved, existing, nextContent, nextLock, dependencies);
|
|
317
|
+
result.written = true;
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
@@ -5,6 +5,7 @@ import { fail } from "./errors.mjs";
|
|
|
5
5
|
import { reviewSource } from "./maintenance.mjs";
|
|
6
6
|
import { normalizeRelativePath } from "./path-policy.mjs";
|
|
7
7
|
import { effectiveItems, scopeApplies } from "./scope-compiler.mjs";
|
|
8
|
+
import { createSourceReadContext } from "./source-reader.mjs";
|
|
8
9
|
|
|
9
10
|
const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
|
|
10
11
|
const LARGE_IMPACT_THRESHOLD = 100;
|
|
@@ -347,11 +348,12 @@ function syncWorkUnits(problemReviews, signals, findings, pendingItems) {
|
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
export async function buildSyncAssistBundle(root, project, changedPaths = []) {
|
|
351
|
+
const sourceReadContext = createSourceReadContext();
|
|
350
352
|
const reviewResults = await Promise.all(project.contract.sources.map(async (source) => {
|
|
351
353
|
if (sourceStatus(source) === "deprecated" || !LOCAL_SOURCE_KINDS.has(source.kind)) {
|
|
352
354
|
return { source, nonLocal: nonLocalSourceChange(source) };
|
|
353
355
|
}
|
|
354
|
-
return { source, review: await reviewSource(root, project, source.id) };
|
|
356
|
+
return { source, review: await reviewSource(root, project, source.id, { sourceReadContext }) };
|
|
355
357
|
}));
|
|
356
358
|
const reviews = reviewResults.filter((entry) => entry.review).map((entry) => entry.review);
|
|
357
359
|
const problemReviews = reviews.filter((review) => ["changed", "missing", "unreadable"].includes(review.status));
|
|
@@ -368,7 +370,7 @@ export async function buildSyncAssistBundle(root, project, changedPaths = []) {
|
|
|
368
370
|
.map((id) => affectedItem(itemById.get(id), sourceImpacts.get(id), largeImpactIds.has(id)))
|
|
369
371
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
370
372
|
const signals = pathSignals(project.contract, changedPaths);
|
|
371
|
-
const findings = stableFindings(await checkProject(root, project));
|
|
373
|
+
const findings = stableFindings(await checkProject(root, project, { sourceReadContext }));
|
|
372
374
|
const pending = project.contract.items
|
|
373
375
|
.filter((item) => item.status === "proposed")
|
|
374
376
|
.map(pendingItem)
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
} from "./exchange-schema.mjs";
|
|
12
12
|
import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
|
|
13
13
|
import { RENDERER_VERSION } from "./renderer.mjs";
|
|
14
|
+
import { AI_ENTRY_RENDERER_VERSION } from "./ai-entry.mjs";
|
|
15
|
+
import { PROJECT_STATUS_SCHEMA_VERSION } from "./project-status.mjs";
|
|
16
|
+
import { EVIDENCE_BUNDLE_SCHEMA_VERSION, EVIDENCE_INPUT_SCHEMA_VERSION } from "./evidence-schema.mjs";
|
|
14
17
|
import {
|
|
15
18
|
CONTEXT_BUDGET_UNIT,
|
|
16
19
|
INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
@@ -18,57 +21,99 @@ import {
|
|
|
18
21
|
STAGE_RECEIPT_SCHEMA_VERSION,
|
|
19
22
|
TASK_CONTEXT_PLAN_SCHEMA_VERSION,
|
|
20
23
|
} from "./task-context-schema.mjs";
|
|
24
|
+
import {
|
|
25
|
+
MIGRATION_PLAN_SCHEMA_VERSION,
|
|
26
|
+
UPGRADE_ASSESSMENT_SCHEMA_VERSION,
|
|
27
|
+
UPGRADE_RESULT_BUNDLE_SCHEMA_VERSION,
|
|
28
|
+
} from "./upgrade-schema.mjs";
|
|
29
|
+
import {
|
|
30
|
+
ADAPTIVE_CONTEXT_BUNDLE_SCHEMA_VERSION,
|
|
31
|
+
CONTEXT_QUERY_SCHEMA_VERSION,
|
|
32
|
+
COVERAGE_AUDIT_SCHEMA_VERSION,
|
|
33
|
+
ROUTING_INDEX_SCHEMA_VERSION,
|
|
34
|
+
} from "./adaptive-context-schema.mjs";
|
|
35
|
+
import {
|
|
36
|
+
HOST_PROMOTION_EVIDENCE_SCHEMA_VERSION,
|
|
37
|
+
TRUTH_RECONCILIATION_INPUT_SCHEMA_VERSION,
|
|
38
|
+
TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
39
|
+
} from "./truth-reconciliation-schema.mjs";
|
|
21
40
|
|
|
22
|
-
function schemas() {
|
|
41
|
+
function schemas(projectionLockWritten = 1) {
|
|
23
42
|
return {
|
|
24
43
|
actionPlan: ACTION_PLAN_SCHEMA_VERSION,
|
|
44
|
+
adaptiveContextBundle: ADAPTIVE_CONTEXT_BUNDLE_SCHEMA_VERSION,
|
|
25
45
|
assistBundle: ASSIST_BUNDLE_SCHEMA_VERSION,
|
|
26
46
|
capabilities: CAPABILITIES_SCHEMA_VERSION,
|
|
27
47
|
contract: 2,
|
|
48
|
+
contextQuery: CONTEXT_QUERY_SCHEMA_VERSION,
|
|
49
|
+
coverageAudit: COVERAGE_AUDIT_SCHEMA_VERSION,
|
|
28
50
|
dashboardViewModel: DASHBOARD_SCHEMA_VERSION,
|
|
51
|
+
evidenceBundle: EVIDENCE_BUNDLE_SCHEMA_VERSION,
|
|
52
|
+
evidenceInput: EVIDENCE_INPUT_SCHEMA_VERSION,
|
|
29
53
|
integrationReviewBundle: INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
30
|
-
|
|
54
|
+
hostPromotionEvidence: HOST_PROMOTION_EVIDENCE_SCHEMA_VERSION,
|
|
55
|
+
projectionLock: 2,
|
|
56
|
+
projectionLockReadable: [1, 2],
|
|
57
|
+
projectionLockWritten,
|
|
31
58
|
projectionRenderer: RENDERER_VERSION,
|
|
32
59
|
proposal: 1,
|
|
60
|
+
projectStatus: PROJECT_STATUS_SCHEMA_VERSION,
|
|
61
|
+
aiEntryRenderer: AI_ENTRY_RENDERER_VERSION,
|
|
62
|
+
migrationManifest: 2,
|
|
63
|
+
migrationPlan: MIGRATION_PLAN_SCHEMA_VERSION,
|
|
33
64
|
reviewBundle: REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
65
|
+
routingIndex: ROUTING_INDEX_SCHEMA_VERSION,
|
|
34
66
|
sourceLock: 1,
|
|
35
67
|
stageContextBundle: STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
|
|
36
68
|
stageReceipt: STAGE_RECEIPT_SCHEMA_VERSION,
|
|
37
69
|
taskContextPlan: TASK_CONTEXT_PLAN_SCHEMA_VERSION,
|
|
70
|
+
truthReconciliationInput: TRUTH_RECONCILIATION_INPUT_SCHEMA_VERSION,
|
|
71
|
+
truthReconciliationReviewBundle: TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
72
|
+
upgradeAssessment: UPGRADE_ASSESSMENT_SCHEMA_VERSION,
|
|
73
|
+
upgradeResultBundle: UPGRADE_RESULT_BUNDLE_SCHEMA_VERSION,
|
|
38
74
|
};
|
|
39
75
|
}
|
|
40
76
|
|
|
77
|
+
export const PERMANENT_BOUNDARIES = Object.freeze({
|
|
78
|
+
provider: false,
|
|
79
|
+
agentRuntime: false,
|
|
80
|
+
git: false,
|
|
81
|
+
network: false,
|
|
82
|
+
dependencyInstallation: false,
|
|
83
|
+
automaticApproval: false,
|
|
84
|
+
businessCodeWrites: false,
|
|
85
|
+
taskExecution: false,
|
|
86
|
+
stagePathBodyReads: false,
|
|
87
|
+
applyPlan: false,
|
|
88
|
+
scheduler: false,
|
|
89
|
+
daemon: false,
|
|
90
|
+
telemetry: false,
|
|
91
|
+
selfUpdate: false,
|
|
92
|
+
automaticEvidenceUpload: false,
|
|
93
|
+
packageManager: false,
|
|
94
|
+
automaticUpgrade: false,
|
|
95
|
+
});
|
|
96
|
+
|
|
41
97
|
export async function buildCapabilities(root) {
|
|
42
98
|
const initialization = await inspectProjectInitialization(root);
|
|
43
99
|
let project = null;
|
|
100
|
+
let projectionLockWritten = 1;
|
|
44
101
|
if (initialization.status === "initialized") {
|
|
45
102
|
const loaded = await loadProject(root);
|
|
46
103
|
project = { id: loaded.contract.project.id, name: loaded.contract.project.name };
|
|
104
|
+
projectionLockWritten = loaded.projectionsLock.schemaVersion;
|
|
47
105
|
}
|
|
48
106
|
return {
|
|
49
107
|
schemaVersion: CAPABILITIES_SCHEMA_VERSION,
|
|
50
108
|
package: { name: "frontend-project-context", version: PACKAGE_VERSION },
|
|
51
109
|
exchangeProtocolVersion: EXCHANGE_PROTOCOL_VERSION,
|
|
52
|
-
schemas: schemas(),
|
|
110
|
+
schemas: schemas(projectionLockWritten),
|
|
53
111
|
commands: [...COMMANDS],
|
|
54
112
|
actionKinds: [...ACTION_KINDS],
|
|
55
113
|
contextBudget: { unit: CONTEXT_BUDGET_UNIT, modelTokens: false, callerMustProvideLimit: true },
|
|
56
114
|
initialization: initialization.status,
|
|
57
115
|
initialized: initialization.status === "initialized",
|
|
58
116
|
project,
|
|
59
|
-
boundaries: {
|
|
60
|
-
provider: false,
|
|
61
|
-
agentRuntime: false,
|
|
62
|
-
git: false,
|
|
63
|
-
network: false,
|
|
64
|
-
dependencyInstallation: false,
|
|
65
|
-
automaticApproval: false,
|
|
66
|
-
businessCodeWrites: false,
|
|
67
|
-
taskExecution: false,
|
|
68
|
-
stagePathBodyReads: false,
|
|
69
|
-
applyPlan: false,
|
|
70
|
-
scheduler: false,
|
|
71
|
-
daemon: false,
|
|
72
|
-
},
|
|
117
|
+
boundaries: { ...PERMANENT_BOUNDARIES },
|
|
73
118
|
};
|
|
74
119
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { AI_ENTRY_RENDERER_VERSION, parseAiEntryRegion } from "./ai-entry.mjs";
|
|
3
4
|
import { sha256 } from "./canonical-json.mjs";
|
|
4
5
|
import { sourceStatus } from "./contract-schema.mjs";
|
|
5
6
|
import { readSourceDigest, verifyItem } from "./source-reader.mjs";
|
|
@@ -7,7 +8,8 @@ import { findConflicts, validateOverrides } from "./scope-compiler.mjs";
|
|
|
7
8
|
import { parseProjectionMarker, RENDERER_VERSION, renderProjection } from "./renderer.mjs";
|
|
8
9
|
import { resolveWritableInside } from "./path-policy.mjs";
|
|
9
10
|
|
|
10
|
-
export async function checkProject(root, project) {
|
|
11
|
+
export async function checkProject(root, project, options = {}) {
|
|
12
|
+
const sourceReadContext = options.sourceReadContext;
|
|
11
13
|
const findings = [];
|
|
12
14
|
const contractSourceIds = new Set(project.contract.sources.map((source) => source.id));
|
|
13
15
|
const sourceLock = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
|
|
@@ -24,7 +26,7 @@ export async function checkProject(root, project) {
|
|
|
24
26
|
continue;
|
|
25
27
|
}
|
|
26
28
|
try {
|
|
27
|
-
const actual = await readSourceDigest(root, source);
|
|
29
|
+
const actual = await readSourceDigest(root, source, sourceReadContext);
|
|
28
30
|
if (actual !== locked) findings.push({ code: "source-changed", source: source.id, path: source.path, expected: locked, actual });
|
|
29
31
|
} catch (error) {
|
|
30
32
|
const code = error.code === "source-missing"
|
|
@@ -50,7 +52,7 @@ export async function checkProject(root, project) {
|
|
|
50
52
|
const sourceMap = new Map(project.contract.sources.map((source) => [source.id, source]));
|
|
51
53
|
for (const item of project.contract.items.filter((candidate) => candidate.status === "approved")) {
|
|
52
54
|
try {
|
|
53
|
-
const verification = await verifyItem(root, item, sourceMap);
|
|
55
|
+
const verification = await verifyItem(root, item, sourceMap, sourceReadContext);
|
|
54
56
|
if (verification) findings.push(verification);
|
|
55
57
|
} catch (error) {
|
|
56
58
|
findings.push({
|
|
@@ -74,7 +76,23 @@ export async function checkProject(root, project) {
|
|
|
74
76
|
try {
|
|
75
77
|
content = await readFile(resolved.absolute, "utf8");
|
|
76
78
|
} catch (error) {
|
|
77
|
-
findings.push({
|
|
79
|
+
findings.push({
|
|
80
|
+
code: entry.ownership === "region"
|
|
81
|
+
? error?.code === "ENOENT" ? "ai-entry-missing" : "projection-unreadable"
|
|
82
|
+
: error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable",
|
|
83
|
+
path: entry.path,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (entry.ownership === "region") {
|
|
88
|
+
const parsed = parseAiEntryRegion(content);
|
|
89
|
+
if (parsed.state !== "present" || sha256(parsed.region) !== entry.regionDigest) {
|
|
90
|
+
findings.push({ code: "ai-entry-ownership-conflict", path: entry.path });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (entry.rendererVersion !== AI_ENTRY_RENDERER_VERSION) {
|
|
94
|
+
findings.push({ code: "ai-entry-renderer-stale", path: entry.path, expected: AI_ENTRY_RENDERER_VERSION, actual: entry.rendererVersion });
|
|
95
|
+
}
|
|
78
96
|
continue;
|
|
79
97
|
}
|
|
80
98
|
const marker = parseProjectionMarker(content);
|
|
@@ -113,7 +131,7 @@ export async function checkProject(root, project) {
|
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
export function checkExitCode(findings) {
|
|
116
|
-
return findings.some((finding) =>
|
|
134
|
+
return findings.some((finding) => ["projection-ownership-conflict", "ai-entry-ownership-conflict", "ai-entry-path-conflict"].includes(finding.code)) ? 3 : findings.length > 0 ? 1 : 0;
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
export function blockingContextFindings(findings) {
|
|
@@ -127,6 +145,6 @@ export function blockingContextFindings(findings) {
|
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
export function findingSeverity(finding) {
|
|
130
|
-
if (
|
|
148
|
+
if (["projection-ownership-conflict", "ai-entry-ownership-conflict", "ai-entry-path-conflict"].includes(finding.code)) return "conflict";
|
|
131
149
|
return blockingContextFindings([finding]).length > 0 ? "blocked" : "attention";
|
|
132
150
|
}
|