@polycode-projects/the-mechanical-code-talker 2.3.1 → 2.5.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.
Files changed (66) hide show
  1. package/README.md +131 -32
  2. package/bin/tmct.mjs +18 -91
  3. package/corpus/README.md +3 -3
  4. package/corpus/seon/README.md +1 -0
  5. package/corpus/tier2/generate.mjs +18 -18
  6. package/corpus/tier2/manifest.json +3 -3
  7. package/data/games/hanoi-3.txt +8 -2
  8. package/package.json +26 -8
  9. package/src/adapters/corpus-lanes.mjs +13 -0
  10. package/src/adapters/graph-build.mjs +5 -7
  11. package/src/adapters/import-closure.mjs +28 -0
  12. package/src/adapters/memory/blocks.mjs +5 -4
  13. package/src/adapters/memory/core.mjs +78 -5
  14. package/src/adapters/memory/shacl.mjs +12 -0
  15. package/src/adapters/providers/graph-service.mjs +12 -5
  16. package/src/adapters/tracked-files.mjs +17 -0
  17. package/src/domain/ask-vocab.mjs +2 -0
  18. package/src/domain/ask.mjs +225 -13
  19. package/src/domain/cli-verbs.mjs +201 -0
  20. package/src/domain/codegraph.mjs +142 -56
  21. package/src/domain/completions/graph-adapter.mjs +1 -1
  22. package/src/domain/completions/group.mjs +3 -17
  23. package/src/domain/completions/infer.mjs +4 -13
  24. package/src/domain/completions/rank.mjs +6 -19
  25. package/src/domain/grammar/lexicon-core.json +1 -1
  26. package/src/domain/hash.mjs +36 -13
  27. package/src/domain/interpret/fuzzy.mjs +7 -2
  28. package/src/domain/interpret/normalize.mjs +9 -0
  29. package/src/domain/interpret/strategies/keywords.mjs +19 -9
  30. package/src/domain/memory/capability.mjs +22 -3
  31. package/src/domain/memory/touched-facts.mjs +17 -0
  32. package/src/domain/module-paths.mjs +9 -0
  33. package/src/domain/persona/tiers.mjs +1 -1
  34. package/src/domain/planning.mjs +37 -0
  35. package/src/domain/prose.mjs +10 -2
  36. package/src/domain/relative-specifiers.mjs +12 -0
  37. package/src/domain/router/registry.mjs +3 -2
  38. package/src/domain/router/results.mjs +5 -18
  39. package/src/domain/seeded-random.mjs +33 -0
  40. package/src/domain/syllogise.mjs +10 -7
  41. package/src/domain/text-stats.mjs +31 -0
  42. package/src/services/chat.mjs +722 -184
  43. package/src/services/extract-facts.mjs +155 -0
  44. package/src/services/import-file.mjs +2 -2
  45. package/src/services/init.mjs +2 -2
  46. package/src/services/ledger-viz.mjs +6 -1
  47. package/src/services/sentences.mjs +26 -0
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
  49. package/src/tools/graph-load.mjs +7 -1
  50. package/src/tools/readme-docs.mjs +113 -0
  51. package/src/tools/schema-docs.mjs +2 -2
  52. package/ROADMAP.md +0 -129
  53. package/corpus/namenet/generate.mjs +0 -309
  54. package/corpus/wordnet/generate.mjs +0 -332
  55. package/src/adapters/prose-tokens.mjs +0 -98
  56. package/src/adapters/wordnet-source.mjs +0 -70
  57. package/src/domain/corpus-matrix.mjs +0 -87
  58. package/src/domain/inflect.mjs +0 -67
  59. package/src/domain/licences.mjs +0 -68
  60. package/src/domain/markdown-links.mjs +0 -55
  61. package/src/domain/persona/codegen.mjs +0 -123
  62. package/src/domain/publish-gate.mjs +0 -41
  63. package/src/domain/schemaorg/turtle.mjs +0 -25
  64. package/src/domain/semcor/parse.mjs +0 -87
  65. package/src/domain/version-stamp.mjs +0 -36
  66. package/src/domain/wordnet/yaml.mjs +0 -133
@@ -1,68 +0,0 @@
1
- // licences.mjs — the allowlist, the SPDX expression rule, and the two pure
2
- // reads the licence check needs. Pure: objects and strings in, verdicts out, so
3
- // the rule that gates CI can be tested without an installed node_modules tree.
4
- //
5
- // The check itself (scripts/check-licences.mjs) shells out to `npm ls` and
6
- // reads each package's package.json off disk. That is the disk half. This is
7
- // the deciding half, and the deciding half is where the edge cases are.
8
-
9
- export const ALLOWED = new Set([
10
- "MIT",
11
- "ISC",
12
- "BSD-2-Clause",
13
- "BSD-3-Clause",
14
- "Apache-2.0",
15
- "MPL-2.0",
16
- "0BSD",
17
- "CC0-1.0",
18
- "Unlicense",
19
- ]);
20
-
21
- /** The licence `pkg` declares, across the three shapes npm has used: the
22
- * current string, the legacy `{ type }` object, and the legacy `licenses[]`
23
- * array (read as a choice, so it joins with OR). */
24
- export function licenseFromPackageJson(pkg) {
25
- if (typeof pkg.license === "string") return pkg.license;
26
- if (pkg.license && typeof pkg.license.type === "string") return pkg.license.type;
27
- if (Array.isArray(pkg.licenses)) return pkg.licenses.map((l) => l.type).join(" OR ");
28
- return "(none declared)";
29
- }
30
-
31
- /** True iff `license` is inside the allowlist.
32
- *
33
- * A flat OR expression passes when any part is allowlisted, because OR is a
34
- * choice and we can take the allowlisted one. A flat AND expression needs
35
- * every part. Everything else — nesting, a WITH exception, an OR and an AND in
36
- * the same expression, even a redundantly parenthesised single licence like
37
- * "(MIT)" — returns false and gets reviewed by hand. Those are rare enough
38
- * that a parser would be more code than the reviews it saves, and false is the
39
- * safe direction: it stops CI and asks a human, rather than waving through an
40
- * expression it only half understood. */
41
- export function isAllowed(license) {
42
- if (ALLOWED.has(license)) return true;
43
- const inner = license.replace(/^\(/, "").replace(/\)$/, "");
44
- if (/[()]|\bWITH\b/.test(inner)) return false;
45
- if (inner.includes(" OR ") && !inner.includes(" AND ")) {
46
- return inner.split(" OR ").some((part) => ALLOWED.has(part.trim()));
47
- }
48
- if (inner.includes(" AND ") && !inner.includes(" OR ")) {
49
- return inner.split(" AND ").every((part) => ALLOWED.has(part.trim()));
50
- }
51
- return false;
52
- }
53
-
54
- /** Every installed package in an `npm ls --json` dependency tree, deduped by
55
- * name@version and sorted by name. A node with no `path` is a peer or optional
56
- * dependency that was never installed, so it cannot ship and is skipped. */
57
- export function installedPackages(deps) {
58
- const seen = new Map();
59
- (function walk(level) {
60
- for (const [name, node] of Object.entries(level ?? {})) {
61
- if (node && node.path && node.version) {
62
- seen.set(`${name}@${node.version}`, { name, version: node.version, path: node.path });
63
- }
64
- if (node) walk(node.dependencies);
65
- }
66
- })(deps);
67
- return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
68
- }
@@ -1,55 +0,0 @@
1
- // markdown-links.mjs — pull the relative link targets out of a markdown string.
2
- // Pure: a string in, `[{ target, line }]` out, no filesystem and no imports, so
3
- // the CI jobs that run without `npm ci` can reach it.
4
- //
5
- // Code is not prose. A doc that writes `[text](target)` inside backticks is
6
- // showing you what a link looks like, not making one, and a checker that cannot
7
- // tell the difference reports the example as a broken link to a file named
8
- // "target". So the spans are blanked before the link patterns run — blanked
9
- // rather than cut, because every offset behind them still has to name the right
10
- // line number.
11
-
12
- const blank = (line) => line.replace(/[^\n]/g, " ");
13
- const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
14
-
15
- /** Replace every fenced block and inline code span with spaces, keeping the
16
- * string's length and its newlines so later offsets still map to their line.
17
- * Fences are walked line by line: a lazy multiline regex stops at the end of
18
- * the opening fence's own line and blanks only the markers. */
19
- export function blankCodeSpans(markdown) {
20
- let fence = null;
21
- const lines = markdown.split("\n").map((line) => {
22
- const marker = line.match(FENCE)?.[1];
23
- if (fence) {
24
- if (marker && marker[0] === fence[0] && marker.length >= fence.length) fence = null;
25
- return blank(line);
26
- }
27
- if (marker) fence = marker;
28
- return marker ? blank(line) : line;
29
- });
30
- return lines.join("\n").replace(/(`+)[\s\S]*?\1/g, blank);
31
- }
32
-
33
- // Inline links/images: [text](target "title") — target ends at the first
34
- // whitespace or closing paren. Reference definitions: [label]: target.
35
- const INLINE_LINK = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
36
- const REFERENCE_DEF = /^\s{0,3}\[[^\]]+\]:\s+(\S+)/gm;
37
-
38
- /** Every repo-relative link target in `markdown`, with the 1-based line it sits
39
- * on. External URLs, bare #anchors and absolute paths are out of scope. */
40
- export function relativeTargets(markdown) {
41
- const prose = blankCodeSpans(markdown);
42
- const targets = [];
43
- for (const regex of [INLINE_LINK, REFERENCE_DEF]) {
44
- for (const match of prose.matchAll(regex)) {
45
- let target = match[1];
46
- if (/^(https?|mailto|ftp):/i.test(target)) continue;
47
- if (target.startsWith("#") || target.startsWith("/") || target.startsWith("<")) continue;
48
- target = decodeURIComponent(target.split("#")[0].split("?")[0]);
49
- if (!target) continue;
50
- const line = prose.slice(0, match.index).split("\n").length;
51
- targets.push({ target, line });
52
- }
53
- }
54
- return targets;
55
- }
@@ -1,123 +0,0 @@
1
- // codegen.mjs — renders a reviewed persona-tier worksheet as JS source, ready
2
- // to splice into corpus/tier2/generate.mjs's CORPUSES object, and as the
3
- // lexicon-core.json noun entries those facts need.
4
- //
5
- // Pure: worksheet in, source text out, no imports.
6
-
7
- export const CLUMP_ORDER = [
8
- "human-core", "human-places", "human-objects", "human-nature",
9
- "human-time-events", "human-body-food", "human-mind",
10
- ];
11
-
12
- export const CLUMP_LABEL = {
13
- "human-core": "people, family, common roles",
14
- "human-places": "places",
15
- "human-objects": "objects, clothing, tools",
16
- "human-nature": "animals, plants, substances",
17
- "human-time-events": "time and events",
18
- "human-body-food": "body and food",
19
- "human-mind": "communication, cognition, feeling",
20
- };
21
-
22
- // The irregular plurals among the words these batches introduce (Latin/Greek-
23
- // derived cognition/body/nature vocabulary especially). A word absent from this
24
- // map gets `{}` and relies on the regular -s/-es/-ies suffix fold at LOOKUP
25
- // time (src/domain/grammar/lexicon.mjs's foldCandidates), same as the vast
26
- // majority of Small tier's own additions. So this map is not a pluralizer: it
27
- // is the list of exceptions that folding cannot recover, and declaring a
28
- // regular plural here would be redundant.
29
- //
30
- // This is deliberately NOT src/domain/inflect.mjs's pluralOf, and the two must
31
- // not be merged. They answer opposite questions. pluralOf GENERATES candidate
32
- // surface forms for the real-word collision table, where over-generating is the
33
- // cheap mistake and its own header commits to regular rules only — it wants
34
- // "foots", because a form it fails to generate is a real word the repair tier
35
- // may rewrite into a different question. This map DECLARES the one correct
36
- // plural for a lexicon entry, where "foots" would simply be a lie the grammar
37
- // then trusts. Teaching pluralOf about "feet" would cost the collision table
38
- // "foots"; deriving this map from pluralOf would put "foots" in the lexicon.
39
- export const IRREGULAR_PLURALS = {
40
- foot: "feet", tooth: "teeth", goose: "geese", ox: "oxen", die: "dice",
41
- louse: "lice", mouse: "mice", crisis: "crises", analysis: "analyses",
42
- hypothesis: "hypotheses", thesis: "theses", axis: "axes", basis: "bases",
43
- oasis: "oases", criterion: "criteria", phenomenon: "phenomena",
44
- alumnus: "alumni", cactus: "cacti", focus: "foci", radius: "radii",
45
- fungus: "fungi", nucleus: "nuclei", stimulus: "stimuli", larva: "larvae",
46
- alga: "algae", vertebra: "vertebrae", antenna: "antennae",
47
- formula: "formulae", datum: "data", medium: "media", index: "indices",
48
- matrix: "matrices", appendix: "appendices", curriculum: "curricula",
49
- memorandum: "memoranda", millennium: "millennia", bacterium: "bacteria",
50
- stratum: "strata", genus: "genera", species: "species", series: "series",
51
- sheep: "sheep", deer: "deer", moose: "moose", salmon: "salmon",
52
- trout: "trout", swine: "swine", offspring: "offspring", spacecraft: "spacecraft",
53
- aircraft: "aircraft", scissors: "scissors", knife: "knives", wife: "wives",
54
- life: "lives", leaf: "leaves", loaf: "loaves", thief: "thieves",
55
- shelf: "shelves", elf: "elves", calf: "calves", half: "halves",
56
- wolf: "wolves", self: "selves", woman: "women", man: "men",
57
- child: "children", person: "people", tempo: "tempi",
58
- };
59
-
60
- /** One noun's lexicon-core.json entry: an explicit plural only where folding
61
- * could not recover it. */
62
- export function lexiconNounEntry(word) {
63
- const plural = IRREGULAR_PLURALS[word];
64
- return plural ? { plural } : {};
65
- }
66
-
67
- /** One clump's facts as indented JS array-literal source lines. */
68
- export function factsBlock(facts) {
69
- return facts.map(([s, r, o]) => ` ["${s}", "${r}", "${o}"],`).join("\n");
70
- }
71
-
72
- /** A noun list wrapped at ~8 words/line, matching Small tier's own
73
- * lexicon-list style. */
74
- export function nounsListBlock(words) {
75
- const lines = [];
76
- for (let i = 0; i < words.length; i += 8) {
77
- lines.push(` ${words.slice(i, i + 8).map((w) => `"${w}"`).join(", ")},`);
78
- }
79
- return lines.join("\n");
80
- }
81
-
82
- /** One CORPUSES entry as JS source, for `id` ("human-medium"/"human-large")
83
- * from `byClump`, the reviewed worksheet's per-clump {facts, newNouns}. */
84
- export function buildCorpusEntry(id, tierLabel, byClump) {
85
- const nounsSrc = CLUMP_ORDER.map((c) => ` // ${c} (+${byClump[c].newNouns.length} words: ${CLUMP_LABEL[c]})\n${nounsListBlock(byClump[c].newNouns)}`).join("\n");
86
- const factsSrc = CLUMP_ORDER.map((c) => ` // ---- ${c} (+${byClump[c].facts.length} facts, ${tierLabel}) ----\n${factsBlock(byClump[c].facts)}`).join("\n\n");
87
-
88
- return `
89
- // ${tierLabel} tier — INCREMENTAL facts beyond ${id === "human-medium" ? "Small (corpus/tier2/human.jsonl)" : "Medium (corpus/tier2/human-medium.jsonl)"} only
90
- // (PLAN_SEED.md §3's tier-selection design: Small/Medium/Large are SIZES of
91
- // one bundle, not separate corpus ids — this file holds only what ${tierLabel}
92
- // ADDS beyond the previous tier). Built by scripts/build-persona-tiers.mjs
93
- // from the same two locally-cloned WordNet source files as Small
94
- // (~/projects/globalwordnet/english-wordnet/src/yaml/), automatically
95
- // curated: candidate words ranked by WordNet sense-count (a commonness
96
- // proxy, PLAN_SEED.md §12), restricted to each word's own TOP senses (not
97
- // some rare/slang meaning that happens to live in this domain), obscure/
98
- // archaic/offensive/pharmaceutical content excluded via a definition-text
99
- // blocklist plus an explicit word denylist, reviewed by hand before being
100
- // spliced in here (scripts/apply-persona-tiers.mjs). ${id === "human-large" ? "Large's own facts walk real multi-hop hypernym chains (up to 4 hops, PLAN_SEED.md §3's own \"surgeon ⊑ doctor ⊑ … ⊑ person\" example) wherever WordNet's real structure supports it, not a flat one-hop-per-word cap." : "Medium stays flat, one hop per word, same style as Small."}
101
- "${id}": {
102
- kind: "domain",
103
- description: "The ${tierLabel} tier of the default human-world persona (PLAN_SEED.md): incremental facts beyond ${id === "human-medium" ? "Small" : "Medium"} only — activated alongside \\"human\\" via --persona-size ${id === "human-medium" ? "medium" : "large"}, never active by default.",
104
- lexicon: {
105
- nouns: [
106
- ${nounsSrc}
107
- ],
108
- },
109
- facts: [
110
- ${factsSrc}
111
- ],
112
- },
113
- `;
114
- }
115
-
116
- /** The CORPUSES source with `entries` spliced in before its closing brace.
117
- * Throws when the anchor is absent rather than writing a mangled file. */
118
- export function spliceCorpusEntries(generateSrc, entries) {
119
- const anchor = "\n};\n\nconst conceptUri = ";
120
- const idx = generateSrc.indexOf(anchor);
121
- if (idx === -1) throw new Error("apply-persona-tiers: could not find CORPUSES closing anchor in generate.mjs");
122
- return generateSrc.slice(0, idx) + entries.join("") + generateSrc.slice(idx + 1);
123
- }
@@ -1,41 +0,0 @@
1
- // publish-gate.mjs — should this commit publish to npm? Pure: two version
2
- // strings in, a decision and a reason out, no imports, so CI can ask without
3
- // npm ci.
4
- //
5
- // CI asked this with `[ "$PUBLISHED" = "$LOCAL" ]`, which only answers "same".
6
- // A local version BELOW what npm already has — a revert, a bad merge, a branch
7
- // landing behind — reads as "different" and goes to `npm publish`, where the
8
- // registry rejects it and the job fails on a confusing error instead of a clear
9
- // skip. Equality cannot tell "ahead" from "behind"; comparing can.
10
-
11
- const CORE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
12
-
13
- /** [major, minor, patch] for a semver string, or null if it isn't one. */
14
- function core(version) {
15
- const found = CORE.exec(String(version ?? "").trim());
16
- return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : null;
17
- }
18
-
19
- /** -1 | 0 | 1 comparing the release cores of `a` and `b`. Pre-release and build
20
- * metadata are ignored: this gate decides whether a release moved, and 2.2.0
21
- * and 2.2.0-rc.1 are the same release for that purpose. */
22
- export function compareVersions(a, b) {
23
- const [x, y] = [core(a), core(b)];
24
- if (!x || !y) throw new Error(`not a comparable version: "${!x ? a : b}"`);
25
- for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
26
- return 0;
27
- }
28
-
29
- /** Should `local` publish, given the registry currently serves `published`?
30
- * `published` is "none" when the package has never been published.
31
- * Returns { publish, reason } — the reason is what CI prints either way. */
32
- export function shouldPublish(local, published) {
33
- if (!core(local)) throw new Error(`not a publishable version: "${local}"`);
34
- if (published === "none" || published == null || published === "") {
35
- return { publish: true, reason: `publishing ${local} (nothing published yet)` };
36
- }
37
- const order = compareVersions(local, published);
38
- if (order === 0) return { publish: false, reason: `npm already has ${local} — no version bump in this push, skipping publish` };
39
- if (order < 0) return { publish: false, reason: `local ${local} is BEHIND npm's ${published} — refusing to publish; the registry would reject it` };
40
- return { publish: true, reason: `publishing ${local} (npm currently has ${published})` };
41
- }
@@ -1,25 +0,0 @@
1
- // turtle.mjs — a very small Turtle reader for schema.ttl's OWN regular shape:
2
- // each class is one `:Name a rdfs:Class ;` block terminated by a line ending
3
- // in ` .`, with `rdfs:label`/`rdfs:comment`/`rdfs:subClassOf` as `;`-separated
4
- // predicate lines. Not a general Turtle parser — schema.ttl's own generator
5
- // emits a single, very regular style (confirmed by direct inspection).
6
- //
7
- // Pure: text in, Map out, no imports.
8
-
9
- /** Every rdfs:Class in `text`, as name -> { name, label, comment, subClassOf }.
10
- * A class with no rdfs:label falls back to its own name; no rdfs:comment
11
- * yields "". Blocks that are not classes (properties, say) are skipped. */
12
- export function parseSchemaClasses(text) {
13
- const classes = new Map();
14
- const blocks = text.split(/\n(?=:[A-Za-z])/); // each class/property starts a new top-level block
15
- for (const block of blocks) {
16
- const head = /^:([A-Za-z0-9_]+)\s+a\s+rdfs:Class\s*;/.exec(block);
17
- if (!head) continue;
18
- const name = head[1];
19
- const label = /rdfs:label\s+"([^"]*)"/.exec(block)?.[1] || name;
20
- const comment = /rdfs:comment\s+"([^"]*)"/.exec(block)?.[1] || "";
21
- const subClassOf = [...block.matchAll(/rdfs:subClassOf\s+:([A-Za-z0-9_]+)/g)].map((m) => m[1]);
22
- classes.set(name, { name, label, comment, subClassOf });
23
- }
24
- return classes;
25
- }
@@ -1,87 +0,0 @@
1
- // parse.mjs — a targeted reader for SemCor's own regular YAML shape:
2
- // flow-style lemmas/pos arrays and a folded single-quoted `text` scalar, one
3
- // record per sentence. Not a general YAML parser (this repo has no YAML
4
- // dependency), and not the same shape as the WordNet dump's reader in
5
- // src/domain/wordnet/yaml.mjs — SemCor's flow style is JSON-compatible once
6
- // isolated, which the WordNet subset never is.
7
- //
8
- // Pure: text in, arrays/strings out, no imports, so it runs with no SemCor
9
- // clone present.
10
-
11
- /** Split a SemCor YAML file into per-sentence record blocks (top-level
12
- * "<key>:" lines, skipping the leading "_meta:" schema block). */
13
- export function splitRecords(text) {
14
- const lines = text.split("\n");
15
- const blocks = [];
16
- let i = 0;
17
- while (i < lines.length && lines[i] !== "_meta:") i++;
18
- i += 1;
19
- while (i < lines.length && (lines[i].startsWith(" ") || lines[i].trim() === "")) i++; // skip rest of _meta
20
- while (i < lines.length) {
21
- if (/^[A-Za-z0-9_]+:$/.test(lines[i])) {
22
- let j = i + 1;
23
- const block = [];
24
- while (j < lines.length && !/^[A-Za-z0-9_]+:$/.test(lines[j])) {
25
- block.push(lines[j]);
26
- j += 1;
27
- }
28
- blocks.push(block.join("\n"));
29
- i = j;
30
- } else {
31
- i += 1;
32
- }
33
- }
34
- return blocks;
35
- }
36
-
37
- /** Extract a flow-style JSON-compatible array value for `key` from one
38
- * record block (lemmas/pos are double-quoted string arrays — valid JSON
39
- * once isolated), balancing brackets across a line wrap if one occurs. */
40
- export function extractArray(block, key) {
41
- const re = new RegExp(`^\\s*${key}:\\s*(\\[.*)$`, "m");
42
- const m = re.exec(block);
43
- if (!m) return null;
44
- let buf = m[1];
45
- let depth = (buf.match(/\[/g) || []).length - (buf.match(/\]/g) || []).length;
46
- const afterIdx = block.indexOf(m[0]) + m[0].length;
47
- const rest = block.slice(afterIdx).split("\n");
48
- let ri = 0;
49
- while (depth > 0 && ri < rest.length) {
50
- buf += `\n${rest[ri]}`;
51
- depth += (rest[ri].match(/\[/g) || []).length - (rest[ri].match(/\]/g) || []).length;
52
- ri += 1;
53
- }
54
- try { return JSON.parse(buf); } catch { return null; }
55
- }
56
-
57
- /** Extract the `text:` folded single-quoted scalar (YAML's own `''` ->
58
- * literal `'` escape; line breaks folded to spaces). */
59
- export function extractText(block) {
60
- const m = /^\s*text:\s*'/m.exec(block);
61
- if (!m) return null;
62
- const start = block.indexOf("'", m.index);
63
- let i = start + 1;
64
- let raw = "";
65
- while (i < block.length) {
66
- if (block[i] === "'") {
67
- if (block[i + 1] === "'") { raw += "'"; i += 2; continue; }
68
- break;
69
- }
70
- raw += block[i];
71
- i += 1;
72
- }
73
- return raw.replace(/\s+/g, " ").trim();
74
- }
75
-
76
- export const NOUN_POS = new Set(["NN", "NNS"]);
77
-
78
- /** Simple-grammar filter: short, no semicolons/colons, no embedded quotes
79
- * (which signal reported speech), no more than one comma — a rough proxy for
80
- * "no complex embedded clauses". */
81
- export function isSimpleSentence(text, wordCount) {
82
- if (wordCount > 18) return false;
83
- if (/[;:]/.test(text)) return false;
84
- if ((text.match(/,/g) || []).length > 1) return false;
85
- if (/"/.test(text)) return false;
86
- return true;
87
- }
@@ -1,36 +0,0 @@
1
- // version-stamp.mjs — the home page's #pkg-version element, written and read
2
- // from one place. Pure: strings in, strings out, no imports, so the deploy
3
- // smoke check can reach it without npm ci.
4
- //
5
- // This existed three times and the copies had already drifted: the writer
6
- // matched [^<]*, the smoke check demanded \d+\.\d+\.\d+, and the estate test
7
- // accepted [^<\s]*. A writer that accepts what its reader rejects is a green
8
- // build and a failed deploy, so the pattern lives here and all three call it.
9
-
10
- /** The element that carries the version, and the value inside it. */
11
- const STAMP = /(id="pkg-version"[^>]*>)\s*v?([^<]*?)\s*(<)/;
12
-
13
- /** A semver core, which is what the deploy smoke check is entitled to expect. */
14
- const SEMVER = /^\d+\.\d+\.\d+$/;
15
-
16
- /** True iff `html` carries an element the stamp can be written into. */
17
- export function hasVersionStamp(html) {
18
- return STAMP.test(html);
19
- }
20
-
21
- /** The version `html` displays, or null when the element is absent or holds
22
- * something that is not a semver core (an unstamped placeholder, say). */
23
- export function parseVersionStamp(html) {
24
- const found = STAMP.exec(html);
25
- if (!found) return null;
26
- const value = found[2].trim();
27
- return SEMVER.test(value) ? value : null;
28
- }
29
-
30
- /** `html` with the stamp set to `version`. Throws when there is nothing to
31
- * stamp — a page that lost its element would otherwise publish blank. */
32
- export function stampVersion(html, version) {
33
- if (!SEMVER.test(version)) throw new Error(`not a stampable version: "${version}"`);
34
- if (!hasVersionStamp(html)) throw new Error("no #pkg-version element to stamp");
35
- return html.replace(STAMP, `$1${version}$3`);
36
- }
@@ -1,133 +0,0 @@
1
- // yaml.mjs — a reader for the small YAML subset the Open English WordNet dump
2
- // uses: 2-space-indented block mappings/sequences, quoted or bare scalars, and
3
- // long scalar list-items that simply WRAP onto a further-indented continuation
4
- // line. No block scalars, no anchors, no flow style — confirmed by direct
5
- // inspection of the dump. This reads exactly that subset; it is not a general
6
- // YAML parser.
7
- //
8
- // Pure: text in, object out, no imports, so it is testable without the WordNet
9
- // clone the scripts that call it need.
10
-
11
- /** Non-greedy key group so a MULTI-WORD entry key ("M-1 rifle", "ice cream")
12
- * still matches — the first ": "/end-of-line colon wins, exactly as real
13
- * YAML's block-mapping key/value split works. A plain wrapped scalar
14
- * continuation line (a definition/example fragment) only coincidentally
15
- * matches this if it ALSO happens to contain a bare "word: " sequence — rare
16
- * in this corpus's prose, and this feeds a maintainer worksheet whose output
17
- * is hand-reviewed, not a correctness-critical parser. */
18
- const KEY_RE = /^(.+?):(\s+(.*)|)$/;
19
-
20
- const isDash = (t) => t === "-" || t.startsWith("- ");
21
-
22
- function parseScalar(s) {
23
- const t = s.trim();
24
- if ((t.startsWith("'") && t.endsWith("'") && t.length >= 2) || (t.startsWith('"') && t.endsWith('"') && t.length >= 2)) {
25
- return t.slice(1, -1);
26
- }
27
- return t;
28
- }
29
-
30
- export function parseYaml(text) {
31
- const rawLines = text.split("\n");
32
- const lines = [];
33
- for (const line of rawLines) {
34
- if (!line.trim() || line.trim().startsWith("#")) continue;
35
- const indent = line.length - line.trimStart().length;
36
- lines.push({ indent, text: line.trimStart() });
37
- }
38
- let pos = 0;
39
-
40
- // A scalar that may continue on subsequent MORE-indented lines with no
41
- // "key:"/"- " marker of their own (WordNet's definition-wrapping style). A
42
- // QUOTED scalar ('...' or "...") is handled separately: WordNet definitions
43
- // routinely contain a literal ": " inside the quoted text itself (e.g. "…
44
- // Matthew, Mark, Luke, and John" split across a line boundary right after a
45
- // colon) — the bare-scalar heuristic below would misread that continuation
46
- // line as a new "key:" line and truncate the string. Once inside an open
47
- // quote, EVERY line is a continuation until one ends with the matching
48
- // closing quote, full stop — the key/dash heuristic never applies inside it.
49
- function parseScalarOrContinue(first, minContinIndent) {
50
- const trimmed = first.trim();
51
- const quote = trimmed[0] === "'" || trimmed[0] === '"' ? trimmed[0] : null;
52
- if (quote) {
53
- const closes = (s) => s.length >= 2 && s.endsWith(quote);
54
- let buf = trimmed;
55
- while (!closes(buf) && pos < lines.length && lines[pos].indent >= minContinIndent) {
56
- buf += " " + lines[pos].text.trim();
57
- pos += 1;
58
- }
59
- return closes(buf) ? buf.slice(1, -1) : buf;
60
- }
61
- let s = parseScalar(first);
62
- while (pos < lines.length && lines[pos].indent >= minContinIndent
63
- && !isDash(lines[pos].text) && !KEY_RE.test(lines[pos].text)) {
64
- s += " " + lines[pos].text.trim();
65
- pos += 1;
66
- }
67
- return s;
68
- }
69
-
70
- /** The value that follows a "key:" (bare, no inline scalar) — peeks at the
71
- * next line to decide whether it's a nested sequence (which YAML allows to
72
- * sit at the SAME indent as the key itself, not just deeper) or a nested
73
- * mapping (which must be deeper) or simply absent (null). `parentIndent`
74
- * is the indent of the "key:" line whose value this resolves. */
75
- function parseValue(parentIndent) {
76
- if (pos >= lines.length || lines[pos].indent < parentIndent) return null;
77
- const line = lines[pos];
78
- if (isDash(line.text)) return parseSeq(line.indent);
79
- if (line.indent > parentIndent && KEY_RE.test(line.text)) return parseMap(line.indent);
80
- return null;
81
- }
82
-
83
- function parseSeq(indent) {
84
- const arr = [];
85
- while (pos < lines.length && lines[pos].indent === indent && isDash(lines[pos].text)) {
86
- const dashIndent = indent;
87
- const rest = lines[pos].text === "-" ? "" : lines[pos].text.slice(2);
88
- pos += 1;
89
- if (rest === "") {
90
- arr.push(parseValue(dashIndent));
91
- continue;
92
- }
93
- // A quoted scalar is classified FIRST, unconditionally — WordNet
94
- // definitions routinely contain a literal ": " (or a colon at the very
95
- // end of a wrapped line, e.g. "…including:\n whales, …") inside quoted
96
- // prose, which KEY_RE would otherwise misread as an inline "- key:"
97
- // mapping. Only an UNQUOTED rest is even considered for that shape.
98
- const quoted = rest[0] === "'" || rest[0] === '"';
99
- const m = quoted ? null : KEY_RE.exec(rest);
100
- if (m) {
101
- // "- key: value" or "- key:" — an inline mapping for this list item;
102
- // sibling keys of the SAME item are indented +2 from the dash.
103
- const obj = {};
104
- obj[m[1]] = m[3] !== undefined && m[3] !== "" ? parseScalarOrContinue(m[3], dashIndent + 2) : parseValue(dashIndent + 2);
105
- while (pos < lines.length && lines[pos].indent === dashIndent + 2 && KEY_RE.test(lines[pos].text)) {
106
- const mm = KEY_RE.exec(lines[pos].text);
107
- pos += 1;
108
- obj[mm[1]] = mm[3] !== undefined && mm[3] !== "" ? parseScalarOrContinue(mm[3], dashIndent + 4) : parseValue(dashIndent + 2);
109
- }
110
- arr.push(obj);
111
- } else {
112
- // a plain (or quoted) scalar list item — may wrap onto continuation lines
113
- arr.push(parseScalarOrContinue(rest, dashIndent + 2));
114
- }
115
- }
116
- return arr;
117
- }
118
-
119
- function parseMap(indent) {
120
- const obj = {};
121
- while (pos < lines.length && lines[pos].indent === indent && KEY_RE.test(lines[pos].text)) {
122
- const m = KEY_RE.exec(lines[pos].text);
123
- const key = parseScalar(m[1]);
124
- pos += 1;
125
- obj[key] = m[3] !== undefined && m[3] !== "" ? parseScalarOrContinue(m[3], indent + 2) : parseValue(indent);
126
- // (parseValue(indent) — not indent+2 — so a same-indent sequence value
127
- // is recognized; parseValue itself accepts child indent >= indent.)
128
- }
129
- return obj;
130
- }
131
-
132
- return parseMap(0);
133
- }