Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# OpenSpec Change: form-detection
- **Change ID**: form-detection
- **Package**: `@suaveplan/form-detection`
- **Status**: PROPOSED
- **Date**: 2026-04-29
## Summary
Create `@suaveplan/form-detection`, a tree-shakeable DOM heuristics engine that detects login, registration, and password-change forms in web pages with confidence scoring. The package targets browser extension content scripts and is designed to run at ~10KB bundled with zero external dependencies.
## Why
Password-manager browser extensions require reliable autofill injection, but no good standalone open-source library exists for form detection. Competitors (1Password, Bitwarden) keep this logic proprietary. Building a first-party heuristic engine lets Vaulted tune scoring for its own field types and avoids shipping a heavy external dependency inside content scripts.
## What changes
- ADDED: `detectForms(document)` — returns an array of `FormCandidate` objects with `type`, `confidence`, and `fields` properties.
- ADDED: `classifyField(element)` — classifies a single `<input>` or `<textarea>` into `username`, `password`, `new-password`, `email`, `otp`, `credit-card`, or `unknown`.
- ADDED: `extractLabel(field)` — extracts the human-readable label from associated `<label>`, `aria-label`, `placeholder`, `name`, or surrounding text heuristics.
- ADDED: `scoreConfidence(candidate)` — computes a 0.0–1.0 confidence score based on field composition, label keywords, and structural signals.
- ADDED: `FormObserver` — `MutationObserver`-based wrapper that re-evaluates forms as the DOM changes, with debouncing and configurable `root` / `threshold`.
- ADDED: `FormCandidate`, `FieldClassification`, `LabelSource` — named types for cross-package reuse in `@suaveplan/types` if referenced by ≥2 packages.
- ADDED: `FormDetectionError` — typed error hierarchy (`Timeout`, `NoDocument`, `InvalidField`) with error codes.
## Out of scope
- iframe traversal across origins — content-script CSP and cross-origin policy prevent this; only same-origin iframes are considered.
- CAPTCHA solving or bot-detection bypass — strictly form detection, not interaction automation.
- Shadow-DOM piercing beyond open shadow roots — closed shadow roots are intentionally opaque.
- Visual rendering or CSS-based detection — purely semantic/DOM heuristics, no canvas or computed-style analysis.
- Internationalized keyword expansion beyond the bundled set — future change can add lazy-loaded locale packs.
## Gap analysis: sub-standard → enterprise
Most autofill heuristics rely on brittle regex matching against raw HTML, break under shadow DOM, and ignore dynamically injected content. They lack confidence scoring, forcing integrators to accept or reject candidates blindly. Enterprise deployments need a robust engine that scores every candidate, observes DOM mutations without freezing the main thread, ships tree-shakeable by feature, and stays within a < 16 ms budget to avoid jank. This proposal replaces regex soup with a structured heuristic pipeline, `MutationObserver`-based re-evaluation, and explicit performance budgets.
## Security model
- **Threat model**: Hostile pages may craft deceptive DOM structures to misclassify fields, trick autofill injection, or probe the extension via timing side channels.
- **Encryption boundaries**: N/A — this is a pure DOM-heuristics package with no network transport.
- **Input validation**: Zod schemas validate all exported function inputs (`document`, `Element`, options). Invalid inputs throw `FormDetectionError` with a stable error code.
- **Side-channel resistance**: Detection batches scoring to avoid per-element timing observable from the host page; no early-exit shortcuts that leak form structure.
- **Key zeroization**: N/A — no cryptographic material is handled.
- **Privacy guarantees**: Field values are never logged, never captured in telemetry, and never leave the content-script context. Only structural metadata (tag name, confidence score, field count) may be emitted for diagnostics.
- **Browser extension threats**:
- **Hostile page XSS probes**: The package never executes page scripts; all DOM reads are passive queries.
- **CSP `unsafe-inline` restrictions**: The package runs as a content script injected by the extension manifest, not as inline JavaScript, and is therefore not blocked by CSP `unsafe-inline` policies.
- **Same-origin iframe inclusion with cross-origin exclusion**: The traversal descends into same-origin `<iframe>` documents and includes their forms; cross-origin iframes are excluded by the same-origin policy.
- **Open shadow-root descent with closed shadow-root rejection**: The package descends into open shadow roots to discover fields; closed shadow roots are treated as opaque boundaries and are not pierced.
## Performance SLOs
- **Detection latency**: < 16 ms p99 for pages with < 500 input elements.
- **Observer callback latency**: < 4 ms p99 per mutation batch.
- **Memory**: < 1 MB heap for observer and candidate state on typical pages.
- **Bundle size**: core detection < 5 KB gzipped; full observer + detection < 10 KB gzipped; 100 % tree-shakeable by submodule.
- **Frame rates / layout thrashing**: No forced synchronous layout. DOM reads are batched inside `requestAnimationFrame`; writes are avoided entirely.
- **Throughput**: Mutation-driven re-evaluation debounced at 100 ms; capped at 10 batches/sec.
## Observability plan
- **OTel spans**: `form_detection.detect`, `form_detection.classify`, `form_detection.observer.batch`.
- **Metrics**: detection count by form type, average confidence score, observer mutation batch size histogram, sampled false-negative rate (opt-in feedback channel only).
- **Structured logging**: JSON inside content-script context; never includes field values, URLs, or DOM snapshots.
- **Error telemetry**: `NoDocument` and `InvalidField` rates captured with scrubbed stack traces.
- **Health checks**: `FormObserver` exposes `isConnected()` and `flush()` for runtime diagnostics and graceful teardown.
## Dependency audit
- **Zero runtime dependencies** (enforced by `tests/dep-graph.test.ts`).
- Build-time only: TypeScript, biome, test runner.
- **Supply-chain risk**: Minimal — no third-party runtime packages to compromise.
- **Tree-shakeability**: Every exported submodule is independently tree-shakeable.
- **License**: MIT (project standard).
## Operational readiness
- **Graceful degradation**: If DOM access is restricted (e.g., sandboxed iframe), `detectForms` returns an empty array with `confidence: 0` rather than throwing.
- **Resource limits**: Max 1 000 elements scanned per detection pass; `FormObserver` caps the mutation record buffer at 500 records before forced flush.
- **Error recovery**: Observer automatically re-attaches on SPA navigation via `pageshow` / `visibilitychange` listeners.
- **Backpressure**: Mutation batches are debounced at 100 ms; intermediate states are dropped and only the latest DOM snapshot is evaluated.
- **Version compatibility**: Semantic API versioning; `detectForms` and `classifyField` signatures remain stable across minor versions.
## Verification approach
- `src/detect/detect.test.ts` — covers login, registration, password-change, and checkout form discovery in synthetic DOM trees.
- `src/classify/classify.test.ts` — covers field classification for all supported types and ambiguous edge cases.
- `src/label/label.test.ts` — covers label extraction from `<label for>`, `aria-label`, `aria-labelledby`, `placeholder`, and fallback text heuristics.
- `src/score/score.test.ts` — covers confidence scoring monotonicity, boundary values, and signal aggregation.
- `src/observer/observer.test.ts` — covers mutation debouncing, childList vs attribute mutations, and disconnect lifecycle.
- `src/error/error.test.ts` — covers error code uniqueness, `instanceof` branches, and serialization round-trip.
- `src/spec-conformance.test.ts` — pins tree-shakeability invariant, no-external-deps invariant, and sibling-doc presence.
- `tests/dep-graph.test.ts` — pins dependency invariants (zero runtime dependencies).
- **100 % coverage target** for all non-test source files.
- **Property-based tests** (`fast-check`): random DOM trees preserve classification invariants; confidence monotonicity under field addition; label extraction idempotence.
- **Load / stress criteria**: 10 000-element DOM detected in < 16 ms; observer stress with 1 000 mutations/sec for 60 s without memory growth.
- **Security audit checklist**: verify no field values in logs or telemetry; verify no external network requests; DOM-injection fuzzing confirms classification stability under adversarial input.
## Migration impact
None — additive only.
## Impact
### Affected dependents
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/form-detection`).

View File

@@ -0,0 +1,174 @@
# Capability Spec Delta: form-detection (form-detection)
**Package**: `@suaveplan/form-detection`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: DOM Traversal and Form Candidate Discovery
The package SHALL traverse the DOM to discover candidate form containers. A candidate is any `<form>` element or a loose grouping of related input fields that lack an explicit `<form>` ancestor (e.g., a `<div>` containing username and password inputs). The traversal SHALL respect the same-origin iframe boundary and SHALL descend into open shadow roots. The traversal SHALL stop at `<template>` elements and document fragments that are not attached to the active document.
#### Scenario: Login form inside `<form>` is discovered
- **WHEN** the document contains a `<form>` with username and password inputs
- **THEN** `detectForms` returns a `FormCandidate` with `type: 'login'` and both fields listed
#### Scenario: Registration form without `<form>` ancestor is discovered
- **WHEN** the document contains a `<div>` with email, password, and confirm-password inputs but no wrapping `<form>`
- **THEN** `detectForms` returns a `FormCandidate` with `type: 'registration'` and all three fields listed
#### Scenario: Same-origin iframe forms are included
- **WHEN** the document contains a same-origin `<iframe>` with a login form
- **THEN** `detectForms` called on the top-level document also returns the iframe's form candidate
#### Scenario: Cross-origin iframe forms are excluded
- **WHEN** the document contains a cross-origin `<iframe>` with a login form
- **THEN** `detectForms` does not return the iframe's form candidate
#### Scenario: Open shadow root inputs are discovered
- **WHEN** a custom element hosts an open shadow root containing a password input
- **THEN** `detectForms` descends into the shadow root and discovers the input
**Verified by:** `src/detect/detect.test.ts::"discovers login form inside explicit form element"` AND `src/detect/detect.test.ts::"discovers registration fields without form ancestor"` AND `src/detect/detect.test.ts::"includes same-origin iframe forms"` AND `src/detect/detect.test.ts::"excludes cross-origin iframe forms"` AND `src/detect/detect.spec-conformance.test.ts::"descends into open shadow roots"`
---
### Requirement: Field Classification and Label Extraction
The package SHALL classify each discovered field into `username`, `password`, `new-password`, `email`, `otp`, `credit-card`, or `unknown`. Classification SHALL use a weighted combination of HTML attributes (`type`, `name`, `id`, `autocomplete`), label keywords, and surrounding text context. The package SHALL also extract the human-readable label for each field from, in order of precedence: associated `<label for>`, `aria-label`, `aria-labelledby`, `placeholder`, and surrounding text heuristics.
#### Scenario: Password input classified by type and autocomplete
- **WHEN** an `<input type="password" autocomplete="current-password">` is encountered
- **THEN** `classifyField` returns `password` with high confidence
#### Scenario: Username input classified by name heuristic
- **WHEN** an `<input type="text" name="user">` is encountered with no autocomplete attribute
- **THEN** `classifyField` returns `username` based on name-keyword matching
#### Scenario: OTP input classified by pattern
- **WHEN** an `<input inputmode="numeric" autocomplete="one-time-code" maxlength="6">` is encountered
- **THEN** `classifyField` returns `otp`
#### Scenario: Label extracted from explicit label element
- **WHEN** a field has `<label for="field-id">Email address</label>`
- **THEN** `extractLabel` returns `"Email address"` with source `label-element`
#### Scenario: Label falls back to placeholder
- **WHEN** a field has no `<label>`, `aria-label`, or `aria-labelledby`, but has `placeholder="Enter your name"`
- **THEN** `extractLabel` returns `"Enter your name"` with source `placeholder`
#### Scenario: Ambiguous field returns unknown
- **WHEN** an `<input type="text">` has no distinguishing attributes, labels, or context
- **THEN** `classifyField` returns `unknown` and `extractLabel` returns `null`
**Verified by:** `src/classify/classify.test.ts::"classifies password input by type and autocomplete"` AND `src/classify/classify.test.ts::"classifies username input by name heuristic"` AND `src/classify/classify.test.ts::"classifies OTP input by pattern"` AND `src/label/label.test.ts::"extracts label from explicit label element"` AND `src/label/label.test.ts::"falls back to placeholder when no label exists"` AND `src/classify/classify.test.ts::"returns unknown for ambiguous field"`
---
### Requirement: Confidence Scoring
The package SHALL assign every `FormCandidate` a confidence score in the range [0.0, 1.0]. The score SHALL be a weighted aggregation of structural signals: field-type diversity, presence of expected field combinations, label-keyword matches, and attribute specificity. A candidate with all expected fields for its type and strong label matches SHALL score above 0.8. A candidate with only one weak signal SHALL score below 0.3.
#### Scenario: Perfect login form scores above 0.8
- **WHEN** a form contains a username field, a password field, and a submit button with clear labels
- **THEN** the candidate's `confidence` is greater than or equal to 0.8
#### Scenario: Weak candidate scores below 0.3
- **WHEN** a lone password field exists with no username field and no supporting labels
- **THEN** the candidate's `confidence` is less than or equal to 0.3
#### Scenario: Score is monotonic with added signals
- **WHEN** a second confirming signal (e.g., matching label keyword) is added to a candidate
- **THEN** the new confidence score is greater than or equal to the previous score
#### Scenario: Registration form with confirm password scores higher than without
- **WHEN** a registration form contains email, password, and confirm-password fields versus email and password only
- **THEN** the three-field candidate scores higher than the two-field candidate
**Verified by:** `src/score/score.test.ts::"perfect login form scores at least 0.8"` AND `src/score/score.test.ts::"weak candidate scores at most 0.3"` AND `src/score/score.spec-conformance.test.ts::"confidence is monotonic with added signals"` AND `src/score/score.test.ts::"registration with confirm password scores higher than without"`
---
### Requirement: Mutation Observer Integration
The package SHALL expose `FormObserver`, a `MutationObserver` wrapper that re-evaluates form candidates when the DOM changes. The observer SHALL debounce re-evaluation by a configurable interval (default 150 ms) to batch rapid mutations. It SHALL emit `onFormsDetected` with the full set of current candidates after each debounced pass. Calling `disconnect()` SHALL stop observation and release the underlying `MutationObserver`.
#### Scenario: Observer detects dynamically injected login form
- **WHEN** a login form is injected into the DOM after `FormObserver.start()`
- **THEN** `onFormsDetected` eventually emits a candidate array containing the new login form
#### Scenario: Observer debounces rapid mutations
- **WHEN** ten DOM mutations occur within 50 ms
- **THEN** `detectForms` is called at most twice within that window due to debouncing
#### Scenario: Observer stops after disconnect
- **WHEN** `observer.disconnect()` is called and then a new form is injected
- **THEN** `onFormsDetected` does not emit again
#### Scenario: Observer re-evaluates removed forms
- **WHEN** a previously detected form is removed from the DOM
- **THEN** the next `onFormsDetected` emission no longer includes that candidate
**Verified by:** `src/observer/observer.test.ts::"detects dynamically injected login form"` AND `src/observer/observer.test.ts::"debounces rapid mutations"` AND `src/observer/observer.test.ts::"stops emission after disconnect"` AND `src/observer/observer.test.ts::"removes candidate when form is removed from DOM"`
---
### Requirement: Tree-Shakeable Public API
The package SHALL be fully tree-shakeable. Importing a single function (e.g., `detectForms`) SHALL not pull in `FormObserver` or label-extraction utilities unless they are also imported. The package SHALL ship ES modules only and SHALL declare `sideEffects: false` in `package.json`. The bundled core (without observer) SHALL remain under 10 KB.
#### Scenario: Importing detectForms alone does not include observer
- **WHEN** a consumer imports only `detectForms` and bundles with Rollup/Vite
- **THEN** the output bundle does not contain `FormObserver` or `MutationObserver` references
#### Scenario: Bundle size under 10 KB
- **WHEN** the core entry point (`detectForms`, `classifyField`, `scoreConfidence`) is bundled and minified
- **THEN** the resulting gzip size is less than 10 KB
**Verified by:** `src/spec-conformance.test.ts::"tree-shaking excludes unused modules"` AND `src/spec-conformance.test.ts::"core bundle gzip size is under 10KB"`
---
### Requirement: Performance Budget
The package SHALL complete a full `detectForms` pass on a document with 1,000 DOM nodes in under 16 ms on a modern laptop. The `FormObserver` debounce timer SHALL use an injectable `Clock` interface so tests can run without real timers. No synchronous operation within the package SHALL trigger forced synchronous layout (no `offsetHeight`, `getComputedStyle`, or similar reads during the detection pass).
#### Scenario: Detection completes within 16 ms
- **WHEN** `detectForms` is called on a synthetic document with 1,000 nodes
- **THEN** the elapsed wall time is less than 16 ms
#### Scenario: No forced synchronous layout
- **WHEN** `detectForms` runs on a document with nested flex containers
- **THEN** no `offsetHeight`, `offsetWidth`, `getComputedStyle`, or `getBoundingClientRect` calls are made
#### Scenario: Observer uses injectable clock
- **WHEN** `FormObserver` is instantiated with a custom `Clock` that advances time programmatically
- **THEN** debounced re-evaluation fires exactly when the clock reaches the debounce threshold
**Verified by:** `src/detect/detect.spec-conformance.test.ts::"detects 1000 nodes in under 16ms"` AND `src/detect/detect.spec-conformance.test.ts::"does not trigger forced synchronous layout"` AND `src/observer/observer.test.ts::"debounce respects injectable clock"`

View File

@@ -0,0 +1,72 @@
# Tasks — form-detection
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify. Retroactive ticking is the failure mode this template guards against.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `MIGRATION.md` Sections 4.2, 8, and 9 for package requirements and sequencing
- [ ] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.4 Draft `specs/form-detection/spec.md` from `openspec/templates/spec-delta.md`
- [ ] 1.5 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [ ] 2.1 Scaffold package at `packages/plugin/form-detection` with `package.json`, `tsconfig.json`, `vitest.config.ts`
- [ ] 2.2 Implement `detectForms` in `src/detect/detect.ts` with DOM traversal and form-candidate collection
- [ ] 2.3 Implement `classifyField` in `src/classify/classify.ts` with heuristic rule engine
- [ ] 2.4 Implement `extractLabel` in `src/label/label.ts` with multi-source label resolution
- [ ] 2.5 Implement `scoreConfidence` in `src/score/score.ts` with weighted signal aggregation
- [ ] 2.6 Implement `FormObserver` in `src/observer/observer.ts` with debounced `MutationObserver` wrapper
- [ ] 2.7 Implement `FormDetectionError` hierarchy in `src/error/error.ts` with error codes
- [ ] 2.8 Add subpath exports in `package.json` for every feature module
- [ ] 2.9 Ensure bundle size stays under 10KB for core tree-shaken entry point
- [ ] 2.10 Wire Zod schemas at every declared boundary if external input is accepted
## 3. Tests
- [ ] 3.1 Unit tests at `src/detect/detect.test.ts` covering every scenario
- [ ] 3.2 Unit tests at `src/classify/classify.test.ts` covering every scenario
- [ ] 3.3 Unit tests at `src/label/label.test.ts` covering every scenario
- [ ] 3.4 Unit tests at `src/score/score.test.ts` covering every scenario
- [ ] 3.5 Unit tests at `src/observer/observer.test.ts` covering every scenario
- [ ] 3.6 Unit tests at `src/error/error.test.ts` covering every scenario
- [ ] 3.7 Spec-conformance tests at `src/spec-conformance.test.ts` pinning structural invariants
- [ ] 3.8 Property-based tests for confidence-score monotonicity under signal addition
- [ ] 3.9 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
## 4. Documentation
- [ ] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 4.2 README links every co-located `.md` under `## Modules`
- [ ] 4.3 Update `CLAUDE.md` package listing
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/form-detection` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/form-detection` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/form-detection` — exit 0, no `error` findings
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/form-detection` — green
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [ ] 6.2 Zod schemas wired at every declared boundary
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
- [ ] 6.4 Strict-mode behaviors tested both on and off
- [ ] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 6.6 Lifecycle dedup tested where claimed (in-flight Promise, idempotent set/load)
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/form-detection/spec.md`
- [ ] 7.2 Move `openspec/changes/form-detection/` → `openspec/archive/2026-04-29-form-detection/`
- [ ] 7.3 Refresh `MANIFEST.md` if package set or deps changed