unknown-knowledge 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +167 -0
- package/cli/.gitkeep +0 -0
- package/cli/commands/init-copy.js +90 -0
- package/cli/commands/init.js +386 -0
- package/cli/init-copy.js +24 -0
- package/cli/init.js +24 -0
- package/cli/kit.manifest.yaml +256 -0
- package/cli/lib/copy-payload.js +334 -0
- package/cli/lib/generate-wrappers.js +162 -0
- package/package.json +36 -0
- package/payload/adapter-fixtures/README.md +53 -0
- package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
- package/payload/adapter-fixtures/html/sample.html +38 -0
- package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
- package/payload/adapter-fixtures/md/sample.md +32 -0
- package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
- package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
- package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
- package/payload/adapter-fixtures/txt/sample.txt +18 -0
- package/payload/docs/README.md +102 -0
- package/payload/docs/boundaries.md +60 -0
- package/payload/docs/ci-wiring.md +109 -0
- package/payload/docs/steward-guide.md +238 -0
- package/payload/engine/audit.js +34 -0
- package/payload/engine/commands/audit.js +289 -0
- package/payload/engine/commands/derive.js +334 -0
- package/payload/engine/commands/ingest.js +124 -0
- package/payload/engine/commands/log-entry.js +85 -0
- package/payload/engine/commands/phoenix.js +206 -0
- package/payload/engine/commands/preflight.js +530 -0
- package/payload/engine/commands/resolve.js +1678 -0
- package/payload/engine/commands/survey-map.js +351 -0
- package/payload/engine/commands/validate-values.js +315 -0
- package/payload/engine/commands/validate.js +1426 -0
- package/payload/engine/derive.js +34 -0
- package/payload/engine/ingest.js +34 -0
- package/payload/engine/lib/anchor-signatures.js +126 -0
- package/payload/engine/lib/boot.js +39 -0
- package/payload/engine/lib/call-numbers.js +133 -0
- package/payload/engine/lib/cli.js +147 -0
- package/payload/engine/lib/coverage.js +849 -0
- package/payload/engine/lib/decomposition.js +225 -0
- package/payload/engine/lib/derived.js +494 -0
- package/payload/engine/lib/engine-refusal.js +40 -0
- package/payload/engine/lib/exit-codes.js +14 -0
- package/payload/engine/lib/extractor-kinds.js +955 -0
- package/payload/engine/lib/format-adapters.js +802 -0
- package/payload/engine/lib/id-grammars.js +178 -0
- package/payload/engine/lib/iso-date.js +55 -0
- package/payload/engine/lib/kit-root.js +101 -0
- package/payload/engine/lib/load-stores.js +1624 -0
- package/payload/engine/lib/log-entry.js +196 -0
- package/payload/engine/lib/phoenix.js +628 -0
- package/payload/engine/lib/scoring.js +150 -0
- package/payload/engine/lib/suppressions.js +172 -0
- package/payload/engine/lib/time-verdicts.js +282 -0
- package/payload/engine/lib/usage-error.js +14 -0
- package/payload/engine/lib/validate-record.js +504 -0
- package/payload/engine/log-entry.js +34 -0
- package/payload/engine/phoenix.js +39 -0
- package/payload/engine/preflight.js +34 -0
- package/payload/engine/resolve.js +34 -0
- package/payload/engine/survey-map.js +34 -0
- package/payload/engine/validate-values.js +34 -0
- package/payload/engine/validate.js +34 -0
- package/payload/extractor-fixtures/.gitkeep +0 -0
- package/payload/extractor-fixtures/README.md +29 -0
- package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
- package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
- package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
- package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
- package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
- package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
- package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
- package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
- package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
- package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
- package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
- package/payload/hooks/pre-commit +37 -0
- package/payload/hooks/reverse-lookup +66 -0
- package/payload/package.json +3 -0
- package/payload/protocol/.gitkeep +0 -0
- package/payload/protocol/AGENTS.md +239 -0
- package/payload/protocol/derived-layer.md +174 -0
- package/payload/protocol/new-kind-pipeline.md +179 -0
- package/payload/protocol/registry-warrant.md +162 -0
- package/payload/protocol/skills/kb-build.md +303 -0
- package/payload/protocol/skills/knowledge-audit.md +183 -0
- package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
- package/payload/protocol/skills/knowledge-reflect.md +397 -0
- package/payload/schemas/catalog.schema.json +32 -0
- package/payload/schemas/decision-entry.schema.json +122 -0
- package/payload/schemas/finding.schema.json +77 -0
- package/payload/schemas/gap.schema.json +52 -0
- package/payload/schemas/graduation-categories.schema.json +64 -0
- package/payload/schemas/knowledge-leaf.schema.json +194 -0
- package/payload/schemas/miss.schema.json +45 -0
- package/payload/schemas/ontology-concept.schema.json +115 -0
- package/payload/schemas/phoenix-event.schema.json +76 -0
- package/payload/schemas/registry.schema.json +57 -0
- package/payload/schemas/rules.schema.json +14 -0
- package/payload/schemas/survey-scope.schema.json +23 -0
- package/payload/templates/decisions/_catalog.yaml +7 -0
- package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
- package/payload/templates/decisions/phoenix-event.yaml +74 -0
- package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
- package/payload/templates/decisions/registry-minting.yaml +58 -0
- package/payload/templates/decisions/trust-graduation.yaml +120 -0
- package/payload/templates/decisions/trust-revocation.yaml +106 -0
- package/payload/templates/knowledge/_catalog.yaml +9 -0
- package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
- package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
- package/payload/templates/knowledge/_registries/domains.yaml +43 -0
- package/payload/templates/knowledge/_registries/form.yaml +38 -0
- package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
- package/payload/templates/knowledge/_registries/operations.yaml +18 -0
- package/payload/templates/knowledge/_registries/stage.yaml +53 -0
- package/payload/templates/knowledge/_rules.yaml +6 -0
- package/payload/templates/new-kind/README.md +107 -0
- package/payload/templates/new-kind/descriptor.example.yaml +18 -0
- package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
- package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
- package/payload/templates/new-kind/fixture/sample.list +7 -0
- package/payload/templates/new-kind/parser.example.js +98 -0
- package/payload/templates/ontology/_catalog.yaml +6 -0
- package/payload/templates/ontology/_rules.yaml +6 -0
- package/payload/wrappers/cursor.mdc +15 -0
- package/payload/wrappers/pointer.md +10 -0
|
@@ -0,0 +1,1678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolver (KK-06) — the runtime loop's RESOLVE step and the ACT step's
|
|
3
|
+
* pre-commit reverse lookup (PRD §4, §7). Plain CLI so any agent that can run
|
|
4
|
+
* a shell command gets resolution — no MCP required.
|
|
5
|
+
*
|
|
6
|
+
* node payload/engine/resolve.js <query terms...> [--json] [--root <dir>]
|
|
7
|
+
* node payload/engine/resolve.js --paths <file1,file2> [--json] [--root <dir>]
|
|
8
|
+
* node payload/engine/resolve.js --doc <document> [--json] [--root <dir>]
|
|
9
|
+
*
|
|
10
|
+
* ONE ENTRY POINT, THREE INPUT SHAPES (UCS-1156). A query, a set of repo paths,
|
|
11
|
+
* and a whole document all enter here, and the pipeline is SIZE- AND
|
|
12
|
+
* FORMAT-INVARIANT because a query is processed as a ONE-BLOCK DOCUMENT through
|
|
13
|
+
* the same code: `resolveQuery` and `--doc` both reach the store through
|
|
14
|
+
* `joinText`, so a query and its equivalent one-block document produce
|
|
15
|
+
* identical joins by construction rather than by two implementations agreeing.
|
|
16
|
+
* A second document-shaped matcher is how two surfaces come to disagree about
|
|
17
|
+
* what a store contains, and the disagreement would be invisible — both would
|
|
18
|
+
* return plausible results.
|
|
19
|
+
*
|
|
20
|
+
* --doc mode emits a COVERAGE MAP (lib/coverage.js): per-section joins and
|
|
21
|
+
* candidates, a gather rollup with verdicts and scope-mismatch flags, and
|
|
22
|
+
* ranked candidates each carrying a section locator for just-in-time reads. Its
|
|
23
|
+
* size grows with content RICHNESS, not document length — a long redundant
|
|
24
|
+
* document repeats vocabulary that joins nothing new, so it produces a smaller
|
|
25
|
+
* map than a short dense one. An agent's context cost is the map plus the
|
|
26
|
+
* sections it chooses to open, never the document.
|
|
27
|
+
*
|
|
28
|
+
* An unsupported format, or content outside an adapter's envelope, is a HARD
|
|
29
|
+
* ERROR WITH CONDUCT and exits 2 — a parse that never ran is a failure, never a
|
|
30
|
+
* silent partial that would report a document as covered when half of it was
|
|
31
|
+
* never read (PRD §5.1).
|
|
32
|
+
*
|
|
33
|
+
* Query mode — scored term matching over the ontology. The query is the terms
|
|
34
|
+
* joined by single spaces, lowercased. A concept scores on the HIGHEST rung it
|
|
35
|
+
* reaches (rungs never add up); the scoring is pinned so ranking is stable:
|
|
36
|
+
*
|
|
37
|
+
* 100 exact-term query == term (case-insensitive)
|
|
38
|
+
* 80 exact-alias query == an alias
|
|
39
|
+
* 60 term-match term starts with the query, or every query word is a
|
|
40
|
+
* whole word of the term
|
|
41
|
+
* 50 alias-match an alias starts with the query, or every query word is
|
|
42
|
+
* a whole word of an alias — aliases are the synonyms
|
|
43
|
+
* recorded to cure retrieval-struggle findings, so they
|
|
44
|
+
* get the same rung treatment as terms (slightly lower)
|
|
45
|
+
* 40 summary-match every query word is a whole word of the summary
|
|
46
|
+
*
|
|
47
|
+
* -30 draft/proposed concepts are downranked (floor 1) — §3.5: the resolver
|
|
48
|
+
* downranks, preflight verdicts them unknown. Deprecated concepts keep
|
|
49
|
+
* their score but are surfaced flagged (status travels with the result).
|
|
50
|
+
*
|
|
51
|
+
* Each result carries: id/term/summary/status, score + matched rung, the
|
|
52
|
+
* concept's source-of-truth pointers (GATHER follows these), knowledge entry
|
|
53
|
+
* points (leaves whose `terms` name the concept's term or an alias — the
|
|
54
|
+
* knowledge-catalog descent, PRD §4), and confusable-with surfaced with each
|
|
55
|
+
* referenced concept's term so disambiguation needs no second lookup.
|
|
56
|
+
*
|
|
57
|
+
* Each knowledge entry point publishes `id`, the accession (L-NNNNNN) that IS
|
|
58
|
+
* the leaf's identity and the only spelling anything cites it by (UCS-1147),
|
|
59
|
+
* and `notation`, the optional legacy display label, null when the leaf carries
|
|
60
|
+
* none. Two fields because they answer two questions — which leaf this is, and
|
|
61
|
+
* what it was once filed as — and only the first is an identity.
|
|
62
|
+
*
|
|
63
|
+
* Frontmatter v2 adds three more (UCS-1149), all stable keys that may be null
|
|
64
|
+
* rather than fields that come and go:
|
|
65
|
+
*
|
|
66
|
+
* stage the leaf's promotion stage (facets.stage), or null
|
|
67
|
+
* excerpt the first sentence of the BODY, derived — v2 retired the
|
|
68
|
+
* authored `description`, so display prose is read back from the
|
|
69
|
+
* content and cannot drift from it
|
|
70
|
+
* provenance { author, skill-version }, carried through untouched
|
|
71
|
+
*
|
|
72
|
+
* Typed edges add two more (UCS-1151), on every leaf the resolver publishes in
|
|
73
|
+
* either mode:
|
|
74
|
+
*
|
|
75
|
+
* via how this leaf was reached — `declared` (it names the concept in
|
|
76
|
+
* its `concepts` edge) or `terms` (its term text matched), and in
|
|
77
|
+
* --paths mode `direct` (it names the path) or `concept` (the path
|
|
78
|
+
* is under a concept it declares). Two joins of different strength,
|
|
79
|
+
* so the result says which one fired rather than leaving a reader
|
|
80
|
+
* to assume the stronger one
|
|
81
|
+
* relates the leaf's ONE-HOP neighborhood, keyed by edge kind (depends-on /
|
|
82
|
+
* see-also / contradicts / supersedes), each neighbor a minimal
|
|
83
|
+
* reference {id, notation, heading, file}. Outgoing edges only —
|
|
84
|
+
* what this leaf's author asserted. Exactly one hop: a neighbor's
|
|
85
|
+
* neighbors are absent, because the hop exists to show what sits
|
|
86
|
+
* immediately around a hit, and depth 2 is most of the store
|
|
87
|
+
* arriving unranked
|
|
88
|
+
*
|
|
89
|
+
* Knowledge entry points now join STRUCTURALLY as well as textually: a leaf
|
|
90
|
+
* that declares a concept surfaces under it whether or not any term text
|
|
91
|
+
* matches, so knowledge stops depending on two authors choosing the same words.
|
|
92
|
+
*
|
|
93
|
+
* A leaf at a pre-promotion stage is DOWNRANKED: flagged `downranked: true` and
|
|
94
|
+
* sorted below every promoted entry point. The flag comes off the same
|
|
95
|
+
* `isPrePromotionStatus` predicate preflight verdicts on, so the resolver's
|
|
96
|
+
* demotion and preflight's unknown verdict cannot disagree about which leaves
|
|
97
|
+
* are provisional.
|
|
98
|
+
*
|
|
99
|
+
* The Time facet adds two more published fields and a second demotion
|
|
100
|
+
* (UCS-1150):
|
|
101
|
+
*
|
|
102
|
+
* time the leaf's freshness verdict — {verdict, stale, volatility,
|
|
103
|
+
* verified, age, limit, reason}, from the shared
|
|
104
|
+
* lib/time-verdicts.js that preflight and the derived layer also
|
|
105
|
+
* read. Verdict classes: trusted, stale, skipped (no --today was
|
|
106
|
+
* injected), exempt (the leaf declares no volatility), undated
|
|
107
|
+
* (it declares one but carries no usable date)
|
|
108
|
+
* demotions every demotion that fired, each with its reason — `stage` for a
|
|
109
|
+
* pre-promotion leaf, `time` for a stale one. Never a bare flag:
|
|
110
|
+
* a demotion a reader cannot explain is one they cannot act on
|
|
111
|
+
*
|
|
112
|
+
* `downranked` is now the UNION of both demotions, so a stale leaf sorts below
|
|
113
|
+
* the fresh ones through the comparator the draft demotion already used. It is
|
|
114
|
+
* still a demotion and never a filter — a stale leaf is still the best answer
|
|
115
|
+
* when it is the only answer, and hiding it would send the reader to invent one.
|
|
116
|
+
*
|
|
117
|
+
* `--today <YYYY-MM-DD>` injects the date verdicts are measured against, and
|
|
118
|
+
* the engine never reads the wall clock (D-012). WITHOUT it, time verdicts
|
|
119
|
+
* report themselves `skipped` and the output SAYS so on a `time-check` line
|
|
120
|
+
* present in every payload: a run that computed no freshness verdicts must not
|
|
121
|
+
* read like one that checked and found everything fresh.
|
|
122
|
+
*
|
|
123
|
+
* QUERY DECOMPOSITION (UCS-1152) turns the query itself into joins against the
|
|
124
|
+
* governed vocabularies, and says what did not join. Three axes, three
|
|
125
|
+
* vocabularies, and none of them guessing:
|
|
126
|
+
*
|
|
127
|
+
* verb -> the `knowledge/operations` registry "add a token" -> add-token
|
|
128
|
+
* noun -> concept terms and aliases "token" -> K-101
|
|
129
|
+
* place -> the `knowledge/jurisdictions` registry "eu eaa" -> eu-eaa
|
|
130
|
+
*
|
|
131
|
+
* Four sections join the payload, every one a STABLE key that may be empty:
|
|
132
|
+
*
|
|
133
|
+
* decomposition what each axis resolved to, the tokens it consumed, the
|
|
134
|
+
* near-misses, the residue, and the resolved context residue
|
|
135
|
+
* should be recorded alongside
|
|
136
|
+
* scoring the signal→score table the ranking was computed with, so a
|
|
137
|
+
* consumer reproducing it never vendors a copy that goes stale
|
|
138
|
+
* leaves leaves as FIRST-CLASS SCORED RESULTS, each carrying the
|
|
139
|
+
* signals that scored it. Before this ticket a leaf could only
|
|
140
|
+
* appear as an attachment to a concept, which made an entire
|
|
141
|
+
* class of correct answer unreachable: "add a token" resolves a
|
|
142
|
+
* VERB, and the leaf declaring that operation is the answer
|
|
143
|
+
* whether or not any concept matched
|
|
144
|
+
* exclusions leaves the query's scope excluded, each with its REASON —
|
|
145
|
+
* excluded, never silently absent
|
|
146
|
+
*
|
|
147
|
+
* EXTENSION, NOT REPLACEMENT. `results` and its concept-attached `knowledge`
|
|
148
|
+
* lists are untouched, and concept scores are exactly what they were: the
|
|
149
|
+
* ladder still decides them, and a concept the ask merely NAMED (reached by the
|
|
150
|
+
* token-level phrase test but not the whole-query ladder) appears in
|
|
151
|
+
* `decomposition.concepts` without being given an invented rung in `results`.
|
|
152
|
+
* Every pre-1152 consumer keeps working; a new one can read leaves directly.
|
|
153
|
+
*
|
|
154
|
+
* SCOPE EXCLUSION is the criterion that most needs saying out loud: a leaf
|
|
155
|
+
* whose `applies.jurisdictions` is non-empty and excludes the query's
|
|
156
|
+
* jurisdiction is published in `exclusions` with the reason, never dropped.
|
|
157
|
+
* "No knowledge about theming tokens for us-ca" and "the knowledge about theming
|
|
158
|
+
* tokens is eu-eaa-only" demand opposite conduct, and a filtered-away leaf
|
|
159
|
+
* makes them indistinguishable. An empty `applies` is UNIVERSAL and never
|
|
160
|
+
* excluded; a query naming no jurisdiction excludes nothing.
|
|
161
|
+
*
|
|
162
|
+
* RESIDUE is the unconsumed non-stopword tokens — the store's own record of
|
|
163
|
+
* what it does not yet know — emitted with `resolved-context` so the gap is
|
|
164
|
+
* localized enough to act on. The stopword list is pinned and shipped in the
|
|
165
|
+
* engine (lib/decomposition.js), never configurable per run.
|
|
166
|
+
*
|
|
167
|
+
* --paths mode — reverse lookup over BOTH pointer families: "which concepts
|
|
168
|
+
* point at these files, and which leaves govern them" (UCS-1151). The join runs
|
|
169
|
+
* over concept source-of-truth pointers and over leaf `paths` declarations, so
|
|
170
|
+
* a diff-shaped input surfaces the knowledge that governs the files before an
|
|
171
|
+
* edit rather than stopping at the concept and leaving the reader to make that
|
|
172
|
+
* hop by hand. A path matches a pointer when equal to it or
|
|
173
|
+
* nested under a FOLDER pointer (§3.1). Folder-ness is read from the
|
|
174
|
+
* filesystem, not from the name — `src/api.v2` is a directory whose extname is
|
|
175
|
+
* ".v2" — and from the name only when the pointer is gone, since a diff names
|
|
176
|
+
* deleted paths; see folderPointerTest. Paths are normalized with path.posix
|
|
177
|
+
* semantics (dots resolved, separators collapsed, backslashes converted,
|
|
178
|
+
* absolute paths relativized against the store root) so attribution survives
|
|
179
|
+
* the forms real tooling emits. An entry naming the repo root is a usage
|
|
180
|
+
* error, never a silently dropped lookup. A lookup, not subset validation (no
|
|
181
|
+
* D-012 conflict). Paths are deduped and sorted ascending.
|
|
182
|
+
*
|
|
183
|
+
* Zero resolution is a NORMAL outcome (PRD §7 — common in month one): exit 0
|
|
184
|
+
* with an explicit empty result plus the fallback conduct (search within
|
|
185
|
+
* survey-scope.yaml; append a retrieval-miss finding only if the topic
|
|
186
|
+
* plausibly should be mapped). Exit codes (PRD §5): 0 = the lookup ran (hits
|
|
187
|
+
* or none), 2 = usage/engine failure — a lookup that never ran is a failure,
|
|
188
|
+
* never a silent empty result. The resolver emits no findings, so it never
|
|
189
|
+
* exits 1; gating on store health is preflight's job. Store health is still
|
|
190
|
+
* surfaced (single health model), and resolution runs on whatever loaded.
|
|
191
|
+
*
|
|
192
|
+
* JSON output is deterministic and stable-sorted — results by score desc then
|
|
193
|
+
* id asc; paths/pointers/entry points lexicographic — with no timestamps.
|
|
194
|
+
*/
|
|
195
|
+
import process from 'node:process';
|
|
196
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
197
|
+
import { join, posix, resolve as resolvePath } from 'node:path';
|
|
198
|
+
import {
|
|
199
|
+
LEAF_PATHS_FIELD, RELATES_FIELD, RELATES_KINDS, healthSummary, isPrePromotionStatus,
|
|
200
|
+
leafIdentityOf, leafStage, loadStores, storeHealth,
|
|
201
|
+
} from '../lib/load-stores.js';
|
|
202
|
+
import { locateKitRoot } from '../lib/kit-root.js';
|
|
203
|
+
import { EXIT_CODES } from '../lib/exit-codes.js';
|
|
204
|
+
import { UsageError, parseArgs as parseFlags, rethrowIfBug } from '../lib/cli.js';
|
|
205
|
+
import { compare } from '../lib/validate-record.js';
|
|
206
|
+
// Query decomposition (UCS-1152) — the joins against the governed vocabularies,
|
|
207
|
+
// and the scoring table those joins are weighed by. Both extracted into lib so
|
|
208
|
+
// the signal→score mapping is one declaration rather than arithmetic spread
|
|
209
|
+
// through the matcher.
|
|
210
|
+
import {
|
|
211
|
+
STOPWORDS, mintedValues, phraseHit, phraseOverlap, phraseWords, tokenize, valuePhrases,
|
|
212
|
+
} from '../lib/decomposition.js';
|
|
213
|
+
import { conceptScore, leafScore, scoringTable } from '../lib/scoring.js';
|
|
214
|
+
// The Time facet (UCS-1150). The resolver computes no verdict of its own — one
|
|
215
|
+
// implementation, shared with preflight and the derived layer, so a leaf ranked
|
|
216
|
+
// stale here is never verdicted trusted there.
|
|
217
|
+
import { timeCheckStatus, timeVerdict } from '../lib/time-verdicts.js';
|
|
218
|
+
import { isCalendarDate } from '../lib/iso-date.js';
|
|
219
|
+
// The document coverage map (UCS-1156) and the adapter seam it reads. The
|
|
220
|
+
// coverage module owns the MAP; this command owns the JOINS, and passes its own
|
|
221
|
+
// joiner in — so the document path cannot grow a second matcher.
|
|
222
|
+
import { buildCoverageMap } from '../lib/coverage.js';
|
|
223
|
+
import { AdaptError, UnsupportedFormatError, adapt, adapterFor } from '../lib/format-adapters.js';
|
|
224
|
+
import { loadSuppressions } from '../lib/suppressions.js';
|
|
225
|
+
|
|
226
|
+
export const USAGE = `usage: node payload/engine/resolve.js <query terms...> [--json] [--root <dir>] [--today <YYYY-MM-DD>]
|
|
227
|
+
node payload/engine/resolve.js --paths <file1,file2> [--json] [--root <dir>] [--today <YYYY-MM-DD>]
|
|
228
|
+
node payload/engine/resolve.js --doc <document> [--json] [--root <dir>] [--today <YYYY-MM-DD>]`;
|
|
229
|
+
|
|
230
|
+
// The concept ladder and the draft downrank moved to lib/scoring.js (UCS-1152)
|
|
231
|
+
// — one signal→score table, so a reader asking "what is a score of 70 made of"
|
|
232
|
+
// has one place to look and `explain`-style reproduction is possible at all.
|
|
233
|
+
// The NUMBERS are unchanged and pinned by the existing goldens: the extraction
|
|
234
|
+
// moved this arithmetic, it did not renegotiate it.
|
|
235
|
+
|
|
236
|
+
/** Registry keys the query's verb and place axes join through (UCS-1148). */
|
|
237
|
+
const OPERATIONS_REGISTRY = 'knowledge/operations';
|
|
238
|
+
const JURISDICTIONS_REGISTRY = 'knowledge/jurisdictions';
|
|
239
|
+
|
|
240
|
+
/** The leaf front-matter fields the structured joins read. */
|
|
241
|
+
const OPERATIONS_FIELD = 'operations';
|
|
242
|
+
const APPLIES_FIELD = 'applies';
|
|
243
|
+
const JURISDICTIONS_FIELD = 'jurisdictions';
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The first sentence of a leaf's body, or null when it has none (UCS-1149).
|
|
247
|
+
*
|
|
248
|
+
* Frontmatter v2 retired the free-prose `description` field, so display prose
|
|
249
|
+
* is DERIVED rather than authored: a leaf opens its body with a topic sentence
|
|
250
|
+
* and this reads it back. The point is that the summary cannot drift from the
|
|
251
|
+
* content — an authored one-liner is a second copy of the claim, and the copy
|
|
252
|
+
* is what goes stale when the body is edited and the frontmatter is not.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately literal about what a "first sentence" is, because a clever
|
|
255
|
+
* extractor that guesses wrong is worse than a plain one that occasionally
|
|
256
|
+
* returns a long line:
|
|
257
|
+
*
|
|
258
|
+
* - Markdown structure is skipped LINE BY LINE, not block by block. That is
|
|
259
|
+
* the whole subtlety: structure in markdown is a property of a line, and a
|
|
260
|
+
* heading needs no blank line after it, so `# Title\nThe real opening.` is
|
|
261
|
+
* one block whose first line is a heading and whose second is the prose.
|
|
262
|
+
* Discarding the block would blank the excerpt for a perfectly ordinary
|
|
263
|
+
* body; discarding just the heading line finds the sentence underneath.
|
|
264
|
+
* - Fenced code is skipped WHOLE, tracked as state across lines rather than
|
|
265
|
+
* matched line by line, in both CommonMark spellings (``` and ~~~).
|
|
266
|
+
* Matching only the fence markers would leave the code between them
|
|
267
|
+
* looking like ordinary prose, which is how `code();` ends up published as
|
|
268
|
+
* a leaf's excerpt. An unclosed fence runs to the end of the body.
|
|
269
|
+
* - Prose then runs to the next blank line or structural line, with internal
|
|
270
|
+
* newlines collapsed to single spaces: bodies are hard-wrapped, so a
|
|
271
|
+
* sentence routinely spans two lines and a line-based reader would truncate
|
|
272
|
+
* it mid-clause.
|
|
273
|
+
* - A sentence ends at `.`/`!`/`?` followed by whitespace or end-of-text.
|
|
274
|
+
* `§4.2` and `v3.` do not end a sentence mid-token, which is why the
|
|
275
|
+
* following character must be whitespace rather than anything at all.
|
|
276
|
+
* - Prose with no terminator IS the excerpt — a body whose opening line is a
|
|
277
|
+
* fragment still has display prose, and returning null there would silently
|
|
278
|
+
* blank the surface rather than show what the author wrote.
|
|
279
|
+
*
|
|
280
|
+
* @param {string|undefined} body the markdown below the front matter
|
|
281
|
+
* @returns {string|null}
|
|
282
|
+
*/
|
|
283
|
+
export function firstSentence(body) {
|
|
284
|
+
if (typeof body !== 'string') return null;
|
|
285
|
+
// Headings, list items, ordered items, block quotes, and table rows.
|
|
286
|
+
const structural = /^(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|\|)/;
|
|
287
|
+
// Both fence spellings — CommonMark allows ~~~ as well as ```.
|
|
288
|
+
const fence = /^(```|~~~)/;
|
|
289
|
+
// A fence has to be tracked as STATE, not matched as a line: skipping the
|
|
290
|
+
// fence markers alone would leave the code BETWEEN them looking like
|
|
291
|
+
// ordinary prose, and `code();` would be published as a leaf's excerpt.
|
|
292
|
+
// So everything from an opening fence to its closing one is skipped whole.
|
|
293
|
+
const prose = [];
|
|
294
|
+
let fenced = false;
|
|
295
|
+
for (const raw of body.split('\n')) {
|
|
296
|
+
const line = raw.trim();
|
|
297
|
+
if (fence.test(line)) {
|
|
298
|
+
// An unclosed fence runs to the end of the body — which is the honest
|
|
299
|
+
// reading of a malformed block, and never leaves code in the excerpt.
|
|
300
|
+
fenced = !fenced;
|
|
301
|
+
if (prose.length) break; // prose already collected; a fence ends it
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (fenced) continue;
|
|
305
|
+
if (line === '' || structural.test(line)) {
|
|
306
|
+
// Prose stops AT structure rather than swallowing it, so an excerpt
|
|
307
|
+
// never shows markup; before any prose, structure is just skipped.
|
|
308
|
+
if (prose.length) break;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
prose.push(line);
|
|
312
|
+
}
|
|
313
|
+
if (!prose.length) return null;
|
|
314
|
+
const flat = prose.join(' ').replace(/\s+/g, ' ').trim();
|
|
315
|
+
const stop = flat.search(/[.!?](\s|$)/);
|
|
316
|
+
return stop === -1 ? flat : flat.slice(0, stop + 1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
320
|
+
const words = (s) => norm(s).split(/[^a-z0-9]+/).filter(Boolean);
|
|
321
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
322
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------- query mode
|
|
325
|
+
|
|
326
|
+
/** Prefix or whole-word rung shared by terms and aliases. */
|
|
327
|
+
const rungMatch = (query, queryWords, name) =>
|
|
328
|
+
norm(name).startsWith(query) || queryWords.every((w) => words(name).includes(w));
|
|
329
|
+
|
|
330
|
+
/** Highest scoring rung the concept reaches for this query, or null. */
|
|
331
|
+
function matchConcept(query, queryWords, record) {
|
|
332
|
+
const term = typeof record.term === 'string' ? record.term : '';
|
|
333
|
+
const aliases = strings(record.aliases);
|
|
334
|
+
if (norm(term) === query) return 'exact-term';
|
|
335
|
+
if (aliases.some((alias) => norm(alias) === query)) return 'exact-alias';
|
|
336
|
+
if (rungMatch(query, queryWords, term)) return 'term-match';
|
|
337
|
+
if (aliases.some((alias) => rungMatch(query, queryWords, alias))) return 'alias-match';
|
|
338
|
+
const summaryWords = words(typeof record.summary === 'string' ? record.summary : '');
|
|
339
|
+
if (queryWords.every((w) => summaryWords.includes(w))) return 'summary-match';
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* A leaf's declared operations (UCS-1152) — the single reader of the
|
|
345
|
+
* `operations` spelling, for the same reason `leafConcepts` is for `concepts`.
|
|
346
|
+
*
|
|
347
|
+
* Non-strings are dropped rather than coerced: the schema already diagnoses the
|
|
348
|
+
* wrong type, and a coerced value would join a leaf to an operation nobody
|
|
349
|
+
* declared.
|
|
350
|
+
*
|
|
351
|
+
* @param {object} record a leaf's front-matter record
|
|
352
|
+
* @returns {string[]}
|
|
353
|
+
*/
|
|
354
|
+
const leafOperations = (record) => strings(record?.[OPERATIONS_FIELD]);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The jurisdictions a leaf declares itself applicable to (UCS-1152).
|
|
358
|
+
*
|
|
359
|
+
* An EMPTY list is the universal case and is load-bearing: a leaf that declares
|
|
360
|
+
* no jurisdictions applies everywhere and is never scope-excluded. The
|
|
361
|
+
* distinction between "applies to nowhere" and "applies everywhere" is
|
|
362
|
+
* precisely the one an empty array has to carry, and it reads as universal
|
|
363
|
+
* because that is what an author who wrote no jurisdiction meant — the
|
|
364
|
+
* alternative would silently hide every leaf in every store that has not yet
|
|
365
|
+
* adopted the facet.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} record a leaf's front-matter record
|
|
368
|
+
* @returns {string[]}
|
|
369
|
+
*/
|
|
370
|
+
function leafJurisdictions(record) {
|
|
371
|
+
const applies = record?.[APPLIES_FIELD];
|
|
372
|
+
return isObject(applies) ? strings(applies[JURISDICTIONS_FIELD]) : [];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** confusable-with ids, each resolved to its term for one-lookup disambiguation. */
|
|
376
|
+
function confusables(model, record) {
|
|
377
|
+
return strings(record['confusable-with'])
|
|
378
|
+
.sort(compare)
|
|
379
|
+
.map((id) => ({ id, term: model.concepts.get(id)?.record.term ?? null }));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Knowledge entry points: leaves whose `terms` name the concept term/alias.
|
|
384
|
+
*
|
|
385
|
+
* Ordered promoted-first (UCS-1149): a pre-promotion leaf sorts BELOW every
|
|
386
|
+
* promoted one, and ties keep the loader's id order so output stays byte-stable.
|
|
387
|
+
* That ordering is the resolver's half of the draft-stage contract — an agent
|
|
388
|
+
* reading the list top-down reaches certified knowledge before provisional
|
|
389
|
+
* knowledge — and it is deliberately a demotion rather than a filter: a draft
|
|
390
|
+
* leaf is still the best answer when it is the only answer, and hiding it would
|
|
391
|
+
* send the reader to invent one instead.
|
|
392
|
+
*/
|
|
393
|
+
/**
|
|
394
|
+
* One leaf's one-hop `relates` neighborhood, typed and labeled by edge kind
|
|
395
|
+
* (UCS-1151).
|
|
396
|
+
*
|
|
397
|
+
* The shape is a MAP keyed by edge kind, not a flat list with a `kind` field,
|
|
398
|
+
* because the kinds are not interchangeable: `contradicts` and `see-also` ask
|
|
399
|
+
* an agent to do different things, and a flat list invites reading the first
|
|
400
|
+
* few entries as if the kind were incidental. Every declared kind is present as
|
|
401
|
+
* a key even when empty, for the reason every other v2 field is a stable key
|
|
402
|
+
* that may be null — one result shape, so a consumer never needs a presence
|
|
403
|
+
* check to tell "no contradictions" from "this engine predates contradicts".
|
|
404
|
+
*
|
|
405
|
+
* Exactly ONE hop, and that is a deliberate boundary rather than a first
|
|
406
|
+
* increment. A neighbor's neighbors are absent: the point of the hop is to show
|
|
407
|
+
* an agent what sits immediately around a hit so it can decide what to read
|
|
408
|
+
* next, and two hops would put material in front of it that nothing it asked
|
|
409
|
+
* for actually touches — the relates graph is dense enough that depth 2 is most
|
|
410
|
+
* of the store, arriving unranked and unexplained. Each neighbor carries what
|
|
411
|
+
* it takes to decide whether to follow it (`heading`) and to actually go
|
|
412
|
+
* (`file`, and both ids); following IS the second hop, and that is the caller's
|
|
413
|
+
* call to make.
|
|
414
|
+
*
|
|
415
|
+
* OUTGOING edges only. The ticket says the resolver expands over relates edges
|
|
416
|
+
* FROM a hit, and outgoing is what this leaf's author asserted: a leaf declares
|
|
417
|
+
* what IT depends on, what IT contradicts. An incoming edge is somebody else's
|
|
418
|
+
* claim about this leaf, which is a genuinely useful thing to see and a
|
|
419
|
+
* different question — it belongs to whatever surface presents "what cites
|
|
420
|
+
* this", where it can be labeled as such rather than blended into the leaf's
|
|
421
|
+
* own assertions.
|
|
422
|
+
*
|
|
423
|
+
* Neighbors resolve through `leafIdentityOf`, the one lookup every surface
|
|
424
|
+
* asks — so an edge citing a leaf by its retired notation reaches nothing here
|
|
425
|
+
* for the same reason it fails validation (UCS-1147), rather than through a
|
|
426
|
+
* second rule this function spells itself. An edge that resolves to nothing is
|
|
427
|
+
* DROPPED here rather than
|
|
428
|
+
* published as a stub: the loader has already raised the unresolved-ref finding
|
|
429
|
+
* against it, and a neighborhood entry naming a leaf that does not exist would
|
|
430
|
+
* send a reader after a file nobody can open.
|
|
431
|
+
*
|
|
432
|
+
* @param {object} model the loaded store model
|
|
433
|
+
* @param {object} record the leaf's front-matter record
|
|
434
|
+
* @returns {Record<string, Array<{id, notation, heading, file}>>}
|
|
435
|
+
*/
|
|
436
|
+
function relatesNeighborhood(model, record) {
|
|
437
|
+
const declared = isObject(record[RELATES_FIELD]) ? record[RELATES_FIELD] : {};
|
|
438
|
+
const neighborhood = {};
|
|
439
|
+
for (const kind of RELATES_KINDS) {
|
|
440
|
+
const seen = new Set();
|
|
441
|
+
const neighbors = [];
|
|
442
|
+
for (const cited of strings(declared[kind])) {
|
|
443
|
+
const identity = leafIdentityOf(model, cited);
|
|
444
|
+
// Unresolvable: the loader already reported it as unresolved-ref. A stub
|
|
445
|
+
// here would be a second report of one defect, wearing the shape of a
|
|
446
|
+
// real neighbor.
|
|
447
|
+
if (identity === undefined || seen.has(identity)) continue;
|
|
448
|
+
seen.add(identity);
|
|
449
|
+
const entry = model.leaves.get(identity);
|
|
450
|
+
neighbors.push({
|
|
451
|
+
id: typeof entry.id === 'string' ? entry.id : null,
|
|
452
|
+
notation: typeof entry.notation === 'string' ? entry.notation : null,
|
|
453
|
+
heading: entry.record?.heading ?? null,
|
|
454
|
+
file: entry.file,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// Sorted by identity, so a neighborhood is byte-stable regardless of the
|
|
458
|
+
// order the author happened to list the citations in.
|
|
459
|
+
neighborhood[kind] = neighbors.sort((a, b) => compare(a.id ?? a.notation, b.id ?? b.notation));
|
|
460
|
+
}
|
|
461
|
+
return neighborhood;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The published shape of one knowledge leaf — every surface that surfaces a
|
|
466
|
+
* leaf builds it HERE.
|
|
467
|
+
*
|
|
468
|
+
* Extracted in UCS-1151 because a leaf now reaches the caller three ways: as a
|
|
469
|
+
* concept's knowledge entry point (by term text or by declared `concepts`
|
|
470
|
+
* edge), and as a governing leaf in reverse path lookup. Three call sites
|
|
471
|
+
* spelling this object out would be three chances for a field to be published
|
|
472
|
+
* one way in one mode and another way in the next — and the fields most at risk
|
|
473
|
+
* are exactly the ones whose whole contract is that they are STABLE keys that
|
|
474
|
+
* may be null.
|
|
475
|
+
*/
|
|
476
|
+
function publishLeaf(model, entry, today) {
|
|
477
|
+
const { file, record: leaf } = entry;
|
|
478
|
+
const stage = leafStage(leaf);
|
|
479
|
+
const provenance = leaf.provenance;
|
|
480
|
+
// The Time facet's verdict (UCS-1150), computed once per published leaf and
|
|
481
|
+
// carried on it. Published on EVERY leaf including the exempt and the
|
|
482
|
+
// skipped ones, for the reason every other v2 field is a stable key: a
|
|
483
|
+
// consumer must never need a presence check to tell "this leaf is fresh"
|
|
484
|
+
// from "this run never asked what day it is".
|
|
485
|
+
const time = timeVerdict(leaf, today);
|
|
486
|
+
return {
|
|
487
|
+
// `notation` and `id` are this command's PUBLISHED field names (§4), so
|
|
488
|
+
// they are spelled here on purpose; the VALUES come from the loader's
|
|
489
|
+
// indexed entry, which is what an id-space change moves (UCS-1142).
|
|
490
|
+
// Wire name and storage field are two different decisions.
|
|
491
|
+
//
|
|
492
|
+
// `id` is the accession — the leaf's IDENTITY (UCS-1147), and the only
|
|
493
|
+
// spelling anything may cite it by. Placed first for that reason;
|
|
494
|
+
// JSON.stringify preserves insertion order, so this fixes the field's
|
|
495
|
+
// position in the byte-stable output for good.
|
|
496
|
+
//
|
|
497
|
+
// `notation` is the OPTIONAL LEGACY display label, published alongside and
|
|
498
|
+
// never as identity. It stays because it is still a fact about the leaf a
|
|
499
|
+
// reader may want to see, and because a published field that vanished would
|
|
500
|
+
// break consumers as surely as one that changed meaning — but nothing
|
|
501
|
+
// resolves through it, and a store keying on it is keying on a label.
|
|
502
|
+
//
|
|
503
|
+
// Both are published as STRING-OR-NULL, tested with `typeof` rather than
|
|
504
|
+
// `??`: `??` only catches null/undefined, so an unquoted YAML `id: 12345` —
|
|
505
|
+
// a number, not an accession — would travel into the JSON as a number and
|
|
506
|
+
// break the field's published type for every consumer. `id` is null only
|
|
507
|
+
// for a leaf no check has approved: the schema requires an accession, but
|
|
508
|
+
// the resolver never gates on store health (a lookup runs on whatever
|
|
509
|
+
// loaded, §4), so it is the one surface that can be asked to publish an id
|
|
510
|
+
// the store should not have had. A stable key whose value is null is what
|
|
511
|
+
// it says then, rather than a key that disappears.
|
|
512
|
+
// Same `typeof` test the loader and the orphan check use.
|
|
513
|
+
id: typeof entry.id === 'string' ? entry.id : null,
|
|
514
|
+
notation: typeof entry.notation === 'string' ? entry.notation : null,
|
|
515
|
+
heading: leaf.heading ?? null,
|
|
516
|
+
// `stage`, `excerpt`, and `provenance` are frontmatter v2 (UCS-1149).
|
|
517
|
+
// Like `id` they are stable keys whose value may be null, never omitted
|
|
518
|
+
// keys: a store mid-migration must emit ONE result shape, or every
|
|
519
|
+
// consumer needs a presence check to tell "this leaf declares no stage"
|
|
520
|
+
// from "this engine predates stages".
|
|
521
|
+
//
|
|
522
|
+
// `excerpt` is DERIVED from the body, not read from a field — v2 retired
|
|
523
|
+
// the authored `description` precisely so display prose cannot drift from
|
|
524
|
+
// the content it summarizes.
|
|
525
|
+
//
|
|
526
|
+
// `provenance` travels verbatim: no registry governs it, so the resolver
|
|
527
|
+
// has no judgement to apply and passing it through unchanged is the whole
|
|
528
|
+
// contract. Spelled field by field rather than spread, so a later
|
|
529
|
+
// provenance field cannot leak into published output before anyone
|
|
530
|
+
// decided it should be public.
|
|
531
|
+
stage,
|
|
532
|
+
excerpt: firstSentence(entry.body),
|
|
533
|
+
provenance: isObject(provenance)
|
|
534
|
+
? {
|
|
535
|
+
author: typeof provenance.author === 'string' ? provenance.author : null,
|
|
536
|
+
'skill-version': typeof provenance['skill-version'] === 'string' ? provenance['skill-version'] : null,
|
|
537
|
+
}
|
|
538
|
+
: null,
|
|
539
|
+
// The SAME predicate preflight verdicts on (UCS-1149). A leaf whose
|
|
540
|
+
// stage is pre-promotion is downranked here and verdicted unknown
|
|
541
|
+
// there; reading the two off one predicate is what stops the surfaces
|
|
542
|
+
// disagreeing about which leaves are provisional.
|
|
543
|
+
//
|
|
544
|
+
// `downranked` is now the UNION of two independent demotions (UCS-1150):
|
|
545
|
+
// a pre-promotion stage and a stale time verdict. It stays a single
|
|
546
|
+
// boolean because it answers a single question — does this leaf sort
|
|
547
|
+
// below the promoted ones — and every consumer already reads it that way.
|
|
548
|
+
// WHICH demotions fired is `demotions`, so a leaf that is both draft and
|
|
549
|
+
// stale reports both rather than having one silently absorb the other.
|
|
550
|
+
downranked: isPrePromotionStatus(stage) || time.stale,
|
|
551
|
+
// Never a bare flag: the ticket's demand is that a demotion is never
|
|
552
|
+
// silent, so the reason travels with it. Empty for a leaf that was not
|
|
553
|
+
// demoted — a stable key whose value is an empty array, like every other
|
|
554
|
+
// v2 field that may be absent-but-present.
|
|
555
|
+
demotions: [
|
|
556
|
+
...(isPrePromotionStatus(stage)
|
|
557
|
+
? [{ reason: 'stage', detail: `stage "${stage}" is pre-promotion — no moderator has certified this leaf's citations (UCS-1149)` }]
|
|
558
|
+
: []),
|
|
559
|
+
...(time.stale ? [{ reason: 'time', detail: time.reason }] : []),
|
|
560
|
+
],
|
|
561
|
+
// The full time verdict (UCS-1150) — verdict class, the declared facts it
|
|
562
|
+
// was computed from, and the reason. Carried on every leaf so a projection
|
|
563
|
+
// can show WHY without recomputing, which is what keeps every surface
|
|
564
|
+
// reading one answer.
|
|
565
|
+
time,
|
|
566
|
+
file,
|
|
567
|
+
// The one-hop structural neighborhood (UCS-1151) — every leaf the resolver
|
|
568
|
+
// publishes carries it, so "any hit carries its relates neighborhood" is
|
|
569
|
+
// true by construction rather than by remembering to attach it per mode.
|
|
570
|
+
[RELATES_FIELD]: relatesNeighborhood(model, leaf),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* How a leaf came to be attached to a concept (UCS-1151).
|
|
576
|
+
*
|
|
577
|
+
* Published on every entry point, because the two joins answer differently and
|
|
578
|
+
* a reader deserves to know which one fired. `declared` means the leaf names
|
|
579
|
+
* this concept in its `concepts` edge — a curatorial claim, checked by the ref
|
|
580
|
+
* graph. `terms` means the leaf's `terms` text matched the concept's term or an
|
|
581
|
+
* alias, which is the pre-1151 join and is exactly as reliable as the two
|
|
582
|
+
* authors' vocabularies happening to agree.
|
|
583
|
+
*
|
|
584
|
+
* A leaf that does both reads `declared`: the structural edge is the stronger
|
|
585
|
+
* claim, and it is the one that survives a concept being renamed.
|
|
586
|
+
*/
|
|
587
|
+
const VIA_DECLARED = 'declared';
|
|
588
|
+
const VIA_TERMS = 'terms';
|
|
589
|
+
|
|
590
|
+
function knowledgeEntryPoints(model, record, conceptId, today) {
|
|
591
|
+
const names = new Set(
|
|
592
|
+
[record.term, ...strings(record.aliases)]
|
|
593
|
+
.filter((s) => typeof s === 'string')
|
|
594
|
+
.map(norm),
|
|
595
|
+
);
|
|
596
|
+
// The STRUCTURAL half of the join (UCS-1151): leaves that declared this
|
|
597
|
+
// concept, read off the reverse index the loader derived at load. This is
|
|
598
|
+
// what makes a concept's knowledge reachable without term luck — the leaf
|
|
599
|
+
// said which concept it is about, so renaming the concept's term, or writing
|
|
600
|
+
// the leaf in different words, cannot silently sever them.
|
|
601
|
+
const declaring = new Set(model.leavesByConcept.get(conceptId) ?? []);
|
|
602
|
+
const out = [];
|
|
603
|
+
for (const entry of model.leaves.values()) {
|
|
604
|
+
const { record: leaf } = entry;
|
|
605
|
+
const declared = declaring.has(entry.identity);
|
|
606
|
+
if (declared || strings(leaf.terms).some((t) => names.has(norm(t)))) {
|
|
607
|
+
// `via` records WHICH join fired, because the two are not equally
|
|
608
|
+
// reliable and a reader should not have to guess. The structural edge
|
|
609
|
+
// wins a tie: it is the claim that survives a concept rename.
|
|
610
|
+
out.push({ via: declared ? VIA_DECLARED : VIA_TERMS, ...publishLeaf(model, entry, today) });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Stable by construction: model.leaves is already sorted by leaf id, and a
|
|
614
|
+
// boolean comparator moves only the downranked ones, so ties never reorder.
|
|
615
|
+
// `downranked` now folds in the stale verdict (UCS-1150), so a stale leaf
|
|
616
|
+
// sorts below the fresh ones through the SAME comparator the draft demotion
|
|
617
|
+
// already used — one ordering rule, not two competing ones.
|
|
618
|
+
return out.sort((a, b) => Number(a.downranked) - Number(b.downranked));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Decompose the query against the governed vocabularies (UCS-1152).
|
|
623
|
+
*
|
|
624
|
+
* Three axes, three vocabularies, no guessing — the module header of
|
|
625
|
+
* lib/decomposition.js argues the semantics; this is the join itself. Each axis
|
|
626
|
+
* records the TOKENS it consumed, because residue is defined as what no join
|
|
627
|
+
* consumed and that is only computable if every join says what it took.
|
|
628
|
+
*
|
|
629
|
+
* Concept matching runs the pre-1152 ladder (`matchConcept`), NOT the phrase
|
|
630
|
+
* test, so published concept scores stay exactly what they were. The phrase
|
|
631
|
+
* test is used only to learn which tokens the concept consumed — a concept that
|
|
632
|
+
* matched on `summary-match` consumed nothing nameable, and claiming otherwise
|
|
633
|
+
* would delete residue the store should have reported.
|
|
634
|
+
*
|
|
635
|
+
* @returns {{operations, concepts, jurisdictions, tokens, consumed, nearMiss}}
|
|
636
|
+
*/
|
|
637
|
+
function decompose(model, query, queryWords, tokens) {
|
|
638
|
+
const consumed = new Set();
|
|
639
|
+
const consume = (taken) => { for (const t of taken) consumed.add(t); };
|
|
640
|
+
|
|
641
|
+
/** Join one registry's minted values, recording the spelling that matched. */
|
|
642
|
+
const joinRegistry = (key) => {
|
|
643
|
+
const hits = [];
|
|
644
|
+
for (const value of mintedValues(model, key)) {
|
|
645
|
+
for (const { spelling, words: phrase } of valuePhrases(value)) {
|
|
646
|
+
const taken = phraseHit(phrase, tokens);
|
|
647
|
+
if (!taken) continue;
|
|
648
|
+
consume(taken);
|
|
649
|
+
hits.push({ value, matched: spelling, tokens: [...taken] });
|
|
650
|
+
break; // one value resolves once; the first (identifier) spelling wins
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return hits;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const operations = joinRegistry(OPERATIONS_REGISTRY);
|
|
657
|
+
const jurisdictions = joinRegistry(JURISDICTIONS_REGISTRY);
|
|
658
|
+
|
|
659
|
+
// The NOUN axis. Two joins are asked, and they answer different questions:
|
|
660
|
+
//
|
|
661
|
+
// ladder `matchConcept` — the pre-1152 whole-query ladder that produces
|
|
662
|
+
// the published concept `score`. It is deliberately strict: it
|
|
663
|
+
// tests the query AS A WHOLE against a term, so "add a token" does
|
|
664
|
+
// NOT reach "Token", and the concept result list stays exactly what
|
|
665
|
+
// consumers already rank on.
|
|
666
|
+
// phrase the token-level phrase test — does the concept's name appear IN
|
|
667
|
+
// the ask at all? "add a token" does contain "token", and the leaf
|
|
668
|
+
// declaring K-101 is a correct answer to it.
|
|
669
|
+
//
|
|
670
|
+
// Before this ticket only the ladder existed, so a concept the ask genuinely
|
|
671
|
+
// named went unjoined whenever the ask said anything else as well — which is
|
|
672
|
+
// every real query. Running both, and keeping them separate, is what lets the
|
|
673
|
+
// decomposition find the noun without renegotiating the published ranking:
|
|
674
|
+
// `match` is the ladder's verdict and is null when only the phrase test
|
|
675
|
+
// fired, so a reader can always tell which join reached the concept.
|
|
676
|
+
const concepts = [];
|
|
677
|
+
for (const { id, file, record } of model.concepts.values()) {
|
|
678
|
+
const match = matchConcept(query, queryWords, record);
|
|
679
|
+
// Which tokens this concept's own vocabulary accounts for. Only the term
|
|
680
|
+
// and aliases are consulted — a `summary-match` consumes nothing, because
|
|
681
|
+
// a summary is prose about the concept, not a name for it, and treating it
|
|
682
|
+
// as one would silently absorb tokens the store cannot actually resolve.
|
|
683
|
+
const taken = [];
|
|
684
|
+
for (const name of [record.term, ...strings(record.aliases)]) {
|
|
685
|
+
if (typeof name !== 'string') continue;
|
|
686
|
+
const hit = phraseHit(phraseWords(name), tokens);
|
|
687
|
+
if (hit) taken.push(...hit);
|
|
688
|
+
}
|
|
689
|
+
if (match === null && !taken.length) continue;
|
|
690
|
+
consume(taken);
|
|
691
|
+
concepts.push({ id, file, record, match, tokens: [...new Set(taken)] });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return { operations, concepts, jurisdictions, tokens, consumed, nearMiss: nearMisses(model, tokens, operations, concepts, jurisdictions) };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Vocabulary entries that share tokens with the query but did NOT match
|
|
699
|
+
* (UCS-1152).
|
|
700
|
+
*
|
|
701
|
+
* The answer that was nearly right, reported with the overlap that carried it.
|
|
702
|
+
* A reader whose query returned nothing useful needs to see these more than
|
|
703
|
+
* anyone: the near-miss is where a store's vocabulary and its users' vocabulary
|
|
704
|
+
* are visibly drifting apart, and a search that reports only its hits lets that
|
|
705
|
+
* drift run silently until the store stops being usable.
|
|
706
|
+
*
|
|
707
|
+
* All three axes are swept, not just concepts, because a verb or a place can
|
|
708
|
+
* near-miss exactly as a noun can — an ask that half-names a place should say
|
|
709
|
+
* which jurisdiction it was a token away from rather than reporting a bare zero.
|
|
710
|
+
*
|
|
711
|
+
* Sorted by kind then id, so the section is byte-stable regardless of the order
|
|
712
|
+
* the vocabularies happened to load in.
|
|
713
|
+
*/
|
|
714
|
+
function nearMisses(model, tokens, operations, concepts, jurisdictions) {
|
|
715
|
+
const out = [];
|
|
716
|
+
const matchedValues = (hits) => new Set(hits.map((h) => h.value));
|
|
717
|
+
|
|
718
|
+
const sweepRegistry = (kind, key, hits) => {
|
|
719
|
+
const already = matchedValues(hits);
|
|
720
|
+
for (const value of mintedValues(model, key)) {
|
|
721
|
+
if (already.has(value)) continue;
|
|
722
|
+
// The widest spelling decides the overlap: `add-token` opened into
|
|
723
|
+
// ["add","token"] shares a token with "token" that the closed spelling
|
|
724
|
+
// never would, and reporting the narrower answer would hide the miss.
|
|
725
|
+
let overlap = [];
|
|
726
|
+
for (const { words: phrase } of valuePhrases(value)) {
|
|
727
|
+
const shared = phraseOverlap(phrase, tokens);
|
|
728
|
+
if (shared.length > overlap.length) overlap = shared;
|
|
729
|
+
}
|
|
730
|
+
if (overlap.length) out.push({ kind, id: value, overlap });
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
sweepRegistry('operation', OPERATIONS_REGISTRY, operations);
|
|
735
|
+
sweepRegistry('jurisdiction', JURISDICTIONS_REGISTRY, jurisdictions);
|
|
736
|
+
|
|
737
|
+
const matchedConcepts = new Set(concepts.map((c) => c.id));
|
|
738
|
+
for (const { id, record } of model.concepts.values()) {
|
|
739
|
+
if (matchedConcepts.has(id)) continue;
|
|
740
|
+
let overlap = [];
|
|
741
|
+
for (const name of [record.term, ...strings(record.aliases)]) {
|
|
742
|
+
if (typeof name !== 'string') continue;
|
|
743
|
+
const shared = phraseOverlap(phraseWords(name), tokens);
|
|
744
|
+
if (shared.length > overlap.length) overlap = shared;
|
|
745
|
+
}
|
|
746
|
+
if (overlap.length) out.push({ kind: 'concept', id, overlap });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return out.sort((a, b) => compare(a.kind, b.kind) || compare(a.id, b.id));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The leaves a decomposed query reaches, as FIRST-CLASS SCORED RESULTS
|
|
754
|
+
* (UCS-1152).
|
|
755
|
+
*
|
|
756
|
+
* Before this ticket a leaf could only appear as an attachment to a concept
|
|
757
|
+
* result, which made an entire class of correct answer unreachable: "add a
|
|
758
|
+
* token" resolves a VERB, and the leaf declaring that operation is the answer
|
|
759
|
+
* whether or not any concept matched. Hanging it off a concept meant either
|
|
760
|
+
* guessing a noun to hang it on or losing it.
|
|
761
|
+
*
|
|
762
|
+
* Scored additively over the structured joins — see lib/scoring.js for why this
|
|
763
|
+
* family adds where the concept ladder does not. Every leaf carries its
|
|
764
|
+
* `signals`, so the score is reproducible: `sum(signals[].score) === score`.
|
|
765
|
+
*
|
|
766
|
+
* Concept-declaration joins read the loader's reverse index (`leavesByConcept`),
|
|
767
|
+
* the same structural edge UCS-1151 built, so a leaf reaches its concept without
|
|
768
|
+
* term luck here exactly as it does there.
|
|
769
|
+
*/
|
|
770
|
+
function scoreLeaves(model, decomposition, today) {
|
|
771
|
+
const { operations, concepts, tokens } = decomposition;
|
|
772
|
+
const declaringConcept = new Map(); // leaf identity -> concept ids it declares
|
|
773
|
+
for (const { id } of concepts) {
|
|
774
|
+
for (const identity of model.leavesByConcept.get(id) ?? []) {
|
|
775
|
+
if (!declaringConcept.has(identity)) declaringConcept.set(identity, []);
|
|
776
|
+
declaringConcept.get(identity).push(id);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const scored = [];
|
|
781
|
+
for (const entry of model.leaves.values()) {
|
|
782
|
+
const { record: leaf } = entry;
|
|
783
|
+
// Signals are gathered in DESCENDING weight — operation, concept, term —
|
|
784
|
+
// so the strongest reason a leaf surfaced reads first in the output.
|
|
785
|
+
//
|
|
786
|
+
// WITHIN each weight class they are sorted by `via`, and that sort is a
|
|
787
|
+
// determinism requirement rather than tidiness. Every one of these three
|
|
788
|
+
// sources is an AUTHORED array (the query's matched operations, the leaf's
|
|
789
|
+
// declared concepts, the leaf's `terms`), so emitting them in encounter
|
|
790
|
+
// order would make the published `signals` depend on the order somebody
|
|
791
|
+
// happened to write a list in — two stores with identical content and
|
|
792
|
+
// different authoring order would produce different bytes, which is exactly
|
|
793
|
+
// what D-012 forbids. Sorting on the value makes the output a function of
|
|
794
|
+
// WHAT a leaf declares, never of the sequence it was typed in.
|
|
795
|
+
const declaredOps = leafOperations(leaf);
|
|
796
|
+
const operationSignals = operations
|
|
797
|
+
.filter(({ value }) => declaredOps.includes(value))
|
|
798
|
+
.map(({ value }) => ({ signal: 'operation', via: value }));
|
|
799
|
+
const conceptSignals = [...(declaringConcept.get(entry.identity) ?? [])]
|
|
800
|
+
.map((id) => ({ signal: 'concept', via: id }));
|
|
801
|
+
const termTokens = [];
|
|
802
|
+
const termSignals = [];
|
|
803
|
+
for (const term of strings(leaf.terms)) {
|
|
804
|
+
const hit = phraseHit(phraseWords(term), tokens);
|
|
805
|
+
if (!hit) continue;
|
|
806
|
+
termTokens.push(...hit);
|
|
807
|
+
termSignals.push({ signal: 'term', via: term });
|
|
808
|
+
}
|
|
809
|
+
const byVia = (a, b) => compare(a.via, b.via);
|
|
810
|
+
const signals = [
|
|
811
|
+
...operationSignals.sort(byVia),
|
|
812
|
+
...conceptSignals.sort(byVia),
|
|
813
|
+
...termSignals.sort(byVia),
|
|
814
|
+
];
|
|
815
|
+
if (!signals.length) continue;
|
|
816
|
+
// A leaf's own term text consumes tokens too — it is a join like any other,
|
|
817
|
+
// and a token it accounted for is not unresolved. Recorded on the shared
|
|
818
|
+
// consumed set so residue sees it.
|
|
819
|
+
for (const t of termTokens) decomposition.consumed.add(t);
|
|
820
|
+
const { score, signals: weighted } = leafScore(signals);
|
|
821
|
+
scored.push({ entry, score, signals: weighted });
|
|
822
|
+
}
|
|
823
|
+
// Both declared arrays are SORTED before publication, for the same reason the
|
|
824
|
+
// signals are: they are authored lists, and byte-stable output must be a
|
|
825
|
+
// function of what a leaf declares rather than the order its author typed it
|
|
826
|
+
// (D-012). Sorted copies, never in place — mutating the loaded record would
|
|
827
|
+
// reorder the model every other surface reads.
|
|
828
|
+
return scored.map(({ entry, score, signals }) => ({
|
|
829
|
+
score,
|
|
830
|
+
signals,
|
|
831
|
+
applies: [...leafJurisdictions(entry.record)].sort(compare),
|
|
832
|
+
[OPERATIONS_FIELD]: [...leafOperations(entry.record)].sort(compare),
|
|
833
|
+
...publishLeaf(model, entry, today),
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Split scored leaves into those the query's scope keeps and those it excludes
|
|
839
|
+
* (UCS-1152).
|
|
840
|
+
*
|
|
841
|
+
* A leaf is EXCLUDED when it declares jurisdictions and the query named a
|
|
842
|
+
* jurisdiction that is not among them. It is excluded WITH ITS REASON and
|
|
843
|
+
* published in its own section — never silently absent, which is the acceptance
|
|
844
|
+
* criterion and the whole point. "No knowledge about theming tokens for us-ca"
|
|
845
|
+
* and "the knowledge about theming tokens is eu-eaa-only" demand opposite
|
|
846
|
+
* conduct from a reader, and a filtered-away leaf makes them indistinguishable.
|
|
847
|
+
*
|
|
848
|
+
* A leaf declaring NO jurisdictions is universal and never excluded — see
|
|
849
|
+
* `leafJurisdictions`. A query naming no jurisdiction excludes nothing: with no
|
|
850
|
+
* scope asserted there is nothing to be out of scope of.
|
|
851
|
+
*/
|
|
852
|
+
function applyScope(scored, jurisdictions) {
|
|
853
|
+
if (!jurisdictions.length) return { kept: scored, excluded: [] };
|
|
854
|
+
const asked = jurisdictions.map((j) => j.value);
|
|
855
|
+
const kept = [];
|
|
856
|
+
const excluded = [];
|
|
857
|
+
for (const leaf of scored) {
|
|
858
|
+
if (!leaf.applies.length || leaf.applies.some((j) => asked.includes(j))) {
|
|
859
|
+
kept.push(leaf);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
excluded.push({
|
|
863
|
+
id: leaf.id,
|
|
864
|
+
notation: leaf.notation,
|
|
865
|
+
heading: leaf.heading,
|
|
866
|
+
file: leaf.file,
|
|
867
|
+
applies: leaf.applies,
|
|
868
|
+
asked,
|
|
869
|
+
reason: `declares applies.jurisdictions [${leaf.applies.join(', ')}] — the query is scoped to [${asked.join(', ')}], which this leaf does not cover (UCS-1152)`,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
return { kept, excluded };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Rank scored leaves: time-verdict and stage demotions first, then score
|
|
877
|
+
* (UCS-1152).
|
|
878
|
+
*
|
|
879
|
+
* `downranked` is already the UNION of the stage and time demotions
|
|
880
|
+
* (publishLeaf, UCS-1150), so sorting on it applies BOTH demotions through one
|
|
881
|
+
* comparator rather than two competing ones — a stale leaf and a draft leaf
|
|
882
|
+
* both sort below the promoted, fresh ones, and a leaf that is both reports
|
|
883
|
+
* both reasons without either absorbing the other.
|
|
884
|
+
*
|
|
885
|
+
* Demotion before score, deliberately: a high-scoring stale leaf is still one
|
|
886
|
+
* whose claims nobody has re-verified, and putting it above a fresh lower-scoring
|
|
887
|
+
* answer would rank confidence above currency. It is a demotion and never a
|
|
888
|
+
* filter — the leaf is still published, still scored, still explains itself.
|
|
889
|
+
*/
|
|
890
|
+
const rankLeaves = (leaves) => [...leaves].sort((a, b) =>
|
|
891
|
+
Number(a.downranked) - Number(b.downranked)
|
|
892
|
+
|| b.score - a.score
|
|
893
|
+
|| compare(a.id ?? a.notation, b.id ?? b.notation));
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* THE JOIN CORE — one text in, the store's joins out (UCS-1156).
|
|
897
|
+
*
|
|
898
|
+
* Extracted from `resolveQuery` so that a query and a document section reach
|
|
899
|
+
* the store through the SAME function rather than through two implementations
|
|
900
|
+
* that are supposed to agree. This is what makes the ticket's size-invariance
|
|
901
|
+
* claim a property of the code instead of a promise: `resolveQuery` calls it
|
|
902
|
+
* with the whole query, and `--doc` calls it once per section, so a query and
|
|
903
|
+
* its equivalent one-block document cannot produce different joins.
|
|
904
|
+
*
|
|
905
|
+
* Everything here was already the query path's behavior — the decomposition,
|
|
906
|
+
* the additive leaf scoring, the scope exclusion, the ranking. Nothing about
|
|
907
|
+
* matching changed; only its call site moved so a second caller could exist.
|
|
908
|
+
*
|
|
909
|
+
* @param {object} model the loaded store model
|
|
910
|
+
* @param {string} raw the text to join — a query, or one section's prose
|
|
911
|
+
* @param {string|null} today the injected date verdicts are measured against
|
|
912
|
+
* @returns {{query, tokens, decomposition, leaves, exclusions, residue, ...}}
|
|
913
|
+
*/
|
|
914
|
+
function joinText(model, raw, today) {
|
|
915
|
+
const query = norm(raw);
|
|
916
|
+
const queryWords = words(query);
|
|
917
|
+
const tokens = tokenize(raw);
|
|
918
|
+
const decomposition = decompose(model, query, queryWords, tokens);
|
|
919
|
+
const { kept, excluded } = applyScope(scoreLeaves(model, decomposition, today), decomposition.jurisdictions);
|
|
920
|
+
// Residue is computed LAST, after every join has had its chance to consume:
|
|
921
|
+
// it is defined as what nothing resolved, so anything computed earlier would
|
|
922
|
+
// be measuring a partially-run decomposition. De-duplicated, in query order —
|
|
923
|
+
// a token the user typed twice is one unresolved thing.
|
|
924
|
+
const residue = [...new Set(tokens.filter((t) => !decomposition.consumed.has(t) && !STOPWORDS.has(t)))];
|
|
925
|
+
return {
|
|
926
|
+
query,
|
|
927
|
+
queryWords,
|
|
928
|
+
tokens,
|
|
929
|
+
decomposition,
|
|
930
|
+
operations: decomposition.operations,
|
|
931
|
+
concepts: decomposition.concepts,
|
|
932
|
+
jurisdictions: decomposition.jurisdictions,
|
|
933
|
+
leaves: rankLeaves(kept),
|
|
934
|
+
exclusions: excluded.sort((a, b) => compare(a.id ?? a.notation, b.id ?? b.notation)),
|
|
935
|
+
residue,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function resolveQuery(model, terms, today) {
|
|
940
|
+
const raw = terms.join(' ');
|
|
941
|
+
if (!words(norm(raw)).length) throw new UsageError('query terms must contain a word');
|
|
942
|
+
const joined = joinText(model, raw, today);
|
|
943
|
+
const { query, decomposition, tokens, residue } = joined;
|
|
944
|
+
|
|
945
|
+
// Concept results keep their pre-1152 shape and their pre-1152 scores — the
|
|
946
|
+
// structured joins are ADDITIVE surface, never a renegotiation of a ranking
|
|
947
|
+
// consumers already read.
|
|
948
|
+
const results = [];
|
|
949
|
+
for (const { id, file, record, match } of decomposition.concepts) {
|
|
950
|
+
// Only LADDER matches become concept results. A concept the phrase test
|
|
951
|
+
// reached but the ladder did not has no rung and therefore no score, and
|
|
952
|
+
// inventing one would put concepts in this list that the pre-1152 engine
|
|
953
|
+
// never returned — breaking the ranking this ticket promised to leave
|
|
954
|
+
// alone. It is still published in `decomposition.concepts`, where it is
|
|
955
|
+
// what it actually is: a noun the ask named, joined structurally to leaves.
|
|
956
|
+
if (match === null) continue;
|
|
957
|
+
results.push({
|
|
958
|
+
id,
|
|
959
|
+
term: record.term ?? null,
|
|
960
|
+
summary: record.summary ?? null,
|
|
961
|
+
status: record.status ?? null,
|
|
962
|
+
score: conceptScore(match, record.status),
|
|
963
|
+
match,
|
|
964
|
+
file,
|
|
965
|
+
'source-of-truth': strings(record['source-of-truth']),
|
|
966
|
+
'confusable-with': confusables(model, record),
|
|
967
|
+
knowledge: knowledgeEntryPoints(model, record, id, today),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
results.sort((a, b) => b.score - a.score || compare(a.id, b.id));
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
query,
|
|
974
|
+
// The decomposition itself, published (UCS-1152) — which vocabulary each
|
|
975
|
+
// axis of the ask landed in, and what did not land anywhere. This is the
|
|
976
|
+
// section that makes the resolution auditable: a reader can see that "add a
|
|
977
|
+
// token" resolved a VERB through the operations registry rather than
|
|
978
|
+
// guessing at a noun.
|
|
979
|
+
decomposition: {
|
|
980
|
+
tokens,
|
|
981
|
+
operations: decomposition.operations,
|
|
982
|
+
concepts: decomposition.concepts.map((c) => ({ id: c.id, term: c.record.term ?? null, match: c.match, tokens: c.tokens })),
|
|
983
|
+
jurisdictions: decomposition.jurisdictions,
|
|
984
|
+
// Every one of these is a STABLE key that may be an empty array, never an
|
|
985
|
+
// omitted one: a consumer must not need a presence check to tell "nothing
|
|
986
|
+
// near-missed" from "this engine predates near-miss reporting".
|
|
987
|
+
'near-miss': decomposition.nearMiss,
|
|
988
|
+
residue,
|
|
989
|
+
// The resolved context a residue finding is logged ALONGSIDE (UCS-1152).
|
|
990
|
+
// The acceptance criterion is that residue is emitted "with the resolved
|
|
991
|
+
// context attached" — a bare unresolved token is a finding nobody can act
|
|
992
|
+
// on, while "`stencil` was unresolved in an ask that DID resolve
|
|
993
|
+
// add-token and eu-eaa" localizes the gap precisely enough that the
|
|
994
|
+
// minting decision writes itself.
|
|
995
|
+
'resolved-context': [
|
|
996
|
+
...decomposition.operations.map((o) => o.value),
|
|
997
|
+
...decomposition.concepts.map((c) => c.id),
|
|
998
|
+
...decomposition.jurisdictions.map((j) => j.value),
|
|
999
|
+
],
|
|
1000
|
+
},
|
|
1001
|
+
// The scoring table the ranking above was computed with, so a consumer
|
|
1002
|
+
// reproducing it never hard-codes weights or vendors a copy that goes stale.
|
|
1003
|
+
scoring: scoringTable(),
|
|
1004
|
+
results,
|
|
1005
|
+
// Leaves as FIRST-CLASS scored results, not attachments (UCS-1152). The
|
|
1006
|
+
// concept-attached `knowledge` lists above are untouched and still
|
|
1007
|
+
// published: this extends the payload rather than breaking it, so every
|
|
1008
|
+
// existing consumer keeps working while a new one can read leaves directly.
|
|
1009
|
+
leaves: joined.leaves,
|
|
1010
|
+
// Excluded, never silently absent.
|
|
1011
|
+
exclusions: joined.exclusions,
|
|
1012
|
+
// Zero resolution is a NORMAL outcome and must be machine-distinguishable
|
|
1013
|
+
// from a failure (PRD §7). The conduct text is IN THE PAYLOAD rather than
|
|
1014
|
+
// only on the human surface, so an agent reading JSON is told what to do
|
|
1015
|
+
// next instead of inferring it from an empty array.
|
|
1016
|
+
...(results.length || joined.leaves.length ? {} : { conduct: ZERO_RESOLUTION_CONDUCT }),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* What to do when nothing resolved (PRD §7) — the fallback conduct, in the
|
|
1022
|
+
* payload.
|
|
1023
|
+
*
|
|
1024
|
+
* Zero resolution exits 0 and is a normal outcome, common in month one. What
|
|
1025
|
+
* makes it machine-distinguishable from a failure is not the exit code alone
|
|
1026
|
+
* but this: an explicit empty result WITH the conduct that follows from it. An
|
|
1027
|
+
* agent that receives empty arrays and no instruction has to guess whether the
|
|
1028
|
+
* lookup failed or the store is simply silent on the topic, and those demand
|
|
1029
|
+
* different next steps.
|
|
1030
|
+
*/
|
|
1031
|
+
const ZERO_RESOLUTION_CONDUCT = 'zero resolution is a normal outcome (PRD §7): fall back to search within survey-scope.yaml; append a retrieval-miss finding only if this topic plausibly should be mapped (an unmapped area the scope excludes is expected, not a miss)';
|
|
1032
|
+
|
|
1033
|
+
// ---------------------------------------------------------------- paths mode
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Normalize a path to the repo-root-relative posix form pointers use (§9.1):
|
|
1037
|
+
* backslashes become '/', `..`/`.`/`//` resolve away (path.posix semantics),
|
|
1038
|
+
* trailing slashes drop, and absolute paths relativize against `root`.
|
|
1039
|
+
* Wrong normalization is wrong ATTRIBUTION — `a/b/../c.ts` must hit the file
|
|
1040
|
+
* pointer `a/c.ts`, not the folder pointer `a/b`.
|
|
1041
|
+
*/
|
|
1042
|
+
function normPath(root, p) {
|
|
1043
|
+
let path = posix.normalize(p.trim().replace(/\\/g, '/'));
|
|
1044
|
+
if (posix.isAbsolute(path)) path = posix.relative(root.replace(/\\/g, '/'), path);
|
|
1045
|
+
path = path.replace(/\/+$/, '');
|
|
1046
|
+
return path === '.' ? '' : path;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* §3.1: a folder pointer nests over its subtree. Ask the filesystem, never
|
|
1051
|
+
* the name — the map is never the fact. `src/api.v2` is a directory whose
|
|
1052
|
+
* `extname` is ".v2", so a name-based guess drops every path beneath it: a
|
|
1053
|
+
* silent missed attribution in exactly the ACT pre-commit check a developer
|
|
1054
|
+
* trusts to say which concepts their change touches.
|
|
1055
|
+
*
|
|
1056
|
+
* The filesystem decides whenever it can. It cannot when the pointer does not
|
|
1057
|
+
* exist — `--paths` is fed from a diff, and a diff names deleted paths — so a
|
|
1058
|
+
* missing pointer falls back to the name: no extension, treat as a folder.
|
|
1059
|
+
* That keeps deletion attribution working (deleting a source-of-truth
|
|
1060
|
+
* directory still flags its concept) while never nesting under something that
|
|
1061
|
+
* looks like a file. Erring this way keeps every residual gap on the
|
|
1062
|
+
* missed-attribution side: a deleted DOTTED directory stops nesting, which
|
|
1063
|
+
* under-reports. Over-reporting would put a concept the change never touched
|
|
1064
|
+
* in front of a human, and false attribution is the costlier error here.
|
|
1065
|
+
*
|
|
1066
|
+
* An unreadable pointer (EACCES on its parent) is epistemically the same as an
|
|
1067
|
+
* absent one — the filesystem declines to say — so it takes the same name
|
|
1068
|
+
* fallback rather than crashing a lookup that can still answer for every other
|
|
1069
|
+
* pointer. `throwIfNoEntry: false` silences ENOENT only.
|
|
1070
|
+
*
|
|
1071
|
+
* Stat once per pointer: `--paths` crosses every pointer with every path.
|
|
1072
|
+
*/
|
|
1073
|
+
function folderPointerTest(repoRoot) {
|
|
1074
|
+
const cache = new Map();
|
|
1075
|
+
const statOrNull = (path) => {
|
|
1076
|
+
try {
|
|
1077
|
+
return statSync(path, { throwIfNoEntry: false }) ?? null;
|
|
1078
|
+
} catch {
|
|
1079
|
+
return null; // unreadable: the filesystem cannot decide, so the name does
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
return (pointer) => {
|
|
1083
|
+
if (!cache.has(pointer)) {
|
|
1084
|
+
const stat = statOrNull(join(repoRoot, pointer));
|
|
1085
|
+
cache.set(pointer, stat ? stat.isDirectory() : posix.extname(pointer) === '');
|
|
1086
|
+
}
|
|
1087
|
+
return cache.get(pointer);
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function resolvePaths(model, rawPaths, repoRoot, today) {
|
|
1092
|
+
// Pointers are repo-root-relative (§9.1), so both sides normalize against
|
|
1093
|
+
// the repo root — the KK-08 two-root convention (model.root may be the
|
|
1094
|
+
// nested unknown-knowledge/ store dir in a seeded repo).
|
|
1095
|
+
// An entry that normalizes away (empty, ".", "src/..") names the repo root,
|
|
1096
|
+
// not a path inside it. Dropping it silently would shrink the lookup the
|
|
1097
|
+
// caller asked for — a lookup that never ran, wearing a clean exit.
|
|
1098
|
+
const rootish = rawPaths.filter((p) => normPath(repoRoot, p) === '');
|
|
1099
|
+
if (rootish.length) {
|
|
1100
|
+
throw new UsageError(`--paths entries ${rootish.map((p) => JSON.stringify(p)).join(', ')} name the repo root, not a path inside it — name the files or directories the change touched`);
|
|
1101
|
+
}
|
|
1102
|
+
const paths = [...new Set(rawPaths.map((p) => normPath(repoRoot, p)))].sort(compare);
|
|
1103
|
+
if (!paths.length) {
|
|
1104
|
+
throw new UsageError('--paths must name at least one path — a lookup that never ran is a failure, never a silent empty result');
|
|
1105
|
+
}
|
|
1106
|
+
const isFolderPointer = folderPointerTest(repoRoot);
|
|
1107
|
+
// Leaf `paths` are the second pointer family (UCS-1151), indexed once for the
|
|
1108
|
+
// whole lookup rather than re-walked per path: --paths already crosses every
|
|
1109
|
+
// pointer with every path, and a diff is routinely hundreds of paths.
|
|
1110
|
+
const leafPointers = leafPathIndex(model);
|
|
1111
|
+
/**
|
|
1112
|
+
* Does this declared pointer govern this path — exactly, or by nesting?
|
|
1113
|
+
*
|
|
1114
|
+
* A pointer that normalizes to EMPTY names the repo root, and it is skipped
|
|
1115
|
+
* rather than treated as a folder that nests over everything. The structural
|
|
1116
|
+
* validator refuses such a pointer outright (`missing-path`: a pointer at
|
|
1117
|
+
* everything attributes nothing), so this branch only runs against a store
|
|
1118
|
+
* that has not been validated — and the resolver deliberately never gates on
|
|
1119
|
+
* store health (§4), so it is reachable. Matching every path here would put
|
|
1120
|
+
* one leaf in front of every developer regardless of what they touched,
|
|
1121
|
+
* which is the false-attribution direction §3.1 already calls the costlier
|
|
1122
|
+
* error. Skipping keeps the residual gap on the under-reporting side, and
|
|
1123
|
+
* the validator is where the author is told to fix it.
|
|
1124
|
+
*/
|
|
1125
|
+
const governs = (pointer, path) => {
|
|
1126
|
+
const p = normPath(repoRoot, pointer);
|
|
1127
|
+
if (p === '') return false;
|
|
1128
|
+
return path === p || (isFolderPointer(p) && path.startsWith(`${p}/`));
|
|
1129
|
+
};
|
|
1130
|
+
return paths.map((path) => {
|
|
1131
|
+
const seen = new Set();
|
|
1132
|
+
const concepts = [];
|
|
1133
|
+
for (const [pointer, ids] of model.pointers) {
|
|
1134
|
+
if (!governs(pointer, path)) continue;
|
|
1135
|
+
for (const id of ids) {
|
|
1136
|
+
if (seen.has(id)) continue; // keep the lexicographically first pointer
|
|
1137
|
+
seen.add(id);
|
|
1138
|
+
const record = model.concepts.get(id)?.record;
|
|
1139
|
+
concepts.push({
|
|
1140
|
+
id,
|
|
1141
|
+
term: record?.term ?? null,
|
|
1142
|
+
status: record?.status ?? null,
|
|
1143
|
+
pointer,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
concepts.sort((a, b) => compare(a.id, b.id));
|
|
1148
|
+
return { path, concepts, knowledge: governingLeaves(model, path, concepts, leafPointers, governs, today) };
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Leaf `paths` declarations, indexed pointer → leaf identities (UCS-1151).
|
|
1154
|
+
*
|
|
1155
|
+
* The leaf-side mirror of the loader's concept pointer index, built here rather
|
|
1156
|
+
* than in the loader because it is a resolver concern: nothing else joins over
|
|
1157
|
+
* it. Same shape and same guarantees — de-duplicated, sorted, so the reverse
|
|
1158
|
+
* lookup is stable regardless of authoring order.
|
|
1159
|
+
*
|
|
1160
|
+
* @param {object} model the loaded store model
|
|
1161
|
+
* @returns {Map<string, string[]>} declared path -> leaf identities
|
|
1162
|
+
*/
|
|
1163
|
+
function leafPathIndex(model) {
|
|
1164
|
+
const index = new Map();
|
|
1165
|
+
for (const entry of model.leaves.values()) {
|
|
1166
|
+
for (const path of strings(entry.record?.[LEAF_PATHS_FIELD])) {
|
|
1167
|
+
if (!index.has(path)) index.set(path, []);
|
|
1168
|
+
const identities = index.get(path);
|
|
1169
|
+
if (!identities.includes(entry.identity)) identities.push(entry.identity);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
for (const identities of index.values()) identities.sort(compare);
|
|
1173
|
+
return index;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The leaves that GOVERN one path — the reverse lookup's whole point
|
|
1178
|
+
* (UCS-1151).
|
|
1179
|
+
*
|
|
1180
|
+
* Two joins, unioned, because a leaf can be attached to a file two different
|
|
1181
|
+
* ways and a developer about to edit that file needs both:
|
|
1182
|
+
*
|
|
1183
|
+
* direct the leaf declares the path in its own `paths` — it says outright
|
|
1184
|
+
* that it governs this part of the tree.
|
|
1185
|
+
* concept the path is under a CONCEPT's source-of-truth pointer, and the
|
|
1186
|
+
* leaf declared that concept. The knowledge is one hop away through
|
|
1187
|
+
* the ontology, which is exactly the join the concept pointers were
|
|
1188
|
+
* always for; before this ticket the reverse lookup stopped at the
|
|
1189
|
+
* concept and left the reader to make that hop by hand.
|
|
1190
|
+
*
|
|
1191
|
+
* `via` says which, and `direct` wins when both fire: it is the leaf's own
|
|
1192
|
+
* claim about this path rather than an inference through a third record.
|
|
1193
|
+
*
|
|
1194
|
+
* Concept attribution reuses the `concepts` already computed for this path, so
|
|
1195
|
+
* the two halves of one result can never disagree about which concepts the path
|
|
1196
|
+
* touched — a second pointer walk here would be a second answer to a question
|
|
1197
|
+
* already settled four lines up.
|
|
1198
|
+
*
|
|
1199
|
+
* Stable-sorted the same way knowledge entry points are: promoted before
|
|
1200
|
+
* downranked, then by identity. An agent reading top-down reaches certified
|
|
1201
|
+
* knowledge first, and byte-stability does not depend on store iteration order.
|
|
1202
|
+
*/
|
|
1203
|
+
function governingLeaves(model, path, concepts, leafPointers, governs, today) {
|
|
1204
|
+
const via = new Map(); // leaf identity -> how it was reached
|
|
1205
|
+
for (const [pointer, identities] of leafPointers) {
|
|
1206
|
+
if (!governs(pointer, path)) continue;
|
|
1207
|
+
for (const identity of identities) via.set(identity, 'direct');
|
|
1208
|
+
}
|
|
1209
|
+
for (const { id } of concepts) {
|
|
1210
|
+
for (const identity of model.leavesByConcept.get(id) ?? []) {
|
|
1211
|
+
if (!via.has(identity)) via.set(identity, 'concept');
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const out = [];
|
|
1215
|
+
for (const [identity, how] of via) {
|
|
1216
|
+
const entry = model.leaves.get(identity);
|
|
1217
|
+
if (entry) out.push({ via: how, ...publishLeaf(model, entry, today) });
|
|
1218
|
+
}
|
|
1219
|
+
return out.sort((a, b) =>
|
|
1220
|
+
Number(a.downranked) - Number(b.downranked)
|
|
1221
|
+
|| compare(a.id ?? a.notation, b.id ?? b.notation));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ---------------------------------------------------------------- doc mode
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Resolve a whole document into a COVERAGE MAP (UCS-1156).
|
|
1228
|
+
*
|
|
1229
|
+
* The document is adapted into the IR by the versioned adapter seam
|
|
1230
|
+
* (UCS-1153), then the coverage module sections it and streams the store's
|
|
1231
|
+
* vocabularies over each section — using THIS command's `joinText`, so a
|
|
1232
|
+
* section's join is the query pipeline run over that section's text.
|
|
1233
|
+
*
|
|
1234
|
+
* The suppression entries are loaded here, from the same client-zone
|
|
1235
|
+
* `suppressions.yaml` the reverse audit reads, and they FAIL OPEN exactly as
|
|
1236
|
+
* they do there: a malformed file suppresses nothing and its warnings travel
|
|
1237
|
+
* into the payload, because a suppression that could silence a candidate by
|
|
1238
|
+
* being broken is one nobody can trust.
|
|
1239
|
+
*
|
|
1240
|
+
* @param {object} model the loaded store model
|
|
1241
|
+
* @param {string} document the submitted document's path
|
|
1242
|
+
* @param {string} kitRoot where suppressions.yaml lives
|
|
1243
|
+
* @param {string|null} today the injected date
|
|
1244
|
+
*/
|
|
1245
|
+
function resolveDoc(model, document, kitRoot, today) {
|
|
1246
|
+
// DISPATCH BEFORE READ, the same order ingest.js uses and for the same
|
|
1247
|
+
// reason: whether the bytes exist is irrelevant when no adapter claims the
|
|
1248
|
+
// format, and reading first would answer a `.docx` submission with "cannot
|
|
1249
|
+
// read", burying the conduct the submitter actually needs.
|
|
1250
|
+
adapterFor(document);
|
|
1251
|
+
let bytes;
|
|
1252
|
+
try {
|
|
1253
|
+
bytes = readFileSync(resolvePath(document));
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
rethrowIfBug(error);
|
|
1256
|
+
throw new UsageError(`cannot read ${document}: ${error.message}`);
|
|
1257
|
+
}
|
|
1258
|
+
const ir = adapt(document, bytes);
|
|
1259
|
+
const { entries, warnings } = loadSuppressions(kitRoot);
|
|
1260
|
+
const map = buildCoverageMap({
|
|
1261
|
+
document,
|
|
1262
|
+
ir,
|
|
1263
|
+
model,
|
|
1264
|
+
joinSection: (text) => joinText(model, text, today),
|
|
1265
|
+
suppressionEntries: entries,
|
|
1266
|
+
});
|
|
1267
|
+
// Warnings surface in the payload, never only on stderr: a malformed
|
|
1268
|
+
// suppressions file must not vanish silently from a machine-read output.
|
|
1269
|
+
// A STABLE KEY that may be an empty array, like every other field in this
|
|
1270
|
+
// payload — a consumer must never need a presence check to tell "the
|
|
1271
|
+
// suppressions file was clean" from "this engine predates the warning".
|
|
1272
|
+
return { ...map, 'suppression-warnings': warnings };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* The coverage map, for a human.
|
|
1277
|
+
*
|
|
1278
|
+
* Deliberately COMPACT. The map's whole promise is that it is cheaper to read
|
|
1279
|
+
* than the document, and a human surface that reprinted every join per section
|
|
1280
|
+
* would cost as much as the document it summarizes. So: one line per section
|
|
1281
|
+
* with its locator, one line per gathered leaf with its verdict, and the ranked
|
|
1282
|
+
* candidates. The `--json` payload carries everything.
|
|
1283
|
+
*/
|
|
1284
|
+
function renderDoc(payload) {
|
|
1285
|
+
const lines = [];
|
|
1286
|
+
const map = payload.map;
|
|
1287
|
+
lines.push(
|
|
1288
|
+
`resolve --doc ${map.document} -> ${map.sections.length} section(s) with signal, `
|
|
1289
|
+
+ `${map.gather.length} governed leaf/leaves, ${map['candidates-ranked'].length} candidate(s)`,
|
|
1290
|
+
'',
|
|
1291
|
+
`adapter: ${map.adapter} hash: ${map.hash} (byte-identical resubmission dedupes on this hash)`,
|
|
1292
|
+
`ir: ${map.ir.blocks} block(s) -> ${map.ir.sections} section(s); `
|
|
1293
|
+
+ `repetition threshold ${map.ir['repetition-threshold']} (pinned step function of document size)`,
|
|
1294
|
+
'',
|
|
1295
|
+
);
|
|
1296
|
+
renderTimeCheck(payload, lines);
|
|
1297
|
+
renderHealth(payload['store-health'], lines);
|
|
1298
|
+
|
|
1299
|
+
if (map.sections.length) {
|
|
1300
|
+
lines.push('coverage by section:');
|
|
1301
|
+
for (const s of map.sections) {
|
|
1302
|
+
const at = s.locator.line === undefined
|
|
1303
|
+
? `p${s.locator.page}-${s.locator.endPage}`
|
|
1304
|
+
: `L${s.locator.line}-${s.locator.endLine}`;
|
|
1305
|
+
lines.push(` ${at} ${s.section}`);
|
|
1306
|
+
const joins = [
|
|
1307
|
+
s.joins.operations.length ? `operations: ${s.joins.operations.join(', ')}` : null,
|
|
1308
|
+
s.joins.concepts.length ? `concepts: ${s.joins.concepts.join(', ')}` : null,
|
|
1309
|
+
s.joins.jurisdictions.length ? `jurisdictions: ${s.joins.jurisdictions.join(', ')}` : null,
|
|
1310
|
+
s.joins.leaves.length ? `leaves: ${s.joins.leaves.join(', ')}` : null,
|
|
1311
|
+
].filter(Boolean);
|
|
1312
|
+
for (const join of joins) lines.push(` ${join}`);
|
|
1313
|
+
if (s.candidates.length) lines.push(` candidates: ${s.candidates.join(', ')}`);
|
|
1314
|
+
// Folded sections are named, not merely counted away: an agent may need
|
|
1315
|
+
// to open any one of them, so each keeps its own address and locator.
|
|
1316
|
+
if (s['repeats-count']) {
|
|
1317
|
+
const shown = s.repeats.map((r) => r.section).join(', ');
|
|
1318
|
+
const more = s['repeats-count'] - s.repeats.length;
|
|
1319
|
+
lines.push(` same coverage in ${s['repeats-count']} other section(s): ${shown}${more ? ` (+${more} more)` : ''}`);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
lines.push('');
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (map.gather.length) {
|
|
1326
|
+
lines.push('gather rollup:');
|
|
1327
|
+
for (const g of map.gather) {
|
|
1328
|
+
lines.push(` ${g.id ? `${g.id} ` : ''}${g.notation} ${g.heading} score ${g.score} [${g.verdict}] (${g.file})`);
|
|
1329
|
+
const reached = `${g.sections.join(', ')}${g['sections-more'] ? ` (+${g['sections-more']} more)` : ''}`;
|
|
1330
|
+
lines.push(` signals: ${g.signals.join(', ')} sections: ${reached}`);
|
|
1331
|
+
// The flag is printed on its own line because it is a claim about
|
|
1332
|
+
// applicability the reader has to act on, not a detail of the hit.
|
|
1333
|
+
if (g['scope-mismatch']) lines.push(` scope-mismatch: ${g['scope-mismatch']}`);
|
|
1334
|
+
for (const d of g.demotions) lines.push(` demoted (${d.reason}): ${d.detail}`);
|
|
1335
|
+
}
|
|
1336
|
+
lines.push('');
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (map['candidates-ranked'].length) {
|
|
1340
|
+
lines.push('candidates (ranked — the document\'s residue, section-addressed):');
|
|
1341
|
+
for (const c of map['candidates-ranked']) {
|
|
1342
|
+
const where = `${c.sections.join(', ')}${c['sections-more'] ? ` (+${c['sections-more']} more)` : ''}`;
|
|
1343
|
+
lines.push(` ${c.term} x${c.count} [${c.signatures.join(', ')}] in ${where}`);
|
|
1344
|
+
}
|
|
1345
|
+
lines.push('');
|
|
1346
|
+
}
|
|
1347
|
+
// Suppressed candidates are NAMED, not counted away: "reported as suppressed
|
|
1348
|
+
// rather than silently absent" is the acceptance criterion, and a bare count
|
|
1349
|
+
// would leave a reader unable to tell which term a steward had settled.
|
|
1350
|
+
if (map.suppressed.length) {
|
|
1351
|
+
lines.push('suppressed candidates (a steward refused these; reported, never silently absent):');
|
|
1352
|
+
for (const c of map.suppressed) lines.push(` ${c.term} x${c.count} in ${c.sections.join(', ')}`);
|
|
1353
|
+
lines.push('');
|
|
1354
|
+
}
|
|
1355
|
+
for (const w of payload.map['suppression-warnings'] ?? []) lines.push(w);
|
|
1356
|
+
lines.push('open a section just-in-time with its locator — the context cost is this map plus what you open, never the document');
|
|
1357
|
+
return lines;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ------------------------------------------------------------- CLI plumbing
|
|
1361
|
+
|
|
1362
|
+
function parseArgs(argv) {
|
|
1363
|
+
const { options, positionals } = parseFlags(argv, {
|
|
1364
|
+
boolean: ['json'],
|
|
1365
|
+
value: ['root', 'today', 'doc'],
|
|
1366
|
+
repeatable: ['paths'],
|
|
1367
|
+
// Query terms arrive as bare arguments; --paths is the reverse lookup;
|
|
1368
|
+
// --doc is the third input shape (UCS-1156).
|
|
1369
|
+
positionals: true,
|
|
1370
|
+
});
|
|
1371
|
+
const opts = {
|
|
1372
|
+
json: !!options.json,
|
|
1373
|
+
root: options.root ?? process.cwd(),
|
|
1374
|
+
paths: options.paths ? options.paths.flatMap((v) => v.split(',')) : null,
|
|
1375
|
+
doc: options.doc ?? null,
|
|
1376
|
+
terms: positionals,
|
|
1377
|
+
// The injected date the time verdicts are measured against (UCS-1150).
|
|
1378
|
+
// Null is a legitimate answer, not a default to be filled in: without it
|
|
1379
|
+
// the verdicts report themselves skipped, and the output says so.
|
|
1380
|
+
today: options.today ?? null,
|
|
1381
|
+
};
|
|
1382
|
+
if (opts.today !== null && !isCalendarDate(opts.today)) {
|
|
1383
|
+
// Same strictness as audit and preflight (UCS-957): `Date.parse` rolls
|
|
1384
|
+
// 2026-02-30 forward to March 2nd, so a leaf's age would be measured from
|
|
1385
|
+
// a day the caller never named — and here that age decides a demotion.
|
|
1386
|
+
throw new UsageError(`--today must be a real calendar date (YYYY-MM-DD), got ${JSON.stringify(opts.today)}`);
|
|
1387
|
+
}
|
|
1388
|
+
// The three input shapes are alternatives, not a combination. Two of them at
|
|
1389
|
+
// once has no honest answer — a coverage map of a document is not a lookup of
|
|
1390
|
+
// a query — so it is a usage error rather than a silently-preferred mode.
|
|
1391
|
+
const shapes = [
|
|
1392
|
+
opts.terms.length ? 'query terms' : null,
|
|
1393
|
+
opts.paths ? '--paths' : null,
|
|
1394
|
+
opts.doc ? '--doc' : null,
|
|
1395
|
+
].filter(Boolean);
|
|
1396
|
+
if (shapes.length > 1) {
|
|
1397
|
+
throw new UsageError(`give exactly one input shape, got ${shapes.join(' and ')} — a query, --paths, or --doc`);
|
|
1398
|
+
}
|
|
1399
|
+
if (!shapes.length) {
|
|
1400
|
+
throw new UsageError('nothing to resolve — give query terms, --paths, or --doc');
|
|
1401
|
+
}
|
|
1402
|
+
return opts;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function renderHealth(health, lines) {
|
|
1406
|
+
if (health.ok && !health.warnings) return; // warnings surface even when ok
|
|
1407
|
+
lines.push(
|
|
1408
|
+
`store health: ${health.errors} error(s), ${health.warnings} warning(s) — resolution ran on what loaded; run preflight for verdicts`,
|
|
1409
|
+
'',
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* A leaf's one-hop neighborhood, for the human surface (UCS-1151).
|
|
1415
|
+
*
|
|
1416
|
+
* Only NON-EMPTY kinds are printed, which is the opposite of the JSON contract
|
|
1417
|
+
* on purpose: JSON keeps every kind as a stable key so a consumer never needs a
|
|
1418
|
+
* presence check, while a human reading four "(none)" lines per leaf learns
|
|
1419
|
+
* nothing and loses the hits in the noise. The kind is always named — the whole
|
|
1420
|
+
* value of a typed edge is that `contradicts` and `see-also` do not mean the
|
|
1421
|
+
* same thing to the agent deciding what to read next.
|
|
1422
|
+
*
|
|
1423
|
+
* @param {object} leaf a published leaf
|
|
1424
|
+
* @param {string} indent leading whitespace for the block
|
|
1425
|
+
*/
|
|
1426
|
+
function renderRelates(leaf, indent = ' ') {
|
|
1427
|
+
const lines = [];
|
|
1428
|
+
for (const kind of RELATES_KINDS) {
|
|
1429
|
+
const neighbors = leaf[RELATES_FIELD]?.[kind] ?? [];
|
|
1430
|
+
if (!neighbors.length) continue;
|
|
1431
|
+
lines.push(`${indent}${kind}: ${neighbors.map((n) => `${n.id ?? n.notation} "${n.heading ?? '?'}"`).join(', ')}`);
|
|
1432
|
+
}
|
|
1433
|
+
return lines;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* The demotion marker for one published leaf, for the human surface
|
|
1438
|
+
* (UCS-1150).
|
|
1439
|
+
*
|
|
1440
|
+
* Every demotion that fired is named, on the line the ordering already put the
|
|
1441
|
+
* leaf on — a reader skimming top-down sees WHY a leaf sits at the bottom
|
|
1442
|
+
* without a second lookup. A leaf that is both draft and stale shows both: the
|
|
1443
|
+
* two are independent reasons to distrust it, and printing only the first would
|
|
1444
|
+
* hide a stale verdict behind a draft one.
|
|
1445
|
+
*/
|
|
1446
|
+
const renderDemotions = (leaf) => (leaf.demotions?.length
|
|
1447
|
+
? ` [${leaf.demotions.map((d) => (d.reason === 'time'
|
|
1448
|
+
? `stale — ${leaf.time.volatility} verified ${leaf.time.age}d ago > ${leaf.time.limit}d`
|
|
1449
|
+
: `${leaf.stage} — downranked`)).join('; ')}]`
|
|
1450
|
+
: '');
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Whether time verdicts ran, printed on every human run (UCS-1150).
|
|
1454
|
+
*
|
|
1455
|
+
* Unconditional, and that is the requirement rather than a stylistic choice: a
|
|
1456
|
+
* run without `--today` computed no freshness verdicts, and a surface that said
|
|
1457
|
+
* nothing would be indistinguishable from one that checked and found everything
|
|
1458
|
+
* fresh. A check that never ran is never a silent pass (PRD §5).
|
|
1459
|
+
*/
|
|
1460
|
+
const renderTimeCheck = (payload, lines) => lines.push(`time check: ${payload['time-check']}`, '');
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* The decomposition block, for the human surface (UCS-1152).
|
|
1464
|
+
*
|
|
1465
|
+
* Printed BEFORE the results, because it is what the results follow from: a
|
|
1466
|
+
* reader who sees "verb: add-token" first understands why leaves about token
|
|
1467
|
+
* registries came back for an ask that named no concept. Only non-empty axes
|
|
1468
|
+
* print — except residue and exclusions, which print their absence explicitly
|
|
1469
|
+
* elsewhere, since "nothing was unresolved" is a claim worth making out loud.
|
|
1470
|
+
*/
|
|
1471
|
+
function renderDecomposition(payload, lines) {
|
|
1472
|
+
const d = payload.decomposition;
|
|
1473
|
+
if (!d) return;
|
|
1474
|
+
lines.push('decomposition:');
|
|
1475
|
+
if (d.operations.length) {
|
|
1476
|
+
lines.push(` verb -> operations: ${d.operations.map((o) => `${o.value} (matched "${o.matched}")`).join(', ')}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (d.concepts.length) {
|
|
1479
|
+
// `match` is the ladder's rung, and it is null for a concept the phrase
|
|
1480
|
+
// test reached but the ladder did not. Printed as `named` rather than as
|
|
1481
|
+
// "null", because that is what it means: the ask named this concept without
|
|
1482
|
+
// being a query for it, which is the ordinary case for any ask that says
|
|
1483
|
+
// more than one thing.
|
|
1484
|
+
lines.push(` noun -> concepts: ${d.concepts.map((c) => `${c.id} "${c.term ?? '?'}" (${c.match ?? 'named'})`).join(', ')}`);
|
|
1485
|
+
}
|
|
1486
|
+
if (d.jurisdictions.length) {
|
|
1487
|
+
lines.push(` place -> jurisdictions: ${d.jurisdictions.map((j) => `${j.value} (matched "${j.matched}")`).join(', ')}`);
|
|
1488
|
+
}
|
|
1489
|
+
if (!d.operations.length && !d.concepts.length && !d.jurisdictions.length) {
|
|
1490
|
+
lines.push(' no axis of this ask joined a governed vocabulary');
|
|
1491
|
+
}
|
|
1492
|
+
// Residue is stated either way. "Residue: none" is the store saying it
|
|
1493
|
+
// understood the whole ask, which is a different and more useful message than
|
|
1494
|
+
// saying nothing at all.
|
|
1495
|
+
lines.push(d.residue.length
|
|
1496
|
+
? ` residue (unresolved): ${d.residue.join(', ')} [resolved context: ${d['resolved-context'].join(', ') || 'none'}]`
|
|
1497
|
+
: ' residue: none — every non-stopword token resolved');
|
|
1498
|
+
for (const m of d['near-miss']) {
|
|
1499
|
+
lines.push(` near-miss: ${m.kind} ${m.id} — token overlap [${m.overlap.join(', ')}] below the match threshold`);
|
|
1500
|
+
}
|
|
1501
|
+
lines.push('');
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Scope exclusions, for the human surface — excluded, never silently absent. */
|
|
1505
|
+
function renderExclusions(payload, lines) {
|
|
1506
|
+
if (!payload.exclusions?.length) return;
|
|
1507
|
+
lines.push('excluded by scope:');
|
|
1508
|
+
for (const x of payload.exclusions) {
|
|
1509
|
+
lines.push(` ${x.id ? `${x.id} ` : ''}${x.notation} ${x.heading} (${x.file})`);
|
|
1510
|
+
lines.push(` ${x.reason}`);
|
|
1511
|
+
}
|
|
1512
|
+
lines.push('');
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** Leaves as first-class results, for the human surface (UCS-1152). */
|
|
1516
|
+
function renderLeaves(payload, lines) {
|
|
1517
|
+
if (!payload.leaves?.length) return;
|
|
1518
|
+
lines.push(`knowledge leaves -> ${payload.leaves.length}`);
|
|
1519
|
+
for (const leaf of payload.leaves) {
|
|
1520
|
+
lines.push(` ${leaf.id ? `${leaf.id} ` : ''}${leaf.notation} ${leaf.heading} score ${leaf.score}${renderDemotions(leaf)} (${leaf.file})`);
|
|
1521
|
+
// The signals, so the score is reproducible on the human surface too — a
|
|
1522
|
+
// ranking a reader cannot decompose is one they cannot check.
|
|
1523
|
+
lines.push(` signals: ${leaf.signals.map((s) => `${s.signal}:${s.via} +${s.score}`).join(', ')}`);
|
|
1524
|
+
if (leaf.excerpt) lines.push(` ${leaf.excerpt}`);
|
|
1525
|
+
lines.push(...renderRelates(leaf, ' '));
|
|
1526
|
+
}
|
|
1527
|
+
lines.push('');
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function renderQuery(payload) {
|
|
1531
|
+
const lines = [];
|
|
1532
|
+
const n = payload.results.length;
|
|
1533
|
+
lines.push(`resolve "${payload.query}" -> ${n} concept${n === 1 ? '' : 's'}`, '');
|
|
1534
|
+
renderTimeCheck(payload, lines);
|
|
1535
|
+
renderHealth(payload['store-health'], lines);
|
|
1536
|
+
renderDecomposition(payload, lines);
|
|
1537
|
+
renderLeaves(payload, lines);
|
|
1538
|
+
renderExclusions(payload, lines);
|
|
1539
|
+
if (!n) {
|
|
1540
|
+
// The same conduct the payload carries, so the two surfaces cannot drift
|
|
1541
|
+
// into telling a reader different things about the same empty result.
|
|
1542
|
+
if (payload.conduct) lines.push(payload.conduct);
|
|
1543
|
+
else lines.push('no concepts matched, but knowledge leaves resolved directly — see above');
|
|
1544
|
+
return lines;
|
|
1545
|
+
}
|
|
1546
|
+
for (const r of payload.results) {
|
|
1547
|
+
lines.push(`${r.id} ${r.term} [${r.status}] score ${r.score} (${r.match})`);
|
|
1548
|
+
for (const c of r['confusable-with']) {
|
|
1549
|
+
lines.push(` confusable-with: ${c.id} "${c.term ?? '?'}" — confirm this is the concept you mean`);
|
|
1550
|
+
}
|
|
1551
|
+
if (r.summary) lines.push(` summary: ${r.summary}`);
|
|
1552
|
+
if (r['source-of-truth'].length) {
|
|
1553
|
+
lines.push(' source-of-truth:');
|
|
1554
|
+
for (const p of r['source-of-truth']) lines.push(` ${p}`);
|
|
1555
|
+
}
|
|
1556
|
+
if (r.knowledge.length) {
|
|
1557
|
+
lines.push(' knowledge entry points:');
|
|
1558
|
+
// The accession leads — it is the leaf's identity and what a citation
|
|
1559
|
+
// must spell (UCS-1147) — with the legacy notation still shown after it,
|
|
1560
|
+
// since a reader navigating an older tree still recognizes it.
|
|
1561
|
+
// The draft marker rides the same line as the heading, so the demotion is
|
|
1562
|
+
// visible where the ordering already put the leaf last — an agent
|
|
1563
|
+
// skimming the list sees WHY a leaf sits at the bottom without a second
|
|
1564
|
+
// lookup. The derived excerpt follows indented beneath, which is the
|
|
1565
|
+
// display prose the retired `description` field used to be for; a leaf
|
|
1566
|
+
// whose body opens with no prose simply shows no excerpt line.
|
|
1567
|
+
for (const k of r.knowledge) {
|
|
1568
|
+
lines.push(` ${k.id ? `${k.id} ` : ''}${k.notation} ${k.heading}${renderDemotions(k)} (${k.file})`);
|
|
1569
|
+
if (k.excerpt) lines.push(` ${k.excerpt}`);
|
|
1570
|
+
lines.push(...renderRelates(k));
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
lines.push('');
|
|
1574
|
+
}
|
|
1575
|
+
return lines;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function renderPaths(payload) {
|
|
1579
|
+
const lines = [];
|
|
1580
|
+
const n = payload.paths.length;
|
|
1581
|
+
lines.push(`resolve --paths -> ${n} path${n === 1 ? '' : 's'}`, '');
|
|
1582
|
+
renderTimeCheck(payload, lines);
|
|
1583
|
+
renderHealth(payload['store-health'], lines);
|
|
1584
|
+
for (const { path, concepts, knowledge } of payload.paths) {
|
|
1585
|
+
lines.push(path);
|
|
1586
|
+
if (!concepts.length) {
|
|
1587
|
+
lines.push(' no concepts point at this path');
|
|
1588
|
+
}
|
|
1589
|
+
for (const c of concepts) {
|
|
1590
|
+
lines.push(` ${c.id} ${c.term ?? '?'} [${c.status}] (pointer: ${c.pointer})`);
|
|
1591
|
+
}
|
|
1592
|
+
// The leaves that govern this path (UCS-1151) — the knowledge to read
|
|
1593
|
+
// BEFORE editing the file, which is the whole reason a diff is fed in here.
|
|
1594
|
+
// `via` is shown because "this leaf names your file" and "this leaf covers a
|
|
1595
|
+
// concept your file is under" are different strengths of claim.
|
|
1596
|
+
if (knowledge.length) {
|
|
1597
|
+
lines.push(' governing knowledge:');
|
|
1598
|
+
for (const k of knowledge) {
|
|
1599
|
+
lines.push(` ${k.id ? `${k.id} ` : ''}${k.notation} ${k.heading} [via ${k.via}]${renderDemotions(k)} (${k.file})`);
|
|
1600
|
+
if (k.excerpt) lines.push(` ${k.excerpt}`);
|
|
1601
|
+
lines.push(...renderRelates(k));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
lines.push('');
|
|
1605
|
+
}
|
|
1606
|
+
lines.push('update every concept listed above in the same commit as the change (PRD §7 ACT)');
|
|
1607
|
+
return lines;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
export function main(argv) {
|
|
1611
|
+
{
|
|
1612
|
+
const opts = parseArgs(argv);
|
|
1613
|
+
|
|
1614
|
+
let model;
|
|
1615
|
+
let kitRoot;
|
|
1616
|
+
try {
|
|
1617
|
+
// KK-08 two-root convention: --root is the REPO root; the stores live
|
|
1618
|
+
// at <root>/unknown-knowledge/ when seeded (§9.1) or at the root itself
|
|
1619
|
+
// (dogfood layout).
|
|
1620
|
+
kitRoot = locateKitRoot(opts.root);
|
|
1621
|
+
model = loadStores(kitRoot);
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
// An EXPECTED refusal from the loader — an unreadable root, an ambiguous
|
|
1624
|
+
// kit layout, a Store that will not load. The stores this command would
|
|
1625
|
+
// check never loaded, so its checks never ran: exit 2, never 1.
|
|
1626
|
+
process.stderr.write(`resolve: ${error.message}\n`);
|
|
1627
|
+
rethrowIfBug(error); // a bug, or a UsageError raised deep in the loader, is not ours to speak for
|
|
1628
|
+
return EXIT_CODES.FAILURE;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const health = healthSummary(storeHealth(model));
|
|
1632
|
+
// `time-check` is on EVERY payload, in both modes, whether or not --today
|
|
1633
|
+
// was passed (UCS-1150). A run that computed no verdicts must say so in
|
|
1634
|
+
// its output — otherwise it is indistinguishable from one that checked and
|
|
1635
|
+
// found everything fresh, which is a check that never ran wearing a clean
|
|
1636
|
+
// result (PRD §5).
|
|
1637
|
+
const timeCheck = timeCheckStatus(opts.today);
|
|
1638
|
+
let payload;
|
|
1639
|
+
if (opts.doc) {
|
|
1640
|
+
// An out-of-envelope submission is an ANTICIPATED REFUSAL, not a bug: it
|
|
1641
|
+
// exits 2 with the adapter's conduct, because a parse that never ran is a
|
|
1642
|
+
// failure and never a silent partial (PRD §5.1). The exit contract is
|
|
1643
|
+
// unchanged — 0 ran, 2 never ran, never 1.
|
|
1644
|
+
try {
|
|
1645
|
+
payload = {
|
|
1646
|
+
mode: 'doc',
|
|
1647
|
+
'time-check': timeCheck,
|
|
1648
|
+
'store-health': health,
|
|
1649
|
+
map: resolveDoc(model, opts.doc, kitRoot, opts.today),
|
|
1650
|
+
};
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
rethrowIfBug(error);
|
|
1653
|
+
if (!(error instanceof UnsupportedFormatError || error instanceof AdaptError)) throw error;
|
|
1654
|
+
// Nothing goes to stdout: a caller piping it must receive no map at
|
|
1655
|
+
// all, not a truncated one.
|
|
1656
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
1657
|
+
return EXIT_CODES.FAILURE;
|
|
1658
|
+
}
|
|
1659
|
+
} else if (opts.paths) {
|
|
1660
|
+
payload = {
|
|
1661
|
+
mode: 'paths', 'time-check': timeCheck, 'store-health': health,
|
|
1662
|
+
paths: resolvePaths(model, opts.paths, opts.root, opts.today),
|
|
1663
|
+
};
|
|
1664
|
+
} else {
|
|
1665
|
+
payload = {
|
|
1666
|
+
mode: 'query', 'time-check': timeCheck, 'store-health': health,
|
|
1667
|
+
...resolveQuery(model, opts.terms, opts.today),
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const RENDER = { query: renderQuery, paths: renderPaths, doc: renderDoc };
|
|
1672
|
+
const lines = opts.json
|
|
1673
|
+
? [JSON.stringify(payload, null, 2)]
|
|
1674
|
+
: RENDER[payload.mode](payload);
|
|
1675
|
+
process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
|
|
1676
|
+
return EXIT_CODES.CLEAN;
|
|
1677
|
+
}
|
|
1678
|
+
}
|