Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | 2x 2x 2x 2x 2x 68x 68x 64x 3x 61x 61x 2x 59x 59x 59x 13x 52x 13x 49x 13x | /**
* File Ownership Validator for News Workflow Conflict Prevention
*
* Enforces a strict file-ownership contract between content and translation workflows:
* - Content workflows (news-committee-reports, news-propositions, etc.) own EN/SV files
* - Translation workflow (news-translate) owns all other language files (DA/NO/FI/DE/FR/ES/NL/AR/HE/JA/KO/ZH)
*
* This prevents merge conflicts when concurrent workflows touch the same date's article files.
*
* @author Hack23 AB
* @license Apache-2.0
*/
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Languages owned by content generation workflows */
export const CONTENT_LANGS = ['en', 'sv'] as const;
/** Languages owned by the translation workflow */
export const TRANSLATION_LANGS = [
'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh',
] as const;
/** Workflow category for file ownership validation */
export type WorkflowCategory = 'content' | 'translation';
/** Result of a file ownership validation check */
export interface ValidationResult {
/** Whether all pending files (staged + unstaged + untracked) pass ownership validation */
passed: boolean;
/** Files that violate the ownership contract */
violations: string[];
/** Total pending news HTML files checked */
checkedCount: number;
}
/**
* Extract the language code from a news article filename.
* Expected pattern: `news/YYYY-MM-DD-slug-{lang}.html`
*
* @param filepath - The file path to extract the language from
* @returns The two-letter language code, or null if no match
*/
export function extractLangFromPath(filepath: string): string | null {
const match = filepath.match(/-([a-z]{2})\.html$/);
return match?.[1] ?? null;
}
/**
* Check whether a file belongs to the given workflow category.
*
* @param filepath - The file path to check
* @param category - The workflow category ('content' or 'translation')
* @returns true if the file is allowed for the given category
*/
export function isFileOwnedByCategory(
filepath: string,
category: WorkflowCategory,
): boolean {
if (!filepath.startsWith('news/') || !filepath.endsWith('.html')) {
// Non-news or non-HTML files are always allowed (metadata, indexes, etc.)
return true;
}
const lang = extractLangFromPath(filepath);
if (!lang) {
// Files without a language suffix (e.g., news/index.html) are allowed for both
return true;
}
const isContentLang = (CONTENT_LANGS as readonly string[]).includes(lang);
const isTranslationLang = (TRANSLATION_LANGS as readonly string[]).includes(lang);
return category === 'content' ? isContentLang : isTranslationLang;
}
/**
* Validate that all pending files (staged + unstaged + untracked working-tree changes)
* conform to the file-ownership contract for the given workflow category.
*
* Checks the union of:
* - `git diff --cached --name-only` (staged)
* - `git diff --name-only` (unstaged modifications)
* - `git ls-files --others --exclude-standard` (untracked new files)
*
* This ensures violations are caught regardless of whether `git add` has been run.
*
* @param category - The workflow category ('content' or 'translation')
* @returns Validation result with pass/fail status and any violations
*/
export function validatePendingFileOwnership(
category: WorkflowCategory,
): ValidationResult {
const stagedOutput = execSync('git diff --cached --name-only', {
encoding: 'utf-8',
}).trim();
const unstagedOutput = execSync('git diff --name-only', {
encoding: 'utf-8',
}).trim();
const untrackedOutput = execSync(
'git ls-files --others --exclude-standard',
{ encoding: 'utf-8' },
).trim();
const allFiles = new Set<string>();
if (stagedOutput) {
for (const f of stagedOutput.split('\n')) if (f) allFiles.add(f);
}
if (unstagedOutput) {
for (const f of unstagedOutput.split('\n')) if (f) allFiles.add(f);
}
if (untrackedOutput) {
for (const f of untrackedOutput.split('\n')) if (f) allFiles.add(f);
}
if (allFiles.size === 0) {
return { passed: true, violations: [], checkedCount: 0 };
}
return validateFileList([...allFiles], category);
}
/**
* Validate ownership for staged files only (backwards-compatible legacy API).
*
* @deprecated Use `validatePendingFileOwnership` instead, which validates
* pending changes including staged, unstaged, and untracked files.
*/
export function validateStagedFileOwnership(
category: WorkflowCategory,
): ValidationResult {
const stagedOutput = execSync('git diff --cached --name-only', {
encoding: 'utf-8',
}).trim();
if (!stagedOutput) {
return { passed: true, violations: [], checkedCount: 0 };
}
const files = stagedOutput.split('\n').filter((f) => f);
return validateFileList(files, category);
}
/**
* Validate a list of file paths against the ownership contract.
* This is the pure-logic core, usable without git.
*
* @param files - Array of file paths to validate
* @param category - The workflow category ('content' or 'translation')
* @returns Validation result with pass/fail status and any violations
*/
export function validateFileList(
files: string[],
category: WorkflowCategory,
): ValidationResult {
const newsHtmlFiles = files.filter(
(f) => f.startsWith('news/') && f.endsWith('.html'),
);
const violations = newsHtmlFiles.filter(
(f) => !isFileOwnedByCategory(f, category),
);
return {
passed: violations.length === 0,
violations,
checkedCount: newsHtmlFiles.length,
};
}
/* istanbul ignore next -- CLI entry point */
if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1] ?? '')) {
const category = process.argv[2] as WorkflowCategory | undefined;
if (!category || !['content', 'translation'].includes(category)) {
console.error(
'Usage: npx tsx scripts/validate-file-ownership.ts <content|translation>\n' +
' Validates staged, unstaged, and untracked changes against the file-ownership contract.',
);
process.exit(2);
}
const result = validatePendingFileOwnership(category);
if (result.passed) {
console.log(
`✅ File ownership validation passed (${result.checkedCount} news files checked for '${category}' category)`,
);
process.exit(0);
} else {
console.error(
`❌ File ownership violation! The following files do not belong to the '${category}' workflow category:`,
);
for (const v of result.violations) {
console.error(` - ${v}`);
}
process.exit(1);
}
}
|