diff --git a/.gitignore b/.gitignore index 992084a..63c4f28 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,6 @@ tools/specs/ # into a workspace-relative path so every OS/edition shares one cache location). .psmodules/ -# Not yet decided whether this should be tracked -- excluded for now -tools/ - # OS Thumbs.db Desktop.ini diff --git a/Data/PfbCapabilityMap.json b/Data/PfbCapabilityMap.json index 4822063..72c0c52 100644 --- a/Data/PfbCapabilityMap.json +++ b/Data/PfbCapabilityMap.json @@ -2146,16 +2146,16 @@ "quiesce": "2.28", "skip_quiesce": "2.28" }, - "contextScope": { - "scope": "array", - "provenance": "default" - }, "readOnlyBodyProperties": [ "created", "id", "promotion_status", "time_remaining" ], + "contextScope": { + "scope": "array", + "provenance": "default" + }, "parameterComponentOverrides": { "ignore_usage": "Ignore_usage" } @@ -7060,14 +7060,14 @@ "names": "2.14" }, "bodyProperties": { - "policy_type": "2.14", "name": "2.14", "id": "2.14", - "signing_authority": "2.14", - "static_authorized_principals": "2.14", "enabled": "2.14", "is_local": "2.14", "location": "2.14", + "policy_type": "2.14", + "signing_authority": "2.14", + "static_authorized_principals": "2.14", "realms": "2.19", "context": "2.24" }, @@ -7731,6 +7731,11 @@ "names": "2.16" }, "bodyProperties": { + "name": "2.16", + "id": "2.16", + "enabled": "2.16", + "is_local": "2.16", + "location": "2.16", "policy_type": "2.16", "lockout_duration": "2.16", "max_login_attempts": "2.16", @@ -7741,11 +7746,6 @@ "min_characters_per_group": "2.16", "enforce_username_check": "2.16", "enforce_dictionary_check": "2.16", - "name": "2.17", - "id": "2.17", - "location": "2.17", - "is_local": "2.17", - "enabled": "2.17", "max_password_age": "2.18", "realms": "2.19" }, @@ -9392,7 +9392,30 @@ "X-Request-ID": "2.18", "add_to_groups": "2.23" }, - "bodyProperties": {}, + "bodyProperties": { + "id": "2.18", + "name": "2.18", + "capacity": "2.18", + "data_addresses": "2.18", + "details": "2.18", + "management_address": "2.18", + "raw_capacity": "2.18", + "serial_number": "2.18", + "status": "2.18", + "unique": "2.18", + "chassis_serial_number": "2.23", + "node_key": "2.27" + }, + "readOnlyBodyProperties": [ + "capacity", + "chassis_serial_number", + "data_addresses", + "details", + "id", + "raw_capacity", + "status", + "unique" + ], "contextScope": { "scope": "array", "provenance": "default" @@ -10164,7 +10187,10 @@ "parameters": { "X-Request-ID": "2.19" }, - "bodyProperties": {}, + "bodyProperties": { + "resource": "2.19", + "scope": "2.19" + }, "contextScope": { "scope": "array", "provenance": "default" @@ -11862,7 +11888,13 @@ "resource_ids": "2.23", "resource_names": "2.23" }, - "bodyProperties": {}, + "bodyProperties": { + "copyable": "2.23", + "key": "2.23", + "namespace": "2.23", + "resource": "2.23", + "value": "2.23" + }, "contextScope": { "scope": "array", "provenance": "default" @@ -13539,7 +13571,12 @@ "fleet_names": "2.27", "validate_target_certificates": "2.27" }, - "bodyProperties": {}, + "bodyProperties": { + "authentication_credentials": "2.27", + "ca_certificate": "2.27", + "ca_certificate_group": "2.27", + "management_address": "2.27" + }, "contextScope": { "scope": "array", "provenance": "default" diff --git a/Private/Assert-PfbApiCapability.ps1 b/Private/Assert-PfbApiCapability.ps1 index 5045d85..65b7482 100644 --- a/Private/Assert-PfbApiCapability.ps1 +++ b/Private/Assert-PfbApiCapability.ps1 @@ -85,17 +85,30 @@ function Assert-PfbApiCapability { } } - # Dictionary bodies only, and deliberately so. An array body's per-element fields are NOT - # checked, because the capability map records nothing to check them against: - # tools/Build-PfbCapabilityMap.ps1 fills bodyProperties from Get-PfbSchemaPropertyNames, - # whose schema walk resolves $ref and allOf but never descends through an array schema's - # "items". Every array-bodied endpoint in the spec -- PUT /workloads/tags/batch, - # POST /nodes/batch, POST /resource-accesses/batch -- therefore carries - # "bodyProperties": {} in Data/PfbCapabilityMap.json, so a union-of-element-fields check - # would compare every field against an empty map and could never fire. Skipping is an - # honest no-op; the alternative is a gate that only looks like one. Teaching the map to - # record per-element fields is its own change, and this loop picks it up for free if a - # future map representation ever lands. + # Dictionary bodies only. An array body's per-element fields are NOT checked -- but note + # the reason has changed, and the note this comment used to carry was wrong about what + # would happen next. + # + # It previously read that array-bodied endpoints carry "bodyProperties": {} in + # Data/PfbCapabilityMap.json, because Get-PfbSchemaPropertyNames' walk resolves $ref and + # allOf but never descends an array schema's "items" -- so a check would compare every + # field against an empty map and could never fire. That was true, and it concluded that + # "this loop picks it up for free if a future map representation ever lands." + # + # That map representation has now landed (issue #82): Get-PfbSpecCapabilities hops "items" + # at its call site, and PUT /workloads/tags/batch, POST /nodes/batch, + # POST /resource-accesses/batch and POST /fleets/members/batch now carry real per-element + # fields. But it is NOT picked up for free, because of the type guard on the line below: + # an array body arrives as [hashtable[]] (see Set-PfbWorkloadTag, which passes its -Tags + # straight through), and [hashtable[]] is not an IDictionary, so this loop is skipped + # before the map is ever consulted. + # + # Enabling it therefore means relaxing this guard to iterate the elements and union their + # keys -- a deliberate behaviour change that can start refusing calls that succeed today, + # not a no-op. It is left as its own change rather than smuggled in with the map fix. + # Today the blast radius is nil: Set-PfbWorkloadTag is the only cmdlet reaching an + # array-bodied endpoint, and all five of its fields are 2.23 -- but that stops being true + # as soon as #44 adds cmdlets for the other three. # # Endpoint minVersion and query-parameter checks above still run for array-bodied calls, # which is gating those endpoints previously had none of. diff --git a/Reports/PfbApiDriftReport.json b/Reports/PfbApiDriftReport.json index cf99091..787c823 100644 --- a/Reports/PfbApiDriftReport.json +++ b/Reports/PfbApiDriftReport.json @@ -10548,114 +10548,16 @@ "context_names", "ids", "local_file_system_ids", + "remote_default_exports", "remote_ids" ], "missingBodyProperties": [ - { - "name": "direction", - "type": null, - "format": null, - "specRequired": false, - "synopsis": null, - "suggestedPowerShellType": "[object]", - "enumValues": [ - "inbound", - "outbound" - ], - "enumStatus": "matched", - "target": { - "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", - "paramBlockLine": 55, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": false - } - }, - { - "name": "link_type", - "type": "string", - "format": null, - "specRequired": false, - "synopsis": "Type of the replica link.", - "suggestedPowerShellType": "[string]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", - "paramBlockLine": 55, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": false - } - }, - { - "name": "local_file_system", - "type": null, - "format": null, - "specRequired": false, - "synopsis": "Reference to a local file system.", - "suggestedPowerShellType": "[object]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", - "paramBlockLine": 55, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": false - } - }, - { - "name": "policies", - "type": "array", - "format": null, - "specRequired": false, - "synopsis": null, - "suggestedPowerShellType": "[object[]]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", - "paramBlockLine": 55, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": false - } - }, - { - "name": "remote", - "type": null, - "format": null, - "specRequired": false, - "synopsis": "Reference to a remote array or realm.", - "suggestedPowerShellType": "[object]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", - "paramBlockLine": 55, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": false - } - }, - { - "name": "remote_file_system", - "type": null, - "format": null, - "specRequired": false, - "synopsis": "Reference to a remote file system.", - "suggestedPowerShellType": "[object]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", - "paramBlockLine": 55, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": false - } - } + "direction", + "link_type", + "local_file_system", + "policies", + "remote", + "remote_file_system" ], "readOnlyFields": [ "context", @@ -10666,10 +10568,17 @@ "status_details" ], "confidence": { - "level": "high", - "unresolvedParameters": [], + "level": "partial", + "unresolvedParameters": [ + { + "parameter": "RemoteDefaultExports", + "surface": "TypedUnresolved", + "file": "Public/Replication/New-PfbFileSystemReplicaLink.ps1", + "line": 57 + } + ], "escapeHatchOnly": [], - "caveat": "" + "caveat": "one or more parameters could not be traced to a wire name and have no -Attributes escape hatch; lists reflect typed-parameter coverage only, not full wire reachability" }, "annotations": [] }, @@ -14149,7 +14058,13 @@ "missingQueryParameters": [ "context_names" ], - "missingBodyProperties": [], + "missingBodyProperties": [ + "copyable", + "key", + "namespace", + "resource", + "value" + ], "readOnlyFields": [], "confidence": { "level": "partial", @@ -14170,8 +14085,8 @@ "systemicGaps": [ { "name": "context_names", - "endpointCount": 270, - "queryEndpointCount": 270, + "endpointCount": 269, + "queryEndpointCount": 269, "bodyEndpointCount": 0, "endpoints": [ "DELETE /admins/api-tokens", @@ -14404,7 +14319,6 @@ "POST /directory-services/local/directory-services", "POST /directory-services/local/groups", "POST /file-system-exports", - "POST /file-system-replica-links", "POST /file-system-replica-links/policies", "POST /file-systems/audit-policies", "POST /file-systems/locks/nlm-reclamations", @@ -14592,8 +14506,8 @@ }, { "name": "ids", - "endpointCount": 43, - "queryEndpointCount": 43, + "endpointCount": 42, + "queryEndpointCount": 42, "bodyEndpointCount": 0, "endpoints": [ "DELETE /network-access-policies/rules", @@ -14637,8 +14551,7 @@ "PATCH /password-policies", "PATCH /smb-client-policies/rules", "PATCH /smb-share-policies/rules", - "PATCH /syslog-servers/settings", - "POST /file-system-replica-links" + "PATCH /syslog-servers/settings" ], "annotations": [] }, @@ -14848,8 +14761,8 @@ }, { "name": "remote_ids", - "endpointCount": 15, - "queryEndpointCount": 15, + "endpointCount": 14, + "queryEndpointCount": 14, "bodyEndpointCount": 0, "endpoints": [ "DELETE /array-connections", @@ -14865,8 +14778,7 @@ "GET /file-system-replica-links/policies", "GET /file-system-replica-links/transfer", "GET /policies-all/members", - "GET /policies/file-system-replica-links", - "POST /file-system-replica-links" + "GET /policies/file-system-replica-links" ], "annotations": [] }, @@ -14926,23 +14838,6 @@ ], "annotations": [] }, - { - "name": "local_file_system_ids", - "endpointCount": 8, - "queryEndpointCount": 8, - "bodyEndpointCount": 0, - "endpoints": [ - "DELETE /file-system-replica-links", - "DELETE /file-system-replica-links/policies", - "DELETE /policies/file-system-replica-links", - "GET /file-system-replica-links", - "GET /file-system-replica-links/policies", - "GET /policies-all/members", - "GET /policies/file-system-replica-links", - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "actions", "endpointCount": 7, @@ -14975,6 +14870,22 @@ ], "annotations": [] }, + { + "name": "local_file_system_ids", + "endpointCount": 7, + "queryEndpointCount": 7, + "bodyEndpointCount": 0, + "endpoints": [ + "DELETE /file-system-replica-links", + "DELETE /file-system-replica-links/policies", + "DELETE /policies/file-system-replica-links", + "GET /file-system-replica-links", + "GET /file-system-replica-links/policies", + "GET /policies-all/members", + "GET /policies/file-system-replica-links" + ], + "annotations": [] + }, { "name": "name", "endpointCount": 7, @@ -16409,16 +16320,6 @@ ], "annotations": [] }, - { - "name": "direction", - "endpointCount": 1, - "queryEndpointCount": 0, - "bodyEndpointCount": 1, - "endpoints": [ - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "discover_mtu", "endpointCount": 1, @@ -16759,16 +16660,6 @@ ], "annotations": [] }, - { - "name": "link_type", - "endpointCount": 1, - "queryEndpointCount": 0, - "bodyEndpointCount": 1, - "endpoints": [ - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "local_bucket_ids", "endpointCount": 1, @@ -16779,16 +16670,6 @@ ], "annotations": [] }, - { - "name": "local_file_system", - "endpointCount": 1, - "queryEndpointCount": 0, - "bodyEndpointCount": 1, - "endpoints": [ - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "local_host", "endpointCount": 1, @@ -17049,16 +16930,6 @@ ], "annotations": [] }, - { - "name": "policies", - "endpointCount": 1, - "queryEndpointCount": 0, - "bodyEndpointCount": 1, - "endpoints": [ - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "port", "endpointCount": 1, @@ -17229,16 +17100,6 @@ ], "annotations": [] }, - { - "name": "remote", - "endpointCount": 1, - "queryEndpointCount": 0, - "bodyEndpointCount": 1, - "endpoints": [ - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "remote_assist_active", "endpointCount": 1, @@ -17259,16 +17120,6 @@ ], "annotations": [] }, - { - "name": "remote_file_system", - "endpointCount": 1, - "queryEndpointCount": 0, - "bodyEndpointCount": 1, - "endpoints": [ - "POST /file-system-replica-links" - ], - "annotations": [] - }, { "name": "remote_host", "endpointCount": 1, @@ -19519,15 +19370,6 @@ "Update-PfbSubnet" ] }, - { - "name": "remote", - "cmdletCount": 3, - "cmdlets": [ - "New-PfbArrayConnection", - "Update-PfbArrayConnection", - "Update-PfbObjectStoreRemoteCredential" - ] - }, { "name": "remote_file_system_names", "cmdletCount": 3, @@ -20335,11 +20177,6 @@ "cmdletCount": 0, "cmdlets": [] }, - { - "name": "direction", - "cmdletCount": 0, - "cmdlets": [] - }, { "name": "directory_configurations", "cmdletCount": 0, @@ -20470,11 +20307,6 @@ "cmdletCount": 0, "cmdlets": [] }, - { - "name": "link_type", - "cmdletCount": 0, - "cmdlets": [] - }, { "name": "local_bucket_ids", "cmdletCount": 0, @@ -20485,11 +20317,6 @@ "cmdletCount": 0, "cmdlets": [] }, - { - "name": "local_file_system", - "cmdletCount": 0, - "cmdlets": [] - }, { "name": "local_host", "cmdletCount": 0, @@ -20610,11 +20437,6 @@ "cmdletCount": 0, "cmdlets": [] }, - { - "name": "policies", - "cmdletCount": 0, - "cmdlets": [] - }, { "name": "port", "cmdletCount": 0, @@ -20670,11 +20492,6 @@ "cmdletCount": 0, "cmdlets": [] }, - { - "name": "remote_file_system", - "cmdletCount": 0, - "cmdlets": [] - }, { "name": "remote_file_system_ids", "cmdletCount": 0, diff --git a/Reports/PfbApiDriftReport.md b/Reports/PfbApiDriftReport.md index ca94f04..1360edd 100644 --- a/Reports/PfbApiDriftReport.md +++ b/Reports/PfbApiDriftReport.md @@ -22,12 +22,12 @@ This report accepts **false positives in order to eliminate false negatives**. A - Uncovered endpoints: 96 - Endpoints with parameter gaps: 438 -- Missing body properties (addable): 422 -- Missing query parameters (addable): 952 +- Missing body properties (addable): 427 +- Missing query parameters (addable): 953 - Read-only body fields (not addable -- see the Read-only fields section below): 382 - Phantom fields silently excluded (accumulated in the capability map, absent from the newest analysed spec): 40 -- Partial-confidence endpoints (see `How to read this report` above, and each row's marker in the Parameter gaps table): 59 -- Systemic gaps (distinct field names collapsed across high-confidence endpoints, detailed below): 252 +- Partial-confidence endpoints (see `How to read this report` above, and each row's marker in the Parameter gaps table): 60 +- Systemic gaps (distinct field names collapsed across high-confidence endpoints, detailed below): 246 - ValidateSet drift: 0 - New ValidateSet candidates: 1 - Context cardinality signal disagreements (fb2.28): 9 @@ -39,13 +39,13 @@ This report accepts **false positives in order to eliminate false negatives**. A One finding per distinct wire field name, collapsed across every endpoint where a high-confidence gap exists (decision 7) -- turns hundreds of per-endpoint rows into a handful of real, actionable decisions. "Cmdlets already using this name" is decision 8's convention-strength ranking: a high count means closing the remaining gaps for this name is a mechanical batch fix; zero means no established convention exists to extend at all -- closing it is an architectural decision, not a mechanical one. -Showing the top 25 of 252 findings by endpoint count -- the full list is in the JSON manifest's `systemicGaps`, nothing is dropped there. +Showing the top 25 of 246 findings by endpoint count -- the full list is in the JSON manifest's `systemicGaps`, nothing is dropped there. | Field name | Endpoints | Query | Body | Cmdlets already using this name | Annotation | |---|---|---|---|---|---| -| `context_names` | 270 | 270 | 0 | 0 | not yet implemented | +| `context_names` | 269 | 269 | 0 | 0 | not yet implemented | | `allow_errors` | 118 | 118 | 0 | 0 | not yet implemented | -| `ids` | 43 | 43 | 0 | 223 | | +| `ids` | 42 | 42 | 0 | 223 | | | `names` | 31 | 31 | 0 | 308 | | | `sort` | 28 | 28 | 0 | 180 | | | `bucket_ids` | 17 | 17 | 0 | 2 | | @@ -53,13 +53,13 @@ Showing the top 25 of 252 findings by endpoint count -- the full list is in the | `total_only` | 17 | 17 | 0 | 39 | | | `bucket_names` | 16 | 16 | 0 | 3 | | | `member_ids` | 15 | 15 | 0 | 85 | | -| `remote_ids` | 15 | 15 | 0 | 3 | | +| `remote_ids` | 14 | 14 | 0 | 3 | | | `policy_names` | 11 | 11 | 0 | 118 | | | `file_system_ids` | 9 | 9 | 0 | 9 | | | `remote_names` | 9 | 9 | 0 | 9 | | -| `local_file_system_ids` | 8 | 8 | 0 | 2 | | | `actions` | 7 | 0 | 7 | 2 | | | `limit` | 7 | 7 | 0 | 199 | | +| `local_file_system_ids` | 7 | 7 | 0 | 2 | | | `name` | 7 | 0 | 7 | 20 | | | `versions` | 7 | 7 | 0 | 5 | | | `enabled` | 6 | 0 | 6 | 10 | | @@ -437,7 +437,7 @@ Endpoints an existing cmdlet already calls, where the capability map knows of a | `POST /directory-services/roles` | New-PfbDirectoryServiceRole | | group, group_base, management_access_policies, role | `high` | | | `POST /dns` | New-PfbDns | context_names, names | ca_certificate, ca_certificate_group, domain, nameservers, services, sources | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | | `POST /file-system-exports` | New-PfbFileSystemExport | context_names, member_ids, policy_ids | | `high` | | -| `POST /file-system-replica-links` | New-PfbFileSystemReplicaLink | context_names, ids, local_file_system_ids, remote_ids | direction, link_type, local_file_system, policies, remote, remote_file_system | `high` | | +| `POST /file-system-replica-links` | New-PfbFileSystemReplicaLink | context_names, ids, local_file_system_ids, remote_default_exports, remote_ids | direction, link_type, local_file_system, policies, remote, remote_file_system | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | | `POST /file-system-replica-links/policies` | New-PfbFileSystemReplicaLinkPolicy | context_names | | `high` | | | `POST /file-system-snapshots` | New-PfbFileSystemSnapshot | context_names, source_ids, source_names | | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | | `POST /file-systems` | New-PfbFileSystem | context_names, default_exports, discard_non_snapshotted_data, include_snapshot, overwrite, policy_ids, policy_names | fast_remove_directory_enabled, hard_limit_enabled, http, multi_protocol, nfs, node_group, smb, snapshot_directory_enabled, workload, writable | `partial` -- /!\ 15 unresolved params (see Partial-confidence detail below) | | @@ -512,7 +512,7 @@ Endpoints an existing cmdlet already calls, where the capability map knows of a | `POST /workloads/placement-recommendations` | New-PfbWorkloadPlacementRecommendation | context_names | additional_constraints, parameters, preset, projection_months, recommendation_engine, results_limit | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | | `POST /worm-data-policies` | New-PfbWormPolicy | context_names, names | default_retention, enabled, location, max_retention, min_retention, mode, retention_lock | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | | `PUT /presets/workload` | Set-PfbPresetWorkload | context_names | description, directory_configurations, export_configurations, name, parameters, periodic_replication_configurations, placement_configurations, platform_features, qos_configurations, quota_configurations, snapshot_configurations, volume_configurations, workload_tags, workload_type | `high` | | -| `PUT /workloads/tags/batch` | Set-PfbWorkloadTag | context_names | | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | +| `PUT /workloads/tags/batch` | Set-PfbWorkloadTag | context_names | copyable, key, namespace, resource, value | `partial` -- /!\ 1 unresolved param (see Partial-confidence detail below) | | ### Partial-confidence detail @@ -572,6 +572,7 @@ Per the decision-6 procedure above: open each parameter at its `file:line` and f | `POST /data-eviction-policies` | `-Disabled` | TypedUnresolved | `Public/DataEviction/New-PfbDataEvictionPolicy.ps1:31` | one or more parameters could not be traced to a wire name and have no -Attributes escape hatch; lists reflect typed-parameter coverage only, not full wire reachability | | `POST /directory-services/local/groups/members` | `-Member` | TypedUnresolved | `Public/DirectoryService/New-PfbLocalGroupMember.ps1:35` | one or more parameters could not be traced to a wire name and have no -Attributes escape hatch; lists reflect typed-parameter coverage only, not full wire reachability | | `POST /dns` | `-Name` | AttributesOnly | `Public/Network/New-PfbDns.ps1:29` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | +| `POST /file-system-replica-links` | `-RemoteDefaultExports` | TypedUnresolved | `Public/Replication/New-PfbFileSystemReplicaLink.ps1:57` | one or more parameters could not be traced to a wire name and have no -Attributes escape hatch; lists reflect typed-parameter coverage only, not full wire reachability | | `POST /file-system-snapshots` | `-SourceName` | TypedUnresolved | `Public/FileSystemSnapshot/New-PfbFileSystemSnapshot.ps1:36` | one or more parameters could not be traced to a wire name and have no -Attributes escape hatch; lists reflect typed-parameter coverage only, not full wire reachability | | `POST /file-systems` | `-FastRemoveDirectoryEnabled` | AttributesOnly | `Public/FileSystem/New-PfbFileSystem.ps1:139` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | | `POST /file-systems` | `-HardLimit` | AttributesOnly | `Public/FileSystem/New-PfbFileSystem.ps1:93` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | diff --git a/Reports/PfbFieldCmdletMap.json b/Reports/PfbFieldCmdletMap.json index df3fce1..b5587d7 100644 --- a/Reports/PfbFieldCmdletMap.json +++ b/Reports/PfbFieldCmdletMap.json @@ -12157,16 +12157,6 @@ "stableSinceOldestVersion": null, "recommendation": null }, - { - "cmdlet": "New-PfbFileSystemReplicaLink", - "parameter": "RemoteDefaultExports", - "wireName": "remote_default_exports", - "status": "no-spec-enum-found", - "matchedKey": null, - "specValues": null, - "stableSinceOldestVersion": null, - "recommendation": null - }, { "cmdlet": "New-PfbFileSystemReplicaLink", "parameter": "RemoteFileSystemName", @@ -20517,6 +20507,10 @@ "cmdlet": "New-PfbDataEvictionPolicy", "parameter": "Disabled" }, + { + "cmdlet": "New-PfbFileSystemReplicaLink", + "parameter": "RemoteDefaultExports" + }, { "cmdlet": "New-PfbFileSystemSnapshot", "parameter": "SourceName" diff --git a/Reports/PfbFieldCmdletMapping.md b/Reports/PfbFieldCmdletMapping.md index 86be9c2..e9277cc 100644 --- a/Reports/PfbFieldCmdletMapping.md +++ b/Reports/PfbFieldCmdletMapping.md @@ -9,7 +9,7 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - matched: 1 - collision: 1 - not-found-in-resource: 29 -- no-spec-enum-found: 1981 +- no-spec-enum-found: 1980 | Cmdlet | Parameter | Wire name | Status | Spec values | Recommendation | |---|---|---|---|---|---| @@ -116,7 +116,7 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Update-PfbSmbSharePolicy -Enabled` - `Update-PfbUserGroupQuotaPolicy -Enabled` -## Typed but unresolved wire name (needs manual inspection): 39 +## Typed but unresolved wire name (needs manual inspection): 40 - `Connect-PfbArray -ApiToken` - `Connect-PfbArray -ApiVersion` @@ -139,6 +139,7 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Get-PfbUserGroupQuotaPolicy -Id` - `Get-PfbUserGroupQuotaPolicy -Name` - `New-PfbDataEvictionPolicy -Disabled` +- `New-PfbFileSystemReplicaLink -RemoteDefaultExports` - `New-PfbFileSystemSnapshot -SourceName` - `New-PfbLocalGroupMember -Member` - `New-PfbWorkloadPlacementRecommendation -Inputs` diff --git a/Tests/PfbSpecTools.Tests.ps1 b/Tests/PfbSpecTools.Tests.ps1 index 2d96c90..aafb410 100644 --- a/Tests/PfbSpecTools.Tests.ps1 +++ b/Tests/PfbSpecTools.Tests.ps1 @@ -725,3 +725,188 @@ Describe 'Get-PfbSpecResponseShapes -MaxDepth (regression: the 184-false-removal $shapes[0].ItemProperties | Should -Not -Contain 'deep_field' } } + +Describe 'Get-PfbSpecCapabilities -MaxDepth (regression: issue #71 body-schema allOf truncation)' { + BeforeAll { + # Same 10-level allOf chain as the response-shape regression above, but reached + # through a REQUEST BODY rather than a response. This mirrors the fb2.12-2.16 + # PATCH /password-policies body, whose allOf chain runs deeper than 8 and whose five + # properties therefore recorded introducedVersion 2.17 instead of 2.16. That is + # runtime-visible, not cosmetic: Private/Assert-PfbApiCapability.ps1 reads + # bodyProperties, so it would refuse those five parameters against a 2.16 array. + # The 2.17 spec restructuring flattened these chains, which is why the defect + # self-heals from 2.17 on and stays invisible against current data. + $schemas = [PSCustomObject]@{} + $schemas | Add-Member -NotePropertyName 'Level9' -NotePropertyValue ([PSCustomObject]@{ + properties = [PSCustomObject]@{ deep_field = [PSCustomObject]@{ type = 'string' } } + }) + foreach ($i in 8..0) { + $schemas | Add-Member -NotePropertyName "Level$i" -NotePropertyValue ([PSCustomObject]@{ + allOf = @([PSCustomObject]@{ '$ref' = "#/components/schemas/Level$($i + 1)" }) + }) + } + $script:deepBodySpec = [PSCustomObject]@{ + components = [PSCustomObject]@{ schemas = $schemas } + paths = [PSCustomObject]@{ + '/api/9.9/deep-body' = [PSCustomObject]@{ + patch = [PSCustomObject]@{ + requestBody = [PSCustomObject]@{ + content = [PSCustomObject]@{ + 'application/json' = [PSCustomObject]@{ + schema = [PSCustomObject]@{ + # 'shallow_field' sits inline alongside the deep allOf chain and is + # reachable at any MaxDepth. It is the positive anchor for the + # depth-8 test below -- see that It's own comment for why a negative + # assertion without one proves nothing. + properties = [PSCustomObject]@{ shallow_field = [PSCustomObject]@{ type = 'string' } } + allOf = @([PSCustomObject]@{ '$ref' = '#/components/schemas/Level0' }) + } + } + } + } + } + } + } + } + } + + It 'finds a body property nested deeper than the old default MaxDepth of 8' { + $caps = @(Get-PfbSpecCapabilities -Spec $script:deepBodySpec) + $caps.Count | Should -Be 1 + $caps[0].BodyProperties | Should -Contain 'shallow_field' + $caps[0].BodyProperties | Should -Contain 'deep_field' + } + + It 'also surfaces the deep property in BodyPropertyDetails, not only in BodyProperties' { + # BodyProperties and BodyPropertyDetails come from two SEPARATE helper calls + # (Get-PfbSchemaPropertyNames and Get-PfbSchemaPropertyDetails), each with its own + # MaxDepth default. Asserting only the former would leave a fix that raises depth on + # one call site and not the other looking green, while ReadOnlyBodyProperties and + # DeprecatedBodyProperties -- both projections of Details -- stayed truncated. + $caps = @(Get-PfbSpecCapabilities -Spec $script:deepBodySpec) + ($caps[0].BodyPropertyDetails | ForEach-Object Name) | Should -Contain 'shallow_field' + ($caps[0].BodyPropertyDetails | ForEach-Object Name) | Should -Contain 'deep_field' + } + + It 'would MISS that property at depth 8 -- proving the constraint is real' { + $caps = @(Get-PfbSpecCapabilities -Spec $script:deepBodySpec -MaxDepth 8) + + # POSITIVE ANCHORS FIRST, for the same reason as the response-shape regression above: + # a negative assertion about a collection is satisfied by every degenerate outcome as + # well as the intended one. If the walk returned nothing, $caps[0] would be $null and + # the -Not -Contain below would pass while testing nothing. These anchors fail on that + # mutant, so reaching the negative proves the walk ran, produced this endpoint's + # record, populated BodyProperties, and that the ONLY casualty at depth 8 is the + # property needing more than 8 levels to reach. + $caps.Count | Should -Be 1 + $caps[0].BodyProperties | Should -Contain 'shallow_field' + + $caps[0].BodyProperties | Should -Not -Contain 'deep_field' + } +} + +Describe 'Get-PfbSpecCapabilities array-bodied requests (regression: issue #82)' { + BeforeAll { + # Mirrors POST /nodes/batch on fb2.18+: the request body schema is ITSELF `type: array`, + # with `items` as a sibling keyword rather than a property. The walker's branches cover + # $ref/allOf/properties/required but not `items`, so such a body resolved to a node with + # no properties and no allOf, the accumulator added nothing, and the endpoint recorded + # "bodyProperties": {} -- hiding every field from the runtime gate and the drift report. + # Unlike #71 this does not self-heal on newer specs. + # + # The element schema is allOf-composed exactly like the real NodePost + # (allOf [Node, {node_key}]), because the existing allOf handling has to run AFTER the + # items hop. A fix that descends into items but stops composing allOf would find + # node_key and miss name/id/status; one that composes allOf but never hops items finds + # nothing at all. Asserting both members catches either half being wrong. + $script:arrayBodySpec = [PSCustomObject]@{ + components = [PSCustomObject]@{ + schemas = [PSCustomObject]@{ + BatchNode = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + name = [PSCustomObject]@{ type = 'string' } + id = [PSCustomObject]@{ type = 'string'; readOnly = $true } + status = [PSCustomObject]@{ type = 'string'; readOnly = $true } + } + } + BatchNodePost = [PSCustomObject]@{ + allOf = @( + [PSCustomObject]@{ '$ref' = '#/components/schemas/BatchNode' } + [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ node_key = [PSCustomObject]@{ type = 'string' } } + } + ) + } + BatchEnvelope = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ '$ref' = '#/components/schemas/BatchNodePost' } + } + } + } + paths = [PSCustomObject]@{ + # Inline array body -- the shape all four real endpoints actually use. + '/api/9.9/nodes/batch' = [PSCustomObject]@{ + post = [PSCustomObject]@{ + requestBody = [PSCustomObject]@{ + content = [PSCustomObject]@{ + 'application/json' = [PSCustomObject]@{ + schema = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ '$ref' = '#/components/schemas/BatchNodePost' } + } + } + } + } + } + } + # $ref'd array body -- not used by any endpoint today, but the 2.17 restructuring + # is precedent for this arrangement changing between spec versions. Distinguishes + # a fix that resolves the media schema before testing `type` from one that reads + # `.type` off an unresolved $ref node and finds $null. + '/api/9.9/nodes/batch-ref' = [PSCustomObject]@{ + post = [PSCustomObject]@{ + requestBody = [PSCustomObject]@{ + content = [PSCustomObject]@{ + 'application/json' = [PSCustomObject]@{ + schema = [PSCustomObject]@{ '$ref' = '#/components/schemas/BatchEnvelope' } + } + } + } + } + } + } + } + } + + It 'finds the element schema properties when the request body is itself an array' { + $caps = @(Get-PfbSpecCapabilities -Spec $script:arrayBodySpec) + $batch = $caps | Where-Object Path -eq '/nodes/batch' + $batch.BodyProperties | Should -Contain 'node_key' + $batch.BodyProperties | Should -Contain 'name' + $batch.BodyProperties | Should -Contain 'id' + $batch.BodyProperties | Should -Contain 'status' + } + + It 'resolves ReadOnlyBodyProperties for an array-bodied endpoint' { + # ReadOnlyBodyProperties is a projection of BodyPropertyDetails, which comes from a + # SEPARATE helper call than BodyProperties. Asserting it proves the items hop reached + # both call sites, not just the names one. All four real endpoints currently record + # null here, and NodePost carries obviously read-only members, so this is asserted + # rather than assumed. + $caps = @(Get-PfbSpecCapabilities -Spec $script:arrayBodySpec) + $batch = $caps | Where-Object Path -eq '/nodes/batch' + $batch.ReadOnlyBodyProperties | Should -Contain 'id' + $batch.ReadOnlyBodyProperties | Should -Contain 'status' + $batch.ReadOnlyBodyProperties | Should -Not -Contain 'name' + $batch.ReadOnlyBodyProperties | Should -Not -Contain 'node_key' + } + + It 'resolves a $ref''d array body schema before testing it for items' { + $caps = @(Get-PfbSpecCapabilities -Spec $script:arrayBodySpec) + $batch = $caps | Where-Object Path -eq '/nodes/batch-ref' + $batch.BodyProperties | Should -Contain 'node_key' + $batch.BodyProperties | Should -Contain 'name' + } +} diff --git a/tools/lib/PfbSpecTools.ps1 b/tools/lib/PfbSpecTools.ps1 index 8672400..0cabe33 100644 --- a/tools/lib/PfbSpecTools.ps1 +++ b/tools/lib/PfbSpecTools.ps1 @@ -563,7 +563,16 @@ function Get-PfbSpecCapabilities { [CmdletBinding()] param( [Parameter(Mandatory)] - $Spec + $Spec, + + # 32, not the helpers' own default of 8. The fb2.12-2.16 body schemas compose through + # allOf chains deeper than 8, and reading them at 8 silently drops the properties + # below the cut -- which surfaces as an inflated introducedVersion in the capability + # map (issue #71) and, through Private/Assert-PfbApiCapability.ps1, as a runtime + # refusal of parameters the array actually supports. 32 is not a guess: it is the + # value Get-PfbSpecResponseShapes already defaults to, chosen there by measuring the + # same fb2.12-2.16 truncation (184 false removals at 8 versus 7 true ones at 32). + [int]$MaxDepth = 32 ) $results = [System.Collections.Generic.List[object]]::new() @@ -629,8 +638,33 @@ function Get-PfbSpecCapabilities { $mediaKey = if ($mediaTypes -contains 'application/json') { 'application/json' } else { $mediaTypes | Select-Object -First 1 } if ($mediaKey) { $mediaSchema = $op.requestBody.content.$mediaKey.schema - $bodyPropNames = Get-PfbSchemaPropertyNames -Schema $mediaSchema -Spec $Spec - $bodyPropertyDetails = @(Get-PfbSchemaPropertyDetails -Schema $mediaSchema -Spec $Spec) + + # A request body that is itself `type: array` carries its element schema on + # the `items` SIBLING KEYWORD, not as a property, so the property walk has + # nothing to descend and the endpoint records an empty bodyProperties + # (issue #82). Hop items here, at the call site, rather than teaching + # Add-PfbSchemaPropertyNodes to descend `items` itself: that walker is shared + # with Get-PfbSpecResponseShapes, whose contract is that an envelope's + # properties and its items element's properties are two deliberately-separate + # levels. Teaching the walker to descend unconditionally would collapse them, + # silently changing Data/PfbResponseShapeMap.json and making its + # cross-version removal detection compare incomparable sets. This mirrors the + # hop Get-PfbSpecResponseShapes already performs at its own call site. + # + # Resolve before testing `type`: an unresolved $ref node has no `.type`, so + # reading it directly would silently fall through for a $ref'd array body. + # The non-array path deliberately passes the UNRESOLVED $mediaSchema through + # unchanged, so object bodies behave exactly as before. + $resolvedMedia = Resolve-PfbRef -Node $mediaSchema -Spec $Spec + $bodySchema = if ($null -ne $resolvedMedia -and $resolvedMedia.type -eq 'array' -and $resolvedMedia.items) { + $resolvedMedia.items + } + else { + $mediaSchema + } + + $bodyPropNames = Get-PfbSchemaPropertyNames -Schema $bodySchema -Spec $Spec -MaxDepth $MaxDepth + $bodyPropertyDetails = @(Get-PfbSchemaPropertyDetails -Schema $bodySchema -Spec $Spec -MaxDepth $MaxDepth) } }