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,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight verdict module (KK-26) — the session gate (PRD §4, D-011). Joins
|
|
3
|
+
* the KK-04 loader's diagnostics and the KK-05/KK-07 validator results to the
|
|
4
|
+
* concepts a task resolved, emitting one deterministic verdict per concept
|
|
5
|
+
* plus a next action. JSON-first; the engine computes verdicts ONLY — conduct
|
|
6
|
+
* on a verdict (quarantine-and-continue, fail-stop, …) is protocol-layer
|
|
7
|
+
* policy the client owns (KK-20, D-011).
|
|
8
|
+
*
|
|
9
|
+
* node payload/engine/preflight.js [--concepts <ids>] [--leaves <ids>] [--json]
|
|
10
|
+
* [--root <dir>] [--today <YYYY-MM-DD>] [--log]
|
|
11
|
+
*
|
|
12
|
+
* `--leaves` is the LEAF-facing surface (UCS-1149), and it exists because
|
|
13
|
+
* frontmatter v2 gave leaves a promotion stage. A `stage: draft` leaf must
|
|
14
|
+
* yield an unknown-class verdict through the SAME `isPrePromotionStatus`
|
|
15
|
+
* predicate that downranks it in the resolver — otherwise the two surfaces
|
|
16
|
+
* could disagree about which leaves are provisional, which is exactly the
|
|
17
|
+
* divergence one shared predicate exists to prevent. Leaf verdicts land in
|
|
18
|
+
* `leaf-verdicts` and are counted with the concept ones, so a quarantined leaf
|
|
19
|
+
* gates the run; a `--concepts`-only invocation is byte-identical to before.
|
|
20
|
+
*
|
|
21
|
+
* Verdicts (derived, never cached — a stale "trusted" is a false all-clear,
|
|
22
|
+
* D-011; every run recomputes from the working tree):
|
|
23
|
+
* trusted every check attributable to the concept ran and ran clean
|
|
24
|
+
* quarantined the structural (KK-05) or value (KK-07) checks yield
|
|
25
|
+
* error-severity findings or hard errors attributable to the
|
|
26
|
+
* concept — do not rely on it until the evidence is fixed
|
|
27
|
+
* unknown the checks could not certify anything: draft/proposed status
|
|
28
|
+
* (§3.5: structural checks only, value checks skipped), a leaf
|
|
29
|
+
* under time governance whose freshness could not be computed
|
|
30
|
+
* (UCS-1150 — no `verified` date, or no injected --today), or a
|
|
31
|
+
* store-wide failure (loader error-severity diagnostics), which
|
|
32
|
+
* degrades ALL requested verdicts to unknown — no check ran
|
|
33
|
+
* stale LEAVES ONLY (UCS-1150): the leaf's age exceeds the pinned
|
|
34
|
+
* limit for its volatility class — 365 days for `stable`, 90 for
|
|
35
|
+
* `volatile`; `static` never stales. Its own class rather than a
|
|
36
|
+
* mapping onto the others, because nothing about a stale leaf is
|
|
37
|
+
* broken (quarantined) and its checks DID run and returned a
|
|
38
|
+
* definite answer (unknown). The action is re-verification
|
|
39
|
+
* against the cited sources, which neither of those would say.
|
|
40
|
+
*
|
|
41
|
+
* `--today <YYYY-MM-DD>` is what time verdicts are measured against; the engine
|
|
42
|
+
* never reads the wall clock (D-012). Without it, a time-governed leaf verdicts
|
|
43
|
+
* unknown rather than trusted, and the `time-check` line in the output says the
|
|
44
|
+
* check was skipped — a check that never ran is never a silent pass.
|
|
45
|
+
*
|
|
46
|
+
* Exit codes (PRD §5, lib/exit-codes.js): 0 = all trusted, 1 = quarantines or
|
|
47
|
+
* stale verdicts present, 2 = engine failure / check-never-ran. Any unknown
|
|
48
|
+
* verdict gates at 2 — a check that never ran is a blocking defect, never a
|
|
49
|
+
* silent pass; only an all-trusted run may read as clean. A stale verdict gates
|
|
50
|
+
* at 1 rather than 2, because the check ran: rotted knowledge is a finding to
|
|
51
|
+
* fix, not a broken engine. An id --concepts names that the ontology does not
|
|
52
|
+
* carry is exit 2 for the same reason a never-run check is: a verdict on a typo
|
|
53
|
+
* must never read as anything.
|
|
54
|
+
*
|
|
55
|
+
* Empty or omitted --concepts = store-health-only validation: the run exits
|
|
56
|
+
* on the store verdict alone (trusted when the loader's single health model
|
|
57
|
+
* is clean, unknown/exit 2 otherwise) and computes no per-concept checks.
|
|
58
|
+
*
|
|
59
|
+
* Quarantine finding trigger (KK-13, engine-attributed): with `--log`, every
|
|
60
|
+
* quarantined verdict appends one open finding fragment to logs/findings/ via
|
|
61
|
+
* the KK-13 helper (one file per entry, D-010). `--log` requires `--today`
|
|
62
|
+
* because the helper never reads the wall clock (PRD §5) — diffable output
|
|
63
|
+
* stays date-injected. Capture content policy (§3.4): the summary carries
|
|
64
|
+
* concept IDs, finding codes, and file paths ONLY — never verbatim user text.
|
|
65
|
+
*
|
|
66
|
+
* Reuses the exported check logic — runChecks (validate.js), validateValues
|
|
67
|
+
* (validate-values.js) — over one loadStores model: the single-health-model
|
|
68
|
+
* guarantee that preflight and the validators can never disagree. Output is
|
|
69
|
+
* deterministic and stable-sorted (verdicts by concept id), no wall-clock
|
|
70
|
+
* timestamps; only --log introduces fragment file names (random suffixes are
|
|
71
|
+
* the D-010 id space, and they live in `logged`, written on request only).
|
|
72
|
+
*/
|
|
73
|
+
import process from 'node:process';
|
|
74
|
+
import { resolve } from 'node:path';
|
|
75
|
+
import { fileURLToPath } from 'node:url';
|
|
76
|
+
import { healthSummary, loadStores, isPrePromotionStatus, leafIdentityOf, leafStage, normalizeConceptIds, selectConcepts, selectLeaves, storeHealth, UnknownConceptsError, UnknownLeavesError } from '../lib/load-stores.js';
|
|
77
|
+
import { locateKitRoot } from '../lib/kit-root.js';
|
|
78
|
+
import { EXIT_CODES } from '../lib/exit-codes.js';
|
|
79
|
+
import { UsageError, parseArgs as parseFlags, rethrowIfBug } from '../lib/cli.js';
|
|
80
|
+
import { compare } from '../lib/validate-record.js';
|
|
81
|
+
import { createEntry } from '../lib/log-entry.js';
|
|
82
|
+
import { runChecks } from './validate.js';
|
|
83
|
+
import { validateValues } from './validate-values.js';
|
|
84
|
+
import { isCalendarDate } from '../lib/iso-date.js';
|
|
85
|
+
// The Time facet (UCS-1150) — the same verdict function the resolver ranks on,
|
|
86
|
+
// so a leaf demoted stale there is never verdicted trusted here.
|
|
87
|
+
import { TIME_VERDICTS, timeCheckStatus, timeVerdict } from '../lib/time-verdicts.js';
|
|
88
|
+
|
|
89
|
+
export const USAGE = 'usage: node payload/engine/preflight.js [--concepts <ids>] [--leaves <ids>] [--json] [--root <dir>] [--today <YYYY-MM-DD>] [--log]';
|
|
90
|
+
|
|
91
|
+
/** finding.schema.json conceptRef — `consulted` only carries conforming ids. */
|
|
92
|
+
const CONCEPT_REF = /^K-[0-9]+$/;
|
|
93
|
+
|
|
94
|
+
/** The per-verdict next action (engine hint; conduct is protocol policy). */
|
|
95
|
+
const NEXT_ACTIONS = Object.freeze({
|
|
96
|
+
trusted: 'proceed — this verdict was computed fresh this run; never cache it (a stale "trusted" is a false all-clear, D-011)',
|
|
97
|
+
quarantined: 'treat the concept as untrusted and fix the error-severity evidence, then re-run preflight — what a session does meanwhile (quarantine-and-continue vs. fail-stop) is protocol-layer policy (KK-20, D-011)',
|
|
98
|
+
'unknown-status': 'do not rely on the enumerated values — only structural checks ran (§3.5); promote the concept to active to make its checks blocking-grade, or verify against the source-of-truth directly',
|
|
99
|
+
'unknown-stage': 'do not rely on this leaf — a pre-promotion stage means no moderator has verified its citations (UCS-1149); read the cited sources directly, or have the leaf promoted to a verified stage',
|
|
100
|
+
'unknown-store': 'repair the store first (fix the loader error diagnostics), then re-run preflight — no check ran for this concept, and a check that never ran is a blocking defect, never a silent pass (PRD §5)',
|
|
101
|
+
// The Time facet (UCS-1150). A stale leaf is not broken and its checks did
|
|
102
|
+
// run — the action is re-verification against the sources, which is a
|
|
103
|
+
// steward's job rather than a repair.
|
|
104
|
+
stale: 're-verify this leaf against its cited sources and update its `verified` date, or treat the claim as unverified — the knowledge is past the pinned freshness limit for its volatility class, so nothing currently vouches for it (UCS-1150)',
|
|
105
|
+
// A leaf that asked to be governed by time and gave nothing to measure from.
|
|
106
|
+
// Its verdict can only ever be `undated`, so the fix is the missing field.
|
|
107
|
+
'unknown-undated': 'add the `verified` date this leaf is missing — it declares a volatility class, so it is under time governance, but its age cannot be computed and its freshness can never be certified (UCS-1150); the validator reports the same omission as a missing-verified finding',
|
|
108
|
+
// No --today was injected, so no freshness verdict was computed at all.
|
|
109
|
+
'unknown-skipped': 'pass --today <YYYY-MM-DD> to compute time verdicts — this leaf declares a volatility class but nothing measured its age this run, and a check that never ran is never a silent pass (PRD §5, D-012)',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------- verdict joining
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Join both validators' results to the requested concepts — one verdict per
|
|
116
|
+
* concept. Only called on a healthy store (the store-wide degradation path
|
|
117
|
+
* never reaches the validators: their checks would not have run).
|
|
118
|
+
*/
|
|
119
|
+
function computeVerdicts(model, ids, repoRoot) {
|
|
120
|
+
const structural = runChecks(model, repoRoot);
|
|
121
|
+
const values = validateValues(model, null, repoRoot); // full run; attribution below
|
|
122
|
+
|
|
123
|
+
return selectConcepts(model, ids).map(({ id, record }) => {
|
|
124
|
+
const status = record.status ?? null;
|
|
125
|
+
// Evidence: error-severity findings from either validator, plus value
|
|
126
|
+
// hard errors (unknown-kind, source-missing, …) — all attributable to
|
|
127
|
+
// this concept, all reasons not to trust it (quarantine, per §4).
|
|
128
|
+
const evidence = [
|
|
129
|
+
...structural
|
|
130
|
+
.filter((f) => f.id === id && f.severity === 'error')
|
|
131
|
+
.map(({ code, file, path, message }) => ({ check: 'structural', code, severity: 'error', file, path, message })),
|
|
132
|
+
...values.findings
|
|
133
|
+
.filter((f) => f.concept === id && f.severity === 'error')
|
|
134
|
+
.map(({ code, file, path, source, value, message }) => ({ check: 'value', code, severity: 'error', file, path, ...(source ? { source } : {}), ...(value !== undefined ? { value } : {}), message })),
|
|
135
|
+
...values.hardErrors
|
|
136
|
+
.filter((e) => e.concept === id)
|
|
137
|
+
.map(({ code, file, path, source, message }) => ({ check: 'value', code, severity: 'hard-error', file, path, ...(source ? { source } : {}), message })),
|
|
138
|
+
].sort((a, b) => compare(a.check, b.check) || compare(a.path ?? '', b.path ?? '')
|
|
139
|
+
|| compare(a.code, b.code) || compare(a.value ?? '', b.value ?? ''));
|
|
140
|
+
|
|
141
|
+
if (evidence.length) {
|
|
142
|
+
return {
|
|
143
|
+
concept: id, status, verdict: 'quarantined',
|
|
144
|
+
reason: `${evidence.length} error-severity check result(s) attributable to this concept — see evidence`,
|
|
145
|
+
'next-action': NEXT_ACTIONS.quarantined,
|
|
146
|
+
evidence,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (isPrePromotionStatus(status)) {
|
|
150
|
+
return {
|
|
151
|
+
concept: id, status, verdict: 'unknown',
|
|
152
|
+
reason: `status "${status}" — structural checks only (§3.5); the value checks were skipped, so nothing certifies the claims`,
|
|
153
|
+
'next-action': NEXT_ACTIONS['unknown-status'],
|
|
154
|
+
evidence,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
concept: id, status, verdict: 'trusted',
|
|
159
|
+
reason: 'every attributable check ran clean this run',
|
|
160
|
+
'next-action': NEXT_ACTIONS.trusted,
|
|
161
|
+
evidence,
|
|
162
|
+
};
|
|
163
|
+
}).sort((a, b) => compare(a.concept, b.concept));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Leaf verdicts (UCS-1149) — the same three verdicts, computed for leaves.
|
|
168
|
+
*
|
|
169
|
+
* A leaf earns a verdict on the same two questions a concept does, asked of the
|
|
170
|
+
* evidence a leaf actually has:
|
|
171
|
+
*
|
|
172
|
+
* quarantined error-severity structural findings attributable to this leaf —
|
|
173
|
+
* an unminted facet value, a citation with no authority tier, a
|
|
174
|
+
* cross-reference that does not resolve. There is no value-check
|
|
175
|
+
* half: value checks diff a descriptor against source code, and
|
|
176
|
+
* a leaf carries no descriptor. Its evidence is structural only,
|
|
177
|
+
* which is stated rather than silently implied by an empty list.
|
|
178
|
+
* unknown `facets.stage` is pre-promotion — the SAME predicate the
|
|
179
|
+
* concept path calls, so a draft leaf and a draft concept cannot
|
|
180
|
+
* be verdicted differently by two surfaces that both think they
|
|
181
|
+
* are asking one question. This is preflight's half of the
|
|
182
|
+
* draft-stage contract; the resolver's half is the downrank.
|
|
183
|
+
* trusted neither.
|
|
184
|
+
*
|
|
185
|
+
* Attribution is by the finding's `id`, which for a leaf is its identity — its
|
|
186
|
+
* accession (UCS-1142/1147) — so a leaf is matched by the same string the
|
|
187
|
+
* validator names it by, not by a second guess at its id space.
|
|
188
|
+
*/
|
|
189
|
+
function computeLeafVerdicts(model, ids, repoRoot, today) {
|
|
190
|
+
const structural = runChecks(model, repoRoot);
|
|
191
|
+
|
|
192
|
+
return selectLeaves(model, ids).map((entry) => {
|
|
193
|
+
const id = entry.identity;
|
|
194
|
+
const stage = leafStage(entry.record);
|
|
195
|
+
const time = timeVerdict(entry.record, today);
|
|
196
|
+
const base = { leaf: id, stage, time };
|
|
197
|
+
const evidence = structural
|
|
198
|
+
.filter((f) => f.id === id && f.severity === 'error')
|
|
199
|
+
.map(({ code, file, path, message }) => ({ check: 'structural', code, severity: 'error', file, path, message }))
|
|
200
|
+
.sort((a, b) => compare(a.path ?? '', b.path ?? '') || compare(a.code, b.code));
|
|
201
|
+
|
|
202
|
+
if (evidence.length) {
|
|
203
|
+
return {
|
|
204
|
+
...base, verdict: 'quarantined',
|
|
205
|
+
reason: `${evidence.length} error-severity check result(s) attributable to this leaf — see evidence`,
|
|
206
|
+
'next-action': NEXT_ACTIONS.quarantined,
|
|
207
|
+
evidence,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (isPrePromotionStatus(stage)) {
|
|
211
|
+
return {
|
|
212
|
+
...base, verdict: 'unknown',
|
|
213
|
+
reason: `stage "${stage}" — this leaf is pre-promotion, so no moderator has certified its citations and nothing vouches for the claim`,
|
|
214
|
+
'next-action': NEXT_ACTIONS['unknown-stage'],
|
|
215
|
+
evidence,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// The Time facet (UCS-1150). Asked AFTER stage, because a leaf that no
|
|
219
|
+
// moderator has promoted is unverified for a reason that outranks its age:
|
|
220
|
+
// re-dating a draft would not make it trusted. A promoted leaf, though, is
|
|
221
|
+
// exactly the one whose freshness is the remaining question.
|
|
222
|
+
//
|
|
223
|
+
// `stale` is its OWN verdict class rather than a mapping onto `unknown` or
|
|
224
|
+
// `quarantined`, and the choice is the ticket's ("trusted/stale verdicts").
|
|
225
|
+
// The existing three each mean something a stale leaf is not: nothing about
|
|
226
|
+
// it is broken (quarantined), and its checks did run and returned a
|
|
227
|
+
// definite answer (unknown). Folding it into either would tell a steward to
|
|
228
|
+
// do the wrong thing — repair evidence that is fine, or pass a flag they
|
|
229
|
+
// already passed — and would make the leaf-verdicts surface dishonest about
|
|
230
|
+
// what it computed. It gates like the others: only trusted reads as clean.
|
|
231
|
+
if (time.stale) {
|
|
232
|
+
return {
|
|
233
|
+
...base, verdict: 'stale',
|
|
234
|
+
reason: time.reason,
|
|
235
|
+
'next-action': NEXT_ACTIONS.stale,
|
|
236
|
+
evidence,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// A leaf under time governance whose freshness could not be computed is
|
|
240
|
+
// NOT trusted. Two ways that happens, and they need different actions: the
|
|
241
|
+
// leaf is missing its date (`undated`), or this run never injected one
|
|
242
|
+
// (`skipped`). Both are unknown-class — a check that never ran is never a
|
|
243
|
+
// silent pass — and each says which fix applies.
|
|
244
|
+
if (time.verdict === TIME_VERDICTS.UNDATED || time.verdict === TIME_VERDICTS.SKIPPED) {
|
|
245
|
+
return {
|
|
246
|
+
...base, verdict: 'unknown',
|
|
247
|
+
reason: time.reason,
|
|
248
|
+
'next-action': NEXT_ACTIONS[`unknown-${time.verdict}`],
|
|
249
|
+
evidence,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...base, verdict: 'trusted',
|
|
254
|
+
reason: 'every attributable check ran clean this run',
|
|
255
|
+
'next-action': NEXT_ACTIONS.trusted,
|
|
256
|
+
evidence,
|
|
257
|
+
};
|
|
258
|
+
}).sort((a, b) => compare(a.leaf, b.leaf));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Store-wide failure: no check ran — every requested LEAF verdict is unknown.
|
|
263
|
+
*
|
|
264
|
+
* Ids are resolved through `leafIdentityOf`, the same lookup the healthy path's
|
|
265
|
+
* `selectLeaves` uses, so a leaf that IS in the store reports under its own
|
|
266
|
+
* identity whether the store loaded clean or not. An id that resolves to
|
|
267
|
+
* nothing keys on the caller's spelling instead — see below.
|
|
268
|
+
*/
|
|
269
|
+
function degradeAllLeaves(model, ids, today) {
|
|
270
|
+
const errors = storeHealth(model).errorCount;
|
|
271
|
+
// De-duplicated by IDENTITY, like selectLeaves: naming one leaf twice is one
|
|
272
|
+
// leaf, and emitting two verdict rows for it would have a caller reconciling
|
|
273
|
+
// two answers about a single record. An
|
|
274
|
+
// id that resolves to nothing keys on the caller's spelling instead — on a
|
|
275
|
+
// store this broken the leaf may simply have failed to load, so echoing back
|
|
276
|
+
// what was asked for is more honest than inventing an identity, and two
|
|
277
|
+
// distinct unresolved ids stay two rows.
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const id of ids) {
|
|
281
|
+
const identity = leafIdentityOf(model, id) ?? id;
|
|
282
|
+
if (seen.has(identity)) continue;
|
|
283
|
+
seen.add(identity);
|
|
284
|
+
const record = model.leaves.get(identity)?.record;
|
|
285
|
+
out.push({
|
|
286
|
+
leaf: identity, stage: leafStage(record),
|
|
287
|
+
// The time verdict travels on the degraded path too, computed from
|
|
288
|
+
// whatever loaded. A key that vanished on a broken store would make a
|
|
289
|
+
// consumer's presence check mean two things at once.
|
|
290
|
+
time: timeVerdict(record, today),
|
|
291
|
+
verdict: 'unknown',
|
|
292
|
+
reason: `store-wide failure: the loader reported ${errors} error(s) — no check ran for any leaf (single health model, PRD §4)`,
|
|
293
|
+
'next-action': NEXT_ACTIONS['unknown-store'],
|
|
294
|
+
evidence: [],
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return out.sort((a, b) => compare(a.leaf, b.leaf));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Store-wide failure: no check ran — every requested verdict is unknown. */
|
|
301
|
+
function degradeAll(model, ids) {
|
|
302
|
+
const errors = storeHealth(model).errorCount;
|
|
303
|
+
return ids.map((id) => ({
|
|
304
|
+
concept: id, status: model.concepts.get(id)?.record.status ?? null, verdict: 'unknown',
|
|
305
|
+
reason: `store-wide failure: the loader reported ${errors} error(s) — no check ran for any concept (single health model, PRD §4)`,
|
|
306
|
+
'next-action': NEXT_ACTIONS['unknown-store'],
|
|
307
|
+
evidence: [],
|
|
308
|
+
})).sort((a, b) => compare(a.concept, b.concept));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -------------------------------------------- quarantine findings (KK-13)
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Append one engine-attributed quarantine finding per quarantined concept
|
|
315
|
+
* (capture content policy §3.4: concept ids, codes, and paths only).
|
|
316
|
+
* Returns the root-relative fragment paths, sorted.
|
|
317
|
+
*/
|
|
318
|
+
function logQuarantines(root, verdicts, today) {
|
|
319
|
+
const logged = [];
|
|
320
|
+
for (const v of verdicts) {
|
|
321
|
+
if (v.verdict !== 'quarantined') continue;
|
|
322
|
+
const codes = [...new Set(v.evidence.map((e) => e.code))].sort(compare);
|
|
323
|
+
const paths = [...new Set(v.evidence.flatMap((e) => [e.file, e.source]).filter(Boolean))].sort(compare);
|
|
324
|
+
const { file } = createEntry({
|
|
325
|
+
root, log: 'findings', date: today,
|
|
326
|
+
fields: {
|
|
327
|
+
trigger: 'quarantine',
|
|
328
|
+
session: 'engine/preflight.js',
|
|
329
|
+
summary: `preflight quarantined ${v.concept}: ${codes.join(', ')} (${paths.join(', ')})`,
|
|
330
|
+
...(CONCEPT_REF.test(v.concept) ? { consulted: { concepts: [v.concept] } } : {}),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
logged.push(file);
|
|
334
|
+
}
|
|
335
|
+
return logged.sort(compare);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ------------------------------------------------------------- CLI plumbing
|
|
339
|
+
|
|
340
|
+
function parseArgs(argv) {
|
|
341
|
+
const { options } = parseFlags(argv, {
|
|
342
|
+
boolean: ['json', 'log'],
|
|
343
|
+
value: ['root', 'today'],
|
|
344
|
+
repeatable: ['concepts', 'leaves'],
|
|
345
|
+
// PRD §7: an explicitly empty --concepts selects store-health-only.
|
|
346
|
+
allowEmpty: ['concepts', 'leaves'],
|
|
347
|
+
});
|
|
348
|
+
const opts = {
|
|
349
|
+
json: !!options.json,
|
|
350
|
+
log: !!options.log,
|
|
351
|
+
root: options.root ?? process.cwd(),
|
|
352
|
+
today: options.today ?? null,
|
|
353
|
+
concepts: options.concepts ? normalizeConceptIds(options.concepts.flatMap((v) => v.split(','))) : null,
|
|
354
|
+
// Same grammar as --concepts, deliberately: an id list is an id list, and
|
|
355
|
+
// two surfaces that trimmed arguments differently is the divergence
|
|
356
|
+
// normalizeConceptIds was written to end (UCS-935).
|
|
357
|
+
leaves: options.leaves ? normalizeConceptIds(options.leaves.flatMap((v) => v.split(','))) : null,
|
|
358
|
+
};
|
|
359
|
+
if (opts.today !== null && !isCalendarDate(opts.today)) {
|
|
360
|
+
// --log writes --today into permanent fragments, so a date that does not
|
|
361
|
+
// exist would be stamped into an audit trail forever.
|
|
362
|
+
throw new UsageError(`--today must be a real calendar date (YYYY-MM-DD), got ${JSON.stringify(opts.today)}`);
|
|
363
|
+
}
|
|
364
|
+
if (opts.log && !opts.today) {
|
|
365
|
+
throw new UsageError('--log requires --today <YYYY-MM-DD> — the finding helper never reads the wall clock (PRD §5)');
|
|
366
|
+
}
|
|
367
|
+
return opts;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function renderHuman(payload) {
|
|
371
|
+
const lines = [];
|
|
372
|
+
const { counts } = payload;
|
|
373
|
+
const leafVerdicts = payload['leaf-verdicts'] ?? [];
|
|
374
|
+
if (payload.mode === 'store-health') {
|
|
375
|
+
lines.push(`preflight (store-health only — no --concepts/--leaves): store verdict ${payload['store-verdict']}`);
|
|
376
|
+
} else {
|
|
377
|
+
// One counted subject line over both record kinds, matching `counts`: a
|
|
378
|
+
// header that tallied only concepts would disagree with the exit code the
|
|
379
|
+
// moment a leaf was quarantined.
|
|
380
|
+
const subjects = [
|
|
381
|
+
payload.verdicts.length ? `${payload.verdicts.length} concept(s)` : null,
|
|
382
|
+
leafVerdicts.length ? `${leafVerdicts.length} leaf/leaves` : null,
|
|
383
|
+
].filter(Boolean).join(' + ');
|
|
384
|
+
lines.push(
|
|
385
|
+
`preflight: ${subjects} — ${counts.trusted} trusted, `
|
|
386
|
+
+ `${counts.quarantined} quarantined, ${counts.stale} stale, ${counts.unknown} unknown `
|
|
387
|
+
+ `(store verdict ${payload['store-verdict']})`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
// Whether time verdicts ran at all — printed whenever leaves were asked
|
|
391
|
+
// about, computed or not. A skipped check that said nothing would read
|
|
392
|
+
// exactly like a check that passed (PRD §5).
|
|
393
|
+
if (payload['time-check']) lines.push(`time check: ${payload['time-check']}`);
|
|
394
|
+
for (const d of payload['store-errors'] ?? []) {
|
|
395
|
+
lines.push(` store error ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''}`, ` ${d.message}`);
|
|
396
|
+
}
|
|
397
|
+
for (const v of payload.verdicts) {
|
|
398
|
+
lines.push('', `${v.verdict.toUpperCase()} ${v.concept}${v.status ? ` (${v.status})` : ''}`, ` ${v.reason}`);
|
|
399
|
+
for (const e of v.evidence) {
|
|
400
|
+
lines.push(` ${e.severity === 'hard-error' ? 'HARD ERROR' : 'error'} ${e.code} ${e.file} ${e.path}${e.source ? ` (source: ${e.source})` : ''}`);
|
|
401
|
+
}
|
|
402
|
+
lines.push(` next: ${v['next-action']}`);
|
|
403
|
+
}
|
|
404
|
+
for (const v of leafVerdicts) {
|
|
405
|
+
// The time verdict rides the subject line beside the stage: both are
|
|
406
|
+
// properties of the leaf a reader judges it by, and a stale leaf must say
|
|
407
|
+
// so where its verdict is read rather than only in the tally.
|
|
408
|
+
const time = v.time ? ` (time: ${v.time.verdict}${v.time.volatility ? `, ${v.time.volatility}` : ''}${v.time.age === null ? '' : `, ${v.time.age}d`})` : '';
|
|
409
|
+
lines.push('', `${v.verdict.toUpperCase()} ${v.leaf}${v.stage ? ` (stage: ${v.stage})` : ''}${time}`, ` ${v.reason}`);
|
|
410
|
+
for (const e of v.evidence) {
|
|
411
|
+
lines.push(` error ${e.code} ${e.file} ${e.path}`);
|
|
412
|
+
}
|
|
413
|
+
lines.push(` next: ${v['next-action']}`);
|
|
414
|
+
}
|
|
415
|
+
for (const file of payload.logged ?? []) {
|
|
416
|
+
lines.push('', `quarantine finding appended: ${file}`);
|
|
417
|
+
}
|
|
418
|
+
if (payload.ok) {
|
|
419
|
+
lines.push('', payload.mode === 'store-health'
|
|
420
|
+
? 'store health is clean — per-record verdicts need a --concepts or --leaves list'
|
|
421
|
+
: 'everything requested is trusted this run — verdicts are never cached (D-011)');
|
|
422
|
+
}
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function main(argv) {
|
|
427
|
+
{
|
|
428
|
+
const opts = parseArgs(argv);
|
|
429
|
+
|
|
430
|
+
let model;
|
|
431
|
+
try {
|
|
432
|
+
// --root is the repo root (§9.1), same as validate-values.js.
|
|
433
|
+
model = loadStores(locateKitRoot(opts.root));
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// An EXPECTED refusal from the loader — an unreadable root, an ambiguous
|
|
436
|
+
// kit layout, a Store that will not load. The stores this command would
|
|
437
|
+
// check never loaded, so its checks never ran: exit 2, never 1.
|
|
438
|
+
process.stderr.write(`preflight: ${error.message}\n`);
|
|
439
|
+
rethrowIfBug(error); // a bug, or a UsageError raised deep in the loader, is not ours to speak for
|
|
440
|
+
return EXIT_CODES.FAILURE;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// One read of the one authority; the wire shape is a projection of it.
|
|
444
|
+
const fullHealth = storeHealth(model);
|
|
445
|
+
const health = healthSummary(fullHealth);
|
|
446
|
+
const storeVerdict = model.ok ? 'trusted' : 'unknown';
|
|
447
|
+
const storeErrors = fullHealth.errors
|
|
448
|
+
.map(({ code, file, path, message }) => ({ code, file, path, message }));
|
|
449
|
+
|
|
450
|
+
// Empty/omitted --concepts AND --leaves: store-health-only — exit on the
|
|
451
|
+
// store verdict alone (§7); no per-record check runs, so no per-record
|
|
452
|
+
// verdict exists. Either flag alone selects that flag's records; both
|
|
453
|
+
// select both, because "which concepts" and "which leaves" are two
|
|
454
|
+
// questions and a run may legitimately ask one, the other, or both.
|
|
455
|
+
const wantConcepts = !!opts.concepts && opts.concepts.length > 0;
|
|
456
|
+
const wantLeaves = !!opts.leaves && opts.leaves.length > 0;
|
|
457
|
+
const storeHealthOnly = !wantConcepts && !wantLeaves;
|
|
458
|
+
|
|
459
|
+
let verdicts = [];
|
|
460
|
+
let leafVerdicts = [];
|
|
461
|
+
if (wantConcepts) {
|
|
462
|
+
// Store-wide failures degrade ALL requested verdicts to unknown: the
|
|
463
|
+
// validators' checks never ran over a store that failed to load, and a
|
|
464
|
+
// check that never ran is a blocking defect, never a silent pass.
|
|
465
|
+
verdicts = model.ok ? computeVerdicts(model, opts.concepts, opts.root) : degradeAll(model, opts.concepts);
|
|
466
|
+
}
|
|
467
|
+
if (wantLeaves) {
|
|
468
|
+
leafVerdicts = model.ok
|
|
469
|
+
? computeLeafVerdicts(model, opts.leaves, opts.root, opts.today)
|
|
470
|
+
: degradeAllLeaves(model, opts.leaves, opts.today);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Counted TOGETHER, over both verdict lists. Splitting the counts would let
|
|
474
|
+
// a run exit 0 on clean concepts while a requested leaf was quarantined —
|
|
475
|
+
// the gate reading as clean about the half of the question it liked.
|
|
476
|
+
const all = [...verdicts, ...leafVerdicts];
|
|
477
|
+
const counts = {
|
|
478
|
+
trusted: all.filter((v) => v.verdict === 'trusted').length,
|
|
479
|
+
quarantined: all.filter((v) => v.verdict === 'quarantined').length,
|
|
480
|
+
unknown: all.filter((v) => v.verdict === 'unknown').length,
|
|
481
|
+
// Counted separately (UCS-1150) so a stale leaf is visible in the tally
|
|
482
|
+
// rather than absorbed into a class that means something else. `ok`
|
|
483
|
+
// below still requires trusted === all.length, so a stale leaf gates.
|
|
484
|
+
stale: all.filter((v) => v.verdict === 'stale').length,
|
|
485
|
+
};
|
|
486
|
+
const logged = opts.log ? logQuarantines(model.root, verdicts, opts.today) : null;
|
|
487
|
+
|
|
488
|
+
const ok = storeHealthOnly
|
|
489
|
+
? storeVerdict === 'trusted'
|
|
490
|
+
: storeVerdict === 'trusted' && counts.trusted === all.length;
|
|
491
|
+
const payload = {
|
|
492
|
+
ok,
|
|
493
|
+
// The mode names what was ASKED. `leaves` and `concepts+leaves` are new
|
|
494
|
+
// (UCS-1149); a run that named only concepts reads exactly as it did
|
|
495
|
+
// before, so no existing consumer sees a shape it did not ask for.
|
|
496
|
+
mode: storeHealthOnly
|
|
497
|
+
? 'store-health'
|
|
498
|
+
: [wantConcepts ? 'concepts' : null, wantLeaves ? 'leaves' : null].filter(Boolean).join('+'),
|
|
499
|
+
'store-verdict': storeVerdict,
|
|
500
|
+
'store-health': health,
|
|
501
|
+
...(storeErrors.length ? { 'store-errors': storeErrors } : {}),
|
|
502
|
+
counts,
|
|
503
|
+
verdicts,
|
|
504
|
+
// Present only when leaves were asked about, for the same reason `mode`
|
|
505
|
+
// still says `concepts`: a --concepts-only run's JSON is unchanged.
|
|
506
|
+
// `time-check` rides the same condition — leaves are the only records the
|
|
507
|
+
// time facet governs, so a concepts-only run has no time check to report
|
|
508
|
+
// and inventing one would answer a question nobody asked. When leaves
|
|
509
|
+
// ARE asked about, it is always present: a run that computed no freshness
|
|
510
|
+
// verdicts must never look like one that checked and found them fresh.
|
|
511
|
+
...(wantLeaves ? { 'time-check': timeCheckStatus(opts.today), 'leaf-verdicts': leafVerdicts } : {}),
|
|
512
|
+
...(logged ? { logged } : {}),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const lines = opts.json ? [JSON.stringify(payload, null, 2)] : renderHuman(payload);
|
|
516
|
+
process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
|
|
517
|
+
|
|
518
|
+
// Exit-code contract (PRD §5, D-011): only all-trusted reads as clean.
|
|
519
|
+
// Any unknown — store-wide failure or a draft/proposed skip — gates at 2:
|
|
520
|
+
// its checks never ran, and that is a blocking defect, never exit 0.
|
|
521
|
+
if (storeHealthOnly) return storeVerdict === 'trusted' ? EXIT_CODES.CLEAN : EXIT_CODES.FAILURE;
|
|
522
|
+
if (storeVerdict !== 'trusted' || counts.unknown > 0) return EXIT_CODES.FAILURE;
|
|
523
|
+
// A stale verdict gates at 1, alongside quarantine, and NOT at 2 (UCS-1150).
|
|
524
|
+
// The distinction is the one the exit contract already draws: 2 means a
|
|
525
|
+
// check never ran, and the time check ran — it returned a definite answer
|
|
526
|
+
// a steward can act on. Rotted knowledge is a finding to fix, not a broken
|
|
527
|
+
// engine, and only an all-trusted run still reads as clean.
|
|
528
|
+
return counts.quarantined > 0 || counts.stale > 0 ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
|
|
529
|
+
}
|
|
530
|
+
}
|