skill-family-engineering-kit 0.1.3 → 0.2.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/README.md +26 -3
- package/docs/architecture/index.html +56 -9
- package/docs/integration/audit/index.html +2 -1
- package/docs/integration/audit/version-compatibility/index.html +5 -2
- package/docs/public/status/index.html +8 -8
- package/docs/search/search_index.json +1 -1
- package/docs/setup/index.html +3 -2
- package/package.json +3 -3
- package/src/cli.mjs +185 -4
- package/src/errors.mjs +8 -0
- package/src/host-drivers.mjs +24 -0
- package/src/host-profiles.mjs +51 -0
- package/src/host.mjs +108 -0
- package/src/index.mjs +22 -2
- package/src/report.mjs +335 -0
package/src/report.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { lstat, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
buildBinding,
|
|
6
|
+
checkReport,
|
|
7
|
+
computeModelDigest,
|
|
8
|
+
computeResultDigest,
|
|
9
|
+
digestReport,
|
|
10
|
+
readFileContained,
|
|
11
|
+
renderReportMarkdown,
|
|
12
|
+
resolveContained,
|
|
13
|
+
validateReportModel,
|
|
14
|
+
writeFileAtomic,
|
|
15
|
+
} from "skill-family-harness-node";
|
|
16
|
+
import { ContractsError } from "skill-family-contracts";
|
|
17
|
+
import { invalidParamsError, kitError, KIT_ERROR_KINDS } from "./errors.mjs";
|
|
18
|
+
import { resolveTargetRoot } from "./workspace.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Report sub-actions of the existing kit commands (FND-ADR-005 / FND-DES-004).
|
|
22
|
+
*
|
|
23
|
+
* These are positional sub-actions, not new top-level commands: the kit keeps
|
|
24
|
+
* exactly four commands.
|
|
25
|
+
*
|
|
26
|
+
* projection report — render one validated report model to neutral Markdown
|
|
27
|
+
* check report — grade one rendered report against its model and source result
|
|
28
|
+
*
|
|
29
|
+
* Write discipline: rendering writes nothing by default (Markdown goes to
|
|
30
|
+
* stdout); a file is written only when explicit --out/--binding paths are
|
|
31
|
+
* given, and every such path is contained inside --root and written
|
|
32
|
+
* atomically. `check report` never writes. Hard failures and advisory style
|
|
33
|
+
* warnings are separate outputs: style warnings never block a
|
|
34
|
+
* machine-correct report, and hard failures never exit 0.
|
|
35
|
+
*
|
|
36
|
+
* Actions return { status: "ok" | "findings" | "rejected", output }; the CLI
|
|
37
|
+
* maps status onto KIT_EXIT_CODES (0/1/2). Throws carry registered SFC codes.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
async function readReportJson(rootAbs, relPath, role) {
|
|
41
|
+
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
42
|
+
throw invalidParamsError(`${role} path must be a non-empty relative path`, { flag: `--${role}` });
|
|
43
|
+
}
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = await readFileContained(rootAbs, relPath, { encoding: "utf8" });
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
throw kitError(
|
|
49
|
+
KIT_ERROR_KINDS.REPORT_INPUT_MISSING,
|
|
50
|
+
`report ${role} is missing or unreadable: ${relPath}`,
|
|
51
|
+
{ path: relPath, causeKind: cause && cause.details ? cause.details.kind : undefined },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
} catch {
|
|
57
|
+
throw kitError(
|
|
58
|
+
KIT_ERROR_KINDS.CONTRACT_PARSE_FAILED,
|
|
59
|
+
`report ${role} is not valid JSON: ${relPath}`,
|
|
60
|
+
{ path: relPath },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readReportText(rootAbs, relPath, role) {
|
|
66
|
+
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
67
|
+
throw invalidParamsError(`${role} path must be a non-empty relative path`, { flag: `--${role}` });
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return await readFileContained(rootAbs, relPath, { encoding: "utf8" });
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
throw kitError(
|
|
73
|
+
KIT_ERROR_KINDS.REPORT_INPUT_MISSING,
|
|
74
|
+
`report ${role} is missing or unreadable: ${relPath}`,
|
|
75
|
+
{ path: relPath, causeKind: cause && cause.details ? cause.details.kind : undefined },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function canonicalCandidate(absPath) {
|
|
81
|
+
try {
|
|
82
|
+
return await realpath(absPath);
|
|
83
|
+
} catch {
|
|
84
|
+
const missing = [path.basename(absPath)];
|
|
85
|
+
let ancestor = path.dirname(absPath);
|
|
86
|
+
while (true) {
|
|
87
|
+
try {
|
|
88
|
+
return path.join(await realpath(ancestor), ...missing);
|
|
89
|
+
} catch {
|
|
90
|
+
const parent = path.dirname(ancestor);
|
|
91
|
+
if (parent === ancestor) return absPath;
|
|
92
|
+
missing.unshift(path.basename(ancestor));
|
|
93
|
+
ancestor = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function describeReportPath(rootAbs, relPath, role, { output = false } = {}) {
|
|
100
|
+
const absPath = await resolveContained(rootAbs, relPath);
|
|
101
|
+
let entry = null;
|
|
102
|
+
try {
|
|
103
|
+
entry = await lstat(absPath);
|
|
104
|
+
} catch {
|
|
105
|
+
entry = null;
|
|
106
|
+
}
|
|
107
|
+
if (output && entry?.isSymbolicLink()) {
|
|
108
|
+
throw kitError(
|
|
109
|
+
KIT_ERROR_KINDS.REPORT_PATH_CONFLICT,
|
|
110
|
+
`report ${role} must not be a symbolic link`,
|
|
111
|
+
{ role, path: relPath },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (output && entry && !entry.isFile()) {
|
|
115
|
+
throw kitError(
|
|
116
|
+
KIT_ERROR_KINDS.REPORT_PATH_CONFLICT,
|
|
117
|
+
`report ${role} must be absent or a regular file`,
|
|
118
|
+
{ role, path: relPath },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
let identity = null;
|
|
122
|
+
if (entry) {
|
|
123
|
+
try {
|
|
124
|
+
const inspected = await stat(absPath);
|
|
125
|
+
identity = `${inspected.dev}:${inspected.ino}`;
|
|
126
|
+
} catch {
|
|
127
|
+
identity = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
role,
|
|
132
|
+
relPath,
|
|
133
|
+
absPath,
|
|
134
|
+
canonicalPath: await canonicalCandidate(absPath),
|
|
135
|
+
identity,
|
|
136
|
+
existed: entry !== null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function samePath(left, right) {
|
|
141
|
+
return left.canonicalPath === right.canonicalPath ||
|
|
142
|
+
(left.identity !== null && left.identity === right.identity);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function stageReportOutputs(rootAbs, options, markdown, bindingDocument) {
|
|
146
|
+
const inputs = [
|
|
147
|
+
await describeReportPath(rootAbs, options.model, "model"),
|
|
148
|
+
await describeReportPath(rootAbs, options.result, "result"),
|
|
149
|
+
];
|
|
150
|
+
const outputs = [
|
|
151
|
+
await describeReportPath(rootAbs, options.out, "out", { output: true }),
|
|
152
|
+
await describeReportPath(rootAbs, options.binding, "binding", { output: true }),
|
|
153
|
+
];
|
|
154
|
+
for (const [index, output] of outputs.entries()) {
|
|
155
|
+
for (const other of [...inputs, ...outputs.slice(0, index)]) {
|
|
156
|
+
if (samePath(output, other)) {
|
|
157
|
+
throw kitError(
|
|
158
|
+
KIT_ERROR_KINDS.REPORT_PATH_CONFLICT,
|
|
159
|
+
`report ${output.role} aliases ${other.role}; inputs and outputs must be distinct`,
|
|
160
|
+
{ role: output.role, path: output.relPath, conflictsWith: other.role },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const contents = [markdown, `${JSON.stringify(bindingDocument, null, 2)}\n`];
|
|
166
|
+
return Promise.all(outputs.map(async (output, index) => ({
|
|
167
|
+
...output,
|
|
168
|
+
content: contents[index],
|
|
169
|
+
priorBytes: output.existed ? await readFile(output.absPath) : null,
|
|
170
|
+
})));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function rollbackReportOutputs(rootAbs, written, rollbackWrite = writeFileAtomic) {
|
|
174
|
+
const failures = [];
|
|
175
|
+
for (const output of [...written].reverse()) {
|
|
176
|
+
try {
|
|
177
|
+
if (output.priorBytes === null) {
|
|
178
|
+
await rm(output.absPath, { force: true });
|
|
179
|
+
} else {
|
|
180
|
+
await rollbackWrite(rootAbs, output.relPath, output.priorBytes);
|
|
181
|
+
}
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
failures.push({ role: output.role, message: cause?.message ?? String(cause) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return failures;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function commitReportOutputs(rootAbs, staged, fileOps = {}) {
|
|
190
|
+
const commitWrite = fileOps.commitWrite ?? writeFileAtomic;
|
|
191
|
+
const rollbackWrite = fileOps.rollbackWrite ?? writeFileAtomic;
|
|
192
|
+
const written = [];
|
|
193
|
+
try {
|
|
194
|
+
for (const output of staged) {
|
|
195
|
+
await commitWrite(rootAbs, output.relPath, output.content);
|
|
196
|
+
written.push(output);
|
|
197
|
+
}
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
const rollbackFailures = await rollbackReportOutputs(rootAbs, written, rollbackWrite);
|
|
200
|
+
if (rollbackFailures.length === 0 && cause instanceof ContractsError) throw cause;
|
|
201
|
+
throw kitError(
|
|
202
|
+
KIT_ERROR_KINDS.REPORT_WRITE_FAILED,
|
|
203
|
+
"report output group commit failed; committed outputs were rolled back",
|
|
204
|
+
{
|
|
205
|
+
causeCode: cause?.code,
|
|
206
|
+
causeKind: cause?.details?.kind,
|
|
207
|
+
causeMessage: cause?.message ?? String(cause),
|
|
208
|
+
rollbackFailures,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `projection report`: deterministic render of one caller-authored report model.
|
|
216
|
+
*
|
|
217
|
+
* Options: root, model (required), result (required), out, binding.
|
|
218
|
+
* Without --out the Markdown goes to stdout and nothing is written; with
|
|
219
|
+
* --out, --binding is mandatory and only those explicit contained paths are
|
|
220
|
+
* written, atomically. A missing report element rejects with an SFC3002 list
|
|
221
|
+
* and writes nothing (no half report).
|
|
222
|
+
*/
|
|
223
|
+
export async function renderReportAction(options = {}) {
|
|
224
|
+
const rootAbs = await resolveTargetRoot(options.root ?? ".");
|
|
225
|
+
if (!options.model) {
|
|
226
|
+
throw invalidParamsError("projection report: --model <path> is required", { flag: "--model" });
|
|
227
|
+
}
|
|
228
|
+
if (!options.result) {
|
|
229
|
+
throw invalidParamsError("projection report: --result <path> is required", { flag: "--result" });
|
|
230
|
+
}
|
|
231
|
+
if (options.out && !options.binding) {
|
|
232
|
+
return {
|
|
233
|
+
status: "rejected",
|
|
234
|
+
output: {
|
|
235
|
+
kind: "skill-family.kit.report-render",
|
|
236
|
+
ok: false,
|
|
237
|
+
errors: [{
|
|
238
|
+
code: "SFC3002",
|
|
239
|
+
message: "missing report element: binding",
|
|
240
|
+
details: { element: "binding" },
|
|
241
|
+
}],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (!options.out && options.binding) {
|
|
246
|
+
throw invalidParamsError("projection report: --binding requires --out", { flag: "--binding" });
|
|
247
|
+
}
|
|
248
|
+
const reportModel = await readReportJson(rootAbs, options.model, "model");
|
|
249
|
+
const resultDocument = await readReportJson(rootAbs, options.result, "result");
|
|
250
|
+
const validated = validateReportModel(reportModel, { resultDocument });
|
|
251
|
+
if (!validated.ok) {
|
|
252
|
+
return {
|
|
253
|
+
status: "rejected",
|
|
254
|
+
output: {
|
|
255
|
+
kind: "skill-family.kit.report-render",
|
|
256
|
+
ok: false,
|
|
257
|
+
errors: validated.hardFailures,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const markdown = renderReportMarkdown(reportModel);
|
|
263
|
+
const summary = {
|
|
264
|
+
kind: "skill-family.kit.report-render",
|
|
265
|
+
ok: true,
|
|
266
|
+
runId: reportModel.identity.runId,
|
|
267
|
+
locale: reportModel.identity.locale,
|
|
268
|
+
modelDigest: computeModelDigest(reportModel),
|
|
269
|
+
resultDigest: computeResultDigest(resultDocument),
|
|
270
|
+
reportDigest: digestReport(markdown),
|
|
271
|
+
bytes: Buffer.byteLength(markdown, "utf8"),
|
|
272
|
+
writes: [],
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (options.out) {
|
|
276
|
+
const bindingDocument = buildBinding(reportModel, resultDocument, markdown);
|
|
277
|
+
const staged = await stageReportOutputs(rootAbs, options, markdown, bindingDocument);
|
|
278
|
+
await commitReportOutputs(rootAbs, staged, options.fileOps);
|
|
279
|
+
summary.writes.push({ path: options.out, role: "report" });
|
|
280
|
+
summary.writes.push({ path: options.binding, role: "binding" });
|
|
281
|
+
} else {
|
|
282
|
+
// stdout mode: the Markdown itself is the only stdout payload.
|
|
283
|
+
process.stdout.write(markdown);
|
|
284
|
+
}
|
|
285
|
+
return { status: "ok", output: options.out ? summary : undefined };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `check report`: graded diagnosis of one rendered report.
|
|
290
|
+
*
|
|
291
|
+
* Options: root, report (required), model (required), result (required), binding.
|
|
292
|
+
* Read-only, never writes. Hard failures (SFC3001/SFC3002/SFC3003)
|
|
293
|
+
* are findings (exit 1); advisory style warnings are reported alongside but
|
|
294
|
+
* never change the verdict; usage/mechanism problems throw (exit 2).
|
|
295
|
+
*/
|
|
296
|
+
export async function checkReportAction(options = {}) {
|
|
297
|
+
const rootAbs = await resolveTargetRoot(options.root ?? ".");
|
|
298
|
+
if (!options.report) {
|
|
299
|
+
throw invalidParamsError("check report: --report <path> is required", { flag: "--report" });
|
|
300
|
+
}
|
|
301
|
+
if (!options.model) {
|
|
302
|
+
throw invalidParamsError("check report: --model <path> is required", { flag: "--model" });
|
|
303
|
+
}
|
|
304
|
+
if (!options.result) {
|
|
305
|
+
throw invalidParamsError("check report: --result <path> is required", { flag: "--result" });
|
|
306
|
+
}
|
|
307
|
+
const reportMarkdown = await readReportText(rootAbs, options.report, "report");
|
|
308
|
+
const reportModel = await readReportJson(rootAbs, options.model, "model");
|
|
309
|
+
const resultDocument = await readReportJson(rootAbs, options.result, "result");
|
|
310
|
+
const binding = options.binding
|
|
311
|
+
? await readReportJson(rootAbs, options.binding, "binding")
|
|
312
|
+
: undefined;
|
|
313
|
+
|
|
314
|
+
const graded = checkReport({
|
|
315
|
+
reportMarkdown,
|
|
316
|
+
reportModel,
|
|
317
|
+
resultDocument,
|
|
318
|
+
binding,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
status: graded.ok ? "ok" : "findings",
|
|
323
|
+
output: {
|
|
324
|
+
kind: "skill-family.kit.report-check",
|
|
325
|
+
ok: graded.ok,
|
|
326
|
+
hardFailures: graded.hardFailures,
|
|
327
|
+
styleWarnings: graded.styleWarnings,
|
|
328
|
+
digests: {
|
|
329
|
+
model: computeModelDigest(reportModel),
|
|
330
|
+
report: digestReport(reportMarkdown),
|
|
331
|
+
result: computeResultDigest(resultDocument),
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|