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,107 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { canonicalJson, digestJson, sha256 } from "./canonical-json.mjs";
4
+ import { validateContract, validateProjectionLock } from "./contract-schema.mjs";
5
+ import { fail } from "./errors.mjs";
6
+ import { atomicWriteFile, atomicWriteJson, readJsonFile } from "./io.mjs";
7
+ import { assertProjectionPath, resolveWritableInside } from "./path-policy.mjs";
8
+ import { renderProjection, parseProjectionMarker } from "./renderer.mjs";
9
+
10
+ async function readIfPresent(filePath) {
11
+ try {
12
+ return await readFile(filePath, "utf8");
13
+ } catch (error) {
14
+ if (error?.code === "ENOENT") return null;
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ export async function publishProjection(root, project, options, dependencies = {}) {
20
+ const writeFile = dependencies.writeFile ?? atomicWriteFile;
21
+ const writeJson = dependencies.writeJson ?? atomicWriteJson;
22
+ const readJson = dependencies.readJson ?? readJsonFile;
23
+ const readContent = dependencies.readFile ?? readIfPresent;
24
+ const { target, output, paths, write } = options;
25
+ const normalized = output.replaceAll("\\", "/").replace(/^\.\//u, "");
26
+ assertProjectionPath(target, normalized);
27
+ const resolved = await resolveWritableInside(root, output, { createParent: write });
28
+ const rendered = renderProjection(project.contract, paths, target);
29
+ const existing = await readContent(resolved.absolute);
30
+ const lockEntry = project.projectionsLock.projections.find((entry) => entry.path === resolved.normalized);
31
+ if (existing !== null) {
32
+ const marker = parseProjectionMarker(existing);
33
+ if (
34
+ !marker ||
35
+ !lockEntry ||
36
+ sha256(existing) !== lockEntry.contentDigest ||
37
+ marker.target !== lockEntry.target ||
38
+ marker.rendererVersion !== lockEntry.rendererVersion
39
+ ) {
40
+ fail("managed-file-ownership-conflict", `refusing to overwrite unmanaged or modified file: ${resolved.normalized}; use a new scoped AGENTS.md or Ruler projection instead`, {
41
+ exitCode: 3,
42
+ details: { path: resolved.normalized },
43
+ });
44
+ }
45
+ }
46
+ const nextEntry = {
47
+ path: resolved.normalized,
48
+ target,
49
+ paths: rendered.paths,
50
+ contractDigest: rendered.contractDigest,
51
+ bundleDigest: rendered.bundleDigest,
52
+ contentDigest: sha256(rendered.content),
53
+ itemIds: rendered.itemIds,
54
+ rendererVersion: rendered.rendererVersion,
55
+ };
56
+ const nextLock = {
57
+ schemaVersion: 1,
58
+ projections: [
59
+ ...project.projectionsLock.projections.filter((entry) => entry.path !== resolved.normalized),
60
+ nextEntry,
61
+ ].sort((left, right) => left.path.localeCompare(right.path)),
62
+ };
63
+ const action = existing === null ? "create" : existing === rendered.content ? "unchanged" : "update";
64
+ if (write && action !== "unchanged") {
65
+ if (dependencies.beforeCommit) await dependencies.beforeCommit();
66
+ const currentContract = validateContract(await readJson(project.files.contract, "contract.json"));
67
+ const currentProjectionLock = validateProjectionLock(await readJson(project.files.projectionsLock, "projections.lock.json"));
68
+ if (digestJson(currentContract) !== project.contractDigest) {
69
+ fail("project-state-changed", "contract changed while the projection was being prepared", { exitCode: 1 });
70
+ }
71
+ if (digestJson(currentProjectionLock) !== project.projectionsLockDigest) {
72
+ fail("projection-state-changed", "projection lock changed while the projection was being prepared", { exitCode: 1 });
73
+ }
74
+ const currentContent = await readContent(resolved.absolute);
75
+ if (currentContent !== existing) {
76
+ fail("projection-state-changed", "projection file changed while the update was being prepared", {
77
+ exitCode: 1,
78
+ details: { path: resolved.normalized },
79
+ });
80
+ }
81
+ await writeJson(project.files.projectionsLock, nextLock);
82
+ try {
83
+ await writeFile(resolved.absolute, rendered.content);
84
+ } catch (error) {
85
+ try {
86
+ const recoveryCurrent = validateProjectionLock(await readJson(project.files.projectionsLock, "projections.lock.json"));
87
+ if (canonicalJson(recoveryCurrent) !== canonicalJson(nextLock)) {
88
+ if (error && typeof error === "object") {
89
+ error.details = { ...(error.details ?? {}), recovery: "projection-lock-restore-skipped-concurrent-change" };
90
+ }
91
+ } else {
92
+ await writeJson(project.files.projectionsLock, project.projectionsLock);
93
+ }
94
+ } catch (recoveryError) {
95
+ if (error && typeof error === "object") {
96
+ error.details = {
97
+ ...(error.details ?? {}),
98
+ recovery: "projection-lock-restore-failed",
99
+ recoveryMessage: recoveryError.message,
100
+ };
101
+ }
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+ return { action, content: rendered.content, entry: nextEntry, written: Boolean(write) };
107
+ }
@@ -0,0 +1,135 @@
1
+ import { canonicalJson, digestJson, sha256 } from "./canonical-json.mjs";
2
+ import { describeScope, effectiveItems } from "./scope-compiler.mjs";
3
+ import { normalizeRelativePath } from "./path-policy.mjs";
4
+
5
+ const KIND_TITLES = new Map([
6
+ ["fact", "Facts"],
7
+ ["policy", "Policies"],
8
+ ["reference", "References"],
9
+ ["validation-description", "Validation descriptions"],
10
+ ]);
11
+ export const RENDERER_VERSION = 3;
12
+
13
+ function fenceFor(value) {
14
+ const runs = [...String(value).matchAll(/`+/gu)].map((match) => match[0].length);
15
+ return "`".repeat(Math.max(3, (runs.length ? Math.max(...runs) : 0) + 1));
16
+ }
17
+
18
+ export function collectBundle(contract, paths) {
19
+ const normalizedPaths = paths.map((value) => normalizeRelativePath(value, { allowRoot: true, label: "target path" }));
20
+ const sections = normalizedPaths.map((targetPath) => ({
21
+ path: targetPath,
22
+ items: effectiveItems(contract.items, targetPath),
23
+ }));
24
+ const itemIds = [...new Set(sections.flatMap((section) => section.items.map((item) => item.id)))].sort();
25
+ return {
26
+ project: contract.project,
27
+ contractDigest: digestJson(contract),
28
+ paths: normalizedPaths,
29
+ sections,
30
+ itemIds,
31
+ };
32
+ }
33
+
34
+ function localizedStatement(statement, locale) {
35
+ const match = /^\[zh-CN\] ([^\n]+)\n\[en\] ([\s\S]+)$/u.exec(statement);
36
+ if (!match || locale === "all") return statement;
37
+ return locale === "en" ? match[2] : match[1];
38
+ }
39
+
40
+ function renderItem(item, locale) {
41
+ const sources = item.sources.map((source) => `\`${source}\``).join(", ");
42
+ const value = canonicalJson(item.value);
43
+ const fence = fenceFor(value);
44
+ const subject = item.subject === item.id ? "" : `Subject: \`${item.subject}\`; `;
45
+ const overrides = item.overrides.length > 0
46
+ ? `\n - Overrides: ${item.overrides.map((id) => `\`${id}\``).join(", ")}`
47
+ : "";
48
+ return `- **${item.id}** — ${localizedStatement(item.statement, locale)}\n - ${subject}Scope: \`${describeScope(item.scope)}\`; sources: ${sources}${overrides}\n - Value (canonical JSON):\n\n ${fence}json\n ${value}\n ${fence}`;
49
+ }
50
+
51
+ function groupedSections(sections) {
52
+ const groups = [];
53
+ const byItems = new Map();
54
+ for (const section of sections) {
55
+ const key = section.items.map((item) => item.id).join("\u0000");
56
+ let group = byItems.get(key);
57
+ if (!group) {
58
+ group = { paths: [], items: section.items };
59
+ byItems.set(key, group);
60
+ groups.push(group);
61
+ }
62
+ group.paths.push(section.path);
63
+ }
64
+ return groups;
65
+ }
66
+
67
+ function renderCollectedBundle(bundle, sources, task, options = {}) {
68
+ const locale = options.locale ?? "zh-CN";
69
+ const lines = [
70
+ "# Project Context Bundle",
71
+ "",
72
+ `Project: **${bundle.project.name}** (\`${bundle.project.id}\`)`,
73
+ `Contract digest: \`${bundle.contractDigest}\``,
74
+ ];
75
+ if (task !== undefined) {
76
+ const fence = fenceFor(task);
77
+ lines.push("", "## Task constraint", "", fence, String(task), fence);
78
+ }
79
+ for (const section of groupedSections(bundle.sections)) {
80
+ const targetLabel = section.paths.length === 1
81
+ ? `## Target: \`${section.paths[0]}\``
82
+ : `## Targets: ${section.paths.map((targetPath) => `\`${targetPath}\``).join(", ")}`;
83
+ lines.push("", targetLabel);
84
+ for (const [kind, title] of KIND_TITLES) {
85
+ const items = section.items.filter((item) => item.kind === kind);
86
+ if (items.length === 0) continue;
87
+ lines.push("", `### ${title}`, "", ...items.map((item) => renderItem(item, locale)));
88
+ }
89
+ if (section.items.length === 0) lines.push("", "No approved contract items apply.");
90
+ }
91
+ const sourceIds = [...new Set(bundle.sections.flatMap((section) => section.items.flatMap((item) => item.sources)))].sort();
92
+ lines.push("", "## Source index", "");
93
+ if (sourceIds.length === 0) {
94
+ lines.push("No sources selected.");
95
+ } else {
96
+ const sourceMap = new Map(sources.map((source) => [source.id, source]));
97
+ for (const id of sourceIds) {
98
+ const source = sourceMap.get(id);
99
+ const location = source?.path ?? source?.reference ?? "unknown";
100
+ lines.push(`- \`${id}\` — ${source?.kind ?? "unknown"}: \`${location}\``);
101
+ }
102
+ }
103
+ return `${lines.join("\n")}\n`;
104
+ }
105
+
106
+ export function renderContextBundle(contract, paths, task, options = {}) {
107
+ return renderCollectedBundle(collectBundle(contract, paths), contract.sources, task, options);
108
+ }
109
+
110
+ export function renderProjection(contract, paths, target) {
111
+ const bundle = collectBundle(contract, paths);
112
+ const body = renderCollectedBundle(bundle, contract.sources);
113
+ const bundleDigest = sha256(body);
114
+ const marker = `<!-- managed-by: project-context; renderer-version: ${RENDERER_VERSION}; target: ${target}; contract-digest: ${bundle.contractDigest}; bundle-digest: ${bundleDigest} -->`;
115
+ return {
116
+ content: `${marker}\n${body}`,
117
+ contractDigest: bundle.contractDigest,
118
+ bundleDigest,
119
+ itemIds: bundle.itemIds,
120
+ paths: bundle.paths,
121
+ rendererVersion: RENDERER_VERSION,
122
+ };
123
+ }
124
+
125
+ export function parseProjectionMarker(content) {
126
+ const first = String(content).split(/\r?\n/u, 1)[0];
127
+ const match = first.match(/^<!-- managed-by: project-context; renderer-version: (\d+); target: (agents|ruler); contract-digest: (sha256:[a-f0-9]{64}); bundle-digest: (sha256:[a-f0-9]{64}) -->$/u);
128
+ if (!match) return null;
129
+ return {
130
+ rendererVersion: Number(match[1]),
131
+ target: match[2],
132
+ contractDigest: match[3],
133
+ bundleDigest: match[4],
134
+ };
135
+ }
@@ -0,0 +1,132 @@
1
+ import { canonicalJson } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { normalizeRelativePath } from "./path-policy.mjs";
4
+
5
+ function scopePath(scope) {
6
+ return scope.kind === "project" ? "." : scope.path;
7
+ }
8
+
9
+ function withinPrefix(target, prefix) {
10
+ return target === prefix || target.startsWith(`${prefix}/`);
11
+ }
12
+
13
+ export function scopeApplies(scope, targetPath) {
14
+ const target = normalizeRelativePath(targetPath, { allowRoot: true, label: "target path" });
15
+ if (scope.kind === "project") return true;
16
+ if (scope.kind === "file") return target === scope.path;
17
+ return withinPrefix(target, scope.path);
18
+ }
19
+
20
+ export function scopeContains(outer, inner) {
21
+ if (outer.kind === "project") return true;
22
+ if (outer.kind === "file") return inner.kind === "file" && outer.path === inner.path;
23
+ if (inner.kind === "project") return false;
24
+ return withinPrefix(inner.path, outer.path);
25
+ }
26
+
27
+ export function scopesOverlap(left, right) {
28
+ return scopeContains(left, right) || scopeContains(right, left);
29
+ }
30
+
31
+ export function scopeSpecificity(scope) {
32
+ if (scope.kind === "project") return 0;
33
+ const segments = scope.path.split("/").length;
34
+ return segments * 2 + (scope.kind === "file" ? 1 : 0);
35
+ }
36
+
37
+ function overrideClosure(items) {
38
+ const byId = new Map(items.map((item) => [item.id, item]));
39
+ const memo = new Map();
40
+ const visiting = new Set();
41
+ function visit(id) {
42
+ if (memo.has(id)) return memo.get(id);
43
+ if (visiting.has(id)) fail("scope-override-cycle", `override cycle includes ${id}`);
44
+ visiting.add(id);
45
+ const item = byId.get(id);
46
+ const values = new Set(item?.overrides ?? []);
47
+ for (const direct of item?.overrides ?? []) {
48
+ for (const inherited of visit(direct)) values.add(inherited);
49
+ }
50
+ visiting.delete(id);
51
+ memo.set(id, values);
52
+ return values;
53
+ }
54
+ for (const item of items) visit(item.id);
55
+ return memo;
56
+ }
57
+
58
+ export function validateOverrides(items) {
59
+ const approved = items.filter((item) => item.status === "approved");
60
+ const byId = new Map(approved.map((item) => [item.id, item]));
61
+ const findings = [];
62
+ for (const item of approved) {
63
+ for (const targetId of item.overrides) {
64
+ const target = byId.get(targetId);
65
+ if (!target) {
66
+ findings.push({ code: "scope-override-invalid", item: item.id, target: targetId, reason: "missing-or-inactive" });
67
+ } else if (item.subject !== target.subject) {
68
+ findings.push({ code: "scope-override-invalid", item: item.id, target: targetId, reason: "subject-mismatch" });
69
+ } else if (!scopeContains(target.scope, item.scope)) {
70
+ findings.push({ code: "scope-override-invalid", item: item.id, target: targetId, reason: "target-is-narrower" });
71
+ }
72
+ }
73
+ }
74
+ try {
75
+ overrideClosure(approved);
76
+ } catch (error) {
77
+ findings.push({ code: error.code ?? "scope-override-cycle", message: error.message });
78
+ }
79
+ return findings;
80
+ }
81
+
82
+ export function findConflicts(items) {
83
+ const approved = items.filter((item) => item.status === "approved");
84
+ let closures;
85
+ try {
86
+ closures = overrideClosure(approved);
87
+ } catch (error) {
88
+ return [{ code: error.code ?? "scope-override-cycle", message: error.message }];
89
+ }
90
+ const findings = [];
91
+ for (let leftIndex = 0; leftIndex < approved.length; leftIndex += 1) {
92
+ for (let rightIndex = leftIndex + 1; rightIndex < approved.length; rightIndex += 1) {
93
+ const left = approved[leftIndex];
94
+ const right = approved[rightIndex];
95
+ if (left.subject !== right.subject || !scopesOverlap(left.scope, right.scope)) continue;
96
+ if (canonicalJson(left.value) === canonicalJson(right.value)) continue;
97
+ if (closures.get(left.id)?.has(right.id) || closures.get(right.id)?.has(left.id)) continue;
98
+ findings.push({ code: "contract-conflict", subject: left.subject, items: [left.id, right.id] });
99
+ }
100
+ }
101
+ return findings;
102
+ }
103
+
104
+ export function effectiveItems(items, targetPath) {
105
+ const approved = items.filter((item) => item.status === "approved");
106
+ const applicable = approved.filter((item) => scopeApplies(item.scope, targetPath));
107
+ const closures = overrideClosure(approved);
108
+ const overridden = new Set();
109
+ for (const item of applicable) {
110
+ for (const target of closures.get(item.id) ?? []) overridden.add(target);
111
+ }
112
+ const effective = applicable.filter((item) => !overridden.has(item.id));
113
+ const conflicts = findConflicts(effective);
114
+ if (conflicts.length > 0) {
115
+ fail("contract-conflict", `contract has unresolved conflicts for ${targetPath}`, {
116
+ exitCode: 1,
117
+ details: { path: targetPath, findings: conflicts },
118
+ });
119
+ }
120
+ return effective.sort((left, right) => {
121
+ return (
122
+ left.kind.localeCompare(right.kind) ||
123
+ scopeSpecificity(left.scope) - scopeSpecificity(right.scope) ||
124
+ left.subject.localeCompare(right.subject) ||
125
+ left.id.localeCompare(right.id)
126
+ );
127
+ });
128
+ }
129
+
130
+ export function describeScope(scope) {
131
+ return scope.kind === "project" ? "project" : `${scope.kind}:${scopePath(scope)}`;
132
+ }
@@ -0,0 +1,124 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { canonicalJson, sha256 } from "./canonical-json.mjs";
4
+ import { sourceStatus } from "./contract-schema.mjs";
5
+ import { fail } from "./errors.mjs";
6
+ import { resolveExistingInside } from "./path-policy.mjs";
7
+
8
+ const DEFAULT_IGNORES = new Set([".git", ".project-context", "node_modules", "dist", "build", "coverage"]);
9
+
10
+ export function decodeJsonPointer(pointer) {
11
+ if (pointer === "") return [];
12
+ if (typeof pointer !== "string" || !pointer.startsWith("/")) {
13
+ fail("json-pointer-invalid", "JSON pointer must be empty or start with '/'");
14
+ }
15
+ return pointer.slice(1).split("/").map((part) => {
16
+ if (/~(?:[^01]|$)/u.test(part)) fail("json-pointer-invalid", `JSON pointer contains an invalid escape: ${pointer}`);
17
+ return part.replaceAll("~1", "/").replaceAll("~0", "~");
18
+ });
19
+ }
20
+
21
+ export function readJsonPointer(value, pointer) {
22
+ let current = value;
23
+ for (const token of decodeJsonPointer(pointer)) {
24
+ const ownToken = current !== null &&
25
+ typeof current === "object" &&
26
+ (!Array.isArray(current) || /^(?:0|[1-9][0-9]*)$/u.test(token)) &&
27
+ Object.hasOwn(current, token);
28
+ if (!ownToken) {
29
+ fail("json-pointer-missing", `JSON pointer does not resolve: ${pointer}`);
30
+ }
31
+ current = current[token];
32
+ }
33
+ return current;
34
+ }
35
+
36
+ async function digestDirectory(absolute) {
37
+ const entries = [];
38
+ async function visit(directory, prefix) {
39
+ const children = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
40
+ for (const child of children) {
41
+ if (DEFAULT_IGNORES.has(child.name)) continue;
42
+ const relative = prefix ? `${prefix}/${child.name}` : child.name;
43
+ const full = path.join(directory, child.name);
44
+ if (child.isSymbolicLink()) {
45
+ entries.push([relative, "symlink"]);
46
+ } else if (child.isDirectory()) {
47
+ await visit(full, relative);
48
+ } else if (child.isFile()) {
49
+ entries.push([relative, sha256(await readFile(full))]);
50
+ }
51
+ }
52
+ }
53
+ await visit(absolute, "");
54
+ return sha256(canonicalJson(entries));
55
+ }
56
+
57
+ export async function digestPath(projectRoot, relativePath) {
58
+ const { absolute } = await resolveExistingInside(projectRoot, relativePath);
59
+ const info = await stat(absolute);
60
+ if (info.isDirectory()) return digestDirectory(absolute);
61
+ if (!info.isFile()) fail("source-unsupported", `source is not a regular file or directory: ${relativePath}`);
62
+ return sha256(await readFile(absolute));
63
+ }
64
+
65
+ export async function digestPathIdentity(projectRoot, relativePath) {
66
+ const { absolute } = await resolveExistingInside(projectRoot, relativePath);
67
+ const info = await stat(absolute);
68
+ const kind = info.isDirectory() ? "directory" : info.isFile() ? "file" : null;
69
+ if (!kind) fail("source-unsupported", `source is not a regular file or directory: ${relativePath}`);
70
+ return sha256(canonicalJson({ kind }));
71
+ }
72
+
73
+ export async function readSourceDigest(projectRoot, source) {
74
+ if (sourceStatus(source) === "deprecated") return null;
75
+ if (source.kind === "human-decision" || source.kind === "external-reference") return null;
76
+ if (source.kind === "path") return digestPathIdentity(projectRoot, source.path);
77
+ if (source.kind === "file") return digestPath(projectRoot, source.path);
78
+ return sha256(canonicalJson(await readJsonSourceValue(projectRoot, source)));
79
+ }
80
+
81
+ async function readJsonSourceValue(projectRoot, source) {
82
+ const { absolute } = await resolveExistingInside(projectRoot, source.path);
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(await readFile(absolute, "utf8"));
86
+ } catch (error) {
87
+ fail("source-json-invalid", `cannot parse JSON source: ${source.path}`, { cause: error });
88
+ }
89
+ return readJsonPointer(parsed, source.pointer);
90
+ }
91
+
92
+ export async function verifyItem(projectRoot, item, sourceMap) {
93
+ const verification = item.verification;
94
+ if (!verification || verification.kind === "none") return null;
95
+ const source = sourceMap.get(verification.source);
96
+ if (!source) return { code: "verification-source-missing", item: item.id, source: verification.source };
97
+ if (verification.kind === "file-exists") {
98
+ try {
99
+ await resolveExistingInside(projectRoot, source.path);
100
+ return null;
101
+ } catch {
102
+ return { code: "verification-failed", item: item.id, source: source.id };
103
+ }
104
+ }
105
+ if (verification.kind === "json-value") {
106
+ if (source.kind !== "json-pointer") {
107
+ return { code: "verification-source-incompatible", item: item.id, source: source.id };
108
+ }
109
+ const actualValue = await readJsonSourceValue(projectRoot, source);
110
+ if (canonicalJson(actualValue) !== canonicalJson(verification.expected)) {
111
+ return { code: "verification-failed", item: item.id, source: source.id, expected: verification.expected, actual: actualValue };
112
+ }
113
+ return null;
114
+ }
115
+ if (verification.kind !== "path-digest") {
116
+ return { code: "verification-kind-unsupported", item: item.id, source: source.id };
117
+ }
118
+ const actual = await digestPath(projectRoot, source.path);
119
+ const expected = verification.expected ?? source.digest;
120
+ if (actual !== expected) return { code: "verification-failed", item: item.id, source: source.id, expected, actual };
121
+ return null;
122
+ }
123
+
124
+ export { DEFAULT_IGNORES };