Skip to content
Closed
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
18 changes: 10 additions & 8 deletions .aios-core/core/execution/build-state-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ class BuildStateManager {
* @returns {Object|null} Last checkpoint or null
*/
getLastCheckpoint() {
if (!this._state || this._state.checkpoints.length === 0) {
if (!this._state || !this._state.checkpoints || this._state.checkpoints.length === 0) {

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a discrepancy between the PR description and the actual code. The PR description states that checkpoints is optional in validation, but validateBuildState at line 103 includes 'checkpoints' in the required fields array. In reality, checkpoints is REQUIRED by validation, while failedAttempts and notifications are truly optional (not in the required array). The null guards being added here are still valuable for defensive programming and to handle cases where _state is manually set (as in tests), but the PR description should be corrected to accurately reflect that checkpoints is actually a required field according to the schema validation.

Copilot uses AI. Check for mistakes.
return null;
}
return this._state.checkpoints[this._state.checkpoints.length - 1];
Expand All @@ -410,7 +410,7 @@ class BuildStateManager {
* @returns {Object|null} Checkpoint or null
*/
getCheckpoint(checkpointId) {
if (!this._state) {
if (!this._state || !this._state.checkpoints) {
return null;
}
return this._state.checkpoints.find((c) => c.id === checkpointId) || null;
Expand Down Expand Up @@ -551,11 +551,11 @@ class BuildStateManager {
: 0,
},
metrics: state.metrics,
recentFailures: state.failedAttempts.slice(-5),
recentFailures: (state.failedAttempts || []).slice(-5),
abandoned: isAbandoned,
worktree: state.worktree,
checkpointCount: state.checkpoints.length,
notificationCount: state.notifications.filter((n) => !n.acknowledged).length,
checkpointCount: (state.checkpoints || []).length,
notificationCount: (state.notifications || []).filter((n) => !n.acknowledged).length,
};
}

Expand Down Expand Up @@ -781,6 +781,8 @@ class BuildStateManager {
throw new Error('No state loaded');
}

this._state.failedAttempts ??= [];

const failure = {
subtaskId,
attempt:
Expand Down Expand Up @@ -831,7 +833,7 @@ class BuildStateManager {
}

// Get attempts for this subtask
const attempts = this._state.failedAttempts
const attempts = (this._state.failedAttempts || [])
.filter((f) => f.subtaskId === subtaskId)
.map((f) => ({
success: false,
Expand Down Expand Up @@ -895,7 +897,7 @@ class BuildStateManager {
*/
getNotifications() {
if (!this._state) return [];
return this._state.notifications.filter((n) => !n.acknowledged);
return (this._state.notifications || []).filter((n) => !n.acknowledged);
}

/**
Expand All @@ -904,7 +906,7 @@ class BuildStateManager {
* @param {number} index - Notification index
*/
acknowledgeNotification(index) {
if (!this._state || !this._state.notifications[index]) return;
if (!this._state || !this._state.notifications || !this._state.notifications[index]) return;
this._state.notifications[index].acknowledged = true;
this.saveState();
}
Expand Down
239 changes: 239 additions & 0 deletions tests/core/execution/build-state-manager-guards.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* Testes para null guards em BuildStateManager
*
* Valida que getLastCheckpoint, getCheckpoint, getStatus,
* getNotifications, acknowledgeNotification e recordFailure
* não falham com TypeError quando arrays são undefined.
*
* Closes #513
*/
Comment on lines +1 to +9

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file header claims to test getStatus, but there are no actual test cases for the getStatus method in this file. The PR description mentions that getStatus was fixed to handle undefined arrays for failedAttempts, checkpoints, and notifications, but these fixes are not covered by any tests. Consider adding a test suite for getStatus that validates the guards work correctly when these arrays are undefined.

Copilot uses AI. Check for mistakes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const fs = require('fs');
const path = require('path');

// Mock fs para evitar I/O real
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {});

const { BuildStateManager } = require('../../../.aios-core/core/execution/build-state-manager');

describe('BuildStateManager - null guards (#513)', () => {
let manager;

beforeEach(() => {
manager = new BuildStateManager('test-story', {
rootPath: '/fake/project',
});
});

// ============================================================
// getLastCheckpoint
// ============================================================
describe('getLastCheckpoint', () => {
test('retorna null quando _state é null', () => {
manager._state = null;
expect(manager.getLastCheckpoint()).toBeNull();
});

test('retorna null quando checkpoints é undefined', () => {
manager._state = { status: 'running' };
expect(manager.getLastCheckpoint()).toBeNull();
});

test('retorna null quando checkpoints é array vazio', () => {
manager._state = { checkpoints: [] };
expect(manager.getLastCheckpoint()).toBeNull();
});

test('retorna último checkpoint', () => {
const checkpoint = { id: 'cp-2', phase: 'build' };
manager._state = {
checkpoints: [{ id: 'cp-1', phase: 'setup' }, checkpoint],
};
expect(manager.getLastCheckpoint()).toEqual(checkpoint);
});
});

// ============================================================
// getCheckpoint
// ============================================================
describe('getCheckpoint', () => {
test('retorna null quando _state é null', () => {
manager._state = null;
expect(manager.getCheckpoint('cp-1')).toBeNull();
});

test('retorna null quando checkpoints é undefined', () => {
manager._state = { status: 'running' };
expect(manager.getCheckpoint('cp-1')).toBeNull();
});

test('retorna null quando checkpoint não existe', () => {
manager._state = { checkpoints: [{ id: 'cp-1' }] };
expect(manager.getCheckpoint('cp-999')).toBeNull();
});

test('retorna checkpoint pelo ID', () => {
const target = { id: 'cp-2', phase: 'test' };
manager._state = {
checkpoints: [{ id: 'cp-1' }, target],
};
expect(manager.getCheckpoint('cp-2')).toEqual(target);
});
});

// ============================================================
// constructor
// ============================================================
describe('constructor', () => {
test('requer storyId', () => {
expect(() => new BuildStateManager()).toThrow('storyId is required');
});

test('aceita storyId string', () => {
const m = new BuildStateManager('story-123');
expect(m.storyId).toBe('story-123');
});

test('inicializa _state como null', () => {
expect(manager._state).toBeNull();
});

test('usa rootPath padrão se não fornecido', () => {
const m = new BuildStateManager('s1');
expect(m.rootPath).toBe(process.cwd());
});
});
Comment on lines +85 to +106

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constructor tests are not related to the null guard fixes described in issue #513 or the PR. They test basic constructor functionality that should be covered by general BuildStateManager tests, not in a file specifically focused on null guard testing. Consider moving these tests to a separate general test file or removing them if they're already covered elsewhere.

Suggested change
// ============================================================
// constructor
// ============================================================
describe('constructor', () => {
test('requer storyId', () => {
expect(() => new BuildStateManager()).toThrow('storyId is required');
});
test('aceita storyId string', () => {
const m = new BuildStateManager('story-123');
expect(m.storyId).toBe('story-123');
});
test('inicializa _state como null', () => {
expect(manager._state).toBeNull();
});
test('usa rootPath padrão se não fornecido', () => {
const m = new BuildStateManager('s1');
expect(m.rootPath).toBe(process.cwd());
});
});

Copilot uses AI. Check for mistakes.

// ============================================================
// getStatus - null guards para arrays opcionais
// ============================================================
describe('getStatus', () => {
test('retorna exists:false quando loadState retorna null', () => {
jest.spyOn(manager, 'loadState').mockReturnValue(null);
const status = manager.getStatus();
expect(status.exists).toBe(false);
});

test('calcula recentFailures com failedAttempts undefined', () => {
jest.spyOn(manager, 'loadState').mockReturnValue({
status: 'running',
startedAt: new Date().toISOString(),
currentPhase: 'build',
currentSubtask: null,
metrics: { completedSubtasks: 0, totalSubtasks: 1, totalFailures: 0, totalAttempts: 0 },
worktree: null,
lastCheckpoint: null,
});
jest.spyOn(manager, '_checkAbandoned').mockReturnValue(false);
const status = manager.getStatus();
expect(status.recentFailures).toEqual([]);
expect(status.checkpointCount).toBe(0);
expect(status.notificationCount).toBe(0);
});

test('calcula checkpointCount e notificationCount com arrays presentes', () => {
jest.spyOn(manager, 'loadState').mockReturnValue({
status: 'running',
startedAt: new Date().toISOString(),
currentPhase: 'build',
currentSubtask: null,
metrics: { completedSubtasks: 0, totalSubtasks: 1, totalFailures: 0, totalAttempts: 0 },
worktree: null,
lastCheckpoint: null,
checkpoints: [{ id: 'cp-1' }, { id: 'cp-2' }],
notifications: [
{ acknowledged: false },
{ acknowledged: true },
{ acknowledged: false },
],
failedAttempts: [{ subtaskId: 'a' }, { subtaskId: 'b' }],
});
jest.spyOn(manager, '_checkAbandoned').mockReturnValue(false);
const status = manager.getStatus();
expect(status.checkpointCount).toBe(2);
expect(status.notificationCount).toBe(2);
expect(status.recentFailures).toHaveLength(2);
});
});

// ============================================================
// getNotifications - null guard
// ============================================================
describe('getNotifications', () => {
test('retorna array vazio quando _state é null', () => {
manager._state = null;
expect(manager.getNotifications()).toEqual([]);
});

test('retorna array vazio quando notifications é undefined', () => {
manager._state = { status: 'running' };
expect(manager.getNotifications()).toEqual([]);
});

test('filtra apenas não-acknowledged', () => {
manager._state = {
notifications: [
{ message: 'a', acknowledged: false },
{ message: 'b', acknowledged: true },
{ message: 'c', acknowledged: false },
],
};
const result = manager.getNotifications();
expect(result).toHaveLength(2);
expect(result[0].message).toBe('a');
expect(result[1].message).toBe('c');
});
});

// ============================================================
// acknowledgeNotification - null guard
// ============================================================
describe('acknowledgeNotification', () => {
test('não falha quando _state é null', () => {
manager._state = null;
expect(() => manager.acknowledgeNotification(0)).not.toThrow();
});

test('não falha quando notifications é undefined', () => {
manager._state = { status: 'running' };
expect(() => manager.acknowledgeNotification(0)).not.toThrow();
});

test('não falha com índice inválido', () => {
manager._state = { notifications: [{ acknowledged: false }] };
jest.spyOn(manager, 'saveState').mockImplementation(() => {});
expect(() => manager.acknowledgeNotification(99)).not.toThrow();
});

test('marca notification como acknowledged', () => {
manager._state = {
notifications: [
{ message: 'alerta', acknowledged: false },
],
};
jest.spyOn(manager, 'saveState').mockImplementation(() => {});
manager.acknowledgeNotification(0);
expect(manager._state.notifications[0].acknowledged).toBe(true);
});
});

// ============================================================
// recordFailure - failedAttempts null guard
// ============================================================
describe('recordFailure', () => {
test('inicializa failedAttempts quando undefined', () => {
manager._state = {
status: 'running',
metrics: { totalFailures: 0, totalAttempts: 0 },
notifications: [],
};
jest.spyOn(manager, 'saveState').mockImplementation(() => {});
jest.spyOn(manager, '_logAttempt').mockImplementation(() => {});

const result = manager.recordFailure('subtask-1', { error: 'test error' });
expect(result.failure.subtaskId).toBe('subtask-1');
expect(manager._state.failedAttempts).toHaveLength(1);
});
});
});
Loading