euparliamentmonitor 0.8.34 → 0.8.36
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/README.md +1 -1
- package/package.json +3 -2
- package/scripts/mcp/ep-mcp-client.d.ts +6 -6
- package/scripts/mcp/ep-mcp-client.js +8 -8
- package/scripts/types/mcp.d.ts +5 -5
- package/scripts/utils/validate-analysis-completeness.d.ts +2 -0
- package/scripts/utils/validate-analysis-completeness.js +545 -0
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ import {
|
|
|
124
124
|
|
|
125
125
|
**MCP Server Integration**: The project uses the
|
|
126
126
|
[European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server)
|
|
127
|
-
v1.2.
|
|
127
|
+
v1.2.9 for accessing real EU Parliament data via the Model Context Protocol.
|
|
128
128
|
|
|
129
129
|
- **MCP Server Status**: ✅ Fully operational — 60+ EP data tools available
|
|
130
130
|
(feeds, direct lookups, analytical tools, intelligence correlation)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"retrofit-analysis": "npx tsx src/utils/retrofit-analysis-links.ts",
|
|
69
69
|
"validate-articles": "npx tsx src/utils/validate-articles.ts",
|
|
70
70
|
"validate-articles:strict": "npx tsx src/utils/validate-articles.ts --strict",
|
|
71
|
+
"validate-analysis": "npx tsx src/utils/validate-analysis-completeness.ts",
|
|
71
72
|
"validate-ep-api": "npx tsx src/utils/validate-ep-api.ts",
|
|
72
73
|
"htmlhint": "sh -c 'htmlhint *.html; set -- news/*.html; if [ -e \"$1\" ]; then htmlhint \"$@\"; else echo \"No news/*.html files to lint\"; fi'",
|
|
73
74
|
"serve": "python3 -m http.server 8080",
|
|
@@ -170,7 +171,7 @@
|
|
|
170
171
|
"node": ">=25"
|
|
171
172
|
},
|
|
172
173
|
"dependencies": {
|
|
173
|
-
"european-parliament-mcp-server": "1.2.
|
|
174
|
+
"european-parliament-mcp-server": "1.2.9"
|
|
174
175
|
},
|
|
175
176
|
"optionalDependencies": {
|
|
176
177
|
"worldbank-mcp": "1.0.1"
|
|
@@ -77,7 +77,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
77
77
|
/**
|
|
78
78
|
* Search legislative documents
|
|
79
79
|
*
|
|
80
|
-
* @param options - Search options using v1.2.
|
|
80
|
+
* @param options - Search options using v1.2.9 parameters: keyword, documentType, docId, etc.
|
|
81
81
|
* @returns Search results
|
|
82
82
|
*/
|
|
83
83
|
searchDocuments(options?: SearchDocumentsOptions): Promise<MCPToolResult>;
|
|
@@ -210,14 +210,14 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
210
210
|
/**
|
|
211
211
|
* Get plenary speeches and debate contributions
|
|
212
212
|
*
|
|
213
|
-
* @param options - Filter options including optional speechId, dateFrom/dateTo (v1.2.
|
|
213
|
+
* @param options - Filter options including optional speechId, dateFrom/dateTo (v1.2.9: year removed)
|
|
214
214
|
* @returns Speeches data
|
|
215
215
|
*/
|
|
216
216
|
getSpeeches(options?: GetSpeechesOptions): Promise<MCPToolResult>;
|
|
217
217
|
/**
|
|
218
218
|
* Get legislative procedures
|
|
219
219
|
*
|
|
220
|
-
* @param options - Filter options including optional processId (v1.2.
|
|
220
|
+
* @param options - Filter options including optional processId (v1.2.9: year removed)
|
|
221
221
|
* @returns Procedures data
|
|
222
222
|
*/
|
|
223
223
|
getProcedures(options?: GetProceduresOptions): Promise<MCPToolResult>;
|
|
@@ -231,7 +231,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
231
231
|
/**
|
|
232
232
|
* Get European Parliament events (hearings, conferences, seminars)
|
|
233
233
|
*
|
|
234
|
-
* @param options - Filter options including optional eventId, pagination only (v1.2.
|
|
234
|
+
* @param options - Filter options including optional eventId, pagination only (v1.2.9: year/dateFrom/dateTo removed — EP API /events has no date filtering)
|
|
235
235
|
* @returns Events data
|
|
236
236
|
*/
|
|
237
237
|
getEvents(options?: GetEventsOptions): Promise<MCPToolResult>;
|
|
@@ -287,7 +287,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
287
287
|
/**
|
|
288
288
|
* Get committee documents
|
|
289
289
|
*
|
|
290
|
-
* @param options - Filter options including optional docId (v1.2.
|
|
290
|
+
* @param options - Filter options including optional docId (v1.2.9: year removed)
|
|
291
291
|
* @returns Committee documents data
|
|
292
292
|
*/
|
|
293
293
|
getCommitteeDocuments(options?: GetCommitteeDocumentsOptions): Promise<MCPToolResult>;
|
|
@@ -315,7 +315,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
315
315
|
/**
|
|
316
316
|
* Get external documents (non-EP documents such as Council positions)
|
|
317
317
|
*
|
|
318
|
-
* @param options - Filter options including optional docId (v1.2.
|
|
318
|
+
* @param options - Filter options including optional docId (v1.2.9: year removed)
|
|
319
319
|
* @returns External documents data
|
|
320
320
|
*/
|
|
321
321
|
getExternalDocuments(options?: GetExternalDocumentsOptions): Promise<MCPToolResult>;
|
|
@@ -29,7 +29,7 @@ const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
|
|
|
29
29
|
/**
|
|
30
30
|
* Classify an error message into a diagnostic error category.
|
|
31
31
|
*
|
|
32
|
-
* Maps EP MCP Server v1.2.
|
|
32
|
+
* Maps EP MCP Server v1.2.9 structured error codes and generic HTTP/network
|
|
33
33
|
* errors into one of six broad categories used for logging and retry decisions:
|
|
34
34
|
*
|
|
35
35
|
* Returned categories (priority order):
|
|
@@ -45,7 +45,7 @@ const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
|
|
|
45
45
|
*/
|
|
46
46
|
function classifyToolError(message) {
|
|
47
47
|
const lowerMsg = message.toLowerCase();
|
|
48
|
-
// EP MCP Server v1.2.
|
|
48
|
+
// EP MCP Server v1.2.9 structured error codes (matched case-insensitively)
|
|
49
49
|
if (lowerMsg.includes('internal_error')) {
|
|
50
50
|
return 'INTERNAL_ERROR';
|
|
51
51
|
}
|
|
@@ -214,7 +214,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
214
214
|
/**
|
|
215
215
|
* Search legislative documents
|
|
216
216
|
*
|
|
217
|
-
* @param options - Search options using v1.2.
|
|
217
|
+
* @param options - Search options using v1.2.9 parameters: keyword, documentType, docId, etc.
|
|
218
218
|
* @returns Search results
|
|
219
219
|
*/
|
|
220
220
|
async searchDocuments(options = {}) {
|
|
@@ -423,7 +423,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
423
423
|
/**
|
|
424
424
|
* Get plenary speeches and debate contributions
|
|
425
425
|
*
|
|
426
|
-
* @param options - Filter options including optional speechId, dateFrom/dateTo (v1.2.
|
|
426
|
+
* @param options - Filter options including optional speechId, dateFrom/dateTo (v1.2.9: year removed)
|
|
427
427
|
* @returns Speeches data
|
|
428
428
|
*/
|
|
429
429
|
async getSpeeches(options = {}) {
|
|
@@ -432,7 +432,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
432
432
|
/**
|
|
433
433
|
* Get legislative procedures
|
|
434
434
|
*
|
|
435
|
-
* @param options - Filter options including optional processId (v1.2.
|
|
435
|
+
* @param options - Filter options including optional processId (v1.2.9: year removed)
|
|
436
436
|
* @returns Procedures data
|
|
437
437
|
*/
|
|
438
438
|
async getProcedures(options = {}) {
|
|
@@ -450,7 +450,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
450
450
|
/**
|
|
451
451
|
* Get European Parliament events (hearings, conferences, seminars)
|
|
452
452
|
*
|
|
453
|
-
* @param options - Filter options including optional eventId, pagination only (v1.2.
|
|
453
|
+
* @param options - Filter options including optional eventId, pagination only (v1.2.9: year/dateFrom/dateTo removed — EP API /events has no date filtering)
|
|
454
454
|
* @returns Events data
|
|
455
455
|
*/
|
|
456
456
|
async getEvents(options = {}) {
|
|
@@ -530,7 +530,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
530
530
|
/**
|
|
531
531
|
* Get committee documents
|
|
532
532
|
*
|
|
533
|
-
* @param options - Filter options including optional docId (v1.2.
|
|
533
|
+
* @param options - Filter options including optional docId (v1.2.9: year removed)
|
|
534
534
|
* @returns Committee documents data
|
|
535
535
|
*/
|
|
536
536
|
async getCommitteeDocuments(options = {}) {
|
|
@@ -566,7 +566,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
566
566
|
/**
|
|
567
567
|
* Get external documents (non-EP documents such as Council positions)
|
|
568
568
|
*
|
|
569
|
-
* @param options - Filter options including optional docId (v1.2.
|
|
569
|
+
* @param options - Filter options including optional docId (v1.2.9: year removed)
|
|
570
570
|
* @returns External documents data
|
|
571
571
|
*/
|
|
572
572
|
async getExternalDocuments(options = {}) {
|
package/scripts/types/mcp.d.ts
CHANGED
|
@@ -218,7 +218,7 @@ export interface GetCurrentMEPsOptions {
|
|
|
218
218
|
limit?: number | undefined;
|
|
219
219
|
offset?: number | undefined;
|
|
220
220
|
}
|
|
221
|
-
/** Options for getSpeeches — v1.2.
|
|
221
|
+
/** Options for getSpeeches — v1.2.9 removed `year` (EP API ignores it for /speeches) */
|
|
222
222
|
export interface GetSpeechesOptions {
|
|
223
223
|
speechId?: string | undefined;
|
|
224
224
|
/** Filter by sitting date start (maps to sitting-date in EP API) */
|
|
@@ -228,7 +228,7 @@ export interface GetSpeechesOptions {
|
|
|
228
228
|
limit?: number | undefined;
|
|
229
229
|
offset?: number | undefined;
|
|
230
230
|
}
|
|
231
|
-
/** Options for getProcedures — v1.2.
|
|
231
|
+
/** Options for getProcedures — v1.2.9 removed `year` (EP API ignores it for /procedures) */
|
|
232
232
|
export interface GetProceduresOptions {
|
|
233
233
|
processId?: string | undefined;
|
|
234
234
|
limit?: number | undefined;
|
|
@@ -241,7 +241,7 @@ export interface GetAdoptedTextsOptions {
|
|
|
241
241
|
limit?: number | undefined;
|
|
242
242
|
offset?: number | undefined;
|
|
243
243
|
}
|
|
244
|
-
/** Options for getEvents — v1.2.
|
|
244
|
+
/** Options for getEvents — v1.2.9 removed `year`, `dateFrom`, `dateTo` (EP API /events has no date filtering) */
|
|
245
245
|
export interface GetEventsOptions {
|
|
246
246
|
eventId?: string | undefined;
|
|
247
247
|
limit?: number | undefined;
|
|
@@ -288,7 +288,7 @@ export interface GetPlenaryDocumentsOptions {
|
|
|
288
288
|
limit?: number | undefined;
|
|
289
289
|
offset?: number | undefined;
|
|
290
290
|
}
|
|
291
|
-
/** Options for getCommitteeDocuments — v1.2.
|
|
291
|
+
/** Options for getCommitteeDocuments — v1.2.9 removed `year` (EP API ignores it for /committee-documents) */
|
|
292
292
|
export interface GetCommitteeDocumentsOptions {
|
|
293
293
|
docId?: string | undefined;
|
|
294
294
|
limit?: number | undefined;
|
|
@@ -311,7 +311,7 @@ export interface GetControlledVocabulariesOptions {
|
|
|
311
311
|
limit?: number | undefined;
|
|
312
312
|
offset?: number | undefined;
|
|
313
313
|
}
|
|
314
|
-
/** Options for getExternalDocuments — v1.2.
|
|
314
|
+
/** Options for getExternalDocuments — v1.2.9 removed `year` (EP API ignores it for /external-documents) */
|
|
315
315
|
export interface GetExternalDocumentsOptions {
|
|
316
316
|
docId?: string | undefined;
|
|
317
317
|
limit?: number | undefined;
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* @module Utils/ValidateAnalysisCompleteness
|
|
5
|
+
* @description Pre-article-generation blocking gate that enforces
|
|
6
|
+
* `analysis/methodologies/ai-driven-analysis-guide.md` §Reference-Quality Depth
|
|
7
|
+
* Requirements and Rule 19 (Mandatory Pre-Flight Analysis Reading).
|
|
8
|
+
*
|
|
9
|
+
* This validator is the hard precondition that agentic news workflows MUST pass
|
|
10
|
+
* before invoking any article generator. It verifies that the analysis run
|
|
11
|
+
* directory contains the mandatory intelligence artifacts with sufficient depth,
|
|
12
|
+
* no placeholder markers, and a well-formed `manifest.json` listing every
|
|
13
|
+
* artifact under `files.*`.
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* - 0 — all mandatory artifacts present, each ≥ `--min-lines` (default 30),
|
|
17
|
+
* no placeholder markers, manifest lists every on-disk artifact.
|
|
18
|
+
* - 1 — one or more mandatory artifacts missing, too short, contain
|
|
19
|
+
* placeholder markers, or manifest omits an on-disk artifact.
|
|
20
|
+
* - 2 — usage error (missing `--analysis-dir`, unreadable directory, invalid
|
|
21
|
+
* `manifest.json`, etc.).
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* npx tsx src/utils/validate-analysis-completeness.ts --analysis-dir=analysis/daily/2026-04-18/breaking-run184
|
|
25
|
+
* npx tsx src/utils/validate-analysis-completeness.ts --analysis-dir=<dir> --article-type=week-in-review
|
|
26
|
+
* npx tsx src/utils/validate-analysis-completeness.ts --analysis-dir=<dir> --json
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { PROJECT_ROOT } from '../constants/config.js';
|
|
31
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
32
|
+
/** Minimum line count below which an artifact is considered a stub */
|
|
33
|
+
const DEFAULT_MIN_LINES = 30;
|
|
34
|
+
/** Placeholder markers that indicate an incomplete analysis artifact */
|
|
35
|
+
const PLACEHOLDER_MARKERS = [
|
|
36
|
+
'[AI_ANALYSIS_REQUIRED]',
|
|
37
|
+
'AI_ANALYSIS_PENDING',
|
|
38
|
+
'[TO BE FILLED BY AI AGENT]',
|
|
39
|
+
'[TBD]',
|
|
40
|
+
'TODO:',
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* The seven reference-quality intelligence artifacts per
|
|
44
|
+
* `analysis/methodologies/ai-driven-analysis-guide.md` §Reference-Quality Depth
|
|
45
|
+
* Requirements (basis: breaking-run184).
|
|
46
|
+
*/
|
|
47
|
+
const REFERENCE_QUALITY_INTELLIGENCE = [
|
|
48
|
+
'intelligence/pestle-analysis.md',
|
|
49
|
+
'intelligence/stakeholder-map.md',
|
|
50
|
+
'intelligence/scenario-forecast.md',
|
|
51
|
+
'intelligence/threat-model.md',
|
|
52
|
+
'intelligence/historical-baseline.md',
|
|
53
|
+
'intelligence/economic-context.md',
|
|
54
|
+
'intelligence/wildcards-blackswans.md',
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Artifacts required on top of the reference-quality seven.
|
|
58
|
+
* These provide the pre-flight entry point (analysis-index) and the
|
|
59
|
+
* composition layer (synthesis-summary) per Rule 19.
|
|
60
|
+
*/
|
|
61
|
+
const COMMON_REQUIRED = [
|
|
62
|
+
'intelligence/analysis-index.md',
|
|
63
|
+
'intelligence/synthesis-summary.md',
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* Per-article-type additional mandatory artifacts.
|
|
67
|
+
* Weekly / monthly reviews require a historical-baseline (already in the seven);
|
|
68
|
+
* breaking additionally requires coalition-dynamics and an MCP reliability audit
|
|
69
|
+
* during plenary-recess windows when API availability is degraded.
|
|
70
|
+
*/
|
|
71
|
+
const ARTICLE_TYPE_EXTRAS = {
|
|
72
|
+
breaking: ['intelligence/coalition-dynamics.md'],
|
|
73
|
+
'week-in-review': [],
|
|
74
|
+
'month-in-review': [],
|
|
75
|
+
'week-ahead': [],
|
|
76
|
+
'month-ahead': [],
|
|
77
|
+
'committee-reports': [],
|
|
78
|
+
motions: [],
|
|
79
|
+
propositions: [],
|
|
80
|
+
};
|
|
81
|
+
// ─── CLI parsing ──────────────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Apply a single CLI argument token to an in-progress options object.
|
|
84
|
+
*
|
|
85
|
+
* @param arg - The raw CLI token.
|
|
86
|
+
* @param opts - Mutable options being built.
|
|
87
|
+
* @returns `true` when the arg is recognised, `false` otherwise.
|
|
88
|
+
*/
|
|
89
|
+
function applyArg(arg, opts) {
|
|
90
|
+
if (arg.startsWith('--analysis-dir=')) {
|
|
91
|
+
opts.analysisDir = arg.slice('--analysis-dir='.length);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (arg.startsWith('--article-type=')) {
|
|
95
|
+
opts.articleType = arg.slice('--article-type='.length);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (arg.startsWith('--min-lines=')) {
|
|
99
|
+
const parsed = parseInt(arg.slice('--min-lines='.length), 10);
|
|
100
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
101
|
+
opts.minLines = parsed;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (arg === '--json') {
|
|
105
|
+
opts.json = true;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (arg === '--warn-only') {
|
|
109
|
+
opts.warnOnly = true;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Parse command-line arguments into a `CliOptions` record.
|
|
116
|
+
*
|
|
117
|
+
* @param argv - CLI arguments excluding the `node` + script entries.
|
|
118
|
+
* @returns Parsed options; exits with code 2 if required args are missing.
|
|
119
|
+
*/
|
|
120
|
+
function parseArgs(argv) {
|
|
121
|
+
const opts = {
|
|
122
|
+
analysisDir: '',
|
|
123
|
+
minLines: DEFAULT_MIN_LINES,
|
|
124
|
+
json: false,
|
|
125
|
+
warnOnly: false,
|
|
126
|
+
};
|
|
127
|
+
for (const arg of argv) {
|
|
128
|
+
if (arg === '--help' || arg === '-h') {
|
|
129
|
+
printHelp();
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
applyArg(arg, opts);
|
|
133
|
+
}
|
|
134
|
+
if (!opts.analysisDir) {
|
|
135
|
+
console.error('❌ Missing required argument: --analysis-dir=<path>');
|
|
136
|
+
printHelp();
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
return opts;
|
|
140
|
+
}
|
|
141
|
+
function printHelp() {
|
|
142
|
+
console.log(`
|
|
143
|
+
validate-analysis-completeness — pre-article-generation blocking gate
|
|
144
|
+
|
|
145
|
+
Usage:
|
|
146
|
+
npx tsx src/utils/validate-analysis-completeness.ts \\
|
|
147
|
+
--analysis-dir=analysis/daily/<date>/<type>-run<id> \\
|
|
148
|
+
[--article-type=<slug>] \\
|
|
149
|
+
[--min-lines=30] \\
|
|
150
|
+
[--json] \\
|
|
151
|
+
[--warn-only]
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
--analysis-dir=<path> Run directory to validate (required).
|
|
155
|
+
Path is resolved relative to PROJECT_ROOT.
|
|
156
|
+
--article-type=<slug> Article category slug (breaking, week-in-review, …).
|
|
157
|
+
When omitted, inferred from manifest.json.
|
|
158
|
+
--min-lines=<n> Minimum line count per artifact (default 30).
|
|
159
|
+
--json Emit a JSON report on stdout instead of text.
|
|
160
|
+
--warn-only Exit 0 on validation failure (report only). Use for
|
|
161
|
+
local exploration; workflows MUST NOT pass this flag.
|
|
162
|
+
|
|
163
|
+
Exit codes:
|
|
164
|
+
0 = all mandatory artifacts present, no placeholders, manifest consistent
|
|
165
|
+
1 = validation failed
|
|
166
|
+
2 = usage error (missing args, unreadable dir, invalid manifest)
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Extract all analysis file paths from the manifest's `files` field.
|
|
171
|
+
* Supports two shapes: nested `{ intelligence: [...] }` or flat `{ "path": "desc" }`.
|
|
172
|
+
*
|
|
173
|
+
* @param filesField - The `files` object from manifest.json.
|
|
174
|
+
* @returns Array of relative artifact paths listed in the manifest.
|
|
175
|
+
*/
|
|
176
|
+
function extractListedPaths(filesField) {
|
|
177
|
+
if (!filesField || typeof filesField !== 'object')
|
|
178
|
+
return [];
|
|
179
|
+
const allListed = [];
|
|
180
|
+
const firstValue = Object.values(filesField)[0];
|
|
181
|
+
if (Array.isArray(firstValue)) {
|
|
182
|
+
// Nested shape: { category: string[] }
|
|
183
|
+
for (const arr of Object.values(filesField)) {
|
|
184
|
+
if (!Array.isArray(arr))
|
|
185
|
+
continue;
|
|
186
|
+
for (const rel of arr) {
|
|
187
|
+
if (typeof rel === 'string')
|
|
188
|
+
allListed.push(rel);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return allListed;
|
|
192
|
+
}
|
|
193
|
+
if (firstValue !== undefined) {
|
|
194
|
+
// Flat shape: { "path": "description" }
|
|
195
|
+
return Object.keys(filesField);
|
|
196
|
+
}
|
|
197
|
+
return allListed;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Load and parse `manifest.json` from a run directory, returning any schema
|
|
201
|
+
* errors and the set of listed artifact paths.
|
|
202
|
+
*
|
|
203
|
+
* @param runDir - Absolute path to the analysis run directory.
|
|
204
|
+
* @returns Parsed manifest, list of artifact paths, and any schema errors.
|
|
205
|
+
*/
|
|
206
|
+
function loadManifest(runDir) {
|
|
207
|
+
const manifestPath = path.join(runDir, 'manifest.json');
|
|
208
|
+
const errors = [];
|
|
209
|
+
if (!fs.existsSync(manifestPath)) {
|
|
210
|
+
return { raw: {}, allListedPaths: [], errors: ['manifest.json is missing'] };
|
|
211
|
+
}
|
|
212
|
+
let raw;
|
|
213
|
+
try {
|
|
214
|
+
const text = fs.readFileSync(manifestPath, 'utf-8');
|
|
215
|
+
raw = JSON.parse(text);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
return {
|
|
219
|
+
raw: {},
|
|
220
|
+
allListedPaths: [],
|
|
221
|
+
errors: [`manifest.json is not valid JSON: ${err.message}`],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (!raw.articleType || typeof raw.articleType !== 'string') {
|
|
225
|
+
errors.push('manifest.json is missing top-level "articleType" (Rule 6)');
|
|
226
|
+
}
|
|
227
|
+
const filesField = raw.files;
|
|
228
|
+
if (!filesField || typeof filesField !== 'object') {
|
|
229
|
+
errors.push('manifest.json is missing "files" object');
|
|
230
|
+
return { raw, allListedPaths: [], errors };
|
|
231
|
+
}
|
|
232
|
+
return { raw, allListedPaths: extractListedPaths(filesField), errors };
|
|
233
|
+
}
|
|
234
|
+
// ─── Artifact inspection ─────────────────────────────────────────────────────
|
|
235
|
+
/**
|
|
236
|
+
* Read and inspect a single artifact, producing the data needed by the
|
|
237
|
+
* aggregate pass/fail logic in `countErrors` / `artifactIssues`.
|
|
238
|
+
*
|
|
239
|
+
* @param runDir - Absolute path to the analysis run directory.
|
|
240
|
+
* @param relPath - Path relative to `runDir` of the artifact to inspect.
|
|
241
|
+
* @param listedInManifest - Whether the artifact appears under `manifest.files.*`.
|
|
242
|
+
* @returns Presence, line count, placeholder findings, and manifest-listing flag.
|
|
243
|
+
*/
|
|
244
|
+
function inspectArtifact(runDir, relPath, listedInManifest) {
|
|
245
|
+
const abs = path.join(runDir, relPath);
|
|
246
|
+
if (!fs.existsSync(abs)) {
|
|
247
|
+
return {
|
|
248
|
+
relativePath: relPath,
|
|
249
|
+
present: false,
|
|
250
|
+
lineCount: 0,
|
|
251
|
+
placeholdersFound: [],
|
|
252
|
+
listedInManifest,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const text = fs.readFileSync(abs, 'utf-8');
|
|
256
|
+
const lines = text.split('\n');
|
|
257
|
+
const lineCount = lines.length;
|
|
258
|
+
const placeholders = findUnfilledPlaceholders(lines);
|
|
259
|
+
// NOTE: `lineCount < minLines` is intentionally not flagged here — the caller
|
|
260
|
+
// (`countErrors` / `artifactIssues`) is the single source of truth for
|
|
261
|
+
// short-file failures so the validator can report them with the correct
|
|
262
|
+
// formatting and exit semantics.
|
|
263
|
+
return {
|
|
264
|
+
relativePath: relPath,
|
|
265
|
+
present: true,
|
|
266
|
+
lineCount,
|
|
267
|
+
placeholdersFound: placeholders,
|
|
268
|
+
listedInManifest,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Tests whether a given line is a meta-documentation reference to the placeholder
|
|
273
|
+
* marker (rather than a real unfilled slot). Table rows, negation sentences, and
|
|
274
|
+
* backtick-quoted marker names are considered documentation.
|
|
275
|
+
*
|
|
276
|
+
* @param raw - Raw line from the artifact file (with original indentation).
|
|
277
|
+
* @param trimmed - The same line after leading whitespace is stripped.
|
|
278
|
+
* @param marker - The placeholder marker being checked.
|
|
279
|
+
* @returns `true` when the line describes the marker rather than requiring it.
|
|
280
|
+
*/
|
|
281
|
+
function isMetaDocumentationLine(raw, trimmed, marker) {
|
|
282
|
+
if (trimmed.startsWith('|'))
|
|
283
|
+
return true;
|
|
284
|
+
if (/\b(zero|no|none|without|absent|replaced|replace every)\b/i.test(trimmed))
|
|
285
|
+
return true;
|
|
286
|
+
if (raw.includes('`' + marker + '`'))
|
|
287
|
+
return true;
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Detect placeholder markers that represent an unfilled content slot, while
|
|
292
|
+
* ignoring lines where the marker is referenced in a meta-documentation context
|
|
293
|
+
* (e.g. "Zero [AI_ANALYSIS_REQUIRED] markers", table rows that document absence,
|
|
294
|
+
* or code fences quoting the marker for illustration).
|
|
295
|
+
*
|
|
296
|
+
* @param lines - Lines of the artifact file.
|
|
297
|
+
* @returns Sorted array of placeholder markers that were found as real slots.
|
|
298
|
+
*/
|
|
299
|
+
function findUnfilledPlaceholders(lines) {
|
|
300
|
+
const found = new Set();
|
|
301
|
+
let inCodeFence = false;
|
|
302
|
+
for (const raw of lines) {
|
|
303
|
+
const line = raw.trimStart();
|
|
304
|
+
if (line.startsWith('```')) {
|
|
305
|
+
inCodeFence = !inCodeFence;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (inCodeFence)
|
|
309
|
+
continue;
|
|
310
|
+
for (const marker of PLACEHOLDER_MARKERS) {
|
|
311
|
+
if (!raw.includes(marker))
|
|
312
|
+
continue;
|
|
313
|
+
if (isMetaDocumentationLine(raw, line, marker))
|
|
314
|
+
continue;
|
|
315
|
+
found.add(marker);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return Array.from(found).sort();
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* List all `.md` files directly inside `<runDir>/intelligence/`.
|
|
322
|
+
*
|
|
323
|
+
* @param runDir - Absolute path to the analysis run directory.
|
|
324
|
+
* @returns Array of paths relative to `runDir` (POSIX-style).
|
|
325
|
+
*/
|
|
326
|
+
function walkIntelligenceDir(runDir) {
|
|
327
|
+
const intelDir = path.join(runDir, 'intelligence');
|
|
328
|
+
if (!fs.existsSync(intelDir) || !fs.statSync(intelDir).isDirectory()) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const entry of fs.readdirSync(intelDir)) {
|
|
333
|
+
if (entry.endsWith('.md'))
|
|
334
|
+
out.push(path.posix.join('intelligence', entry));
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
// ─── Validation orchestration ────────────────────────────────────────────────
|
|
339
|
+
/** Type-safe Map view of `ARTICLE_TYPE_EXTRAS` that avoids property-access injection. */
|
|
340
|
+
const ARTICLE_TYPE_EXTRAS_MAP = new Map(Object.entries(ARTICLE_TYPE_EXTRAS));
|
|
341
|
+
/**
|
|
342
|
+
* Compute the set of mandatory artifacts for a given article type.
|
|
343
|
+
*
|
|
344
|
+
* Combines the common `COMMON_REQUIRED` set, the seven reference-quality
|
|
345
|
+
* intelligence artifacts, and any article-type-specific extras.
|
|
346
|
+
*
|
|
347
|
+
* @param articleType - The article category slug (e.g. `breaking`).
|
|
348
|
+
* @returns Sorted list of required relative artifact paths.
|
|
349
|
+
*/
|
|
350
|
+
function computeRequired(articleType) {
|
|
351
|
+
const extras = ARTICLE_TYPE_EXTRAS_MAP.get(articleType) ?? [];
|
|
352
|
+
const set = new Set([...COMMON_REQUIRED, ...REFERENCE_QUALITY_INTELLIGENCE, ...extras]);
|
|
353
|
+
return Array.from(set).sort();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Count how many artifact checks failed, combined with any manifest errors.
|
|
357
|
+
*
|
|
358
|
+
* @param checks - Per-artifact inspection results.
|
|
359
|
+
* @param minLines - Minimum required line count.
|
|
360
|
+
* @param manifestErrorCount - Number of manifest-level errors.
|
|
361
|
+
* @returns Total error count used for the pass/fail decision.
|
|
362
|
+
*/
|
|
363
|
+
function countErrors(checks, minLines, manifestErrorCount) {
|
|
364
|
+
let errorCount = manifestErrorCount;
|
|
365
|
+
for (const c of checks) {
|
|
366
|
+
if (!c.present)
|
|
367
|
+
errorCount++;
|
|
368
|
+
else if (c.lineCount < minLines)
|
|
369
|
+
errorCount++;
|
|
370
|
+
else if (c.placeholdersFound.length > 0)
|
|
371
|
+
errorCount++;
|
|
372
|
+
else if (!c.listedInManifest)
|
|
373
|
+
errorCount++;
|
|
374
|
+
}
|
|
375
|
+
return errorCount;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Thrown by `validate()` for usage errors (missing/unreadable run directory).
|
|
379
|
+
* Carries the exit code that `main()` should use, so all `process.exit(…)`
|
|
380
|
+
* calls live in one place and the `validate()` function stays unit-testable.
|
|
381
|
+
*/
|
|
382
|
+
class ValidationUsageError extends Error {
|
|
383
|
+
exitCode;
|
|
384
|
+
constructor(message, exitCode = 2) {
|
|
385
|
+
super(message);
|
|
386
|
+
this.name = 'ValidationUsageError';
|
|
387
|
+
this.exitCode = exitCode;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Run the full validation pipeline for a given analysis run directory.
|
|
392
|
+
*
|
|
393
|
+
* @param options - Parsed CLI options.
|
|
394
|
+
* @returns Validation result with per-artifact checks and pass/fail flag.
|
|
395
|
+
* @throws {ValidationUsageError} When the analysis directory does not exist
|
|
396
|
+
* or is not a directory — the CLI entrypoint translates this into
|
|
397
|
+
* `process.exit(2)`.
|
|
398
|
+
*/
|
|
399
|
+
function validate(options) {
|
|
400
|
+
const absRunDir = path.isAbsolute(options.analysisDir)
|
|
401
|
+
? options.analysisDir
|
|
402
|
+
: path.join(PROJECT_ROOT, options.analysisDir);
|
|
403
|
+
if (!fs.existsSync(absRunDir) || !fs.statSync(absRunDir).isDirectory()) {
|
|
404
|
+
throw new ValidationUsageError(`Analysis directory not found or not a directory: ${absRunDir}`, 2);
|
|
405
|
+
}
|
|
406
|
+
const manifest = loadManifest(absRunDir);
|
|
407
|
+
const articleType = options.articleType ?? manifest.raw.articleType ?? 'unknown';
|
|
408
|
+
const required = computeRequired(articleType);
|
|
409
|
+
const listedSet = new Set(manifest.allListedPaths);
|
|
410
|
+
const checks = required.map((rel) => inspectArtifact(absRunDir, rel, listedSet.has(rel)));
|
|
411
|
+
const onDiskIntel = walkIntelligenceDir(absRunDir);
|
|
412
|
+
// O(1)-per-path lookup: convert `required` into a Set for the orphan filter.
|
|
413
|
+
const requiredSet = new Set(required);
|
|
414
|
+
const orphaned = onDiskIntel.filter((rel) => !listedSet.has(rel) && !requiredSet.has(rel));
|
|
415
|
+
// Orphaned files are warnings, not errors (per Rule 6 "contamination risk"
|
|
416
|
+
// they're a signal but not a blocker — a second workflow may legitimately add files)
|
|
417
|
+
const errorCount = countErrors(checks, options.minLines, manifest.errors.length);
|
|
418
|
+
return {
|
|
419
|
+
analysisDir: absRunDir,
|
|
420
|
+
articleType,
|
|
421
|
+
required,
|
|
422
|
+
checks,
|
|
423
|
+
orphanedOnDisk: orphaned,
|
|
424
|
+
manifestValid: manifest.errors.length === 0,
|
|
425
|
+
manifestErrors: manifest.errors,
|
|
426
|
+
passed: errorCount === 0,
|
|
427
|
+
errorCount,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// ─── Reporting ────────────────────────────────────────────────────────────────
|
|
431
|
+
/**
|
|
432
|
+
* Build a list of issue labels for a single artifact check.
|
|
433
|
+
*
|
|
434
|
+
* @param c - The artifact check result.
|
|
435
|
+
* @param minLines - Minimum required line count.
|
|
436
|
+
* @returns Array of short issue labels; empty if the artifact passes.
|
|
437
|
+
*/
|
|
438
|
+
function artifactIssues(c, minLines) {
|
|
439
|
+
if (!c.present)
|
|
440
|
+
return ['MISSING'];
|
|
441
|
+
const parts = [];
|
|
442
|
+
if (c.lineCount < minLines)
|
|
443
|
+
parts.push(`SHORT (${c.lineCount} < ${minLines} lines)`);
|
|
444
|
+
if (c.placeholdersFound.length > 0) {
|
|
445
|
+
parts.push(`PLACEHOLDERS (${c.placeholdersFound.join(', ')})`);
|
|
446
|
+
}
|
|
447
|
+
if (!c.listedInManifest)
|
|
448
|
+
parts.push('NOT_LISTED_IN_MANIFEST');
|
|
449
|
+
return parts;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Print the header block of a text-mode report.
|
|
453
|
+
*
|
|
454
|
+
* @param result - Validation result.
|
|
455
|
+
* @param minLines - Minimum required line count.
|
|
456
|
+
*/
|
|
457
|
+
function printHeader(result, minLines) {
|
|
458
|
+
console.log('━'.repeat(72));
|
|
459
|
+
console.log('🔍 Analysis Completeness Validator (Rule 19 pre-flight gate)');
|
|
460
|
+
console.log('━'.repeat(72));
|
|
461
|
+
console.log(`📁 Run dir : ${path.relative(PROJECT_ROOT, result.analysisDir)}`);
|
|
462
|
+
console.log(`🏷️ Article type : ${result.articleType}`);
|
|
463
|
+
console.log(`📋 Required count : ${result.required.length}`);
|
|
464
|
+
console.log(`🧾 Min lines/file : ${minLines}`);
|
|
465
|
+
console.log('');
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Print the pass/fail footer of a text-mode report.
|
|
469
|
+
*
|
|
470
|
+
* @param result - Validation result.
|
|
471
|
+
*/
|
|
472
|
+
function printFooter(result) {
|
|
473
|
+
console.log('');
|
|
474
|
+
if (result.passed) {
|
|
475
|
+
console.log('✅ Pre-flight gate PASSED — article generation may proceed.');
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
console.log(`❌ Pre-flight gate FAILED — ${result.errorCount} error(s). ` +
|
|
479
|
+
'Article generation MUST NOT proceed.');
|
|
480
|
+
console.log(' See analysis/methodologies/ai-driven-analysis-guide.md §Rule 19 and');
|
|
481
|
+
console.log(' .github/prompts/SHARED_PROMPT_PATTERNS.md §Article Generation Pre-Flight.');
|
|
482
|
+
}
|
|
483
|
+
console.log('━'.repeat(72));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Render the full text-mode report to stdout.
|
|
487
|
+
*
|
|
488
|
+
* @param result - Validation result.
|
|
489
|
+
* @param minLines - Minimum required line count threshold.
|
|
490
|
+
*/
|
|
491
|
+
function renderTextReport(result, minLines) {
|
|
492
|
+
printHeader(result, minLines);
|
|
493
|
+
if (!result.manifestValid) {
|
|
494
|
+
console.log('❌ Manifest errors:');
|
|
495
|
+
for (const err of result.manifestErrors)
|
|
496
|
+
console.log(` • ${err}`);
|
|
497
|
+
console.log('');
|
|
498
|
+
}
|
|
499
|
+
console.log('📊 Artifact checks:');
|
|
500
|
+
for (const c of result.checks) {
|
|
501
|
+
const issues = artifactIssues(c, minLines);
|
|
502
|
+
const status = issues.length === 0 ? '✅ ok' : `❌ ${issues.join('; ')}`;
|
|
503
|
+
const lineInfo = c.present ? ` (${c.lineCount} lines)` : '';
|
|
504
|
+
console.log(` ${status.padEnd(60)} ${c.relativePath}${lineInfo}`);
|
|
505
|
+
}
|
|
506
|
+
if (result.orphanedOnDisk.length > 0) {
|
|
507
|
+
console.log('');
|
|
508
|
+
console.log('⚠️ Intelligence files on disk not listed in manifest.files.*:');
|
|
509
|
+
for (const rel of result.orphanedOnDisk)
|
|
510
|
+
console.log(` • ${rel}`);
|
|
511
|
+
console.log(' → Update manifest.files.intelligence[] to include these');
|
|
512
|
+
console.log(' (or delete them if they are leftovers from a prior run).');
|
|
513
|
+
}
|
|
514
|
+
printFooter(result);
|
|
515
|
+
}
|
|
516
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
517
|
+
/**
|
|
518
|
+
* CLI entrypoint — parses args, runs validation, renders output, and owns
|
|
519
|
+
* every `process.exit(…)` decision for this module.
|
|
520
|
+
*/
|
|
521
|
+
function main() {
|
|
522
|
+
const options = parseArgs(process.argv.slice(2));
|
|
523
|
+
let result;
|
|
524
|
+
try {
|
|
525
|
+
result = validate(options);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (err instanceof ValidationUsageError) {
|
|
529
|
+
console.error(`❌ ${err.message}`);
|
|
530
|
+
process.exit(err.exitCode);
|
|
531
|
+
}
|
|
532
|
+
throw err;
|
|
533
|
+
}
|
|
534
|
+
if (options.json) {
|
|
535
|
+
console.log(JSON.stringify(result, null, 2));
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
renderTextReport(result, options.minLines);
|
|
539
|
+
}
|
|
540
|
+
if (!result.passed && !options.warnOnly) {
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
main();
|
|
545
|
+
//# sourceMappingURL=validate-analysis-completeness.js.map
|