Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Project schema-version classification (Slice A, #553):** `PROJECT_SCHEMA_V1` as a fresh
production version marker, disjoint classification
(`LEGACY_UNVERSIONED`/`SUPPORTED_OLDER`/`UNSUPPORTED_OLDER`/`CURRENT`/`FUTURE`/`MALFORMED`),
raw/header parsing before typed parsing so a `FUTURE` document with a breaking shape still
classifies `FUTURE` rather than `MALFORMED`, and the `LEGACY_TO_V1` in-memory admission
primitive. PR #618.
- **Schema-version classification observed on IDB project load (Slice B, 1/N, #553):** the first
ingress path wired to Slice A's classifiers, observation-only (classify and log, never alter
load behavior, never throw), matching the established Core shadow-validation pattern. PR #619.
- **Canonical document projection foundation:** establishes the canonical parser/import/admission
boundary that retains the original JSON text alongside a bounded typed projection, the
foundation Slice C's admission primitive builds on.
- **Explicit legacy-to-v1 admission primitive (#653):** the non-destructive `LEGACY_TO_V1`
in-memory admission path recognizes, fully validates, losslessly overlays the V1 version marker,
and revalidates the destination. PR #653.
- **Filesystem admission converged after #654 (#658):** carries forward canonical filesystem
admission and closes legacy editable-admission authority gaps. PR #658.

### Fixed

- **`schemaVersion` now enforces raw integer grammar (#553):** rejects rounded fractional schema
versions and non-number schema-version tokens instead of silently coercing them. PR #621.
- **Canonical document projection now reuses its own parsed input** instead of re-parsing,
removing a redundant-parse divergence risk between the raw and typed projections.
- **Projection failure now retains the raw header verdict** instead of discarding the
already-classified version header when the typed projection itself fails.
- **Unsupported-project startup copy added** alongside the existing migration-gap copy, so a
project whose schema version this build cannot open at all gets its own distinct startup
message rather than reusing the migration-gap wording.
- **Migration-gap startup copy now distinguished from the unsupported-project case (#656):** the
two failure classes previously shared wording; each now gets copy specific to its actual cause.
PR #656.
- **PR-size exception governance hardened (#657):** closes control gaps in how per-PR size-ceiling
exceptions are recorded and validated. PR #657.
- **Security docs no longer cite closed PR #356 as active desktop-encryption remediation:**
`docs/SECURITY-THREAT-MODEL.md` and `docs/IDB-ENCRYPTION.md` both asserted PR #356 was the
active/pending remediation for desktop plaintext storage after it was closed as superseded on
2026-08-18. Rewritten to anchor on the living Ledger-row-9/R-15 state instead of a PR number, and
`check-doc-metrics.mjs` now mechanically rejects an unqualified live/pending-remediation claim
tied to a bare PR number in these two files. PR #673.
- **CHANGELOG completeness-check upgrade, backfill Unreleased (audit F-2):** `scanUnreleasedTruth`
previously accepted any non-empty `[Unreleased]` section forever, so a single unrelated bullet
let arbitrarily many later `feat`/`fix`/`perf` commits go completely undocumented — 13 real
commits since `v1.28.4` had gone unrecorded. It now requires every governed commit to be
individually referenced by PR number or subject slug, naming any that aren't; this section was
backfilled with all 13 currently-undocumented entries above. Review findings addressed on this
same upgrade: PR-number matching now requires a non-digit boundary (a bare substring check let
`#65` satisfy `#656`), subject-slug matching is now scoped to one changelog entry at a time
instead of the whole section, and full per-commit completeness is enforced only outside
`pull_request` CI context — a PR's own git-log range enumerates every commit unique to that
branch, not the one commit that will exist after squash-merge, so a review-fix follow-up commit
can't reference itself in advance. Stop reading the GitHub event name (`GITHUB_EVENT_NAME`) as
that `pull_request`-context check's own default: the same environment variable is ambiently
visible to the Vitest process too when the whole suite runs inside a `pull_request`-triggered
job — the pure function's default is now hardcoded `false`; only the real CLI invocation reads
the actual environment.

### Security

- **AI/session attribution now rejected in commits and PRs:** a fail-closed guard
(`check-commit-attribution.mjs`, a commit-msg hook, a pre-push scan of outgoing commits, and a
CI check on the commit range and PR title/body) blocks Claude/Anthropic attribution trailers,
session URLs, and generated-by footers before they can land in repository history. PR #672.

### Documentation

- **Post-release v1.28.4 truth sync:** removed the now-stale release-candidate markers from
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2942_keys-0EA5E9" alt="i18n 19 locales — 2942 keys">
<img src="https://img.shields.io/badge/Tests-7593%2B_%2F_603_files-22C55E" alt="7593+ tests / 603 files">
<img src="https://img.shields.io/badge/Tests-7605%2B_%2F_603_files-22C55E" alt="7605+ tests / 603 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7593+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7605+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7593+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7605+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**.

**Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):**
- **7593+ unit tests** across **603 test files** — CI is authoritative for pass/fail
- **7605+ unit tests** across **603 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

Expand Down
18 changes: 15 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,21 @@ Status: 🔄 in progress | ⬜ open | ✅ done
`TODO.md`/`README.md` reconciled, `v1.28.4` tag and GitHub Release published with all expected
Desktop artifacts; post-merge main CI and CodeQL green (CodeQL has no tag trigger), tag-triggered
CI/Tauri/Docker all green, and `AUDIT.md` updated with real evidence.
- ⬜ Close the outstanding Wave 2 prerequisite (ledger row 9: project state-shape compatibility
adapter) — currently in progress, not complete. Wave 3/4 R-15 implementation stays blocked
(`S5_IMPLEMENTATION_READY=NO`) until this and `S5_TERMINAL=YES` are both true.
- ✅ Attribution-hygiene guard merged (PR #672): fail-closed rejection of AI/session attribution
trailers and footers in commits, tags, and PR titles/bodies (commit-msg hook, pre-push scan, CI).
- ✅ Post-audit truth/governance remediation PR #673 merged: stale `#356`-active-remediation
citations in `docs/SECURITY-THREAT-MODEL.md`/`docs/IDB-ENCRYPTION.md` rewritten to anchor on the
living ledger state, with a mechanical `check-doc-metrics.mjs` guard against recurrence (first
slice of the 2026-09-08 external audit's S1–S10 remediation sequence; further slices in progress).
- 🔄 Ledger row 9 (project state-shape compatibility adapter) — five `feat`/`fix` PRs landed
(#618 Slice A persisted schema-version classification, #619 Slice B IDB-load observation, #621
raw-integer-grammar hardening, #653 the non-destructive `LEGACY_TO_V1` admission primitive, #658
filesystem-admission convergence) but **still not complete**: durable source-generation fencing,
Comment thread
qnbs marked this conversation as resolved.
writer integration, and universal ingress/egress remain open per
`docs/native/CORE-MIGRATION-LEDGER.md` row 9. Wave 3/4 R-15 implementation stays blocked
(`S5_IMPLEMENTATION_READY=NO`, recorded on row 10) until row 9 and `S5_TERMINAL=YES` are both
true — no target release is set for that yet (tracked, not invented; see the S1–S10 remediation
plan's exit-criterion work).
- ⬜ #614 (narrow concurrent-first-install multi-tab race, requires cross-tab coordination) and
#532 (WelcomePortal E2E entry nondeterminism root cause) remain open, tracked separately —
not part of this sprint unless they directly block release or R-15 work.
Expand Down
1 change: 1 addition & 0 deletions scripts/check-doc-metrics.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function scanUnreleasedTruth(
postReleaseCommitSubjects: string[] | null,
packageVersion?: string,
taggedVersions?: Set<string>,
isPullRequestContext?: boolean,
): string[];
export function scanForDrift(
content: string,
Expand Down
142 changes: 136 additions & 6 deletions scripts/check-doc-metrics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,19 +330,124 @@ export function scanReadmeReleaseTruth(readme, taggedVersions) {
return findings;
}

function hasMeaningfulUnreleasedContent(changelog) {
// QNBS-v3 (audit F-2): shared by the presence check below and the completeness check further
// down — both need the raw [Unreleased] section text with HTML comments stripped.
function getUnreleasedSectionText(changelog) {
const heading = /^## \[Unreleased\]\s*$/m.exec(changelog);
if (!heading) return false;
if (!heading) return '';
const afterHeading = changelog.slice(heading.index + heading[0].length);
const nextHeading = afterHeading.search(/^##\s/m);
const section = nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading);
const withoutHtmlComments = section.replace(/<!--[\s\S]*?(?:-->|$)/g, '');
return withoutHtmlComments.split('\n').some((line) => {
return section.replace(/<!--[\s\S]*?(?:-->|$)/g, '');
}

function hasMeaningfulUnreleasedContent(changelog) {
const section = getUnreleasedSectionText(changelog);
return section.split('\n').some((line) => {
const trimmed = line.trim();
return trimmed.length > 0 && !trimmed.startsWith('<!--') && !trimmed.startsWith('###');
});
}

// QNBS-v3 (audit F-2): a single doc-sync bullet previously satisfied hasMeaningfulUnreleasedContent
// forever, letting arbitrarily many later feat/fix/perf commits go undocumented — the exact gap
// this audit found (13 of 13 real post-tag commits undocumented). Governed commits must each be
// referenced by PR number OR a recognizable subject slug, not merely "some content exists."
const GOVERNED_COMMIT_TYPE = /^(?:feat|fix|perf)(?:\([^)]*\))?!?:\s*/i;
const TRAILING_PR_REF = /\(#(\d+)\)\s*$/;
const SLUG_STOP_WORDS = new Set([
'the',
'a',
'an',
'and',
'or',
'of',
'to',
'in',
'on',
'for',
'with',
'from',
'at',
'by',
'is',
'are',
'this',
'that',
'not',
Comment thread
qnbs marked this conversation as resolved.
]);
// QNBS-v3: a commit not merged via the standard squash flow (no trailing "(#NNN)") has nothing to
// key off but its own wording — require most of a bounded set of its most identifying words to
// appear in [Unreleased], rather than an exact-sentence match this file's other scanners avoid.
const SLUG_WORD_COUNT = 6;
const SLUG_MATCH_RATIO = 0.6;

function significantSlugWords(description) {
return description
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter((word) => word.length > 2 && !SLUG_STOP_WORDS.has(word))
.slice(0, SLUG_WORD_COUNT);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
qnbs marked this conversation as resolved.
Outdated
}

// QNBS-v3 (coderabbit/CodeAnt): a bare String.includes let "#65" incorrectly satisfy a check for
// "#656" (and vice versa) since one is a substring of the other — require a non-digit boundary on
// both sides so only the exact PR number counts.
function isReferencedByPrNumber(subject, unreleasedSection) {
const prMatch = TRAILING_PR_REF.exec(subject);
if (!prMatch) return false;
return new RegExp(`(?:^|\\D)#${prMatch[1]}(?!\\d)`).test(unreleasedSection);
}

// QNBS-v3 (codex): match ratio is computed PER changelog entry, not against the whole section —
// otherwise words scattered across several unrelated bullets could collectively satisfy the
// threshold for a commit none of them actually documents, and one generic bullet could
// simultaneously "document" multiple different undocumented commits.
function isReferencedBySlug(subject, unreleasedEntries) {
const description = subject.replace(GOVERNED_COMMIT_TYPE, '').replace(TRAILING_PR_REF, '');
const words = significantSlugWords(description);
if (words.length === 0) return false;
return unreleasedEntries.some((entry) => {
const matched = words.filter((word) => new RegExp(`\\b${word}\\b`, 'i').test(entry));
return matched.length / words.length >= SLUG_MATCH_RATIO;
Comment thread
qnbs marked this conversation as resolved.
Outdated
Comment thread
qnbs marked this conversation as resolved.
Outdated
});
}

// QNBS-v3: a Markdown bullet may wrap across several physical lines — join a bullet's own
// continuation lines into one entry so slug-matching sees the whole thought, not a fragment.
function splitUnreleasedEntries(unreleasedSection) {
const entries = [];
let current = [];
const flush = () => {
if (current.length > 0) entries.push(current.join(' '));
current = [];
};
for (const rawLine of unreleasedSection.split('\n')) {
const line = rawLine.trim();
if (/^-\s/.test(line)) {
flush();
current.push(line);
} else if (current.length > 0 && line !== '') {
current.push(line);
} else if (line === '') {
flush();
}
}
flush();
return entries;
}

function findUndocumentedGovernedCommits(postReleaseCommitSubjects, unreleasedSection) {
const entries = splitUnreleasedEntries(unreleasedSection);
return postReleaseCommitSubjects.filter(
(subject) =>
GOVERNED_COMMIT_TYPE.test(subject) &&
!isReferencedByPrNumber(subject, unreleasedSection) &&
!isReferencedBySlug(subject, entries),
Comment thread
qnbs marked this conversation as resolved.
Outdated
);
}
Comment thread
qnbs marked this conversation as resolved.

/**
* Return post-release commit subjects when the checkout has enough history to answer reliably.
* A tagless or shallow checkout intentionally returns null, so CI does not turn missing history
Expand All @@ -367,11 +472,18 @@ export function getPostReleaseCommitSubjects(repositoryRoot = root) {
}
}

// QNBS-v3: deliberately NOT defaulted from process.env here — this same env var is ambiently
// visible to the Vitest process itself when the whole suite runs inside a GitHub Actions
// pull_request-triggered job (not just this gate's own CLI step), so a default read at this
// pure-function boundary silently changed every test's behavior based on which CI context ran
// it. Only main()'s real CLI invocation below reads the actual environment; tests always get a
// deterministic, explicitly-passed value.
export function scanUnreleasedTruth(
changelog,
postReleaseCommitSubjects,
packageVersion,
taggedVersions,
isPullRequestContext = false,
) {
if (!postReleaseCommitSubjects || postReleaseCommitSubjects.length === 0) return [];
const candidateVersion = changelog.match(
Expand All @@ -388,9 +500,26 @@ export function scanUnreleasedTruth(
semverCompare(candidateVersion, latestTagged) > 0;
// QNBS-v3: only the current untagged release candidate may defer Unreleased history until merge-time tagging.
if (isActiveUntaggedCandidate) return [];
if (hasMeaningfulUnreleasedContent(changelog)) return [];
if (!hasMeaningfulUnreleasedContent(changelog)) {
return [
`CHANGELOG.md — ${postReleaseCommitSubjects.length} commit(s) exist after the latest release tag, but [Unreleased] is empty`,
];
}
// QNBS-v3 (codex, P1): a pull_request CI run's `git log` range enumerates every commit unique
// to that branch — not the one commit that will actually exist after squash-merge. Enforcing
// full per-commit completeness there would make a routine review-fix follow-up commit
// unsatisfiable (it cannot reference itself in [Unreleased] in advance). Completeness is
// enforced once those commits are real, permanent history: on the push to main right after
// merge (and locally, since a developer isn't fighting the multi-commit-per-PR shape there).
if (isPullRequestContext) return [];
Comment thread
qnbs marked this conversation as resolved.
Outdated
const unreleasedSection = getUnreleasedSectionText(changelog);
const undocumented = findUndocumentedGovernedCommits(
postReleaseCommitSubjects,
unreleasedSection,
);
Comment thread
qnbs marked this conversation as resolved.
if (undocumented.length === 0) return [];
return [
`CHANGELOG.md — ${postReleaseCommitSubjects.length} commit(s) exist after the latest release tag, but [Unreleased] is empty`,
`CHANGELOG.md — [Unreleased] does not reference ${undocumented.length} post-tag feat/fix/perf commit(s) by PR number or subject: ${undocumented.map((s) => `"${s}"`).join('; ')}`,
];
}

Expand Down Expand Up @@ -735,6 +864,7 @@ function main() {
getPostReleaseCommitSubjects(),
packageVersion,
taggedVersions,
process.env.GITHUB_EVENT_NAME === 'pull_request',
),
);
allFindings.push(
Expand Down
Loading
Loading