superpowers-manager 0.2.0 → 0.4.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.
Files changed (68) hide show
  1. package/README.md +340 -66
  2. package/dist/adapter-result.js +204 -0
  3. package/dist/adapter.js +690 -0
  4. package/dist/atomic.js +142 -0
  5. package/dist/cli-arguments.js +32 -0
  6. package/dist/cli.js +313 -0
  7. package/dist/codex-harness.js +271 -0
  8. package/dist/codex-json.js +89 -0
  9. package/dist/codex-prepare.js +199 -0
  10. package/dist/codex-presentation.js +259 -0
  11. package/dist/codex-state.js +78 -0
  12. package/dist/commands/context.js +1 -0
  13. package/dist/commands/install.js +291 -0
  14. package/dist/commands/pin.js +79 -0
  15. package/dist/commands/prepare.js +420 -0
  16. package/dist/commands/probe.js +257 -0
  17. package/dist/commands/track-latest.js +44 -0
  18. package/dist/commands/uninstall.js +217 -0
  19. package/dist/commands/unpin.js +95 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/domain/fingerprint.js +4 -0
  22. package/dist/domain/refs.js +37 -0
  23. package/dist/effective-selection.js +123 -0
  24. package/dist/generated-plugin.js +748 -0
  25. package/dist/git.js +38 -0
  26. package/dist/harness.js +1 -0
  27. package/dist/hooks.js +290 -0
  28. package/dist/lifecycle.js +135 -0
  29. package/dist/manifest-overlay.js +125 -0
  30. package/dist/provenance.js +111 -0
  31. package/dist/python-json-format.js +83 -0
  32. package/dist/python-json.js +29 -0
  33. package/dist/python-text.js +74 -0
  34. package/dist/safe-path.js +137 -0
  35. package/dist/safety-error.js +10 -0
  36. package/dist/selection-state-cli.js +85 -0
  37. package/dist/selection-store.js +172 -0
  38. package/dist/selection.js +200 -0
  39. package/dist/status.js +24 -0
  40. package/dist/strict-json.js +248 -0
  41. package/dist/upstream-cli.js +110 -0
  42. package/dist/upstream-version.js +40 -0
  43. package/dist/upstream.js +253 -0
  44. package/dist/validate-generated-plugin-cli.js +118 -0
  45. package/dist/validator.js +317 -0
  46. package/dist/workspace.js +139 -0
  47. package/package.json +25 -4
  48. package/bin/superpowers-manager.js +0 -290
  49. package/scripts/adapters/codex/adapter +0 -603
  50. package/scripts/adapters/codex/lib.sh +0 -449
  51. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  52. package/scripts/core/adapter.sh +0 -103
  53. package/scripts/core/common.sh +0 -59
  54. package/scripts/core/lifecycle.sh +0 -141
  55. package/scripts/core/provenance.sh +0 -135
  56. package/scripts/core/selection-state.py +0 -414
  57. package/scripts/core/selection.sh +0 -168
  58. package/scripts/core/status.sh +0 -27
  59. package/scripts/core/upstream.sh +0 -280
  60. package/scripts/core/validate-adapter-response.py +0 -284
  61. package/scripts/install +0 -59
  62. package/scripts/pin +0 -51
  63. package/scripts/prepare +0 -117
  64. package/scripts/probe +0 -82
  65. package/scripts/track-latest +0 -30
  66. package/scripts/uninstall +0 -35
  67. package/scripts/unpin +0 -41
  68. package/scripts/update +0 -34
@@ -0,0 +1,172 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { atomicWriteFile, } from "./atomic.js";
5
+ import { classifyPathNoFollow } from "./safe-path.js";
6
+ import { SafetyError } from "./safety-error.js";
7
+ import { selectionError, serializeRecord, validateRecord, } from "./selection.js";
8
+ import { parseStrictJson } from "./strict-json.js";
9
+ const SELECTION_JSON_PROFILE = {
10
+ duplicateKeys: "reject",
11
+ nonStandardConstants: "reject",
12
+ maxDepth: 256,
13
+ // integerNumbersOnly owns lexical 1.0/1e0 rejection.
14
+ integerNumbersOnly: true,
15
+ };
16
+ function errorText(cause) {
17
+ return cause instanceof Error ? cause.message : String(cause);
18
+ }
19
+ function causedErrorText(cause) {
20
+ return cause instanceof SafetyError && cause.cause !== undefined
21
+ ? errorText(cause.cause)
22
+ : errorText(cause);
23
+ }
24
+ async function classify(path, label) {
25
+ try {
26
+ return await classifyPathNoFollow(path);
27
+ }
28
+ catch (cause) {
29
+ throw selectionError(`cannot inspect ${label} ${path}: ${causedErrorText(cause)}`, cause);
30
+ }
31
+ }
32
+ function translateParseFailure(path, text, cause) {
33
+ if (cause instanceof SafetyError && cause.module === "selection")
34
+ throw cause;
35
+ const message = cause instanceof Error ? cause.message : String(cause);
36
+ if (message.startsWith("container depth exceeds ")) {
37
+ throw selectionError(`JSON nesting exceeds limit in ${path}`, cause);
38
+ }
39
+ const duplicate = /^duplicate object key (".*") at character \d+$/.exec(message);
40
+ if (duplicate?.[1] !== undefined) {
41
+ const key = JSON.parse(duplicate[1]);
42
+ throw selectionError(`duplicate JSON key: ${key}`, cause);
43
+ }
44
+ const objectKey = /^object key must be a string at character (\d+)$/.exec(message);
45
+ if (objectKey?.[1] !== undefined) {
46
+ const index = Number(objectKey[1]);
47
+ const prefix = text.slice(0, index);
48
+ const lines = prefix.split("\n");
49
+ const line = lines.length;
50
+ const column = (lines.at(-1)?.length ?? 0) + 1;
51
+ throw selectionError(`invalid JSON in ${path}: line ${line} column ${column}: Expecting property name enclosed in double quotes`, cause);
52
+ }
53
+ throw selectionError(`invalid JSON in ${path}: ${message}`, cause);
54
+ }
55
+ function parseRecordBytes(path, bytes) {
56
+ let text;
57
+ try {
58
+ text = new TextDecoder("utf-8", {
59
+ fatal: true,
60
+ ignoreBOM: true,
61
+ }).decode(bytes);
62
+ }
63
+ catch (cause) {
64
+ throw selectionError(`cannot read selection state ${path}: ${errorText(cause)}`, cause);
65
+ }
66
+ try {
67
+ return validateRecord(parseStrictJson(text, SELECTION_JSON_PROFILE));
68
+ }
69
+ catch (cause) {
70
+ translateParseFailure(path, text, cause);
71
+ }
72
+ }
73
+ async function readOpenedRecord(path) {
74
+ let handle;
75
+ try {
76
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
77
+ handle = await open(path, constants.O_RDONLY | noFollow);
78
+ if (!(await handle.stat()).isFile()) {
79
+ throw selectionError(`selection state must be a regular file: ${path}`);
80
+ }
81
+ return parseRecordBytes(path, await handle.readFile());
82
+ }
83
+ catch (cause) {
84
+ if (cause instanceof SafetyError && cause.module === "selection") {
85
+ throw cause;
86
+ }
87
+ throw selectionError(`cannot read selection state ${path}: ${errorText(cause)}`, cause);
88
+ }
89
+ finally {
90
+ await handle?.close().catch(() => { });
91
+ }
92
+ }
93
+ async function classifyStateDirectory(path) {
94
+ const current = await classify(path, "selection state directory");
95
+ if (current === "symlink") {
96
+ throw selectionError(`selection state directory must not be a symlink: ${path}`);
97
+ }
98
+ if (current !== "missing" && current !== "directory") {
99
+ throw selectionError(`selection state directory must be a directory: ${path}`);
100
+ }
101
+ return current;
102
+ }
103
+ export async function readSelectionState(path) {
104
+ const parent = dirname(path);
105
+ await classifyStateDirectory(parent);
106
+ const targetType = await classify(path, "selection state");
107
+ if (targetType === "missing")
108
+ return null;
109
+ if (targetType === "symlink") {
110
+ throw selectionError(`selection state must not be a symlink: ${path}`);
111
+ }
112
+ if (targetType !== "regular-file") {
113
+ throw selectionError(`selection state must be a regular file: ${path}`);
114
+ }
115
+ return readOpenedRecord(path);
116
+ }
117
+ async function ensureStateDirectory(path) {
118
+ if ((await classifyStateDirectory(path)) !== "missing")
119
+ return;
120
+ const previousUmask = process.umask(0o077);
121
+ try {
122
+ await mkdir(path, { recursive: true, mode: 0o700 });
123
+ }
124
+ catch (cause) {
125
+ throw selectionError(`cannot create selection state directory ${path}: ${errorText(cause)}`, cause);
126
+ }
127
+ finally {
128
+ process.umask(previousUmask);
129
+ }
130
+ const created = await classify(path, "selection state directory");
131
+ if (created !== "directory") {
132
+ throw selectionError(`selection state directory is not usable: ${path}`);
133
+ }
134
+ }
135
+ async function finalStateDiagnostic(path, finalBytes) {
136
+ try {
137
+ const record = finalBytes === undefined
138
+ ? await readSelectionState(path)
139
+ : parseRecordBytes(path, finalBytes);
140
+ return record === null
141
+ ? "selection state is now absent"
142
+ : `selection state is now ${record.mode}`;
143
+ }
144
+ catch (cause) {
145
+ return `final selection state cannot be validated: ${errorText(cause)}`;
146
+ }
147
+ }
148
+ export async function writeSelectionState(path, proposed, options = {}) {
149
+ await ensureStateDirectory(dirname(path));
150
+ // Invalid existing state must block overwrite.
151
+ await readSelectionState(path);
152
+ const record = validateRecord(proposed);
153
+ const bytes = new TextEncoder().encode(serializeRecord(record));
154
+ try {
155
+ await atomicWriteFile(path, bytes, {
156
+ hooks: options.hooks,
157
+ validate: async (temporary) => {
158
+ await readOpenedRecord(temporary);
159
+ },
160
+ });
161
+ }
162
+ catch (cause) {
163
+ const details = cause instanceof SafetyError && cause.module === "atomic"
164
+ ? cause.details
165
+ : undefined;
166
+ if (details?.phase !== "post-replacement") {
167
+ throw selectionError(`cannot write selection state: ${causedErrorText(cause)}`, cause);
168
+ }
169
+ const diagnostic = await finalStateDiagnostic(path, details.finalBytes);
170
+ throw selectionError(`cannot complete selection state write: ${causedErrorText(cause)}; ${diagnostic}`, cause);
171
+ }
172
+ }
@@ -0,0 +1,200 @@
1
+ import { isIP } from "node:net";
2
+ import { COMMIT_RE, TAG_RE, normalizeCommitInput } from "./domain/refs.js";
3
+ import { escapePythonJsonString } from "./python-json.js";
4
+ import { SafetyError } from "./safety-error.js";
5
+ export function selectionError(message, cause) {
6
+ return new SafetyError("selection", message, { cause });
7
+ }
8
+ function requireObject(raw, label) {
9
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
10
+ throw selectionError(`${label} must be a JSON object`);
11
+ }
12
+ return raw;
13
+ }
14
+ function requireExactKeys(record, expected) {
15
+ const expectedSet = new Set(expected);
16
+ const actual = Object.keys(record);
17
+ const missing = expected.filter((key) => !Object.hasOwn(record, key)).sort();
18
+ const unknown = actual.filter((key) => !expectedSet.has(key)).sort();
19
+ if (missing.length === 0 && unknown.length === 0)
20
+ return;
21
+ const details = [];
22
+ if (missing.length > 0)
23
+ details.push(`missing ${missing.join(", ")}`);
24
+ if (unknown.length > 0)
25
+ details.push(`unknown ${unknown.join(", ")}`);
26
+ throw selectionError(`selection state keys are invalid: ${details.join("; ")}`);
27
+ }
28
+ function requireSingleLineString(value, label) {
29
+ if (typeof value !== "string" ||
30
+ value.length === 0 ||
31
+ value.includes("\r") ||
32
+ value.includes("\n") ||
33
+ value.includes("\0")) {
34
+ throw selectionError(`${label} must be a non-empty single-line string`);
35
+ }
36
+ return value;
37
+ }
38
+ // Bounded CPython urllib.parse bracket/NFKC emulation preserves version compatibility.
39
+ function bracketedHostIsInvalid(host) {
40
+ if (host.startsWith("v"))
41
+ return !/^v[0-9A-Fa-f]+\..+$/.test(host);
42
+ return isIP(host) !== 6;
43
+ }
44
+ function authorityHasMalformedBrackets(authority) {
45
+ const hasOpening = authority.includes("[");
46
+ const hasClosing = authority.includes("]");
47
+ if (hasOpening !== hasClosing)
48
+ return true;
49
+ if (!hasOpening)
50
+ return false;
51
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1);
52
+ const opening = hostPort.indexOf("[");
53
+ let host;
54
+ if (opening !== -1) {
55
+ if (opening !== 0)
56
+ return true;
57
+ const bracketed = hostPort.slice(1);
58
+ const closing = bracketed.indexOf("]");
59
+ host = closing === -1 ? bracketed : bracketed.slice(0, closing);
60
+ const suffix = closing === -1 ? "" : bracketed.slice(closing + 1);
61
+ if (suffix !== "" && !suffix.startsWith(":"))
62
+ return true;
63
+ }
64
+ else {
65
+ host = hostPort.split(":", 1)[0];
66
+ }
67
+ return bracketedHostIsInvalid(host);
68
+ }
69
+ function authorityFailsNfkc(authority) {
70
+ const checked = authority.replace(/[@:#?]/g, "");
71
+ const normalized = checked.normalize("NFKC");
72
+ return normalized !== checked && /[/?#@:]/.test(normalized);
73
+ }
74
+ export function validateSource(raw) {
75
+ const source = requireSingleLineString(raw, "source");
76
+ let leadingWhitespaceEnd = 0;
77
+ while (leadingWhitespaceEnd < source.length &&
78
+ source.charCodeAt(leadingWhitespaceEnd) <= 0x20) {
79
+ leadingWhitespaceEnd += 1;
80
+ }
81
+ const parsed = source.slice(leadingWhitespaceEnd).replaceAll("\t", "");
82
+ const schemeMatch = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/s.exec(parsed);
83
+ const scheme = schemeMatch?.[1]?.toLowerCase() ?? "";
84
+ const remainder = schemeMatch?.[2] ?? parsed;
85
+ let authority = "";
86
+ if (remainder.startsWith("//")) {
87
+ authority = remainder.slice(2).split(/[/?#]/, 1)[0];
88
+ }
89
+ if (authorityHasMalformedBrackets(authority) ||
90
+ authorityFailsNfkc(authority)) {
91
+ throw selectionError("source URL is malformed");
92
+ }
93
+ if ((scheme === "http" || scheme === "https") && authority.includes("@")) {
94
+ throw selectionError("HTTP(S) source must not include userinfo");
95
+ }
96
+ return source;
97
+ }
98
+ export function displaySource(raw) {
99
+ try {
100
+ return validateSource(raw);
101
+ }
102
+ catch (cause) {
103
+ if (!(cause instanceof SafetyError) || cause.module !== "selection") {
104
+ throw cause;
105
+ }
106
+ return "<redacted-source>";
107
+ }
108
+ }
109
+ function validatePinnedRecord(record, source) {
110
+ const requestedRef = requireSingleLineString(record.requested_ref, "requested_ref");
111
+ const resolvedRef = requireSingleLineString(record.resolved_ref, "resolved_ref");
112
+ const commit = requireSingleLineString(record.commit, "commit");
113
+ if (!COMMIT_RE.test(commit)) {
114
+ throw selectionError("commit must be a lowercase 40-hex value");
115
+ }
116
+ if (TAG_RE.test(requestedRef)) {
117
+ if (resolvedRef !== requestedRef) {
118
+ throw selectionError("tag resolved_ref must equal requested_ref");
119
+ }
120
+ }
121
+ else if (COMMIT_RE.test(requestedRef)) {
122
+ if (resolvedRef !== requestedRef || commit !== requestedRef) {
123
+ throw selectionError("raw commit requested_ref, resolved_ref, and commit must be equal");
124
+ }
125
+ }
126
+ else {
127
+ throw selectionError("requested_ref must be an exact tag or full commit");
128
+ }
129
+ return {
130
+ schema_version: 1,
131
+ mode: "pinned",
132
+ source,
133
+ requested_ref: requestedRef,
134
+ resolved_ref: resolvedRef,
135
+ commit,
136
+ };
137
+ }
138
+ export function validateRecord(raw) {
139
+ const record = requireObject(raw, "selection state");
140
+ if (record.schema_version !== 1) {
141
+ throw selectionError("schema_version must equal integer 1");
142
+ }
143
+ if (record.mode === "track-latest") {
144
+ requireExactKeys(record, ["schema_version", "mode", "source"]);
145
+ return {
146
+ schema_version: 1,
147
+ mode: "track-latest",
148
+ source: validateSource(record.source),
149
+ };
150
+ }
151
+ if (record.mode === "pinned") {
152
+ requireExactKeys(record, [
153
+ "schema_version",
154
+ "mode",
155
+ "source",
156
+ "requested_ref",
157
+ "resolved_ref",
158
+ "commit",
159
+ ]);
160
+ return validatePinnedRecord(record, validateSource(record.source));
161
+ }
162
+ throw selectionError("mode must be pinned or track-latest");
163
+ }
164
+ export function normalizeSaved(record) {
165
+ if (record === null) {
166
+ return {
167
+ saved_mode: "none",
168
+ saved_source: "",
169
+ saved_requested_ref: "",
170
+ saved_resolved_ref: "",
171
+ saved_commit: "",
172
+ };
173
+ }
174
+ return {
175
+ saved_mode: record.mode,
176
+ saved_source: record.source,
177
+ saved_requested_ref: record.mode === "pinned" ? record.requested_ref : "",
178
+ saved_resolved_ref: record.mode === "pinned" ? record.resolved_ref : "",
179
+ saved_commit: record.mode === "pinned" ? record.commit : "",
180
+ };
181
+ }
182
+ export function normalizePinnedArguments(arguments_) {
183
+ const requestedRef = normalizeCommitInput(arguments_.requestedRef) ?? arguments_.requestedRef;
184
+ const resolvedRef = normalizeCommitInput(arguments_.resolvedRef) ?? arguments_.resolvedRef;
185
+ const commit = arguments_.commit.toLowerCase();
186
+ return {
187
+ schema_version: 1,
188
+ mode: "pinned",
189
+ source: arguments_.source,
190
+ requested_ref: requestedRef,
191
+ resolved_ref: resolvedRef,
192
+ commit,
193
+ };
194
+ }
195
+ export function serializeRecord(record) {
196
+ if (record.mode === "track-latest") {
197
+ return `{\n "schema_version": 1,\n "mode": "track-latest",\n "source": "${escapePythonJsonString(record.source)}"\n}\n`;
198
+ }
199
+ return `{\n "schema_version": 1,\n "mode": "pinned",\n "source": "${escapePythonJsonString(record.source)}",\n "requested_ref": "${escapePythonJsonString(record.requested_ref)}",\n "resolved_ref": "${escapePythonJsonString(record.resolved_ref)}",\n "commit": "${escapePythonJsonString(record.commit)}"\n}\n`;
200
+ }
package/dist/status.js ADDED
@@ -0,0 +1,24 @@
1
+ // Ports scripts/core/status.sh in full. Both functions are pure: no I/O, no
2
+ // environment. The three status strings are operator-facing and frozen.
3
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/status.sh:4-9::spw_commit_matches(`.
4
+ // The `[ -n "$observed" ]` guard comes first and is load-bearing: an empty
5
+ // observed commit must never match, or an absent generated tree reads as
6
+ // current.
7
+ export function commitMatches(desired, observed) {
8
+ if (observed.length === 0)
9
+ return false;
10
+ return observed === desired || observed === desired.slice(0, 7);
11
+ }
12
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/status.sh:11-27::spw_status_for_commits`.
13
+ // Branch order is part of the contract: a stale generated tree outranks a null
14
+ // installed fingerprint, so the operator is told to prepare before being told
15
+ // to install.
16
+ export function statusForCommits(desired, generated, installed) {
17
+ if (generated.length === 0 || !commitMatches(desired, generated)) {
18
+ return "needs prepare";
19
+ }
20
+ if (installed.length === 0 || !commitMatches(desired, installed)) {
21
+ return "needs install";
22
+ }
23
+ return "current";
24
+ }
@@ -0,0 +1,248 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { SafetyError } from "./safety-error.js";
3
+ // Module-private brands. JSON object keys are always strings, so no parsed
4
+ // input can produce a symbol-keyed property — these are unforgeable by
5
+ // construction rather than by convention. A structural check such as
6
+ // `typeof value.rawNumber === "string"` is NOT equivalent: the ordinary
7
+ // upstream input {"future":{"rawNumber":"123"}} satisfies it and would be
8
+ // re-emitted as a bare number, corrupting a field AGENTS.md requires
9
+ // preserving.
10
+ const RAW_NUMBER = Symbol("strict-json.rawNumber");
11
+ const RAW_OBJECT = Symbol("strict-json.rawObject");
12
+ export function isRawNumber(value) {
13
+ return typeof value === "object" && value !== null && RAW_NUMBER in value;
14
+ }
15
+ export function isRawObject(value) {
16
+ return typeof value === "object" && value !== null && RAW_OBJECT in value;
17
+ }
18
+ const VALUE_BUILDER = {
19
+ number: (token) => Number(token),
20
+ object: (entries) => Object.fromEntries(entries),
21
+ };
22
+ const RAW_BUILDER = {
23
+ number: (token) => ({ [RAW_NUMBER]: true, source: token }),
24
+ object: (entries) => ({ [RAW_OBJECT]: true, entries }),
25
+ };
26
+ export function parseStrictJson(input, profile) {
27
+ return parseWith(input, profile, VALUE_BUILDER);
28
+ }
29
+ export function parseStrictJsonPreservingNumbers(input, profile) {
30
+ return parseWith(input, profile, RAW_BUILDER);
31
+ }
32
+ function parseWith(input, profile, builder) {
33
+ const byteLength = typeof input === "string"
34
+ ? Buffer.byteLength(input, "utf8")
35
+ : input.byteLength;
36
+ if (profile.maxBytes !== undefined && byteLength > profile.maxBytes) {
37
+ throw new SafetyError("strict-json", `input exceeds ${profile.maxBytes} UTF-8 bytes`);
38
+ }
39
+ let text;
40
+ try {
41
+ text =
42
+ typeof input === "string"
43
+ ? input
44
+ : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input);
45
+ }
46
+ catch (cause) {
47
+ throw new SafetyError("strict-json", "input is not valid UTF-8", { cause });
48
+ }
49
+ try {
50
+ return new Parser(text, profile, builder).parse();
51
+ }
52
+ catch (cause) {
53
+ if (cause instanceof SafetyError)
54
+ throw cause;
55
+ throw new SafetyError("strict-json", "JSON parsing failed", { cause });
56
+ }
57
+ }
58
+ class Parser {
59
+ index = 0;
60
+ text;
61
+ profile;
62
+ builder;
63
+ constructor(text, profile, builder) {
64
+ this.text = text;
65
+ this.profile = profile;
66
+ this.builder = builder;
67
+ }
68
+ parse() {
69
+ this.skipWhitespace();
70
+ const value = this.parseValue(0);
71
+ this.skipWhitespace();
72
+ if (this.index !== this.text.length)
73
+ this.fail("unexpected trailing input");
74
+ return value;
75
+ }
76
+ parseValue(depth) {
77
+ if (this.text.startsWith("NaN", this.index)) {
78
+ return this.parseNonStandardConstant("NaN");
79
+ }
80
+ if (this.text.startsWith("Infinity", this.index)) {
81
+ return this.parseNonStandardConstant("Infinity");
82
+ }
83
+ if (this.text.startsWith("-Infinity", this.index)) {
84
+ return this.parseNonStandardConstant("-Infinity");
85
+ }
86
+ const token = this.text[this.index];
87
+ if (token === "{")
88
+ return this.parseObject(depth + 1);
89
+ if (token === "[")
90
+ return this.parseArray(depth + 1);
91
+ if (token === '"')
92
+ return this.parseString();
93
+ if (token === "t")
94
+ return this.parseLiteral("true", true);
95
+ if (token === "f")
96
+ return this.parseLiteral("false", false);
97
+ if (token === "n")
98
+ return this.parseLiteral("null", null);
99
+ return this.parseNumber();
100
+ }
101
+ checkDepth(depth) {
102
+ if (this.profile.maxDepth !== undefined && depth > this.profile.maxDepth) {
103
+ this.fail(`container depth exceeds ${this.profile.maxDepth}`);
104
+ }
105
+ }
106
+ parseObject(depth) {
107
+ this.checkDepth(depth);
108
+ this.index += 1;
109
+ const entries = [];
110
+ const seen = new Map();
111
+ this.skipWhitespace();
112
+ if (this.take("}"))
113
+ return this.builder.object(entries);
114
+ for (;;) {
115
+ if (this.text[this.index] !== '"')
116
+ this.fail("object key must be a string");
117
+ const key = this.parseString();
118
+ this.skipWhitespace();
119
+ if (!this.take(":"))
120
+ this.fail("expected ':' after object key");
121
+ this.skipWhitespace();
122
+ const value = this.parseValue(depth);
123
+ const previous = seen.get(key);
124
+ if (previous === undefined) {
125
+ seen.set(key, entries.length);
126
+ entries.push([key, value]);
127
+ }
128
+ else if (this.profile.duplicateKeys === "reject") {
129
+ this.fail(`duplicate object key ${JSON.stringify(key)}`);
130
+ }
131
+ else {
132
+ // Last value wins AT THE FIRST KEY'S POSITION. Verified against
133
+ // CPython 2026-07-31: json.loads('{"a":1,"b":2,"a":3}') reserializes
134
+ // as {"a": 3, "b": 2} — the dict keeps the original slot. Pushing a
135
+ // new entry instead would satisfy tests/unit/strict-json.test.js's
136
+ // "last-wins replaces in place, keeping the first key's position"
137
+ // while silently reordering the output.
138
+ entries[previous] = [key, value];
139
+ }
140
+ this.skipWhitespace();
141
+ if (this.take("}"))
142
+ return this.builder.object(entries);
143
+ if (!this.take(","))
144
+ this.fail("expected ',' or '}'");
145
+ this.skipWhitespace();
146
+ }
147
+ }
148
+ parseArray(depth) {
149
+ this.checkDepth(depth);
150
+ this.index += 1;
151
+ const result = [];
152
+ this.skipWhitespace();
153
+ if (this.take("]"))
154
+ return result;
155
+ for (;;) {
156
+ result.push(this.parseValue(depth));
157
+ this.skipWhitespace();
158
+ if (this.take("]"))
159
+ return result;
160
+ if (!this.take(","))
161
+ this.fail("expected ',' or ']'");
162
+ this.skipWhitespace();
163
+ }
164
+ }
165
+ parseString() {
166
+ this.index += 1;
167
+ let result = "";
168
+ while (this.index < this.text.length) {
169
+ const character = this.text[this.index];
170
+ this.index += 1;
171
+ if (character === '"')
172
+ return result;
173
+ if (character === "\\") {
174
+ const escape = this.text[this.index];
175
+ this.index += 1;
176
+ const short = {
177
+ '"': '"',
178
+ "\\": "\\",
179
+ "/": "/",
180
+ b: "\b",
181
+ f: "\f",
182
+ n: "\n",
183
+ r: "\r",
184
+ t: "\t",
185
+ };
186
+ if (escape !== undefined && Object.hasOwn(short, escape)) {
187
+ result += short[escape];
188
+ continue;
189
+ }
190
+ if (escape === "u") {
191
+ const hex = this.text.slice(this.index, this.index + 4);
192
+ if (!/^[0-9A-Fa-f]{4}$/.test(hex))
193
+ this.fail("invalid Unicode escape");
194
+ result += String.fromCharCode(Number.parseInt(hex, 16));
195
+ this.index += 4;
196
+ continue;
197
+ }
198
+ this.fail("invalid string escape");
199
+ }
200
+ if (character.charCodeAt(0) < 0x20) {
201
+ this.fail("unescaped control character in string");
202
+ }
203
+ result += character;
204
+ }
205
+ this.fail("unterminated string");
206
+ }
207
+ parseNumber() {
208
+ const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(this.text.slice(this.index));
209
+ if (match === null)
210
+ this.fail("expected JSON value");
211
+ const token = match[0];
212
+ this.index += token.length;
213
+ if (this.profile.integerNumbersOnly === true && /[.eE]/.test(token)) {
214
+ this.fail("non-integer JSON number");
215
+ }
216
+ return this.builder.number(token);
217
+ }
218
+ parseLiteral(literal, value) {
219
+ if (!this.text.startsWith(literal, this.index))
220
+ this.fail("invalid literal");
221
+ this.index += literal.length;
222
+ return value;
223
+ }
224
+ parseNonStandardConstant(token) {
225
+ if (this.profile.nonStandardConstants === "reject") {
226
+ this.fail(`non-standard JSON constant ${token}`);
227
+ }
228
+ this.index += token.length;
229
+ return this.builder.number(token);
230
+ }
231
+ skipWhitespace() {
232
+ while (this.text[this.index] === " " ||
233
+ this.text[this.index] === "\n" ||
234
+ this.text[this.index] === "\r" ||
235
+ this.text[this.index] === "\t") {
236
+ this.index += 1;
237
+ }
238
+ }
239
+ take(character) {
240
+ if (this.text[this.index] !== character)
241
+ return false;
242
+ this.index += 1;
243
+ return true;
244
+ }
245
+ fail(message) {
246
+ throw new SafetyError("strict-json", `${message} at character ${this.index}`);
247
+ }
248
+ }