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,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
|
+
}
|