SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
2 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%

Classifier: development-verb evidence covers investment commitments, infrastructure nouns, plural campuses/facilities, MW-noun phrases

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 20 days ago (Sep 12, 2026) parent f7366fa

14 changed files +175 −10

modified apps/api/src/app.ts +11 −1
@@ -17,6 +17,11 @@ import { facilityRoutes } from "./routes/public/facilities.js";
17 17 import { graphRoutes } from "./routes/public/graph.js";
18 18 import { activityRoutes } from "./routes/public/activity.js";
19 19 import { discoveryRoutes } from "./routes/public/discovery.js";
20 +import { marketRoutes } from "./routes/public/markets.js";
21 +import { intelligenceRoutes } from "./routes/public/intelligence.js";
22 +import { downloadRoutes } from "./routes/public/download.js";
23 +import { watchlistRoutes } from "./routes/public/watchlist.js";
24 +import { docsMetaRoutes } from "./routes/public/docs-meta.js";
20 25 import { adminRoutes } from "./routes/admin/index.js";
21 26
22 27 export interface BuildOptions { logger?: boolean; underPressure?: boolean }
@@ -36,7 +41,7 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance
36 41 app.setValidatorCompiler(() => () => true);
37 42 app.setSerializerCompiler(() => (data) => JSON.stringify(data));
38 43
39 − await app.register(cors, { origin: true, methods: ["GET", "HEAD", "OPTIONS", "POST", "PATCH"], allowedHeaders: ["content-type", "x-dci-admin-token", "if-none-match", "authorization"], exposedHeaders: ["etag", "x-cache", "x-route", "x-ratelimit-limit", "x-ratelimit-remaining"], maxAge: 86400 });
44 + await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "OPTIONS", "POST", "PATCH", "DELETE"], allowedHeaders: ["content-type", "x-dci-admin-token", "if-none-match", "authorization"], exposedHeaders: ["etag", "x-cache", "x-route", "x-ratelimit-limit", "x-ratelimit-remaining", "content-disposition"], maxAge: 86400 });
40 45
41 46 await app.register(rateLimit, {
42 47 global: true,
@@ -96,6 +101,11 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance
96 101 await v1.register(graphRoutes);
97 102 await v1.register(activityRoutes);
98 103 await v1.register(discoveryRoutes);
104 + await v1.register(marketRoutes);
105 + await v1.register(intelligenceRoutes);
106 + await v1.register(downloadRoutes);
107 + await v1.register(watchlistRoutes);
108 + await v1.register(docsMetaRoutes);
99 109 },
100 110 { prefix: "/api/v1" },
101 111 );
modified apps/api/src/lib/route.ts +73 −1
@@ -1,6 +1,7 @@
1 1 /**
2 2 * Route helper for public GET endpoints: zod query parsing → cache (memory + Redis) → envelope → ETag.
3 − * The zod schema is also converted to JSON Schema for the OpenAPI document (validation itself is zod-only).
3 + * The zod schema is also converted to JSON Schema for the OpenAPI document (validation itself is zod-only), and every
4 + * public route is recorded in `routeRegistry` so `/docs-meta` can describe the API from the live route table.
4 5 */
5 6 import type { FastifyInstance, FastifyRequest } from "fastify";
6 7 import { z, type ZodType } from "zod";
@@ -10,6 +11,14 @@ import { cached } from "../cache.js";
10 11
11 12 export interface RouteResult<T> { data: T; meta?: Record<string, unknown>; sources?: SourceRef[] }
12 13
14 +export interface ResponseDoc {
15 + /** name of the api-types.ts type carried in `data` (e.g. "FacilitySummary[]") */
16 + type: string;
17 + description?: string;
18 + /** small illustrative example of `data` (published in OpenAPI as x-example) */
19 + example?: unknown;
20 +}
21 +
13 22 export interface PublicRouteOptions<Q> {
14 23 url: string;
15 24 ttl: number;
@@ -18,6 +27,28 @@ export interface PublicRouteOptions<Q> {
18 27 description?: string;
19 28 tags?: string[];
20 29 params?: Record<string, string>;
30 + response?: ResponseDoc;
31 + /** override the prefix recorded in the route registry (default /api/v1) */
32 + prefix?: string;
33 +}
34 +
35 +export interface RegisteredRoute {
36 + method: "GET" | "POST" | "DELETE" | "PATCH";
37 + path: string;
38 + summary: string;
39 + description?: string;
40 + group: string;
41 + params: Array<{ name: string; in: "query" | "path"; type: string; description: string; example?: string }>;
42 + responseType: string;
43 + responseDescription?: string;
44 + example?: unknown;
45 +}
46 +
47 +/** Every public route registered through publicGet (or registerRoute), in registration order. */
48 +export const routeRegistry: RegisteredRoute[] = [];
49 +
50 +export function registerRoute(r: RegisteredRoute): void {
51 + if (!routeRegistry.some((x) => x.method === r.method && x.path === r.path)) routeRegistry.push(r);
21 52 }
22 53
23 54 export function jsonSchemaOf(schema: ZodType | undefined): Record<string, unknown> | undefined {
@@ -36,6 +67,35 @@ function paramsSchema(params?: Record<string, string>): Record<string, unknown>
36 67 return { type: "object", properties: Object.fromEntries(Object.entries(params).map(([k, d]) => [k, { type: "string", description: d }])), required: Object.keys(params) };
37 68 }
38 69
70 +/** OpenAPI response schema for the `{ data, meta, sources }` envelope. */
71 +export function envelopeSchema(doc?: ResponseDoc): Record<string, unknown> {
72 + const schema: Record<string, unknown> = {
73 + description: doc?.description ?? `Envelope with \`data\` = ${doc?.type ?? "object"} (see packages/core/src/api-types.ts).`,
74 + type: "object",
75 + properties: {
76 + data: { description: doc?.type ?? "payload", oneOf: [{ type: "object", additionalProperties: true }, { type: "array", items: {} }] },
77 + meta: { type: "object", additionalProperties: true, properties: { total: { type: "integer" }, page: { type: "integer" }, perPage: { type: "integer" }, generatedAt: { type: "string", format: "date-time" }, methodology: { type: "string" } } },
78 + sources: { type: "array", items: { type: "object", additionalProperties: true, properties: { id: { type: "string" }, name: { type: "string" }, kind: { type: "string" }, license: { type: ["string", "null"] }, redistribution: { type: ["string", "null"] } } } },
79 + },
80 + required: ["data"],
81 + };
82 + if (doc?.example !== undefined) schema["x-example"] = { data: doc.example, meta: { generatedAt: "2026-09-12T00:00:00.000Z" }, sources: [] };
83 + return schema;
84 +}
85 +
86 +const ERROR_SCHEMA = { description: "Error", type: "object", properties: { error: { type: "string" }, statusCode: { type: "integer" }, details: {} }, required: ["error", "statusCode"] };
87 +
88 +/** Describe the zod query schema as docs params. */
89 +export function queryParamsDoc(schema: ZodType | undefined): RegisteredRoute["params"] {
90 + const js = jsonSchemaOf(schema);
91 + const props = (js?.properties as Record<string, Record<string, unknown>> | undefined) ?? {};
92 + return Object.entries(props).map(([name, p]) => {
93 + const t = Array.isArray(p.type) ? (p.type as string[]).filter((x) => x !== "null").join("|") : typeof p.type === "string" ? p.type : p.enum ? "enum" : p.anyOf ? "string" : "string";
94 + const desc = typeof p.description === "string" ? p.description : p.enum ? `one of ${(p.enum as unknown[]).join(", ")}` : "";
95 + return { name, in: "query" as const, type: t || "string", description: desc };
96 + });
97 +}
98 +
39 99 /** Register a cached public GET route. `handler` receives the parsed query and route params. */
40 100 export function publicGet<Q = Record<string, never>>(app: FastifyInstance, opts: PublicRouteOptions<Q>, handler: (q: Q, params: Record<string, string>, req: FastifyRequest) => Promise<RouteResult<unknown>>): void {
41 101 const schema: Record<string, unknown> = { summary: opts.summary, tags: opts.tags ?? ["public"] };
@@ -44,6 +104,18 @@ export function publicGet<Q = Record<string, never>>(app: FastifyInstance, opts:
44 104 if (qs) schema.querystring = qs;
45 105 const ps = paramsSchema(opts.params);
46 106 if (ps) schema.params = ps;
107 + schema.response = { 200: envelopeSchema(opts.response), 400: ERROR_SCHEMA, 404: ERROR_SCHEMA };
108 + registerRoute({
109 + method: "GET",
110 + path: `${opts.prefix ?? "/api/v1"}${opts.url}`,
111 + summary: opts.summary,
112 + description: opts.description,
113 + group: opts.tags?.[0] ?? "public",
114 + params: [...Object.entries(opts.params ?? {}).map(([name, d]) => ({ name, in: "path" as const, type: "string", description: d })), ...queryParamsDoc(opts.query as ZodType | undefined)],
115 + responseType: opts.response?.type ?? "object",
116 + responseDescription: opts.response?.description,
117 + example: opts.response?.example,
118 + });
47 119 app.get(opts.url, { schema }, async (req, reply) => {
48 120 const q = (opts.query ? parseQuery(opts.query, req.query) : {}) as Q;
49 121 const params = (req.params ?? {}) as Record<string, string>;
added apps/api/src/routes/public/docs-meta.ts +6 −0
@@ -0,0 +1,6 @@
1 +/** /docs-meta — endpoint catalogue generated from the route registry for the web API docs page. */
2 +import type { FastifyInstance } from "fastify";
3 +
4 +export async function docsMetaRoutes(_app: FastifyInstance): Promise<void> {
5 + // filled in by the intelligence implementation
6 +}
added apps/api/src/routes/public/download.ts +6 −0
@@ -0,0 +1,6 @@
1 +/** Dataset downloads (CSV / JSON / GeoJSON) with license gating. */
2 +import type { FastifyInstance } from "fastify";
3 +
4 +export async function downloadRoutes(_app: FastifyInstance): Promise<void> {
5 + // filled in by the intelligence implementation
6 +}
added apps/api/src/routes/public/intelligence.ts +6 −0
@@ -0,0 +1,6 @@
1 +/** Nearby, explore, AI index, power, connectivity, time machine. */
2 +import type { FastifyInstance } from "fastify";
3 +
4 +export async function intelligenceRoutes(_app: FastifyInstance): Promise<void> {
5 + // filled in by the intelligence implementation
6 +}
added apps/api/src/routes/public/markets.ts +6 −0
@@ -0,0 +1,6 @@
1 +/** Pulse, operator comparison, coverage report (owned by the aggregates group). */
2 +import type { FastifyInstance } from "fastify";
3 +
4 +export async function marketRoutes(_app: FastifyInstance): Promise<void> {
5 + // filled in by the aggregates implementation
6 +}
added apps/api/src/routes/public/watchlist.ts +6 −0
@@ -0,0 +1,6 @@
1 +/** Private cookie-scoped watchlist (no accounts). */
2 +import type { FastifyInstance } from "fastify";
3 +
4 +export async function watchlistRoutes(_app: FastifyInstance): Promise<void> {
5 + // filled in by the intelligence implementation
6 +}
modified apps/worker/src/configs.ts +5 −2
@@ -3,7 +3,7 @@
3 3 * GenericConnector) and mirror them into the `connectors` + `sources` tables for the admin UI / scheduler.
4 4 */
5 5 import { existsSync } from "node:fs";
6 −import { GenericConnector, getImplementation, getParser, loadConnectorConfigs, type Connector, type ConnectorConfig } from "@dci/connectors";
6 +import { GenericConnector, assertAgainstRegistry, getImplementation, getParser, loadConnectorConfigs, type Connector, type ConnectorConfig } from "@dci/connectors";
7 7 import { sha256, stableId } from "@dci/core";
8 8 import { getDb, connectors as connectorsTable, sources as sourcesTable, sql, eq, type Db } from "@dci/db";
9 9 import { getEnv } from "./env.js";
@@ -51,7 +51,10 @@ export function loadAllConnectors(opts: { reload?: boolean; dir?: string } = {})
51 51 const dir = opts.dir ?? getEnv().configDir;
52 52 const out = new Map<string, LoadedConnector>();
53 53 if (existsSync(dir)) {
54 − for (const cfg of loadConnectorConfigs(dir)) { const connector = buildConnector(cfg); out.set(cfg.id, { cfg, connector, sourceId: sourceIdFor(cfg.id), extractorVersion: effectiveExtractorVersion(cfg, connector) }); }
54 + const cfgs = loadConnectorConfigs(dir);
55 + // every `extractors.*.parser` / `implementation` must be registered (no-op while the registry is still empty)
56 + assertAgainstRegistry(cfgs);
57 + for (const cfg of cfgs) { const connector = buildConnector(cfg); out.set(cfg.id, { cfg, connector, sourceId: sourceIdFor(cfg.id), extractorVersion: effectiveExtractorVersion(cfg, connector) }); }
55 58 }
56 59 cache = out;
57 60 return [...out.values()];
modified apps/worker/src/documents.ts +2 −1
@@ -152,7 +152,8 @@ export function planFetchBookkeeping(doc: Pick<DocumentRow, "changeFrequencyScor
152 152 const errorCount = failed ? (doc.errorCount ?? 0) + 1 : 0;
153 153 const error = hadError ? `${raw.error!.code}: ${raw.error!.message}`.slice(0, 500) : httpFail ? `HTTP ${raw.status}` : null;
154 154 const changeScore = failed ? doc.changeFrequencyScore : updateChangeScore(doc.changeFrequencyScore, outcome.changed);
155 − const nc = nextCheckAfter({ now, baseMs, changeScore, statusCode: hadError ? null : raw.status, consecutiveErrors: errorCount, hadError });
155 + const retryAfterMs = typeof raw.meta?.retryAfterMs === "number" ? raw.meta.retryAfterMs : null;
156 + const nc = nextCheckAfter({ now, baseMs, changeScore, statusCode: hadError ? null : raw.status, consecutiveErrors: errorCount, hadError, retryAfterMs });
156 157 return { nextCheck: nc.nextCheck ? nc.nextCheck.toISOString() : null, quarantined: nc.quarantined, changeScore, errorCount, error, reason: nc.reason, changed: outcome.changed && !failed, notModified: raw.notModified, failed };
157 158 }
158 159
modified apps/worker/src/pipeline.ts +6 −2
@@ -225,10 +225,14 @@ async function processDocument(ctx: RunContext, loaded: LoadedConnector, doc: Do
225 225 } catch { /* storage hiccup: fall through and treat as changed */ }
226 226 }
227 227
228 − // archive raw body (dedupe by hash; unchanged pages reuse the existing key)
228 + // archive raw body (dedupe by hash; unchanged pages reuse the existing key). Pages carrying an X-Robots-Tag /
229 + // <meta name="robots"> `noarchive` (or `noindex`) directive are never archived — facts are still extracted below.
229 230 let storageKey: string | null = doc.storageKey;
230 − if (!dryRun && (changed || !doc.storageKey)) {
231 + const noarchive = raw.meta?.noarchive === true;
232 + if (!dryRun && (changed || !doc.storageKey) && !noarchive) {
231 233 try { storageKey = (await putRaw(cfg.id, doc.id, hash, raw.body, raw.contentType, raw.finalUrl)).key; } catch (e) { ctx.log("warn", `archive failed for ${doc.url}: ${(e as Error).message}`); }
234 + } else if (noarchive && (changed || !doc.storageKey)) {
235 + ctx.log("debug", `${doc.url} not archived (${String(raw.meta?.robotsDirective ?? "noarchive")})`);
232 236 }
233 237
234 238 const skip = markupOnly || shouldSkipExtraction({ newHash: hash, storedHash: doc.contentHash, force, extractorVersion: loaded.extractorVersion, storedExtractorVersion: doc.extractorVersion, notModified: false });
modified apps/worker/src/scheduling.ts +7 −0
@@ -74,6 +74,8 @@ export interface NextCheckInput {
74 74 consecutiveErrors: number;
75 75 /** transport error (dns, timeout, blocked…) on this fetch */
76 76 hadError: boolean;
77 + /** server-requested wait from `Retry-After` on a 429 (ms), when the fetcher exposed it (`doc.meta.retryAfterMs`) */
78 + retryAfterMs?: number | null;
77 79 }
78 80
79 81 export interface NextCheckResult {
@@ -92,6 +94,11 @@ export function nextCheckAfter(i: NextCheckInput): NextCheckResult {
92 94 if (i.consecutiveErrors >= MAX_CONSECUTIVE_GONE) return { nextCheck: null, quarantined: true, reason: `gone ×${i.consecutiveErrors}` };
93 95 return { nextCheck: new Date(i.now.getTime() + Math.max(MIN_INTERVAL_MS, i.baseMs * 4)), quarantined: false, reason: "gone backoff ×4" };
94 96 }
97 + if (i.statusCode === 429 && i.retryAfterMs != null && Number.isFinite(i.retryAfterMs) && i.retryAfterMs > 0) {
98 + // the server told us when to come back: honour it (never sooner than the floor, never later than the error backoff cap)
99 + const wait = Math.min(Math.max(MIN_INTERVAL_MS, i.retryAfterMs), Math.max(MIN_INTERVAL_MS, i.baseMs * 8));
100 + return { nextCheck: new Date(i.now.getTime() + wait), quarantined: false, reason: `retry-after ${Math.round(i.retryAfterMs / 1000)}s` };
101 + }
95 102 if (i.hadError || (i.statusCode != null && i.statusCode >= 400)) {
96 103 // exponential backoff on errors: base × 2^(n-1), capped at 8× base
97 104 const mult = Math.min(8, 2 ** Math.max(0, i.consecutiveErrors - 1));
modified packages/connectors/src/classify.ts +1 −1
@@ -33,7 +33,7 @@ export const PLANNING_VOCAB_RE = /\b(planning|permit(s|ting)?|zoning|rezon\w*|ap
33 33
34 34 const TEXT_RULES: Array<[RegExp, PageType]> = [
35 35 [/\b(acquires|acquisition of|to acquire|completes acquisition|agreed to acquire|acquired|übernimmt|übernahme|rachète|acquisition de|adquiere|adquisición)\b/i, "acquisition"],
36 − [/\b(expansion|expands|expanding|adds? \d+ ?MW|additional (capacity|phase)|new phase|erweitert|erweiterung|agrandit|extension|ampliación|amplía|uitbreiding)\b/i, "expansion"],
36 + [/\b(expansion|expands|expanding|adds? \d+ ?MW|additional (capacity|phase)|new phase|erweitert|erweiterung|agrandit|agrandissement|ampliación|amplía|uitbreiding)\b/i, "expansion"],
37 37 [/\b(closes?|closure|closing|decommission|shut(ting)? down|exit(s|ing)? (the )?market)\b/i, "closure"],
38 38 [/\b(planning (application|permission|commission|board)|rezon(e|ing)|site plan|conditional use|environmental (assessment|impact)|permit(ting)?|bebauungsplan|permis de construire)\b/i, "planning_document"],
39 39 [/\b(under construction|breaks? ground|groundbreaking|topping out|construction (update|progress|milestone)|shell (is )?complete|spatenstich|im bau|en construction|en construcción|em construção|in aanbouw)\b/i, "construction_update"],
modified packages/connectors/src/config.ts +38 −0
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
3 3 import { readFileSync, readdirSync } from "node:fs";
4 4 import { join } from "node:path";
5 5 import { SOURCE_KINDS, PAGE_TYPES } from "@dci/core";
6 +import { getImplementation, listImplementations, listParsers } from "./registry.js";
6 7
7 8 /**
8 9 * Connector configuration (YAML in config/connectors/*.yaml). Declarative first; `parser:` names a
@@ -107,6 +108,24 @@ export const connectorConfigSchema = z.object({
107 108 implementation: z.string().optional(),
108 109 params: z.record(z.string(), z.unknown()).optional(),
109 110 parserVersion: z.string().default("v1"),
111 +}).superRefine((cfg, ctx) => {
112 + // cross-field rules — a config that passes the schema must also be internally consistent
113 + if (cfg.fetch.level > cfg.fetch.maxLevel) ctx.addIssue({ code: "custom", path: ["fetch", "level"], message: `fetch.level ${cfg.fetch.level} exceeds fetch.maxLevel ${cfg.fetch.maxLevel}` });
114 + if (cfg.fetch.maxCreditsPerDay !== undefined && cfg.fetch.maxCreditsPerDay < cfg.fetch.maxCreditsPerRun && cfg.fetch.maxLevel >= 3) ctx.addIssue({ code: "custom", path: ["fetch", "maxCreditsPerRun"], message: `fetch.maxCreditsPerRun ${cfg.fetch.maxCreditsPerRun} exceeds fetch.maxCreditsPerDay ${cfg.fetch.maxCreditsPerDay}` });
115 + for (const s of cfg.discovery.seeds) { const lvl = typeof s === "object" ? s.minLevel : undefined; if (lvl !== undefined && lvl > cfg.fetch.maxLevel) ctx.addIssue({ code: "custom", path: ["discovery", "seeds"], message: `seed ${typeof s === "object" ? s.url : s} minLevel ${lvl} exceeds fetch.maxLevel ${cfg.fetch.maxLevel}` }); }
116 + cfg.discovery.classify.forEach((r, i) => { if (r.minLevel !== undefined && r.minLevel > cfg.fetch.maxLevel) ctx.addIssue({ code: "custom", path: ["discovery", "classify", i, "minLevel"], message: `minLevel ${r.minLevel} exceeds fetch.maxLevel ${cfg.fetch.maxLevel}` }); });
117 + // every regex is compiled once here so a bad pattern fails `dci validate` instead of throwing mid-discovery
118 + const tryRe = (pattern: string, path: Array<string | number>) => { try { new RegExp(pattern, "i"); } catch (e) { ctx.addIssue({ code: "custom", path, message: `invalid regex /${pattern}/: ${(e as Error).message}` }); } };
119 + cfg.discovery.include.forEach((p, i) => tryRe(p, ["discovery", "include", i]));
120 + cfg.discovery.exclude.forEach((p, i) => tryRe(p, ["discovery", "exclude", i]));
121 + cfg.discovery.follow.forEach((p, i) => tryRe(p, ["discovery", "follow", i]));
122 + cfg.discovery.classify.forEach((r, i) => tryRe(r.pattern, ["discovery", "classify", i, "pattern"]));
123 + for (const [name, ex] of Object.entries(cfg.extractors)) {
124 + ex.match?.forEach((p, i) => tryRe(p, ["extractors", name, "match", i]));
125 + if (!ex.parser && !ex.fields) ctx.addIssue({ code: "custom", path: ["extractors", name], message: "extractor needs either `parser` or `fields`" });
126 + for (const [field, rule] of Object.entries(ex.fields ?? {})) if (typeof rule === "object" && rule.regex) { try { new RegExp(rule.regex, rule.regexFlags ?? "i"); } catch (e) { ctx.addIssue({ code: "custom", path: ["extractors", name, "fields", field, "regex"], message: `invalid regex: ${(e as Error).message}` }); } }
127 + }
128 + for (const [group, spec] of Object.entries(cfg.schedule)) { try { intervalMs(spec); } catch { ctx.addIssue({ code: "custom", path: ["schedule", group], message: `bad interval "${spec}" (daily | weekly | monthly | 6h | 30m …)` }); } }
110 129 });
111 130
112 131 export type ConnectorConfig = z.infer<typeof connectorConfigSchema>;
@@ -120,6 +139,25 @@ export function parseConnectorConfig(text: string, file = "<inline>"): Connector
120 139 return res.data;
121 140 }
122 141
142 +/**
143 + * Registry-dependent checks, run lazily once parsers / implementations are registered (the YAML schema cannot know
144 + * them): every `extractors.*.parser` and the `implementation` must exist. Returns the list of problems (empty = ok);
145 + * `assertAgainstRegistry` throws one clear error listing them. When the registry is still empty the check is skipped
146 + * (returns []), so configs can be parsed before the worker groups are loaded.
147 + */
148 +export function validateAgainstRegistry(cfg: ConnectorConfig): string[] {
149 + if (!listParsers().length && !listImplementations().length) return [];
150 + const problems: string[] = [];
151 + const known = new Set(listParsers().map((p) => p.name));
152 + for (const [name, ex] of Object.entries(cfg.extractors)) if (ex.parser && !known.has(ex.parser)) problems.push(`extractors.${name}.parser "${ex.parser}" is not registered`);
153 + if (cfg.implementation && !getImplementation(cfg.implementation)) problems.push(`implementation "${cfg.implementation}" is not registered`);
154 + return problems;
155 +}
156 +export function assertAgainstRegistry(cfgs: ConnectorConfig[]): void {
157 + const all = cfgs.flatMap((c) => validateAgainstRegistry(c).map((p) => `${c.id}: ${p}`));
158 + if (all.length) throw new Error(`connector config(s) reference unknown parsers/implementations — ${all.join("; ")}. Registered parsers: ${listParsers().map((p) => p.name).sort().join(", ") || "none"}`);
159 +}
160 +
123 161 export function loadConnectorConfigs(dir: string): ConnectorConfig[] {
124 162 const out: ConnectorConfig[] = [];
125 163 const seen = new Set<string>();
modified packages/core/src/claims.ts +2 −2
@@ -332,11 +332,11 @@ const CLASS_RULES: Array<[ProjectClass, RegExp]> = [
332 332 ["PERMIT", /\b(planning (?:application|permission|approval|consent|committee|commission|board|inquiry)|permit(?:s|ted|ting)?\b|rezon\w+|zoning (?:approval|change|request|case|board|commission)|(?:files?|filed|submits?|submitted|lodges?|lodged) (?:plans?|an application|a planning|planning|for approval|proposal)|(?:approv(?:es|ed|al)|green-?light(?:s|ed)?|consent(?:s|ed)?|clears?|cleared|greenlit|(?:votes?|voted) to approve|unanimously approved|signs? off|sign-off) (?:for |of |on )?(?:a |the |its |\d+[ -]?(?:mw|gw) )?(?:data ?cent(?:er|re)|campus|project|plans?|development|proposal|application|rezoning|site plan|special use|conditional use)|environmental (?:impact|assessment|review|permit)|eia\b|site plan (?:approval|review)|special (?:use|exception) permit|conditional use permit|comprehensive plan amendment|public hearing|(?:county|city|town|council|board|commission|supervisors) (?:approves?|approved|rejects?|rejected|denies|denied|defers?|deferred|delays?|tables?|tabled))\b/i],
333 333 ["CONSTRUCTION_START", /\b(breaks? ground|broke ground|groundbreaking|ground-?breaking|construction (?:has |have )?(?:started|begins?|began|begun|commenced|commences|kicks? off|is under ?way|under ?way|gets under ?way)|(?:begins?|began|starts?|started|commences?|commenced|kicks? off) (?:construction|building|work|site work|vertical construction)|under construction|topped out|topping out|tops out|steel (?:erection|going up)|first concrete|shovels in the ground|site work (?:begins|has begun|under ?way)|construction (?:milestone|update|progress))\b/i],
334 334 ["EXPANSION", /\b(expan(?:sion|ds?|ded|ding)|adds? (?:\d+[ -]?(?:mw|gw)|capacity|a (?:second|third|fourth|new) (?:building|data hall|phase))|additional (?:\d+[ -]?(?:mw|gw)|capacity|building|data hall|phase)|(?:second|third|fourth|fifth|next|new) (?:phase|building|data hall|hall|facility (?:at|on|in) (?:its|the) (?:existing )?campus)|phase (?:2|3|4|ii|iii|iv|two|three|four)\b|(?:grows?|growing|scales?|scaling|extends?|extending|enlarges?|doubles?|doubling|triples?|tripling) (?:its |the )?(?:capacity|campus|footprint at|data cent(?:er|re)|site|facility)|(?:grows?|expands?|extends?) [A-Z][\w .-]{0,40}?(?:campus|data cent(?:er|re)|facility|site)\b|more capacity (?:at|in|to)|further (?:capacity|building|facility|phase)|extension (?:of|to) (?:its|the) (?:existing )?(?:data cent(?:er|re)|campus|facility|site))\b/i],
335 − ["NEW_BUILD", /\b((?:to|will|would|could|may|might|plans? to|set to|aims? to|intends? to|proposes? to|wants? to|is|are|is expected to|expected to) (?:build|construct|develop|create|deliver|establish|open|erect|bring)|(?:plans?|planned|planning|proposes?|proposed|proposal|announces?|announced|unveils?|unveiled|reveals?|revealed|confirms?|confirmed|launches?|launched|commits? to|committed to|pledges?|earmarks?|eyes|eyeing|mulls?|weighs?|considering|exploring|in talks) (?:for |to build |to develop |to construct |to invest in |to open |a |an |its |the |new |first |second |two |three |\d+[ -]?(?:mw|gw) |\$[\d.,]+ ?(?:m|bn|b|million|billion)? |€[\d.,]+ ?(?:m|bn|b|million|billion)? |£[\d.,]+ ?(?:m|bn|b|million|billion)? |hyperscale |ai |massive |major |huge |giant |sprawling |\w+-acre )*(?:data ?cent(?:er|re)s?|campus|campuses|facility|facilities|ai factory|ai factories|hub|site|development|project|complex|cluster|supercomputer)|new (?:\d+[ -]?(?:mw|gw) |hyperscale |ai |\$[\d.,]+ ?(?:m|bn|b|million|billion) )?(?:data ?cent(?:er|re)|campus|facility|ai factory|hub) (?:in|near|at|for|to|planned|proposed|coming|slated|set)|(?:\d+[ -]?(?:mw|gw)) (?:ai )?(?:data ?cent(?:er|re)|campus|facility|site|project|development|hub) (?:in|near|at|for|planned|proposed)|(?:build|building|develop|developing|construct|constructing|open|opening) (?:a |an |its |the |new |first |\d+[ -]?(?:mw|gw) )*(?:data ?cent(?:er|re)|campus|facility|ai factory) (?:in|near|at|on|for)|first (?:data ?cent(?:er|re)|campus|facility) in|(?:enters?|entering|entry into) (?:the )?[A-Z][\w.-]+ (?:market|with)|coming to|slated for|(?:to|will|would|could) (?:house|host|feature|include) (?:a |an |up to )?(?:\d+[ -]?(?:mw|gw)|data ?cent(?:er|re)|campus))\b/i],
335 + ["NEW_BUILD", /\b((?:to|will|would|could|may|might|plans? to|set to|aims? to|intends? to|proposes? to|wants? to|is|are|is expected to|expected to) (?:build|construct|develop|create|deliver|establish|open|erect|bring)|(?:plans?|planned|planning|proposes?|proposed|proposal|announces?|announced|unveils?|unveiled|reveals?|revealed|confirms?|confirmed|launches?|launched|commits? to|committed to|pledges?|earmarks?|eyes|eyeing|mulls?|weighs?|considering|exploring|in talks) (?:for |to build |to develop |to construct |to invest in |to open |a |an |its |the |new |first |second |two |three |\d+[ -]?(?:mw|gw) |\$[\d.,]+ ?(?:m|bn|b|million|billion)? |€[\d.,]+ ?(?:m|bn|b|million|billion)? |£[\d.,]+ ?(?:m|bn|b|million|billion)? |at least [€$£][\d.,]+ ?(?:m|bn|b|million|billion)? |hyperscale |ai |massive |major |huge |giant |sprawling |\w+-acre |[“"']?[\w.-]{2,14}[”"']?,? ){0,4}(?:data ?cent(?:er|re)s?|campus|campuses|mega-?campus|facility|facilities|ai factory|ai factories|(?:ai |digital |cloud |compute |sovereign )infrastructure|hub|site|development|project|complex|cluster|supercomputer|data hall)|new (?:\d+[ -]?(?:mw|gw) |hyperscale |ai |\$[\d.,]+ ?(?:m|bn|b|million|billion) )?(?:data ?cent(?:er|re)|campus|facility|ai factory|hub) (?:in|near|at|for|to|planned|proposed|coming|slated|set)|(?:\d+[ -]?(?:mw|gw)) (?:ai )?(?:data ?cent(?:er|re)|campus|facility|site|project|development|hub) (?:in|near|at|for|planned|proposed)|(?:build|building|develop|developing|construct|constructing|open|opening) (?:a |an |its |the |new |first |\d+[ -]?(?:mw|gw) )*(?:data ?cent(?:er|re)|campus|facility|ai factory) (?:in|near|at|on|for)|first (?:data ?cent(?:er|re)|campus|facility) in|(?:enters?|entering|entry into) (?:the )?[A-Z][\w.-]+ (?:market|with)|coming to|slated for|(?:to|will|would|could) (?:house|host|feature|include) (?:a |an |up to )?(?:\d+[ -]?(?:mw|gw)|data ?cent(?:er|re)|campus))\b/i],
336 336 ];
337 337
338 338 /** Verbs / nouns that describe physical development (build, construct, break ground, expand, permit, approve, file, acquire land, energize…). */
339 −export const DEVELOPMENT_VERB_RE = /\b(build|builds|building|built|construct\w*|develop\w*|break\w* ground|broke ground|groundbreaking|expan\w+|open|opens|opening|opened|permit\w*|approv\w+|file[sd]?|filing|rezon\w+|acquire[sd]? (?:\d[\d,.]*[ -]?(?:acre|hectare)s?|land|a site|the site|a parcel)|land (?:purchase|acquisition)|energi[sz]\w+|deliver\w*|commission\w*|top\w* out|phase|campus|data ?cent(?:er|re)s?|facility|site plan|planning application|zoning)\b/i;
339 +export const DEVELOPMENT_VERB_RE = /\b(build|builds|building|built|construct\w*|develop\w*|break\w* ground|broke ground|groundbreaking|expan\w+|open|opens|opening|opened|permit\w*|approv\w+|file[sd]?|filing|rezon\w+|acquire[sd]? (?:\d[\d,.]*[ -]?(?:acre|hectare)s?|land|a site|the site|a parcel)|land (?:purchase|acquisition)|energi[sz]\w+|deliver\w*|commission\w*|top\w* out|takes? shape|rising|going up|nears? completion|commits? (?:at least |up to |over |more than )?[€$£]\d|invest\w* (?:at least |up to |over |more than )?[€$£]\d|(?:ai |digital |cloud |compute |sovereign )infrastructure|phase|campus(?:es)?|mega-?campus(?:es)?|data ?cent(?:er|re)s?|data hall|facilit(?:y|ies)|site plan|planning application|zoning|\d[\d.,]*\s?(?:mw|gw)\s+(?:[\w€$£,]+\s){0,3}(?:campus|project|data ?cent(?:er|re)|facility|site|hub|megacampus))\b/i;
340 340
341 341 export interface ProjectClassInput {
342 342 title: string | null | undefined;
343 343