euparliamentmonitor 0.9.20 → 0.9.21

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.
@@ -4,7 +4,7 @@ import { _parseResultPayload } from './parse.js';
4
4
  /**
5
5
  * Classify an error message into a diagnostic error category.
6
6
  *
7
- * Maps EP MCP Server v1.3.9 structured error codes and generic HTTP/network
7
+ * Maps EP MCP Server v1.3.10 structured error codes and generic HTTP/network
8
8
  * errors into one of six broad categories used for logging and retry decisions:
9
9
  *
10
10
  * Returned categories (priority order):
@@ -55,7 +55,7 @@ export function classifyToolError(message) {
55
55
  * covering the two shapes historically emitted by the EP MCP server.
56
56
  *
57
57
  * 1. **Uniform envelope** (all feeds as of
58
- * `european-parliament-mcp-server@1.3.9`) —
58
+ * `european-parliament-mcp-server@1.3.10`) —
59
59
  * `{status:"unavailable", items:[], generatedAt:"..."}` established by
60
60
  * Hack23/European-Parliament-MCP-Server#301 and extended to
61
61
  * `get_events_feed`/`get_procedures_feed` by
@@ -34,9 +34,25 @@
34
34
  * is a machine-readable fixed token; dropping a diagram silently breaks
35
35
  * downstream HTML rendering.
36
36
  *
37
+ * **Skeleton-aware mode**: when a translation file declares itself as a
38
+ * Phase A skeleton via the `<!-- translation-skeleton: lang=<code> ... -->`
39
+ * marker (written by the `news-translate` workflow's 2-phase
40
+ * largeSource strategy), gates 3–7 are SKIPPED and a single
41
+ * `skeleton-incomplete` advisory is emitted instead. The advisory is
42
+ * classified as `severity: warning` and does NOT cause a non-zero exit
43
+ * unless `--strict-skeletons` is passed. This is intentional: emergency
44
+ * partial flushes (Step 4b of the translate workflow) write skeleton
45
+ * stubs for languages that did not reach Phase B before the wall-clock
46
+ * budget expired; those stubs would otherwise trigger 5+ cascading
47
+ * violations per file (length-floor, fixed-token-preservation,
48
+ * heading-parity, mermaid-parity) that drown out real defects in fully
49
+ * translated siblings. Real translations in the same brief continue to
50
+ * receive strict validation.
51
+ *
37
52
  * Each translation that fails any gate produces a structured report entry.
38
53
  * The process exits with code 1 if any failures are present (unless
39
- * `--no-fail` is passed for advisory mode).
54
+ * `--no-fail` is passed for advisory mode, or unless every remaining
55
+ * violation has `severity: warning`).
40
56
  *
41
57
  * This script is invoked by:
42
58
  * - `npm run validate:translations` (CI + local)
@@ -48,6 +64,8 @@
48
64
  * [--paths <glob>...] # validate specific translation files only
49
65
  * [--report <path>] # write JSON report; default stdout
50
66
  * [--no-fail] # exit 0 even when violations found
67
+ * [--strict-skeletons] # treat skeleton-incomplete advisories as
68
+ * # blocking violations (default: warning)
51
69
  * [--quiet] # suppress per-file logging
52
70
  */
53
71
 
@@ -199,6 +217,45 @@ export function countMermaidBlocks(text) {
199
217
  return countGlobal(text, MERMAID_OPENER);
200
218
  }
201
219
 
220
+ /**
221
+ * Marker that the `news-translate` workflow Phase A writes at the very top
222
+ * of every skeleton file (before the H1 line). When the validator sees this
223
+ * marker it knows the file is a deliberately incomplete Phase A skeleton
224
+ * from an emergency partial flush and emits a single `skeleton-incomplete`
225
+ * advisory instead of cascading length/token/heading/mermaid violations.
226
+ *
227
+ * The marker is intentionally a forgiving regex: any HTML comment starting
228
+ * with `<!-- translation-skeleton` within the first 10 lines counts. This
229
+ * accommodates both `<!-- translation-skeleton: lang=sv phase=A -->` and
230
+ * the legacy `<!-- translation-skeleton -->` formats.
231
+ */
232
+ export const SKELETON_MARKER_RE = /<!--\s*translation-skeleton\b/;
233
+
234
+ /**
235
+ * Heuristic skeleton detector. A file is a skeleton if EITHER:
236
+ * - it contains the explicit `SKELETON_MARKER_RE` marker in its first
237
+ * 10 lines (preferred, set by Phase A), OR
238
+ * - it matches the fallback heuristic for older Phase A output that
239
+ * pre-dates the marker convention: ≥3 H2 headings AND the number of
240
+ * `<!-- pending -->` / `<PENDING>` / `PENDING` placeholders is at
241
+ * least equal to the H2 count (i.e. every H2 section appears to be
242
+ * an unfilled stub).
243
+ *
244
+ * @param {string} text
245
+ * @returns {boolean}
246
+ */
247
+ export function isSkeletonStub(text) {
248
+ if (typeof text !== 'string' || text.length === 0) return false;
249
+ const head = text.split('\n', 10).join('\n');
250
+ if (SKELETON_MARKER_RE.test(head)) return true;
251
+ // Fallback heuristic: many H2s, almost every body line is a pending marker.
252
+ const h2Count = countHeadings(text, 2);
253
+ if (h2Count < 3) return false;
254
+ const pendingRe = /(<!--\s*pending\s*-->|<PENDING>|\bPENDING\b)/i;
255
+ const pendingHits = (text.match(new RegExp(pendingRe.source, 'gi')) || []).length;
256
+ return pendingHits >= h2Count;
257
+ }
258
+
202
259
  /**
203
260
  * Extract H2 section titles from markdown text. Mirrors the shape returned
204
261
  * by `scripts/discover-untranslated-briefs.js#extractH2Titles` so the
@@ -324,6 +381,10 @@ export function aggregateByKey(items, key) {
324
381
  * @property {string} lang
325
382
  * @property {string} gate
326
383
  * @property {string} message
384
+ * @property {'error'|'warning'} [severity] - When present, controls
385
+ * blocking semantics: `'warning'` entries (e.g. `skeleton-incomplete`)
386
+ * do not cause a non-zero exit unless `--strict-skeletons` is passed.
387
+ * Omitted entries default to blocking (`'error'` equivalent).
327
388
  */
328
389
 
329
390
  /** Parse CLI argv. Exported for unit tests. */
@@ -334,6 +395,7 @@ export function parseArgs(argv) {
334
395
  report: null,
335
396
  fail: true,
336
397
  quiet: false,
398
+ strictSkeletons: false,
337
399
  };
338
400
  for (let i = 0; i < argv.length; i += 1) {
339
401
  const arg = argv[i];
@@ -355,6 +417,9 @@ export function parseArgs(argv) {
355
417
  case '--no-fail':
356
418
  opts.fail = false;
357
419
  break;
420
+ case '--strict-skeletons':
421
+ opts.strictSkeletons = true;
422
+ break;
358
423
  case '--quiet':
359
424
  opts.quiet = true;
360
425
  break;
@@ -362,7 +427,8 @@ export function parseArgs(argv) {
362
427
  case '-h':
363
428
  process.stdout.write(
364
429
  'Usage: validate-brief-translations.js [--repo-root <path>] ' +
365
- '[--paths <file>...] [--report <path>] [--no-fail] [--quiet]\n'
430
+ '[--paths <file>...] [--report <path>] [--no-fail] ' +
431
+ '[--strict-skeletons] [--quiet]\n'
366
432
  );
367
433
  process.exit(0);
368
434
  break;
@@ -444,6 +510,34 @@ export function validateTranslation(translationPath, repoRoot) {
444
510
  return violations;
445
511
  }
446
512
 
513
+ // Skeleton short-circuit: when a translation file declares itself as a
514
+ // Phase A skeleton (via the `<!-- translation-skeleton -->` marker the
515
+ // news-translate workflow writes during emergency partial flushes), skip
516
+ // gates 3–7 and emit a single non-blocking `skeleton-incomplete`
517
+ // advisory. The marker is a deliberate contract between the workflow
518
+ // and this validator: an emergency partial flush is a SUCCESSFUL
519
+ // outcome (some real translations were saved), and the unfilled
520
+ // skeleton stubs for languages that did not reach Phase B should not
521
+ // generate 5+ cascading violations that drown out real defects in
522
+ // the fully translated siblings. The next scheduled run will pick up
523
+ // the skeleton languages via the discovery queue's missing-language
524
+ // detection. See `.github/workflows/news-translate.md` §"🐘
525
+ // LARGE-SOURCE 2-PHASE STRATEGY" and §"4b. Wall-clock safety net".
526
+ const targetTextEarly = fs.readFileSync(translationPath, 'utf8');
527
+ if (isSkeletonStub(targetTextEarly)) {
528
+ violations.push({
529
+ translationPath: rel,
530
+ sourcePath: sourceRel,
531
+ lang,
532
+ gate: 'skeleton-incomplete',
533
+ severity: 'warning',
534
+ message:
535
+ `Phase A skeleton stub — translation for "${lang}" did not reach Phase B before the wall-clock budget expired. ` +
536
+ `Re-queue this language on the next scheduled run; the discovery script will detect it as a missing sibling.`,
537
+ });
538
+ return violations;
539
+ }
540
+
447
541
  const sourceBytes = fs.statSync(sourcePath).size;
448
542
  const targetBytes = fs.statSync(translationPath).size;
449
543
  if (sourceBytes > 0 && targetBytes < sourceBytes * LENGTH_FLOOR_RATIO) {
@@ -458,7 +552,7 @@ export function validateTranslation(translationPath, repoRoot) {
458
552
  });
459
553
  }
460
554
 
461
- const targetText = fs.readFileSync(translationPath, 'utf8');
555
+ const targetText = targetTextEarly;
462
556
  let englishHits = 0;
463
557
  for (const re of EN_PATTERNS) {
464
558
  if (re.test(targetText)) englishHits += 1;
@@ -602,8 +696,9 @@ export function runValidation(translationPaths, repoRoot, { quiet = false } = {}
602
696
  allViolations.push(...v);
603
697
  if (!quiet) {
604
698
  for (const entry of v) {
699
+ const icon = entry.severity === 'warning' ? '⚠️' : '❌';
605
700
  process.stderr.write(
606
- `❌ ${entry.translationPath} [${entry.gate}] ${entry.message}\n`
701
+ `${icon} ${entry.translationPath} [${entry.gate}] ${entry.message}\n`
607
702
  );
608
703
  }
609
704
  }
@@ -614,6 +709,23 @@ export function runValidation(translationPaths, repoRoot, { quiet = false } = {}
614
709
  return allViolations;
615
710
  }
616
711
 
712
+ /**
713
+ * Count the entries in a violations list that should be treated as
714
+ * blocking (cause a non-zero exit). When `strictSkeletons` is false
715
+ * (the default), entries with `severity: 'warning'` — i.e. the
716
+ * skeleton-incomplete advisory emitted for Phase A stubs from
717
+ * emergency partial flushes — are not counted as blocking.
718
+ */
719
+ export function countBlockingViolations(violations, { strictSkeletons = false } = {}) {
720
+ if (strictSkeletons) {
721
+ // Promote skeleton-incomplete advisories to blocking, but leave any
722
+ // other warning-level advisory types non-blocking so that future
723
+ // additions of new warning gates don't inadvertently become strict.
724
+ return violations.filter((v) => v.severity !== 'warning' || v.gate === 'skeleton-incomplete').length;
725
+ }
726
+ return violations.filter((v) => v.severity !== 'warning').length;
727
+ }
728
+
617
729
  /** Main entry point. */
618
730
  export function main(argv) {
619
731
  const opts = parseArgs(argv);
@@ -622,12 +734,14 @@ export function main(argv) {
622
734
  : findAllTranslations(opts.repoRoot);
623
735
 
624
736
  const violations = runValidation(paths, opts.repoRoot, { quiet: opts.quiet });
737
+ const blocking = countBlockingViolations(violations, { strictSkeletons: opts.strictSkeletons });
625
738
 
626
739
  const report = {
627
740
  generatedAt: new Date().toISOString(),
628
741
  totals: {
629
742
  filesChecked: paths.length,
630
743
  violations: violations.length,
744
+ blocking,
631
745
  byGate: aggregateByKey(violations, 'gate'),
632
746
  byLang: aggregateByKey(violations, 'lang'),
633
747
  },
@@ -641,7 +755,7 @@ export function main(argv) {
641
755
  process.stdout.write(json);
642
756
  }
643
757
 
644
- if (violations.length > 0 && opts.fail) {
758
+ if (blocking > 0 && opts.fail) {
645
759
  process.exit(1);
646
760
  }
647
761
  return report;