rfphub-validate 0.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 +21 -0
- package/README.md +117 -0
- package/dist/chunk-QO7M2D7A.js +184 -0
- package/dist/chunk-QO7M2D7A.js.map +1 -0
- package/dist/cli.cjs +455 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +279 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +205 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +87 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The RFP Hub contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# rfphub-validate
|
|
2
|
+
|
|
3
|
+
CLI **and** typed library to validate funding opportunities against the
|
|
4
|
+
[RFP Hub Standard](https://github.com/The-RFP-Hub/the-rfp-hub/tree/main/packages/standard)
|
|
5
|
+
(JSON Schema, draft 2020-12). MIT licensed.
|
|
6
|
+
|
|
7
|
+
Built on `@the-rfp-hub/standard` — the schema is **not** vendored here; it comes from the
|
|
8
|
+
single source of truth.
|
|
9
|
+
|
|
10
|
+
## CLI (no install)
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx rfphub-validate opportunity.json
|
|
14
|
+
npx rfphub-validate ./exports/ # validates every *.json in the dir
|
|
15
|
+
cat opportunity.json | npx rfphub-validate -
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A JSON document may be a single opportunity object **or** an array of objects.
|
|
19
|
+
|
|
20
|
+
| Option | Description |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `--spec <version>` | Standard version to validate against (default: bundled) |
|
|
23
|
+
| `--list-specs` | List bundled spec versions |
|
|
24
|
+
| `--json` | Emit a machine-readable JSON report |
|
|
25
|
+
| `--strict` | Treat advisory warnings as failures |
|
|
26
|
+
| `-q, --quiet` | Only print failures, warnings and the summary |
|
|
27
|
+
| `-h, --help` | Show help |
|
|
28
|
+
|
|
29
|
+
Exit codes: `0` all valid · `1` one or more invalid (or, with `--strict`, any warning) ·
|
|
30
|
+
`2` usage/IO/parse error.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx rfphub-validate ./data/ || exit 1 # CI gate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Two tiers: errors and warnings
|
|
37
|
+
|
|
38
|
+
**Schema errors** are hard conformance failures. **Advisory warnings** cover what the schema
|
|
39
|
+
deliberately leaves open, and never make a document non-conformant on their own:
|
|
40
|
+
|
|
41
|
+
| Check | Fires when |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `unregistered-eligibility-key` | an `eligibility` key is not in `registries/eligibility-keys.json` |
|
|
44
|
+
| `unregistered-deadline-label` | a `deadlines[].label` is not in `registries/deadline-labels.json` |
|
|
45
|
+
| `unregistered-program-model` | `grant.programModel` is not in `registries/program-models.json` |
|
|
46
|
+
| `milestone-amount-without-currency` | a `milestones[].amount` is present with no `funding.currency` to denominate it |
|
|
47
|
+
|
|
48
|
+
The split is the point. A closed enum built from one publisher's vocabulary would force every
|
|
49
|
+
other publisher into it, so those fields stay open — and the registries would be documentation
|
|
50
|
+
nobody reads if nothing ever checked them. Text output is count-phrased ("3 of 40 entries use
|
|
51
|
+
an unregistered eligibility key") so the summary reads as coverage rather than noise. The last
|
|
52
|
+
check exists because its rule is real but crosses two objects, which JSON Schema cannot express:
|
|
53
|
+
a milestone amount MUST follow the top-level envelope currency, and warning is the only
|
|
54
|
+
enforcement that rule has.
|
|
55
|
+
|
|
56
|
+
Pass `--strict` in CI once your data is clean, to keep it clean.
|
|
57
|
+
|
|
58
|
+
## Library (typed)
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import {
|
|
62
|
+
validateOpportunity,
|
|
63
|
+
assertOpportunity,
|
|
64
|
+
humanizeErrors,
|
|
65
|
+
type Opportunity,
|
|
66
|
+
} from "rfphub-validate";
|
|
67
|
+
|
|
68
|
+
const { valid, errors, warnings } = validateOpportunity(input);
|
|
69
|
+
if (!valid) console.error(humanizeErrors(errors, input));
|
|
70
|
+
for (const w of warnings) console.warn(`${w.code} ${w.instancePath}: ${w.message}`);
|
|
71
|
+
|
|
72
|
+
// or narrow the type and throw on failure:
|
|
73
|
+
assertOpportunity(input); // input is now typed as Opportunity
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Exports: `validateOpportunity`, `assertOpportunity`, `createValidator` (inject a custom
|
|
77
|
+
schema), `humanizeError`/`humanizeErrors`, `checks`/`runChecks`/`entryPhrase` (the advisory
|
|
78
|
+
tier), `SPEC_VERSION`, and the `Opportunity` type (re-exported from `@the-rfp-hub/standard`).
|
|
79
|
+
|
|
80
|
+
### Error messages
|
|
81
|
+
|
|
82
|
+
`errors` are raw ajv `ErrorObject`s. `humanizeErrors(errors, instance)` renders them as lines
|
|
83
|
+
naming the rule that failed. **Pass the instance as the second argument** where you have it —
|
|
84
|
+
it is optional, but without it the one-block-per-`fundingType` rule cannot name the block that
|
|
85
|
+
should not be there:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
(root) carries a type block that does not match fundingType: 'rfp'. Only the 'grant' block may be present.
|
|
89
|
+
(root) must NOT have additional properties: 'chainID'
|
|
90
|
+
/status must be equal to one of the allowed values: upcoming, open, closed, archived
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Two things happen behind that. ajv reports the schema's `not` construct as `must NOT be valid`,
|
|
94
|
+
which is true and useless, so it is replaced with the rule's actual name; and ajv reports every
|
|
95
|
+
`if`/`then` failure twice — once for the real constraint and once for a wrapper reading
|
|
96
|
+
`must match "then" schema` — so the wrapper is dropped. The CLI does both automatically.
|
|
97
|
+
|
|
98
|
+
## How it validates
|
|
99
|
+
|
|
100
|
+
ajv (`ajv/dist/2020`) + `ajv-formats` with the configuration the standard is authored
|
|
101
|
+
against: draft 2020-12, `strict: true`, `strictRequired: false` (so the conditional
|
|
102
|
+
type-block pattern — `opportunity[opportunity.fundingType]` — is permitted), plus the
|
|
103
|
+
standard's `x-stability` / `x-since` / `x-deprecated` annotation keywords declared so strict
|
|
104
|
+
mode accepts them. The same `createValidator()` is reused by the API and tests, so validation
|
|
105
|
+
is identical everywhere.
|
|
106
|
+
|
|
107
|
+
The test suite runs the standard's own conformance suite
|
|
108
|
+
(`@the-rfp-hub/standard/conformance/v1.0.0/{pass,fail}/`) rather than private fixtures, so the
|
|
109
|
+
reference implementation is held to exactly the contract external implementers are given.
|
|
110
|
+
|
|
111
|
+
## Develop
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pnpm install
|
|
115
|
+
pnpm --filter rfphub-validate build
|
|
116
|
+
pnpm --filter rfphub-validate test
|
|
117
|
+
```
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { opportunitySchema, isRegistered, activeValues, SPEC_VERSION } from '@rfp-hub/standard';
|
|
2
|
+
export { SPEC_VERSION } from '@rfp-hub/standard';
|
|
3
|
+
import addFormats from 'ajv-formats';
|
|
4
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
5
|
+
|
|
6
|
+
// src/checks/types.ts
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/checks/currency.ts
|
|
12
|
+
var milestoneAmountWithoutCurrency = {
|
|
13
|
+
code: "milestone-amount-without-currency",
|
|
14
|
+
entryPhrase: "carry a milestone amount with no funding.currency to denominate it",
|
|
15
|
+
run(entry) {
|
|
16
|
+
const milestones = entry.milestones;
|
|
17
|
+
if (!Array.isArray(milestones)) return [];
|
|
18
|
+
const funding = entry.funding;
|
|
19
|
+
const currency = isRecord(funding) ? funding.currency : void 0;
|
|
20
|
+
if (typeof currency === "string" && currency.length > 0) return [];
|
|
21
|
+
const out = [];
|
|
22
|
+
milestones.forEach((milestone, i) => {
|
|
23
|
+
if (!isRecord(milestone)) return;
|
|
24
|
+
if (typeof milestone.amount !== "number") return;
|
|
25
|
+
out.push({
|
|
26
|
+
code: this.code,
|
|
27
|
+
instancePath: `/milestones/${i}/amount`,
|
|
28
|
+
message: `milestone amount ${milestone.amount} has no funding.currency to denominate it; milestone amounts must follow the top-level envelope currency`
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var shortList = (name) => activeValues(name).join(", ");
|
|
35
|
+
var unregisteredEligibilityKey = {
|
|
36
|
+
code: "unregistered-eligibility-key",
|
|
37
|
+
entryPhrase: "use an unregistered eligibility key",
|
|
38
|
+
run(entry) {
|
|
39
|
+
const eligibility = entry.eligibility;
|
|
40
|
+
if (!isRecord(eligibility)) return [];
|
|
41
|
+
return Object.keys(eligibility).filter((key) => !isRegistered("eligibility-keys", key)).map((key) => ({
|
|
42
|
+
code: this.code,
|
|
43
|
+
instancePath: `/eligibility/${key}`,
|
|
44
|
+
message: `eligibility key '${key}' is not registered; conventional keys are ${shortList("eligibility-keys")}`
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var unregisteredDeadlineLabel = {
|
|
49
|
+
code: "unregistered-deadline-label",
|
|
50
|
+
entryPhrase: "use an unregistered deadline label",
|
|
51
|
+
run(entry) {
|
|
52
|
+
const deadlines = entry.deadlines;
|
|
53
|
+
if (!Array.isArray(deadlines)) return [];
|
|
54
|
+
const out = [];
|
|
55
|
+
deadlines.forEach((deadline, i) => {
|
|
56
|
+
if (!isRecord(deadline)) return;
|
|
57
|
+
const label = deadline.label;
|
|
58
|
+
if (typeof label !== "string" || label.length === 0) return;
|
|
59
|
+
if (isRegistered("deadline-labels", label)) return;
|
|
60
|
+
out.push({
|
|
61
|
+
code: this.code,
|
|
62
|
+
instancePath: `/deadlines/${i}/label`,
|
|
63
|
+
message: `deadline label '${label}' is not registered; conventional labels are ${shortList("deadline-labels")}`
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var unregisteredProgramModel = {
|
|
70
|
+
code: "unregistered-program-model",
|
|
71
|
+
entryPhrase: "use an unregistered grant.programModel",
|
|
72
|
+
run(entry) {
|
|
73
|
+
const grant = entry.grant;
|
|
74
|
+
if (!isRecord(grant)) return [];
|
|
75
|
+
const model = grant.programModel;
|
|
76
|
+
if (typeof model !== "string" || model.length === 0) return [];
|
|
77
|
+
if (isRegistered("program-models", model)) return [];
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
code: this.code,
|
|
81
|
+
instancePath: "/grant/programModel",
|
|
82
|
+
message: `grant.programModel '${model}' is not registered; conventional values are ${shortList("program-models")}`
|
|
83
|
+
}
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/checks/index.ts
|
|
89
|
+
var checks = [
|
|
90
|
+
unregisteredEligibilityKey,
|
|
91
|
+
unregisteredDeadlineLabel,
|
|
92
|
+
unregisteredProgramModel,
|
|
93
|
+
milestoneAmountWithoutCurrency
|
|
94
|
+
];
|
|
95
|
+
function runChecks(data) {
|
|
96
|
+
if (!isRecord(data)) return [];
|
|
97
|
+
return checks.flatMap((check) => check.run(data));
|
|
98
|
+
}
|
|
99
|
+
function entryPhrase(code) {
|
|
100
|
+
return checks.find((c) => c.code === code)?.entryPhrase ?? `raise ${code}`;
|
|
101
|
+
}
|
|
102
|
+
var TYPE_BLOCKS = (() => {
|
|
103
|
+
const props = opportunitySchema.properties;
|
|
104
|
+
const values = props?.fundingType?.enum;
|
|
105
|
+
return Array.isArray(values) ? values : [];
|
|
106
|
+
})();
|
|
107
|
+
function isRecord2(v) {
|
|
108
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
109
|
+
}
|
|
110
|
+
function isRedundantIfWrapper(e) {
|
|
111
|
+
return e.keyword === "if" && e.params.failingKeyword != null;
|
|
112
|
+
}
|
|
113
|
+
function explainNot(e, data) {
|
|
114
|
+
if (e.keyword !== "not") return void 0;
|
|
115
|
+
if (!/^#\/allOf\/\d+\/then\/not$/.test(e.schemaPath)) return void 0;
|
|
116
|
+
const declared = isRecord2(data) && typeof data.fundingType === "string" ? data.fundingType : null;
|
|
117
|
+
const extras = isRecord2(data) ? TYPE_BLOCKS.filter((k) => k !== declared && Object.hasOwn(data, k)) : [];
|
|
118
|
+
const offending = extras.length > 0 ? `: ${extras.map((k) => `'${k}'`).join(", ")}` : "";
|
|
119
|
+
const expected = declared ? ` Only the '${declared}' block may be present.` : "";
|
|
120
|
+
return `carries a type block that does not match fundingType${offending}.${expected}`;
|
|
121
|
+
}
|
|
122
|
+
function humanizeError(e, data) {
|
|
123
|
+
const where = e.instancePath?.length ? e.instancePath : "(root)";
|
|
124
|
+
const notMessage = explainNot(e, data);
|
|
125
|
+
if (notMessage) return `${where} ${notMessage}`;
|
|
126
|
+
let msg = e.message ?? "is invalid";
|
|
127
|
+
if (e.keyword === "additionalProperties") {
|
|
128
|
+
const { additionalProperty } = e.params;
|
|
129
|
+
msg += `: '${additionalProperty}'`;
|
|
130
|
+
} else if (e.keyword === "enum") {
|
|
131
|
+
const { allowedValues } = e.params;
|
|
132
|
+
if (allowedValues) msg += `: ${allowedValues.join(", ")}`;
|
|
133
|
+
} else if (e.keyword === "const") {
|
|
134
|
+
const { allowedValue } = e.params;
|
|
135
|
+
if (allowedValue !== void 0) msg += `: ${JSON.stringify(allowedValue)}`;
|
|
136
|
+
}
|
|
137
|
+
return `${where} ${msg}`;
|
|
138
|
+
}
|
|
139
|
+
function humanizeErrors(errors, data) {
|
|
140
|
+
const meaningful = errors.filter((e) => !isRedundantIfWrapper(e));
|
|
141
|
+
const kept = meaningful.length > 0 ? meaningful : errors;
|
|
142
|
+
return kept.map((e) => humanizeError(e, data));
|
|
143
|
+
}
|
|
144
|
+
var ANNOTATION_KEYWORDS = ["x-stability", "x-since", "x-deprecated"];
|
|
145
|
+
function createValidator(schema = opportunitySchema) {
|
|
146
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
|
|
147
|
+
addFormats(ajv);
|
|
148
|
+
for (const keyword of ANNOTATION_KEYWORDS) ajv.addKeyword(keyword);
|
|
149
|
+
return ajv.compile(schema);
|
|
150
|
+
}
|
|
151
|
+
var _default;
|
|
152
|
+
function defaultValidator() {
|
|
153
|
+
if (!_default) _default = createValidator();
|
|
154
|
+
return _default;
|
|
155
|
+
}
|
|
156
|
+
function resolveValidator(opts) {
|
|
157
|
+
if (opts.validator) return opts.validator;
|
|
158
|
+
if (opts.spec && opts.spec !== SPEC_VERSION) {
|
|
159
|
+
throw new Error(`unsupported spec '${opts.spec}' (this build ships ${SPEC_VERSION})`);
|
|
160
|
+
}
|
|
161
|
+
return defaultValidator();
|
|
162
|
+
}
|
|
163
|
+
function validateOpportunity(data, opts = {}) {
|
|
164
|
+
const validate = resolveValidator(opts);
|
|
165
|
+
const valid = validate(data);
|
|
166
|
+
return {
|
|
167
|
+
valid,
|
|
168
|
+
errors: valid ? [] : validate.errors ?? [],
|
|
169
|
+
warnings: opts.checks === false ? [] : runChecks(data)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function assertOpportunity(data, opts = {}) {
|
|
173
|
+
const { valid, errors } = validateOpportunity(data, { ...opts, checks: false });
|
|
174
|
+
if (!valid) {
|
|
175
|
+
const summary = humanizeErrors(errors, data).join("; ");
|
|
176
|
+
const err = new Error(`invalid opportunity: ${summary}`);
|
|
177
|
+
err.errors = errors;
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export { assertOpportunity, checks, createValidator, entryPhrase, humanizeError, humanizeErrors, runChecks, validateOpportunity };
|
|
183
|
+
//# sourceMappingURL=chunk-QO7M2D7A.js.map
|
|
184
|
+
//# sourceMappingURL=chunk-QO7M2D7A.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/checks/types.ts","../src/checks/currency.ts","../src/checks/registry.ts","../src/checks/index.ts","../src/errors.ts","../src/validator.ts"],"names":["isRecord","opportunitySchema"],"mappings":"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","file":"chunk-QO7M2D7A.js","sourcesContent":["/**\n * The advisory tier.\n *\n * Schema errors and advisory warnings are deliberately different things. The schema stays\n * permissive — open key→value eligibility, free-text deadline labels, an open programModel\n * list — because a closed enum built from one publisher's vocabulary would force every other\n * publisher into it. That permissiveness is what makes the registries load-bearing: nothing\n * would ever notice drift if the only signal were pass/fail.\n *\n * So: a document that raises warnings is still CONFORMANT. Warnings are quality signal,\n * reported separately, and only `--strict` turns them into a failing exit code.\n */\nexport interface Warning {\n /** Stable machine-readable identifier for the check that fired. */\n code: string;\n /** JSON-Pointer-ish path to the offending value, in the same shape ajv uses. */\n instancePath: string;\n /** One-line human-readable explanation, naming the offending value. */\n message: string;\n}\n\nexport interface Check {\n code: string;\n /**\n * Verb phrase completing \"N of M entries …\", for count-phrased text output.\n * e.g. \"use an unregistered eligibility key\".\n */\n entryPhrase: string;\n run(entry: Record<string, unknown>): Warning[];\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n","import { type Check, type Warning, isRecord } from \"./types.js\";\n\n/**\n * A milestone amount MUST be denominated in the top-level `funding.currency` — a stated rule\n * of the standard, not a soft convention. It is also schema-unenforceable: the two values live\n * in different objects, and JSON Schema cannot express a dependency across them. Warning at\n * ingest is the entire enforcement mechanism the rule has, which is why it lives here.\n */\nexport const milestoneAmountWithoutCurrency: Check = {\n code: \"milestone-amount-without-currency\",\n entryPhrase: \"carry a milestone amount with no funding.currency to denominate it\",\n run(entry) {\n const milestones = entry.milestones;\n if (!Array.isArray(milestones)) return [];\n\n const funding = entry.funding;\n const currency = isRecord(funding) ? funding.currency : undefined;\n if (typeof currency === \"string\" && currency.length > 0) return [];\n\n const out: Warning[] = [];\n milestones.forEach((milestone, i) => {\n if (!isRecord(milestone)) return;\n if (typeof milestone.amount !== \"number\") return;\n out.push({\n code: this.code,\n instancePath: `/milestones/${i}/amount`,\n message: `milestone amount ${milestone.amount} has no funding.currency to denominate it; milestone amounts must follow the top-level envelope currency`,\n });\n });\n return out;\n },\n};\n","import { activeValues, isRegistered } from \"@rfp-hub/standard\";\nimport { type Check, type Warning, isRecord } from \"./types.js\";\n\nconst shortList = (name: Parameters<typeof activeValues>[0]) => activeValues(name).join(\", \");\n\n/**\n * `eligibility` is an open map on purpose: publishers choose their own keys. The cost is that\n * two publishers writing `stage` and `projectStage` produce two facets nobody can compare.\n * The registry is the mitigation, and this check is what makes it visible.\n */\nexport const unregisteredEligibilityKey: Check = {\n code: \"unregistered-eligibility-key\",\n entryPhrase: \"use an unregistered eligibility key\",\n run(entry) {\n const eligibility = entry.eligibility;\n if (!isRecord(eligibility)) return [];\n return Object.keys(eligibility)\n .filter((key) => !isRegistered(\"eligibility-keys\", key))\n .map((key) => ({\n code: this.code,\n instancePath: `/eligibility/${key}`,\n message: `eligibility key '${key}' is not registered; conventional keys are ${shortList(\"eligibility-keys\")}`,\n }));\n },\n};\n\n/**\n * Since every per-type date folds into `deadlines[]`, the label is the ONLY thing separating\n * \"when must I apply by?\" from \"when does the event start?\". An unregistered label is a\n * deadline a consumer cannot route on.\n */\nexport const unregisteredDeadlineLabel: Check = {\n code: \"unregistered-deadline-label\",\n entryPhrase: \"use an unregistered deadline label\",\n run(entry) {\n const deadlines = entry.deadlines;\n if (!Array.isArray(deadlines)) return [];\n const out: Warning[] = [];\n deadlines.forEach((deadline, i) => {\n if (!isRecord(deadline)) return;\n const label = deadline.label;\n if (typeof label !== \"string\" || label.length === 0) return;\n if (isRegistered(\"deadline-labels\", label)) return;\n out.push({\n code: this.code,\n instancePath: `/deadlines/${i}/label`,\n message: `deadline label '${label}' is not registered; conventional labels are ${shortList(\"deadline-labels\")}`,\n });\n });\n return out;\n },\n};\n\n/**\n * `grant.programModel` is an open string because it came from one publisher's taxonomy and a\n * closed enum would have imposed that taxonomy on everyone else. The registry keeps the\n * common cases comparable without closing the field.\n */\nexport const unregisteredProgramModel: Check = {\n code: \"unregistered-program-model\",\n entryPhrase: \"use an unregistered grant.programModel\",\n run(entry) {\n const grant = entry.grant;\n if (!isRecord(grant)) return [];\n const model = grant.programModel;\n if (typeof model !== \"string\" || model.length === 0) return [];\n if (isRegistered(\"program-models\", model)) return [];\n return [\n {\n code: this.code,\n instancePath: \"/grant/programModel\",\n message: `grant.programModel '${model}' is not registered; conventional values are ${shortList(\"program-models\")}`,\n },\n ];\n },\n};\n","import { milestoneAmountWithoutCurrency } from \"./currency.js\";\nimport {\n unregisteredDeadlineLabel,\n unregisteredEligibilityKey,\n unregisteredProgramModel,\n} from \"./registry.js\";\nimport { type Check, type Warning, isRecord } from \"./types.js\";\n\n/**\n * The advisory checks, in report order. Each one covers something the schema deliberately\n * leaves open — see ./types.ts for why the two tiers are separate.\n */\nexport const checks: readonly Check[] = [\n unregisteredEligibilityKey,\n unregisteredDeadlineLabel,\n unregisteredProgramModel,\n milestoneAmountWithoutCurrency,\n];\n\n/** Run every advisory check against one entry. Never throws; non-objects yield no warnings. */\nexport function runChecks(data: unknown): Warning[] {\n if (!isRecord(data)) return [];\n return checks.flatMap((check) => check.run(data));\n}\n\n/** The count-phrase for a code, for \"N of M entries <phrase>\" output. */\nexport function entryPhrase(code: string): string {\n return checks.find((c) => c.code === code)?.entryPhrase ?? `raise ${code}`;\n}\n\nexport type { Check, Warning };\nexport {\n milestoneAmountWithoutCurrency,\n unregisteredDeadlineLabel,\n unregisteredEligibilityKey,\n unregisteredProgramModel,\n};\n","import { opportunitySchema } from \"@rfp-hub/standard\";\nimport type { ErrorObject } from \"ajv\";\n\n/**\n * The type-block keys, which by the standard's own invariant are exactly the `fundingType`\n * values. Read from the schema rather than hardcoded, so a seventh type could never make this\n * message silently wrong.\n */\nconst TYPE_BLOCKS: readonly string[] = (() => {\n const props = (opportunitySchema as { properties?: Record<string, { enum?: unknown }> })\n .properties;\n const values = props?.fundingType?.enum;\n return Array.isArray(values) ? (values as string[]) : [];\n})();\n\nfunction isRecord(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\n/**\n * ajv reports a failed `if`/`then` twice: once for the constraint that actually failed, and\n * once for the `if` wrapper (\"must match \\\"then\\\" schema\"), which names no rule and adds no\n * information. Drop the wrapper — unless it is all we have.\n */\nfunction isRedundantIfWrapper(e: ErrorObject): boolean {\n return e.keyword === \"if\" && (e.params as { failingKeyword?: string }).failingKeyword != null;\n}\n\n/**\n * `not` produces \"must NOT be valid\", which is true and useless. Every `not` in this schema is\n * the one-block-per-fundingType rule, so say that instead — and name the offending block when\n * the instance is available.\n */\nfunction explainNot(e: ErrorObject, data: unknown): string | undefined {\n if (e.keyword !== \"not\") return undefined;\n if (!/^#\\/allOf\\/\\d+\\/then\\/not$/.test(e.schemaPath)) return undefined;\n\n const declared = isRecord(data) && typeof data.fundingType === \"string\" ? data.fundingType : null;\n const extras = isRecord(data)\n ? TYPE_BLOCKS.filter((k) => k !== declared && Object.hasOwn(data, k))\n : [];\n\n const offending = extras.length > 0 ? `: ${extras.map((k) => `'${k}'`).join(\", \")}` : \"\";\n const expected = declared ? ` Only the '${declared}' block may be present.` : \"\";\n return `carries a type block that does not match fundingType${offending}.${expected}`;\n}\n\n/** Render a single ajv error as a concise, human-readable line naming the rule that failed. */\nexport function humanizeError(e: ErrorObject, data?: unknown): string {\n const where = e.instancePath?.length ? e.instancePath : \"(root)\";\n\n const notMessage = explainNot(e, data);\n if (notMessage) return `${where} ${notMessage}`;\n\n let msg = e.message ?? \"is invalid\";\n // ajv's \"required\" message already names the property; only augment where it doesn't.\n if (e.keyword === \"additionalProperties\") {\n const { additionalProperty } = e.params as { additionalProperty: string };\n msg += `: '${additionalProperty}'`;\n } else if (e.keyword === \"enum\") {\n const { allowedValues } = e.params as { allowedValues?: unknown[] };\n if (allowedValues) msg += `: ${allowedValues.join(\", \")}`;\n } else if (e.keyword === \"const\") {\n const { allowedValue } = e.params as { allowedValue?: unknown };\n if (allowedValue !== undefined) msg += `: ${JSON.stringify(allowedValue)}`;\n }\n return `${where} ${msg}`;\n}\n\n/**\n * Render a list of ajv errors as human-readable lines.\n *\n * Pass the validated instance when you have it: it lets the one-block-per-fundingType rule name\n * the block that should not be there, instead of ajv's bare \"must NOT be valid\".\n */\nexport function humanizeErrors(errors: readonly ErrorObject[], data?: unknown): string[] {\n const meaningful = errors.filter((e) => !isRedundantIfWrapper(e));\n const kept = meaningful.length > 0 ? meaningful : errors;\n return kept.map((e) => humanizeError(e, data));\n}\n","import { type Opportunity, SPEC_VERSION, opportunitySchema } from \"@rfp-hub/standard\";\nimport addFormats from \"ajv-formats\";\nimport Ajv2020, { type ErrorObject, type ValidateFunction } from \"ajv/dist/2020.js\";\nimport { type Warning, runChecks } from \"./checks/index.js\";\nimport { humanizeErrors } from \"./errors.js\";\n\nexport interface ValidationResult {\n /** Schema conformance. Advisory warnings never affect this. */\n valid: boolean;\n /** Hard schema violations. A document with any of these is not conformant. */\n errors: ErrorObject[];\n /**\n * Advisory findings from the check tier — quality signal about things the schema\n * deliberately leaves open (unregistered vocabulary values, cross-object rules JSON Schema\n * cannot express). A conformant document may still carry warnings.\n */\n warnings: Warning[];\n}\n\nexport interface ValidateOptions {\n /** Spec version to validate against. Only the bundled version is supported. */\n spec?: string;\n /** Inject a pre-compiled validator (e.g. to validate against a custom schema). */\n validator?: ValidateFunction;\n /** Run the advisory check tier. Default true. */\n checks?: boolean;\n}\n\n/** Annotation keywords the standard uses. Declared so ajv's strict mode accepts them. */\nconst ANNOTATION_KEYWORDS = [\"x-stability\", \"x-since\", \"x-deprecated\"];\n\n/**\n * Compile an ajv validator. Uses the SAME configuration the standard is authored\n * against: draft 2020-12, strict mode, `strictRequired` off (so the conditional\n * type-block pattern — `opportunity[opportunity.fundingType]` — is permitted).\n */\nexport function createValidator(\n schema: Record<string, unknown> = opportunitySchema as Record<string, unknown>,\n): ValidateFunction {\n const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });\n addFormats(ajv);\n for (const keyword of ANNOTATION_KEYWORDS) ajv.addKeyword(keyword);\n return ajv.compile(schema);\n}\n\nlet _default: ValidateFunction | undefined;\nfunction defaultValidator(): ValidateFunction {\n if (!_default) _default = createValidator();\n return _default;\n}\n\nfunction resolveValidator(opts: ValidateOptions): ValidateFunction {\n if (opts.validator) return opts.validator;\n if (opts.spec && opts.spec !== SPEC_VERSION) {\n throw new Error(`unsupported spec '${opts.spec}' (this build ships ${SPEC_VERSION})`);\n }\n return defaultValidator();\n}\n\n/** Validate arbitrary data against the RFP Hub Standard, plus the advisory check tier. */\nexport function validateOpportunity(data: unknown, opts: ValidateOptions = {}): ValidationResult {\n const validate = resolveValidator(opts);\n const valid = validate(data);\n return {\n valid,\n errors: valid ? [] : (validate.errors ?? []),\n warnings: opts.checks === false ? [] : runChecks(data),\n };\n}\n\n/** Assert that data is a valid Opportunity, narrowing its type. Throws otherwise. */\nexport function assertOpportunity(\n data: unknown,\n opts: ValidateOptions = {},\n): asserts data is Opportunity {\n const { valid, errors } = validateOpportunity(data, { ...opts, checks: false });\n if (!valid) {\n const summary = humanizeErrors(errors, data).join(\"; \");\n const err = new Error(`invalid opportunity: ${summary}`) as Error & { errors?: ErrorObject[] };\n err.errors = errors;\n throw err;\n }\n}\n\nexport { SPEC_VERSION };\n"]}
|