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,190 @@
1
+ import { canonicalJson } from "./canonical-json.mjs";
2
+ import {
3
+ sourceForContract,
4
+ sourceRegistrationShape,
5
+ sourceStatus,
6
+ validateItem,
7
+ validateProposal,
8
+ validateSource,
9
+ validateSourceLock,
10
+ validateContract,
11
+ } from "./contract-schema.mjs";
12
+ import { fail } from "./errors.mjs";
13
+ import { normalizeRelativePath } from "./path-policy.mjs";
14
+ import { writeProjectState } from "./project-store.mjs";
15
+ import { readSourceDigest } from "./source-reader.mjs";
16
+ import { validateOverrides } from "./scope-compiler.mjs";
17
+
18
+ const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
19
+ const REFERENCE_SOURCE_KINDS = new Set(["human-decision", "external-reference"]);
20
+ const VERIFICATION_KINDS = new Set(["none", "file-exists", "json-value", "path-digest"]);
21
+
22
+ function has(value) {
23
+ return value !== undefined;
24
+ }
25
+
26
+ function sourceLocator(source) {
27
+ return canonicalJson({
28
+ kind: source.kind,
29
+ ...(source.path !== undefined ? { path: source.path } : {}),
30
+ ...(source.pointer !== undefined ? { pointer: source.pointer } : {}),
31
+ ...(source.reference !== undefined ? { reference: source.reference } : {}),
32
+ });
33
+ }
34
+
35
+ export async function buildRegisteredSource(root, input) {
36
+ const { id, kind, path: sourcePath, pointer, reference } = input;
37
+ let source;
38
+ if (LOCAL_SOURCE_KINDS.has(kind)) {
39
+ if (!has(sourcePath) || has(reference) || (kind === "json-pointer" ? !has(pointer) : has(pointer))) {
40
+ fail("argument-conflict", `${kind} source requires the documented path/pointer arguments`, { details: { source: id } });
41
+ }
42
+ source = {
43
+ id,
44
+ kind,
45
+ path: normalizeRelativePath(sourcePath, { label: "source path" }),
46
+ ...(kind === "json-pointer" ? { pointer } : {}),
47
+ };
48
+ source.digest = await readSourceDigest(root, source);
49
+ } else if (REFERENCE_SOURCE_KINDS.has(kind)) {
50
+ if (!has(reference) || has(sourcePath) || has(pointer)) {
51
+ fail("argument-conflict", `${kind} source requires only --reference`, { details: { source: id } });
52
+ }
53
+ source = { id, kind, reference };
54
+ } else {
55
+ fail("schema-invalid-enum", `source kind is invalid: ${kind}`, { details: { source: id } });
56
+ }
57
+ return validateSource(source);
58
+ }
59
+
60
+ export async function registerSource(root, project, input, options = {}) {
61
+ const source = await buildRegisteredSource(root, input);
62
+ const existingById = project.contract.sources.find((entry) => entry.id === source.id);
63
+ if (existingById) {
64
+ if (sourceStatus(existingById) === "deprecated" || canonicalJson(sourceRegistrationShape(existingById)) !== canonicalJson(source)) {
65
+ fail("source-id-conflict", `source ID conflicts with contract: ${source.id}`, { details: { source: source.id } });
66
+ }
67
+ return { action: "unchanged", source: structuredClone(existingById), written: false };
68
+ }
69
+ const locator = sourceLocator(source);
70
+ const existingByLocation = project.contract.sources.find((entry) =>
71
+ sourceStatus(entry) === "active" && sourceLocator(entry) === locator,
72
+ );
73
+ if (existingByLocation) {
74
+ fail("source-location-conflict", `source location is already registered as ${existingByLocation.id}`, {
75
+ details: { source: source.id, existing: existingByLocation.id },
76
+ });
77
+ }
78
+
79
+ const nextContract = structuredClone(project.contract);
80
+ const storedSource = sourceForContract(source, nextContract.schemaVersion);
81
+ nextContract.sources.push(storedSource);
82
+ nextContract.sources.sort((left, right) => left.id.localeCompare(right.id));
83
+ validateContract(nextContract);
84
+
85
+ const locks = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
86
+ if (typeof source.digest === "string") locks.set(source.id, source.digest);
87
+ const nextSourcesLock = validateSourceLock({
88
+ schemaVersion: 1,
89
+ sources: [...locks].map(([id, digest]) => ({ id, digest })).sort((left, right) => left.id.localeCompare(right.id)),
90
+ });
91
+
92
+ if (options.write) {
93
+ if (LOCAL_SOURCE_KINDS.has(source.kind)) {
94
+ const actual = await readSourceDigest(root, source);
95
+ if (actual !== source.digest) {
96
+ fail("source-changed-during-register", `source changed while registration was being prepared: ${source.id}`, {
97
+ exitCode: 1,
98
+ details: { source: source.id, expected: source.digest, actual },
99
+ });
100
+ }
101
+ }
102
+ await writeProjectState(project, nextContract, nextSourcesLock);
103
+ }
104
+ return { action: "create", source: storedSource, written: Boolean(options.write) };
105
+ }
106
+
107
+ function buildScope(kind, scopePath) {
108
+ if (kind === "project") {
109
+ if (has(scopePath)) fail("argument-conflict", "project scope does not accept --scope-path");
110
+ return { kind: "project" };
111
+ }
112
+ if (kind !== "path-prefix" && kind !== "file") fail("schema-invalid-enum", `scope kind is invalid: ${kind}`);
113
+ if (!has(scopePath)) fail("argument-missing", `--scope-path is required for ${kind} scope`);
114
+ return { kind, path: normalizeRelativePath(scopePath, { label: "scope path" }) };
115
+ }
116
+
117
+ function buildVerification(input) {
118
+ const kind = input.verification;
119
+ const source = input.verificationSource;
120
+ const expectedPresent = input.verificationExpectedPresent;
121
+ if (!has(kind)) {
122
+ if (has(source) || expectedPresent) fail("argument-conflict", "verification details require --verification");
123
+ return undefined;
124
+ }
125
+ if (!VERIFICATION_KINDS.has(kind)) fail("schema-invalid-enum", `verification kind is invalid: ${kind}`);
126
+ if (kind === "none") {
127
+ if (has(source) || expectedPresent) fail("argument-conflict", "none verification does not accept source or expected value");
128
+ return { kind };
129
+ }
130
+ if (!has(source)) fail("argument-missing", `--verification-source is required for ${kind}`);
131
+ if (kind === "file-exists" && expectedPresent) fail("argument-conflict", "file-exists verification does not accept expected value");
132
+ if (kind === "json-value" && !expectedPresent) fail("argument-missing", "--verification-expected-json is required for json-value");
133
+ return { kind, source, ...(expectedPresent ? { expected: input.verificationExpected } : {}) };
134
+ }
135
+
136
+ export function buildItemProposal(project, input) {
137
+ if (project.contract.items.some((entry) => entry.id === input.id)) {
138
+ fail("item-id-conflict", `item ID already exists in contract: ${input.id}`, { details: { item: input.id } });
139
+ }
140
+ const sourceIds = [...new Set(input.sources)];
141
+ if (sourceIds.length !== input.sources.length) fail("schema-duplicate", "--sources contains duplicate IDs");
142
+ const sourceMap = new Map(project.contract.sources.map((source) => [source.id, source]));
143
+ for (const sourceId of sourceIds) {
144
+ const source = sourceMap.get(sourceId);
145
+ if (!source) {
146
+ fail("source-reference-missing", `item references unknown source: ${sourceId}`, { details: { item: input.id, source: sourceId } });
147
+ }
148
+ if (sourceStatus(source) === "deprecated") {
149
+ fail("source-reference-deprecated", `item references deprecated source: ${sourceId}`, {
150
+ details: { item: input.id, source: sourceId },
151
+ });
152
+ }
153
+ }
154
+ const verification = buildVerification(input);
155
+ if (verification?.source && !sourceIds.includes(verification.source)) {
156
+ fail("source-reference-missing", "verification source must be included in --sources", {
157
+ details: { item: input.id, source: verification.source },
158
+ });
159
+ }
160
+ const item = {
161
+ id: input.id,
162
+ kind: input.kind,
163
+ subject: input.subject,
164
+ value: input.value,
165
+ statement: input.statement,
166
+ scope: buildScope(input.scope, input.scopePath),
167
+ status: "proposed",
168
+ sources: sourceIds,
169
+ overrides: input.overrides ?? [],
170
+ ...(verification ? { verification } : {}),
171
+ };
172
+ validateItem(item, "proposal item", { proposal: true });
173
+
174
+ if (item.overrides.length > 0) {
175
+ const hypothetical = { ...structuredClone(item), status: "approved", approval: { by: "proposal-preflight", at: "1970-01-01T00:00:00.000Z" } };
176
+ const findings = validateOverrides([...project.contract.items, hypothetical]);
177
+ if (findings.length > 0) {
178
+ fail("approval-preflight-failed", "proposed overrides are invalid", { exitCode: 1, details: { item: item.id, findings } });
179
+ }
180
+ }
181
+
182
+ return validateProposal({
183
+ schemaVersion: 1,
184
+ projectId: project.contract.project.id,
185
+ sources: sourceIds
186
+ .map((id) => sourceRegistrationShape(sourceMap.get(id)))
187
+ .sort((left, right) => left.id.localeCompare(right.id)),
188
+ items: [item],
189
+ });
190
+ }
@@ -0,0 +1,55 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ function assertJsonCompatible(value, ancestors) {
4
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
5
+ if (typeof value === "number") {
6
+ if (!Number.isFinite(value)) throw new TypeError("canonical JSON requires finite numbers");
7
+ return;
8
+ }
9
+ if (typeof value !== "object") {
10
+ throw new TypeError(`canonical JSON does not support ${typeof value}`);
11
+ }
12
+ if (ancestors.has(value)) throw new TypeError("canonical JSON does not support cyclic values");
13
+ ancestors.add(value);
14
+ if (Array.isArray(value)) {
15
+ if (Object.keys(value).length !== value.length) throw new TypeError("canonical JSON does not support sparse arrays");
16
+ value.forEach((entry) => assertJsonCompatible(entry, ancestors));
17
+ } else {
18
+ const prototype = Object.getPrototypeOf(value);
19
+ if (prototype !== Object.prototype && prototype !== null) {
20
+ throw new TypeError("canonical JSON requires plain objects");
21
+ }
22
+ Object.values(value).forEach((entry) => assertJsonCompatible(entry, ancestors));
23
+ }
24
+ ancestors.delete(value);
25
+ }
26
+
27
+ export function validateJsonValue(value) {
28
+ assertJsonCompatible(value, new WeakSet());
29
+ return value;
30
+ }
31
+
32
+ export function canonicalValue(value) {
33
+ if (Array.isArray(value)) return value.map(canonicalValue);
34
+ if (value && typeof value === "object") {
35
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
36
+ }
37
+ return value;
38
+ }
39
+
40
+ export function canonicalJson(value) {
41
+ return JSON.stringify(canonicalValue(value));
42
+ }
43
+
44
+ export function prettyCanonicalJson(value) {
45
+ return `${JSON.stringify(canonicalValue(value), null, 2)}\n`;
46
+ }
47
+
48
+ export function sha256(value) {
49
+ const input = Buffer.isBuffer(value) ? value : Buffer.from(String(value));
50
+ return `sha256:${createHash("sha256").update(input).digest("hex")}`;
51
+ }
52
+
53
+ export function digestJson(value) {
54
+ return sha256(canonicalJson(value));
55
+ }
@@ -0,0 +1,132 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { sha256 } from "./canonical-json.mjs";
4
+ import { sourceStatus } from "./contract-schema.mjs";
5
+ import { readSourceDigest, verifyItem } from "./source-reader.mjs";
6
+ import { findConflicts, validateOverrides } from "./scope-compiler.mjs";
7
+ import { parseProjectionMarker, RENDERER_VERSION, renderProjection } from "./renderer.mjs";
8
+ import { resolveWritableInside } from "./path-policy.mjs";
9
+
10
+ export async function checkProject(root, project) {
11
+ const findings = [];
12
+ const contractSourceIds = new Set(project.contract.sources.map((source) => source.id));
13
+ const sourceLock = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
14
+ for (const source of project.contract.sources) {
15
+ if (sourceStatus(source) === "deprecated") continue;
16
+ if (source.kind === "human-decision" || source.kind === "external-reference") continue;
17
+ const locked = sourceLock.get(source.id);
18
+ if (!locked) {
19
+ findings.push({ code: "source-lock-missing", source: source.id, path: source.path });
20
+ continue;
21
+ }
22
+ if (locked !== source.digest) {
23
+ findings.push({ code: "source-lock-mismatch", source: source.id, path: source.path });
24
+ continue;
25
+ }
26
+ try {
27
+ const actual = await readSourceDigest(root, source);
28
+ if (actual !== locked) findings.push({ code: "source-changed", source: source.id, path: source.path, expected: locked, actual });
29
+ } catch (error) {
30
+ const code = error.code === "source-missing"
31
+ ? "source-missing"
32
+ : error.code === "json-pointer-missing"
33
+ ? "source-changed"
34
+ : "source-unreadable";
35
+ findings.push({ code, source: source.id, path: source.path });
36
+ }
37
+ }
38
+ const contractSources = new Map(project.contract.sources.map((source) => [source.id, source]));
39
+ for (const entry of project.sourcesLock.sources) {
40
+ if (!contractSourceIds.has(entry.id)) {
41
+ findings.push({ code: "source-lock-orphan", source: entry.id });
42
+ } else if (sourceStatus(contractSources.get(entry.id)) === "deprecated") {
43
+ findings.push({ code: "source-lock-deprecated", source: entry.id });
44
+ }
45
+ }
46
+ for (const item of project.contract.items.filter((candidate) => candidate.status === "proposed")) {
47
+ findings.push({ code: "item-approval-pending", item: item.id });
48
+ }
49
+ findings.push(...validateOverrides(project.contract.items), ...findConflicts(project.contract.items));
50
+ const sourceMap = new Map(project.contract.sources.map((source) => [source.id, source]));
51
+ for (const item of project.contract.items.filter((candidate) => candidate.status === "approved")) {
52
+ try {
53
+ const verification = await verifyItem(root, item, sourceMap);
54
+ if (verification) findings.push(verification);
55
+ } catch (error) {
56
+ findings.push({
57
+ code: "verification-failed",
58
+ item: item.id,
59
+ source: item.verification?.source,
60
+ reason: error.code ?? "unreadable",
61
+ });
62
+ }
63
+ }
64
+ const itemIds = new Set(project.contract.items.map((item) => item.id));
65
+ for (const entry of project.projectionsLock.projections) {
66
+ let resolved;
67
+ try {
68
+ resolved = await resolveWritableInside(root, entry.path);
69
+ } catch (error) {
70
+ findings.push({ code: "projection-path-invalid", path: entry.path, message: error.message });
71
+ continue;
72
+ }
73
+ let content;
74
+ try {
75
+ content = await readFile(resolved.absolute, "utf8");
76
+ } catch (error) {
77
+ findings.push({ code: error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable", path: entry.path });
78
+ continue;
79
+ }
80
+ const marker = parseProjectionMarker(content);
81
+ if (!marker) {
82
+ findings.push({ code: "projection-ownership-conflict", path: entry.path });
83
+ continue;
84
+ }
85
+ if (sha256(content) !== entry.contentDigest) {
86
+ findings.push({ code: "projection-ownership-conflict", path: entry.path });
87
+ continue;
88
+ }
89
+ if (marker.target !== entry.target || marker.rendererVersion !== entry.rendererVersion) {
90
+ findings.push({ code: "projection-ownership-conflict", path: entry.path });
91
+ continue;
92
+ }
93
+ if (entry.rendererVersion !== RENDERER_VERSION) {
94
+ findings.push({ code: "projection-renderer-stale", path: entry.path, expected: RENDERER_VERSION, actual: entry.rendererVersion });
95
+ continue;
96
+ }
97
+ if (entry.contractDigest !== project.contractDigest || marker.contractDigest !== project.contractDigest) {
98
+ findings.push({ code: "projection-stale", path: entry.path });
99
+ continue;
100
+ }
101
+ if (entry.itemIds.some((id) => !itemIds.has(id))) {
102
+ findings.push({ code: "projection-item-missing", path: entry.path });
103
+ continue;
104
+ }
105
+ const expected = renderProjection(project.contract, entry.paths, entry.target);
106
+ if (expected.content !== content || expected.bundleDigest !== entry.bundleDigest) {
107
+ findings.push({ code: "projection-diverged", path: entry.path });
108
+ }
109
+ }
110
+ return findings.sort((left, right) => {
111
+ return left.code.localeCompare(right.code) || String(left.path ?? left.source ?? left.item ?? "").localeCompare(String(right.path ?? right.source ?? right.item ?? ""));
112
+ });
113
+ }
114
+
115
+ export function checkExitCode(findings) {
116
+ return findings.some((finding) => finding.code === "projection-ownership-conflict") ? 3 : findings.length > 0 ? 1 : 0;
117
+ }
118
+
119
+ export function blockingContextFindings(findings) {
120
+ return findings.filter((finding) =>
121
+ finding.code.startsWith("source-") ||
122
+ finding.code === "item-approval-pending" ||
123
+ finding.code.startsWith("scope-") ||
124
+ finding.code === "contract-conflict" ||
125
+ finding.code.startsWith("verification-"),
126
+ );
127
+ }
128
+
129
+ export function findingSeverity(finding) {
130
+ if (finding.code === "projection-ownership-conflict") return "conflict";
131
+ return blockingContextFindings([finding]).length > 0 ? "blocked" : "attention";
132
+ }