Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
46d3ce8
test: reproduce numeric zero loss through editor round trip
seonghobae Aug 26, 2026
005918c
test: run numeric zero regression in cloud e2e
seonghobae Aug 26, 2026
a9e8c86
test: cover zero normalization and CSV export
seonghobae Aug 26, 2026
41a38d6
fix: preserve numeric zero values across normalization
seonghobae Aug 26, 2026
bb2a225
test: guard unrelated falsy editor semantics
seonghobae Aug 26, 2026
4646c41
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] Aug 26, 2026
0b741fc
test: keep non-zero falsy numeric inputs empty
seonghobae Aug 26, 2026
fb381e8
fix: preserve only numeric zero in normalization
seonghobae Aug 26, 2026
dbb30de
test: keep zero preservation numeric-field scoped
seonghobae Aug 26, 2026
7c5617e
test: retain validated zero round-trip contract
seonghobae Aug 26, 2026
ea20d5d
test: bound numeric-zero preservation to numeric fields
seonghobae Aug 26, 2026
f607152
fix: scope numeric-zero preservation to numeric fields
seonghobae Aug 26, 2026
b803db1
fix: keep Hono lockfile aligned
seonghobae Aug 28, 2026
6c8b282
test: cover numeric zero in JSON sync
seonghobae Aug 28, 2026
5c34c79
fix: preserve numeric fields in JSON sync
seonghobae Aug 28, 2026
df7ef92
test: cover planning metadata JSON sync
seonghobae Aug 28, 2026
40f2621
fix: preserve planning metadata in JSON sync
seonghobae Aug 28, 2026
4c873b1
test: reject falsy nonnumeric JSON sync metadata
seonghobae Aug 28, 2026
3bb2bd9
fix: normalize nonnumeric sync metadata
seonghobae Aug 28, 2026
0ead5e6
fix(security): align Hono with 4.13.5 advisories
seonghobae Aug 28, 2026
bd538f0
fix: restore modulepreload contracts
seonghobae Aug 29, 2026
3566962
test: reproduce JSON sync bootstrap race
seonghobae Aug 29, 2026
9ef18de
fix: gate JSON sync until planner hydration completes
seonghobae Aug 29, 2026
6ec8bef
fix: load JSON sync bootstrap guard fail-closed
seonghobae Aug 29, 2026
d97808c
test: require cloud serving for JSON sync guard
seonghobae Aug 29, 2026
45d5eb2
fix: serve JSON sync bootstrap guard
seonghobae Aug 29, 2026
4c965cb
test: run JSON sync static route regression
seonghobae Aug 29, 2026
5a905d4
test: fail when JSON sync guard escapes coverage
seonghobae Aug 29, 2026
2bc9ae2
test: cover JSON sync bootstrap guard behavior
seonghobae Aug 29, 2026
10c91ab
fix(ci): measure JSON sync bootstrap guard coverage
seonghobae Aug 29, 2026
f8c3178
test(ci): fail when required workflow skips coverage
seonghobae Aug 29, 2026
5c1f7ec
test(ci): keep workflow coverage repair in owning lane
seonghobae Aug 29, 2026
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
28 changes: 17 additions & 11 deletions app.js
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const EDITABLE_FIELDS = [
'sprint',
'storyPoints'
];
const ZERO_VALID_FIELDS = new Set(['budget', 'actualCost', 'storyPoints']);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

const CSV_HEADERS = [
'단계',
Expand Down Expand Up @@ -870,7 +871,7 @@ function renderEditorField(label, field, value, type = 'text', required = false,
if (type === 'text') {
input.maxLength = 1000;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
input.value = value || '';
input.value = value === 0 && ZERO_VALID_FIELDS.has(field) ? '0' : (value || '');
if (required) {
input.required = true;
input.setAttribute('aria-required', 'true');
Expand Down Expand Up @@ -1310,7 +1311,9 @@ function sanitizeDraft(draft) {
const sanitized = {};
EDITABLE_FIELDS.forEach((field) => {
// 🛡️ Sentinel: Enforce string coercion before trim() to prevent DoS via type confusion
sanitized[field] = String(draft?.[field] || '').trim().slice(0, 1000);
const val = draft?.[field];
const normalizedValue = val === 0 && ZERO_VALID_FIELDS.has(field) ? '0' : (val || '');
sanitized[field] = String(normalizedValue).trim().slice(0, 1000);
});
// 🛡️ Sentinel: Strictly validate against allowed options to prevent injection
if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) {
Expand Down Expand Up @@ -1496,7 +1499,6 @@ function getDateRangeWarning(startDate, endDate, message) {
}

const cachedHiddenParentIds = new Set();

function getVisibleTasks() {
const visible = [];
cachedHiddenParentIds.clear();
Expand Down Expand Up @@ -1813,10 +1815,10 @@ const createNormalizedExternalRecord = (task, defaults = {}) => ({
actualStartDate: task.actualStartDate || '',
actualEndDate: task.actualEndDate || '',
predecessors: task.predecessors || defaults.predecessors || '',
budget: task.budget || defaults.budget || '',
actualCost: task.actualCost || defaults.actualCost || '',
budget: task.budget === 0 ? 0 : (task.budget || (defaults.budget === 0 ? 0 : (defaults.budget || ''))),
actualCost: task.actualCost === 0 ? 0 : (task.actualCost || (defaults.actualCost === 0 ? 0 : (defaults.actualCost || ''))),
sprint: task.sprint || defaults.sprint || '',
storyPoints: task.storyPoints || defaults.storyPoints || ''
storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || (defaults.storyPoints === 0 ? 0 : (defaults.storyPoints || '')))
});

function getPhaseKey(task, index) {
Expand Down Expand Up @@ -1962,10 +1964,10 @@ function exportCsv() {
task.parentId || '',
task.depth,
task.predecessors || '',
task.budget || '',
task.actualCost || '',
task.budget === 0 ? 0 : (task.budget || ''),
task.actualCost === 0 ? 0 : (task.actualCost || ''),
task.sprint || '',
task.storyPoints || ''
task.storyPoints === 0 ? 0 : (task.storyPoints || '')
];
});

Expand Down Expand Up @@ -2206,7 +2208,12 @@ function exportJsonArray() {
[LEGACY_PLANNED_END_FIELD]: task.plannedEndDate,
actualProgressStatus: task.actualProgressStatus,
actualStartDate: task.actualStartDate,
actualEndDate: task.actualEndDate
actualEndDate: task.actualEndDate,
predecessors: task.predecessors || '',
budget: task.budget === 0 ? 0 : (task.budget || ''),
actualCost: task.actualCost === 0 ? 0 : (task.actualCost || ''),
sprint: task.sprint || '',
storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || '')
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}));
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -2246,7 +2253,6 @@ function trapGanttModalFocus(event) {
event.preventDefault();
return;
}

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
Expand Down
6 changes: 5 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'self';" />
<title>ScopeWeave Planner</title>
<link rel="preload" href="styles.css" as="style" />
<link rel="modulepreload" href="json-sync-bootstrap-guard.js" />
<link rel="modulepreload" href="cloud-sync.js" />
<link rel="modulepreload" href="analytics.js" />
<link rel="modulepreload" href="app.js" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="toast-state.css" />
Expand Down Expand Up @@ -46,7 +49,7 @@ <h1>ScopeWeave Planner</h1>
</div>
<div class="sync-panel">
<span id="sync-status" role="status" aria-live="polite" aria-atomic="true">브라우저 로컬 자동저장 사용 중</span>
<button id="connect-json-sync" type="button" class="secondary-button">wbs.json 자동저장 연결</button>
<button id="connect-json-sync" type="button" class="secondary-button" disabled aria-disabled="true" title="프로젝트 데이터를 불러오는 중입니다.">wbs.json 자동저장 연결</button>
</div>
</div>
</header>
Expand Down Expand Up @@ -112,6 +115,7 @@ <h2 id="gantt-title">간트 차트</h2>
</div>
</div>

<script type="module" src="json-sync-bootstrap-guard.js"></script>
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
<script type="module" src="cloud-sync.js"></script>
<script type="module" src="analytics.js"></script>
<script type="module" src="app.js"></script>
Expand Down
38 changes: 38 additions & 0 deletions json-sync-bootstrap-guard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const syncButton = document.getElementById('connect-json-sync');
const taskTableBody = document.getElementById('task-table-body');

const LOADING_TITLE = '프로젝트 데이터를 불러오는 중입니다.';
const UNSUPPORTED_TITLE = '이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.';

/**
* Keep direct wbs.json writes fail-closed until the planner has rendered its
* hydrated state. The table body always receives either task rows or the empty
* state row from renderAll(), so its first rendered child is the bootstrap
* completion signal for both populated and intentionally empty projects.
*/
function updateJsonSyncAvailability() {
const bootstrapRendered = taskTableBody.childElementCount > 0;
const pickerSupported = typeof window.showSaveFilePicker === 'function';
const ready = bootstrapRendered && pickerSupported;

syncButton.disabled = !ready;
if (ready) {
syncButton.removeAttribute('aria-disabled');
syncButton.title = '';
return;
}

syncButton.setAttribute('aria-disabled', 'true');
syncButton.title = bootstrapRendered ? UNSUPPORTED_TITLE : LOADING_TITLE;
}

updateJsonSyncAvailability();

const bootstrapObserver = new MutationObserver(() => {
updateJsonSyncAvailability();
if (taskTableBody.childElementCount > 0) {
bootstrapObserver.disconnect();
}
});

bootstrapObserver.observe(taskTableBody, { childList: true });
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,23 @@
"check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings",
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-json-sync-guard.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/json-sync-bootstrap-guard.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=json-sync-bootstrap-guard.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/json-sync-bootstrap-guard.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js tests/e2e/numeric-zero-roundtrip.spec.js",
"test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js",
"fuzz": "node --test tests/fuzz/*.mjs"
},
"dependencies": {
"@hono/node-server": "^2.1.1",
"hono": "^4.13.0"
"hono": "^4.13.5"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"c8": "12.0.0",
"fast-check": "4.9.0"
}
}
}
3 changes: 2 additions & 1 deletion server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1302,7 +1302,7 @@ app.get('/api/projects/:id/baselines', requireAuth, (c) => {
const baselines = db.prepare(
'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC'
).all(p.id);
return c.json({ baselines });
return c.json({ baselines, methodology: p.methodology || 'waterfall' });
});

app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => {
Expand Down Expand Up @@ -1392,6 +1392,7 @@ const STATIC = {
'/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'],
'/pricing': ['landing.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/json-sync-bootstrap-guard.js': ['json-sync-bootstrap-guard.js', 'text/javascript; charset=utf-8'],
'/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'],
'/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
Expand Down
15 changes: 15 additions & 0 deletions tests/api/static-json-sync-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';

process.env.SCOPEWEAVE_DB = ':memory:';
process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef';

const { app } = await import('../../server/app.mjs');

test('cloud server serves the JSON sync bootstrap guard as JavaScript', async () => {
const response = await app.request('/json-sync-bootstrap-guard.js');

assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') || '', /^text\/javascript\b/);
assert.match(await response.text(), /updateJsonSyncAvailability/);
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Loading
Loading