full-reverse 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +115 -2
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/VERIFICATION.md +156 -0
- package/bin/full-reverse.mjs +253 -0
- package/package.json +30 -4
- package/skills/full-reverse/LICENSE +21 -0
- package/skills/full-reverse/README.md +116 -0
- package/skills/full-reverse/SKILL.md +173 -0
- package/skills/full-reverse/THIRD_PARTY_NOTICES.md +63 -0
- package/skills/full-reverse/VERIFICATION.md +156 -0
- package/skills/full-reverse/agents/openai.yaml +3 -0
- package/skills/full-reverse/package-lock.json +75 -0
- package/skills/full-reverse/package.json +21 -0
- package/skills/full-reverse/references/investigation-contract.md +171 -0
- package/skills/full-reverse/references/workflow-example.md +148 -0
- package/skills/full-reverse/references/workspace-layout.md +83 -0
- package/skills/full-reverse/schemas/claims.schema.json +123 -0
- package/skills/full-reverse/schemas/common.schema.json +305 -0
- package/skills/full-reverse/schemas/evidence-index.schema.json +135 -0
- package/skills/full-reverse/schemas/examples/claims.example.json +355 -0
- package/skills/full-reverse/schemas/examples/evidence-index.example.json +254 -0
- package/skills/full-reverse/schemas/examples/external-evidence.example.json +33 -0
- package/skills/full-reverse/schemas/examples/inventory.example.json +106 -0
- package/skills/full-reverse/schemas/examples/investigation.example.json +159 -0
- package/skills/full-reverse/schemas/examples/review.example.json +75 -0
- package/skills/full-reverse/schemas/external-evidence.schema.json +68 -0
- package/skills/full-reverse/schemas/inventory.schema.json +91 -0
- package/skills/full-reverse/schemas/investigation.schema.json +142 -0
- package/skills/full-reverse/schemas/review.schema.json +78 -0
- package/skills/full-reverse/scripts/lib/cli.mjs +254 -0
- package/skills/full-reverse/scripts/lib/jsonscan.mjs +285 -0
- package/skills/full-reverse/scripts/lib/rea.mjs +496 -0
- package/skills/full-reverse/scripts/lib/schemas.mjs +79 -0
- package/skills/full-reverse/scripts/lib/scope.mjs +59 -0
- package/skills/full-reverse/scripts/lib/selectors.mjs +256 -0
- package/skills/full-reverse/scripts/lib/util.mjs +333 -0
- package/skills/full-reverse/scripts/lib/validate.mjs +1133 -0
- package/skills/full-reverse/scripts/lib/workspace.mjs +861 -0
- package/skills/full-reverse/scripts/validate-investigation.mjs +28 -0
- package/skills/full-reverse-inspect/SKILL.md +119 -0
- package/skills/full-reverse-inspect/agents/openai.yaml +5 -0
- package/skills/full-reverse-inspect/references/android.md +84 -0
- package/skills/full-reverse-inspect/references/evidence-workflows.md +170 -0
- package/skills/full-reverse-inspect/references/javascript-electron.md +146 -0
- package/skills/full-reverse-inspect/references/managed.md +59 -0
- package/skills/full-reverse-inspect/references/native.md +290 -0
- package/skills/full-reverse-inspect/references/packages-and-specialized-artifacts.md +93 -0
- package/skills/full-reverse-inspect/references/recorded-evidence.md +118 -0
- package/skills/full-reverse-inventory/SKILL.md +134 -0
- package/skills/full-reverse-inventory/agents/openai.yaml +5 -0
- package/skills/full-reverse-model/SKILL.md +148 -0
- package/skills/full-reverse-model/agents/openai.yaml +5 -0
- package/skills/full-reverse-model/references/claim-classification.md +85 -0
- package/skills/full-reverse-model/references/specification-template.md +199 -0
- package/skills/full-reverse-observe/SKILL.md +97 -0
- package/skills/full-reverse-observe/agents/openai.yaml +5 -0
- package/skills/full-reverse-observe/references/runtime-scope.md +301 -0
- package/skills/full-reverse-verify/SKILL.md +111 -0
- package/skills/full-reverse-verify/agents/openai.yaml +5 -0
- package/skills/full-reverse-verify/references/review-checklist.md +99 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://full-reverse.local/schemas/review.schema.json",
|
|
4
|
+
"title": "Full Reverse review (review.json)",
|
|
5
|
+
"description": "Written only by the verify specialist. Valid only for the exact digests of the inventory, evidence index, claims and specifications listed in reviewed_inputs, and for the exact investigation scope (objective, non-goals, question text/feature/build scope and build identities) summarized by reviewed_inputs.scope; any change invalidates closure.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema_version", "investigation_id", "reviewed_at", "reviewer", "reviewed_inputs", "validator_report", "dispositions", "findings", "verdict"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": { "const": "full-reverse.review/1" },
|
|
11
|
+
"investigation_id": { "$ref": "common.schema.json#/$defs/investigationId" },
|
|
12
|
+
"reviewed_at": { "$ref": "common.schema.json#/$defs/timestamp" },
|
|
13
|
+
"reviewer": { "$ref": "common.schema.json#/$defs/nonEmptyString" },
|
|
14
|
+
"reviewed_inputs": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"required": ["inventory", "evidence_index", "claims", "specs", "scope"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"inventory": { "oneOf": [{ "$ref": "common.schema.json#/$defs/fileDigest" }, { "type": "null" }] },
|
|
20
|
+
"evidence_index": { "$ref": "common.schema.json#/$defs/fileDigest" },
|
|
21
|
+
"claims": { "$ref": "common.schema.json#/$defs/fileDigest" },
|
|
22
|
+
"specs": { "type": "array", "items": { "$ref": "common.schema.json#/$defs/fileDigest" } },
|
|
23
|
+
"scope": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"required": ["sha256"],
|
|
27
|
+
"description": "Canonical scope digest from `digests` (reviewed_inputs_template.scope). Covers objective, non_goals, question ids/text/feature/build_ids and build identities; excludes question status/reason/claim_ids, steps, authorizations, phase and lifecycle so lifecycle updates do not invalidate the review.",
|
|
28
|
+
"properties": { "sha256": { "$ref": "common.schema.json#/$defs/sha256" } }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"validator_report": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"required": ["mode", "outcome", "failed", "unverified", "note"],
|
|
36
|
+
"description": "Summary of the deterministic validate run the reviewer relied on. Informational; closure always reruns the validator.",
|
|
37
|
+
"properties": {
|
|
38
|
+
"mode": { "enum": ["progress", "resume", "closure"] },
|
|
39
|
+
"outcome": { "enum": ["pass", "fail", "blocked"] },
|
|
40
|
+
"failed": { "type": "integer", "minimum": 0 },
|
|
41
|
+
"unverified": { "type": "integer", "minimum": 0 },
|
|
42
|
+
"note": { "type": "string" }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dispositions": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"additionalProperties": false,
|
|
50
|
+
"required": ["question_id", "disposition", "reason", "claim_ids"],
|
|
51
|
+
"properties": {
|
|
52
|
+
"question_id": { "$ref": "common.schema.json#/$defs/questionId" },
|
|
53
|
+
"disposition": { "enum": ["answered", "partial", "unresolved"] },
|
|
54
|
+
"reason": { "$ref": "common.schema.json#/$defs/nonEmptyString" },
|
|
55
|
+
"claim_ids": { "type": "array", "items": { "$ref": "common.schema.json#/$defs/claimId" } }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"findings": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"items": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": false,
|
|
64
|
+
"required": ["finding_id", "severity", "category", "claim_id", "text", "status", "route_to"],
|
|
65
|
+
"properties": {
|
|
66
|
+
"finding_id": { "$ref": "common.schema.json#/$defs/findingId" },
|
|
67
|
+
"severity": { "enum": ["blocking", "major", "minor", "note"] },
|
|
68
|
+
"category": { "enum": ["structural", "semantic-overclaim", "coverage", "contradiction", "other"] },
|
|
69
|
+
"claim_id": { "oneOf": [{ "$ref": "common.schema.json#/$defs/claimId" }, { "type": "null" }] },
|
|
70
|
+
"text": { "$ref": "common.schema.json#/$defs/nonEmptyString" },
|
|
71
|
+
"status": { "enum": ["open", "resolved", "accepted-limitation"] },
|
|
72
|
+
"route_to": { "enum": ["inspect", "observe", "model", null] }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"verdict": { "enum": ["approved", "approved-with-limits", "changes-requested", "blocked"] }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Command dispatcher. Every command prints one JSON object on stdout.
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DEFAULT_MAX_PARSE_BYTES, inspectFile, locateValues, pointerSegments, resolvePointer } from "./rea.mjs";
|
|
5
|
+
import { scanFile } from "./jsonscan.mjs";
|
|
6
|
+
import { PINNED_AJV, SCHEMA_NAMES, BUNDLE_DIR, checkNode, loadValidators, validateSchema } from "./schemas.mjs";
|
|
7
|
+
import { validateWorkspace } from "./validate.mjs";
|
|
8
|
+
import {
|
|
9
|
+
describeBuild,
|
|
10
|
+
digests,
|
|
11
|
+
initWorkspace,
|
|
12
|
+
publishEvidence,
|
|
13
|
+
publishFile,
|
|
14
|
+
reconcile,
|
|
15
|
+
registerExternal,
|
|
16
|
+
INDEX_PATH,
|
|
17
|
+
} from "./workspace.mjs";
|
|
18
|
+
import { CliError, EXIT, hashFile, openWorkspace, parseArgs, readJson, resolveInWorkspace, toInt, usage } from "./util.mjs";
|
|
19
|
+
|
|
20
|
+
const BOOLEANS = ["interrupted", "verbose", "check-targets", "no-hash", "help"];
|
|
21
|
+
const MULTI = ["path"];
|
|
22
|
+
|
|
23
|
+
export const USAGE = `validate-investigation.mjs <command> [flags] (stdout: one JSON object; exit 0 pass, 1 failed, 2 usage, 3 prerequisite, 4 blocked, 5 I/O)
|
|
24
|
+
setup-check
|
|
25
|
+
hash PATH...
|
|
26
|
+
inspect-file PATH [--max-parse-bytes N]
|
|
27
|
+
init-workspace --workspace D [--investigation-id inv_x] [--objective TEXT]
|
|
28
|
+
describe-build --build-id bld_x (--file PATH | --workspace D --entry ei_x [--max-parse-bytes N]) [--name N] [--kind K] [--parent bld_y]
|
|
29
|
+
(--entry re-authenticates the saved record: file hash, entry identity, evidence_ids and target digests; the index alone is never trusted)
|
|
30
|
+
publish-evidence --workspace D --staged FILE [--stderr-file F] [--exit-code N] [--interrupted] [--command-json '["rea",...]'|@FILE]
|
|
31
|
+
[--build-id bld_x] [--step-id step_x] [--source cli|session|export|unknown] [--name SLUG] [--note T]
|
|
32
|
+
[--producer-name N] [--producer-version V] (fill producer metadata only for non-REA output; a value that contradicts what a REA record
|
|
33
|
+
declares about itself is refused with exit 2; an exact retry may use another --name and reuses the existing file)
|
|
34
|
+
register-external --workspace D --staged FILE --metadata META.json [--name SLUG] [--step-id step_x]
|
|
35
|
+
publish --workspace D --target REL --source FILE|- [--schema NAME]
|
|
36
|
+
digests --workspace D [--path REL ...]
|
|
37
|
+
reconcile --workspace D [--no-hash]
|
|
38
|
+
validate --workspace D [--mode progress|resume|closure] [--max-parse-bytes N] [--check-targets] [--verbose]
|
|
39
|
+
check-schema --schema NAME --file FILE
|
|
40
|
+
resolve --workspace D --file REL [--record ev_...] --pointer P [--max-bytes N]
|
|
41
|
+
locate --workspace D --file REL [--record ev_...] --value JSON [--under P] [--limit N]`;
|
|
42
|
+
|
|
43
|
+
async function setupCheck() {
|
|
44
|
+
checkNode();
|
|
45
|
+
const { ajvVersion } = await loadValidators();
|
|
46
|
+
return { outcome: "pass", node: process.versions.node, ajv: ajvVersion, ajv_pinned: PINNED_AJV, bundle: BUNDLE_DIR, schemas: SCHEMA_NAMES };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function hashCommand(positionals) {
|
|
50
|
+
if (positionals.length === 0) throw usage("hash needs at least one PATH");
|
|
51
|
+
const files = [];
|
|
52
|
+
for (const file of positionals) {
|
|
53
|
+
const abs = path.resolve(file);
|
|
54
|
+
const stat = await fsp.stat(abs).catch(() => null);
|
|
55
|
+
if (!stat) throw new CliError(`no such file: ${abs}`, { code: "missing" });
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
throw new CliError(`${abs} is a directory; SHA-256 here covers single files only. For a directory/app target, save REA's own analysis record and use its subject digest (describe-build --entry).`, { code: "is_directory" });
|
|
58
|
+
}
|
|
59
|
+
if (!stat.isFile()) {
|
|
60
|
+
throw new CliError(`${abs} is not a regular file (FIFO, device or socket); it was not opened`, { code: "not_regular_file" });
|
|
61
|
+
}
|
|
62
|
+
files.push({ path: abs, ...(await hashFile(abs)) });
|
|
63
|
+
}
|
|
64
|
+
return { files };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function inspectCommand(positionals, flags) {
|
|
68
|
+
if (positionals.length !== 1) throw usage("inspect-file needs exactly one PATH");
|
|
69
|
+
const abs = path.resolve(positionals[0]);
|
|
70
|
+
const stat = await fsp.stat(abs).catch(() => null);
|
|
71
|
+
if (!stat?.isFile()) throw new CliError(`not a regular file: ${abs}`, { code: "not_regular_file" });
|
|
72
|
+
const maxParseBytes = toInt(flags["max-parse-bytes"], "max-parse-bytes", { min: 1 }) ?? DEFAULT_MAX_PARSE_BYTES;
|
|
73
|
+
const result = await inspectFile(abs, { maxParseBytes });
|
|
74
|
+
const { kind_hint: kindHint, ...rest } = result;
|
|
75
|
+
return { path: abs, would_index_as: kindHint ?? (result.content_type === "binary" ? "external-or-diagnostic" : "diagnostic"), max_parse_bytes: maxParseBytes, ...rest };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function locateRecord(flags) {
|
|
79
|
+
const ws = await openWorkspace(flags.workspace);
|
|
80
|
+
if (!flags.file) throw usage("--file REL is required");
|
|
81
|
+
const index = await readJson((await resolveInWorkspace(ws.abs, INDEX_PATH, { allowMissing: false })).abs);
|
|
82
|
+
const entry = index.entries.find((e) => e.file === flags.file);
|
|
83
|
+
if (!entry || !["canonical-record", "canonical-bundle", "derived-view"].includes(entry.kind)) {
|
|
84
|
+
throw new CliError(`${flags.file} is not an indexed canonical evidence file`, { code: "not_indexed" });
|
|
85
|
+
}
|
|
86
|
+
let recordPointer = "";
|
|
87
|
+
if (flags.record) {
|
|
88
|
+
const rec = entry.records.find((x) => x.evidence_id === flags.record);
|
|
89
|
+
if (!rec) {
|
|
90
|
+
if (entry.records_truncated) {
|
|
91
|
+
throw new CliError(`${flags.record} is outside the indexed record prefix of ${flags.file}; the unindexed suffix cannot be resolved`, { code: "record_beyond_index_limit", exit: EXIT.BLOCKED });
|
|
92
|
+
}
|
|
93
|
+
throw new CliError(`${flags.record} is not a record of ${flags.file}`, { code: "unknown_record" });
|
|
94
|
+
}
|
|
95
|
+
recordPointer = rec.pointer;
|
|
96
|
+
} else if (entry.kind === "canonical-bundle" && entry.records.length !== 1) {
|
|
97
|
+
throw usage("--record ev_... is required for bundle files");
|
|
98
|
+
} else if (entry.records.length === 1) recordPointer = entry.records[0].pointer;
|
|
99
|
+
const abs = (await resolveInWorkspace(ws.abs, entry.file, { allowMissing: false })).abs;
|
|
100
|
+
const live = await hashFile(abs);
|
|
101
|
+
if (live.sha256 !== entry.sha256 || live.bytes !== entry.bytes) {
|
|
102
|
+
throw new CliError(`${entry.file} no longer matches its indexed sha256/bytes; the saved evidence was modified and must not be cited as the original`, { code: "evidence_modified", exit: EXIT.FAILED });
|
|
103
|
+
}
|
|
104
|
+
const idPointer = `${recordPointer}/evidence_id`;
|
|
105
|
+
const idScan = await scanFile(abs, [idPointer], { maxCaptureBytes: 1024 });
|
|
106
|
+
const savedId = idScan.captures.get(idPointer) ? JSON.parse(idScan.captures.get(idPointer).text) : null;
|
|
107
|
+
const wantedId = flags.record ?? entry.records[0]?.evidence_id ?? null;
|
|
108
|
+
if (savedId !== wantedId) {
|
|
109
|
+
throw new CliError(`${entry.file} has evidence_id ${savedId ?? "none"} at ${recordPointer || "/"}, not ${wantedId}; the index pointer is stale or edited`, { code: "record_pointer_mismatch", exit: EXIT.FAILED });
|
|
110
|
+
}
|
|
111
|
+
return { abs, entry, recordPointer, bytes: live.bytes };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function resolveCommand(flags) {
|
|
115
|
+
if (flags.pointer === undefined) throw usage("--pointer P is required (use \"\" for the record root)");
|
|
116
|
+
if (pointerSegments(flags.pointer) === null) throw usage("--pointer is not a valid RFC 6901 pointer");
|
|
117
|
+
const { abs, entry, recordPointer, bytes } = await locateRecord(flags);
|
|
118
|
+
const maxBytes = toInt(flags["max-bytes"], "max-bytes", { min: 16 }) ?? 4096;
|
|
119
|
+
const absolutePointer = `${recordPointer}${flags.pointer}`;
|
|
120
|
+
let found;
|
|
121
|
+
let value;
|
|
122
|
+
if (bytes <= DEFAULT_MAX_PARSE_BYTES) {
|
|
123
|
+
const result = resolvePointer(JSON.parse(await fsp.readFile(abs, "utf8")), absolutePointer);
|
|
124
|
+
found = result.found;
|
|
125
|
+
value = result.value;
|
|
126
|
+
} else {
|
|
127
|
+
const scan = await scanFile(abs, [absolutePointer], { maxCaptureBytes: Math.max(maxBytes * 4, 1 << 20) });
|
|
128
|
+
const capture = scan.captures.get(absolutePointer);
|
|
129
|
+
if (!capture) found = false;
|
|
130
|
+
else if (capture.oversized) return { found: true, truncated: true, note: "value larger than the capture limit; select a deeper pointer", bytes: capture.bytes };
|
|
131
|
+
else {
|
|
132
|
+
found = true;
|
|
133
|
+
value = JSON.parse(capture.text);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!found) return { found: false, pointer: flags.pointer, evidence_id: flags.record ?? entry.records[0]?.evidence_id ?? null };
|
|
137
|
+
const text = JSON.stringify(value);
|
|
138
|
+
const type = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
139
|
+
const size = type === "array" ? value.length : type === "object" ? Object.keys(value).length : null;
|
|
140
|
+
return {
|
|
141
|
+
found: true,
|
|
142
|
+
pointer: flags.pointer,
|
|
143
|
+
file: entry.file,
|
|
144
|
+
evidence_id: flags.record ?? entry.records[0]?.evidence_id ?? null,
|
|
145
|
+
type,
|
|
146
|
+
size,
|
|
147
|
+
truncated: text.length > maxBytes,
|
|
148
|
+
value: text.length > maxBytes ? `${text.slice(0, maxBytes)}…` : value,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function locateCommand(flags) {
|
|
153
|
+
if (flags.value === undefined) throw usage("--value JSON is required");
|
|
154
|
+
let wanted;
|
|
155
|
+
try {
|
|
156
|
+
wanted = JSON.parse(flags.value);
|
|
157
|
+
} catch {
|
|
158
|
+
throw usage("--value must be JSON (strings need quotes, e.g. '\"main.js\"')");
|
|
159
|
+
}
|
|
160
|
+
const { abs, entry, recordPointer, bytes } = await locateRecord(flags);
|
|
161
|
+
if (bytes > DEFAULT_MAX_PARSE_BYTES) {
|
|
162
|
+
throw new CliError(`${entry.file} exceeds the ${DEFAULT_MAX_PARSE_BYTES} byte parse limit; publish a smaller REA view and locate within it`, { code: "parse_limit", exit: EXIT.BLOCKED });
|
|
163
|
+
}
|
|
164
|
+
const doc = JSON.parse(await fsp.readFile(abs, "utf8"));
|
|
165
|
+
const limit = toInt(flags.limit, "limit", { min: 1 }) ?? 20;
|
|
166
|
+
const under = flags.under ?? "";
|
|
167
|
+
if (pointerSegments(under) === null) throw usage("--under is not a valid RFC 6901 pointer");
|
|
168
|
+
const result = locateValues(doc, wanted, { under: `${recordPointer}${under}`, limit });
|
|
169
|
+
const strip = (pointer) => pointer.slice(recordPointer.length);
|
|
170
|
+
return {
|
|
171
|
+
file: entry.file,
|
|
172
|
+
evidence_id: flags.record ?? entry.records[0]?.evidence_id ?? null,
|
|
173
|
+
matches: result.matches.map(strip),
|
|
174
|
+
visited: result.visited,
|
|
175
|
+
truncated: result.truncated,
|
|
176
|
+
note: "Pointers are relative to the record root; put them in selectors[].pointer.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function checkSchemaCommand(flags) {
|
|
181
|
+
if (!flags.schema || !flags.file) throw usage("--schema NAME and --file FILE are required");
|
|
182
|
+
if (!SCHEMA_NAMES.includes(flags.schema)) throw usage(`--schema must be one of ${SCHEMA_NAMES.join(", ")}`);
|
|
183
|
+
let doc;
|
|
184
|
+
try {
|
|
185
|
+
doc = JSON.parse(await fsp.readFile(flags.file, "utf8"));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw new CliError(`cannot read JSON: ${error.message}`, { code: "invalid_json" });
|
|
188
|
+
}
|
|
189
|
+
const result = await validateSchema(flags.schema, doc, { maxErrors: 50 });
|
|
190
|
+
return { outcome: result.valid ? "pass" : "fail", schema: flags.schema, errors: result.errors, truncated: result.truncated ?? false };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Run a command; returns { exit, body }. */
|
|
194
|
+
export async function run(argv) {
|
|
195
|
+
const [command, ...rest] = argv;
|
|
196
|
+
if (!command || command === "--help" || command === "help") return { exit: command ? EXIT.OK : EXIT.USAGE, body: { usage: USAGE } };
|
|
197
|
+
const { flags, positionals } = parseArgs(rest, { booleans: BOOLEANS, multi: MULTI });
|
|
198
|
+
if (flags.help) return { exit: EXIT.OK, body: { usage: USAGE } };
|
|
199
|
+
switch (command) {
|
|
200
|
+
case "setup-check":
|
|
201
|
+
return { exit: EXIT.OK, body: await setupCheck() };
|
|
202
|
+
case "hash":
|
|
203
|
+
return { exit: EXIT.OK, body: await hashCommand(positionals) };
|
|
204
|
+
case "inspect-file":
|
|
205
|
+
return { exit: EXIT.OK, body: await inspectCommand(positionals, flags) };
|
|
206
|
+
case "init-workspace":
|
|
207
|
+
await loadValidators();
|
|
208
|
+
return { exit: EXIT.OK, body: await initWorkspace(flags) };
|
|
209
|
+
case "describe-build":
|
|
210
|
+
await loadValidators();
|
|
211
|
+
return { exit: EXIT.OK, body: await describeBuild(flags) };
|
|
212
|
+
case "publish-evidence": {
|
|
213
|
+
await loadValidators();
|
|
214
|
+
const body = await publishEvidence(flags);
|
|
215
|
+
return { exit: EXIT.OK, body };
|
|
216
|
+
}
|
|
217
|
+
case "register-external":
|
|
218
|
+
await loadValidators();
|
|
219
|
+
return { exit: EXIT.OK, body: await registerExternal(flags) };
|
|
220
|
+
case "publish":
|
|
221
|
+
await loadValidators();
|
|
222
|
+
return { exit: EXIT.OK, body: await publishFile(flags) };
|
|
223
|
+
case "digests":
|
|
224
|
+
return { exit: EXIT.OK, body: await digests(flags, { paths: flags.path ?? [] }) };
|
|
225
|
+
case "reconcile": {
|
|
226
|
+
await loadValidators();
|
|
227
|
+
const body = await reconcile(flags);
|
|
228
|
+
return { exit: body.outcome === "pass" ? EXIT.OK : EXIT.FAILED, body };
|
|
229
|
+
}
|
|
230
|
+
case "validate": {
|
|
231
|
+
const mode = flags.mode ?? "progress";
|
|
232
|
+
if (!["progress", "resume", "closure"].includes(mode)) throw usage("--mode must be progress|resume|closure");
|
|
233
|
+
const body = await validateWorkspace({
|
|
234
|
+
workspace: flags.workspace,
|
|
235
|
+
mode,
|
|
236
|
+
maxParseBytes: toInt(flags["max-parse-bytes"], "max-parse-bytes", { min: 1 }) ?? DEFAULT_MAX_PARSE_BYTES,
|
|
237
|
+
verbose: flags.verbose === true,
|
|
238
|
+
checkTargets: flags["check-targets"] === true,
|
|
239
|
+
});
|
|
240
|
+
return { exit: body.outcome === "pass" ? EXIT.OK : body.outcome === "blocked" ? EXIT.BLOCKED : EXIT.FAILED, body };
|
|
241
|
+
}
|
|
242
|
+
case "check-schema": {
|
|
243
|
+
await loadValidators();
|
|
244
|
+
const body = await checkSchemaCommand(flags);
|
|
245
|
+
return { exit: body.outcome === "pass" ? EXIT.OK : EXIT.FAILED, body };
|
|
246
|
+
}
|
|
247
|
+
case "resolve":
|
|
248
|
+
return { exit: EXIT.OK, body: await resolveCommand(flags) };
|
|
249
|
+
case "locate":
|
|
250
|
+
return { exit: EXIT.OK, body: await locateCommand(flags) };
|
|
251
|
+
default:
|
|
252
|
+
throw usage(`unknown command: ${command}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Streaming JSON scanner: captures selected values by RFC 6901 pointer patterns
|
|
2
|
+
// and counts container sizes without loading or parsing the whole document.
|
|
3
|
+
// Structural bytes are ASCII and never occur inside UTF-8 multibyte sequences,
|
|
4
|
+
// so the scan works on raw bytes. Pattern segments may be "*" (any key/index).
|
|
5
|
+
// Captures nest: a value that matches a pattern inside another captured value
|
|
6
|
+
// gets its own independent, separately bounded capture, so an ancestor that
|
|
7
|
+
// exceeds the capture limit never hides a smaller descendant.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
|
|
10
|
+
const QUOTE = 0x22;
|
|
11
|
+
const BACKSLASH = 0x5c;
|
|
12
|
+
const COMMA = 0x2c;
|
|
13
|
+
const COLON = 0x3a;
|
|
14
|
+
const LBRACE = 0x7b;
|
|
15
|
+
const RBRACE = 0x7d;
|
|
16
|
+
const LBRACKET = 0x5b;
|
|
17
|
+
const RBRACKET = 0x5d;
|
|
18
|
+
|
|
19
|
+
const isWs = (b) => b === 0x20 || b === 0x0a || b === 0x0d || b === 0x09;
|
|
20
|
+
const escapeSegment = (segment) => String(segment).replace(/~/g, "~0").replace(/\//g, "~1");
|
|
21
|
+
const unescapeSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
22
|
+
|
|
23
|
+
export function parsePattern(pointer) {
|
|
24
|
+
if (pointer === "") return [];
|
|
25
|
+
if (!pointer.startsWith("/")) throw new TypeError(`invalid pointer pattern: ${pointer}`);
|
|
26
|
+
return pointer.split("/").slice(1).map(unescapeSegment);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class Scanner {
|
|
30
|
+
constructor(patterns, maxCaptureBytes) {
|
|
31
|
+
this.patterns = patterns.map(parsePattern);
|
|
32
|
+
this.maxCaptureBytes = maxCaptureBytes;
|
|
33
|
+
this.stack = [];
|
|
34
|
+
this.state = "value"; // value | key | colon | comma | done
|
|
35
|
+
this.inString = false;
|
|
36
|
+
this.escaped = false;
|
|
37
|
+
this.stringIsKey = false;
|
|
38
|
+
this.keyParts = null;
|
|
39
|
+
this.scalar = false;
|
|
40
|
+
this.sawRoot = false;
|
|
41
|
+
this.rootType = null;
|
|
42
|
+
this.malformed = false;
|
|
43
|
+
this.active = []; // open captures, outermost first (strictly increasing depth)
|
|
44
|
+
this.captureRoot = this.patterns.some((pattern) => pattern.length === 0);
|
|
45
|
+
this.captures = new Map();
|
|
46
|
+
this.counts = new Map();
|
|
47
|
+
this.topKeys = [];
|
|
48
|
+
this.allIndexes = this.patterns.map((_, index) => index).filter((index) => this.patterns[index].length >= 1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- capture helpers -----------------------------------------------------
|
|
52
|
+
startCapture(pointer, from) {
|
|
53
|
+
this.active.push({ pointer, depth: this.stack.length, pieces: [], size: 0, from, oversized: false });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addPiece(cap, buf, from, to) {
|
|
57
|
+
const length = to - from;
|
|
58
|
+
if (length <= 0) return;
|
|
59
|
+
cap.size += length;
|
|
60
|
+
if (cap.size > this.maxCaptureBytes) {
|
|
61
|
+
cap.oversized = true;
|
|
62
|
+
cap.pieces = null;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (cap.pieces) cap.pieces.push(Buffer.from(buf.subarray(from, to)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
finishCapture(buf, endInclusive) {
|
|
69
|
+
const cap = this.active.pop();
|
|
70
|
+
if (!cap.oversized) this.addPiece(cap, buf, cap.from, endInclusive + 1);
|
|
71
|
+
else cap.size += endInclusive + 1 - cap.from;
|
|
72
|
+
this.captures.set(cap.pointer, {
|
|
73
|
+
text: cap.oversized || !cap.pieces ? null : Buffer.concat(cap.pieces).toString("utf8"),
|
|
74
|
+
bytes: cap.size,
|
|
75
|
+
oversized: cap.oversized,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- value lifecycle -----------------------------------------------------
|
|
80
|
+
valueStart(buf, i, b) {
|
|
81
|
+
const frame = this.stack[this.stack.length - 1];
|
|
82
|
+
let parentInterest;
|
|
83
|
+
let depth;
|
|
84
|
+
let segment = null;
|
|
85
|
+
let parentPointer = "";
|
|
86
|
+
if (frame === undefined) {
|
|
87
|
+
parentInterest = this.allIndexes;
|
|
88
|
+
depth = 0;
|
|
89
|
+
if (this.sawRoot) this.malformed = true;
|
|
90
|
+
this.sawRoot = true;
|
|
91
|
+
this.rootType = b === LBRACE ? "object" : b === LBRACKET ? "array" : "scalar";
|
|
92
|
+
} else {
|
|
93
|
+
frame.count += 1;
|
|
94
|
+
if (frame.type === "a") frame.index += 1;
|
|
95
|
+
parentInterest = frame.interest;
|
|
96
|
+
depth = this.stack.length - 1;
|
|
97
|
+
segment = frame.type === "o" ? frame.key : String(frame.index);
|
|
98
|
+
parentPointer = frame.pointer;
|
|
99
|
+
}
|
|
100
|
+
let childInterest = null;
|
|
101
|
+
let captureHere = false;
|
|
102
|
+
let pointer = null;
|
|
103
|
+
if (frame !== undefined && parentInterest) {
|
|
104
|
+
for (const pi of parentInterest) {
|
|
105
|
+
const pattern = this.patterns[pi];
|
|
106
|
+
if (pattern.length > depth && (pattern[depth] === "*" || pattern[depth] === segment)) {
|
|
107
|
+
if (pattern.length === depth + 1) captureHere = true;
|
|
108
|
+
else (childInterest ??= []).push(pi);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (captureHere || childInterest) pointer = `${parentPointer}/${escapeSegment(segment)}`;
|
|
112
|
+
} else if (frame === undefined) {
|
|
113
|
+
if (b === LBRACE || b === LBRACKET) childInterest = parentInterest;
|
|
114
|
+
if (this.captureRoot) captureHere = true;
|
|
115
|
+
if (captureHere || childInterest) pointer = "";
|
|
116
|
+
}
|
|
117
|
+
if (captureHere) this.startCapture(pointer, i);
|
|
118
|
+
if (b === LBRACE || b === LBRACKET) {
|
|
119
|
+
this.stack.push({
|
|
120
|
+
type: b === LBRACE ? "o" : "a",
|
|
121
|
+
key: null,
|
|
122
|
+
index: -1,
|
|
123
|
+
count: 0,
|
|
124
|
+
interest: childInterest,
|
|
125
|
+
pointer,
|
|
126
|
+
capturing: false,
|
|
127
|
+
});
|
|
128
|
+
this.state = b === LBRACE ? "key" : "value";
|
|
129
|
+
} else if (b === QUOTE) {
|
|
130
|
+
this.inString = true;
|
|
131
|
+
this.stringIsKey = false;
|
|
132
|
+
this.escaped = false;
|
|
133
|
+
} else {
|
|
134
|
+
this.scalar = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
valueEnd(buf, i) {
|
|
139
|
+
// A complete value ended at byte index i (inclusive).
|
|
140
|
+
const top = this.active[this.active.length - 1];
|
|
141
|
+
if (top && top.depth === this.stack.length) this.finishCapture(buf, i);
|
|
142
|
+
this.state = this.stack.length === 0 ? "done" : "comma";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
closeContainer(buf, i, expected) {
|
|
146
|
+
const frame = this.stack[this.stack.length - 1];
|
|
147
|
+
if (!frame || frame.type !== expected) {
|
|
148
|
+
this.malformed = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.stack.pop();
|
|
152
|
+
if (frame.pointer !== null) {
|
|
153
|
+
this.counts.set(frame.pointer, frame.type === "a" ? frame.index + 1 : frame.count);
|
|
154
|
+
}
|
|
155
|
+
this.valueEnd(buf, i);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- main loop -------------------------------------------------------------
|
|
159
|
+
push(buf) {
|
|
160
|
+
const n = buf.length;
|
|
161
|
+
// open captures continue from the start of this chunk (their offsets are reset after each carry)
|
|
162
|
+
for (let i = 0; i < n; i += 1) {
|
|
163
|
+
const b = buf[i];
|
|
164
|
+
if (this.inString) {
|
|
165
|
+
if (this.escaped) this.escaped = false;
|
|
166
|
+
else if (b === BACKSLASH) this.escaped = true;
|
|
167
|
+
else if (b === QUOTE) {
|
|
168
|
+
this.inString = false;
|
|
169
|
+
if (this.stringIsKey) this.endKey(buf, i);
|
|
170
|
+
else this.valueEnd(buf, i);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (this.scalar) {
|
|
175
|
+
if (isWs(b) || b === COMMA || b === RBRACE || b === RBRACKET) {
|
|
176
|
+
this.scalar = false;
|
|
177
|
+
this.valueEnd(buf, i - 1);
|
|
178
|
+
} else continue;
|
|
179
|
+
}
|
|
180
|
+
if (isWs(b)) continue;
|
|
181
|
+
switch (this.state) {
|
|
182
|
+
case "value":
|
|
183
|
+
if (b === RBRACKET) {
|
|
184
|
+
// empty array
|
|
185
|
+
this.closeContainer(buf, i, "a");
|
|
186
|
+
} else if (b === COMMA || b === COLON || b === RBRACE) {
|
|
187
|
+
this.malformed = true;
|
|
188
|
+
} else {
|
|
189
|
+
this.valueStart(buf, i, b);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
case "key":
|
|
193
|
+
if (b === QUOTE) {
|
|
194
|
+
this.inString = true;
|
|
195
|
+
this.escaped = false;
|
|
196
|
+
this.stringIsKey = true;
|
|
197
|
+
this.keyParts = [];
|
|
198
|
+
this.keyFrom = i + 1;
|
|
199
|
+
} else if (b === RBRACE) {
|
|
200
|
+
this.closeContainer(buf, i, "o");
|
|
201
|
+
} else this.malformed = true;
|
|
202
|
+
break;
|
|
203
|
+
case "colon":
|
|
204
|
+
if (b === COLON) this.state = "value";
|
|
205
|
+
else this.malformed = true;
|
|
206
|
+
break;
|
|
207
|
+
case "comma": {
|
|
208
|
+
const frame = this.stack[this.stack.length - 1];
|
|
209
|
+
if (b === COMMA) this.state = frame && frame.type === "o" ? "key" : "value";
|
|
210
|
+
else if (b === RBRACE) this.closeContainer(buf, i, "o");
|
|
211
|
+
else if (b === RBRACKET) this.closeContainer(buf, i, "a");
|
|
212
|
+
else this.malformed = true;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
default:
|
|
216
|
+
this.malformed = true; // trailing data after the root value
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// carry open captures and key text across chunk boundaries
|
|
220
|
+
for (const cap of this.active) {
|
|
221
|
+
if (!cap.oversized) this.addPiece(cap, buf, cap.from, n);
|
|
222
|
+
else cap.size += n - cap.from;
|
|
223
|
+
cap.from = 0;
|
|
224
|
+
}
|
|
225
|
+
if (this.inString && this.stringIsKey) {
|
|
226
|
+
this.keyParts.push(Buffer.from(buf.subarray(this.keyFrom, n)));
|
|
227
|
+
this.keyFrom = 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
endKey(buf, i) {
|
|
232
|
+
const frame = this.stack[this.stack.length - 1];
|
|
233
|
+
this.keyParts.push(Buffer.from(buf.subarray(this.keyFrom, i)));
|
|
234
|
+
const raw = Buffer.concat(this.keyParts).toString("utf8");
|
|
235
|
+
this.keyParts = null;
|
|
236
|
+
let key;
|
|
237
|
+
try {
|
|
238
|
+
key = JSON.parse(`"${raw}"`);
|
|
239
|
+
} catch {
|
|
240
|
+
this.malformed = true;
|
|
241
|
+
key = raw;
|
|
242
|
+
}
|
|
243
|
+
frame.key = key;
|
|
244
|
+
if (this.stack.length === 1) this.topKeys.push(key);
|
|
245
|
+
this.state = "colon";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
end() {
|
|
249
|
+
if (this.scalar) {
|
|
250
|
+
this.scalar = false;
|
|
251
|
+
this.valueEnd(Buffer.alloc(0), -1);
|
|
252
|
+
}
|
|
253
|
+
const balanced =
|
|
254
|
+
this.sawRoot && !this.malformed && this.stack.length === 0 && !this.inString && this.state === "done";
|
|
255
|
+
return {
|
|
256
|
+
balanced,
|
|
257
|
+
rootType: this.rootType,
|
|
258
|
+
topKeys: this.topKeys,
|
|
259
|
+
captures: this.captures,
|
|
260
|
+
counts: this.counts,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Scan a file once, capturing values at the given pointer patterns.
|
|
267
|
+
* Returns { balanced, rootType, topKeys, captures: Map(pointer -> {text, bytes, oversized}),
|
|
268
|
+
* counts: Map(pointer -> element/key count of interesting containers) }.
|
|
269
|
+
* balanced=false means the structure was truncated or malformed (not a full JSON parse).
|
|
270
|
+
* Captures nest: every matching value is captured independently, each bounded by
|
|
271
|
+
* maxCaptureBytes. chunkBytes is the read size (tests shrink it to exercise chunk boundaries).
|
|
272
|
+
*/
|
|
273
|
+
export async function scanFile(file, patterns, { maxCaptureBytes = 1 << 20, chunkBytes = 1 << 20 } = {}) {
|
|
274
|
+
const scanner = new Scanner(patterns, maxCaptureBytes);
|
|
275
|
+
const stream = fs.createReadStream(file, { highWaterMark: chunkBytes });
|
|
276
|
+
for await (const chunk of stream) scanner.push(chunk);
|
|
277
|
+
return scanner.end();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Scan an in-memory buffer (used by tests and small inputs). */
|
|
281
|
+
export function scanBuffer(buffer, patterns, { maxCaptureBytes = 1 << 20 } = {}) {
|
|
282
|
+
const scanner = new Scanner(patterns, maxCaptureBytes);
|
|
283
|
+
scanner.push(buffer);
|
|
284
|
+
return scanner.end();
|
|
285
|
+
}
|