Skip to content

Commit dc97532

Browse files
macmadeclaude
andcommitted
feat: make FITSProperty constructible and editable
Turn FITSProperty from a parse-only, closed model into one that consumers can build and mutate, the building block for constructing and modifying FITS headers. Add a public designated initializer taking a keyword, a FITSValue and an optional comment, plus convenience initializers for the common value types (logical, integer, float, string) that delegate to it. The keyword is validated through the existing keyword-normalization routine: a strict option rejects an out-of-charset or over-length name, while a lenient option upper-cases an otherwise-valid one. The serialization options are required, matching the parsing initializers. Expose value and comment as settable so a constructed or parsed property can be edited in place; the keyword stays immutable after construction. Add tests covering construction of each value kind, keyword validation and coercion, in-place editing, serialization to cards, round-tripping, and the non-finite-float and unknown-value edge cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 37ebcd9 commit dc97532

3 files changed

Lines changed: 294 additions & 7 deletions

File tree

Docs/agent-plans/active/2026-07-11-write-support/write-support-plan.html

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,8 @@ <h3 class="phase-title">Phase 2 — Construction from scratch</h3>
429429

430430
<!-- M6 -->
431431
<div class="milestone">
432-
<h4><span class="m-num">M6</span> Mutable &amp; constructible <code>FITSProperty</code> <span class="capsule notstarted">Not started</span></h4>
432+
<h4><span class="m-num">M6</span> Mutable &amp; constructible <code>FITSProperty</code> <span class="capsule completed">Completed</span></h4>
433+
<p class="meta"><strong>Completed:</strong> 2026-07-11</p>
433434
<div class="field goal">
434435
<div class="label">Goal</div>
435436
<div class="body">Let consumers create and edit individual header records, the building block for both construction and modification.</div>
@@ -442,9 +443,36 @@ <h4><span class="m-num">M6</span> Mutable &amp; constructible <code>FITSProperty
442443
<li>Unit tests: construct properties of each value kind, edit them, and serialize (M3) to correct cards.</li>
443444
</ul>
444445
</div>
445-
<div class="field notes-field"><div class="label">Implementation notes</div><div class="body placeholder"></div></div>
446-
<div class="field auto-field"><div class="label">Automated review</div><div class="body placeholder"></div></div>
447-
<div class="field review-field"><div class="label">Review comments</div><div class="body placeholder"></div></div>
446+
<div class="field notes-field"><div class="label">Implementation notes</div><div class="body">
447+
<p>Implemented with TDD (red → green). Wrote the seven new tests first, confirmed the red state via <code>xcodebuild</code> (missing initializers/setters fail to compile), then implemented. No new files, so no Xcode-project changes.</p>
448+
<p><strong>Three design decisions were surfaced to the user and approved before coding:</strong></p>
449+
<ul>
450+
<li><strong>Convenience initializers use distinct argument labels</strong> (<code>logical:</code>, <code>integer:</code>, <code>float:</code>, <code>string:</code>) rather than an overloaded <code>value:</code> — self-documenting and unambiguous at the call site.</li>
451+
<li><strong>The keyword <code>name</code> stays immutable</strong> (<code>public private( set )</code>): only <code>value</code> and <code>comment</code> become settable. Renaming a record is structural; to use a different keyword, build a new property. Documented on the <code>name</code> property.</li>
452+
<li><strong>Keyword validation reuses <code>normalizedKeyword(_:options:)</code></strong> and takes a <code>FITSSerializationOptions</code> parameter, so <code>.lenient</code>/<code>.coerceInvalidKeywords</code> can upper-case an otherwise-valid keyword while <code>.strict</code> rejects it. The parameter <strong>defaults to <code>.strict</code></strong> so the common convenience call stays ergonomic (<code>FITSProperty( name: "SIMPLE", logical: true )</code>); this default is flagged for review.</li>
453+
</ul>
454+
<p><strong>API:</strong> a public designated <code>init( name:value:comment:options: ) throws</code> (comment defaults to <code>nil</code>, options to <code>.strict</code>) plus four public convenience initializers delegating to it. Validation delegates to the existing M3 <code>normalizedKeyword</code>, so an out-of-charset or over-length name throws <code>cannotSerialize</code>; the blank keyword and the commentary keywords (<code>COMMENT</code>/<code>HISTORY</code>) are accepted. Consistent with the M2 design, a non-finite <code>.float</code> is accepted at construction and only rejected on serialization.</p>
455+
<p><strong>Mutability:</strong> <code>value</code> and <code>comment</code> changed from <code>public private( set ) var</code> to <code>public var</code>; both doc comments updated to describe in-place editing (and, for <code>comment</code>, the commentary newline-per-card behavior). No derived state at the property level, so plain setters keep the model consistent; the section-level dirty-tracking hook is M7's concern.</p>
456+
<p><strong>Verification:</strong> <code>xcodebuild test</code><em>TEST SUCCEEDED</em>; <code>swift test</code> → 195 tests passed (+7). No regressions (the visibility change broke no existing callers; the full suite is green).</p>
457+
<p><strong>Automated review loop:</strong> 1 round; converged with no Critical/High/Medium findings.</p>
458+
<p><strong>Post-review (human feedback):</strong> made the <code>options</code> parameter required — dropped the <code>= .strict</code> default from all five initializers, so it is now consistent with the parsing initializers (<code>init( string:options: )</code>), which also take options with no default. Updated the doc comments accordingly and threaded an explicit <code>options: .strict</code> (or <code>.lenient</code>) through every new test call site; while doing so, re-checked alignment and restructured the round-trip test to build via the designated initializer uniformly so its <code>name:</code>/<code>value:</code>/<code>comment:</code>/<code>options:</code> columns align cleanly.</p>
459+
<p>Also added the two tests from automated-review finding #3 (per user request): <code>constructsNonFiniteFloatButRejectsItOnSerialization</code> asserts that <code>±inf</code>/<code>NaN</code> construct successfully but throw on <code>serialized</code>, and <code>constructsWithUnknownValueThroughDesignatedInitializer</code> builds an <code>.unknown</code> value through the designated initializer and round-trips it through serialization. Findings #1 and #2 were left as-is at the user's direction. Full suite now 197 passing.</p>
460+
</div></div>
461+
<div class="field auto-field"><div class="label">Automated review</div><div class="body">
462+
<p>Round 1 — no Critical/High/Medium. The fresh-context reviewer read both changed files in full with surrounding context, confirmed every approved design decision is correctly realized, verified doc-comment completeness/accuracy and alignment adherence, and ran the property tests (<em>TEST SUCCEEDED</em>, no regression). Three findings, all <code>Deferred</code>:</p>
463+
<ul>
464+
<li><strong>Info — reference-type aliasing is now observable through the public setters:</strong> since <code>FITSProperty</code> is a class, code holding a shared instance (e.g. one owned by a <code>FITSSection</code>) can mutate it in place. Intended consequence of the approved "settable to edit in place" decision; flagged as something to weigh when designing section-level mutation APIs (M7+). <em>Deferred.</em></li>
465+
<li><strong>Info — no early validation that the value matches a commentary/reserved keyword:</strong> <code>FITSProperty( name: "COMMENT", value: .integer( 5 ) )</code> serializes with the value silently ignored, and a <code>CONTINUE</code> name with a non-string value throws only at write time. Consistent with the parser's existing behavior and the deferred-validation philosophy. <em>Deferred.</em></li>
466+
<li><strong>Low — test-coverage additions:</strong> the non-finite-<code>.float</code> construct-succeeds / serialize-throws contract (unique to this new surface) and a designated-init-with-<code>.unknown</code> case are not exercised. The milestone's stated test tasks are met; these would lock down a documented guarantee against future refactors. <em>Fixed</em> — both tests were added after the user asked for them.</li>
467+
</ul>
468+
</div></div>
469+
<div class="field review-field"><div class="label">Review comments</div><div class="body">
470+
<ul>
471+
<li>The <code>options</code> parameter must be required — drop the <code>.strict</code> default on the initializers.</li>
472+
<li>Explain each automated-review finding in simple terms.</li>
473+
<li>Add the tests from automated-review finding #3 (the non-finite-<code>.float</code> construct-succeeds / serialize-throws contract, and a designated-init-with-<code>.unknown</code> case). Findings #1 and #2 are fine as-is.</li>
474+
</ul>
475+
</div></div>
448476
</div>
449477

450478
<!-- M7 -->

SwiftFITS/FITSProperty.swift

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,26 @@ import Foundation
3232
public class FITSProperty: CustomStringConvertible
3333
{
3434
/// The keyword name, with trailing padding removed.
35+
///
36+
/// The name is fixed at construction: it is validated (and, under a lenient
37+
/// serialization option, coerced) when the property is created, and cannot be
38+
/// changed afterwards. To use a different keyword, build a new property.
3539
public private( set ) var name: String
3640

37-
/// The parsed value of the record.
38-
public private( set ) var value: FITSValue
41+
/// The value of the record.
42+
///
43+
/// Settable so a constructed or parsed property can be edited in place. Any
44+
/// ``FITSValue`` is accepted; a value that cannot be rendered (for example a
45+
/// non-finite ``FITSValue/float(_:)``) is rejected later, on serialization.
46+
public var value: FITSValue
3947

4048
/// The record's comment, or `nil` when there is none.
41-
public private( set ) var comment: String?
49+
///
50+
/// Settable so a constructed or parsed property can be edited in place. For a
51+
/// commentary keyword (`COMMENT`, `HISTORY` or the blank keyword) the comment
52+
/// is the record's only payload, and embedded newlines render as one card per
53+
/// line on serialization.
54+
public var comment: String?
4255

4356
/// Creates a property from one 80-byte record of ASCII data.
4457
///
@@ -110,6 +123,98 @@ public class FITSProperty: CustomStringConvertible
110123
}
111124
}
112125

126+
/// Creates a property from a keyword, a value and an optional comment.
127+
///
128+
/// This is the building block for constructing header records from scratch
129+
/// and for editing parsed ones. The keyword is validated against the FITS
130+
/// keyword character set via ``normalizedKeyword(_:options:)``: under a strict
131+
/// option an out-of-charset or over-length name is rejected, while a lenient
132+
/// option upper-cases an otherwise-valid name. The blank keyword and the
133+
/// commentary keywords (`COMMENT`, `HISTORY`) are accepted.
134+
///
135+
/// - Parameters:
136+
/// - name: The keyword name.
137+
/// - value: The record's value; use ``FITSValue/undefined`` for a keyword
138+
/// that carries no value.
139+
/// - comment: The record's comment, or `nil` for none.
140+
/// - options: The serialization options governing keyword validation:
141+
/// ``FITSSerializationOptions/strict`` rejects an invalid keyword, while a
142+
/// lenient option may coerce it.
143+
/// - Throws: ``FITSError/cannotSerialize(reason:)`` if the keyword is invalid
144+
/// and cannot be coerced.
145+
public init( name: String, value: FITSValue, comment: String? = nil, options: FITSSerializationOptions ) throws
146+
{
147+
self.name = try FITSProperty.normalizedKeyword( name, options: options )
148+
self.value = value
149+
self.comment = comment
150+
}
151+
152+
/// Creates a property holding a logical (boolean) value.
153+
///
154+
/// - Parameters:
155+
/// - name: The keyword name.
156+
/// - logical: The boolean value, rendered `T` or `F`.
157+
/// - comment: The record's comment, or `nil` for none.
158+
/// - options: The serialization options governing keyword validation:
159+
/// ``FITSSerializationOptions/strict`` rejects an invalid keyword, while a
160+
/// lenient option may coerce it.
161+
/// - Throws: ``FITSError/cannotSerialize(reason:)`` if the keyword is invalid
162+
/// and cannot be coerced.
163+
public convenience init( name: String, logical: Bool, comment: String? = nil, options: FITSSerializationOptions ) throws
164+
{
165+
try self.init( name: name, value: .logical( logical ), comment: comment, options: options )
166+
}
167+
168+
/// Creates a property holding an integer value.
169+
///
170+
/// - Parameters:
171+
/// - name: The keyword name.
172+
/// - integer: The integer value.
173+
/// - comment: The record's comment, or `nil` for none.
174+
/// - options: The serialization options governing keyword validation:
175+
/// ``FITSSerializationOptions/strict`` rejects an invalid keyword, while a
176+
/// lenient option may coerce it.
177+
/// - Throws: ``FITSError/cannotSerialize(reason:)`` if the keyword is invalid
178+
/// and cannot be coerced.
179+
public convenience init( name: String, integer: Int64, comment: String? = nil, options: FITSSerializationOptions ) throws
180+
{
181+
try self.init( name: name, value: .integer( integer ), comment: comment, options: options )
182+
}
183+
184+
/// Creates a property holding a floating-point value.
185+
///
186+
/// - Parameters:
187+
/// - name: The keyword name.
188+
/// - float: The floating-point value. A non-finite value is accepted here
189+
/// but rejected on serialization.
190+
/// - comment: The record's comment, or `nil` for none.
191+
/// - options: The serialization options governing keyword validation:
192+
/// ``FITSSerializationOptions/strict`` rejects an invalid keyword, while a
193+
/// lenient option may coerce it.
194+
/// - Throws: ``FITSError/cannotSerialize(reason:)`` if the keyword is invalid
195+
/// and cannot be coerced.
196+
public convenience init( name: String, float: Double, comment: String? = nil, options: FITSSerializationOptions ) throws
197+
{
198+
try self.init( name: name, value: .float( float ), comment: comment, options: options )
199+
}
200+
201+
/// Creates a property holding a string value.
202+
///
203+
/// - Parameters:
204+
/// - name: The keyword name.
205+
/// - string: The string value; it is single-quoted and, when too long for
206+
/// one card, split across `CONTINUE` records on serialization.
207+
/// - comment: The record's comment, or `nil` for none.
208+
/// - options: The serialization options governing keyword validation:
209+
/// ``FITSSerializationOptions/strict`` rejects an invalid keyword, while a
210+
/// lenient option may coerce it.
211+
/// - Throws: ``FITSError/cannotSerialize(reason:)`` if the keyword is invalid
212+
/// and cannot be coerced.
213+
public convenience init( name: String, string: String, comment: String? = nil, options: FITSSerializationOptions ) throws
214+
{
215+
try self.init( name: name, value: .string( string ), comment: comment, options: options )
216+
}
217+
113218
/// Merges a continuation record into this property in place.
114219
///
115220
/// Supports the three multi-record FITS conventions: appending another

0 commit comments

Comments
 (0)