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,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"`