From 132178632c1251e0afe14b46435b7b98d135adf1 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 16 Jul 2026 14:59:34 +0200 Subject: [PATCH 1/6] ci: add reusable workflow to sync new capability IDs into SDK compliance files --- .github/workflows/sync-sdk-compliance.yml | 94 ++++++++++++++++ .../sync-compliance/sync_compliance_matrix.py | 106 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 .github/workflows/sync-sdk-compliance.yml create mode 100644 scripts/sync-compliance/sync_compliance_matrix.py diff --git a/.github/workflows/sync-sdk-compliance.yml b/.github/workflows/sync-sdk-compliance.yml new file mode 100644 index 0000000..a4d2c68 --- /dev/null +++ b/.github/workflows/sync-sdk-compliance.yml @@ -0,0 +1,94 @@ +name: Sync SDK Compliance Matrix (reusable) + +on: + workflow_call: + inputs: + compliance-file: + description: Path to sdk-compliance.yaml in the calling repo + type: string + default: sdk-compliance.yaml + base-branch: + description: Branch to open the pull request against + type: string + default: main + sdk-ref: + description: Ref to check out from supabase/sdk for the spec and script + type: string + default: main + secrets: + app-id: + description: GitHub App ID used to open the pull request + required: true + private-key: + description: GitHub App private key used to open the pull request + required: true + +jobs: + sync: + name: Sync new capability IDs + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.private-key }} + + - name: Checkout SDK repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout capability spec + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: supabase/sdk + ref: ${{ inputs.sdk-ref }} + path: _sdk-spec + + - name: Insert new capability IDs + env: + COMPLIANCE_FILE: ${{ inputs.compliance-file }} + run: | + python3 _sdk-spec/scripts/sync-compliance/sync_compliance_matrix.py \ + --compliance-file "$GITHUB_WORKSPACE/$COMPLIANCE_FILE" \ + --capabilities-dir "$GITHUB_WORKSPACE/_sdk-spec/capabilities" + + - name: Create pull request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + COMPLIANCE_FILE: ${{ inputs.compliance-file }} + BASE_BRANCH: ${{ inputs.base-branch }} + run: | + if git diff --quiet -- "$COMPLIANCE_FILE"; then + echo "No new capability IDs; nothing to do." + exit 0 + fi + + branch="chore/sync-compliance-matrix" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + git add "$COMPLIANCE_FILE" + git commit -m "chore: sync new capability IDs from canonical spec" + git push --force-with-lease origin "$branch" + + body="$(printf 'New capability IDs from https://github.com/supabase/sdk were added as `not_implemented`.\n\nTriage each one: set the real status and add symbols/notes before merging.\n\n')" + while IFS= read -r feature_id; do + [ -n "$feature_id" ] && body="${body}- \`${feature_id}\`"$'\n' + done < new_ids.txt + + if [ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" ]; then + echo "Pull request already open for $branch; pushed the update." + else + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$branch" \ + --title "chore: sync new capability IDs from canonical spec" \ + --body "$body" + fi diff --git a/scripts/sync-compliance/sync_compliance_matrix.py b/scripts/sync-compliance/sync_compliance_matrix.py new file mode 100644 index 0000000..05dd272 --- /dev/null +++ b/scripts/sync-compliance/sync_compliance_matrix.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Insert canonical capability IDs that are missing from an SDK compliance file. + +Diffs the feature IDs in the canonical `capabilities/*.yaml` files against the +keys already present in a repository's `sdk-compliance.yaml` and inserts any +missing ones as `not_implemented`. Because canonical IDs and the compliance keys +share the `area.group.feature` shape, each new ID is placed next to its existing +siblings (same `area.group.` prefix) so it lands in the correct section. An ID +whose group has no local sibling yet is appended under a comment for manual +placement. The list of added IDs is written to the new-ids output file for the +calling workflow's pull request body. +""" +import argparse +import re +import sys +from pathlib import Path + +FEATURE_KEY = re.compile(r"^ ([a-z0-9_]+(?:\.[a-z0-9_]+)+):") +CANONICAL_ID = re.compile(r"^\s*-\s*id:\s*([a-z0-9_]+(?:\.[a-z0-9_]+)+)") + + +def read_canonical_ids(capabilities_directory): + identifiers = set() + for path in sorted(capabilities_directory.glob("*.yaml")): + for line in path.read_text().splitlines(): + found = CANONICAL_ID.match(line) + if found: + identifiers.add(found.group(1)) + return identifiers + + +def feature_key_at(lines, index): + found = FEATURE_KEY.match(lines[index]) + return found.group(1) if found else None + + +def read_existing_ids(lines): + return {key for index in range(len(lines)) if (key := feature_key_at(lines, index))} + + +def block_end(lines, start): + end = start + 1 + while end < len(lines) and lines[end].startswith(" "): + end += 1 + return end + + +def insertion_index(lines, prefix): + last = None + for index in range(len(lines)): + key = feature_key_at(lines, index) + if key and key.startswith(prefix): + last = index + if last is None: + return None + return block_end(lines, last) + + +def parse_arguments(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--compliance-file", required=True, type=Path) + parser.add_argument("--capabilities-dir", required=True, type=Path) + parser.add_argument("--new-ids-output", default=Path("new_ids.txt"), type=Path) + return parser.parse_args() + + +def main(): + arguments = parse_arguments() + if not arguments.capabilities_dir.is_dir(): + sys.exit(f"Canonical capabilities not found at {arguments.capabilities_dir}") + if not arguments.compliance_file.is_file(): + sys.exit(f"Compliance file not found at {arguments.compliance_file}") + + lines = arguments.compliance_file.read_text().splitlines() + canonical = read_canonical_ids(arguments.capabilities_dir) + new_identifiers = sorted(canonical - read_existing_ids(lines)) + arguments.new_ids_output.write_text("\n".join(new_identifiers)) + if not new_identifiers: + print("No new capability IDs.") + return + + orphans = [] + for identifier in new_identifiers: + prefix = identifier.rsplit(".", 1)[0] + "." + index = insertion_index(lines, prefix) + if index is None: + orphans.append(identifier) + continue + lines.insert(index, f" {identifier}: not_implemented") + + if orphans: + lines.append("") + lines.append( + " # Newly synced from the canonical spec; no local group yet, place manually." + ) + lines += [f" {identifier}: not_implemented" for identifier in orphans] + + arguments.compliance_file.write_text("\n".join(lines) + "\n") + print( + f"Added {len(new_identifiers)} new capability IDs " + f"({len(orphans)} without an existing group)." + ) + + +if __name__ == "__main__": + main() From 889650aaa9c02c12b0b2188fa9d789a5a349d84e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 16 Jul 2026 15:06:57 +0200 Subject: [PATCH 2/6] ci: accept client-id and make app-id optional for the sync workflow --- .github/workflows/sync-sdk-compliance.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-sdk-compliance.yml b/.github/workflows/sync-sdk-compliance.yml index a4d2c68..9cf1ce2 100644 --- a/.github/workflows/sync-sdk-compliance.yml +++ b/.github/workflows/sync-sdk-compliance.yml @@ -17,8 +17,11 @@ on: default: main secrets: app-id: - description: GitHub App ID used to open the pull request - required: true + description: GitHub App ID used to open the pull request (provide this or client-id) + required: false + client-id: + description: GitHub App client ID used to open the pull request (provide this or app-id) + required: false private-key: description: GitHub App private key used to open the pull request required: true @@ -34,9 +37,10 @@ jobs: steps: - name: Generate token id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.app-id }} + client-id: ${{ secrets.client-id }} private-key: ${{ secrets.private-key }} - name: Checkout SDK repo From cc8197e863f1e72b8df179d2ebe8ac8e994d19b2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 16 Jul 2026 15:34:17 +0200 Subject: [PATCH 3/6] ci: port compliance sync script to TypeScript to match matrix tooling --- .github/workflows/sync-sdk-compliance.yml | 16 ++- scripts/capability-matrix/package.json | 1 + .../src/sync-compliance-cli.ts | 125 ++++++++++++++++++ .../sync-compliance/sync_compliance_matrix.py | 106 --------------- 4 files changed, 139 insertions(+), 109 deletions(-) create mode 100644 scripts/capability-matrix/src/sync-compliance-cli.ts delete mode 100644 scripts/sync-compliance/sync_compliance_matrix.py diff --git a/.github/workflows/sync-sdk-compliance.yml b/.github/workflows/sync-sdk-compliance.yml index 9cf1ce2..971d0db 100644 --- a/.github/workflows/sync-sdk-compliance.yml +++ b/.github/workflows/sync-sdk-compliance.yml @@ -55,13 +55,23 @@ jobs: ref: ${{ inputs.sdk-ref }} path: _sdk-spec + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install capability matrix tooling + run: npm ci + working-directory: _sdk-spec/scripts/capability-matrix + - name: Insert new capability IDs + working-directory: _sdk-spec/scripts/capability-matrix env: COMPLIANCE_FILE: ${{ inputs.compliance-file }} run: | - python3 _sdk-spec/scripts/sync-compliance/sync_compliance_matrix.py \ - --compliance-file "$GITHUB_WORKSPACE/$COMPLIANCE_FILE" \ - --capabilities-dir "$GITHUB_WORKSPACE/_sdk-spec/capabilities" + npm run sync-compliance -- \ + "$GITHUB_WORKSPACE/$COMPLIANCE_FILE" \ + --new-ids-output "$GITHUB_WORKSPACE/new_ids.txt" - name: Create pull request env: diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index 8248e5a..9649664 100644 --- a/scripts/capability-matrix/package.json +++ b/scripts/capability-matrix/package.json @@ -8,6 +8,7 @@ "validate": "tsx src/cli.ts validate", "validate:online": "tsx src/cli.ts validate --online", "validate-compliance": "tsx src/compliance-cli.ts", + "sync-compliance": "tsx src/sync-compliance-cli.ts", "normalize-typedoc": "tsx src/normalize-typedoc-cli.ts", "normalize-symbolgraph": "tsx src/normalize-symbolgraph-cli.ts", "normalize-griffe": "tsx src/normalize-griffe-cli.ts", diff --git a/scripts/capability-matrix/src/sync-compliance-cli.ts b/scripts/capability-matrix/src/sync-compliance-cli.ts new file mode 100644 index 0000000..0788cc3 --- /dev/null +++ b/scripts/capability-matrix/src/sync-compliance-cli.ts @@ -0,0 +1,125 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { parse } from "yaml"; +import { loadAreas } from "./load.js"; +import { collectFeatureIds, findMissingFeatureIds } from "./compliance.js"; +import type { RawCompliance } from "./compliance.js"; +import { buildSourceMap } from "./compliance-source-map.js"; + +// Inserts canonical capability IDs that are missing from an SDK's +// sdk-compliance.yaml as `not_implemented`. Canonical IDs and compliance keys +// share the `area.group.feature` shape, so each new ID is placed next to its +// existing siblings (same `area.group.` prefix); an ID whose group has no local +// sibling yet is appended under a comment for manual placement. Editing the raw +// text (rather than re-serializing the parsed document) keeps the file's +// comments and formatting intact. + +function repoRoot(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", ".."); +} + +function parseArguments(argv: string[]): { compliancePath: string; newIdsOutput: string } { + let compliancePath: string | undefined; + let newIdsOutput = "new_ids.txt"; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--new-ids-output") { + newIdsOutput = argv[++index]; + } else if (!argument.startsWith("--") && compliancePath === undefined) { + compliancePath = argument; + } + } + if (!compliancePath) { + console.error( + "Usage: sync-compliance-cli.ts [--new-ids-output ]", + ); + process.exit(1); + } + return { compliancePath, newIdsOutput }; +} + +function blockEnd(lines: string[], start: number): number { + let end = start + 1; + while (end < lines.length && lines[end].startsWith(" ")) end++; + return end; +} + +async function main(): Promise { + const { compliancePath, newIdsOutput } = parseArguments(process.argv.slice(2)); + + const { areas, findings } = loadAreas(join(repoRoot(), "capabilities")); + if (findings.some((finding) => finding.level === "error")) { + console.error("Failed to load canonical capability spec — check this repo's capabilities/*.yaml"); + process.exit(1); + } + const knownIds = collectFeatureIds(areas); + + const text = readFileSync(resolve(compliancePath), "utf8"); + let raw: RawCompliance; + try { + raw = parse(text) as RawCompliance; + } catch (error) { + console.error(`YAML parse error: ${(error as Error).message}`); + process.exit(1); + } + + const missing = findMissingFeatureIds(raw, knownIds); + writeFileSync(resolve(newIdsOutput), missing.length ? missing.join("\n") + "\n" : ""); + if (missing.length === 0) { + console.log("No new capability IDs."); + return; + } + + const { featureLines } = buildSourceMap(text); + const existing = [...featureLines.entries()]; + + const hadTrailingNewline = text.endsWith("\n"); + const lines = text.split("\n"); + if (hadTrailingNewline) lines.pop(); + + const byPrefix = new Map(); + for (const id of missing) { + const prefix = id.slice(0, id.lastIndexOf(".") + 1); + const bucket = byPrefix.get(prefix); + if (bucket) bucket.push(id); + else byPrefix.set(prefix, [id]); + } + + const insertions: { index: number; newLines: string[] }[] = []; + const orphans: string[] = []; + for (const [prefix, ids] of byPrefix) { + const siblingLines = existing + .filter(([existingId]) => existingId.startsWith(prefix)) + .map(([, line]) => line); + if (siblingLines.length === 0) { + orphans.push(...ids); + continue; + } + const lastKeyIndex = Math.max(...siblingLines) - 1; + insertions.push({ + index: blockEnd(lines, lastKeyIndex), + newLines: ids.map((id) => ` ${id}: not_implemented`), + }); + } + + for (const { index, newLines } of insertions.sort((a, b) => b.index - a.index)) { + lines.splice(index, 0, ...newLines); + } + + if (orphans.length > 0) { + lines.push("", " # Newly synced from the canonical spec; no local group yet, place manually."); + for (const id of orphans) lines.push(` ${id}: not_implemented`); + } + + writeFileSync(resolve(compliancePath), lines.join("\n") + "\n"); + console.log( + `Added ${missing.length} new capability IDs (${orphans.length} without an existing group).`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/sync-compliance/sync_compliance_matrix.py b/scripts/sync-compliance/sync_compliance_matrix.py deleted file mode 100644 index 05dd272..0000000 --- a/scripts/sync-compliance/sync_compliance_matrix.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Insert canonical capability IDs that are missing from an SDK compliance file. - -Diffs the feature IDs in the canonical `capabilities/*.yaml` files against the -keys already present in a repository's `sdk-compliance.yaml` and inserts any -missing ones as `not_implemented`. Because canonical IDs and the compliance keys -share the `area.group.feature` shape, each new ID is placed next to its existing -siblings (same `area.group.` prefix) so it lands in the correct section. An ID -whose group has no local sibling yet is appended under a comment for manual -placement. The list of added IDs is written to the new-ids output file for the -calling workflow's pull request body. -""" -import argparse -import re -import sys -from pathlib import Path - -FEATURE_KEY = re.compile(r"^ ([a-z0-9_]+(?:\.[a-z0-9_]+)+):") -CANONICAL_ID = re.compile(r"^\s*-\s*id:\s*([a-z0-9_]+(?:\.[a-z0-9_]+)+)") - - -def read_canonical_ids(capabilities_directory): - identifiers = set() - for path in sorted(capabilities_directory.glob("*.yaml")): - for line in path.read_text().splitlines(): - found = CANONICAL_ID.match(line) - if found: - identifiers.add(found.group(1)) - return identifiers - - -def feature_key_at(lines, index): - found = FEATURE_KEY.match(lines[index]) - return found.group(1) if found else None - - -def read_existing_ids(lines): - return {key for index in range(len(lines)) if (key := feature_key_at(lines, index))} - - -def block_end(lines, start): - end = start + 1 - while end < len(lines) and lines[end].startswith(" "): - end += 1 - return end - - -def insertion_index(lines, prefix): - last = None - for index in range(len(lines)): - key = feature_key_at(lines, index) - if key and key.startswith(prefix): - last = index - if last is None: - return None - return block_end(lines, last) - - -def parse_arguments(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--compliance-file", required=True, type=Path) - parser.add_argument("--capabilities-dir", required=True, type=Path) - parser.add_argument("--new-ids-output", default=Path("new_ids.txt"), type=Path) - return parser.parse_args() - - -def main(): - arguments = parse_arguments() - if not arguments.capabilities_dir.is_dir(): - sys.exit(f"Canonical capabilities not found at {arguments.capabilities_dir}") - if not arguments.compliance_file.is_file(): - sys.exit(f"Compliance file not found at {arguments.compliance_file}") - - lines = arguments.compliance_file.read_text().splitlines() - canonical = read_canonical_ids(arguments.capabilities_dir) - new_identifiers = sorted(canonical - read_existing_ids(lines)) - arguments.new_ids_output.write_text("\n".join(new_identifiers)) - if not new_identifiers: - print("No new capability IDs.") - return - - orphans = [] - for identifier in new_identifiers: - prefix = identifier.rsplit(".", 1)[0] + "." - index = insertion_index(lines, prefix) - if index is None: - orphans.append(identifier) - continue - lines.insert(index, f" {identifier}: not_implemented") - - if orphans: - lines.append("") - lines.append( - " # Newly synced from the canonical spec; no local group yet, place manually." - ) - lines += [f" {identifier}: not_implemented" for identifier in orphans] - - arguments.compliance_file.write_text("\n".join(lines) + "\n") - print( - f"Added {len(new_identifiers)} new capability IDs " - f"({len(orphans)} without an existing group)." - ) - - -if __name__ == "__main__": - main() From 57da07a4bc8b781b3fa11bb7fd1db3cacb5b7556 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 16 Jul 2026 15:49:31 +0200 Subject: [PATCH 4/6] =?UTF-8?q?ci:=20harden=20sync=20workflow=20=E2=80=94?= =?UTF-8?q?=20late=20token,=20no=20persisted=20creds,=20base-branch=20chec?= =?UTF-8?q?kout,=20fetch=20before=20force-push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sync-sdk-compliance.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sync-sdk-compliance.yml b/.github/workflows/sync-sdk-compliance.yml index 971d0db..b7c7959 100644 --- a/.github/workflows/sync-sdk-compliance.yml +++ b/.github/workflows/sync-sdk-compliance.yml @@ -35,18 +35,11 @@ jobs: contents: write pull-requests: write steps: - - name: Generate token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ secrets.app-id }} - client-id: ${{ secrets.client-id }} - private-key: ${{ secrets.private-key }} - - name: Checkout SDK repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - token: ${{ steps.app-token.outputs.token }} + ref: ${{ inputs.base-branch }} + persist-credentials: false - name: Checkout capability spec uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -54,6 +47,7 @@ jobs: repository: supabase/sdk ref: ${{ inputs.sdk-ref }} path: _sdk-spec + persist-credentials: false - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -73,6 +67,14 @@ jobs: "$GITHUB_WORKSPACE/$COMPLIANCE_FILE" \ --new-ids-output "$GITHUB_WORKSPACE/new_ids.txt" + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.app-id }} + client-id: ${{ secrets.client-id }} + private-key: ${{ secrets.private-key }} + - name: Create pull request env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -87,6 +89,8 @@ jobs: branch="chore/sync-compliance-matrix" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git fetch origin "$branch:refs/remotes/origin/$branch" || true git checkout -b "$branch" git add "$COMPLIANCE_FILE" git commit -m "chore: sync new capability IDs from canonical spec" From a6b4cba2da933e43e89d12daa34f6725f8e61d5d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 16 Jul 2026 15:56:45 +0200 Subject: [PATCH 5/6] refactor: derive sync insertion points from the YAML syntax tree; drop async main --- .../src/sync-compliance-cli.ts | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/scripts/capability-matrix/src/sync-compliance-cli.ts b/scripts/capability-matrix/src/sync-compliance-cli.ts index 0788cc3..d2dd1f6 100644 --- a/scripts/capability-matrix/src/sync-compliance-cli.ts +++ b/scripts/capability-matrix/src/sync-compliance-cli.ts @@ -1,19 +1,21 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { parse } from "yaml"; +import { parse, parseDocument, LineCounter, isMap, isNode, isScalar } from "yaml"; import { loadAreas } from "./load.js"; import { collectFeatureIds, findMissingFeatureIds } from "./compliance.js"; import type { RawCompliance } from "./compliance.js"; -import { buildSourceMap } from "./compliance-source-map.js"; // Inserts canonical capability IDs that are missing from an SDK's // sdk-compliance.yaml as `not_implemented`. Canonical IDs and compliance keys // share the `area.group.feature` shape, so each new ID is placed next to its // existing siblings (same `area.group.` prefix); an ID whose group has no local -// sibling yet is appended under a comment for manual placement. Editing the raw -// text (rather than re-serializing the parsed document) keeps the file's -// comments and formatting intact. +// sibling yet is appended under a comment for manual placement. +// +// New entries are spliced into the raw text rather than re-serializing the +// parsed document, because stringifying reflows hand-wrapped `note:` scalars and +// so churns unrelated, human-curated lines. The syntax tree is still used (not +// indentation heuristics) to find exactly where each entry ends. function repoRoot(): string { const here = dirname(fileURLToPath(import.meta.url)); @@ -40,13 +42,25 @@ function parseArguments(argv: string[]): { compliancePath: string; newIdsOutput: return { compliancePath, newIdsOutput }; } -function blockEnd(lines: string[], start: number): number { - let end = start + 1; - while (end < lines.length && lines[end].startsWith(" ")) end++; - return end; +// The 1-based line each declared feature entry ends on, read from the YAML +// syntax tree so multi-line entries (notes, symbol lists) are handled exactly. +function featureEndLines(text: string): Map { + const lineCounter = new LineCounter(); + const features = parseDocument(text, { lineCounter }).get("features", true); + const endLines = new Map(); + if (!isMap(features)) return endLines; + for (const pair of features.items) { + const key = pair.key; + if (!isScalar(key) || typeof key.value !== "string") continue; + const rangeNode = isNode(pair.value) ? pair.value : key; + if (rangeNode.range) { + endLines.set(key.value, lineCounter.linePos(rangeNode.range[1] - 1).line); + } + } + return endLines; } -async function main(): Promise { +function main(): void { const { compliancePath, newIdsOutput } = parseArguments(process.argv.slice(2)); const { areas, findings } = loadAreas(join(repoRoot(), "capabilities")); @@ -72,8 +86,7 @@ async function main(): Promise { return; } - const { featureLines } = buildSourceMap(text); - const existing = [...featureLines.entries()]; + const endLines = featureEndLines(text); const hadTrailingNewline = text.endsWith("\n"); const lines = text.split("\n"); @@ -90,16 +103,15 @@ async function main(): Promise { const insertions: { index: number; newLines: string[] }[] = []; const orphans: string[] = []; for (const [prefix, ids] of byPrefix) { - const siblingLines = existing + const siblingEndLines = [...endLines.entries()] .filter(([existingId]) => existingId.startsWith(prefix)) .map(([, line]) => line); - if (siblingLines.length === 0) { + if (siblingEndLines.length === 0) { orphans.push(...ids); continue; } - const lastKeyIndex = Math.max(...siblingLines) - 1; insertions.push({ - index: blockEnd(lines, lastKeyIndex), + index: Math.max(...siblingEndLines), newLines: ids.map((id) => ` ${id}: not_implemented`), }); } @@ -119,7 +131,4 @@ async function main(): Promise { ); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +main(); From 18b66487dd524e126a8bec9c2d276837e1160120 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 17:07:43 +0200 Subject: [PATCH 6/6] refactor(sync): insert IDs via the yaml writer instead of raw text splicing --- .../src/sync-compliance-cli.ts | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/scripts/capability-matrix/src/sync-compliance-cli.ts b/scripts/capability-matrix/src/sync-compliance-cli.ts index d2dd1f6..6d9212d 100644 --- a/scripts/capability-matrix/src/sync-compliance-cli.ts +++ b/scripts/capability-matrix/src/sync-compliance-cli.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { parse, parseDocument, LineCounter, isMap, isNode, isScalar } from "yaml"; +import { parseDocument, isMap, isScalar } from "yaml"; import { loadAreas } from "./load.js"; import { collectFeatureIds, findMissingFeatureIds } from "./compliance.js"; import type { RawCompliance } from "./compliance.js"; @@ -12,17 +12,20 @@ import type { RawCompliance } from "./compliance.js"; // existing siblings (same `area.group.` prefix); an ID whose group has no local // sibling yet is appended under a comment for manual placement. // -// New entries are spliced into the raw text rather than re-serializing the -// parsed document, because stringifying reflows hand-wrapped `note:` scalars and -// so churns unrelated, human-curated lines. The syntax tree is still used (not -// indentation heuristics) to find exactly where each entry ends. +// Entries are added to the parsed YAML document and re-serialized with the yaml +// writer, so the output is always valid YAML regardless of the source layout. +// Re-serializing may collapse hand-wrapped `note:` scalars onto a single line. +// That is fine; the only concern here is keeping the file valid. function repoRoot(): string { const here = dirname(fileURLToPath(import.meta.url)); return resolve(here, "..", "..", ".."); } -function parseArguments(argv: string[]): { compliancePath: string; newIdsOutput: string } { +function parseArguments(argv: string[]): { + compliancePath: string; + newIdsOutput: string; +} { let compliancePath: string | undefined; let newIdsOutput = "new_ids.txt"; for (let index = 0; index < argv.length; index++) { @@ -42,55 +45,50 @@ function parseArguments(argv: string[]): { compliancePath: string; newIdsOutput: return { compliancePath, newIdsOutput }; } -// The 1-based line each declared feature entry ends on, read from the YAML -// syntax tree so multi-line entries (notes, symbol lists) are handled exactly. -function featureEndLines(text: string): Map { - const lineCounter = new LineCounter(); - const features = parseDocument(text, { lineCounter }).get("features", true); - const endLines = new Map(); - if (!isMap(features)) return endLines; - for (const pair of features.items) { - const key = pair.key; - if (!isScalar(key) || typeof key.value !== "string") continue; - const rangeNode = isNode(pair.value) ? pair.value : key; - if (rangeNode.range) { - endLines.set(key.value, lineCounter.linePos(rangeNode.range[1] - 1).line); - } - } - return endLines; -} - function main(): void { - const { compliancePath, newIdsOutput } = parseArguments(process.argv.slice(2)); + const { compliancePath, newIdsOutput } = parseArguments( + process.argv.slice(2), + ); const { areas, findings } = loadAreas(join(repoRoot(), "capabilities")); if (findings.some((finding) => finding.level === "error")) { - console.error("Failed to load canonical capability spec — check this repo's capabilities/*.yaml"); + console.error( + "Failed to load canonical capability spec — check this repo's capabilities/*.yaml", + ); process.exit(1); } const knownIds = collectFeatureIds(areas); const text = readFileSync(resolve(compliancePath), "utf8"); - let raw: RawCompliance; - try { - raw = parse(text) as RawCompliance; - } catch (error) { - console.error(`YAML parse error: ${(error as Error).message}`); + const doc = parseDocument(text); + if (doc.errors.length > 0) { + console.error(`YAML parse error: ${doc.errors[0].message}`); process.exit(1); } + const raw = doc.toJS() as RawCompliance; const missing = findMissingFeatureIds(raw, knownIds); - writeFileSync(resolve(newIdsOutput), missing.length ? missing.join("\n") + "\n" : ""); + writeFileSync( + resolve(newIdsOutput), + missing.length ? missing.join("\n") + "\n" : "", + ); if (missing.length === 0) { console.log("No new capability IDs."); return; } - const endLines = featureEndLines(text); + const features = doc.get("features", true); + if (!isMap(features)) { + console.error("Compliance file has no `features` map to sync into."); + process.exit(1); + } - const hadTrailingNewline = text.endsWith("\n"); - const lines = text.split("\n"); - if (hadTrailingNewline) lines.pop(); + const idIndex = new Map(); + features.items.forEach((pair, index) => { + if (isScalar(pair.key) && typeof pair.key.value === "string") { + idIndex.set(pair.key.value, index); + } + }); const byPrefix = new Map(); for (const id of missing) { @@ -100,32 +98,34 @@ function main(): void { else byPrefix.set(prefix, [id]); } - const insertions: { index: number; newLines: string[] }[] = []; + const insertions: { index: number; ids: string[] }[] = []; const orphans: string[] = []; for (const [prefix, ids] of byPrefix) { - const siblingEndLines = [...endLines.entries()] + const siblingIndices = [...idIndex.entries()] .filter(([existingId]) => existingId.startsWith(prefix)) - .map(([, line]) => line); - if (siblingEndLines.length === 0) { + .map(([, index]) => index); + if (siblingIndices.length === 0) { orphans.push(...ids); continue; } - insertions.push({ - index: Math.max(...siblingEndLines), - newLines: ids.map((id) => ` ${id}: not_implemented`), - }); + // Insert right after the last existing sibling so the new IDs land in-group. + insertions.push({ index: Math.max(...siblingIndices) + 1, ids }); } - for (const { index, newLines } of insertions.sort((a, b) => b.index - a.index)) { - lines.splice(index, 0, ...newLines); + // Splice back-to-front so earlier indices stay valid as items shift. + for (const { index, ids } of insertions.sort((a, b) => b.index - a.index)) { + const pairs = ids.map((id) => doc.createPair(id, "not_implemented")); + features.items.splice(index, 0, ...pairs); } if (orphans.length > 0) { - lines.push("", " # Newly synced from the canonical spec; no local group yet, place manually."); - for (const id of orphans) lines.push(` ${id}: not_implemented`); + const pairs = orphans.map((id) => doc.createPair(id, "not_implemented")); + (pairs[0].key as { commentBefore?: string }).commentBefore = + " Newly synced from the canonical spec; no local group yet, place manually."; + features.items.push(...pairs); } - writeFileSync(resolve(compliancePath), lines.join("\n") + "\n"); + writeFileSync(resolve(compliancePath), doc.toString({ lineWidth: 0 })); console.log( `Added ${missing.length} new capability IDs (${orphans.length} without an existing group).`, );