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.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The resolver's scoring table (UCS-1152) — every signal that moves a score,
3
+ * declared in one place, with the number it is worth.
4
+ *
5
+ * Extracted BEFORE the query-decomposition signals were added, on the survey's
6
+ * prefactor advice, and the ordering matters: scoring that lives inline in the
7
+ * matcher grows a new arm per signal, and by the fourth signal nobody can say
8
+ * what a score of 7 is made of without reading four functions. A table says it
9
+ * in one screen, and `explain()` turns any score back into the signals that
10
+ * produced it — which is the acceptance criterion "ranking is reproducible from
11
+ * the output", enforced by construction rather than by a comment asking future
12
+ * authors to remember.
13
+ *
14
+ * TWO FAMILIES, deliberately kept apart:
15
+ *
16
+ * CONCEPT_SIGNALS the pre-1152 ladder — a concept scores on the HIGHEST
17
+ * rung it reaches and rungs never add up. Unchanged numbers
18
+ * (100/80/60/50/40, -30 for draft), because published
19
+ * concept scores are a ranking consumers already depend on
20
+ * and this ticket had no reason to move them.
21
+ * LEAF_SIGNALS the structured joins a LEAF scores on, and these DO add
22
+ * up. A leaf reached by both a declared operation and a
23
+ * declared concept is more strongly the answer than one
24
+ * reached by either alone, and additive scoring is the only
25
+ * shape that says so.
26
+ *
27
+ * That the two families disagree about adding is the substantive modelling
28
+ * claim here, not an inconsistency. A concept match is one question asked five
29
+ * ways — "is this the term the user typed" — so the best answer wins and a
30
+ * second, weaker phrasing of the same question adds nothing. A leaf's joins are
31
+ * INDEPENDENT questions — does it declare this operation, this concept, does
32
+ * its term text match — and independent evidence accumulates.
33
+ *
34
+ * The leaf weights (3/2/1) are pinned: their ordering is the behavior the
35
+ * goldens mirror, and the ordering they encode is the defensible part — a
36
+ * DECLARED operation is
37
+ * the strongest join (the leaf's author named the verb this ask is about), a
38
+ * declared concept edge is next (curatorial, survives a rename), and term text
39
+ * is weakest (it is exactly as reliable as two authors choosing the same
40
+ * words). Absolute magnitudes matter only relative to each other.
41
+ */
42
+
43
+ /**
44
+ * The concept ladder — highest rung reached wins, rungs never add (KK-06).
45
+ *
46
+ * @type {Readonly<Record<string, number>>}
47
+ */
48
+ export const CONCEPT_SIGNALS = Object.freeze({
49
+ 'exact-term': 100,
50
+ 'exact-alias': 80,
51
+ 'term-match': 60,
52
+ 'alias-match': 50,
53
+ 'summary-match': 40,
54
+ });
55
+
56
+ /**
57
+ * The structured joins a leaf scores on — these ADD (UCS-1152).
58
+ *
59
+ * @type {Readonly<Record<string, number>>}
60
+ */
61
+ export const LEAF_SIGNALS = Object.freeze({
62
+ // The leaf declares the operation the query's verb resolved to. The strongest
63
+ // join in the table because it needed no noun guessing: the registry turned
64
+ // "add a tool" into `add-tool`, and the leaf had already declared it.
65
+ operation: 3,
66
+ // The leaf declares the concept the query's noun resolved to — the structural
67
+ // edge from UCS-1151, a curatorial claim that survives the concept being
68
+ // renamed.
69
+ concept: 2,
70
+ // The leaf's `terms` text matched query tokens. Weakest, and it is meant to
71
+ // be: it fires exactly when two authors happened to choose the same words.
72
+ term: 1,
73
+ });
74
+
75
+ /**
76
+ * The draft/proposed downrank applied to a CONCEPT score (§3.5).
77
+ *
78
+ * A subtraction with a floor of 1, never a filter: a draft concept that matches
79
+ * is still the best answer when it is the only answer, and hiding it would send
80
+ * the reader to invent one. Leaves take their pre-promotion demotion through
81
+ * the `downranked` ordering instead — see `demotionsOf` in the resolver — because
82
+ * a leaf's stage travels with a reason and sorts below rather than subtracting.
83
+ */
84
+ export const STATUS_DOWNRANK = 30;
85
+
86
+ /** Concept statuses the downrank applies to. */
87
+ const DOWNRANKED_STATUSES = Object.freeze(['draft', 'proposed']);
88
+
89
+ /**
90
+ * A concept's score: the rung it reached, less the draft downrank if it is one.
91
+ *
92
+ * Unchanged from the pre-1152 inline version, and pinned by the existing
93
+ * goldens — the extraction moved this arithmetic, it did not renegotiate it.
94
+ *
95
+ * @param {string} match the rung name, a key of CONCEPT_SIGNALS
96
+ * @param {unknown} status the concept's declared status
97
+ * @returns {number}
98
+ */
99
+ export function conceptScore(match, status) {
100
+ const base = CONCEPT_SIGNALS[match];
101
+ return DOWNRANKED_STATUSES.includes(status) ? Math.max(1, base - STATUS_DOWNRANK) : base;
102
+ }
103
+
104
+ /**
105
+ * Total one leaf's structured-join signals, and keep the working.
106
+ *
107
+ * Returns BOTH the number and the signals that made it, because a score a
108
+ * reader cannot decompose is a ranking they cannot check. The acceptance
109
+ * criterion is literally "ranking is reproducible from the output": every
110
+ * published leaf carries its `signals`, so `sum(signal.score)` must equal the
111
+ * published `score` — an invariant a test can assert rather than a claim a
112
+ * comment makes.
113
+ *
114
+ * Signals arrive in join order and are NOT re-sorted here: the caller emits
115
+ * them operation-first, concept-next, term-last, which is descending weight, so
116
+ * the strongest reason a leaf surfaced reads first. Sorting by name would put
117
+ * `concept` above `operation` and bury the lead.
118
+ *
119
+ * @param {Array<{signal: string, via: string}>} signals the joins that fired
120
+ * @returns {{score: number, signals: Array<{signal: string, via: string, score: number}>}}
121
+ */
122
+ export function leafScore(signals) {
123
+ const scored = signals.map(({ signal, via }) => ({
124
+ signal,
125
+ via,
126
+ // An unknown signal scores 0 rather than NaN. `undefined + n` is NaN, and a
127
+ // NaN score sorts unpredictably AND serializes to JSON as `null`, so one
128
+ // typo in a signal name would silently unrank a leaf and publish a null
129
+ // where every consumer reads a number.
130
+ score: LEAF_SIGNALS[signal] ?? 0,
131
+ }));
132
+ return { score: scored.reduce((total, s) => total + s.score, 0), signals: scored };
133
+ }
134
+
135
+ /**
136
+ * The published scoring table — the engine's own answer to "where do these
137
+ * numbers come from", carried in the payload rather than documented elsewhere.
138
+ *
139
+ * Emitted on every query payload so a consumer reproducing a ranking never has
140
+ * to hard-code the weights it is checking against. A table that lives only in
141
+ * source is one a downstream reader has to guess at or vendor a copy of, and a
142
+ * vendored copy is the thing that goes stale the first time a weight moves.
143
+ *
144
+ * @returns {{concept: Record<string, number>, leaf: Record<string, number>, 'status-downrank': number}}
145
+ */
146
+ export const scoringTable = () => ({
147
+ concept: { ...CONCEPT_SIGNALS },
148
+ leaf: { ...LEAF_SIGNALS },
149
+ 'status-downrank': STATUS_DOWNRANK,
150
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Suppression — the steward's rejection memory (KK-27, D-013, PRD §11.1).
3
+ *
4
+ * A client-zone `suppressions.yaml` next to the stores, telling the reverse
5
+ * audit "this is deliberately not a Concept". It has real depth behind a small
6
+ * interface: a fails-open loader, a strict entry shape, and an exact-match
7
+ * filter that can only ever UNDER-suppress.
8
+ *
9
+ * Two rules give the whole module its shape:
10
+ *
11
+ * 1. IT FAILS OPEN, always. The opposite of the scope file. A missing file is a
12
+ * no-op; anything malformed — the whole document, or one entry — becomes a
13
+ * warning and suppresses nothing, so the findings it would have hidden come
14
+ * back. Suppression is advisory-side; it must never be able to silence a
15
+ * finding by being broken, and never raise an engine failure.
16
+ *
17
+ * 2. IDENTITY TRAVELS WITH THE FINDING. A Finding knows what a suppression
18
+ * entry would have to say to silence it, because it is stamped at the place
19
+ * the Finding is built. Nothing here switches on the Finding's code.
20
+ *
21
+ * Rule 2 is the point of this module existing. The identity used to be derived
22
+ * by a function that switched on `finding.code`, so a third Finding code was
23
+ * one forgotten `if` away from being permanently unsuppressable — silently, and
24
+ * only in a client's Store. Now a Finding without an identity is refused
25
+ * outright: `partitionBySuppression` throws rather than quietly keeping it.
26
+ *
27
+ * v1 is deliberately minimal (§11.1): exact match on `{ term, sourcePath }`.
28
+ * No patterns, no expiry, no globs. A suppression that could match something
29
+ * its author did not read is a suppression that can hide the finding they most
30
+ * needed to see.
31
+ */
32
+ import { readFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { load } from 'js-yaml';
35
+ import { SUPPRESSIONS_FILE } from './kit-root.js';
36
+ import { isCalendarDate } from './iso-date.js';
37
+
38
+ export { SUPPRESSIONS_FILE };
39
+
40
+ /** The v1 entry shape, STRICTLY: exact-match identity + audit trail. */
41
+ export const SUPPRESSION_FIELDS = Object.freeze(['term', 'sourcePath', 'reason', 'date']);
42
+
43
+ /**
44
+ * The identity a Finding is suppressed by, carried on the Finding itself.
45
+ *
46
+ * A Symbol, so it never reaches the wire: `JSON.stringify` and `Object.keys`
47
+ * skip symbol keys, and the audit's `--json` payload is a published contract.
48
+ * It survives object spread, which is how findings are built.
49
+ */
50
+ export const SUPPRESSION_IDENTITY = Symbol('suppression identity');
51
+
52
+
53
+ /**
54
+ * Stamp a Finding with the exact-match identity that would silence it.
55
+ *
56
+ * Call this where the Finding is BUILT, where its shape is known. That is the
57
+ * whole design: no downstream function has to know every Finding code, so a new
58
+ * code cannot fall out of sync with a switch statement that forgot it.
59
+ *
60
+ * @param {object} finding
61
+ * @param {{ term: string, sourcePath: string }} identity
62
+ * @returns {object} the finding, carrying its identity
63
+ */
64
+ export function suppressibleBy(finding, identity) {
65
+ if (typeof identity?.term !== 'string' || identity.term === ''
66
+ || typeof identity?.sourcePath !== 'string' || identity.sourcePath === '') {
67
+ throw new Error(`finding "${finding?.code}": a suppression identity needs a non-empty term and sourcePath`);
68
+ }
69
+ return { ...finding, [SUPPRESSION_IDENTITY]: Object.freeze({ ...identity }) };
70
+ }
71
+
72
+ /** Warning text for one malformed entry, or null when it is well-formed. */
73
+ export function suppressionEntryProblem(entry) {
74
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
75
+ return 'not a mapping';
76
+ }
77
+ for (const field of SUPPRESSION_FIELDS) {
78
+ if (typeof entry[field] !== 'string' || entry[field].trim() === '') {
79
+ return `"${field}" must be a non-empty string`;
80
+ }
81
+ }
82
+ const unknown = Object.keys(entry).filter((k) => !SUPPRESSION_FIELDS.includes(k));
83
+ if (unknown.length > 0) {
84
+ return `unknown field(s) ${unknown.map((k) => `"${k}"`).join(', ')} — v1 entries are strictly { ${SUPPRESSION_FIELDS.join(', ')} } (no patterns, no expiry; §11.1)`;
85
+ }
86
+ if (!isCalendarDate(entry.date)) {
87
+ // A typo'd date drops the entry, which resurfaces its finding. That is the
88
+ // safe direction: suppression fails open, and only ever under-suppresses.
89
+ return `"date" must be an ISO date (YYYY-MM-DD), got ${JSON.stringify(entry.date)}`;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** True when the document has anything but blank lines and `#` comments. */
95
+ const hasContent = (text) =>
96
+ text.split('\n').some((line) => line.trim() !== '' && !line.trim().startsWith('#'));
97
+
98
+ /**
99
+ * Load `<kitRoot>/suppressions.yaml`. FAILS OPEN by design.
100
+ *
101
+ * @returns {{ entries: object[], warnings: string[] }} only well-formed entries
102
+ */
103
+ export function loadSuppressions(kitRoot) {
104
+ const warn = (msg) => ({ entries: [], warnings: [`${SUPPRESSIONS_FILE}: ${msg} — ignoring every entry (suppression fails open, findings resurface)`] });
105
+ let text;
106
+ try {
107
+ text = readFileSync(join(kitRoot, SUPPRESSIONS_FILE), 'utf8');
108
+ } catch (error) {
109
+ if (error.code === 'ENOENT') return { entries: [], warnings: [] }; // no file, no-op
110
+ return warn(`cannot read: ${error.message}`);
111
+ }
112
+ // A file with nothing in it but blank lines and comments is a file with no
113
+ // entries — not a broken one. js-yaml raises "expected a document, but the
114
+ // input is empty" for all three, so a steward who commented out their last
115
+ // suppression used to be told their file was unparseable.
116
+ if (!hasContent(text)) return { entries: [], warnings: [] };
117
+
118
+ let doc;
119
+ try {
120
+ doc = load(text, { filename: SUPPRESSIONS_FILE });
121
+ } catch (error) {
122
+ return warn(`unparseable YAML: ${error.reason ?? error.message}`);
123
+ }
124
+ if (doc === null || doc === undefined) return { entries: [], warnings: [] }; // explicit `null`, no-op
125
+ if (!Array.isArray(doc)) return warn('must be a YAML list of { term, sourcePath, reason, date } entries');
126
+
127
+ const entries = [];
128
+ const warnings = [];
129
+ doc.forEach((entry, i) => {
130
+ const problem = suppressionEntryProblem(entry);
131
+ if (problem === null) {
132
+ entries.push(entry);
133
+ } else {
134
+ warnings.push(`${SUPPRESSIONS_FILE}: entry ${i + 1} ignored (fails open, its finding resurfaces): ${problem}`);
135
+ }
136
+ });
137
+ return { entries, warnings };
138
+ }
139
+
140
+ /**
141
+ * Split findings into those a suppression entry silences and those it does not.
142
+ *
143
+ * Exact match on both fields, in the order the findings arrive — callers sort
144
+ * first, so both lists stay stable.
145
+ *
146
+ * A Finding with no identity is a PROGRAMMING error, not a client-data one, so
147
+ * it is refused rather than passed through. Passing it through would make a new
148
+ * Finding code permanently unsuppressable, and nothing would ever say so: the
149
+ * audit would run clean, the entry the steward wrote would be ignored, and the
150
+ * finding they rejected would come back every run. Failing open is right for a
151
+ * malformed suppressions.yaml; it is wrong for a Finding the engine built.
152
+ *
153
+ * @param {object[]} findings each stamped by `suppressibleBy`
154
+ * @param {object[]} entries well-formed suppression entries
155
+ * @returns {{ kept: object[], suppressed: object[] }}
156
+ */
157
+ export function partitionBySuppression(findings, entries) {
158
+ const kept = [];
159
+ const suppressed = [];
160
+ for (const finding of findings) {
161
+ const identity = finding[SUPPRESSION_IDENTITY];
162
+ if (identity === undefined) {
163
+ throw new Error(
164
+ `finding "${finding?.code}" carries no suppression identity — stamp it with suppressibleBy() `
165
+ + 'where it is built, or a steward could never suppress it',
166
+ );
167
+ }
168
+ const match = entries.some((e) => e.term === identity.term && e.sourcePath === identity.sourcePath);
169
+ (match ? suppressed : kept).push(finding);
170
+ }
171
+ return { kept, suppressed };
172
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The Time facet (UCS-1150) — `verified` + `volatility`, and the one function
3
+ * that turns them into a trusted/stale verdict.
4
+ *
5
+ * Freshness made visible. A leaf records WHEN it was last verified and HOW FAST
6
+ * its subject moves; the engine turns those two facts into a verdict every
7
+ * projection can read. A fast-growing store cannot rot silently, because an
8
+ * agent never has to judge at query time whether a world-fact from eighteen
9
+ * months ago still holds — the verdict already said.
10
+ *
11
+ * ONE implementation, deliberately. Three surfaces demote on this today (the
12
+ * resolver's ranking, preflight's leaf verdicts, the validator's missing-date
13
+ * finding) and the derived-layer trees (UCS-1158) plus the resolution pipeline
14
+ * (UCS-1152) inherit it. That is exactly the shape that goes wrong when each
15
+ * surface computes its own: a leaf ranked stale by the resolver and verdicted
16
+ * trusted by preflight is one record wearing two answers, and the disagreement
17
+ * surfaces as a bug in whichever surface the reader happened to trust. The
18
+ * shared `isPrePromotionStatus` predicate exists for the same reason one rung
19
+ * up, and this is the time facet's half of that discipline.
20
+ *
21
+ * THRESHOLDS ARE PINNED, not configurable. `stable` stales after 365 days,
22
+ * `volatile` after 90, `static` never. A per-store threshold would make "stale"
23
+ * mean something different in every repo, and the verdict's whole value is that
24
+ * an agent reading it needs no local knowledge to act on it. Changing a number
25
+ * here is an engine release with a Decisions entry behind it, which is the
26
+ * governance the audit's `--stale-days` flag deliberately does NOT have (that
27
+ * one is advisory and never demotes anything).
28
+ *
29
+ * THE DATE IS INJECTED, never read from the wall clock (D-012, PRD §5). Without
30
+ * an injected `today` there is no verdict to compute, and the answer is
31
+ * `skipped` — a distinct verdict class that every surface must print, never a
32
+ * quiet `trusted`. A check that never ran is a blocking defect, never a silent
33
+ * pass; the audit's `stale-last-verified` established the rule and this follows
34
+ * it exactly, so baseline finding-set diffing survives.
35
+ */
36
+ import { daysBetween, isCalendarDate } from './iso-date.js';
37
+
38
+ /** The leaf front-matter field naming when this leaf was last verified. */
39
+ export const VERIFIED_FIELD = 'verified';
40
+
41
+ /** The leaf front-matter field naming how fast this leaf's subject moves. */
42
+ export const VOLATILITY_FIELD = 'volatility';
43
+
44
+ /**
45
+ * How long a leaf stays fresh, per volatility class — the pinned thresholds.
46
+ *
47
+ * `static` is `Infinity` rather than absent: a static leaf is IN the time
48
+ * facet and its verdict is that it never stales, which is a different claim
49
+ * from a leaf the facet does not govern at all. Modelling it as a missing row
50
+ * would collapse those two into one and lose the distinction the schema draws.
51
+ *
52
+ * Read as `age > limit`: a `stable` leaf verified exactly 365 days ago is
53
+ * fresh, and stales on day 366. The boundary is pinned that way in both
54
+ * directions by golden fixtures, because "more than a year old" and "a year or
55
+ * more old" differ by exactly one day and nothing in the phrase says which.
56
+ *
57
+ * NULL-PROTOTYPE, and that is a correctness requirement rather than a style
58
+ * preference. A plain object literal inherits `toString`, `constructor`,
59
+ * `valueOf` and the rest of Object.prototype, so `'toString' in LIMITS` is
60
+ * TRUE and `LIMITS['toString']` is a native function. A leaf spelling
61
+ * `volatility: toString` would then be treated as a known class whose limit is
62
+ * a function — and `age > someFunction` is always false, so a leaf verified in
63
+ * 2020 would read `trusted`, undemoted, with `limit` silently dropped from the
64
+ * JSON (JSON.stringify omits function values) and `[native code]` leaking into
65
+ * the reason a human reads.
66
+ *
67
+ * The schema enum already refuses such a leaf, so no VALIDATED store reaches
68
+ * it. That is not enough: the resolver deliberately never gates on store health
69
+ * — a lookup runs on whatever loaded (§4) — so it is precisely the surface that
70
+ * can be asked to publish a verdict on a leaf no check approved. Fixing it at
71
+ * the table rather than at each call site means a future consumer of this
72
+ * module cannot reintroduce the bug by reaching for `in` or `[]` itself.
73
+ *
74
+ * @type {Readonly<Record<string, number>>}
75
+ */
76
+ export const VOLATILITY_LIMITS = Object.freeze(Object.assign(Object.create(null), {
77
+ static: Infinity,
78
+ stable: 365,
79
+ volatile: 90,
80
+ }));
81
+
82
+ /** The closed volatility vocabulary, sorted — the schema enum's single source. */
83
+ export const VOLATILITY_CLASSES = Object.freeze(Object.keys(VOLATILITY_LIMITS).sort());
84
+
85
+ /** Verdict classes. `skipped` is one of them, and that is the point. */
86
+ export const TIME_VERDICTS = Object.freeze({
87
+ TRUSTED: 'trusted',
88
+ STALE: 'stale',
89
+ SKIPPED: 'skipped',
90
+ EXEMPT: 'exempt',
91
+ UNDATED: 'undated',
92
+ });
93
+
94
+ /**
95
+ * A leaf's declared volatility class, or null when it declares none.
96
+ *
97
+ * The single reader of the `volatility` spelling, for the reason `leafStage` is
98
+ * the single reader of `facets.stage`: a later move of the field must not leave
99
+ * one surface silently reading `undefined` and quietly promoting a leaf the
100
+ * defect should have held back.
101
+ *
102
+ * A value outside the closed vocabulary reads as null rather than travelling
103
+ * on. The schema has already refused it, and handing an unknown class to the
104
+ * threshold table would produce `undefined > n` — which is `false`, i.e. a
105
+ * silent `trusted` for a leaf whose volatility nobody can interpret.
106
+ *
107
+ * Membership is an OWN-property test, never `in`. The table is null-prototype
108
+ * so `in` would be safe today, but this is the gate every unknown value passes
109
+ * through and it should be correct on its own terms rather than by depending on
110
+ * how the table above happens to be built — an inherited name like `toString`
111
+ * reading as a known class is the one failure this function exists to prevent.
112
+ *
113
+ * @param {object} record a leaf's front-matter record
114
+ * @returns {string|null}
115
+ */
116
+ export function leafVolatility(record) {
117
+ const volatility = record?.[VOLATILITY_FIELD];
118
+ return typeof volatility === 'string' && Object.prototype.hasOwnProperty.call(VOLATILITY_LIMITS, volatility)
119
+ ? volatility
120
+ : null;
121
+ }
122
+
123
+ /**
124
+ * A leaf's declared verified date, or null when it declares none.
125
+ *
126
+ * Null covers a malformed date as well as an absent one — `2026-02-30` is not a
127
+ * day, and measuring an age from it would report a number nobody's calendar
128
+ * agrees with (the defect `isCalendarDate` was written for, UCS-957). The
129
+ * schema's pattern catches the SHAPE; this catches the calendar, and the
130
+ * validator reports the difference so a malformed date is never merely ignored.
131
+ *
132
+ * @param {object} record a leaf's front-matter record
133
+ * @returns {string|null}
134
+ */
135
+ export function leafVerified(record) {
136
+ const verified = record?.[VERIFIED_FIELD];
137
+ return isCalendarDate(verified) ? verified : null;
138
+ }
139
+
140
+ /**
141
+ * The time verdict for one leaf — the whole facet, in one function.
142
+ *
143
+ * Returns a verdict object, never a bare boolean, because every surface has to
144
+ * show its WORK: the ticket's demand is that a demotion is never silent, and a
145
+ * `true` carries no reason a reader could act on. The shape is one stable set
146
+ * of keys whatever the verdict, so a consumer never needs a presence check to
147
+ * tell "not stale" from "this engine predates the time facet":
148
+ *
149
+ * verdict trusted | stale | skipped | exempt | undated
150
+ * stale the boolean every ranking sorts on — `true` ONLY for `stale`,
151
+ * so a skipped or exempt leaf is never demoted by accident
152
+ * volatility the declared class, or null
153
+ * verified the declared date, or null
154
+ * age whole days from `verified` to `today`, or null when uncomputed
155
+ * limit the pinned threshold for this class, or null
156
+ * reason why this verdict, in words a reader can act on
157
+ *
158
+ * The five verdicts, and why each is its own class rather than folded into
159
+ * another:
160
+ *
161
+ * skipped no `today` was injected. NOT `trusted`: the check did not run,
162
+ * and reporting a leaf as fresh because nobody asked what day it is
163
+ * would be the silent pass this rule exists to prevent (D-012).
164
+ * exempt the leaf declares no volatility, so the store has not placed it
165
+ * under time governance at all. Distinct from `trusted`, which is a
166
+ * leaf the facet governs and finds fresh — collapsing them would
167
+ * report an ungoverned leaf as having passed a check it never sat.
168
+ * undated the leaf declares a volatility but no usable `verified` date, so
169
+ * its age is unknowable. NOT stale (nothing measured it) and not
170
+ * trusted (nothing vouches for it): the validator raises the
171
+ * missing-date finding, and this verdict is what every projection
172
+ * says meanwhile. A leaf whose freshness cannot be computed must
173
+ * not read as fresh.
174
+ * stale age exceeds the pinned limit for its class.
175
+ * trusted the check ran, and the leaf is inside its limit.
176
+ *
177
+ * @param {object} record a leaf's front-matter record
178
+ * @param {string|null} today the injected date (YYYY-MM-DD), or null
179
+ * @returns {{verdict: string, stale: boolean, volatility: string|null, verified: string|null, age: number|null, limit: number|null, reason: string}}
180
+ */
181
+ export function timeVerdict(record, today) {
182
+ const volatility = leafVolatility(record);
183
+ const verified = leafVerified(record);
184
+ // `Infinity` is not JSON: `JSON.stringify(Infinity)` is `null`, silently. So
185
+ // static's limit is published as an explicit null rather than travelling as
186
+ // one by accident — the two are the same bytes, and only one of them is a
187
+ // decision. What distinguishes "never stales" from "no limit applies" on the
188
+ // wire is `volatility`, which is present either way and says which it is.
189
+ // The COMPARISON still uses the table's Infinity; this is the wire shape.
190
+ const limit = volatility === null || volatility === 'static'
191
+ ? null
192
+ : VOLATILITY_LIMITS[volatility];
193
+ const base = { volatility, verified, age: null, limit, stale: false };
194
+
195
+ // Order matters. `exempt` is asked FIRST, before the injected date: a leaf
196
+ // the facet does not govern has the same verdict on every run, and reporting
197
+ // it as `skipped` would tell a reader to pass --today to learn something
198
+ // --today cannot tell them.
199
+ if (volatility === null) {
200
+ return {
201
+ ...base,
202
+ verdict: TIME_VERDICTS.EXEMPT,
203
+ reason: `no ${VOLATILITY_FIELD} declared — this leaf is not under time governance, so no freshness verdict applies (UCS-1150)`,
204
+ };
205
+ }
206
+ // STATIC is settled here, before `today` and before the date check, because
207
+ // neither can change the answer: static knowledge never stales, so there is
208
+ // no age at which it would, and no date that would make it. Reporting it
209
+ // `skipped` would tell a reader to pass --today to learn something --today
210
+ // cannot tell them, and reporting it `undated` would demote a leaf whose
211
+ // whole declared property is that it cannot rot — the same asymmetry the
212
+ // validator's missing-verified finding draws, where a static leaf without a
213
+ // date is clean.
214
+ //
215
+ // The age is still computed and published when BOTH a date and a `today`
216
+ // are available: an author who wrote a date meant it, and a surface that
217
+ // showed nothing would be hiding a fact it holds. The verdict is trusted
218
+ // either way, which is the part that does not depend on the date.
219
+ if (volatility === 'static') {
220
+ const age = verified !== null && today ? daysBetween(verified, today) : null;
221
+ return {
222
+ ...base,
223
+ age,
224
+ verdict: TIME_VERDICTS.TRUSTED,
225
+ reason: age === null
226
+ ? `static knowledge never stales — no age is needed to say so (UCS-1150)`
227
+ : `static knowledge never stales — verified ${age} day(s) ago (UCS-1150)`,
228
+ };
229
+ }
230
+ if (!today) {
231
+ return {
232
+ ...base,
233
+ verdict: TIME_VERDICTS.SKIPPED,
234
+ reason: 'skipped — pass --today YYYY-MM-DD to enable time verdicts; diffable output never reads the wall clock (D-012)',
235
+ };
236
+ }
237
+ if (verified === null) {
238
+ return {
239
+ ...base,
240
+ verdict: TIME_VERDICTS.UNDATED,
241
+ reason: `${volatility} leaf carries no usable ${VERIFIED_FIELD} date — its age cannot be computed, so nothing vouches for its freshness (UCS-1150)`,
242
+ };
243
+ }
244
+ // `static` never stales: Infinity is never exceeded, so the comparison needs
245
+ // no special case and cannot acquire one by accident. Read from the TABLE,
246
+ // not from the published `limit` above — that one is nulled for static to
247
+ // survive JSON, and comparing against it would make every static leaf stale.
248
+ const age = daysBetween(verified, today);
249
+ if (age > VOLATILITY_LIMITS[volatility]) {
250
+ return {
251
+ ...base,
252
+ age,
253
+ verdict: TIME_VERDICTS.STALE,
254
+ stale: true,
255
+ reason: `verified ${age} day(s) ago, past the ${limit}-day limit for ${volatility} knowledge — re-verify against the cited sources, or treat the claim as unverified (UCS-1150)`,
256
+ };
257
+ }
258
+ // Only `stable` and `volatile` reach here — `static` returned above and
259
+ // `exempt` never had a class — so the limit is always a real number.
260
+ return {
261
+ ...base,
262
+ age,
263
+ verdict: TIME_VERDICTS.TRUSTED,
264
+ reason: `verified ${age} day(s) ago, within the ${limit}-day limit for ${volatility} knowledge (UCS-1150)`,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * What a surface prints about whether time checks ran at all.
270
+ *
271
+ * Every projection that can demote on time must SAY whether it computed
272
+ * verdicts, and it must say so in one wording — a surface that phrased its own
273
+ * skip notice would eventually phrase it as silence. Mirrors the audit's
274
+ * `checks['stale-last-verified']` string exactly, so a reader who has seen one
275
+ * recognizes the other.
276
+ *
277
+ * @param {string|null} today the injected date, or null
278
+ * @returns {string}
279
+ */
280
+ export const timeCheckStatus = (today) => (today
281
+ ? `checked against --today ${today} (stale after ${VOLATILITY_LIMITS.stable} days for stable, ${VOLATILITY_LIMITS.volatile} for volatile; static never stales)`
282
+ : 'skipped — pass --today YYYY-MM-DD to enable; diffable output never reads the wall clock (D-012)');
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The caller spelled the command wrong (UCS-944/948).
3
+ *
4
+ * A leaf module on purpose. `lib/cli.js` re-exports it for the shells, and the
5
+ * store loader raises a subclass of it — a `--concepts` id the ontology does
6
+ * not carry is a usage error, not an engine failure. Keeping the class here
7
+ * means the loader never has to import the command-line shell to say so.
8
+ *
9
+ * Every usage error exits 2. Never 1: exit 1 means findings, and a command that
10
+ * refused its own arguments never ran.
11
+ */
12
+ export class UsageError extends Error {
13
+ name = 'UsageError';
14
+ }