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
4 changes: 3 additions & 1 deletion packages/shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
7. **anchored root 式の二重定義を一致させる** (ADR-0017): casual/class の anchored root は `computeAnchoredChainRoot` (verifier) と `HashChainManager.generateAnchoredInitialHash` (editor) の **2 箇所**に `SHA256(fp ‖ localNonce ‖ serverNonce)` を持つ。exam の `computeExamChainRoot`/`generateExamInitialHash` と同じ轍。式を変えるなら両方+テストを同時に直す
5. **`CheckpointManager`** はステートフル。`shouldCreateCheckpoint` の判定は最終 cp の eventIndex / 時刻に依存する。`setCheckpoints` で復元する際は内部状態も再構築すること (実装済み)
6. **検証は cp の間隔を仮定しない**: `verify` 側は cp の存在を補助メタデータとしてのみ扱い、未署名 cp の sampling は信頼しない ([docs/adr/0004-verifier-checkpoint-stance.md](../../docs/adr/0004-verifier-checkpoint-stance.md))
8. **`isPureTyping` の判定点は 1 つ** (#235): `structuralEdit.ts` の `evaluatePureTyping` だけが決める。export 時の自己申告 (`TypingProof`) と採点側の再計算 (`verifyProofMetadata`) が同じ関数を呼ぶので、proof に焼かれる値と検証器の結論が食い違わない。**別の場所に式を書き足さないこと**。なお `metadata.bulkInsertEvents` は `isSuspiciousBulkInsert` の素のカウントのままで、こちらは申告値との完全一致を要求するので定義を変えると既存 proof が invalid になる

## モジュール一覧

Expand Down Expand Up @@ -47,7 +48,8 @@

- `types/` 配下にカテゴリ別 (`events.ts`, `proof.ts`, `storage.ts`, ...) で配置
- ルート `types.ts` がすべて re-export
- **新しいイベント / 入力タイプを追加するときは `types/events.ts` だけでなく `InputTypeValidator.ts` と CLAUDE.md / system-spec.md / shared README も同時更新**。過去にこの同期が崩れて事故った
- **新しいイベント / 入力タイプを追加するときは `types/events.ts` だけでなく CLAUDE.md / system-spec.md / shared README も同時更新**。過去にこの同期が崩れて事故った (#222: `VALID_EVENT_TYPES` が 26 種のまま union の 29 種から取り残された)
- `InputTypeValidator.ts` の `VALID_EVENT_TYPES` / `VALID_INPUT_TYPES` は **union から導出済み**なので手動同期は不要 — `EventType` / `InputType` に型を足すと **tsc が記入漏れを検出**する (#235 と同じ「文書ルールでなく機械で強制する」方針)

## テスト規約 (#4 テスト強化の方針)

Expand Down
105 changes: 105 additions & 0 deletions packages/shared/src/__tests__/inputTypeValidatorSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* InputTypeValidator の実行時許可セットが型 union と同期していることのテスト (#222)。
*
* かつて `VALID_EVENT_TYPES` は手書きの Set で、`EventType` union に追加された
* `environmentProbe` / `fullscreenChange` / `examOpened` が漏れていた。実装側は
* `Record<EventType, true>` から導出して tsc に漏れを検出させているが、ここでは
* 「union の全メンバを受理し、union 外を持たない」ことを実行時にも固定する。
*
* 下のリテラル一覧も `Record<EventType, true>` / `Record<InputType, true>` なので、
* union に型を足すとこのテスト自身も tsc に指摘される (仕様の二重記述ではなく確認)。
*/

import { describe, expect, it } from 'vitest';
import {
VALID_EVENT_TYPES,
VALID_INPUT_TYPES,
validateEventType,
validateInputType,
} from '../typingProof/InputTypeValidator.js';
import type { EventType, InputType } from '../types.js';

const ALL_EVENT_TYPES: Record<EventType, true> = {
humanAttestation: true,
preExportAttestation: true,
termsAccepted: true,
contentChange: true,
contentSnapshot: true,
cursorPositionChange: true,
selectionChange: true,
externalInput: true,
editorInitialized: true,
mousePositionChange: true,
visibilityChange: true,
focusChange: true,
keyDown: true,
keyUp: true,
windowResize: true,
networkStatusChange: true,
codeExecution: true,
terminalInput: true,
screenshotCapture: true,
screenShareStart: true,
screenShareStop: true,
templateInjection: true,
sessionResumed: true,
copyOperation: true,
screenShareOptOut: true,
reflectionNote: true,
environmentProbe: true,
fullscreenChange: true,
examOpened: true,
};

const ALL_INPUT_TYPES: Record<InputType, true> = {
insertText: true,
insertLineBreak: true,
insertParagraph: true,
insertTab: true,
insertFromComposition: true,
insertCompositionText: true,
deleteCompositionText: true,
deleteContentBackward: true,
deleteContentForward: true,
deleteWordBackward: true,
deleteWordForward: true,
deleteSoftLineBackward: true,
deleteSoftLineForward: true,
deleteHardLineBackward: true,
deleteHardLineForward: true,
deleteByDrag: true,
deleteByCut: true,
historyUndo: true,
historyRedo: true,
insertFromPaste: true,
insertFromDrop: true,
insertFromYank: true,
insertReplacementText: true,
insertFromPasteAsQuotation: true,
insertFromInternalPaste: true,
replaceContent: true,
};

describe('InputTypeValidator stays in sync with the type unions (#222)', () => {
it('accepts every member of the EventType union', () => {
for (const eventType of Object.keys(ALL_EVENT_TYPES)) {
expect(validateEventType(eventType), eventType).toBe(true);
}
});

it('has no valid event type outside the EventType union', () => {
expect([...VALID_EVENT_TYPES].sort()).toEqual(Object.keys(ALL_EVENT_TYPES).sort());
expect(validateEventType('notAnEventType')).toBe(false);
});

it('accepts every member of the InputType union', () => {
for (const inputType of Object.keys(ALL_INPUT_TYPES)) {
expect(validateInputType(inputType), inputType).toBe(true);
}
});

it('has no valid input type outside the InputType union', () => {
expect([...VALID_INPUT_TYPES].sort()).toEqual(Object.keys(ALL_INPUT_TYPES).sort());
expect(validateInputType('notAnInputType')).toBe(false);
});
});
125 changes: 125 additions & 0 deletions packages/shared/src/__tests__/pureTypingEvaluation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* isPureTyping 判定の単一化 (#235) のテスト。
*
* export 側の自己申告 (`TypingProof.generateTypingProofHash`) と採点側の再計算
* (`verifyProofMetadata`) が同じ関数を使うこと、括弧自動閉じのような editor 由来の
* 複数文字挿入で自己申告が崩れないことを固定する。
*/

import { describe, expect, it } from 'vitest';
import { TypingProof, computeHash, verifyProofMetadata } from '../index.js';
import type { EventType, FingerprintComponents, InputType, ProofData, StoredEvent } from '../types.js';

const createMockFingerprintComponents = (): FingerprintComponents => ({
userAgent: 'Mozilla/5.0 (PureTyping Test)',
language: 'en',
languages: ['en'],
platform: 'TestOS',
hardwareConcurrency: 4,
deviceMemory: 8,
screen: {
width: 1440,
height: 900,
availWidth: 1440,
availHeight: 860,
colorDepth: 24,
pixelDepth: 24,
devicePixelRatio: 2,
},
timezone: 'UTC',
timezoneOffset: 0,
canvas: 'mock-canvas',
webgl: { vendor: 'Mock', renderer: 'Mock' },
fonts: ['Arial'],
cookieEnabled: true,
doNotTrack: 'unspecified',
maxTouchPoints: 0,
});

interface RecordedEvent {
type: EventType;
inputType: InputType;
data: string;
rangeOffset: number;
rangeLength: number;
}

/**
* Monaco の括弧自動閉じ (既定 `autoClosingBrackets: 'languageDefined'`) だけを含むセッション。
* `(` の 1 打鍵で `()` が `insertReplacementText` として、閉じ括弧の type-over が
* `replaceContent` として記録される。どちらも構造文字のみで内容 (コード) を運ばない。
*/
function autoClosingBracketSession(): RecordedEvent[] {
return [
{ type: 'contentChange', inputType: 'insertText', data: 'f', rangeOffset: 0, rangeLength: 0 },
{ type: 'contentChange', inputType: 'insertText', data: 'o', rangeOffset: 1, rangeLength: 0 },
{ type: 'contentChange', inputType: 'insertText', data: 'o', rangeOffset: 2, rangeLength: 0 },
// `(` を打つと editor が `()` を挿入する
{ type: 'contentChange', inputType: 'insertReplacementText', data: '()', rangeOffset: 3, rangeLength: 0 },
// 自動で閉じた `)` の上から `)` を打つ (type-over)
{ type: 'contentChange', inputType: 'replaceContent', data: ')', rangeOffset: 4, rangeLength: 1 },
];
}

/** AI/スニペットによる複数行のコード一括投入 (打鍵で書いていない)。 */
function bulkCodeInsertSession(): RecordedEvent[] {
return [
{ type: 'contentChange', inputType: 'insertText', data: 'f', rangeOffset: 0, rangeLength: 0 },
{
type: 'contentChange',
inputType: 'insertParagraph',
data: 'unction solve(n) {\n return n < 2 ? n : solve(n - 1) + solve(n - 2);\n}\n',
rangeOffset: 1,
rangeLength: 0,
},
];
}

function replayContent(events: RecordedEvent[]): string {
let content = '';
for (const event of events) {
content = content.slice(0, event.rangeOffset) + event.data + content.slice(event.rangeOffset + event.rangeLength);
}
return content;
}

async function exportSession(events: RecordedEvent[]): Promise<{
exportedIsPureTyping: boolean;
storedEvents: StoredEvent[];
proofData: ProofData;
}> {
const components = createMockFingerprintComponents();
const fingerprintHash = await computeHash(JSON.stringify(components, null, 0));
const proof = new TypingProof();
await proof.initialize(fingerprintHash, components);
for (const event of events) {
await proof.recordEvent(event);
}
const exported = await proof.exportProof(replayContent(events));
return {
exportedIsPureTyping: exported.metadata.isPureTyping,
storedEvents: exported.proof.events,
proofData: exported.typingProofData,
};
}

describe('isPureTyping single source of truth (#235)', () => {
it('keeps the exported isPureTyping true for a session whose only multi-char inserts are auto-closing brackets', async () => {
const { exportedIsPureTyping } = await exportSession(autoClosingBracketSession());
expect(exportedIsPureTyping).toBe(true);
});

it('drops the exported isPureTyping to false for a multi-line bulk code insert', async () => {
const { exportedIsPureTyping } = await exportSession(bulkCodeInsertSession());
expect(exportedIsPureTyping).toBe(false);
});

it('agrees with the verifier recomputation on the same events', async () => {
for (const session of [autoClosingBracketSession(), bulkCodeInsertSession()]) {
const { exportedIsPureTyping, storedEvents, proofData } = await exportSession(session);
const recomputed = verifyProofMetadata(proofData, storedEvents);
expect(recomputed.valid).toBe(true);
expect(recomputed.isPureTyping).toBe(exportedIsPureTyping);
}
});
});
Loading
Loading