bantamkit-mcp 0.26.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 +398 -0
- package/assets/contracts/default.yaml +90 -0
- package/assets/evals/devteam/manifest.yaml +351 -0
- package/assets/evals/devteam/repo/HISTORY.md +18 -0
- package/assets/evals/devteam/repo/README.md +12 -0
- package/assets/evals/devteam/repo/docs/architecture.md +17 -0
- package/assets/evals/devteam/repo/docs/runbook.md +10 -0
- package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- package/assets/evals/fixtures/.gitkeep +0 -0
- package/assets/evals/fixtures/catalog.json +6 -0
- package/assets/evals/perturbations/task-completion.yaml +576 -0
- package/assets/evals/tasks/.gitkeep +0 -0
- package/assets/evals/tasks/extract-contact.yaml +14 -0
- package/assets/evals/tasks/extract-invoice.yaml +14 -0
- package/assets/evals/tasks/extract-order.yaml +15 -0
- package/assets/evals/tasks/extract-schedule.yaml +14 -0
- package/assets/evals/tasks/extract-versions.yaml +17 -0
- package/assets/evals/tasks/nav-prod-port.yaml +84 -0
- package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- package/assets/evals/tasks/recall-db-port.yaml +17 -0
- package/assets/evals/tasks/recall-deploy.yaml +13 -0
- package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- package/assets/evals/tasks/recall-oncall.yaml +13 -0
- package/assets/evals/tasks/recall-org-quota.yaml +18 -0
- package/assets/evals/tasks/recall-owner.yaml +13 -0
- package/assets/evals/tasks/shop-basket-total.yaml +10 -0
- package/assets/evals/tasks/shop-cheapest.yaml +9 -0
- package/assets/evals/tasks/shop-compare.yaml +9 -0
- package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- package/assets/evals/tasks/shop-stock-total.yaml +9 -0
- package/assets/evals/tasks/shop-total.yaml +9 -0
- package/assets/profiles/default.yaml +31 -0
- package/assets/profiles/patient.yaml +31 -0
- package/assets/rubrics/.gitkeep +0 -0
- package/assets/rubrics/code-quality.yaml +20 -0
- package/assets/rubrics/grounded-completion.yaml +37 -0
- package/assets/rubrics/task-completion.yaml +28 -0
- package/assets/schemas/shiftwork-checkpoint.json +188 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/file-graph.md +7 -0
- package/assets/skills/memory.md +35 -0
- package/assets/tools/.gitkeep +0 -0
- package/assets/tools/bantamkit_read.json +48 -0
- package/assets/tools/bantamkit_status.json +25 -0
- package/assets/tools/build_identity.json +17 -0
- package/assets/tools/document_list.json +12 -0
- package/assets/tools/document_read.json +31 -0
- package/assets/tools/file_graph.json +12 -0
- package/assets/tools/memory_compact.json +31 -0
- package/assets/tools/memory_recall.json +38 -0
- package/assets/tools/memory_save.json +61 -0
- package/assets/tools/shiftwork_clock_in.json +25 -0
- package/assets/tools/shiftwork_clock_out.json +60 -0
- package/assets/tools/shiftwork_status.json +25 -0
- package/assets/tools/skill_audit.json +70 -0
- package/assets/tools/validate_json.json +31 -0
- package/dist/assets.d.ts +48 -0
- package/dist/assets.js +109 -0
- package/dist/charsets.d.ts +17 -0
- package/dist/charsets.js +580 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +280 -0
- package/dist/contract.d.ts +145 -0
- package/dist/contract.js +608 -0
- package/dist/docread.d.ts +357 -0
- package/dist/docread.js +3315 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/eventlog.d.ts +137 -0
- package/dist/eventlog.js +294 -0
- package/dist/htmlentities.d.ts +2 -0
- package/dist/htmlentities.js +16 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +13 -0
- package/dist/mcp/identity.d.ts +25 -0
- package/dist/mcp/identity.js +275 -0
- package/dist/mcp/pyargs.d.ts +80 -0
- package/dist/mcp/pyargs.js +362 -0
- package/dist/mcp/sdkjson.d.ts +56 -0
- package/dist/mcp/sdkjson.js +91 -0
- package/dist/mcp/server.d.ts +45 -0
- package/dist/mcp/server.js +740 -0
- package/dist/mcp/status.d.ts +210 -0
- package/dist/mcp/status.js +357 -0
- package/dist/mcp/transport.d.ts +68 -0
- package/dist/mcp/transport.js +111 -0
- package/dist/mcpreport.d.ts +214 -0
- package/dist/mcpreport.js +758 -0
- package/dist/memory/cli.d.ts +2 -0
- package/dist/memory/cli.js +396 -0
- package/dist/memory/component.d.ts +256 -0
- package/dist/memory/component.js +497 -0
- package/dist/memory/factfile.d.ts +120 -0
- package/dist/memory/factfile.js +406 -0
- package/dist/memory/layers.d.ts +92 -0
- package/dist/memory/layers.js +410 -0
- package/dist/memory/pyfs.d.ts +521 -0
- package/dist/memory/pyfs.js +1608 -0
- package/dist/memory/pyyaml.d.ts +155 -0
- package/dist/memory/pyyaml.js +868 -0
- package/dist/memory/store.d.ts +478 -0
- package/dist/memory/store.js +1034 -0
- package/dist/pyargparse.d.ts +141 -0
- package/dist/pyargparse.js +813 -0
- package/dist/pyjson.d.ts +110 -0
- package/dist/pyjson.js +524 -0
- package/dist/pyjsonschema.d.ts +50 -0
- package/dist/pyjsonschema.js +951 -0
- package/dist/shiftwork.d.ts +60 -0
- package/dist/shiftwork.js +324 -0
- package/dist/skillaudit.d.ts +172 -0
- package/dist/skillaudit.js +914 -0
- package/dist/statusline.d.ts +106 -0
- package/dist/statusline.js +281 -0
- package/package.json +45 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer resolution: project-store discovery and explicit cross-project grants.
|
|
3
|
+
*
|
|
4
|
+
* A port of `runtime-py/src/bantamkit/memory/layers.py`. The syscalls are `pyfs.ts`'s and
|
|
5
|
+
* are not re-derived here — but the DECISION taken on a failed one is this module's own, and
|
|
6
|
+
* it is deliberately not the store's. See `countFacts`.
|
|
7
|
+
*
|
|
8
|
+
* WHAT IS DELIBERATELY MISSING: nothing. Every function in the reference is reachable from
|
|
9
|
+
* `memory_recall` and `memory_save`, either directly or through `component.Memory.layered`.
|
|
10
|
+
*/
|
|
11
|
+
import { resolveImplicitTag } from './pyyaml.js';
|
|
12
|
+
import { asPyOSError, matchesMd, pyExists, pyIsDir, pyIsFile, pyJoin, pyParent, pyParents, pyReadText, pyRepr, pyResolve, pyScandirNames, pyStatIsDir, pyCwd, pyExpanduser, pyIsAbsolute, PyOSError, } from './pyfs.js';
|
|
13
|
+
import { pyStrip } from './factfile.js';
|
|
14
|
+
import { MemoryValidationError } from './store.js';
|
|
15
|
+
/** `Path(".bantamkit") / "memory"`, kept as its components so `pyJoin` builds it. */
|
|
16
|
+
export const PROJECT_STORE = ['.bantamkit', 'memory'];
|
|
17
|
+
export const CONFIG_NAME = 'config.yaml';
|
|
18
|
+
/**
|
|
19
|
+
* The operator's one lever over binding, and the only one they actually hold: an MCP
|
|
20
|
+
* server's cwd is chosen by the HOST, not by the user, so every rule derived from cwd is a
|
|
21
|
+
* rule the operator cannot control. The name is spelled here and nowhere else — including in
|
|
22
|
+
* the tests and in the sentences `component.Memory` builds — because a magic string
|
|
23
|
+
* duplicated between a resolver and its message is how the two come to name different
|
|
24
|
+
* variables.
|
|
25
|
+
*/
|
|
26
|
+
export const MEMORY_DIR_ENV = 'BANTAMKIT_MEMORY_DIR';
|
|
27
|
+
/**
|
|
28
|
+
* Walk up from `start` (default cwd) looking for an existing `.bantamkit/memory`.
|
|
29
|
+
*
|
|
30
|
+
* Returns the nearest existing store dir; if none exists anywhere up the tree, designates
|
|
31
|
+
* `start/.bantamkit/memory` without creating anything. The ancestor path is fully resolved;
|
|
32
|
+
* the returned store path is not resolved further — a symlinked store keeps its config
|
|
33
|
+
* beside the symlink. `MEMORY_DIR_ENV` outranks all of it.
|
|
34
|
+
*/
|
|
35
|
+
export function discoverProjectStore(start) {
|
|
36
|
+
const pin = pinnedStore();
|
|
37
|
+
if (pin !== null)
|
|
38
|
+
return pin;
|
|
39
|
+
return walkToStore(resolvedBase(start));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The same walk, plus what is actually in the store.
|
|
43
|
+
*
|
|
44
|
+
* Read-only in both directions. An unreadable `facts/` raises `MemoryValidationError` rather
|
|
45
|
+
* than reporting "empty" — reporting a store you could not read as a store with nothing in
|
|
46
|
+
* it is the exact conflation this exists to end. A pin never yields "designated":
|
|
47
|
+
* `pinnedStore` has already established that the pinned directory is there, or raised.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveProjectStore(start) {
|
|
50
|
+
const pin = pinnedStore();
|
|
51
|
+
if (pin !== null)
|
|
52
|
+
return countIntoBinding(pin, null, 'pin');
|
|
53
|
+
const base = resolvedBase(start);
|
|
54
|
+
const path = walkToStore(base);
|
|
55
|
+
if (!pyIsDir(path)) {
|
|
56
|
+
return { path, state: 'designated', factCount: 0, searchedFrom: base, origin: 'walk' };
|
|
57
|
+
}
|
|
58
|
+
return countIntoBinding(path, base, 'walk');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* How many `facts/*.md` a store holds, counted so that unreadable is never zero.
|
|
62
|
+
*
|
|
63
|
+
* A directory scan that fails RAISES and each caller decides what to do with it; nobody gets
|
|
64
|
+
* to be told "empty" by accident. The name filter mirrors `MemoryStore._facts`' `glob("*.md")`
|
|
65
|
+
* exactly — dotfiles and directories included — because a count that disagrees with the read
|
|
66
|
+
* is the other half of the same lie.
|
|
67
|
+
*
|
|
68
|
+
* THIS DECIDES DIFFERENTLY FROM `store._listing` AND THAT IS A KNOWN DEFECT, DEFERRED
|
|
69
|
+
* UPSTREAM. `_listing` keys "first run" on `os.path.lexists`, so a `facts/` that is a
|
|
70
|
+
* DANGLING SYMLINK is unreadable there; this catches `FileNotFoundError` and only that, so
|
|
71
|
+
* the same shape counts 0 and the binding answers `state="empty"`. runtime-py pins the
|
|
72
|
+
* disagreement with an unconditional `xfail(strict=True)` on
|
|
73
|
+
* `test_a_dangling_facts_symlink_is_unreadable_to_both_layers`, and job37/W5 measured what it
|
|
74
|
+
* costs: on a read-only GRANT layer `Memory.recall` then says "nothing is saved in any layer
|
|
75
|
+
* bound here" where the same store at `0o311` correctly says "could not be read".
|
|
76
|
+
*
|
|
77
|
+
* DO NOT FIX IT HERE. A port that quietly does the right thing where the reference does the
|
|
78
|
+
* wrong thing is a port that disagrees with production. The conformance case
|
|
79
|
+
* `count_facts: a dangling facts/ symlink is 0 — THE DEFERRED DEFECT` compares this against
|
|
80
|
+
* the running Python, so the day `count_facts` grows the check, that case goes red and
|
|
81
|
+
* somebody has to come here.
|
|
82
|
+
*
|
|
83
|
+
* On Windows the shape splits in two and only one half is this one: a FILE reparse point
|
|
84
|
+
* raises `NotADirectoryError` (errno 20, winerror 267) and the layers AGREE, while a
|
|
85
|
+
* DIRECTORY reparse point raises `FileNotFoundError` (errno 2, winerror 3) and they do not.
|
|
86
|
+
* On POSIX a symlink has no type and both collapse to the second. NOT MEASURED on Windows.
|
|
87
|
+
*/
|
|
88
|
+
export function countFacts(root) {
|
|
89
|
+
const facts = pyJoin(root, 'facts');
|
|
90
|
+
try {
|
|
91
|
+
return pyScandirNames(facts).filter((name) => matchesMd(name)).length;
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
const error = e instanceof PyOSError ? e : asPyOSError(e, facts);
|
|
95
|
+
if (error.code === 'ENOENT')
|
|
96
|
+
return 0; // `except FileNotFoundError` and nothing wider
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function countIntoBinding(path, searchedFrom, origin) {
|
|
101
|
+
let count;
|
|
102
|
+
try {
|
|
103
|
+
count = countFacts(path);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
if (!(e instanceof PyOSError))
|
|
107
|
+
throw e;
|
|
108
|
+
throw new MemoryValidationError(`memory store is unreadable: ${pyJoin(path, 'facts')}: ${e.strerror}; a store that ` +
|
|
109
|
+
'could not be listed is not a store with nothing in it, and answering ' +
|
|
110
|
+
"'empty' here is the conflation this binding exists to end");
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
path,
|
|
114
|
+
state: count ? 'populated' : 'empty',
|
|
115
|
+
factCount: count,
|
|
116
|
+
searchedFrom,
|
|
117
|
+
origin,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The store named by `MEMORY_DIR_ENV`, or `null` if the operator named none.
|
|
122
|
+
*
|
|
123
|
+
* Three rulings live here, and each one is a choice about who gets blamed. A BLANK value is
|
|
124
|
+
* not a pin — a host that emits `""` has named no store. A RELATIVE pin raises: a path
|
|
125
|
+
* resolved against cwd is a pin whose meaning depends on the exact thing the pin exists to
|
|
126
|
+
* escape. `~` is expanded first, because an MCP host passes `env` verbatim with no shell to
|
|
127
|
+
* expand it. A pin that is NOT THERE raises and is never created.
|
|
128
|
+
*
|
|
129
|
+
* `os.stat` rather than `is_dir()` on purpose: a pin is a single path the operator named out
|
|
130
|
+
* loud, so it gets the accurate reason rather than being reported as a typo. The path is not
|
|
131
|
+
* symlink-resolved, matching the walk.
|
|
132
|
+
*/
|
|
133
|
+
function pinnedStore() {
|
|
134
|
+
const raw = process.env[MEMORY_DIR_ENV];
|
|
135
|
+
if (raw === undefined || pyStrip(raw) === '')
|
|
136
|
+
return null;
|
|
137
|
+
const pin = pyExpanduser(raw);
|
|
138
|
+
if (!pyIsAbsolute(pin)) {
|
|
139
|
+
throw new MemoryValidationError(`pinned memory store must be an absolute path, got ${pyRepr(raw)} ` +
|
|
140
|
+
`(${MEMORY_DIR_ENV}=${raw}); a relative pin is resolved against a cwd ` +
|
|
141
|
+
'the MCP host chose, which is what the pin exists to override');
|
|
142
|
+
}
|
|
143
|
+
let isDir;
|
|
144
|
+
try {
|
|
145
|
+
isDir = pyStatIsDir(pin);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (!(e instanceof PyOSError))
|
|
149
|
+
throw e;
|
|
150
|
+
throw new MemoryValidationError(`pinned memory store is unreachable: ${pin}: ${e.strerror} ` +
|
|
151
|
+
`(${MEMORY_DIR_ENV}=${raw}); nothing was created`);
|
|
152
|
+
}
|
|
153
|
+
if (!isDir) {
|
|
154
|
+
throw new MemoryValidationError(`pinned memory store is not a directory: ${pin} (${MEMORY_DIR_ENV}=${raw})`);
|
|
155
|
+
}
|
|
156
|
+
return pin;
|
|
157
|
+
}
|
|
158
|
+
function resolvedBase(start) {
|
|
159
|
+
return pyResolve(start === undefined || start === null ? pyCwd() : start);
|
|
160
|
+
}
|
|
161
|
+
/** `for d in (base, *base.parents)` — it stops at `/`, and designates under `base` if it got there. */
|
|
162
|
+
function walkToStore(base) {
|
|
163
|
+
for (const d of [base, ...pyParents(base)]) {
|
|
164
|
+
const candidate = pyJoin(d, ...PROJECT_STORE);
|
|
165
|
+
if (pyIsDir(candidate))
|
|
166
|
+
return candidate;
|
|
167
|
+
}
|
|
168
|
+
return pyJoin(base, ...PROJECT_STORE);
|
|
169
|
+
}
|
|
170
|
+
// ------------------------------------------------------------------------------- grants
|
|
171
|
+
/**
|
|
172
|
+
* Read extra read-only store paths from the config beside the project store.
|
|
173
|
+
*
|
|
174
|
+
* Missing config -> no grants. A config that exists but is wrong — unparsable, not a
|
|
175
|
+
* mapping, non-list/non-str `extra_stores`, or a listed path that is not an existing
|
|
176
|
+
* directory — raises `MemoryValidationError`: a grant you wrote that is wrong is a mistake to
|
|
177
|
+
* surface at construction, not silently drop. Returned grant paths are fully resolved.
|
|
178
|
+
*/
|
|
179
|
+
export function loadGrants(projectStore) {
|
|
180
|
+
const configPath = pyJoin(pyParent(projectStore), CONFIG_NAME);
|
|
181
|
+
if (!pyExists(configPath))
|
|
182
|
+
return [];
|
|
183
|
+
if (!pyIsFile(configPath)) {
|
|
184
|
+
throw new MemoryValidationError(`invalid memory config ${configPath}: not a file`);
|
|
185
|
+
}
|
|
186
|
+
let data;
|
|
187
|
+
try {
|
|
188
|
+
data = parseConfigDocument(pyReadText(configPath));
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
// `except (yaml.YAMLError, OSError, UnicodeDecodeError)`. `pyReadText` raises the last
|
|
192
|
+
// two with CPython's own text; the first is where this port's reason differs — see the
|
|
193
|
+
// ruling on `parseConfigDocument`.
|
|
194
|
+
throw new MemoryValidationError(`invalid memory config ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
195
|
+
}
|
|
196
|
+
if (data.kind === 'null')
|
|
197
|
+
return [];
|
|
198
|
+
if (data.kind !== 'mapping') {
|
|
199
|
+
throw new MemoryValidationError(`invalid memory config ${configPath}: expected a mapping`);
|
|
200
|
+
}
|
|
201
|
+
const raw = data.entries.get('extra_stores') ?? { kind: 'seq', items: [] };
|
|
202
|
+
if (raw.kind !== 'seq' || !raw.items.every((item) => item.kind === 'str')) {
|
|
203
|
+
throw new MemoryValidationError(`invalid memory config ${configPath}: extra_stores must be a list of paths`);
|
|
204
|
+
}
|
|
205
|
+
const grants = [];
|
|
206
|
+
for (const entry of raw.items) {
|
|
207
|
+
const resolved = pyResolve(pyJoin(pyParent(configPath), entry.value));
|
|
208
|
+
if (!pyIsDir(resolved)) {
|
|
209
|
+
throw new MemoryValidationError(`granted store does not exist: ${resolved} (from ${configPath})`);
|
|
210
|
+
}
|
|
211
|
+
grants.push(resolved);
|
|
212
|
+
}
|
|
213
|
+
return grants;
|
|
214
|
+
}
|
|
215
|
+
class ConfigParseError extends Error {
|
|
216
|
+
}
|
|
217
|
+
function parseConfigDocument(text) {
|
|
218
|
+
const lines = text.split('\n');
|
|
219
|
+
if (lines[lines.length - 1] === '')
|
|
220
|
+
lines.pop(); // the trailing newline is not a line
|
|
221
|
+
let i = 0;
|
|
222
|
+
const isBlank = (line) => pyStrip(line) === '' || /^\s*#/.test(line);
|
|
223
|
+
while (i < lines.length && isBlank(lines[i]))
|
|
224
|
+
i += 1;
|
|
225
|
+
if (i < lines.length && lines[i].trimEnd() === '---') {
|
|
226
|
+
i += 1;
|
|
227
|
+
while (i < lines.length && isBlank(lines[i]))
|
|
228
|
+
i += 1;
|
|
229
|
+
}
|
|
230
|
+
if (i >= lines.length)
|
|
231
|
+
return { kind: 'null' };
|
|
232
|
+
const first = lines[i];
|
|
233
|
+
if (/^\s*(?:-(?:\s|$)|\[)/.test(first))
|
|
234
|
+
return { kind: 'other' }; // a sequence document
|
|
235
|
+
if (/^\s*(?:[!&*]|<<|---|\.\.\.)/.test(first)) {
|
|
236
|
+
throw new ConfigParseError('a tag, an anchor, an alias or a second document is outside this reader');
|
|
237
|
+
}
|
|
238
|
+
const entries = new Map();
|
|
239
|
+
let indent = null;
|
|
240
|
+
while (i < lines.length) {
|
|
241
|
+
const line = lines[i];
|
|
242
|
+
if (isBlank(line)) {
|
|
243
|
+
i += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// A key: not starting with a flow indicator, a quote or a comment, and followed by a
|
|
247
|
+
// colon and whitespace — `a:b` is a plain SCALAR in YAML, not a mapping entry.
|
|
248
|
+
const head = /^([ \t]*)([^\s#:'"[\]{}&*!][^:]*):([ \t]|$)/.exec(line);
|
|
249
|
+
if (!head || (indent !== null && head[1] !== indent)) {
|
|
250
|
+
// The very first content line deciding not to be a mapping entry is a scalar
|
|
251
|
+
// document, which is what PyYAML answers too; anything later is malformed. A key at
|
|
252
|
+
// a different indent from the first is a nested mapping, which this reader refuses
|
|
253
|
+
// rather than flattening into a grant list that was never written.
|
|
254
|
+
if (entries.size === 0 && !head)
|
|
255
|
+
return { kind: 'other' };
|
|
256
|
+
throw new ConfigParseError(`line ${i + 1} is not a root "key: value" mapping entry`);
|
|
257
|
+
}
|
|
258
|
+
indent = head[1];
|
|
259
|
+
const key = pyStrip(head[2]);
|
|
260
|
+
const rest = line.slice(head[1].length + head[2].length + 1);
|
|
261
|
+
i += 1;
|
|
262
|
+
if (pyStrip(stripComment(rest)) === '') {
|
|
263
|
+
const [items, next] = scanBlockSequence(lines, i);
|
|
264
|
+
entries.set(key, items === null ? { kind: 'other', value: null } : { kind: 'seq', items });
|
|
265
|
+
i = next;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
entries.set(key, scanValue(pyStrip(stripComment(rest))));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { kind: 'mapping', entries };
|
|
272
|
+
}
|
|
273
|
+
/** A block sequence under a key, at whatever indent; `null` when there is none. */
|
|
274
|
+
function scanBlockSequence(lines, from) {
|
|
275
|
+
const items = [];
|
|
276
|
+
let i = from;
|
|
277
|
+
for (;;) {
|
|
278
|
+
while (i < lines.length && (pyStrip(lines[i]) === '' || /^\s*#/.test(lines[i])))
|
|
279
|
+
i += 1;
|
|
280
|
+
if (i >= lines.length)
|
|
281
|
+
break;
|
|
282
|
+
const item = /^(\s*)-(\s+|$)/.exec(lines[i]);
|
|
283
|
+
if (!item)
|
|
284
|
+
break;
|
|
285
|
+
const rest = pyStrip(stripComment(lines[i].slice(item[0].length)));
|
|
286
|
+
if (rest === '') {
|
|
287
|
+
throw new ConfigParseError('a null item in a block sequence is outside this reader');
|
|
288
|
+
}
|
|
289
|
+
const value = scanValue(rest);
|
|
290
|
+
if (value.kind === 'seq')
|
|
291
|
+
throw new ConfigParseError('a nested sequence is outside this reader');
|
|
292
|
+
items.push(value);
|
|
293
|
+
i += 1;
|
|
294
|
+
}
|
|
295
|
+
return [items.length === 0 ? null : items, i];
|
|
296
|
+
}
|
|
297
|
+
/** One value: a flow sequence, a quoted scalar, or a plain one resolved by tag. */
|
|
298
|
+
function scanValue(text) {
|
|
299
|
+
if (text.startsWith('[')) {
|
|
300
|
+
if (!text.endsWith(']'))
|
|
301
|
+
throw new ConfigParseError('an unterminated flow sequence');
|
|
302
|
+
const body = text.slice(1, -1).trim();
|
|
303
|
+
if (body === '')
|
|
304
|
+
return { kind: 'seq', items: [] };
|
|
305
|
+
const items = [];
|
|
306
|
+
for (const piece of splitFlow(body)) {
|
|
307
|
+
const value = scanValue(piece.trim());
|
|
308
|
+
if (value.kind === 'seq')
|
|
309
|
+
throw new ConfigParseError('a nested sequence is outside this reader');
|
|
310
|
+
items.push(value);
|
|
311
|
+
}
|
|
312
|
+
return { kind: 'seq', items };
|
|
313
|
+
}
|
|
314
|
+
if (text.startsWith('{'))
|
|
315
|
+
throw new ConfigParseError('a flow mapping is outside this reader');
|
|
316
|
+
if (text.startsWith("'") || text.startsWith('"')) {
|
|
317
|
+
return { kind: 'str', value: unquote(text) };
|
|
318
|
+
}
|
|
319
|
+
const tag = resolveImplicitTag(text);
|
|
320
|
+
if (tag === 'tag:yaml.org,2002:str')
|
|
321
|
+
return { kind: 'str', value: text };
|
|
322
|
+
if (tag === 'tag:yaml.org,2002:null')
|
|
323
|
+
return { kind: 'other', value: null };
|
|
324
|
+
return { kind: 'other', value: text };
|
|
325
|
+
}
|
|
326
|
+
/** `a, b` at flow level 0. A comma inside quotes is content, not a separator. */
|
|
327
|
+
function splitFlow(body) {
|
|
328
|
+
const out = [];
|
|
329
|
+
let current = '';
|
|
330
|
+
let quote = null;
|
|
331
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
332
|
+
const ch = body[i];
|
|
333
|
+
if (quote !== null) {
|
|
334
|
+
current += ch;
|
|
335
|
+
if (ch === '\\' && quote === '"') {
|
|
336
|
+
current += body[i + 1] ?? '';
|
|
337
|
+
i += 1;
|
|
338
|
+
}
|
|
339
|
+
else if (ch === quote) {
|
|
340
|
+
quote = null;
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (ch === "'" || ch === '"') {
|
|
345
|
+
quote = ch;
|
|
346
|
+
current += ch;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (ch === ',') {
|
|
350
|
+
out.push(current);
|
|
351
|
+
current = '';
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
current += ch;
|
|
355
|
+
}
|
|
356
|
+
if (quote !== null)
|
|
357
|
+
throw new ConfigParseError('an unterminated quoted scalar');
|
|
358
|
+
out.push(current);
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
/** A single-line quoted scalar: `''` is a literal quote, and `\` escapes inside `"`. */
|
|
362
|
+
function unquote(text) {
|
|
363
|
+
const quote = text[0];
|
|
364
|
+
let out = '';
|
|
365
|
+
let i = 1;
|
|
366
|
+
for (;;) {
|
|
367
|
+
if (i >= text.length)
|
|
368
|
+
throw new ConfigParseError('an unterminated quoted scalar');
|
|
369
|
+
const ch = text[i];
|
|
370
|
+
if (ch === quote) {
|
|
371
|
+
if (quote === "'" && text[i + 1] === "'") {
|
|
372
|
+
out += "'";
|
|
373
|
+
i += 2;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (pyStrip(text.slice(i + 1)) !== '') {
|
|
377
|
+
throw new ConfigParseError('trailing content after a quoted scalar');
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
if (quote === '"' && ch === '\\') {
|
|
382
|
+
out += text[i + 1] ?? '';
|
|
383
|
+
i += 2;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
out += ch;
|
|
387
|
+
i += 1;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** A ` #` outside quotes starts a comment. Inside them it is content. */
|
|
391
|
+
function stripComment(text) {
|
|
392
|
+
let quote = null;
|
|
393
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
394
|
+
const ch = text[i];
|
|
395
|
+
if (quote !== null) {
|
|
396
|
+
if (ch === '\\' && quote === '"')
|
|
397
|
+
i += 1;
|
|
398
|
+
else if (ch === quote)
|
|
399
|
+
quote = null;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (ch === "'" || ch === '"') {
|
|
403
|
+
quote = ch;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (ch === '#' && (i === 0 || /\s/.test(text[i - 1])))
|
|
407
|
+
return text.slice(0, i);
|
|
408
|
+
}
|
|
409
|
+
return text;
|
|
410
|
+
}
|