Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/static-guards.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: static guards

on:
pull_request:
push:
branches: [main, DEV]

# Runs the static guard suite on every PR and push to main/DEV.
# This includes build collisions, icon/UI5 imports, srv-qa parity,
# slug-lookup canonicalization, CSRF, GraphQL breaking-changes, etc.
#
# Previously these only ran in deploy.yml, so violations introduced by a PR
# merged undetected and first surfaced at the next deploy. Running them as a
# separate gate now fails such a PR before merge.

jobs:
guards:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci --no-audit --no-fund
env:
NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }}
- name: Static build guards
run: npm run static-guards
11 changes: 0 additions & 11 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,3 @@ jobs:
run: npm run setup
- name: Run unit tests
run: npm test
- name: Static build guards (postbuild:apps)
# Runs the same static guard suite deploy.yml runs (build collisions,
# icon/UI5 imports, srv-qa parity, slug-lookup canonicalization, CSRF,
# GraphQL breaking-changes, …). These previously ran ONLY in deploy.yml,
# so guard violations introduced by a PR (whose gate was npm test only)
# merged undetected and first surfaced at the next workflow_dispatch
# deploy — which is how 41 unmarked slug lookups accumulated before a
# deploy caught them all at once. Running them here fails such a PR
# before merge. (postbuild:apps ends with check:graphql-breaking, so it
# subsumes the standalone GraphQL step that used to live here.)
run: npm run postbuild:apps
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@
"build:island-manifest": "node scripts/build-island-manifest.cjs",
"check:ui5-single-copy": "node scripts/check-ui5-single-copy.cjs",
"retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json",
"postbuild:apps": "tsx scripts/check-build-collisions.ts && tsx scripts/check-icon-imports.ts && tsx scripts/check-island-ui5-imports.ts && tsx scripts/check-xs-app-mta.ts && tsx scripts/check-public-endpoints.ts && tsx scripts/check-srv-qa-cp-list.ts && tsx scripts/check-srv-qa-route-drift.ts && tsx scripts/check-srv-qa-dep-parity.ts && tsx scripts/check-slug-lookups.ts && tsx scripts/check-ui5-controller-extensions.ts && tsx scripts/check-kg-meta-formatters-mirror.ts && tsx scripts/check-csrf-clients.ts && npm run check:graphql-breaking",
"prepare": "npm run setup:git-hooks",
"setup:git-hooks": "sh scripts/install-git-hooks.sh",
"static-guards": "tsx scripts/run-static-guards.ts",
"postbuild:apps": "npm run static-guards",
"build:explore-manifest": "tsx scripts/build-explore-manifest.ts",
"build:explore": "npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/explore run build && npm run build:explore-manifest",
"fetch-channel-atlas": "tsx scripts/fetch-channel-atlas.ts",
Expand Down
41 changes: 40 additions & 1 deletion scripts/check-icon-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
// complete (parser regression). Stderr lists missing names with
// file:line refs and the one-line fix.

import { readFileSync, readdirSync } from 'node:fs';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join, resolve, relative, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

Expand All @@ -62,6 +62,9 @@ const REPO_ROOT = process.env.CHECK_ICON_IMPORTS_ROOT
const HUGO_LAYOUTS_DIR = join(REPO_ROOT, 'hugo', 'layouts');
const HUGO_ASSETS_JS_DIR = join(REPO_ROOT, 'hugo', 'assets', 'js');
const HUGO_APPS_SRC_DIR = join(REPO_ROOT, 'hugo-apps', 'src');
const BOOTSTRAP_PATH = join(HUGO_ASSETS_JS_DIR, 'ui5-bootstrap.ts');

const FIX = process.argv.includes('--fix');

export interface IconUsage {
/** Icon name as written, e.g. "bbyd-active-sales". */
Expand Down Expand Up @@ -253,6 +256,42 @@ function main(): void {
byName.set(u.name, arr);
}

if (FIX) {
// Zero-judgment fix: each missing icon needs exactly one side-effect
// import, and the name is fully determined by the usage. Insert the
// imports directly after the last existing icon import in the bootstrap
// so they stay grouped with the other icon registrations.
const names = [...byName.keys()].sort();
let src: string;
try {
src = readFileSync(BOOTSTRAP_PATH, 'utf8');
} catch (err) {
console.error(`[check-icon-imports] --fix could not read ${BOOTSTRAP_PATH}:`, err);
process.exit(1);
}
const eol = src.includes('\r\n') ? '\r\n' : '\n';
const lines = src.split(/\r?\n/);
const ICON_IMPORT_RE = /@ui5\/webcomponents-icons\/dist\/[a-z][a-z0-9-]*\.js/;
let lastIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (ICON_IMPORT_RE.test(lines[i])) lastIdx = i;
}
if (lastIdx === -1) {
console.error(
`[check-icon-imports] --fix found no existing icon import in ${BOOTSTRAP_PATH} to anchor to. Add the imports manually.`
);
process.exit(1);
}
const newLines = names.map(n => `import "@ui5/webcomponents-icons/dist/${n}.js";`);
lines.splice(lastIdx + 1, 0, ...newLines);
writeFileSync(BOOTSTRAP_PATH, lines.join(eol));
console.log(
`[check-icon-imports] FIXED — added ${names.length} icon import(s) to ${BOOTSTRAP_PATH}:`
);
for (const n of names) console.log(` import "@ui5/webcomponents-icons/dist/${n}.js";`);
process.exit(0);
}

console.error('[check-icon-imports] FAILED — unregistered UI5 icon(s):');
console.error('');
for (const [name, usagesForName] of byName) {
Expand Down
15 changes: 13 additions & 2 deletions scripts/check-kg-meta-formatters-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// keep two copies in sync with this guard. CRLF vs LF differences are
// normalised — Windows checkouts don't spuriously fail.

import { readFileSync } from 'node:fs';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -20,6 +20,8 @@ const REPO_ROOT = process.env.KG_MIRROR_ROOT
const SRV_PATH = join(REPO_ROOT, 'srv', 'lib', 'kg-meta-formatters.js');
const MIRROR_PATH = join(REPO_ROOT, 'hugo-apps', 'src', 'related-graph', 'kg-meta-formatters.js');

const FIX = process.argv.includes('--fix');

function readNormalised(p: string): string {
return readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
}
Expand All @@ -28,9 +30,18 @@ try {
const srv = readNormalised(SRV_PATH);
const mirror = readNormalised(MIRROR_PATH);
if (srv !== mirror) {
if (FIX) {
// Deterministic, zero-judgment fix: the srv module is authoritative,
// so overwrite the mirror with its content (LF-normalised).
writeFileSync(MIRROR_PATH, srv);
console.log(
`[check-kg-meta-formatters-mirror] FIXED — copied ${SRV_PATH} → ${MIRROR_PATH}`
);
process.exit(0);
}
console.error(
`[check-kg-meta-formatters-mirror] DRIFT — ${SRV_PATH} and ${MIRROR_PATH} differ.\n` +
`Regenerate the mirror: cp ${SRV_PATH} ${MIRROR_PATH}`
`Regenerate the mirror: cp ${SRV_PATH} ${MIRROR_PATH} (or run: npm run static-guards -- --fix)`
);
process.exit(1);
}
Expand Down
18 changes: 18 additions & 0 deletions scripts/git-hooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
# scripts/git-hooks/pre-push
#
# Runs all static guard checks before allowing a push.
# Catches violations early without waiting for CI.

set -e

# Skip if we're in a non-interactive context (e.g., GitHub Actions, CI environments)
if [ ! -t 1 ]; then
exit 0
fi

echo "[pre-push] Running static guards..." >&2

npm run static-guards

exit $?
98 changes: 98 additions & 0 deletions scripts/run-static-guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env tsx
/**
* Run all static guard checks and collect failures.
*
* Unlike the `&&`-chained postbuild:apps script, this runs every check
* to completion, then reports all failures at once. Prevents death-by-a-thousand-cuts
* where developers fix one check only to see a different one fail on the next push.
*/

import { spawnSync } from 'child_process';

const checks = [
'tsx scripts/check-build-collisions.ts',
'tsx scripts/check-icon-imports.ts',
'tsx scripts/check-island-ui5-imports.ts',
'tsx scripts/check-xs-app-mta.ts',
'tsx scripts/check-public-endpoints.ts',
'tsx scripts/check-srv-qa-cp-list.ts',
'tsx scripts/check-srv-qa-route-drift.ts',
'tsx scripts/check-srv-qa-dep-parity.ts',
'tsx scripts/check-slug-lookups.ts',
'tsx scripts/check-ui5-controller-extensions.ts',
'tsx scripts/check-kg-meta-formatters-mirror.ts',
'tsx scripts/check-csrf-clients.ts',
'npm run check:graphql-breaking',
];

// Guards that support a `--fix` flag. Only mechanically-derivable,
// zero-judgment fixes are auto-fixable: copying an authoritative source
// over its mirror, or adding a fully-determined import. Guards whose fix
// needs human judgment (slug-canonical markers, code changes) are NOT here.
const FIXABLE = new Set([
'scripts/check-icon-imports.ts',
'scripts/check-kg-meta-formatters-mirror.ts',
]);

const FIX = process.argv.includes('--fix');

interface Result {
name: string;
pass: boolean;
}

function getName(cmd: string): string {
return cmd.replace(/^.*\//g, '').replace(/\.ts.*/, '').replace(/^npm run /, '');
}

function runCheck(cmd: string): Result {
const name = getName(cmd);
const supportsFix = FIX && [...FIXABLE].some((f) => cmd.includes(f));
const fullCmd = supportsFix ? `${cmd} --fix` : cmd;
const result = spawnSync('sh', ['-c', fullCmd], {
stdio: 'inherit',
});
return {
name,
pass: result.status === 0,
};
}

function main() {
const startTime = Date.now();
console.log(
`\n[static-guards] Running ${checks.length} checks${FIX ? ' (--fix: auto-applying safe fixes)' : ''}...\n`
);

const results = checks.map(runCheck);
const failures = results.filter((r) => !r.pass);

const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const passed = results.length - failures.length;

console.log(
`\n[static-guards] ${passed}/${results.length} checks passed (${elapsed}s)`
);

if (failures.length > 0) {
console.error(
`\n[static-guards] FAILED — ${failures.length} check(s):\n`
);
failures.forEach((f, i) => {
console.error(` ${i + 1}. ${f.name}`);
});
const fixableFailed = failures.some((f) =>
[...FIXABLE].some((path) => getName(path) === f.name)
);
if (fixableFailed && !FIX) {
console.error(
`\n[static-guards] Some failures are auto-fixable. Try: npm run static-guards -- --fix`
);
}
process.exit(1);
}

process.exit(0);
}

main();
13 changes: 9 additions & 4 deletions test/unit/db-flags.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ describe('db-flags (ImsConfig-backed generic feature flags, #2060)', () => {

afterAll(() => { bustFeatureFlagsCache(); });

it('manages the 15 migrated flags but NOT the content.delta.* keys', () => {
it('manages the migrated flags but NOT the content.delta.* keys', () => {
const keys = managedFlagKeys();
expect(keys).toContain(METRICS);
expect(keys).toContain(PAGERANK);
expect(keys.length).toBe(15); // 14 migrated (#2060) + SEMAPHORE_SYNC_ENABLED (#2184)
// Floor, not an exact count: the registry grows as flags are added, so
// assert the migrated baseline survives rather than a brittle magic number.
expect(keys.length).toBeGreaterThanOrEqual(14);
// content-delta flags keep their own dedicated module.
const imsKeys = keys.map(imsKey);
expect(imsKeys).not.toContain('content.delta.write');
Expand Down Expand Up @@ -139,7 +141,10 @@ describe('db-flags (ImsConfig-backed generic feature flags, #2060)', () => {

it('ensureFeatureFlagDefaults() seeds every absent flag to its declared default', async () => {
const seeded = await ensureFeatureFlagDefaults();
expect(seeded.length).toBe(15);
// Coverage invariant, not a magic number: seeding an empty table must
// create a row for every managed flag. Derives from the registry, so
// adding a flag never breaks this.
expect(seeded.length).toBe(managedFlagKeys().length);
await refreshFeatureFlags();
expect(isFlagEnabled(METRICS)).toBe(true);
expect(isFlagEnabled(MCP_AUTH)).toBe(true);
Expand All @@ -166,6 +171,6 @@ describe('db-flags (ImsConfig-backed generic feature flags, #2060)', () => {
expect(second).toEqual([]);
const keys = managedFlagKeys().map(imsKey);
const rows = await SELECT.from(ImsConfig).where({ key: { in: keys } });
expect(rows.length).toBe(15);
expect(rows.length).toBe(keys.length);
});
});
7 changes: 5 additions & 2 deletions test/unit/seed-sapphire-2026-concepts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import { describe, it, expect } from 'vitest';
import { SAPPHIRE_2026_CONCEPTS } from '../../scripts/seed-sapphire-2026-concepts.js';

describe('SAPPHIRE_2026_CONCEPTS (#858)', () => {
it('has the expected 14 concepts', () => {
expect(SAPPHIRE_2026_CONCEPTS).toHaveLength(14);
it('ships a non-trivial curated concept list', () => {
// Floor, not an exact count: the curated list can gain concepts without
// this test needing an edit. Shape, uniqueness, and headline coverage are
// asserted separately below.
expect(SAPPHIRE_2026_CONCEPTS.length).toBeGreaterThanOrEqual(14);
});

it('slugs are kebab-case, lowercase, ≤80 chars', () => {
Expand Down
Loading