bantamkit-mcp 0.35.6 → 0.35.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/cli.js +28 -0
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +14 -1
- package/dist/eventlog.js +23 -3
- package/dist/hookadapter.js +260 -45
- package/dist/hostinstall.js +250 -79
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +18 -27
- package/dist/mcp/status.js +11 -4
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +1 -1
package/dist/memory/store.js
CHANGED
|
@@ -38,11 +38,13 @@
|
|
|
38
38
|
* handed — the two branches Python takes when `_snapshot` is pinned have no reachable
|
|
39
39
|
* caller here. Everything else about both functions is the reference's.
|
|
40
40
|
*/
|
|
41
|
+
import { randomBytes } from 'node:crypto';
|
|
42
|
+
import { writeFileSync } from 'node:fs';
|
|
41
43
|
import { basename } from 'node:path';
|
|
42
44
|
import { BantamError } from '../errors.js';
|
|
43
45
|
import { formatFact, parseFrontmatter, pySplit, pyStrip, todayLocal } from './factfile.js';
|
|
44
46
|
import { PyScalar } from './pyyaml.js';
|
|
45
|
-
import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyParent, pyParents, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink,
|
|
47
|
+
import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyIsDir, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyNewlineOut, pyParent, pyParents, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWriteText, PyOSError, PyUnicodeDecodeError, sortedPathNames, } from './pyfs.js';
|
|
46
48
|
/**
|
|
47
49
|
* The exact bytes written into a `.bantamkit/.gitignore` the first time such a directory is
|
|
48
50
|
* brought into existence (user ruling 2026-09-15, `.shiftwork/notes-job51/P0-probes.md`).
|
|
@@ -162,6 +164,42 @@ export const DURABLE_TYPES = ['feedback', 'user'];
|
|
|
162
164
|
*/
|
|
163
165
|
const NAME_RE = /^[a-z0-9][a-z0-9-]*\n?$/;
|
|
164
166
|
const NAME_PATTERN = '^[a-z0-9][a-z0-9-]*$';
|
|
167
|
+
/**
|
|
168
|
+
* THE LONGEST NAME A SAVE ACCEPTS (job65/#156): `NAME_MAX_CHARS` in the reference, the same
|
|
169
|
+
* number. The fact lands as `<name>.md` through `<name>.md.<pid>.<8 hex>.tmp` (#161), so the
|
|
170
|
+
* name leaves room for that suffix under the 255-byte file-name limit. WAS: no cap, and a
|
|
171
|
+
* 301-character name came back as a raw `[Errno 63] File name too long: '<absolute path>'`.
|
|
172
|
+
* `NAME_RE` admits ASCII only, so characters and bytes are one count.
|
|
173
|
+
*/
|
|
174
|
+
export const NAME_MAX_CHARS = 128;
|
|
175
|
+
/**
|
|
176
|
+
* ONE LINE MEANS ONE LINE (job65/#155): the reference's `_NOT_ONE_LINE`, the same ranges —
|
|
177
|
+
* every C0/C1 control plus U+2028/U+2029. Refused in a saved `description`; in a fact file on
|
|
178
|
+
* disk each run of them is READ as one space (see `facts()`), so `index.md` and the hook's
|
|
179
|
+
* injected header can never gain a line nobody saved.
|
|
180
|
+
*/
|
|
181
|
+
const NOT_ONE_LINE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
|
|
182
|
+
/**
|
|
183
|
+
* A LONE SURROGATE IS NOT TEXT (job65/#160). JSON can carry `\ud800` on its own; no UTF-8
|
|
184
|
+
* file can hold it. This port used to write U+FFFD and say "saved"; the reference raised a
|
|
185
|
+
* `UnicodeEncodeError`. A JS string holds a pair as two code units, so a surrogate is lone
|
|
186
|
+
* when it is a high one not followed by a low one, or a low one not preceded by a high one.
|
|
187
|
+
*/
|
|
188
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
189
|
+
function codepoint(unit) {
|
|
190
|
+
return `U+${unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
|
|
191
|
+
}
|
|
192
|
+
/** `U+XXXX` of the first lone surrogate in `text`, or `null` when it is all text. */
|
|
193
|
+
export function loneSurrogate(text) {
|
|
194
|
+
const found = LONE_SURROGATE.exec(text);
|
|
195
|
+
return found === null ? null : codepoint(found[0]);
|
|
196
|
+
}
|
|
197
|
+
function notOneLine(text) {
|
|
198
|
+
NOT_ONE_LINE.lastIndex = 0;
|
|
199
|
+
const found = NOT_ONE_LINE.exec(text);
|
|
200
|
+
NOT_ONE_LINE.lastIndex = 0;
|
|
201
|
+
return found === null ? null : codepoint(found[0]);
|
|
202
|
+
}
|
|
165
203
|
export const DUPLICATE_JACCARD = 0.5;
|
|
166
204
|
// ---- the precision gate (roadmap #6) ---------------------------------------------
|
|
167
205
|
//
|
|
@@ -276,6 +314,13 @@ const FACT_UNREACHABLE = "a fact that could not be stat'd is not a fact that is
|
|
|
276
314
|
'this path';
|
|
277
315
|
const ARCHIVE_DESTINATION_UNREACHABLE = "a destination that could not be stat'd is not a name that is already archived, " +
|
|
278
316
|
'and nothing has moved: the fact is still in facts/';
|
|
317
|
+
/**
|
|
318
|
+
* job65/#141, #123: the codes that say a directory EXISTS and this process may not write into
|
|
319
|
+
* it — `NOT_WRITABLE_ERRNOS` in the reference. EACCES is the chmod case everywhere, EPERM an
|
|
320
|
+
* immutable flag or a Windows ACL, EROFS a read-only mount. On the mkdir path it is only half
|
|
321
|
+
* the test (`ensureDirs` also needs the root to be there); see the reference's docstring.
|
|
322
|
+
*/
|
|
323
|
+
const NOT_WRITABLE_CODES = new Set(['EACCES', 'EPERM', 'EROFS']);
|
|
279
324
|
export class MemoryValidationError extends BantamError {
|
|
280
325
|
}
|
|
281
326
|
export class MemoryBudgetExceeded extends BantamError {
|
|
@@ -632,6 +677,13 @@ export class MemoryStore {
|
|
|
632
677
|
* the failing leaf, or that strerror, would be a divergence manufactured by mkdir's
|
|
633
678
|
* internals. `this.root` is the path the caller named and both sides agree on it, which is
|
|
634
679
|
* what lets one conformance case pin this line byte for byte.
|
|
680
|
+
*
|
|
681
|
+
* AMENDED job65/#141: THE SENTENCE ABOVE IS NOW THE "NOT THERE" ARM ONLY. A `chmod 000`
|
|
682
|
+
* store answered it too, about a directory that exists. The second arm is still an outcome:
|
|
683
|
+
* the mkdir failed with one of `NOT_WRITABLE_CODES` AND `this.root` is a directory that is
|
|
684
|
+
* there, and then the refusal is `memory store is not writable: <root>: <strerror>; the
|
|
685
|
+
* directory exists but cannot be written to, so nothing was written`. The cwd-`/` divergence
|
|
686
|
+
* measured above is about a root that is NOT there, so it never reaches this arm.
|
|
635
687
|
*/
|
|
636
688
|
ensureDirs() {
|
|
637
689
|
// Checked BEFORE the mkdir below, per user ruling #2: this is the one moment that can
|
|
@@ -651,12 +703,30 @@ export class MemoryStore {
|
|
|
651
703
|
catch (e) {
|
|
652
704
|
if (!(e instanceof PyOSError))
|
|
653
705
|
throw e;
|
|
706
|
+
if (NOT_WRITABLE_CODES.has(e.code) && this.present())
|
|
707
|
+
throw this.unwritable(e);
|
|
654
708
|
throw new MemoryValidationError(`memory store could not be created: ${this.root}; the directory is not` +
|
|
655
709
|
' there and this filesystem would not make it, so nothing was written');
|
|
656
710
|
}
|
|
657
711
|
if (bantamkitDir !== null)
|
|
658
712
|
ensureBantamkitGitignore(bantamkitDir, !bantamkitDirExistedBefore);
|
|
659
713
|
}
|
|
714
|
+
/** `_present`: is `this.root` a directory that is there? "Not allowed to look" is "there". */
|
|
715
|
+
present() {
|
|
716
|
+
try {
|
|
717
|
+
return pyIsDir(this.root);
|
|
718
|
+
}
|
|
719
|
+
catch (e) {
|
|
720
|
+
if (!(e instanceof PyOSError))
|
|
721
|
+
throw e;
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** `_unwritable`: one sentence for a store that exists and refused a write (#141). */
|
|
726
|
+
unwritable(error) {
|
|
727
|
+
return new MemoryValidationError(`memory store is not writable: ${this.root}: ${error.strerror}; the directory` +
|
|
728
|
+
' exists but cannot be written to, so nothing was written');
|
|
729
|
+
}
|
|
660
730
|
// ---- ops ----
|
|
661
731
|
/**
|
|
662
732
|
* Write a fact, or refuse it as a near-duplicate of one already here.
|
|
@@ -679,6 +749,24 @@ export class MemoryStore {
|
|
|
679
749
|
if (pyStrip(description || '') === '') {
|
|
680
750
|
throw new MemoryValidationError('description must be a non-empty line');
|
|
681
751
|
}
|
|
752
|
+
if (name.length > NAME_MAX_CHARS) {
|
|
753
|
+
throw new MemoryValidationError(`invalid name: ${name.length} characters; must be at most ${NAME_MAX_CHARS}`);
|
|
754
|
+
}
|
|
755
|
+
const texts = [
|
|
756
|
+
['description', description],
|
|
757
|
+
['body', body],
|
|
758
|
+
...links.map((link, i) => [`links[${i}]`, link]),
|
|
759
|
+
];
|
|
760
|
+
for (const [label, value] of texts) {
|
|
761
|
+
const bad = typeof value === 'string' ? loneSurrogate(value) : null;
|
|
762
|
+
if (bad !== null) {
|
|
763
|
+
throw new MemoryValidationError(`${label} holds a lone surrogate (${bad}), which is not text and cannot be stored as UTF-8; nothing was saved`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const broken = notOneLine(pyStrip(description));
|
|
767
|
+
if (broken !== null) {
|
|
768
|
+
throw new MemoryValidationError(`description must be one line; it holds the control character ${broken}`);
|
|
769
|
+
}
|
|
682
770
|
const newTokens = tokens(`${name} ${description}`);
|
|
683
771
|
let existing = null;
|
|
684
772
|
for (const fact of this.facts()) {
|
|
@@ -703,7 +791,16 @@ export class MemoryStore {
|
|
|
703
791
|
};
|
|
704
792
|
const path = this.factPath(name);
|
|
705
793
|
const existed = pyExists(path) ? pyReadText(path) : null;
|
|
706
|
-
|
|
794
|
+
// #141, the 555 shape: every directory was already there, so the fact write is the first
|
|
795
|
+
// syscall the mode refuses. Only the "exists, not writable" codes become the sentence.
|
|
796
|
+
try {
|
|
797
|
+
this.writeFact(fact);
|
|
798
|
+
}
|
|
799
|
+
catch (e) {
|
|
800
|
+
if (e instanceof PyOSError && NOT_WRITABLE_CODES.has(e.code))
|
|
801
|
+
throw this.unwritable(e);
|
|
802
|
+
throw e;
|
|
803
|
+
}
|
|
707
804
|
try {
|
|
708
805
|
this.checkIndexBudget();
|
|
709
806
|
}
|
|
@@ -745,13 +842,13 @@ export class MemoryStore {
|
|
|
745
842
|
* reference's `not 0.0 <= min_ratio <= 1.0` refuses it. That is a real mutant (M7b) and
|
|
746
843
|
* it is killed on both sides.
|
|
747
844
|
*/
|
|
748
|
-
recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO) {
|
|
845
|
+
recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO, skipped) {
|
|
749
846
|
if (!(minRatio >= 0 && minRatio <= 1))
|
|
750
847
|
throw new MemoryValidationError(MIN_RATIO_RANGE);
|
|
751
848
|
const limit = k ?? this.k;
|
|
752
849
|
const q = tokens(query);
|
|
753
850
|
let scored = [];
|
|
754
|
-
for (const fact of this.facts()) {
|
|
851
|
+
for (const fact of this.facts(undefined, skipped)) {
|
|
755
852
|
let score = 0;
|
|
756
853
|
for (const t of tokens(`${pyText(fact.name)} ${pyText(fact.description)}`))
|
|
757
854
|
if (q.has(t))
|
|
@@ -798,8 +895,8 @@ export class MemoryStore {
|
|
|
798
895
|
* order, which is the order `recall` breaks a tie in. `stamp` dates the hit through
|
|
799
896
|
* `stamp()` exactly as `recall` dates its hits; a miss writes nothing.
|
|
800
897
|
*/
|
|
801
|
-
lookup(name, stamp = true) {
|
|
802
|
-
for (const fact of this.facts()) {
|
|
898
|
+
lookup(name, stamp = true, skipped) {
|
|
899
|
+
for (const fact of this.facts(undefined, skipped)) {
|
|
803
900
|
if (typeof fact.name === 'string' && fact.name === name) {
|
|
804
901
|
if (stamp)
|
|
805
902
|
this.stamp(fact);
|
|
@@ -828,11 +925,19 @@ export class MemoryStore {
|
|
|
828
925
|
* and the budget error to `lint: FAIL — …` on stderr with exit 1.
|
|
829
926
|
*/
|
|
830
927
|
lint() {
|
|
831
|
-
|
|
928
|
+
const findings = [];
|
|
929
|
+
for (const fact of this.facts(findings)) {
|
|
832
930
|
if (!VALID_TYPES.includes(pyText(fact.type))) {
|
|
833
931
|
throw new MemoryValidationError(`fact '${pyText(fact.name)}' has invalid type '${pyText(fact.type)}'`);
|
|
834
932
|
}
|
|
835
933
|
}
|
|
934
|
+
// The two hand-edit shapes every other op now TOLERATES (#121, #122) are named here,
|
|
935
|
+
// first one in file order, because lint is the one surface whose job is to say what an
|
|
936
|
+
// operator should fix: an unquoted timestamp that `facts` read as a string, and a file
|
|
937
|
+
// whose name is not the fact's. Neither is a reason for `recall` or `compact` to refuse
|
|
938
|
+
// the whole store, and both are a reason for `lint` to exit 1.
|
|
939
|
+
if (findings.length > 0)
|
|
940
|
+
throw new MemoryValidationError(findings[0]);
|
|
836
941
|
this.checkIndexBudget();
|
|
837
942
|
}
|
|
838
943
|
/**
|
|
@@ -924,9 +1029,13 @@ export class MemoryStore {
|
|
|
924
1029
|
// name, instead of surfacing as a `FileNotFoundError` out of the final write. `exist_ok`
|
|
925
1030
|
// semantics make it free for every store that is already there.
|
|
926
1031
|
this.ensureDirs();
|
|
1032
|
+
// Keyed by the FACT, not by `fact.name` (#122): two files can carry one name, and a map
|
|
1033
|
+
// keyed by name collapsed them — `indexBefore` reported 88 bytes for a 134-byte index, and
|
|
1034
|
+
// the second file's line was never subtracted. `byEviction` sorts a copy of this same
|
|
1035
|
+
// array, so the objects are the keys on both sides of the loop.
|
|
927
1036
|
const sizes = new Map();
|
|
928
1037
|
for (const fact of facts) {
|
|
929
|
-
sizes.set(
|
|
1038
|
+
sizes.set(fact, Buffer.byteLength(this.indexLine(fact), 'utf8'));
|
|
930
1039
|
}
|
|
931
1040
|
const all = [...sizes.values()];
|
|
932
1041
|
if (reserve === null) {
|
|
@@ -952,9 +1061,14 @@ export class MemoryStore {
|
|
|
952
1061
|
for (const fact of this.byEviction(facts)) {
|
|
953
1062
|
if (size <= target)
|
|
954
1063
|
break;
|
|
955
|
-
|
|
1064
|
+
// THE FILE THE FACT WAS READ FROM, not `facts/<name>.md` (#122). The two are the same
|
|
1065
|
+
// path for every fact this store wrote; they part on a hand-edited file whose
|
|
1066
|
+
// frontmatter names another fact, and there `factPath(fact.name)` moved the WRONG file
|
|
1067
|
+
// — the newest fact, under the name of the stalest — or raised for a name no file
|
|
1068
|
+
// carries. `lint` names the mismatch.
|
|
1069
|
+
const path = fact.source ?? this.factPath(fact.name);
|
|
956
1070
|
pyReplace(path, pyJoin(this.root, 'archive', pyName(path)));
|
|
957
|
-
const bytes = sizes.get(
|
|
1071
|
+
const bytes = sizes.get(fact);
|
|
958
1072
|
size -= bytes;
|
|
959
1073
|
archived.push({
|
|
960
1074
|
name: fact.name,
|
|
@@ -1186,6 +1300,18 @@ export class MemoryStore {
|
|
|
1186
1300
|
* bytes `dream` MEASURES and the bytes the store WRITES are already one function. That is
|
|
1187
1301
|
* the code-shape difference the Python half had to manufacture, not a behaviour one.
|
|
1188
1302
|
*/
|
|
1303
|
+
/**
|
|
1304
|
+
* Every fact this store can read, and `{file, reason}` for each file it could not.
|
|
1305
|
+
*
|
|
1306
|
+
* THE DECLARED TOLERANT LISTING (job65/#119), `readable_facts` in the reference. One empty,
|
|
1307
|
+
* truncated, non-UTF-8 or unreadable fact file costs that file, not the store: the hook's
|
|
1308
|
+
* SessionStart index and native export read through this, as `Memory.recallOutcome` reads
|
|
1309
|
+
* through `recall(..., skipped)`. A `facts/` that cannot be listed still throws.
|
|
1310
|
+
*/
|
|
1311
|
+
readableFacts() {
|
|
1312
|
+
const skipped = [];
|
|
1313
|
+
return { facts: this.facts(undefined, skipped), skipped };
|
|
1314
|
+
}
|
|
1189
1315
|
internals() {
|
|
1190
1316
|
return {
|
|
1191
1317
|
facts: () => this.facts(),
|
|
@@ -1343,10 +1469,11 @@ export class MemoryStore {
|
|
|
1343
1469
|
/**
|
|
1344
1470
|
* Every fact on disk, parsed.
|
|
1345
1471
|
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1472
|
+
* AMENDED job65/#159: the `read_text` is now INSIDE the reference's try, so a fact file that
|
|
1473
|
+
* is not UTF-8 is `malformed fact file <name>: 'utf-8' codec can't decode …` — naming the
|
|
1474
|
+
* file — instead of the bare decoder message. `pyReadText` raises `PyUnicodeDecodeError`
|
|
1475
|
+
* with CPython's own text and it is wrapped here; a `PyOSError` from the read still escapes
|
|
1476
|
+
* as itself, as in the reference (`OSError` is not in its `except`).
|
|
1350
1477
|
*
|
|
1351
1478
|
* RULING — THE MESSAGE TEXT FOR A MALFORMED FILE. `_facts` interpolates the exception into
|
|
1352
1479
|
* `malformed fact file {name}: {e}`, and that reaches the model. Three of the four shapes
|
|
@@ -1361,12 +1488,66 @@ export class MemoryStore {
|
|
|
1361
1488
|
* carried as a must-differ `ruling` case in the conformance suite so it is re-measured on
|
|
1362
1489
|
* every run instead of asserted once and forgotten.
|
|
1363
1490
|
*/
|
|
1364
|
-
|
|
1491
|
+
/**
|
|
1492
|
+
* `_facts`: every fact on disk, in `factPaths` order, with two hand-edit shapes tolerated.
|
|
1493
|
+
*
|
|
1494
|
+
* AN UNQUOTED TIMESTAMP IS READ AS ITS `str()` (#121). `yaml.safe_load` hands the reference
|
|
1495
|
+
* a `datetime.date` for `created: 2026-01-01` and a `datetime.datetime` for the longer
|
|
1496
|
+
* forms, while every file the store writes carries the quoted string. `recall` and `save`
|
|
1497
|
+
* never minded, but `compact` sorts `(last_recalled or created, name)` and the sort raised
|
|
1498
|
+
* `'<' not supported between instances of 'datetime.date' and 'str'` the moment one
|
|
1499
|
+
* hand-edited file sat beside a written one — the only remedy the budget refusal names,
|
|
1500
|
+
* dead on the store that named it. The scalar's `text` IS `str(value)` on the reference
|
|
1501
|
+
* (`isoformat()` for a date, `isoformat(' ')` for a datetime), so both runtimes read the
|
|
1502
|
+
* same string and the next write quotes it. Only timestamps are touched: an `int` or `bool`
|
|
1503
|
+
* in these fields is left exactly as the scalar cases in `suites/store.mjs` pin it.
|
|
1504
|
+
*
|
|
1505
|
+
* `findings`, when given, collects one sentence per shape `lint` should name — the coerced
|
|
1506
|
+
* timestamp, and a file whose name is not the fact's (#122) — without throwing, because
|
|
1507
|
+
* every other caller tolerates both. `lint` is the only caller that passes an array.
|
|
1508
|
+
*/
|
|
1509
|
+
/**
|
|
1510
|
+
* `skipped`, when given, makes ONE BAD FILE COST THAT FILE (job65/#119): a file that is
|
|
1511
|
+
* malformed or cannot be read is pushed as `{file, reason}` (the sentence this would have
|
|
1512
|
+
* thrown) and every other file is still read. Only `recall`, `lookup` and `readableFacts`
|
|
1513
|
+
* pass it; a save, a compaction or a lint keeps the strict read.
|
|
1514
|
+
*/
|
|
1515
|
+
facts(findings, skipped) {
|
|
1365
1516
|
const out = [];
|
|
1366
1517
|
for (const path of this.factPaths()) {
|
|
1367
|
-
|
|
1518
|
+
if (skipped === undefined) {
|
|
1519
|
+
out.push(this.readFact(path, findings));
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
out.push(this.readFact(path, findings));
|
|
1524
|
+
}
|
|
1525
|
+
catch (e) {
|
|
1526
|
+
if (!(e instanceof BantamError || e instanceof PyOSError))
|
|
1527
|
+
throw e;
|
|
1528
|
+
skipped.push({ file: basename(path), reason: e.message });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return out;
|
|
1532
|
+
}
|
|
1533
|
+
/** One fact file, parsed, or the `malformed fact file` sentence for it (see `facts`). */
|
|
1534
|
+
readFact(path, findings) {
|
|
1535
|
+
const timestampText = (value) => value instanceof PyScalar && (value.ord.kind === 'date' || value.ord.kind === 'datetime')
|
|
1536
|
+
? value.text
|
|
1537
|
+
: value;
|
|
1538
|
+
{
|
|
1368
1539
|
const file = basename(path);
|
|
1369
1540
|
const malformed = (reason) => new MemoryValidationError(`malformed fact file ${file}: ${reason}`);
|
|
1541
|
+
let text;
|
|
1542
|
+
try {
|
|
1543
|
+
text = pyReadText(path);
|
|
1544
|
+
}
|
|
1545
|
+
catch (e) {
|
|
1546
|
+
// #159: `UnicodeDecodeError` is a `ValueError`, which the reference's `except` names.
|
|
1547
|
+
if (e instanceof PyUnicodeDecodeError)
|
|
1548
|
+
throw malformed(e.message);
|
|
1549
|
+
throw e;
|
|
1550
|
+
}
|
|
1370
1551
|
const parts = pySplit(text, '---\n', 2);
|
|
1371
1552
|
if (parts.length !== 3) {
|
|
1372
1553
|
throw malformed(`not enough values to unpack (expected 3, got ${parts.length})`);
|
|
@@ -1385,21 +1566,51 @@ export class MemoryStore {
|
|
|
1385
1566
|
if (!(key in meta))
|
|
1386
1567
|
throw malformed(`'${key}'`);
|
|
1387
1568
|
}
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1569
|
+
const name = meta['name'];
|
|
1570
|
+
const stamps = {};
|
|
1571
|
+
for (const key of ['created', 'last_recalled']) {
|
|
1572
|
+
const raw = meta[key];
|
|
1573
|
+
const value = timestampText(raw);
|
|
1574
|
+
if (findings !== undefined && value !== raw) {
|
|
1575
|
+
findings.push(`fact '${pyText(name)}' in ${file}: ${key} is the unquoted timestamp ${value}, ` +
|
|
1576
|
+
`read as '${value}'; quote it`);
|
|
1577
|
+
}
|
|
1578
|
+
stamps[key] = value;
|
|
1579
|
+
}
|
|
1580
|
+
// `path.stem`, spelled the way `archived()` spells it.
|
|
1581
|
+
const suffix = pySuffix(file);
|
|
1582
|
+
const stem = suffix === '' ? file : file.slice(0, file.length - suffix.length);
|
|
1583
|
+
if (findings !== undefined && stem !== pyText(name)) {
|
|
1584
|
+
findings.push(`fact '${pyText(name)}' in ${file}: the file name does not match the fact name; ` +
|
|
1585
|
+
`rename the file to ${pyText(name)}.md or the fact to ${stem}`);
|
|
1586
|
+
}
|
|
1587
|
+
const rawCreated = stamps['created'];
|
|
1588
|
+
// #155: a description that is not one line is READ as one — each run of `NOT_ONE_LINE`
|
|
1589
|
+
// is one space — and `lint` names it, like the other hand-edit shapes.
|
|
1590
|
+
let description = meta['description'];
|
|
1591
|
+
const broken = typeof description === 'string' ? notOneLine(description) : null;
|
|
1592
|
+
if (broken !== null) {
|
|
1593
|
+
description = description.replace(NOT_ONE_LINE, ' ');
|
|
1594
|
+
if (findings !== undefined) {
|
|
1595
|
+
findings.push(`fact '${pyText(name)}' in ${file}: the description is not one line ` +
|
|
1596
|
+
`(it holds the control character ${broken}), read as '${description}'; make it one line`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
name,
|
|
1601
|
+
description,
|
|
1392
1602
|
type: meta['type'],
|
|
1393
1603
|
body: pyStrip(parts[2]),
|
|
1394
1604
|
links: pyList(meta['links']),
|
|
1395
|
-
last_recalled:
|
|
1605
|
+
last_recalled: stamps['last_recalled'] ?? null,
|
|
1396
1606
|
// `or`, not `??`: every FALSY value falls back to the mtime, and that is now more
|
|
1397
1607
|
// than the empty string — `created: 0`, `created: false` and `created: 0.0` are all
|
|
1398
|
-
// falsy in Python too, while `created: 2026-08-23` is a truthy `date`
|
|
1608
|
+
// falsy in Python too, while `created: 2026-08-23` is a truthy `date` (now its text)
|
|
1609
|
+
// and is kept.
|
|
1399
1610
|
created: pyTruthy(rawCreated) ? rawCreated : pyMtimeDate(path),
|
|
1400
|
-
|
|
1611
|
+
source: path,
|
|
1612
|
+
};
|
|
1401
1613
|
}
|
|
1402
|
-
return out;
|
|
1403
1614
|
}
|
|
1404
1615
|
/**
|
|
1405
1616
|
* Date the recall.
|
|
@@ -1411,14 +1622,51 @@ export class MemoryStore {
|
|
|
1411
1622
|
*/
|
|
1412
1623
|
stamp(fact) {
|
|
1413
1624
|
fact.last_recalled = this.today();
|
|
1414
|
-
|
|
1625
|
+
// job65/#123: BEST-EFFORT. A read-only store (chmod 555, a read-only mount, a CI cache)
|
|
1626
|
+
// used to fail the whole recall here, after the hits were computed. A `PyOSError` from the
|
|
1627
|
+
// write now leaves the old date on disk and the recall answers; see the reference's
|
|
1628
|
+
// `_stamp` for why it is silent.
|
|
1629
|
+
try {
|
|
1630
|
+
this.writeFact(fact);
|
|
1631
|
+
}
|
|
1632
|
+
catch (e) {
|
|
1633
|
+
if (!(e instanceof PyOSError))
|
|
1634
|
+
throw e;
|
|
1635
|
+
}
|
|
1415
1636
|
}
|
|
1416
|
-
/**
|
|
1637
|
+
/**
|
|
1638
|
+
* Write via a PER-WRITER temp and `os.replace`, so a reader never sees a half-written fact
|
|
1639
|
+
* and two writers never share a temp (job65/#161).
|
|
1640
|
+
*
|
|
1641
|
+
* WAS `<name>.md.tmp`, one temp per FACT: two servers saving one name raced on it (the
|
|
1642
|
+
* second `replace` found nothing — `[Errno 2] … 'shared-name.md.tmp'`), and a write that
|
|
1643
|
+
* died half-way left the temp beside the facts. NOW the shape `clockOut` and the hook ledger
|
|
1644
|
+
* use (J65-1, J65-4): `<name>.md.<pid>.<8 hex>.tmp`, created with `wx` (O_EXCL), renamed
|
|
1645
|
+
* onto the fact, and unlinked on any failure before the error goes on up. `*.md` listings
|
|
1646
|
+
* never see it.
|
|
1647
|
+
*/
|
|
1417
1648
|
writeFact(fact) {
|
|
1418
1649
|
const path = this.factPath(fact.name);
|
|
1419
|
-
const tmp =
|
|
1420
|
-
|
|
1421
|
-
|
|
1650
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
1651
|
+
try {
|
|
1652
|
+
try {
|
|
1653
|
+
writeFileSync(tmp, pyNewlineOut(formatFact(fact)), { encoding: 'utf8', flag: 'wx' });
|
|
1654
|
+
}
|
|
1655
|
+
catch (e) {
|
|
1656
|
+
throw asPyOSError(e, tmp, undefined, 'crt');
|
|
1657
|
+
}
|
|
1658
|
+
pyReplace(tmp, path);
|
|
1659
|
+
}
|
|
1660
|
+
catch (e) {
|
|
1661
|
+
try {
|
|
1662
|
+
pyUnlink(tmp);
|
|
1663
|
+
}
|
|
1664
|
+
catch (cleanup) {
|
|
1665
|
+
if (!(cleanup instanceof PyOSError))
|
|
1666
|
+
throw cleanup;
|
|
1667
|
+
}
|
|
1668
|
+
throw e;
|
|
1669
|
+
}
|
|
1422
1670
|
}
|
|
1423
1671
|
/**
|
|
1424
1672
|
* Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.
|
package/dist/npminstall.d.ts
CHANGED
|
@@ -45,6 +45,24 @@ export declare function shlexJoin(command: readonly string[]): string;
|
|
|
45
45
|
* batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
|
|
46
46
|
*/
|
|
47
47
|
export declare function runInstaller(command: readonly string[]): [number, string];
|
|
48
|
+
/** What one spawned command came back with: both streams apart, the status, and any spawn error. */
|
|
49
|
+
export interface Spawned {
|
|
50
|
+
readonly status: number | null;
|
|
51
|
+
readonly stdout: string;
|
|
52
|
+
readonly stderr: string;
|
|
53
|
+
readonly error: (Error & {
|
|
54
|
+
code?: string;
|
|
55
|
+
}) | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One argv, spawned and captured, with the Windows shim handling `runInstaller` established
|
|
59
|
+
* (J65-19 moved it here so `--update`'s version check could spawn npm through the same rule).
|
|
60
|
+
*
|
|
61
|
+
* `timeoutMs` is `spawnSync`'s own deadline: the child is killed when it passes, and the result
|
|
62
|
+
* carries an `error` whose `code` is `ETIMEDOUT` — the one fact `selfupdate.fetchIndex` turns
|
|
63
|
+
* into its timeout refusal. No deadline when it is omitted, which is what an installer gets.
|
|
64
|
+
*/
|
|
65
|
+
export declare function spawnCommand(command: readonly string[], timeoutMs?: number): Spawned;
|
|
48
66
|
/**
|
|
49
67
|
* -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
|
|
50
68
|
*
|
package/dist/npminstall.js
CHANGED
|
@@ -76,17 +76,38 @@ export function shlexJoin(command) {
|
|
|
76
76
|
* batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
|
|
77
77
|
*/
|
|
78
78
|
export function runInstaller(command) {
|
|
79
|
-
const
|
|
80
|
-
const win = process.platform === 'win32';
|
|
81
|
-
const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
|
|
82
|
-
const done = spawnSync(program ?? '', quoted, { encoding: 'utf8', shell: win });
|
|
79
|
+
const done = spawnCommand(command);
|
|
83
80
|
if (done.error) {
|
|
84
81
|
// A missing `npm` is not a stack trace: it is a command that failed, and `COMMAND_FAILED`
|
|
85
82
|
// is the sentence that says so and hands back what went wrong. 127 is the shell's own
|
|
86
83
|
// code for "command not found", so the number means something to the person reading it.
|
|
87
84
|
return [127, done.error.message];
|
|
88
85
|
}
|
|
89
|
-
return [done.status ?? 1, `${done.stdout
|
|
86
|
+
return [done.status ?? 1, `${done.stdout}${done.stderr}`];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One argv, spawned and captured, with the Windows shim handling `runInstaller` established
|
|
90
|
+
* (J65-19 moved it here so `--update`'s version check could spawn npm through the same rule).
|
|
91
|
+
*
|
|
92
|
+
* `timeoutMs` is `spawnSync`'s own deadline: the child is killed when it passes, and the result
|
|
93
|
+
* carries an `error` whose `code` is `ETIMEDOUT` — the one fact `selfupdate.fetchIndex` turns
|
|
94
|
+
* into its timeout refusal. No deadline when it is omitted, which is what an installer gets.
|
|
95
|
+
*/
|
|
96
|
+
export function spawnCommand(command, timeoutMs) {
|
|
97
|
+
const [program, ...args] = command;
|
|
98
|
+
const win = process.platform === 'win32';
|
|
99
|
+
const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
|
|
100
|
+
const done = spawnSync(program ?? '', quoted, {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
shell: win,
|
|
103
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
status: done.status,
|
|
107
|
+
stdout: done.stdout ?? '',
|
|
108
|
+
stderr: done.stderr ?? '',
|
|
109
|
+
error: done.error ?? null,
|
|
110
|
+
};
|
|
90
111
|
}
|
|
91
112
|
/**
|
|
92
113
|
* A dotted version as something orderable, deterministically, without claiming PEP 440.
|
package/dist/pyjsonschema.d.ts
CHANGED
|
@@ -2,6 +2,24 @@ import { type PyValue } from './pyjson.js';
|
|
|
2
2
|
export declare class PyJsonSchemaUnsupported extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* A schema the validator will not resolve, answered — not raised. `contract.schemaError`
|
|
7
|
+
* returns the message as the feedback sentence, so `validate_json` says `{"valid": false,
|
|
8
|
+
* "feedback": …}` where it used to say `isError` (#125). The Python side prints the same
|
|
9
|
+
* sentence from `contract._unsupported_schema` and from the `referencing` exceptions.
|
|
10
|
+
*/
|
|
11
|
+
export declare class PyJsonSchemaRefusal extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
/** The drafts this port validates. Draft 3 is looked up so it can be refused by name. */
|
|
15
|
+
export type Draft = 4 | 6 | 7 | 2019 | 2020;
|
|
16
|
+
/**
|
|
17
|
+
* `urllib.parse.urlsplit(uri).geturl()` — `_utils.URIDict.normalize`, the function every
|
|
18
|
+
* `$schema` goes through before `_META_SCHEMAS` is consulted. 3.12 strips leading C0/space,
|
|
19
|
+
* removes every tab/CR/LF, lowercases the scheme, and `urlunsplit` drops an empty query and
|
|
20
|
+
* an empty fragment. Measured in `tools/conformance/suites/validate.mjs` (`dialect/…`).
|
|
21
|
+
*/
|
|
22
|
+
export declare function pyUrlNormalize(uri: string): string;
|
|
5
23
|
/**
|
|
6
24
|
* `jsonschema.exceptions.ValidationError`, narrowed to the fields the relevance heuristic
|
|
7
25
|
* and `schema_error` read.
|
|
@@ -11,6 +29,9 @@ export declare class PyJsonSchemaUnsupported extends Error {
|
|
|
11
29
|
* is what `schema_error` interpolates. Keeping the two apart is not pedantry: `best_match`
|
|
12
30
|
* ranks context errors by their relative paths and then `schema_error` prints an absolute
|
|
13
31
|
* one, and a port that conflated them would name the wrong field inside every `anyOf`.
|
|
32
|
+
*
|
|
33
|
+
* `draft` is the dialect the keyword ran under — `error._type_checker`, which
|
|
34
|
+
* `_matches_type` consults, and the one place the dialect reaches `best_match`.
|
|
14
35
|
*/
|
|
15
36
|
export interface PyValidationError {
|
|
16
37
|
message: string;
|
|
@@ -21,11 +42,69 @@ export interface PyValidationError {
|
|
|
21
42
|
path: (string | number)[];
|
|
22
43
|
context: PyValidationError[];
|
|
23
44
|
parent: PyValidationError | null;
|
|
45
|
+
draft: Draft;
|
|
24
46
|
}
|
|
25
47
|
export declare function absolutePath(error: PyValidationError): (string | number)[];
|
|
26
48
|
/** `jsonschema._utils.equal`: JSON equality that refuses `True == 1`. */
|
|
27
49
|
export declare function pyEqual(a: PyValue, b: PyValue): boolean;
|
|
28
|
-
|
|
50
|
+
/** What a keyword needs beyond its own value: the root resource and the dialect in force. */
|
|
51
|
+
interface Ctx {
|
|
52
|
+
root: PyValue;
|
|
53
|
+
draft: Draft;
|
|
54
|
+
/** The ROOT's dialect — anchors are found by the resource's specification, not the leaf's. */
|
|
55
|
+
rootDraft: Draft;
|
|
56
|
+
}
|
|
57
|
+
declare function iterErrors(instance: PyValue, schema: PyValue | boolean, ctx?: Ctx): PyValidationError[];
|
|
58
|
+
/**
|
|
59
|
+
* `re.search(pattern, string)` — the reference's dialect, translated, bounded (#131, #132).
|
|
60
|
+
*
|
|
61
|
+
* THE DIALECT IS PYTHON'S. Until job65 this was `new RegExp(pattern, 'u')` with a silent
|
|
62
|
+
* fallback to a non-`u` RegExp, so the two runtimes disagreed on 5 of 5 of #131's probes:
|
|
63
|
+
* `\d`/`\w` are Unicode-wide in `re` and ASCII in JS, `$` matches before a final newline in
|
|
64
|
+
* `re`, `(?P<x>…)` refused here, and the fallback read `\Z` as a literal `Z`. JSON Schema
|
|
65
|
+
* recommends ECMA-262, but the product's contract is the reference's verdict, so the pattern
|
|
66
|
+
* is parsed the way `re._parser` parses it and re-emitted as a `v`-mode RegExp whose every
|
|
67
|
+
* construct means what it means in `re`:
|
|
68
|
+
*
|
|
69
|
+
* - `\d` → `\p{Nd}`; `\w` → `[\p{L}\p{N}_]` (`str.isalnum() or "_"`); `\s` → the 29
|
|
70
|
+
* codepoints `str.isspace()` names (JS's `\s` adds U+FEFF and drops U+001C-1F, U+0085);
|
|
71
|
+
* `\b`/`\B` are look-arounds over that `\w`, and `\B` never matches the empty string
|
|
72
|
+
* (`SRE_AT_UNI_NON_BOUNDARY`, CPython 3.11-3.13). Measured over every codepoint: the
|
|
73
|
+
* three classes are equal except for the codepoints Unicode 16 assigned after the
|
|
74
|
+
* reference's Unicode 15 (5,004 for `\w`, 80 for `\d`, 0 for `\s`).
|
|
75
|
+
* - `.` → `[^\n]` (JS also stops at `\r`, U+2028, U+2029); `$` → `(?=\n?$)`; `\A` → `^`;
|
|
76
|
+
* `\Z` → `$`; `(?P<n>…)` → a group; `{,n}` → `{0,n}`; a `{` that is not a repeat is a
|
|
77
|
+
* literal; every literal is emitted as `\u{…}`, so no JS syntax rule can reach it.
|
|
78
|
+
*
|
|
79
|
+
* A pattern `re` itself refuses is a `PyReError` (what `re.error` is on the reference: the
|
|
80
|
+
* schema check raises it). Constructs with no faithful translation — inline flags,
|
|
81
|
+
* look-behind (fixed-width in `re`, any width in JS), backreferences (a JS backreference to
|
|
82
|
+
* a group that did not take part matches the empty string; `re`'s fails), atomic groups,
|
|
83
|
+
* possessive quantifiers, conditional groups and `\N{…}` (no Unicode name table here) — are
|
|
84
|
+
* parsed, so that a broken pattern is still an error, and then REFUSED on both runtimes with
|
|
85
|
+
* one sentence (`unsupportedPatternSentence`; `contract._pattern_unsupported` on the
|
|
86
|
+
* reference), never answered differently.
|
|
87
|
+
*/
|
|
88
|
+
export declare class PyReError extends Error {
|
|
89
|
+
constructor(message: string);
|
|
90
|
+
}
|
|
91
|
+
interface PyReTranslation {
|
|
92
|
+
/** The `v`-mode source, or `null` when the pattern uses a construct refused above. */
|
|
93
|
+
source: string | null;
|
|
94
|
+
}
|
|
95
|
+
/** `re._parser.parse(pattern)` + `fix_flags`, re-emitted for a `v`-mode RegExp. */
|
|
96
|
+
export declare function translatePyPattern(pattern: string): PyReTranslation;
|
|
97
|
+
/** The sentence both runtimes answer for a pattern outside the shared dialect (#131). */
|
|
98
|
+
export declare function unsupportedPatternSentence(pattern: string): string;
|
|
99
|
+
/** The milliseconds a document's patterns may spend, in total, before the validation is refused (#132). */
|
|
100
|
+
export declare const PATTERN_BUDGET_MS = 500;
|
|
101
|
+
/** The sentence both runtimes answer when a document's patterns run past the budget (#132). */
|
|
102
|
+
export declare function patternBudgetSentence(pattern: string): string;
|
|
103
|
+
/**
|
|
104
|
+
* At most `limit` codepoints of `text`, then `…(N more)` (#153). Codepoints, not UTF-16
|
|
105
|
+
* units, because `len()` on the reference counts codepoints.
|
|
106
|
+
*/
|
|
107
|
+
export declare function clipText(text: string, limit?: number): string;
|
|
29
108
|
/**
|
|
30
109
|
* `jsonschema.exceptions.best_match`.
|
|
31
110
|
*
|
|
@@ -38,13 +117,15 @@ declare function iterErrors(instance: PyValue, schema: PyValue | boolean): PyVal
|
|
|
38
117
|
* parent stands.
|
|
39
118
|
*/
|
|
40
119
|
export declare function bestMatch(errors: PyValidationError[]): PyValidationError | null;
|
|
41
|
-
export declare function checkSchema(schema: PyValue | boolean, where?: string): void;
|
|
120
|
+
export declare function checkSchema(schema: PyValue | boolean, where?: string, draft?: Draft): void;
|
|
42
121
|
/**
|
|
43
122
|
* `jsonschema.validate(instance, schema)` reduced to what `contract.schema_error` needs:
|
|
44
123
|
* the single error `best_match` chooses, or `null`.
|
|
45
124
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
125
|
+
* In the reference's order: `validator_for` (the dialect, which raises on a non-string
|
|
126
|
+
* `$schema`), then the one-resource pre-scan `contract.py` runs before `validate`, then
|
|
127
|
+
* `check_schema`, then the instance. A `PyJsonSchemaRefusal` out of here is an ANSWER —
|
|
128
|
+
* `contract.schemaError` returns its sentence — and a `PyJsonSchemaUnsupported` is a raise.
|
|
48
129
|
*/
|
|
49
130
|
export declare function validate(instance: PyValue, schema: PyValue): PyValidationError | null;
|
|
50
131
|
export { iterErrors };
|