Initial Commit
This commit is contained in:
174
openspec/changes/form-detection/specs/form-detection/spec.md
Normal file
174
openspec/changes/form-detection/specs/form-detection/spec.md
Normal 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"`
|
||||
Reference in New Issue
Block a user