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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jobs:
changes:
runs-on: ubuntu-latest
outputs:
astryx_surface: ${{ steps.plan.outputs.astryx_surface }}
code: ${{ steps.plan.outputs.code }}
e2e: ${{ steps.plan.outputs.e2e }}
runtime_host: ${{ steps.plan.outputs.runtime_host }}
Expand Down Expand Up @@ -43,6 +44,22 @@ jobs:
node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT"
fi

astryx_surface:
needs: changes
if: needs.changes.outputs.astryx_surface == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Astryx surface inventory
run: npm run astryx:surface-inventory
Comment thread
coderabbitai[bot] marked this conversation as resolved.

typecheck:
needs: changes
if: needs.changes.outputs.code == 'true'
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"verify:windows-x64": "node scripts/verify-windows-x64.mjs",
"verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs",
"astryx:theme": "node scripts/build-astryx-theme.mjs",
"astryx:surface-inventory": "node scripts/check-astryx-surface-inventory.mjs",
"astryx:surface-inventory:write": "node scripts/generate-astryx-surface-inventory.mjs",
"sync:model-metadata": "node scripts/sync-model-metadata.mjs",
"generate:bundled-skills": "node scripts/gen-bundled-skill-catalog.mjs",
"cost:deepseek-baseline": "node scripts/deepseek-live-cost-baseline.mjs",
Expand Down
138 changes: 54 additions & 84 deletions scripts/check-astryx-surface-inventory.mjs
Original file line number Diff line number Diff line change
@@ -1,109 +1,79 @@
#!/usr/bin/env node
/**
* Coverage gate: every product surface file on disk appears in
* docs/astryx-surface-file-inventory.paths (and vice versa).
* Uses the same enumeration as generate-astryx-surface-inventory.mjs.
* Coverage gate: committed inventory artifacts match a fresh generator run.
*
* These files are generated. Hand-editing one half (or leaving a deleted
* path in the Markdown table) is the failure mode. Regenerating and
* comparing bytes is the invariant — not a second, hand-written file list.
*
* Run: npm run astryx:surface-inventory
* Fix: npm run astryx:surface-inventory:write
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { renderAstryxSurfaceInventory } from './generate-astryx-surface-inventory.mjs';

const root = join(fileURLToPath(new URL('..', import.meta.url)));
const pathsFile = join(root, 'docs/astryx-surface-file-inventory.paths');
const mdFile = join(root, 'docs/astryx-surface-file-inventory.md');

// Import shared enumerator by running generate's export via dynamic import
const genUrl = pathToFileURL(join(root, 'scripts/generate-astryx-surface-inventory.mjs')).href;

async function main() {
function main() {
if (!existsSync(pathsFile) || !existsSync(mdFile)) {
console.error(
'missing inventory artifacts — run: node scripts/generate-astryx-surface-inventory.mjs',
);
process.exit(1);
}

// Re-run generator's list logic without rewriting: dynamic import of module
// that calls main() on load — so import the file by reading list from a
// subprocess that only prints paths, OR re-implement via spawning generate
// and comparing. Safest: import after making generate not auto-main.
const { listProductSurfaceFiles } = await import(genUrl);
if (typeof listProductSurfaceFiles !== 'function') {
console.error('generate-astryx-surface-inventory.mjs must export listProductSurfaceFiles');
console.error('missing inventory artifacts — run: npm run astryx:surface-inventory:write');
process.exit(1);
}

const { files: diskFiles, excluded } = listProductSurfaceFiles(root);
const listed = readFileSync(pathsFile, 'utf8')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);

const diskSet = new Set(diskFiles);
const listSet = new Set(listed);
const rendered = renderAstryxSurfaceInventory(root);
const committedMd = readFileSync(mdFile, 'utf8');
const committedPaths = readFileSync(pathsFile, 'utf8');
const mdDrift = committedMd !== rendered.markdown;
const pathsDrift = committedPaths !== rendered.paths;

const missing = diskFiles.filter((f) => !listSet.has(f));
const extra = listed.filter((f) => !diskSet.has(f));

const md = readFileSync(mdFile, 'utf8');
const mdMissing = [];
for (const f of diskFiles) {
if (!md.includes(`\`${f}\``)) mdMissing.push(f);
if (!mdDrift && !pathsDrift) {
console.log(
`astryx surface inventory coverage: ok (${rendered.files.length} files, ${rendered.excluded.length} exclusions)`,
);
return;
}

// Required dedicated entries (acceptance)
const required = [
'apps/desktop/src/renderer/settings/general-settings-page.tsx',
'apps/desktop/src/renderer/settings/settings-modal.tsx',
'packages/ui/src/skills-panel.tsx',
'apps/desktop/src/renderer/mcp-page.tsx',
'packages/ui/src/scheduled-task-panel.tsx',
'packages/ui/src/daily-review-panel.tsx',
'packages/ui/src/primitives/module-page.tsx',
];
const requiredMissing = required.filter((f) => !listSet.has(f) || !md.includes(`\`${f}\``));

// No family-batch claim without per-file rows: inventory must not use old batch prose
if (
/General, Appearance, Data, Providers/.test(md) &&
!md.includes('general-settings-page.tsx')
) {
console.error('inventory still uses family-batch claims without per-file rows');
process.exit(1);
}
const committedPathSet = new Set(
committedPaths
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
);
const generatedPathSet = new Set(rendered.files);
const missing = rendered.files.filter((file) => !committedPathSet.has(file));
const extra = [...committedPathSet].filter((file) => !generatedPathSet.has(file));

const failures = [];
if (missing.length)
failures.push(
`on disk but not in .paths (${missing.length}):\n ${missing.slice(0, 20).join('\n ')}`,
);
if (extra.length)
failures.push(
`in .paths but not on disk (${extra.length}):\n ${extra.slice(0, 20).join('\n ')}`,
);
if (mdMissing.length) {
failures.push(
`on disk but not a markdown row (${mdMissing.length}):\n ${mdMissing.slice(0, 20).join('\n ')}`,
);
}
if (requiredMissing.length) {
failures.push(`required surfaces missing:\n ${requiredMissing.join('\n ')}`);
const details = [];
if (pathsDrift) {
details.push('.paths does not match generator output');
if (missing.length) {
details.push(
`on disk but not in .paths (${missing.length}):\n ${missing.slice(0, 20).join('\n ')}`,
);
}
if (extra.length) {
details.push(
`in .paths but not on disk (${extra.length}):\n ${extra.slice(0, 20).join('\n ')}`,
);
}
}

if (failures.length) {
console.error(
`astryx surface inventory coverage failed:\n${failures.map((f) => `- ${f}`).join('\n')}`,
);
process.exit(1);
if (mdDrift) {
details.push('docs/astryx-surface-file-inventory.md does not match generator output');
}

console.log(
`astryx surface inventory coverage: ok (${listed.length} files, ${excluded.length} exclusions)`,
console.error(
`astryx surface inventory is stale; run: npm run astryx:surface-inventory:write\n${details.map((line) => `- ${line}`).join('\n')}`,
);
process.exit(1);
}

main().catch((err) => {
console.error(err);
try {
main();
} catch (error) {
console.error(error);
process.exit(1);
});
}
18 changes: 18 additions & 0 deletions scripts/ci-test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ function isStorybookPath(path) {
* Electron e2e should pay cold install/boot only when the real window surface
* or e2e driver changed — not when only packages/ui unit tests changed.
*/
function isAstryxSurfaceInventoryPath(path) {
if (
path === 'docs/astryx-surface-file-inventory.md' ||
path === 'docs/astryx-surface-file-inventory.paths' ||
path === 'scripts/generate-astryx-surface-inventory.mjs' ||
path === 'scripts/check-astryx-surface-inventory.mjs'
) {
return true;
}
if (path === 'apps/desktop/src/renderer' || path.startsWith('apps/desktop/src/renderer/')) {
return !isPackageTestPath(path);
}
return isUiProductSourcePath(path);
}

function isE2eProductPath(path) {
if (E2E_DRIVING_SCRIPTS.has(path)) return true;
if (path === 'apps/desktop' || path.startsWith('apps/desktop/')) {
Expand Down Expand Up @@ -214,6 +229,7 @@ export function planTests(changedFiles, options = {}) {
if (full) {
const workspaces = [...graph.dirs];
return {
astryxSurface: true,
code: true,
e2e: true,
full: true,
Expand Down Expand Up @@ -284,6 +300,7 @@ export function planTests(changedFiles, options = {}) {
windowsBaselineChanged || files.some((path) => isWindowsStorageSensitivePath(path));

return {
astryxSurface: files.some((path) => isAstryxSurfaceInventoryPath(path)),
code,
// Electron E2E + alignment audit (same job). Product desktop/ui sources and
// e2e drivers only — a storage/runtime change must not drag cold Electron
Expand All @@ -310,6 +327,7 @@ export function planTests(changedFiles, options = {}) {

export function formatGitHubOutputs(plan) {
return [
`astryx_surface=${plan.astryxSurface}`,
`code=${plan.code}`,
`e2e=${plan.e2e}`,
`full=${plan.full}`,
Expand Down
32 changes: 23 additions & 9 deletions scripts/generate-astryx-surface-inventory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,8 @@ function analyzeCss(rel, text) {
};
}

function analyze(rel) {
const full = join(root, rel);
function analyze(repoRoot, rel) {
const full = join(repoRoot, rel);
const text = readFileSync(full, 'utf8');
const role = roleFor(rel);
if (rel.endsWith('.css')) {
Expand All @@ -350,9 +350,9 @@ function analyze(rel) {
return { path: rel, role, ...a };
}

function main() {
const { files, excluded } = listProductSurfaceFiles(root);
const rows = files.map(analyze);
export function renderAstryxSurfaceInventory(repoRoot = root) {
const { files, excluded } = listProductSurfaceFiles(repoRoot);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const rows = files.map((rel) => analyze(repoRoot, rel));

const bySev = { blocker: 0, polish: 0, aligned: 0 };
for (const r of rows) bySev[r.severity] = (bySev[r.severity] || 0) + 1;
Expand Down Expand Up @@ -417,13 +417,27 @@ function main() {
lines.push('- **aligned** — no blocker smell found; Astryx usage noted when present.');
lines.push('');

const markdown = lines.join('\n');
return {
markdown: markdown.endsWith('\n') ? markdown : `${markdown}\n`,
paths: `${files.join('\n')}\n`,
files,
excluded,
totals: bySev,
};
}

function main() {
const rendered = renderAstryxSurfaceInventory(root);
const mdPath = join(root, 'docs/astryx-surface-file-inventory.md');
const pathsPath = join(root, 'docs/astryx-surface-file-inventory.paths');
writeFileSync(mdPath, lines.join('\n'));
writeFileSync(pathsPath, `${files.join('\n')}\n`);
console.log(`wrote ${relative(root, mdPath)} (${rows.length} files)`);
writeFileSync(mdPath, rendered.markdown);
writeFileSync(pathsPath, rendered.paths);
console.log(`wrote ${relative(root, mdPath)} (${rendered.files.length} files)`);
console.log(`wrote ${relative(root, pathsPath)}`);
console.log(`severity: blocker=${bySev.blocker} polish=${bySev.polish} aligned=${bySev.aligned}`);
console.log(
`severity: blocker=${rendered.totals.blocker} polish=${rendered.totals.polish} aligned=${rendered.totals.aligned}`,
);
}

const isDirect = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
Expand Down
Loading