@starci/eslint-grammar-core 0.15.34 → 0.16.2
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/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/why.js +15 -0
- package/dist/mascot.d.ts +28 -0
- package/dist/mascot.js +166 -0
- package/dist/surface-depth.d.ts +7 -0
- package/dist/surface-depth.js +92 -0
- package/docs/mascot.md +60 -0
- package/docs/surface-depth.md +40 -0
- package/package.json +2 -2
- package/runtime/bundle.json +74 -0
- package/runtime/engine/by-code-unit.mjs +6 -6
- package/runtime/engine/model-config.mjs +98 -76
- package/runtime/engine/orca-config.mjs +22 -17
- package/runtime/engine/release-config.mjs +22 -18
- package/runtime/engine/removed-vocabulary.mjs +21 -20
- package/runtime/engine/resources-config.mjs +42 -35
- package/runtime/engine/runtime-root.mjs +11 -13
- package/runtime/engine/secrets.mjs +170 -102
- package/runtime/engine/sonar-config.mjs +16 -13
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +95 -6
- package/runtime/knowledge/hfs/slots.yaml +2 -2
- package/runtime/knowledge/patterns/fe/ui.yaml +13 -1
- package/runtime/scripts/hfs/allows.mjs +37 -38
- package/runtime/scripts/hfs/architecture/config.mjs +302 -268
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
- package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
- package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
- package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
- package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
- package/runtime/scripts/hfs/edition-slots.mjs +43 -40
- package/runtime/scripts/hfs/param-names.mjs +6 -11
- package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
- package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
- package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
- package/runtime/scripts/hfs/slot-classify.mjs +42 -38
- package/runtime/scripts/hfs/slot-errors.mjs +8 -7
- package/runtime/scripts/hfs/slot-imports.mjs +70 -63
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
- package/runtime/scripts/hfs/slot-match.mjs +122 -111
- package/runtime/scripts/hfs/slot-path.mjs +0 -2
- package/runtime/scripts/hfs/slot-required.mjs +65 -59
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
- package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
- package/runtime/scripts/hfs/slots.mjs +175 -152
- package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
- package/runtime/scripts/hfs/view.mjs +38 -40
- package/runtime/scripts/lib/glob.mjs +23 -14
- package/runtime/scripts/lib/grammar-lint.mjs +33 -37
- package/runtime/scripts/lib/i18n.mjs +32 -35
- package/runtime/scripts/lib/json-clone.mjs +45 -36
- package/runtime/scripts/lib/json.mjs +26 -14
- package/runtime/scripts/lib/list.mjs +9 -14
- package/runtime/scripts/lib/path-key.mjs +53 -38
- package/runtime/scripts/lib/read-text.mjs +30 -17
- package/runtime/scripts/lib/regex.mjs +1 -2
- package/runtime/scripts/lib/source-phrases.mjs +13 -13
- package/runtime/scripts/lib/ts-analysis.mjs +28 -26
- package/runtime/scripts/lib/ts-ast.mjs +36 -42
- package/runtime/scripts/lib/walk.mjs +33 -27
- package/runtime/scripts/lib/yaml-cached.mjs +4 -4
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { allowsFile } from './allows.mjs';
|
|
7
7
|
import { openHfs } from './slots.mjs';
|
|
8
|
-
|
|
9
8
|
const posix = (p) => String(p).replaceAll('\\', '/');
|
|
10
9
|
const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
|
|
11
|
-
|
|
12
10
|
/**
|
|
13
11
|
* The view rules read, over an opened resolver.
|
|
14
12
|
*
|
|
@@ -17,44 +15,45 @@ const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(tex
|
|
|
17
15
|
* @returns {object} The frozen view: profile, apps, connections, patterns, ruleParams, relative, classify, slotOf, tierOf, ownerOf, slot.
|
|
18
16
|
*/
|
|
19
17
|
function hfsView(opened, repoRoot) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
18
|
+
const rel = (file) => {
|
|
19
|
+
const text = posix(file);
|
|
20
|
+
if (!isAbsoluteAny(text))
|
|
21
|
+
return text.replace(/^\.\//, '');
|
|
22
|
+
return posix(path.relative(repoRoot, file));
|
|
23
|
+
};
|
|
24
|
+
const slotCache = new Map();
|
|
25
|
+
const classify = (file) => {
|
|
26
|
+
const key = rel(file);
|
|
27
|
+
if (!slotCache.has(key))
|
|
28
|
+
slotCache.set(key, opened.classifyPath(key));
|
|
29
|
+
return slotCache.get(key);
|
|
30
|
+
};
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
repoRoot,
|
|
33
|
+
profile: opened.repo.profile,
|
|
34
|
+
side: opened.repo.side ?? null,
|
|
35
|
+
edition: opened.repo.edition ?? 'full',
|
|
36
|
+
...(opened.repo.grammar === undefined ? {} : { grammar: opened.repo.grammar }),
|
|
37
|
+
apps: opened.repo.apps,
|
|
38
|
+
connections: opened.repo.connections,
|
|
39
|
+
patterns: Object.freeze([...(opened.repo.patterns ?? [])]),
|
|
40
|
+
ruleParams: opened.ruleParams(),
|
|
41
|
+
/** The repository-relative, forward-slash form of a linted filename. */
|
|
42
|
+
relative: rel,
|
|
43
|
+
/** The classification of a file: `{ slot, tier, owner?, bindings? ... }` or a no-slot status. */
|
|
44
|
+
classify,
|
|
45
|
+
/** The slot id that owns a file, or null when no slot does. */
|
|
46
|
+
slotOf: (file) => classify(file).slot ?? null,
|
|
47
|
+
/** The tier of a file, or null. */
|
|
48
|
+
tierOf: (file) => opened.tierOf(rel(file)) ?? null,
|
|
49
|
+
/** The owner root of a file, or null. */
|
|
50
|
+
ownerOf: (file) => opened.ownerOf(rel(file))?.root ?? null,
|
|
51
|
+
/** What the slot that owns a file says about it: `{ slot, root, relative, allowed, entry?, forbiddenBy?, allows }`, or null when the slot names no `allows`. */
|
|
52
|
+
allows: (file) => allowsFile(opened, rel(file)),
|
|
53
|
+
/** A slot definition by id. */
|
|
54
|
+
slot: (id) => opened.slot(id),
|
|
55
|
+
});
|
|
56
56
|
}
|
|
57
|
-
|
|
58
57
|
/**
|
|
59
58
|
* The view of the app folder on disk at `repoRoot`, against the manifest under `runtimeRoot`: a side folder (be/, fe/) finds the
|
|
60
59
|
* app-root hfs.json one level up and gets that side's view, so every linted file below it is judged by its own side's slots.
|
|
@@ -63,7 +62,6 @@ function hfsView(opened, repoRoot) {
|
|
|
63
62
|
* @returns {object} The frozen view.
|
|
64
63
|
*/
|
|
65
64
|
export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot);
|
|
66
|
-
|
|
67
65
|
/**
|
|
68
66
|
* The view of one side of an in-memory app declaration (rule tests): no file is read except the manifest.
|
|
69
67
|
*
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
// glob.mjs — the path-glob subset ESLint, SonarQube and the scoped-lint config share: `**` spans directories,
|
|
2
2
|
// `*` and `?` stay inside one segment, `{a,b}` alternates.
|
|
3
3
|
import { posixPath } from './path-key.mjs';
|
|
4
|
-
|
|
5
4
|
/** Every alternative a `{a,b}` pattern spells, braces expanded left to right. */
|
|
6
5
|
export function braceVariants(value) {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const match = /\{([^{}]+)\}/.exec(value);
|
|
7
|
+
return match ? match[1].split(',').flatMap((part) => braceVariants(`${value.slice(0, match.index)}${part}${value.slice(match.index + match[0].length)}`)) : [value];
|
|
9
8
|
}
|
|
10
|
-
|
|
11
9
|
/** The anchored RegExp of one brace-free glob, read as a posix path (backslashes and a leading './' folded). */
|
|
12
10
|
export function globExpression(value) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
11
|
+
const input = posixPath(value);
|
|
12
|
+
let source = '';
|
|
13
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
14
|
+
const c = input.charAt(i);
|
|
15
|
+
if (c === '*' && input[i + 1] === '*') {
|
|
16
|
+
i += 1;
|
|
17
|
+
if (input[i + 1] === '/') {
|
|
18
|
+
i += 1;
|
|
19
|
+
source += '(?:.*/)?';
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
source += '.*';
|
|
23
|
+
}
|
|
24
|
+
else if (c === '*')
|
|
25
|
+
source += '[^/]*';
|
|
26
|
+
else if (c === '?')
|
|
27
|
+
source += '[^/]';
|
|
28
|
+
else
|
|
29
|
+
source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
30
|
+
}
|
|
31
|
+
return new RegExp(`^${source}$`);
|
|
23
32
|
}
|
|
@@ -1,61 +1,57 @@
|
|
|
1
1
|
// Grammar-family lint selection reads the family catalog; an absent selection never chooses a family.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { parseYamlCached } from './yaml-cached.mjs';
|
|
4
|
-
|
|
5
4
|
const CATALOG_FILE = new URL('../../knowledge/grammars/index.yaml', import.meta.url);
|
|
6
|
-
|
|
7
5
|
function readCatalog() {
|
|
8
|
-
|
|
6
|
+
return parseYamlCached(fs.readFileSync(CATALOG_FILE, 'utf8'));
|
|
9
7
|
}
|
|
10
|
-
|
|
11
8
|
function selectedFamily(family) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
if (typeof family !== 'string' || !family.trim())
|
|
10
|
+
throw new Error('sides.fe.grammar must explicitly name the selected Grammar family');
|
|
11
|
+
const catalog = readCatalog();
|
|
12
|
+
const matches = (catalog.families ?? []).filter(entry => entry.id === family);
|
|
13
|
+
if (matches.length !== 1)
|
|
14
|
+
throw new Error(`Grammar family ${family} has no declared lint contract in knowledge/grammars/index.yaml`);
|
|
15
|
+
return { catalog, selected: matches[0] };
|
|
17
16
|
}
|
|
18
|
-
|
|
19
17
|
/**
|
|
20
18
|
* Resolve a selection to its declared family id; only a catalog family id resolves, there are no aliases.
|
|
21
19
|
* @param {string} family - The id an app or caller names (sides.fe.grammar).
|
|
22
20
|
* @returns {{ id: string }} The declared family id.
|
|
23
21
|
*/
|
|
24
22
|
export function resolveGrammarFamily(family) {
|
|
25
|
-
|
|
23
|
+
return Object.freeze({ id: selectedFamily(family).selected.id });
|
|
26
24
|
}
|
|
27
|
-
|
|
28
25
|
/** The explicitly selected authored family entry; callers share the catalog lookup rather than inferring a family. */
|
|
29
26
|
export function grammarFamilyFor(family) {
|
|
30
|
-
|
|
27
|
+
return Object.freeze(selectedFamily(family).selected);
|
|
31
28
|
}
|
|
32
|
-
|
|
33
29
|
/** The declared family's lint contract, including an explicit null package when that family has no rules. */
|
|
34
30
|
export function grammarLintFor(family) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
const { selected } = selectedFamily(family);
|
|
32
|
+
if (!selected.lint || !Object.hasOwn(selected.lint, 'package')) {
|
|
33
|
+
throw new Error(`Grammar family ${family} has no declared lint contract in knowledge/grammars/index.yaml`);
|
|
34
|
+
}
|
|
35
|
+
const lint = selected.lint;
|
|
36
|
+
if (lint.package === null)
|
|
37
|
+
return Object.freeze({ family: selected.id, package: null });
|
|
38
|
+
if (typeof lint.package !== 'string' || !lint.package.startsWith('@starci/eslint-grammar-')
|
|
39
|
+
|| typeof lint.factory !== 'string' || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(lint.factory)
|
|
40
|
+
|| typeof lint.prefix !== 'string' || !/^[a-z][a-z0-9-]*$/.test(lint.prefix)) {
|
|
41
|
+
throw new Error(`Grammar family ${family} has an invalid lint contract in knowledge/grammars/index.yaml`);
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze({ family: selected.id, package: lint.package, factory: lint.factory, prefix: lint.prefix });
|
|
47
44
|
}
|
|
48
|
-
|
|
49
45
|
/** The selected family's shared component/export contract, resolved through its catalog reference. */
|
|
50
46
|
export function grammarContractFor(family) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
const { catalog, selected } = selectedFamily(family);
|
|
48
|
+
const owners = (catalog.families ?? []).filter(entry => entry.id === selected.contractRef);
|
|
49
|
+
const contract = owners.length === 1 ? owners[0]?.componentContract : null;
|
|
50
|
+
if (!contract || typeof contract.package !== 'string' || !contract.package
|
|
51
|
+
|| typeof contract.entry !== 'string' || !contract.entry.startsWith(`${contract.package}/`)
|
|
52
|
+
|| typeof contract.styleEntry !== 'string' || !contract.styleEntry.startsWith(`${contract.package}/`)
|
|
53
|
+
|| !Array.isArray(contract.peers) || !contract.peers.every(peer => typeof peer === 'string' && peer.trim())) {
|
|
54
|
+
throw new Error(`Grammar family ${family} has no valid component contract in knowledge/grammars/index.yaml`);
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({ ...contract, peers: Object.freeze([...contract.peers]) });
|
|
61
57
|
}
|
|
@@ -18,68 +18,65 @@ import path from 'node:path';
|
|
|
18
18
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
19
19
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
20
20
|
import { DEFAULT_OWNER_LANGUAGE, loadConfig } from '../../engine/config.mjs';
|
|
21
|
-
|
|
22
21
|
export const CATALOG_DIR = 'modules/i18n/messages';
|
|
23
22
|
export const PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
|
|
24
|
-
|
|
25
23
|
const cache = new Map();
|
|
26
|
-
|
|
27
24
|
/** The catalog files of a runtime root: repository-relative paths, sorted. */
|
|
28
25
|
export function catalogFiles(root = skillRoot) {
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const dir = path.join(root, ...CATALOG_DIR.split('/'));
|
|
27
|
+
try {
|
|
28
|
+
return fs.readdirSync(dir).filter((name) => /\.ya?ml$/.test(name)).sort().map((name) => `${CATALOG_DIR}/${name}`);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
31
33
|
}
|
|
32
|
-
|
|
33
34
|
/** Every entry in one catalog scope: `[{ en, vi, area, scope, file }]`. */
|
|
34
35
|
export function catalogEntries(root = skillRoot, { scope = 'runtime' } = {}) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
return catalogFiles(root).flatMap((rel) => {
|
|
37
|
+
const doc = parseYaml(fs.readFileSync(path.join(root, ...rel.split('/')), 'utf8'));
|
|
38
|
+
const documentScope = typeof doc?.scope === 'string' ? doc.scope : 'runtime';
|
|
39
|
+
if (documentScope !== scope)
|
|
40
|
+
return [];
|
|
41
|
+
return (Array.isArray(doc?.messages) ? doc.messages : []).map((entry) => ({
|
|
42
|
+
en: entry?.en, vi: entry?.vi, area: doc?.area, scope: documentScope, file: rel,
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
43
45
|
}
|
|
44
|
-
|
|
45
46
|
/** One scoped catalog as a Map from the English source to the Vietnamese text (cached per root and scope). */
|
|
46
47
|
export function loadCatalog(root = skillRoot, { scope = 'runtime' } = {}) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const key = `${root}\0${scope}`;
|
|
49
|
+
if (!cache.has(key))
|
|
50
|
+
cache.set(key, new Map(catalogEntries(root, { scope }).filter((e) => typeof e.en === 'string' && typeof e.vi === 'string').map((e) => [e.en, e.vi])));
|
|
51
|
+
return cache.get(key);
|
|
50
52
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
53
|
/** The names each `pattern` match captures (group 1) in `text`, in match order (`sort` sorts them). */
|
|
54
54
|
export const captureNames = (text, pattern, { sort = false } = {}) => {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const names = [...String(text).matchAll(pattern)].map((m) => m[1]);
|
|
56
|
+
return sort ? names.sort(byCodeUnit) : names;
|
|
57
57
|
};
|
|
58
|
-
|
|
59
58
|
/** The names of the placeholders of a message. */
|
|
60
59
|
export const placeholdersOf = (text) => captureNames(text, PLACEHOLDER, { sort: true });
|
|
61
|
-
|
|
62
60
|
/** `text` with each `{name}` replaced by vars[name] (an unknown name is left as written). */
|
|
63
61
|
export const fill = (text, vars = {}) => String(text).replace(PLACEHOLDER, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
|
|
64
|
-
|
|
65
62
|
/** The message `en` in `language` (`vi` translates through the catalog; any other language is the English source). */
|
|
66
63
|
export function translate(en, vars = {}, { language = DEFAULT_OWNER_LANGUAGE, root = skillRoot, scope = 'runtime' } = {}) {
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
const vi = language === 'vi' ? loadCatalog(root, { scope }).get(en) : undefined;
|
|
65
|
+
return fill(vi ?? en, vars);
|
|
69
66
|
}
|
|
70
|
-
|
|
71
67
|
/** The owner's language (config.yaml `language`); `fallback` when there is no readable config (DEFAULT_OWNER_LANGUAGE of engine/config.mjs). */
|
|
72
68
|
export function ownerLanguage(fallback = DEFAULT_OWNER_LANGUAGE) {
|
|
73
|
-
|
|
69
|
+
try {
|
|
70
|
+
return loadConfig()?.language ?? fallback;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
74
75
|
}
|
|
75
|
-
|
|
76
76
|
/** A translator bound to one language: `tr(en, vars)`. */
|
|
77
|
-
export const translator = (language = DEFAULT_OWNER_LANGUAGE, { root = skillRoot, scope = 'runtime' } = {}) =>
|
|
78
|
-
(en, vars = {}) => translate(en, vars, { language, root, scope });
|
|
79
|
-
|
|
77
|
+
export const translator = (language = DEFAULT_OWNER_LANGUAGE, { root = skillRoot, scope = 'runtime' } = {}) => (en, vars = {}) => translate(en, vars, { language, root, scope });
|
|
80
78
|
/**
|
|
81
79
|
* A pattern source matching `en` in the owner's language or in English: each language's rendering of the English source,
|
|
82
80
|
* escaped, with the placeholder `slot` (a placeholder name of `en`) replaced by `slotPattern`. Never hardcodes a spelling.
|
|
83
81
|
*/
|
|
84
|
-
export const translatedPattern = (en, slot, slotPattern, languages = ['en', 'vi']) =>
|
|
85
|
-
`(?:${languages.map((language) => escapeRegExp(translate(en, { [slot]: '@@' }, { language })).replace('@@', slotPattern)).join('|')})`;
|
|
82
|
+
export const translatedPattern = (en, slot, slotPattern, languages = ['en', 'vi']) => `(?:${languages.map((language) => escapeRegExp(translate(en, { [slot]: '@@' }, { language })).replace('@@', slotPattern)).join('|')})`;
|
|
@@ -2,52 +2,60 @@
|
|
|
2
2
|
// copy of a value, detached from every reference, frozen state and class of the original. `structuredClone` keeps
|
|
3
3
|
// what JSON drops (undefined members, Dates, Maps, sparse holes) and refuses what JSON accepts (functions,
|
|
4
4
|
// `toJSON` objects), so it is not a substitute; this walks the value once under the JSON.stringify rules.
|
|
5
|
-
|
|
6
5
|
/** The boxed primitives JSON.stringify unwraps: recognised by the internal slot a valueOf call reads, as JSON does. */
|
|
7
6
|
const BOXED = [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
{ slot: Number.prototype.valueOf, read: Number },
|
|
8
|
+
{ slot: String.prototype.valueOf, read: String },
|
|
9
|
+
{ slot: Boolean.prototype.valueOf, read: (box) => Boolean.prototype.valueOf.call(box) },
|
|
10
|
+
{ slot: BigInt.prototype.valueOf, read: (box) => BigInt.prototype.valueOf.call(box) },
|
|
12
11
|
];
|
|
13
|
-
|
|
14
12
|
/** Whether `value` carries the internal slot `slot` reads (a valueOf of that boxed type does not throw on it). */
|
|
15
13
|
const hasSlot = (slot, value) => {
|
|
16
|
-
|
|
14
|
+
try {
|
|
15
|
+
slot.call(value);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
17
21
|
};
|
|
18
|
-
|
|
19
22
|
/** What JSON.stringify takes of `holder[key]`: the toJSON result when there is one, a boxed primitive unwrapped. */
|
|
20
23
|
const serialized = (holder, key) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
let value = holder[key];
|
|
25
|
+
if (value !== null && (typeof value === 'object' || typeof value === 'bigint') && typeof value.toJSON === 'function')
|
|
26
|
+
value = value.toJSON(key);
|
|
27
|
+
if (value === null || typeof value !== 'object')
|
|
28
|
+
return value;
|
|
29
|
+
const box = BOXED.find((candidate) => hasSlot(candidate.slot, value));
|
|
30
|
+
return box ? box.read(value) : value;
|
|
26
31
|
};
|
|
27
|
-
|
|
28
32
|
/** A member JSON leaves out of an object (and writes as null in an array). */
|
|
29
33
|
const isAbsent = (value) => value === undefined || typeof value === 'function' || typeof value === 'symbol';
|
|
30
|
-
|
|
31
34
|
const copyValue = (value, ancestors) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
36
|
+
return value;
|
|
37
|
+
if (typeof value === 'number')
|
|
38
|
+
return Number.isFinite(value) ? value + 0 : null;
|
|
39
|
+
if (typeof value === 'bigint')
|
|
40
|
+
throw new TypeError('Do not know how to serialize a BigInt');
|
|
41
|
+
if (ancestors.includes(value))
|
|
42
|
+
throw new TypeError('Converting circular structure to JSON');
|
|
43
|
+
const inside = [...ancestors, value];
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
46
|
+
const item = serialized(value, String(index));
|
|
47
|
+
return isAbsent(item) ? null : copyValue(item, inside);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const holder = value;
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const name of Object.keys(holder)) {
|
|
53
|
+
const member = serialized(holder, name);
|
|
54
|
+
if (!isAbsent(member))
|
|
55
|
+
entries.push([name, copyValue(member, inside)]);
|
|
56
|
+
}
|
|
57
|
+
return Object.fromEntries(entries);
|
|
49
58
|
};
|
|
50
|
-
|
|
51
59
|
/**
|
|
52
60
|
* `value` copied as JSON data: members that are undefined, functions or symbols are dropped (an array item becomes
|
|
53
61
|
* null), non-finite numbers become null and -0 becomes 0, `toJSON` results replace their objects (a Date becomes its ISO string), boxed
|
|
@@ -55,7 +63,8 @@ const copyValue = (value, ancestors) => {
|
|
|
55
63
|
* a symbol) throws a SyntaxError, as parsing the missing text does.
|
|
56
64
|
*/
|
|
57
65
|
export function jsonClone(value) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
const top = serialized({ '': value }, '');
|
|
67
|
+
if (isAbsent(top))
|
|
68
|
+
throw new SyntaxError('"undefined" is not valid JSON');
|
|
69
|
+
return copyValue(top, []);
|
|
61
70
|
}
|
|
@@ -2,31 +2,43 @@
|
|
|
2
2
|
// truncated payload, so a malformed column is the caller's fallback, never a throw. The parse of the
|
|
3
3
|
// literal 'null' is kept as null (callers that want {} instead write `parseJsonOr`).
|
|
4
4
|
import { readParsedFile } from './read-text.mjs';
|
|
5
|
-
|
|
6
5
|
/** `text` as JSON, or `fallback` when it does not parse. */
|
|
6
|
+
// reason: JSON parsed from a ledger column or file is an untyped input boundary; each caller narrows what it reads.
|
|
7
7
|
export const parseJson = (text, fallback = null) => {
|
|
8
|
-
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(text);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
9
14
|
};
|
|
10
|
-
|
|
11
15
|
/** `text` as JSON, or `fallback` when it does not parse OR parses to null/undefined. Rows written
|
|
12
16
|
* as the literal 'null' read back as the fallback too, so a caller that wants an object gets one. */
|
|
17
|
+
// reason: same parsed-JSON boundary as parseJson.
|
|
13
18
|
export const parseJsonOr = (text, fallback = {}) => parseJson(text) ?? fallback;
|
|
14
|
-
|
|
15
19
|
/** A jobs/events row spread with `payload`: the row's payload_json parsed via parseJsonOr
|
|
16
20
|
* (`fallback` defaults to {}). Pass fallback null to keep a raw parseJson result, nulls included. */
|
|
21
|
+
// reason: same parsed-JSON boundary as parseJson.
|
|
17
22
|
export const withPayload = (row, fallback = {}) => (row ? { ...row, payload: parseJsonOr(row.payload_json, fallback) } : row);
|
|
18
|
-
|
|
19
23
|
/** `file` read and parsed as JSON, or `fallback` when the file is missing, unreadable or malformed. */
|
|
24
|
+
// reason: same parsed-JSON boundary as parseJson.
|
|
20
25
|
export const readJsonFile = (file, fallback = null) => readParsedFile(file, JSON.parse, fallback);
|
|
21
|
-
|
|
22
26
|
/** `stdout` as JSON: the trimmed text, else its first-{-to-last-} span; null when neither parses. */
|
|
27
|
+
// reason: same parsed-JSON boundary as parseJson.
|
|
23
28
|
export const jsonFromStdout = (stdout) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
const text = String(stdout ?? '').trim();
|
|
30
|
+
if (!text)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch { /* fall through */ }
|
|
36
|
+
const first = text.indexOf('{'), last = text.lastIndexOf('}');
|
|
37
|
+
if (first >= 0 && last > first) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(text.slice(first, last + 1));
|
|
40
|
+
}
|
|
41
|
+
catch { /* not json */ }
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
32
44
|
};
|
|
@@ -2,30 +2,25 @@
|
|
|
2
2
|
// `v` when it already is an array, else []. Scalars are NOT wrapped: a caller that wants
|
|
3
3
|
// `[v]` for a lone value writes its own coercion (see contract-version's list).
|
|
4
4
|
export const list = (v) => (Array.isArray(v) ? v : []);
|
|
5
|
-
|
|
6
5
|
/** `v` as an array: itself when already one, [] for null/undefined, else [v]. */
|
|
7
6
|
export const asList = (v) => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
if (Array.isArray(v))
|
|
8
|
+
return v;
|
|
9
|
+
if (v == null)
|
|
10
|
+
return [];
|
|
11
|
+
return [v];
|
|
11
12
|
};
|
|
12
|
-
|
|
13
13
|
/** The object entries of `v` (a non-array reads as []). */
|
|
14
14
|
export const objectList = (v) => (Array.isArray(v) ? v : []).filter((item) => item && typeof item === 'object');
|
|
15
|
-
|
|
16
15
|
/** Every entry of `v` a non-empty string (`blank: false` also rejects all-whitespace entries). */
|
|
17
|
-
export const stringList = (v, { blank = true } = {}) =>
|
|
18
|
-
Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0 && (blank || s.trim().length > 0));
|
|
19
|
-
|
|
16
|
+
export const stringList = (v, { blank = true } = {}) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0 && (blank || s.trim().length > 0));
|
|
20
17
|
/** A non-empty list of distinct values, all accepted by the supplied predicate. */
|
|
21
18
|
export const nonemptyUniqueList = (values, accepts) => Array.isArray(values) && values.length > 0
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
&& values.every((entry) => accepts(entry)) && new Set(values).size === values.length;
|
|
24
20
|
/** The items of a `sep`-separated list: trimmed, empties dropped; `dedupe` keeps first occurrences only. */
|
|
25
21
|
export const splitList = (v, { sep = ',', dedupe = false } = {}) => {
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const items = String(v ?? '').split(sep).map((s) => s.trim()).filter(Boolean);
|
|
23
|
+
return dedupe ? [...new Set(items)] : items;
|
|
28
24
|
};
|
|
29
|
-
|
|
30
25
|
// The comparator of a bare `sort()` (UTF-16 code-unit order) is owned by the engine, which cannot import scripts/lib.
|
|
31
26
|
export { byCodeUnit } from '../../engine/by-code-unit.mjs';
|