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,409 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { approvePendingItems, approveProposal } from "./approver.mjs";
|
|
3
|
+
import { buildItemProposal, registerSource } from "./authoring.mjs";
|
|
4
|
+
import { blockingContextFindings, checkExitCode, checkProject } from "./checker.mjs";
|
|
5
|
+
import { digestJson, prettyCanonicalJson } from "./canonical-json.mjs";
|
|
6
|
+
import { validateProposal } from "./contract-schema.mjs";
|
|
7
|
+
import { buildDashboardModel } from "./dashboard-model.mjs";
|
|
8
|
+
import { renderDashboardHtml } from "./dashboard-renderer.mjs";
|
|
9
|
+
import { discoverProject } from "./discovery.mjs";
|
|
10
|
+
import { ProjectContextError, fail } from "./errors.mjs";
|
|
11
|
+
import { atomicCreateFileOrSame, atomicWriteFile, readJsonFile } from "./io.mjs";
|
|
12
|
+
import { acceptSourceChange, deprecateItem, deprecateSource, reviewSource, reviseItem } from "./maintenance.mjs";
|
|
13
|
+
import { normalizeRelativePath, resolveExistingInside, resolveProjectRoot, resolveWritableInside } from "./path-policy.mjs";
|
|
14
|
+
import { CONTRACT_FILE, initializeProject, loadProject, PROJECTIONS_LOCK_FILE, SOURCES_LOCK_FILE } from "./project-store.mjs";
|
|
15
|
+
import { publishProjection } from "./projection-store.mjs";
|
|
16
|
+
import { renderContextBundle } from "./renderer.mjs";
|
|
17
|
+
|
|
18
|
+
const HELP = `project-context — model-neutral project contract compiler
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
project-context init --project PATH --id ID --name NAME [--write] [--json]
|
|
22
|
+
project-context register --project PATH --id SOURCE_ID --kind KIND [--path PATH] [--pointer POINTER] [--reference TEXT] [--write] [--json]
|
|
23
|
+
project-context propose --project PATH --id ITEM_ID --kind KIND --subject SUBJECT (--value TEXT | --value-json JSON) --statement TEXT --sources SOURCE_ID... --scope SCOPE [--scope-path PATH] [--overrides ITEM_ID...] [--verification KIND] [--verification-source SOURCE_ID] [--verification-expected-json JSON] [--output FILE --write] [--json]
|
|
24
|
+
project-context review-source --project PATH --id SOURCE_ID [--json]
|
|
25
|
+
project-context accept-source-change --project PATH --id SOURCE_ID --expected-digest SHA256 [--affected-items ITEM_ID...] [--write] [--json]
|
|
26
|
+
project-context revise --project PATH --id ITEM_ID [--expected-item-digest SHA256] --kind KIND --subject SUBJECT (--value TEXT | --value-json JSON) --statement TEXT --sources SOURCE_ID... --scope SCOPE [--scope-path PATH] [--overrides ITEM_ID...] [--verification KIND] [--verification-source SOURCE_ID] [--verification-expected-json JSON] [--write] [--json]
|
|
27
|
+
project-context deprecate --project PATH --id ITEM_ID [--expected-item-digest SHA256] --by NAME --rationale TEXT [--write] [--json]
|
|
28
|
+
project-context deprecate-source --project PATH --id SOURCE_ID [--expected-source-digest SHA256] --by NAME --rationale TEXT [--write] [--json]
|
|
29
|
+
project-context discover --project PATH [--output FILE --write] [--json]
|
|
30
|
+
project-context approve --project PATH (--proposal FILE | --pending) --ids ID... --by NAME [--rationale TEXT] [--write] [--json | --full-json]
|
|
31
|
+
project-context context --project PATH --path RELATIVE_PATH... [--task TEXT] [--locale zh-CN|en|all] [--json]
|
|
32
|
+
project-context publish --project PATH --target agents|ruler --output FILE [--path RELATIVE_PATH...] [--write] [--json]
|
|
33
|
+
project-context check --project PATH [--json]
|
|
34
|
+
project-context dashboard --project PATH [--json]
|
|
35
|
+
|
|
36
|
+
All commands are read-only unless their own --write flag is present.
|
|
37
|
+
`;
|
|
38
|
+
const VALUE_FLAGS = new Set([
|
|
39
|
+
"project", "id", "name", "output", "proposal", "by", "task", "target", "rationale",
|
|
40
|
+
"kind", "pointer", "reference", "subject", "value", "value-json", "statement", "scope", "scope-path",
|
|
41
|
+
"verification", "verification-source", "verification-expected-json",
|
|
42
|
+
"expected-digest", "expected-item-digest", "expected-source-digest", "locale",
|
|
43
|
+
]);
|
|
44
|
+
const LIST_FLAGS = new Set(["ids", "path", "sources", "overrides", "affected-items"]);
|
|
45
|
+
const BOOLEAN_FLAGS = new Set(["write", "json", "full-json", "help", "pending"]);
|
|
46
|
+
const COMMAND_OPTIONS = new Map([
|
|
47
|
+
["init", new Set(["project", "id", "name", "write", "json", "help"])],
|
|
48
|
+
["register", new Set(["project", "id", "kind", "path", "pointer", "reference", "write", "json", "help"])],
|
|
49
|
+
["propose", new Set([
|
|
50
|
+
"project", "id", "kind", "subject", "value", "value-json", "statement", "sources", "scope", "scope-path",
|
|
51
|
+
"overrides", "verification", "verification-source", "verification-expected-json", "output", "write", "json", "help",
|
|
52
|
+
])],
|
|
53
|
+
["review-source", new Set(["project", "id", "json", "help"])],
|
|
54
|
+
["accept-source-change", new Set([
|
|
55
|
+
"project", "id", "expected-digest", "affected-items", "write", "json", "help",
|
|
56
|
+
])],
|
|
57
|
+
["revise", new Set([
|
|
58
|
+
"project", "id", "expected-item-digest", "kind", "subject", "value", "value-json", "statement", "sources",
|
|
59
|
+
"scope", "scope-path", "overrides", "verification", "verification-source", "verification-expected-json",
|
|
60
|
+
"write", "json", "help",
|
|
61
|
+
])],
|
|
62
|
+
["deprecate", new Set([
|
|
63
|
+
"project", "id", "expected-item-digest", "by", "rationale", "write", "json", "help",
|
|
64
|
+
])],
|
|
65
|
+
["deprecate-source", new Set([
|
|
66
|
+
"project", "id", "expected-source-digest", "by", "rationale", "write", "json", "help",
|
|
67
|
+
])],
|
|
68
|
+
["discover", new Set(["project", "output", "write", "json", "help"])],
|
|
69
|
+
["approve", new Set(["project", "proposal", "pending", "ids", "by", "rationale", "write", "json", "full-json", "help"])],
|
|
70
|
+
["context", new Set(["project", "path", "task", "locale", "json", "help"])],
|
|
71
|
+
["publish", new Set(["project", "target", "output", "path", "write", "json", "help"])],
|
|
72
|
+
["check", new Set(["project", "json", "help"])],
|
|
73
|
+
["dashboard", new Set(["project", "json", "help"])],
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
export function parseArgs(argv) {
|
|
77
|
+
const args = [...argv];
|
|
78
|
+
const command = args.shift();
|
|
79
|
+
if (command === "--help") return { command: "help", options: { help: true } };
|
|
80
|
+
const options = {};
|
|
81
|
+
while (args.length > 0) {
|
|
82
|
+
const token = args.shift();
|
|
83
|
+
if (!token.startsWith("--")) fail("argument-invalid", `unexpected argument: ${token}`);
|
|
84
|
+
const key = token.slice(2);
|
|
85
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
86
|
+
options[key] = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (LIST_FLAGS.has(key)) {
|
|
90
|
+
const values = [];
|
|
91
|
+
while (args.length > 0 && !args[0].startsWith("--")) values.push(args.shift());
|
|
92
|
+
if (values.length === 0) fail("argument-missing", `--${key} requires at least one value`);
|
|
93
|
+
options[key] = [...(options[key] ?? []), ...values];
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (VALUE_FLAGS.has(key)) {
|
|
97
|
+
const value = args.shift();
|
|
98
|
+
if (value === undefined || value.startsWith("--")) fail("argument-missing", `--${key} requires a value`);
|
|
99
|
+
options[key] = value;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
fail("argument-unknown", `unknown option: --${key}`);
|
|
103
|
+
}
|
|
104
|
+
return { command, options };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function required(options, name) {
|
|
108
|
+
if (options[name] === undefined) fail("argument-missing", `--${name} is required`);
|
|
109
|
+
return options[name];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function oneListValue(options, name) {
|
|
113
|
+
const values = required(options, name);
|
|
114
|
+
if (!Array.isArray(values) || values.length !== 1) fail("argument-conflict", `--${name} requires exactly one value for this command`);
|
|
115
|
+
return values[0];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function optionalOneListValue(options, name) {
|
|
119
|
+
if (options[name] === undefined) return undefined;
|
|
120
|
+
return oneListValue(options, name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseJsonArgument(value, name) {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(value);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
fail("value-json-invalid", `--${name} must contain valid JSON`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rejectUnsupportedOptions(command, options, allowed) {
|
|
132
|
+
for (const key of Object.keys(options)) {
|
|
133
|
+
if (!allowed.has(key)) fail("argument-unknown", `${command} does not accept --${key}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateProposalOutput(output) {
|
|
138
|
+
const normalized = normalizeRelativePath(output, { label: "proposal output" });
|
|
139
|
+
const reserved = new Set([
|
|
140
|
+
`.project-context/${CONTRACT_FILE}`,
|
|
141
|
+
`.project-context/${SOURCES_LOCK_FILE}`,
|
|
142
|
+
`.project-context/${PROJECTIONS_LOCK_FILE}`,
|
|
143
|
+
]);
|
|
144
|
+
if (!normalized.startsWith(".project-context/") || path.posix.extname(normalized) !== ".json" || reserved.has(normalized)) {
|
|
145
|
+
fail("invalid-proposal-output", "proposals must be non-store JSON files inside .project-context/");
|
|
146
|
+
}
|
|
147
|
+
return normalized;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function jsonOrText(options, value, text) {
|
|
151
|
+
return options.json ? prettyCanonicalJson(value) : text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function contextLocale(options) {
|
|
155
|
+
const locale = options.locale ?? "zh-CN";
|
|
156
|
+
if (!["zh-CN", "en", "all"].includes(locale)) {
|
|
157
|
+
fail("argument-invalid", "--locale must be zh-CN, en, or all");
|
|
158
|
+
}
|
|
159
|
+
return locale;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function approvalReceipt(project, result, mode) {
|
|
163
|
+
return {
|
|
164
|
+
action: "approve",
|
|
165
|
+
mode,
|
|
166
|
+
approvedIds: result.approvedIds,
|
|
167
|
+
previousContractDigest: project.contractDigest,
|
|
168
|
+
nextContractDigest: digestJson(result.contract),
|
|
169
|
+
nextSourcesLockDigest: digestJson(result.sourcesLock),
|
|
170
|
+
written: result.written,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function itemInput(options) {
|
|
175
|
+
const hasValue = options.value !== undefined;
|
|
176
|
+
const hasJsonValue = options["value-json"] !== undefined;
|
|
177
|
+
if (hasValue === hasJsonValue) fail("argument-conflict", "exactly one of --value or --value-json is required");
|
|
178
|
+
const hasExpected = options["verification-expected-json"] !== undefined;
|
|
179
|
+
return {
|
|
180
|
+
id: required(options, "id"),
|
|
181
|
+
kind: required(options, "kind"),
|
|
182
|
+
subject: required(options, "subject"),
|
|
183
|
+
value: hasValue ? options.value : parseJsonArgument(options["value-json"], "value-json"),
|
|
184
|
+
statement: required(options, "statement"),
|
|
185
|
+
sources: required(options, "sources"),
|
|
186
|
+
scope: required(options, "scope"),
|
|
187
|
+
scopePath: options["scope-path"],
|
|
188
|
+
overrides: options.overrides ?? [],
|
|
189
|
+
verification: options.verification,
|
|
190
|
+
verificationSource: options["verification-source"],
|
|
191
|
+
verificationExpectedPresent: hasExpected,
|
|
192
|
+
verificationExpected: hasExpected
|
|
193
|
+
? parseJsonArgument(options["verification-expected-json"], "verification-expected-json")
|
|
194
|
+
: undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runCommand(command, options) {
|
|
199
|
+
if (!command || command === "help" || options.help) return { exitCode: 0, stdout: HELP, stderr: "" };
|
|
200
|
+
if (!COMMAND_OPTIONS.has(command)) fail("command-unknown", `unknown command: ${command}`);
|
|
201
|
+
rejectUnsupportedOptions(command, options, COMMAND_OPTIONS.get(command));
|
|
202
|
+
const root = await resolveProjectRoot(required(options, "project"));
|
|
203
|
+
if (command === "init") {
|
|
204
|
+
const result = await initializeProject(root, required(options, "id"), required(options, "name"), options.write);
|
|
205
|
+
return {
|
|
206
|
+
exitCode: 0,
|
|
207
|
+
stdout: jsonOrText(options, result.preview, options.write ? "Initialized .project-context/.\n" : prettyCanonicalJson(result.preview)),
|
|
208
|
+
stderr: "",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const project = await loadProject(root);
|
|
212
|
+
if (command === "register") {
|
|
213
|
+
const result = await registerSource(root, project, {
|
|
214
|
+
id: required(options, "id"),
|
|
215
|
+
kind: required(options, "kind"),
|
|
216
|
+
path: optionalOneListValue(options, "path"),
|
|
217
|
+
pointer: options.pointer,
|
|
218
|
+
reference: options.reference,
|
|
219
|
+
}, { write: options.write });
|
|
220
|
+
const summary = result.action === "unchanged"
|
|
221
|
+
? `Source unchanged: ${result.source.id}.\n`
|
|
222
|
+
: options.write
|
|
223
|
+
? `Registered source: ${result.source.id}.\n`
|
|
224
|
+
: `Preview source registration: ${result.source.id}.\n`;
|
|
225
|
+
return { exitCode: 0, stdout: jsonOrText(options, result, summary), stderr: "" };
|
|
226
|
+
}
|
|
227
|
+
if (command === "propose") {
|
|
228
|
+
const proposal = buildItemProposal(project, itemInput(options));
|
|
229
|
+
if (options.write && !options.output) fail("argument-missing", "--output is required with --write for propose");
|
|
230
|
+
let action = "preview";
|
|
231
|
+
if (options.output && options.write) {
|
|
232
|
+
const output = validateProposalOutput(options.output);
|
|
233
|
+
const resolved = await resolveWritableInside(root, output);
|
|
234
|
+
action = await atomicCreateFileOrSame(resolved.absolute, prettyCanonicalJson(proposal));
|
|
235
|
+
}
|
|
236
|
+
const result = { action, proposal, written: Boolean(options.output && options.write && action === "create") };
|
|
237
|
+
const text = options.output && options.write
|
|
238
|
+
? `${action === "create" ? "Created" : "Unchanged"} proposal: ${options.output}.\n`
|
|
239
|
+
: prettyCanonicalJson(proposal);
|
|
240
|
+
return {
|
|
241
|
+
exitCode: 0,
|
|
242
|
+
stdout: jsonOrText(options, result, text),
|
|
243
|
+
stderr: options.output && !options.write ? `Preview only; ${options.output} was not written.\n` : "",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (command === "review-source") {
|
|
247
|
+
const result = await reviewSource(root, project, required(options, "id"));
|
|
248
|
+
const exitCode = result.status === "unchanged" || result.status === "deprecated" ? 0 : 1;
|
|
249
|
+
const summary = [
|
|
250
|
+
`Source ${result.source.id}: ${result.status}.`,
|
|
251
|
+
`Checkpoint: ${result.lockedDigest ?? "missing"}`,
|
|
252
|
+
`Current: ${result.currentDigest ?? "unavailable"}`,
|
|
253
|
+
`Affected items: ${result.impact.items.map((item) => item.id).join(", ") || "none"}`,
|
|
254
|
+
].join("\n") + "\n";
|
|
255
|
+
return { exitCode, stdout: jsonOrText(options, result, summary), stderr: "" };
|
|
256
|
+
}
|
|
257
|
+
if (command === "accept-source-change") {
|
|
258
|
+
const result = await acceptSourceChange(root, project, {
|
|
259
|
+
id: required(options, "id"),
|
|
260
|
+
expectedDigest: required(options, "expected-digest"),
|
|
261
|
+
affectedItems: options["affected-items"] ?? [],
|
|
262
|
+
}, { write: options.write });
|
|
263
|
+
const summary = options.write
|
|
264
|
+
? `Accepted source change: ${result.source}; ${result.affectedItems.length} item(s) now pending approval.\n`
|
|
265
|
+
: `Preview source acceptance: ${result.source}; ${result.affectedItems.length} item(s) would become pending.\n`;
|
|
266
|
+
return { exitCode: 0, stdout: jsonOrText(options, result, summary), stderr: "" };
|
|
267
|
+
}
|
|
268
|
+
if (command === "revise") {
|
|
269
|
+
if (options.write && options["expected-item-digest"] === undefined) {
|
|
270
|
+
fail("argument-missing", "--expected-item-digest is required with --write for revise");
|
|
271
|
+
}
|
|
272
|
+
const result = await reviseItem(root, project, {
|
|
273
|
+
...itemInput(options),
|
|
274
|
+
expectedItemDigest: options["expected-item-digest"],
|
|
275
|
+
}, { write: options.write });
|
|
276
|
+
const summary = options.write
|
|
277
|
+
? `Revised item into pending approval: ${result.proposedItem.id}.\n`
|
|
278
|
+
: `Preview item revision: ${result.proposedItem.id}; current digest ${result.currentItemDigest}.\n`;
|
|
279
|
+
return { exitCode: 0, stdout: jsonOrText(options, result, summary), stderr: "" };
|
|
280
|
+
}
|
|
281
|
+
if (command === "deprecate") {
|
|
282
|
+
if (options.write && options["expected-item-digest"] === undefined) {
|
|
283
|
+
fail("argument-missing", "--expected-item-digest is required with --write for deprecate");
|
|
284
|
+
}
|
|
285
|
+
const result = await deprecateItem(project, {
|
|
286
|
+
id: required(options, "id"),
|
|
287
|
+
expectedItemDigest: options["expected-item-digest"],
|
|
288
|
+
by: required(options, "by"),
|
|
289
|
+
rationale: required(options, "rationale"),
|
|
290
|
+
}, { write: options.write });
|
|
291
|
+
const summary = options.write
|
|
292
|
+
? `Deprecated item: ${result.deprecatedItem.id}.\n`
|
|
293
|
+
: `Preview item deprecation: ${result.deprecatedItem.id}; current digest ${result.currentItemDigest}.\n`;
|
|
294
|
+
return { exitCode: 0, stdout: jsonOrText(options, result, summary), stderr: "" };
|
|
295
|
+
}
|
|
296
|
+
if (command === "deprecate-source") {
|
|
297
|
+
if (options.write && options["expected-source-digest"] === undefined) {
|
|
298
|
+
fail("argument-missing", "--expected-source-digest is required with --write for deprecate-source");
|
|
299
|
+
}
|
|
300
|
+
const result = await deprecateSource(project, {
|
|
301
|
+
id: required(options, "id"),
|
|
302
|
+
expectedSourceDigest: options["expected-source-digest"],
|
|
303
|
+
by: required(options, "by"),
|
|
304
|
+
rationale: required(options, "rationale"),
|
|
305
|
+
}, { write: options.write });
|
|
306
|
+
const summary = options.write
|
|
307
|
+
? `Deprecated source: ${result.deprecatedSource.id}.\n`
|
|
308
|
+
: `Preview source deprecation: ${result.currentSource.id}; current digest ${result.currentSourceDigest}.\n`;
|
|
309
|
+
return { exitCode: 0, stdout: jsonOrText(options, result, summary), stderr: "" };
|
|
310
|
+
}
|
|
311
|
+
if (command === "discover") {
|
|
312
|
+
const proposal = validateProposal(await discoverProject(root, project.contract));
|
|
313
|
+
if (options.output && options.write) {
|
|
314
|
+
const output = validateProposalOutput(options.output);
|
|
315
|
+
const resolved = await resolveWritableInside(root, output);
|
|
316
|
+
await atomicWriteFile(resolved.absolute, prettyCanonicalJson(proposal));
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
exitCode: 0,
|
|
320
|
+
stdout: jsonOrText(options, proposal, prettyCanonicalJson(proposal)),
|
|
321
|
+
stderr: options.output && !options.write ? `Preview only; ${options.output} was not written.\n` : "",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (command === "approve") {
|
|
325
|
+
if (options.json && options["full-json"]) fail("argument-conflict", "approve accepts either --json or --full-json, not both");
|
|
326
|
+
if (Boolean(options.pending) === Boolean(options.proposal)) {
|
|
327
|
+
fail("argument-conflict", "approve requires exactly one of --proposal or --pending");
|
|
328
|
+
}
|
|
329
|
+
const approvalOptions = {
|
|
330
|
+
ids: required(options, "ids"),
|
|
331
|
+
by: required(options, "by"),
|
|
332
|
+
rationale: options.rationale,
|
|
333
|
+
write: options.write,
|
|
334
|
+
};
|
|
335
|
+
let result;
|
|
336
|
+
if (options.pending) {
|
|
337
|
+
result = await approvePendingItems(root, project, approvalOptions);
|
|
338
|
+
} else {
|
|
339
|
+
const proposalPath = normalizeRelativePath(options.proposal, { label: "proposal path" });
|
|
340
|
+
const resolved = await resolveExistingInside(root, proposalPath);
|
|
341
|
+
const proposal = await readJsonFile(resolved.absolute, "proposal");
|
|
342
|
+
result = await approveProposal(root, project, proposal, approvalOptions);
|
|
343
|
+
}
|
|
344
|
+
const summary = options.write
|
|
345
|
+
? `Approved ${result.approvedIds.join(", ")} and updated the contract.\n`
|
|
346
|
+
: `Preview approval: ${result.approvedIds.join(", ")}.\n`;
|
|
347
|
+
const output = options["full-json"] ? result : approvalReceipt(project, result, options.pending ? "pending" : "proposal");
|
|
348
|
+
return { exitCode: 0, stdout: options.json || options["full-json"] ? prettyCanonicalJson(output) : summary, stderr: "" };
|
|
349
|
+
}
|
|
350
|
+
if (command === "check") {
|
|
351
|
+
const findings = await checkProject(root, project);
|
|
352
|
+
const exitCode = checkExitCode(findings);
|
|
353
|
+
const summary = findings.length === 0
|
|
354
|
+
? "No contract, source, scope, or projection findings.\n"
|
|
355
|
+
: `${findings.length} finding(s):\n${findings.map((finding) => `- ${finding.code}: ${finding.path ?? finding.source ?? finding.item ?? finding.subject ?? "project"}`).join("\n")}\n`;
|
|
356
|
+
return { exitCode, stdout: jsonOrText(options, { findings }, summary), stderr: "" };
|
|
357
|
+
}
|
|
358
|
+
if (command === "dashboard") {
|
|
359
|
+
const model = await buildDashboardModel(root, project);
|
|
360
|
+
return {
|
|
361
|
+
exitCode: model.health.exitCode,
|
|
362
|
+
stdout: options.json ? prettyCanonicalJson(model) : renderDashboardHtml(model),
|
|
363
|
+
stderr: "",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (command === "context") {
|
|
367
|
+
const findings = blockingContextFindings(await checkProject(root, project));
|
|
368
|
+
if (findings.length > 0) fail("context-blocked", "context generation is blocked by contract or source findings", { exitCode: 1, details: { findings } });
|
|
369
|
+
const paths = required(options, "path");
|
|
370
|
+
const content = renderContextBundle(project.contract, paths, options.task, { locale: contextLocale(options) });
|
|
371
|
+
return { exitCode: 0, stdout: options.json ? prettyCanonicalJson({ content }) : content, stderr: "" };
|
|
372
|
+
}
|
|
373
|
+
if (command === "publish") {
|
|
374
|
+
const findings = blockingContextFindings(await checkProject(root, project));
|
|
375
|
+
if (findings.length > 0) fail("publish-blocked", "projection is blocked by contract or source findings", { exitCode: 1, details: { findings } });
|
|
376
|
+
const result = await publishProjection(root, project, {
|
|
377
|
+
target: required(options, "target"),
|
|
378
|
+
output: required(options, "output"),
|
|
379
|
+
paths: options.path ?? ["."],
|
|
380
|
+
write: options.write,
|
|
381
|
+
});
|
|
382
|
+
const summary = options.write ? `${result.action}: ${result.entry.path}\n` : `Preview ${result.action}: ${result.entry.path}\n\n${result.content}`;
|
|
383
|
+
return { exitCode: 0, stdout: options.json ? prettyCanonicalJson(result) : summary, stderr: "" };
|
|
384
|
+
}
|
|
385
|
+
fail("command-unknown", `unknown command: ${command}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function runCli(argv) {
|
|
389
|
+
try {
|
|
390
|
+
const { command, options } = parseArgs(argv);
|
|
391
|
+
return await runCommand(command, options);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const known = error instanceof ProjectContextError;
|
|
394
|
+
const payload = {
|
|
395
|
+
code: known ? error.code : "internal-error",
|
|
396
|
+
message: error.message,
|
|
397
|
+
...(known && Object.keys(error.details).length > 0 ? { details: error.details } : {}),
|
|
398
|
+
};
|
|
399
|
+
const wantsJson = argv.includes("--json") || argv.includes("--full-json");
|
|
400
|
+
const schemaFinding = argv[0] === "check" && known && (
|
|
401
|
+
error.code.startsWith("schema-") || error.code === "source-reference-deprecated"
|
|
402
|
+
);
|
|
403
|
+
return {
|
|
404
|
+
exitCode: schemaFinding ? 1 : known ? error.exitCode : 4,
|
|
405
|
+
stdout: "",
|
|
406
|
+
stderr: wantsJson ? prettyCanonicalJson(payload) : `${payload.code}: ${payload.message}\n`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|