skill-family-engineering-kit 0.1.2 → 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/CONTRIBUTING.md +13 -67
- package/README.md +26 -3
- package/SECURITY.md +4 -9
- package/THIRD_PARTY_NOTICES +22 -9
- package/docs/404.html +27 -0
- package/docs/architecture/index.html +84 -10
- package/docs/git-lifecycle/index.html +27 -0
- package/docs/help/index.html +27 -0
- package/docs/index.html +27 -0
- package/docs/integration/audit/failure-evidence/index.html +27 -0
- package/docs/integration/audit/independence/index.html +27 -0
- package/docs/integration/audit/index.html +29 -1
- package/docs/integration/audit/mutation-taxonomy/index.html +27 -0
- package/docs/integration/audit/version-compatibility/index.html +32 -2
- package/docs/migration/index.html +31 -4
- package/docs/public/status/index.html +858 -0
- package/docs/quickstart/index.html +27 -0
- package/docs/search/search_index.json +1 -1
- package/docs/setup/index.html +30 -2
- package/docs/sitemap.xml +4 -0
- package/package.json +5 -5
- package/src/check.mjs +211 -76
- package/src/cli.mjs +185 -4
- package/src/core-check.mjs +223 -0
- package/src/errors.mjs +16 -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 +35 -7
- package/src/report.mjs +335 -0
- package/src/skeleton.mjs +19 -79
- package/src/workspace.mjs +82 -0
package/src/host.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { canonicalJson, digestDocument, validateDocument } from "skill-family-contracts";
|
|
3
|
+
import {
|
|
4
|
+
buildAdapterClosure,
|
|
5
|
+
materializeAdapterBuild,
|
|
6
|
+
verifyAdapterBuildManifest,
|
|
7
|
+
} from "skill-family-harness-node";
|
|
8
|
+
import { invalidParamsError, kitError, KIT_ERROR_KINDS } from "./errors.mjs";
|
|
9
|
+
import { HOST_CAPABILITIES, probeTrustedVersionDriver } from "./host-drivers.mjs";
|
|
10
|
+
import { describeHost } from "./host-profiles.mjs";
|
|
11
|
+
|
|
12
|
+
const FACT_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-capability-fact.json";
|
|
13
|
+
const PROBE_RESULT_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-probe-result.json";
|
|
14
|
+
const PLAN_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-operation-plan.json";
|
|
15
|
+
|
|
16
|
+
function validateContract(document, schemaId, message) {
|
|
17
|
+
const result = validateDocument(document, { schemaId, dialect: "2020-12", policy: "strict" });
|
|
18
|
+
if (!result.valid) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, message, { errors: result.errors });
|
|
19
|
+
return result.data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function selectCategory(descriptor, pathCategoryId) {
|
|
23
|
+
const category = descriptor.pathCategories.find((candidate) => candidate.id === pathCategoryId);
|
|
24
|
+
if (!category) throw invalidParamsError(`host ${descriptor.hostId} does not declare path category ${String(pathCategoryId)}`);
|
|
25
|
+
return { id: category.id, scope: category.scope, anchor: category.anchor, relPath: category.relPath };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function probeHost({ hostId, hostsRoot, registry, executable, allowSpawn = false, timeoutMs = 5000, runner } = {}) {
|
|
29
|
+
const descriptor = await describeHost({ hostId, hostsRoot, registry });
|
|
30
|
+
if (descriptor.support === "unsupported") {
|
|
31
|
+
return validateContract({ schemaVersion: 1, kind: "skill-family.host-probe-result", hostId, support: "unsupported", reason: descriptor.unsupportedReason, facts: [] }, PROBE_RESULT_SCHEMA_ID, "unsupported probe result fails its contract");
|
|
32
|
+
}
|
|
33
|
+
const facts = await probeTrustedVersionDriver({
|
|
34
|
+
hostId,
|
|
35
|
+
driverId: descriptor.driverId,
|
|
36
|
+
capabilities: descriptor.probeCapabilities,
|
|
37
|
+
executable,
|
|
38
|
+
allowSpawn,
|
|
39
|
+
timeoutMs,
|
|
40
|
+
runner,
|
|
41
|
+
});
|
|
42
|
+
for (const fact of facts) validateContract(fact, FACT_SCHEMA_ID, "host driver emitted an invalid capability fact");
|
|
43
|
+
const names = facts.map((fact) => fact.capability);
|
|
44
|
+
if (new Set(names).size !== HOST_CAPABILITIES.length || !HOST_CAPABILITIES.every((name) => names.includes(name))) {
|
|
45
|
+
throw kitError(KIT_ERROR_KINDS.HOST_PROBE_FAILED, "host driver did not emit the exact capability fact set");
|
|
46
|
+
}
|
|
47
|
+
return validateContract({ schemaVersion: 1, kind: "skill-family.host-probe-result", hostId, support: "supported", facts }, PROBE_RESULT_SCHEMA_ID, "host probe result fails its contract");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function buildHostAdapter({ hostId, pathCategoryId, input, hostsRoot, registry } = {}) {
|
|
51
|
+
const descriptor = await describeHost({ hostId, hostsRoot, registry });
|
|
52
|
+
if (descriptor.support !== "supported") return { status: "unsupported", reason: descriptor.unsupportedReason };
|
|
53
|
+
return buildAdapterClosure({ hostId, pathCategory: selectCategory(descriptor, pathCategoryId), input });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function materializeHostBuild(options) {
|
|
57
|
+
return materializeAdapterBuild(options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateFactSet(hostId, facts) {
|
|
61
|
+
if (!Array.isArray(facts) || facts.length !== HOST_CAPABILITIES.length) throw invalidParamsError(`planHost requires exactly ${HOST_CAPABILITIES.length} explicit probe facts`);
|
|
62
|
+
for (const fact of facts) {
|
|
63
|
+
validateContract(fact, FACT_SCHEMA_ID, "planHost received an invalid probe fact");
|
|
64
|
+
if (fact.hostId !== hostId) throw invalidParamsError("probe fact hostId does not match the planned host");
|
|
65
|
+
}
|
|
66
|
+
const names = facts.map((fact) => fact.capability);
|
|
67
|
+
if (new Set(names).size !== names.length || !HOST_CAPABILITIES.every((name) => names.includes(name))) throw invalidParamsError("probe facts must cover each capability exactly once");
|
|
68
|
+
return [...facts].sort((left, right) => HOST_CAPABILITIES.indexOf(left.capability) - HOST_CAPABILITIES.indexOf(right.capability));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function planHost({ hostId, pathCategoryId, buildManifest, probeFacts, hostsRoot, registry } = {}) {
|
|
72
|
+
const descriptor = await describeHost({ hostId, hostsRoot, registry });
|
|
73
|
+
if (descriptor.support !== "supported") {
|
|
74
|
+
const base = { schemaVersion: 1, kind: "skill-family.host-operation-plan", hostId, status: "unsupported", unsupportedReason: descriptor.unsupportedReason, probeFacts: [], actions: [], writeSet: [] };
|
|
75
|
+
return validateContract({ ...base, digest: digestDocument(base) }, PLAN_SCHEMA_ID, "unsupported host plan fails its contract");
|
|
76
|
+
}
|
|
77
|
+
const category = selectCategory(descriptor, pathCategoryId);
|
|
78
|
+
const facts = validateFactSet(hostId, probeFacts);
|
|
79
|
+
const manifest = verifyAdapterBuildManifest(buildManifest, { hostId, pathCategory: category });
|
|
80
|
+
const actions = manifest.members.map((member, index) => ({ sequence: index + 1, kind: "install-file", categoryId: category.id, scope: category.scope, anchor: category.anchor, target: member.target, expect: "absent", sourceSha256: member.sha256 }));
|
|
81
|
+
const writeSet = actions.map(({ categoryId, scope, anchor, target, sourceSha256 }) => ({ categoryId, scope, anchor, target, sourceSha256 }));
|
|
82
|
+
const base = { schemaVersion: 1, kind: "skill-family.host-operation-plan", hostId, status: "planned", pathCategory: category, probeFacts: facts, actions, writeSet };
|
|
83
|
+
const plan = validateContract({ ...base, digest: digestDocument(base) }, PLAN_SCHEMA_ID, "host operation plan fails its contract");
|
|
84
|
+
assertPlanConsistency(plan);
|
|
85
|
+
return plan;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function assertPlanConsistency(planInput) {
|
|
89
|
+
const plan = validateContract(planInput, PLAN_SCHEMA_ID, "host operation plan fails its registered contract");
|
|
90
|
+
const { digest, ...base } = plan;
|
|
91
|
+
if (digestDocument(base) !== digest) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "plan digest does not match its content");
|
|
92
|
+
if (plan.status === "unsupported") return true;
|
|
93
|
+
if (plan.actions.length !== plan.writeSet.length) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "plan actions/writeSet lengths differ");
|
|
94
|
+
for (let index = 0; index < plan.actions.length; index += 1) {
|
|
95
|
+
const action = plan.actions[index];
|
|
96
|
+
const write = plan.writeSet[index];
|
|
97
|
+
const normalized = path.posix.normalize(action.target);
|
|
98
|
+
if (normalized !== action.target || normalized.startsWith("../") || path.isAbsolute(action.target)) throw invalidParamsError("plan target must be a normalized contained path");
|
|
99
|
+
if (action.sequence !== index + 1 || action.categoryId !== plan.pathCategory.id || action.scope !== plan.pathCategory.scope || action.anchor !== plan.pathCategory.anchor || !action.target.startsWith(`${plan.pathCategory.relPath}/`) || canonicalJson(write) !== canonicalJson({ categoryId: action.categoryId, scope: action.scope, anchor: action.anchor, target: action.target, sourceSha256: action.sourceSha256 })) {
|
|
100
|
+
throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "plan action, target, writeSet, category and anchor are inconsistent");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function refuseHostApply() {
|
|
107
|
+
throw invalidParamsError("host apply is not implemented in the read-only Phase D slice", { action: "apply", stableRefusal: true });
|
|
108
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ContractsError } from "skill-family-contracts";
|
|
2
2
|
import { buildHandoffDraft, HANDOFF_FIELDS, planAdoption } from "./adopt-plan.mjs";
|
|
3
|
-
import { CHECK_CLASSES, runChecks } from "./check.mjs";
|
|
3
|
+
import { CHECK_CLASSES, DOCUMENT_STATES, runChecks } from "./check.mjs";
|
|
4
|
+
import { CORE_CHECK_SECURITY_KINDS, isContainedDeclaration, runCoreCheck } from "./core-check.mjs";
|
|
4
5
|
import {
|
|
5
6
|
invalidParamsError,
|
|
6
7
|
KitError,
|
|
@@ -50,7 +51,17 @@ import {
|
|
|
50
51
|
loadLicensingProfile,
|
|
51
52
|
} from "./licensing.mjs";
|
|
52
53
|
import { runProjection, loadProjectionManifest } from "./projection.mjs";
|
|
54
|
+
import { checkReportAction, renderReportAction } from "./report.mjs";
|
|
53
55
|
import { scaffoldTarget } from "./scaffold.mjs";
|
|
56
|
+
import {
|
|
57
|
+
assertPlanConsistency,
|
|
58
|
+
buildHostAdapter,
|
|
59
|
+
materializeHostBuild,
|
|
60
|
+
planHost,
|
|
61
|
+
probeHost,
|
|
62
|
+
refuseHostApply,
|
|
63
|
+
} from "./host.mjs";
|
|
64
|
+
import { describeHost, loadHostRegistry } from "./host-profiles.mjs";
|
|
54
65
|
import {
|
|
55
66
|
describeSkeletonFiles,
|
|
56
67
|
IDENTITY_RECORD_PATH,
|
|
@@ -108,13 +119,13 @@ export const COMMAND_SIDE_EFFECTS = Object.freeze({
|
|
|
108
119
|
projection: Object.freeze({
|
|
109
120
|
summary: "投影受管生成物。",
|
|
110
121
|
sideEffect:
|
|
111
|
-
"writes only manifest-authorized managed artifacts; unauthorized, handwritten, escaping, or conflicting paths are refused with zero writes",
|
|
122
|
+
"writes only manifest-authorized managed artifacts; the report sub-action (projection report) writes only the explicitly named --out/--binding paths contained in --root (Markdown goes to stdout when no --out is given); unauthorized, handwritten, escaping, or conflicting paths are refused with zero writes",
|
|
112
123
|
exitCodes: "0 成功;2 拒绝/用法/机制错误",
|
|
113
124
|
}),
|
|
114
125
|
check: Object.freeze({
|
|
115
126
|
summary: "契约/漂移/闭包/版本/文档事实/Git 前置状态诊断。",
|
|
116
127
|
sideEffect:
|
|
117
|
-
"none — diagnosis only: never writes, never auto-fixes; git is probed read-only (one frozen status query at most)",
|
|
128
|
+
"none — diagnosis only: never writes, never auto-fixes; git is probed read-only (one frozen status query at most); the report sub-action (check report) grades one rendered report against its bound machine result and writes nothing",
|
|
118
129
|
exitCodes: "0 无发现;1 有发现;2 拒绝/用法/机制错误",
|
|
119
130
|
}),
|
|
120
131
|
});
|
|
@@ -150,10 +161,13 @@ export async function runCommand(command, options = {}) {
|
|
|
150
161
|
}
|
|
151
162
|
case "check": {
|
|
152
163
|
const output = await runChecks(options);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
// 0 clean; 2 mechanism (a selected class could not complete); 1 findings.
|
|
165
|
+
const exitCode = output.mechanism
|
|
166
|
+
? KIT_EXIT_CODES.rejected
|
|
167
|
+
: output.ok
|
|
168
|
+
? KIT_EXIT_CODES.ok
|
|
169
|
+
: KIT_EXIT_CODES.findings;
|
|
170
|
+
return { exitCode, output };
|
|
157
171
|
}
|
|
158
172
|
default: {
|
|
159
173
|
// TOP_LEVEL_COMMANDS is frozen with exactly the cases above; this
|
|
@@ -179,8 +193,14 @@ export {
|
|
|
179
193
|
HANDOFF_FIELDS,
|
|
180
194
|
runProjection,
|
|
181
195
|
loadProjectionManifest,
|
|
196
|
+
renderReportAction,
|
|
197
|
+
checkReportAction,
|
|
182
198
|
runChecks,
|
|
183
199
|
CHECK_CLASSES,
|
|
200
|
+
DOCUMENT_STATES,
|
|
201
|
+
runCoreCheck,
|
|
202
|
+
CORE_CHECK_SECURITY_KINDS,
|
|
203
|
+
isContainedDeclaration,
|
|
184
204
|
probeGitState,
|
|
185
205
|
probeGitFacts,
|
|
186
206
|
GIT_READ_ONLY_ALLOWLIST,
|
|
@@ -196,6 +216,14 @@ export {
|
|
|
196
216
|
PROJECTION_MANIFEST_PATH,
|
|
197
217
|
IDENTITY_RECORD_PATH,
|
|
198
218
|
matchAnyGlob,
|
|
219
|
+
describeHost,
|
|
220
|
+
loadHostRegistry,
|
|
221
|
+
probeHost,
|
|
222
|
+
buildHostAdapter,
|
|
223
|
+
materializeHostBuild,
|
|
224
|
+
planHost,
|
|
225
|
+
assertPlanConsistency,
|
|
226
|
+
refuseHostApply,
|
|
199
227
|
};
|
|
200
228
|
export { ContractsError };
|
|
201
229
|
|
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
|
+
}
|
package/src/skeleton.mjs
CHANGED
|
@@ -379,88 +379,28 @@ function renderDocsDeployWorkflow() {
|
|
|
379
379
|
}
|
|
380
380
|
|
|
381
381
|
function renderCheckCoreScript() {
|
|
382
|
+
// Thin generated shim. ALL mechanics — safe tree enumeration, entry-type
|
|
383
|
+
// rejection (symlink / FIFO / special entries), file-registry closed-world
|
|
384
|
+
// classification, path containment, the seven check classes, and the
|
|
385
|
+
// stable finding/exit mapping — live in the kit's single shared production
|
|
386
|
+
// entry (runCoreCheck). This file must never grow a local tree scan,
|
|
387
|
+
// entry-type classifier, glob matcher, or closed-world algorithm of its own.
|
|
382
388
|
return [
|
|
383
389
|
"#!/usr/bin/env node",
|
|
384
|
-
"// core check:
|
|
385
|
-
"//
|
|
386
|
-
"//
|
|
387
|
-
"//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
'import {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
"",
|
|
394
|
-
|
|
395
|
-
"const failures = [];",
|
|
396
|
-
"",
|
|
397
|
-
"// Load file registry for closed-world classification",
|
|
398
|
-
'const registry = JSON.parse(readFileSync(path.join(root, ".foundation/file-registry.json"), "utf8"));',
|
|
399
|
-
'const lock = JSON.parse(readFileSync(path.join(root, "skill-family.managed-file-lock.json"), "utf8"));',
|
|
400
|
-
"",
|
|
401
|
-
"// Build managed file set from file registry (projenManaged + kitManaged)",
|
|
402
|
-
"const managedSet = new Set([",
|
|
403
|
-
" ...registry.classes.projenManaged.files,",
|
|
404
|
-
" ...registry.classes.kitManaged.files,",
|
|
405
|
-
"]);",
|
|
406
|
-
"",
|
|
407
|
-
"// Build handwritten patterns from registry",
|
|
408
|
-
"const handwrittenPatterns = registry.classes.handwritten.entries.filter((e) => typeof e === \"string\");",
|
|
409
|
-
"",
|
|
410
|
-
"// Artifact patterns from registry",
|
|
411
|
-
"const artifactPatterns = registry.classes.artifacts.patterns.filter((e) => typeof e === \"string\");",
|
|
412
|
-
"",
|
|
413
|
-
"// Pre-flight: verify managed files are regular files (catches symlinks, FIFOs, etc.)",
|
|
414
|
-
"// This prevents runChecks from hanging on FIFOs and provides clear error messages.",
|
|
415
|
-
"for (const relPath of managedSet) {",
|
|
416
|
-
" let st;",
|
|
417
|
-
" try {",
|
|
418
|
-
" st = await lstat(path.resolve(root, relPath));",
|
|
419
|
-
" } catch {",
|
|
420
|
-
' failures.push(`managed file missing: ${relPath}`);',
|
|
421
|
-
" continue;",
|
|
422
|
-
" }",
|
|
423
|
-
" if (st.isSymbolicLink()) {",
|
|
424
|
-
' failures.push(`managed file is a symlink: ${relPath}`);',
|
|
425
|
-
" } else if (!st.isFile()) {",
|
|
426
|
-
' failures.push(`managed file is not a regular file: ${relPath}`);',
|
|
427
|
-
" }",
|
|
428
|
-
"}",
|
|
429
|
-
"",
|
|
430
|
-
"// Closed-world file classification: every tracked file must be classified",
|
|
431
|
-
"const OPAQUE = new Set([\".git\", \"node_modules\"]);",
|
|
432
|
-
"async function walk(dir, base) {",
|
|
433
|
-
" const dirents = await readdir(dir, { withFileTypes: true });",
|
|
434
|
-
" for (const dirent of dirents) {",
|
|
435
|
-
" const rel = base ? `${base}/${dirent.name}` : dirent.name;",
|
|
436
|
-
" if (dirent.isDirectory() && !dirent.isSymbolicLink()) {",
|
|
437
|
-
" if (!OPAQUE.has(dirent.name)) await walk(path.join(dir, dirent.name), rel);",
|
|
438
|
-
" continue;",
|
|
439
|
-
" }",
|
|
440
|
-
" if (!dirent.isFile()) continue;",
|
|
441
|
-
" const isManaged = managedSet.has(rel);",
|
|
442
|
-
" const isHandwritten = matchAnyGlob(handwrittenPatterns, rel);",
|
|
443
|
-
" const isArtifact = matchAnyGlob(artifactPatterns, rel);",
|
|
444
|
-
" if (!isManaged && !isHandwritten && !isArtifact) {",
|
|
445
|
-
' failures.push(`unregistered file: ${rel}`);',
|
|
446
|
-
" }",
|
|
447
|
-
" }",
|
|
448
|
-
"}",
|
|
449
|
-
"await walk(root, \"\");",
|
|
450
|
-
"",
|
|
451
|
-
"// Run the shared production checks (schema, drift, closure, version, docs, git, identity)",
|
|
452
|
-
"if (failures.length === 0) {",
|
|
453
|
-
" const report = await runChecks({ root, allowGitSpawn: true });",
|
|
454
|
-
" for (const f of report.findings) {",
|
|
455
|
-
' failures.push(`${f.class}: ${f.message}`);',
|
|
456
|
-
" }",
|
|
457
|
-
"}",
|
|
458
|
-
"",
|
|
459
|
-
"if (failures.length > 0) {",
|
|
460
|
-
' for (const f of failures) console.error(`[check-core] ${f}`);',
|
|
461
|
-
" process.exit(failures.some((f) => f.includes(\"symlink\") || f.includes(\"not a regular\") || f.includes(\"traversal\") || f.includes(\"escape\")) ? 2 : 1);",
|
|
390
|
+
"// core check: generated shim. It only imports the Foundation's single",
|
|
391
|
+
"// shared production check entry (runCoreCheck), passes the target root,",
|
|
392
|
+
"// prints the structured findings, and maps the stable exit code.",
|
|
393
|
+
"// No local algorithm duplication: safe tree enumeration, symlink/FIFO/",
|
|
394
|
+
"// special-entry rejection, closed-world classification, path containment,",
|
|
395
|
+
"// and the seven check classes all live in skill-family-engineering-kit.",
|
|
396
|
+
'import { runCoreCheck } from "skill-family-engineering-kit";',
|
|
397
|
+
"",
|
|
398
|
+
"const report = await runCoreCheck({ root: process.cwd(), allowGitSpawn: true });",
|
|
399
|
+
"for (const f of report.findings) {",
|
|
400
|
+
' console.error(`[check-core] ${f.class}: ${f.kind}: ${f.message}`);',
|
|
462
401
|
"}",
|
|
463
|
-
|
|
402
|
+
"if (!report.ok) process.exit(report.exitCode);",
|
|
403
|
+
'console.log("[check-core] OK");',
|
|
464
404
|
"",
|
|
465
405
|
].join("\n");
|
|
466
406
|
}
|