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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/PROJECT_STATE.json +176 -0
- package/README.md +148 -0
- package/RTK.md +13 -0
- package/UPGRADING.md +15 -0
- package/bin/project-context.mjs +7 -0
- package/docs/00-PRODUCT-CONSTITUTION.md +166 -0
- package/docs/01-PRODUCT-CORE.md +143 -0
- package/docs/02-MARKET-BOUNDARY.md +88 -0
- package/docs/03-FINAL-SOLUTION.md +203 -0
- package/docs/04-PROGRAM-DESIGN.md +428 -0
- package/docs/05-ACCEPTANCE-CONTRACT.md +348 -0
- package/docs/06-HISTORICAL-PROTOTYPE.md +55 -0
- package/docs/07-REAL-TASK-EVIDENCE.md +52 -0
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +199 -0
- package/docs/09-B0-DTG-TMC-MOBILE.md +173 -0
- package/docs/10-B0-DTG-TMC-PC.md +118 -0
- package/docs/11-V1-AUTHORING-CLOSURE-DESIGN.md +312 -0
- package/docs/12-KNOWLEDGE-MAINTENANCE-CLOSURE-ROADMAP.md +350 -0
- package/docs/13-READ-ONLY-GOVERNANCE-DASHBOARD-DESIGN.md +489 -0
- package/docs/14-FORMAL-RELEASE-READINESS.md +61 -0
- package/docs/15-SOURCE-LIFECYCLE-CLOSURE-DESIGN.md +260 -0
- package/docs/README.md +74 -0
- package/examples/README.md +17 -0
- package/examples/package.json +11 -0
- package/examples/project-context-check.yml +22 -0
- package/package.json +40 -0
- package/src/project-context/approver.mjs +177 -0
- package/src/project-context/authoring.mjs +190 -0
- package/src/project-context/canonical-json.mjs +55 -0
- package/src/project-context/checker.mjs +132 -0
- package/src/project-context/cli.mjs +409 -0
- package/src/project-context/contract-schema.mjs +316 -0
- package/src/project-context/dashboard-model.mjs +278 -0
- package/src/project-context/dashboard-renderer.mjs +637 -0
- package/src/project-context/discovery.mjs +251 -0
- package/src/project-context/errors.mjs +13 -0
- package/src/project-context/io.mjs +93 -0
- package/src/project-context/maintenance.mjs +400 -0
- package/src/project-context/path-policy.mjs +155 -0
- package/src/project-context/project-store.mjs +138 -0
- package/src/project-context/projection-store.mjs +107 -0
- package/src/project-context/renderer.mjs +135 -0
- package/src/project-context/scope-compiler.mjs +132 -0
- package/src/project-context/source-reader.mjs +124 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { validateJsonValue } from "./canonical-json.mjs";
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
import { normalizeRelativePath } from "./path-policy.mjs";
|
|
4
|
+
|
|
5
|
+
const ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u;
|
|
6
|
+
const ITEM_KINDS = new Set(["fact", "policy", "reference", "validation-description"]);
|
|
7
|
+
const SOURCE_KINDS = new Set(["file", "path", "json-pointer", "human-decision", "external-reference"]);
|
|
8
|
+
const SOURCE_STATUSES = new Set(["active", "deprecated"]);
|
|
9
|
+
const STATUSES = new Set(["proposed", "approved", "deprecated"]);
|
|
10
|
+
const SCOPE_KINDS = new Set(["project", "path-prefix", "file"]);
|
|
11
|
+
const VERIFICATION_KINDS = new Set(["none", "file-exists", "json-value", "path-digest"]);
|
|
12
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/u;
|
|
13
|
+
|
|
14
|
+
function object(value, label) {
|
|
15
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
16
|
+
fail("schema-invalid", `${label} must be an object`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function exactKeys(value, allowed, label) {
|
|
21
|
+
for (const key of Object.keys(value)) {
|
|
22
|
+
if (!allowed.has(key)) fail("schema-unknown-field", `${label} contains unknown field: ${key}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function string(value, label, options = {}) {
|
|
27
|
+
if (typeof value !== "string" || (!options.empty && value.length === 0)) {
|
|
28
|
+
fail("schema-invalid", `${label} must be a non-empty string`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stableId(value, label) {
|
|
33
|
+
string(value, label);
|
|
34
|
+
if (!ID.test(value)) fail("schema-invalid-id", `${label} must use stable lowercase dot/kebab naming`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function uniqueStrings(values, label, options = {}) {
|
|
38
|
+
if (!Array.isArray(values) || (!options.empty && values.length === 0)) {
|
|
39
|
+
fail("schema-invalid", `${label} must be ${options.empty ? "an" : "a non-empty"} array`);
|
|
40
|
+
}
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
for (const value of values) {
|
|
43
|
+
string(value, `${label} entry`);
|
|
44
|
+
if (seen.has(value)) fail("schema-duplicate", `${label} contains duplicate value: ${value}`);
|
|
45
|
+
seen.add(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateScope(scope, label) {
|
|
50
|
+
object(scope, label);
|
|
51
|
+
exactKeys(scope, new Set(["kind", "path"]), label);
|
|
52
|
+
if (!SCOPE_KINDS.has(scope.kind)) fail("schema-invalid-enum", `${label}.kind is invalid`);
|
|
53
|
+
if (scope.kind === "project") {
|
|
54
|
+
if (scope.path !== undefined && scope.path !== ".") {
|
|
55
|
+
fail("schema-invalid", `${label}.path must be omitted or '.' for project scope`);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
scope.path = normalizeRelativePath(scope.path, { label: `${label}.path` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sourceStatus(source) {
|
|
63
|
+
return source.status ?? "active";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sourceForContract(source, schemaVersion) {
|
|
67
|
+
return schemaVersion === 2 ? { ...structuredClone(source), status: "active" } : structuredClone(source);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sourceRegistrationShape(source) {
|
|
71
|
+
const copy = structuredClone(source);
|
|
72
|
+
delete copy.status;
|
|
73
|
+
delete copy.deprecation;
|
|
74
|
+
return copy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateSource(source, label = "source", options = {}) {
|
|
78
|
+
const contractSchemaVersion = options.contractSchemaVersion ?? 1;
|
|
79
|
+
object(source, label);
|
|
80
|
+
const allowed = new Set(["id", "kind", "path", "pointer", "reference", "digest"]);
|
|
81
|
+
if (contractSchemaVersion === 2) {
|
|
82
|
+
allowed.add("status");
|
|
83
|
+
allowed.add("deprecation");
|
|
84
|
+
}
|
|
85
|
+
exactKeys(source, allowed, label);
|
|
86
|
+
stableId(source.id, `${label}.id`);
|
|
87
|
+
if (!SOURCE_KINDS.has(source.kind)) fail("schema-invalid-enum", `${label}.kind is invalid`);
|
|
88
|
+
if (source.kind === "file" || source.kind === "path" || source.kind === "json-pointer") {
|
|
89
|
+
source.path = normalizeRelativePath(source.path, { label: `${label}.path` });
|
|
90
|
+
string(source.digest, `${label}.digest`);
|
|
91
|
+
if (!SHA256.test(source.digest)) fail("schema-invalid", `${label}.digest must be sha256`);
|
|
92
|
+
if (source.kind === "json-pointer") {
|
|
93
|
+
string(source.pointer, `${label}.pointer`, { empty: true });
|
|
94
|
+
if (source.pointer !== "" && !source.pointer.startsWith("/")) {
|
|
95
|
+
fail("schema-invalid", `${label}.pointer must use RFC 6901 syntax`);
|
|
96
|
+
}
|
|
97
|
+
} else if (source.pointer !== undefined) {
|
|
98
|
+
fail("schema-invalid", `${label}.pointer is only valid for json-pointer sources`);
|
|
99
|
+
}
|
|
100
|
+
if (source.reference !== undefined) fail("schema-invalid", `${label}.reference is not valid for local sources`);
|
|
101
|
+
} else {
|
|
102
|
+
string(source.reference, `${label}.reference`);
|
|
103
|
+
if (source.path !== undefined || source.pointer !== undefined) {
|
|
104
|
+
fail("schema-invalid", `${label} cannot contain a local path`);
|
|
105
|
+
}
|
|
106
|
+
if (source.digest !== null && source.digest !== undefined) {
|
|
107
|
+
fail("schema-invalid", `${label}.digest must be null or omitted for unverifiable sources`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (contractSchemaVersion === 2) {
|
|
111
|
+
if (!SOURCE_STATUSES.has(source.status)) fail("schema-invalid-enum", `${label}.status is invalid`);
|
|
112
|
+
if (source.status === "active" && source.deprecation !== undefined) {
|
|
113
|
+
fail("schema-invalid", `${label}.deprecation is not allowed for active sources`);
|
|
114
|
+
}
|
|
115
|
+
if (source.status === "deprecated") {
|
|
116
|
+
if (source.deprecation === undefined) fail("schema-invalid", `${label}.deprecation is required for deprecated sources`);
|
|
117
|
+
validateApproval(source.deprecation, `${label}.deprecation`);
|
|
118
|
+
if (source.deprecation.rationale === undefined || source.deprecation.rationale.length === 0) {
|
|
119
|
+
fail("schema-invalid", `${label}.deprecation.rationale must be a non-empty string`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return source;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateApproval(approval, label) {
|
|
127
|
+
object(approval, label);
|
|
128
|
+
exactKeys(approval, new Set(["by", "at", "rationale"]), label);
|
|
129
|
+
string(approval.by, `${label}.by`);
|
|
130
|
+
string(approval.at, `${label}.at`);
|
|
131
|
+
if (!Number.isFinite(Date.parse(approval.at))) fail("schema-invalid", `${label}.at must be an ISO timestamp`);
|
|
132
|
+
if (approval.rationale !== undefined) string(approval.rationale, `${label}.rationale`, { empty: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateVerification(verification, label) {
|
|
136
|
+
object(verification, label);
|
|
137
|
+
exactKeys(verification, new Set(["kind", "source", "expected"]), label);
|
|
138
|
+
if (!VERIFICATION_KINDS.has(verification.kind)) fail("schema-invalid-enum", `${label}.kind is invalid`);
|
|
139
|
+
const hasSource = Object.hasOwn(verification, "source");
|
|
140
|
+
const hasExpected = Object.hasOwn(verification, "expected");
|
|
141
|
+
if (verification.kind === "none") {
|
|
142
|
+
if (hasSource || hasExpected) fail("schema-invalid", `${label} none verification cannot contain source or expected`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
string(verification.source, `${label}.source`);
|
|
146
|
+
if (verification.kind === "file-exists" && hasExpected) {
|
|
147
|
+
fail("schema-invalid", `${label} file-exists verification cannot contain expected`);
|
|
148
|
+
}
|
|
149
|
+
if (verification.kind === "json-value" && !hasExpected) {
|
|
150
|
+
fail("schema-invalid", `${label} json-value verification requires expected`);
|
|
151
|
+
}
|
|
152
|
+
if (verification.kind === "json-value") {
|
|
153
|
+
try {
|
|
154
|
+
validateJsonValue(verification.expected);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
fail("schema-invalid", `${label}.expected must be JSON-compatible`, { cause: error });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (verification.kind === "path-digest" && hasExpected) {
|
|
160
|
+
string(verification.expected, `${label}.expected`);
|
|
161
|
+
if (!SHA256.test(verification.expected)) fail("schema-invalid", `${label}.expected must be sha256`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateItem(item, label = "item", options = {}) {
|
|
166
|
+
object(item, label);
|
|
167
|
+
exactKeys(
|
|
168
|
+
item,
|
|
169
|
+
new Set(["id", "kind", "subject", "value", "statement", "scope", "status", "sources", "overrides", "approval", "verification"]),
|
|
170
|
+
label,
|
|
171
|
+
);
|
|
172
|
+
stableId(item.id, `${label}.id`);
|
|
173
|
+
if (!ITEM_KINDS.has(item.kind)) fail("schema-invalid-enum", `${label}.kind is invalid`);
|
|
174
|
+
stableId(item.subject, `${label}.subject`);
|
|
175
|
+
if (!("value" in item)) fail("schema-invalid", `${label}.value is required`);
|
|
176
|
+
try {
|
|
177
|
+
validateJsonValue(item.value);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
fail("schema-invalid", `${label}.value must be JSON-compatible`, { cause: error });
|
|
180
|
+
}
|
|
181
|
+
string(item.statement, `${label}.statement`);
|
|
182
|
+
validateScope(item.scope, `${label}.scope`);
|
|
183
|
+
if (!STATUSES.has(item.status)) fail("schema-invalid-enum", `${label}.status is invalid`);
|
|
184
|
+
uniqueStrings(item.sources, `${label}.sources`);
|
|
185
|
+
uniqueStrings(item.overrides, `${label}.overrides`, { empty: true });
|
|
186
|
+
for (const source of item.sources) stableId(source, `${label}.sources entry`);
|
|
187
|
+
for (const target of item.overrides) stableId(target, `${label}.overrides entry`);
|
|
188
|
+
if (item.status === "proposed" && item.approval !== undefined) {
|
|
189
|
+
fail("schema-invalid", `${label}.approval is not allowed for proposed items`);
|
|
190
|
+
}
|
|
191
|
+
if (item.status === "approved" && item.approval === undefined) {
|
|
192
|
+
fail("schema-invalid", `${label}.approval is required for approved items`);
|
|
193
|
+
}
|
|
194
|
+
if (item.approval !== undefined && item.status !== "proposed") validateApproval(item.approval, `${label}.approval`);
|
|
195
|
+
if (item.verification != null) validateVerification(item.verification, `${label}.verification`);
|
|
196
|
+
if (options.proposal && item.status !== "proposed") {
|
|
197
|
+
fail("proposal-not-proposed", `${label} must remain proposed`);
|
|
198
|
+
}
|
|
199
|
+
return item;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertUnique(records, label) {
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
for (const record of records) {
|
|
205
|
+
if (seen.has(record.id)) fail("schema-duplicate-id", `${label} contains duplicate id: ${record.id}`);
|
|
206
|
+
seen.add(record.id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function validateContract(contract) {
|
|
211
|
+
object(contract, "contract");
|
|
212
|
+
exactKeys(contract, new Set(["schemaVersion", "project", "sources", "items"]), "contract");
|
|
213
|
+
if (![1, 2].includes(contract.schemaVersion)) fail("schema-version-unsupported", "contract.schemaVersion must be 1 or 2");
|
|
214
|
+
object(contract.project, "contract.project");
|
|
215
|
+
exactKeys(contract.project, new Set(["id", "name", "root"]), "contract.project");
|
|
216
|
+
stableId(contract.project.id, "contract.project.id");
|
|
217
|
+
string(contract.project.name, "contract.project.name");
|
|
218
|
+
if (contract.project.root !== ".") fail("schema-invalid", "contract.project.root must be '.'");
|
|
219
|
+
if (!Array.isArray(contract.sources) || !Array.isArray(contract.items)) {
|
|
220
|
+
fail("schema-invalid", "contract.sources and contract.items must be arrays");
|
|
221
|
+
}
|
|
222
|
+
contract.sources.forEach((source, index) => validateSource(source, `contract.sources[${index}]`, {
|
|
223
|
+
contractSchemaVersion: contract.schemaVersion,
|
|
224
|
+
}));
|
|
225
|
+
contract.items.forEach((item, index) => validateItem(item, `contract.items[${index}]`));
|
|
226
|
+
assertUnique(contract.sources, "contract.sources");
|
|
227
|
+
assertUnique(contract.items, "contract.items");
|
|
228
|
+
const sourcesById = new Map(contract.sources.map((source) => [source.id, source]));
|
|
229
|
+
for (const item of contract.items) {
|
|
230
|
+
for (const source of item.sources) {
|
|
231
|
+
const referenced = sourcesById.get(source);
|
|
232
|
+
if (!referenced) fail("source-reference-missing", `item ${item.id} references unknown source ${source}`);
|
|
233
|
+
if (item.status !== "deprecated" && sourceStatus(referenced) === "deprecated") {
|
|
234
|
+
fail("source-reference-deprecated", `item ${item.id} references deprecated source ${source}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (item.verification?.kind !== "none" && item.verification?.source) {
|
|
238
|
+
const referenced = sourcesById.get(item.verification.source);
|
|
239
|
+
if (!referenced) {
|
|
240
|
+
fail("source-reference-missing", `item ${item.id} verification references unknown source ${item.verification.source}`);
|
|
241
|
+
}
|
|
242
|
+
if (item.status !== "deprecated" && sourceStatus(referenced) === "deprecated") {
|
|
243
|
+
fail("source-reference-deprecated", `item ${item.id} verification references deprecated source ${item.verification.source}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return contract;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function validateProposal(proposal) {
|
|
251
|
+
object(proposal, "proposal");
|
|
252
|
+
exactKeys(proposal, new Set(["schemaVersion", "projectId", "sources", "items"]), "proposal");
|
|
253
|
+
if (proposal.schemaVersion !== 1) fail("schema-version-unsupported", "proposal.schemaVersion must be 1");
|
|
254
|
+
stableId(proposal.projectId, "proposal.projectId");
|
|
255
|
+
if (!Array.isArray(proposal.sources) || !Array.isArray(proposal.items)) {
|
|
256
|
+
fail("schema-invalid", "proposal.sources and proposal.items must be arrays");
|
|
257
|
+
}
|
|
258
|
+
proposal.sources.forEach((source, index) => validateSource(source, `proposal.sources[${index}]`));
|
|
259
|
+
proposal.items.forEach((item, index) => validateItem(item, `proposal.items[${index}]`, { proposal: true }));
|
|
260
|
+
assertUnique(proposal.sources, "proposal.sources");
|
|
261
|
+
assertUnique(proposal.items, "proposal.items");
|
|
262
|
+
const sourceIds = new Set(proposal.sources.map((source) => source.id));
|
|
263
|
+
for (const item of proposal.items) {
|
|
264
|
+
for (const source of item.sources) {
|
|
265
|
+
if (!sourceIds.has(source)) fail("source-reference-missing", `proposal item ${item.id} references unknown source ${source}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return proposal;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function validateSourceLock(lock) {
|
|
272
|
+
object(lock, "sources lock");
|
|
273
|
+
exactKeys(lock, new Set(["schemaVersion", "sources"]), "sources lock");
|
|
274
|
+
if (lock.schemaVersion !== 1 || !Array.isArray(lock.sources)) fail("schema-invalid", "sources lock is invalid");
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
for (const [index, entry] of lock.sources.entries()) {
|
|
277
|
+
object(entry, `sources lock entry ${index}`);
|
|
278
|
+
exactKeys(entry, new Set(["id", "digest"]), `sources lock entry ${index}`);
|
|
279
|
+
stableId(entry.id, `sources lock entry ${index}.id`);
|
|
280
|
+
string(entry.digest, `sources lock entry ${index}.digest`);
|
|
281
|
+
if (!SHA256.test(entry.digest)) fail("schema-invalid", "source lock digest must be sha256");
|
|
282
|
+
if (seen.has(entry.id)) fail("schema-duplicate-id", `sources lock contains duplicate id: ${entry.id}`);
|
|
283
|
+
seen.add(entry.id);
|
|
284
|
+
}
|
|
285
|
+
return lock;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function validateProjectionLock(lock) {
|
|
289
|
+
object(lock, "projections lock");
|
|
290
|
+
exactKeys(lock, new Set(["schemaVersion", "projections"]), "projections lock");
|
|
291
|
+
if (lock.schemaVersion !== 1 || !Array.isArray(lock.projections)) fail("schema-invalid", "projections lock is invalid");
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
for (const [index, entry] of lock.projections.entries()) {
|
|
294
|
+
object(entry, `projection entry ${index}`);
|
|
295
|
+
exactKeys(
|
|
296
|
+
entry,
|
|
297
|
+
new Set(["path", "target", "paths", "contractDigest", "bundleDigest", "contentDigest", "itemIds", "rendererVersion"]),
|
|
298
|
+
`projection entry ${index}`,
|
|
299
|
+
);
|
|
300
|
+
entry.path = normalizeRelativePath(entry.path, { label: `projection entry ${index}.path` });
|
|
301
|
+
if (entry.target !== "agents" && entry.target !== "ruler") fail("schema-invalid-enum", "projection target is invalid");
|
|
302
|
+
uniqueStrings(entry.paths, `projection entry ${index}.paths`);
|
|
303
|
+
entry.paths = entry.paths.map((value) => normalizeRelativePath(value, { allowRoot: true, label: "projection scope path" }));
|
|
304
|
+
for (const key of ["contractDigest", "bundleDigest", "contentDigest"]) {
|
|
305
|
+
string(entry[key], `projection entry ${index}.${key}`);
|
|
306
|
+
if (!SHA256.test(entry[key])) fail("schema-invalid", `projection ${key} must be sha256`);
|
|
307
|
+
}
|
|
308
|
+
uniqueStrings(entry.itemIds, `projection entry ${index}.itemIds`, { empty: true });
|
|
309
|
+
if (![1, 2, 3].includes(entry.rendererVersion)) {
|
|
310
|
+
fail("schema-version-unsupported", "projection rendererVersion must be 1, 2, or 3");
|
|
311
|
+
}
|
|
312
|
+
if (seen.has(entry.path)) fail("schema-duplicate", `projections lock contains duplicate path: ${entry.path}`);
|
|
313
|
+
seen.add(entry.path);
|
|
314
|
+
}
|
|
315
|
+
return lock;
|
|
316
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { blockingContextFindings, checkExitCode, checkProject, findingSeverity } from "./checker.mjs";
|
|
3
|
+
import { digestJson } from "./canonical-json.mjs";
|
|
4
|
+
import { sourceStatus } from "./contract-schema.mjs";
|
|
5
|
+
import { reviewSource, sourceImpact } from "./maintenance.mjs";
|
|
6
|
+
import { describeScope, effectiveItems, scopeApplies } from "./scope-compiler.mjs";
|
|
7
|
+
|
|
8
|
+
export const DASHBOARD_SCHEMA_VERSION = 3;
|
|
9
|
+
|
|
10
|
+
const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
|
|
11
|
+
const ITEM_STATUS_ORDER = new Map([
|
|
12
|
+
["proposed", 0],
|
|
13
|
+
["approved", 1],
|
|
14
|
+
["deprecated", 2],
|
|
15
|
+
]);
|
|
16
|
+
const ITEM_KINDS = ["fact", "policy", "reference", "validation-description"];
|
|
17
|
+
|
|
18
|
+
function increment(record, key) {
|
|
19
|
+
record[key] = (record[key] ?? 0) + 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stableStrings(values) {
|
|
23
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sourceLocator(source) {
|
|
27
|
+
if (source.kind === "json-pointer") return `${source.path}#${source.pointer}`;
|
|
28
|
+
return source.path ?? source.reference;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findingTarget(finding) {
|
|
32
|
+
return finding.path ?? finding.source ?? finding.item ?? finding.subject ?? "project";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decorateFinding(finding) {
|
|
36
|
+
return {
|
|
37
|
+
...structuredClone(finding),
|
|
38
|
+
severity: findingSeverity(finding),
|
|
39
|
+
target: findingTarget(finding),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function healthStatus(findings) {
|
|
44
|
+
if (findings.some((finding) => findingSeverity(finding) === "conflict")) return "conflict";
|
|
45
|
+
if (blockingContextFindings(findings).length > 0) return "blocked";
|
|
46
|
+
return findings.length > 0 ? "attention" : "clean";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sourceRelations(contract, sourceId, impact) {
|
|
50
|
+
const referencingItemIds = contract.items
|
|
51
|
+
.filter((item) => item.sources.includes(sourceId) || item.verification?.source === sourceId)
|
|
52
|
+
.map((item) => item.id);
|
|
53
|
+
return {
|
|
54
|
+
referenceCount: stableStrings(referencingItemIds).length,
|
|
55
|
+
directItemIds: impact.items
|
|
56
|
+
.filter((item) => item.reasons.includes("source"))
|
|
57
|
+
.map((item) => item.id),
|
|
58
|
+
verificationItemIds: impact.items
|
|
59
|
+
.filter((item) => item.reasons.includes("verification"))
|
|
60
|
+
.map((item) => item.id),
|
|
61
|
+
overrideDependentItemIds: impact.items
|
|
62
|
+
.filter((item) => item.reasons.includes("override-dependent"))
|
|
63
|
+
.map((item) => item.id),
|
|
64
|
+
fallbackItemIds: impact.fallbackItems.map((item) => item.id),
|
|
65
|
+
directProjectionPaths: [...impact.directProjectionPaths],
|
|
66
|
+
staleProjectionPaths: [...impact.staleProjectionPaths],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function buildSourceModel(root, project, source) {
|
|
71
|
+
let impact;
|
|
72
|
+
let status;
|
|
73
|
+
let lockedDigest = null;
|
|
74
|
+
let currentDigest = null;
|
|
75
|
+
let reason;
|
|
76
|
+
if (sourceStatus(source) === "deprecated") {
|
|
77
|
+
status = "deprecated";
|
|
78
|
+
impact = sourceImpact(project, source.id);
|
|
79
|
+
} else if (LOCAL_SOURCE_KINDS.has(source.kind)) {
|
|
80
|
+
const review = await reviewSource(root, project, source.id);
|
|
81
|
+
({ status, lockedDigest, currentDigest, reason } = review);
|
|
82
|
+
impact = review.impact;
|
|
83
|
+
} else {
|
|
84
|
+
status = source.kind === "human-decision" ? "manual" : "external";
|
|
85
|
+
impact = sourceImpact(project, source.id);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
id: source.id,
|
|
89
|
+
kind: source.kind,
|
|
90
|
+
locator: sourceLocator(source),
|
|
91
|
+
status,
|
|
92
|
+
contractDigest: source.digest ?? null,
|
|
93
|
+
lockedDigest,
|
|
94
|
+
currentDigest,
|
|
95
|
+
...(reason ? { reason } : {}),
|
|
96
|
+
...(source.deprecation ? { deprecation: structuredClone(source.deprecation) } : {}),
|
|
97
|
+
...sourceRelations(project.contract, source.id, impact),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function addPathWithAncestors(paths, value) {
|
|
102
|
+
let current = value;
|
|
103
|
+
while (current && current !== ".") {
|
|
104
|
+
paths.add(current);
|
|
105
|
+
const parent = path.posix.dirname(current);
|
|
106
|
+
current = parent === current ? "." : parent;
|
|
107
|
+
}
|
|
108
|
+
paths.add(".");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function topLevelDirectory(value) {
|
|
112
|
+
if (!value || value === ".") return ".";
|
|
113
|
+
const first = value.split("/")[0];
|
|
114
|
+
return value.includes("/") ? first : ".";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function knownScopePaths(contract, projections) {
|
|
118
|
+
const paths = new Set(["."]);
|
|
119
|
+
for (const item of contract.items) {
|
|
120
|
+
if (item.scope.kind !== "project") addPathWithAncestors(paths, item.scope.path);
|
|
121
|
+
}
|
|
122
|
+
for (const source of contract.sources) {
|
|
123
|
+
if (source.path) addPathWithAncestors(paths, topLevelDirectory(source.path));
|
|
124
|
+
}
|
|
125
|
+
for (const projection of projections) addPathWithAncestors(paths, topLevelDirectory(projection.path));
|
|
126
|
+
return [...paths].sort((left, right) => left.localeCompare(right));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function emptyGroups() {
|
|
130
|
+
return Object.fromEntries(ITEM_KINDS.map((kind) => [kind, []]));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildScopeViews(contract, projections) {
|
|
134
|
+
const views = knownScopePaths(contract, projections).map((targetPath) => {
|
|
135
|
+
const applicableIds = contract.items
|
|
136
|
+
.filter((item) => item.status === "approved" && scopeApplies(item.scope, targetPath))
|
|
137
|
+
.map((item) => item.id);
|
|
138
|
+
try {
|
|
139
|
+
const effective = effectiveItems(contract.items, targetPath);
|
|
140
|
+
const itemIds = effective.map((item) => item.id);
|
|
141
|
+
const effectiveSet = new Set(itemIds);
|
|
142
|
+
const groups = emptyGroups();
|
|
143
|
+
for (const item of effective) groups[item.kind].push(item.id);
|
|
144
|
+
return {
|
|
145
|
+
path: targetPath,
|
|
146
|
+
itemIds,
|
|
147
|
+
groups,
|
|
148
|
+
excludedItems: applicableIds
|
|
149
|
+
.filter((id) => !effectiveSet.has(id))
|
|
150
|
+
.map((id) => ({ id, reason: "overridden" })),
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
path: targetPath,
|
|
155
|
+
itemIds: [],
|
|
156
|
+
groups: emptyGroups(),
|
|
157
|
+
excludedItems: [],
|
|
158
|
+
error: {
|
|
159
|
+
code: error.code ?? "scope-unavailable",
|
|
160
|
+
message: error.message,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
return views.map((view) => {
|
|
166
|
+
if (view.path === ".") return { ...view, siblingDifferences: [] };
|
|
167
|
+
const siblings = views.filter((candidate) =>
|
|
168
|
+
candidate.path !== "." &&
|
|
169
|
+
candidate.path !== view.path &&
|
|
170
|
+
path.posix.dirname(candidate.path) === path.posix.dirname(view.path),
|
|
171
|
+
);
|
|
172
|
+
const here = new Set(view.itemIds);
|
|
173
|
+
return {
|
|
174
|
+
...view,
|
|
175
|
+
siblingDifferences: siblings.map((sibling) => {
|
|
176
|
+
const there = new Set(sibling.itemIds);
|
|
177
|
+
return {
|
|
178
|
+
path: sibling.path,
|
|
179
|
+
onlyHereItemIds: view.itemIds.filter((id) => !there.has(id)),
|
|
180
|
+
onlyThereItemIds: sibling.itemIds.filter((id) => !here.has(id)),
|
|
181
|
+
};
|
|
182
|
+
}).filter((difference) => difference.onlyHereItemIds.length > 0 || difference.onlyThereItemIds.length > 0),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function itemSort(left, right) {
|
|
188
|
+
return (
|
|
189
|
+
ITEM_STATUS_ORDER.get(left.status) - ITEM_STATUS_ORDER.get(right.status) ||
|
|
190
|
+
left.kind.localeCompare(right.kind) ||
|
|
191
|
+
left.id.localeCompare(right.id)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildItems(contract) {
|
|
196
|
+
return contract.items.map((item) => ({
|
|
197
|
+
...structuredClone(item),
|
|
198
|
+
itemDigest: digestJson(item),
|
|
199
|
+
scopeLabel: describeScope(item.scope),
|
|
200
|
+
})).sort(itemSort);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function projectionStatus(codes) {
|
|
204
|
+
if (codes.includes("projection-ownership-conflict")) return "conflict";
|
|
205
|
+
if (codes.includes("projection-missing")) return "missing";
|
|
206
|
+
if (codes.includes("projection-unreadable") || codes.includes("projection-path-invalid")) return "unreadable";
|
|
207
|
+
if (codes.includes("projection-diverged")) return "diverged";
|
|
208
|
+
if (codes.some((code) => code === "projection-stale" || code === "projection-renderer-stale" || code === "projection-item-missing")) {
|
|
209
|
+
return "stale";
|
|
210
|
+
}
|
|
211
|
+
return codes.length > 0 ? "attention" : "healthy";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildProjections(project, findings) {
|
|
215
|
+
return project.projectionsLock.projections.map((entry) => {
|
|
216
|
+
const findingCodes = findings
|
|
217
|
+
.filter((finding) => finding.path === entry.path && finding.code.startsWith("projection-"))
|
|
218
|
+
.map((finding) => finding.code);
|
|
219
|
+
return {
|
|
220
|
+
...structuredClone(entry),
|
|
221
|
+
status: projectionStatus(findingCodes),
|
|
222
|
+
findingCodes: stableStrings(findingCodes),
|
|
223
|
+
};
|
|
224
|
+
}).sort((left, right) => left.path.localeCompare(right.path));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildSummary(sources, items, projections) {
|
|
228
|
+
const sourceSummary = { registered: sources.length, referenced: sources.filter((source) => source.referenceCount > 0).length };
|
|
229
|
+
for (const source of sources) increment(sourceSummary, source.status);
|
|
230
|
+
sourceSummary.healthy = sourceSummary.unchanged ?? 0;
|
|
231
|
+
|
|
232
|
+
const itemSummary = { total: items.length, approved: 0, proposed: 0, deprecated: 0, byKind: {} };
|
|
233
|
+
for (const item of items) {
|
|
234
|
+
increment(itemSummary, item.status);
|
|
235
|
+
increment(itemSummary.byKind, item.kind);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const projectionSummary = { configured: projections.length, healthy: 0, stale: 0, missing: 0, unreadable: 0, diverged: 0, conflict: 0, attention: 0 };
|
|
239
|
+
for (const projection of projections) increment(projectionSummary, projection.status);
|
|
240
|
+
return { sources: sourceSummary, items: itemSummary, projections: projectionSummary };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function buildDashboardModel(root, project) {
|
|
244
|
+
const rawFindings = await checkProject(root, project);
|
|
245
|
+
const findings = rawFindings.map(decorateFinding);
|
|
246
|
+
const sources = (await Promise.all(
|
|
247
|
+
[...project.contract.sources]
|
|
248
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
249
|
+
.map((source) => buildSourceModel(root, project, source)),
|
|
250
|
+
));
|
|
251
|
+
const projections = buildProjections(project, rawFindings);
|
|
252
|
+
const scopeViews = buildScopeViews(project.contract, projections);
|
|
253
|
+
const items = buildItems(project.contract);
|
|
254
|
+
const findingsByCode = {};
|
|
255
|
+
for (const finding of rawFindings) increment(findingsByCode, finding.code);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
|
259
|
+
project: structuredClone(project.contract.project),
|
|
260
|
+
digests: {
|
|
261
|
+
contract: project.contractDigest,
|
|
262
|
+
sourcesLock: project.sourcesLockDigest,
|
|
263
|
+
projectionsLock: project.projectionsLockDigest,
|
|
264
|
+
},
|
|
265
|
+
health: {
|
|
266
|
+
status: healthStatus(rawFindings),
|
|
267
|
+
exitCode: checkExitCode(rawFindings),
|
|
268
|
+
findingCount: rawFindings.length,
|
|
269
|
+
findingsByCode,
|
|
270
|
+
},
|
|
271
|
+
summary: buildSummary(sources, items, projections),
|
|
272
|
+
items,
|
|
273
|
+
sources,
|
|
274
|
+
scopeViews,
|
|
275
|
+
projections,
|
|
276
|
+
findings,
|
|
277
|
+
};
|
|
278
|
+
}
|