From d6d24dd423e92464f0f8508386c70247aab13c2a Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 4 Feb 2026 15:52:40 -0800 Subject: [PATCH 1/3] docs: add compile command and lifecycle overview to README; clarify contract-baseline Co-authored-by: Cursor --- README.md | 71 ++++++++++++++++++++++++++++++++++++++- docs/contract-baseline.md | 24 ++++++------- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e219c50..0f96488 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ For detailed command documentation, see [API.md](API.md). ## Commands Overview -The CLI provides three main commands: +The CLI provides four main commands: ### `validate` @@ -69,10 +69,79 @@ Enforces policy on interface contracts using configurable enforcement modes: `fa interfacectl enforce [options] ``` +### `compile` + +Compiles a validated interface contract into a deterministic, runtime-readable bundle. The bundle includes a manifest, a normalized contract, per-surface files, and per-constraint files for downstream tools to consume. + +This command does **not** perform enforcement or runtime gating. It produces a stable artifact intended for inspection, tooling, or future runtime consumption. + +```bash +interfacectl compile --contract --out +``` + For complete command documentation with all options, exit codes, and output formats, see [API.md](API.md). **Generation-time gating:** `interfacectl validate` is the canonical command for contract compliance. Use it to gate changes before merge or deployment. For local use and CI, run validate with your contract path and, for deterministic exit codes, use `--exit-codes v2`. The command `enforce --mode fail` is optional; it runs a structural diff then applies a policy threshold and is useful when you want to block on diff severity separately from compliance. +## Lifecycle overview + +interfacectl separates interface governance into clear phases: + +- **Validate** + Enforces contract compliance at generation time. + +- **Diff / Enforce** + Detects and classifies drift with traceability (stableId, contractRef, ruleRef). + +- **Compile** + Produces a deterministic, runtime-readable bundle (manifest, normalized contract, surfaces, constraints) that serves as the handoff point to other tools. + +- **Runtime consumption (framing only)** + Consumption semantics are documented in Phase 4. No runtime enforcement or loaders exist in this repo. + +See: +- `docs/plans/phase-3-compile-directory-bundle.md` +- `docs/plans/phase-4-runtime-consumption-framing.md` + +### Lifecycle diagram + +``` +Design / Generation time +──────────────────────── + + Contract + │ + ▼ + validate + │ + ▼ + diff / enforce + │ + ▼ + compile + │ + ▼ + ┌─────────────────────────────┐ + │ Deterministic bundle │ + │ - manifest.json │ + │ - contract.normalized.json │ + │ - surfaces/*.json │ + │ - constraints/*.json │ + └─────────────────────────────┘ + +Runtime (not implemented here) +────────────────────────────── + + bundle + │ + ▼ + [consumer] + │ + ├─ read / reference + ├─ observe / compare + └─ explain (future) +``` + ## Usage Examples ### Validation diff --git a/docs/contract-baseline.md b/docs/contract-baseline.md index c9c8b00..ac5c209 100644 --- a/docs/contract-baseline.md +++ b/docs/contract-baseline.md @@ -1,16 +1,16 @@ # Contract baseline -This document describes the minimal authoritative interface contract used by interfacectl. It lets you understand what the contract requires without reading the JSON schema file alone. For the exact schema, see the schema location below. +This document defines the minimal authoritative interface contract enforced by interfacectl. It exists so builders can understand what a valid contract must declare without reading the JSON schema file directly. This document describes intent and requirements. The schema remains the source of truth. ## Required top-level fields Every contract must include: - **contractId** (string, non-empty): Identifier for the contract. Used for traceability. -- **version** (string): Semantic version. Must match the pattern `X.Y.Z` (e.g. `1.0.0`). +- **version** (string): Semantic version of the contract. Must match the pattern `X.Y.Z` (for example, `1.0.0`). Used to track compatibility over time. - **surfaces** (array, at least one item): List of surfaces (apps or UIs) that the contract governs. Each surface has its own required and allowed values. - **sections** (array, at least one item): List of sections that surfaces can implement. Each section has an id, intent, and description. -- **constraints** (object): Global constraints. Today the only required part is **motion**: an object with **allowedDurationsMs** (array of integers, milliseconds) and **allowedTimingFunctions** (array of strings). +- **constraints** (object): Global constraints. At present, the only required constraint is **motion**, defined as an object with **allowedDurationsMs** (array of integers, milliseconds) and **allowedTimingFunctions** (array of strings). Additional constraint categories may be added in future schema versions. ## Per-surface required fields @@ -20,15 +20,15 @@ Each entry in **surfaces** must have: - **displayName** (string, non-empty): Human-readable name. - **type** (string): Either `"web"` or `"cli"`. - **requiredSections** (array): Section ids that this surface must implement. Section id pattern: `a-z0-9` and dots (e.g. `main.hero`). -- **allowedFonts** (array, at least one string): Font family values that the surface may use. Implementations are checked against this list. -- **layout** (object): Must include **maxContentWidth** (number, minimum 1). Optionally **requiredContainers** (array of strings) and **pageFrame** (containerSelector, containerMaxWidthPx, paddingXpx, and optional alignment and enforcement). +- **allowedFonts** (array, at least one string): Font family values that the surface may use. Implementations are checked against this list. This list is enforced during compliance evaluation. +- **layout** (object): Must include **maxContentWidth** (number, minimum 1). Optional fields include **requiredContainers** (array of strings) and **pageFrame**. When present, **pageFrame** is enforced according to the contract definition. ## Per-section required fields Each entry in **sections** must have: - **id** (string): Matches the pattern used in surface requiredSections. -- **intent** (string, non-empty): Purpose of the section. Not yet used as an enforcement hook; documented for clarity. +- **intent** (string, non-empty): Purpose of the section. Documented for clarity. Not currently used as an enforcement input. - **description** (string, non-empty): Human-readable description. ## Optional: color policy @@ -36,17 +36,17 @@ Each entry in **sections** must have: The top-level **color** object is optional. When present it can include: - **sourceOfTruth**: If `type` is `"tokens"`, you must provide **tokenNamespaces** (array of strings). Used to allow or disallow CSS variable namespaces for colors. -- **rawValues**: **policy** is required (`"off"`, `"warn"`, or `"strict"`). Optional **allowlist** and **denylist** arrays of color values. Controls whether raw color literals (hex, rgb, hsl) are allowed. +- **rawValues**: **policy** is required (`"off"`, `"warn"`, or `"strict"`). Optional **allowlist** and **denylist** arrays of color values. Controls whether raw color literals (hex, rgb, hsl) are permitted. - **semantics**: Optional **roles** (accent, text, background, border) each with **enforcement** (`"off"`, `"warn"`, `"strict"`). -- **consistency**: Optional **acrossSurfaces** with **enforcement** and **signals**. +- **consistency**: Optional **acrossSurfaces** object with **enforcement** level and **signals** used for reporting. ## Deprecated fields -- **allowedColors** (per-surface): Deprecated. The schema still accepts it for compatibility. Prefer the top-level **color** policy with **sourceOfTruth** and **rawValues**. Migration: move per-surface color allowlists into **color.sourceOfTruth** (e.g. token namespaces) or **color.rawValues** (allowlist/denylist). The CLI emits a deprecation warning when allowedColors is present. +- **allowedColors** (per-surface): Deprecated. The schema still accepts it for compatibility. Prefer the top-level **color** policy with **sourceOfTruth** and **rawValues**. Migration: move per-surface color allowlists into **color.sourceOfTruth** (e.g. token namespaces) or **color.rawValues** (allowlist/denylist). The CLI emits a deprecation warning when allowedColors is present. Deprecated fields are accepted for compatibility but may be removed in a future major version. ## Where contract semantics live in the repo -- **Schema file:** `packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json`. This is the authoritative JSON Schema. The CLI uses a bundled copy; you can override with `--schema `. +- **Schema file:** `packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json`. This is the authoritative JSON Schema. The CLI uses a bundled copy of this schema by default. You may override it with `--schema ` if needed. - **Structure validation:** The function **validateContractStructure** in `packages/interfacectl-validator/src/index.ts` loads the schema and validates contract JSON (via AJV). It returns errors or the parsed contract. -- **Compliance evaluation:** The function **evaluateContractCompliance** in the same file takes a validated contract and a list of surface descriptors. It returns a validation summary with per-surface reports and violations. Compliance rules (fonts, colors, layout, motion, sections, pageFrame) are implemented in **evaluateSurfaceCompliance** in that file. -- **CLI entry point:** The validate command is implemented in `packages/interfacectl-cli/src/commands/validate.ts`. It calls **collectSurfaceDescriptors** (in `packages/interfacectl-cli/src/descriptors/static-analysis.ts`) to gather descriptors from the codebase, then passes the contract and descriptors to **evaluateContractCompliance**. The CLI maps violation types to stable codes (e.g. `font.disallowed`) in that command file. +- **Compliance evaluation:** The function **evaluateContractCompliance** in the same file takes a validated contract and a list of surface descriptors. It returns a validation summary with per-surface reports and violations. Compliance rules for fonts, colors, layout, motion, sections, and pageFrame are implemented in **evaluateSurfaceCompliance** in the same file. +- **CLI entry point:** The validate command is implemented in `packages/interfacectl-cli/src/commands/validate.ts`. It calls **collectSurfaceDescriptors** (in `packages/interfacectl-cli/src/descriptors/static-analysis.ts`) to gather descriptors from the codebase, then passes the contract and descriptors to **evaluateContractCompliance**. The CLI maps violation types to stable codes in this command implementation. From 2e2c9b4a6f30fb8f7e2c164481fcf5aee6f92f50 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 4 Feb 2026 16:50:12 -0800 Subject: [PATCH 2/3] feat: Phase 0 contract extraction from Next.js app code - Add packages/interfacectl-extractor: extractContractFromNextApp() for routes, hasShell, designSystemComponents, authAware. Deterministic output, no network. - Add interfacectl generate-contract command with --app-root, --surface, --out, --report-out. Validates generated contract against schema before writing. - Add optional x_extracted to contract schema (no validation semantics change). - Update README and API.md with generate-contract and Phase 0 scope. Extraction only; no enforcement. Co-authored-by: Cursor --- API.md | 32 ++++ README.md | 21 ++- package.json | 1 + .../dist/commands/generate-contract.d.ts | 9 ++ .../dist/commands/generate-contract.d.ts.map | 1 + .../dist/commands/generate-contract.js | 55 +++++++ packages/interfacectl-cli/dist/index.js | 19 +++ packages/interfacectl-cli/package.json | 3 +- .../src/commands/generate-contract.ts | 80 ++++++++++ packages/interfacectl-cli/src/index.ts | 26 ++++ .../interfacectl-extractor/dist/auth.d.ts | 5 + .../interfacectl-extractor/dist/auth.d.ts.map | 1 + packages/interfacectl-extractor/dist/auth.js | 15 ++ .../dist/design-system.d.ts | 6 + .../dist/design-system.d.ts.map | 1 + .../dist/design-system.js | 50 +++++++ .../interfacectl-extractor/dist/extract.d.ts | 3 + .../dist/extract.d.ts.map | 1 + .../interfacectl-extractor/dist/extract.js | 115 +++++++++++++++ .../interfacectl-extractor/dist/index.d.ts | 4 + .../dist/index.d.ts.map | 1 + packages/interfacectl-extractor/dist/index.js | 2 + .../interfacectl-extractor/dist/routes.d.ts | 7 + .../dist/routes.d.ts.map | 1 + .../interfacectl-extractor/dist/routes.js | 55 +++++++ .../dist/serialize.d.ts | 6 + .../dist/serialize.d.ts.map | 1 + .../interfacectl-extractor/dist/serialize.js | 22 +++ .../interfacectl-extractor/dist/shell.d.ts | 5 + .../dist/shell.d.ts.map | 1 + packages/interfacectl-extractor/dist/shell.js | 22 +++ .../interfacectl-extractor/dist/types.d.ts | 37 +++++ .../dist/types.d.ts.map | 1 + packages/interfacectl-extractor/dist/types.js | 1 + packages/interfacectl-extractor/package.json | 29 ++++ packages/interfacectl-extractor/src/auth.ts | 15 ++ .../src/design-system.ts | 60 ++++++++ .../interfacectl-extractor/src/extract.ts | 139 ++++++++++++++++++ packages/interfacectl-extractor/src/index.ts | 11 ++ packages/interfacectl-extractor/src/routes.ts | 60 ++++++++ .../interfacectl-extractor/src/serialize.ts | 23 +++ packages/interfacectl-extractor/src/shell.ts | 22 +++ packages/interfacectl-extractor/src/types.ts | 40 +++++ .../next-app-minimal/app/auth/login/route.ts | 3 + .../fixtures/next-app-minimal/app/layout.tsx | 7 + .../fixtures/next-app-minimal/app/page.tsx | 9 ++ .../interfacectl-extractor/test/index.mjs | 76 ++++++++++ packages/interfacectl-extractor/tsconfig.json | 9 ++ .../schema/surfaces.web.contract.schema.json | 11 ++ .../schema/surfaces.web.contract.schema.json | 11 ++ pnpm-lock.yaml | 13 ++ 51 files changed, 1146 insertions(+), 2 deletions(-) create mode 100644 packages/interfacectl-cli/dist/commands/generate-contract.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/generate-contract.js create mode 100644 packages/interfacectl-cli/src/commands/generate-contract.ts create mode 100644 packages/interfacectl-extractor/dist/auth.d.ts create mode 100644 packages/interfacectl-extractor/dist/auth.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/auth.js create mode 100644 packages/interfacectl-extractor/dist/design-system.d.ts create mode 100644 packages/interfacectl-extractor/dist/design-system.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/design-system.js create mode 100644 packages/interfacectl-extractor/dist/extract.d.ts create mode 100644 packages/interfacectl-extractor/dist/extract.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/extract.js create mode 100644 packages/interfacectl-extractor/dist/index.d.ts create mode 100644 packages/interfacectl-extractor/dist/index.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/index.js create mode 100644 packages/interfacectl-extractor/dist/routes.d.ts create mode 100644 packages/interfacectl-extractor/dist/routes.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/routes.js create mode 100644 packages/interfacectl-extractor/dist/serialize.d.ts create mode 100644 packages/interfacectl-extractor/dist/serialize.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/serialize.js create mode 100644 packages/interfacectl-extractor/dist/shell.d.ts create mode 100644 packages/interfacectl-extractor/dist/shell.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/shell.js create mode 100644 packages/interfacectl-extractor/dist/types.d.ts create mode 100644 packages/interfacectl-extractor/dist/types.d.ts.map create mode 100644 packages/interfacectl-extractor/dist/types.js create mode 100644 packages/interfacectl-extractor/package.json create mode 100644 packages/interfacectl-extractor/src/auth.ts create mode 100644 packages/interfacectl-extractor/src/design-system.ts create mode 100644 packages/interfacectl-extractor/src/extract.ts create mode 100644 packages/interfacectl-extractor/src/index.ts create mode 100644 packages/interfacectl-extractor/src/routes.ts create mode 100644 packages/interfacectl-extractor/src/serialize.ts create mode 100644 packages/interfacectl-extractor/src/shell.ts create mode 100644 packages/interfacectl-extractor/src/types.ts create mode 100644 packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/auth/login/route.ts create mode 100644 packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/layout.tsx create mode 100644 packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/page.tsx create mode 100644 packages/interfacectl-extractor/test/index.mjs create mode 100644 packages/interfacectl-extractor/tsconfig.json diff --git a/API.md b/API.md index 04bd2ee..6da4605 100644 --- a/API.md +++ b/API.md @@ -410,6 +410,38 @@ No timestamps are included in the manifest so that bundles remain deterministic. --- +### `generate-contract` (Phase 0) + +Extracts a deterministic contract artifact from a Next.js app by analyzing app code. **Extraction only** — no enforcement, no network calls. Output is schema-valid; extracted-only fields live under `x_extracted`. + +**Synopsis:** +```bash +interfacectl generate-contract --app-root --surface [--out ] [--report-out ] [--schema ] +``` + +**Description:** +- Scans the app directory for routes (app router), layout shell (`app/layout.tsx` or `app/(shell)/layout.tsx`), `@surfaces/ui` component imports, and `/auth` routes. +- Writes a contract JSON (default `contracts/generated/.contract.json`) and an extraction report (default `contracts/generated/.extraction.json`). +- Validates the generated contract against the schema before writing. Running the command twice produces identical output (stable key order, no timestamps). + +**Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--app-root ` | Path to the Next.js app root (directory containing `app/`) | (required) | +| `--surface ` | Surface identifier (e.g. `surfaces-web`) | (required) | +| `--out ` | Output path for contract JSON | `contracts/generated/.contract.json` | +| `--report-out ` | Output path for extraction report | `contracts/generated/.extraction.json` | +| `--schema ` | Optional path to the contract schema JSON file | Bundled schema | + +**Exit Codes:** +- `0`: Contract and report written successfully +- `1`: Schema validation failed for generated contract + +**Phase 0 scope:** Routes, hasShell, designSystemComponents (from `@surfaces/ui`), authAware. Other fields use placeholders or defaults; the report lists warnings for any omitted extraction. + +--- + ## Traceability fields (Phase 2) Diff and enforce JSON output include optional traceability fields for correlation and debugging. These fields are additive and optional; existing consumers should keep working. diff --git a/README.md b/README.md index 0f96488..a9fc476 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,14 @@ Before planning or implementing any feature, read "docs/strategy.md" and produce See "docs/feature-plan.template.md". -This repository contains two packages: +This repository contains three packages: - **`@surfaces/interfacectl-validator`** — Core validation library with TypeScript types, schema validation, and bundled contract schema definitions. Provides the foundation for contract validation. - **`@surfaces/interfacectl-cli`** — Command-line interface that consumes the validator to run contract checks from any repository. Most users only need this package. +- **`@surfaces/interfacectl-extractor`** — Library that extracts a contract from a Next.js app (Phase 0). Used by the CLI `generate-contract` command. Exports `extractContractFromNextApp({ appRoot, surfaceId })`. + ## Requirements - **Node.js**: >=18.20.0 or >=20.10.0 (required for `with { type: "json" }` import syntax support) @@ -79,6 +81,23 @@ This command does **not** perform enforcement or runtime gating. It produces a s interfacectl compile --contract --out ``` +### `generate-contract` (Phase 0) + +Extracts a **deterministic contract artifact** from a Next.js app by analyzing app code and config. This is **contract extraction only** — no enforcement, no network calls. + +**Phase 0 scope:** Routes (app router), layout shell presence (`app/layout.tsx` or `app/(shell)/layout.tsx`), design system usage (`@surfaces/ui` component imports), and auth posture (`/auth` routes). Values that cannot be extracted safely are omitted and reported as warnings in the extraction report. + +Outputs: + +- **Contract:** `contracts/generated/.contract.json` — schema-valid contract with extracted data under `x_extracted`. +- **Report:** `contracts/generated/.extraction.json` — machine-readable extraction summary and warnings for debugging. + +```bash +interfacectl generate-contract --app-root --surface [--out ] [--report-out ] +``` + +Running the command twice produces identical contract and report (stable key order, no timestamps). See [API.md](API.md) for full options. + For complete command documentation with all options, exit codes, and output formats, see [API.md](API.md). **Generation-time gating:** `interfacectl validate` is the canonical command for contract compliance. Use it to gate changes before merge or deployment. For local use and CI, run validate with your contract path and, for deterministic exit codes, use `--exit-codes v2`. The command `enforce --mode fail` is optional; it runs a structural diff then applies a policy threshold and is useful when you want to block on diff severity separately from compliance. diff --git a/package.json b/package.json index c877c79..6637d3e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "pnpm": { "overrides": { + "@surfaces/interfacectl-extractor": "link:packages/interfacectl-extractor", "@surfaces/interfacectl-validator": "link:packages/interfacectl-validator" } }, diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.d.ts b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts new file mode 100644 index 0000000..a37e530 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts @@ -0,0 +1,9 @@ +export interface GenerateContractOptions { + appRoot: string; + surfaceId: string; + outPath?: string; + reportOutPath?: string; + schemaPath?: string; +} +export declare function runGenerateContractCommand(options: GenerateContractOptions): Promise; +//# sourceMappingURL=generate-contract.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map new file mode 100644 index 0000000..887bd19 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"generate-contract.d.ts","sourceRoot":"","sources":["../../src/commands/generate-contract.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC,CA2DjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.js b/packages/interfacectl-cli/dist/commands/generate-contract.js new file mode 100644 index 0000000..cf65d6e --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generate-contract.js @@ -0,0 +1,55 @@ +import path from "node:path"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { extractContractFromNextApp, stableStringify, } from "@surfaces/interfacectl-extractor"; +import { validateContractStructure, getBundledContractSchema } from "@surfaces/interfacectl-validator"; +const DEFAULT_OUT_DIR = "contracts/generated"; +export async function runGenerateContractCommand(options) { + const cwd = process.cwd(); + const appRoot = path.resolve(cwd, options.appRoot); + const surfaceId = options.surfaceId; + const outDir = options.outPath + ? path.dirname(path.resolve(cwd, options.outPath)) + : path.resolve(cwd, DEFAULT_OUT_DIR); + const contractPath = options.outPath ? + path.resolve(cwd, options.outPath) + : path.join(outDir, `${surfaceId}.contract.json`); + const reportPath = options.reportOutPath ? + path.resolve(cwd, options.reportOutPath) + : path.join(outDir, `${surfaceId}.extraction.json`); + const { contract, report } = await extractContractFromNextApp({ + appRoot, + surfaceId, + }); + let schema; + if (options.schemaPath) { + const resolved = path.resolve(cwd, options.schemaPath); + const raw = await readFile(resolved, "utf-8"); + schema = JSON.parse(raw); + } + else { + schema = getBundledContractSchema(); + } + const structureResult = validateContractStructure(contract, schema); + if (!structureResult.ok) { + console.error("Generated contract failed schema validation:"); + for (const err of structureResult.errors) { + console.error(` ${err}`); + } + return 1; + } + await mkdir(path.dirname(contractPath), { recursive: true }); + await mkdir(path.dirname(reportPath), { recursive: true }); + const contractJson = stableStringify(contract); + const reportJson = stableStringify(report); + await writeFile(contractPath, `${contractJson}\n`, "utf-8"); + await writeFile(reportPath, `${reportJson}\n`, "utf-8"); + console.log(`Wrote contract: ${contractPath}`); + console.log(`Wrote report: ${reportPath}`); + if (report.warnings.length > 0) { + console.log(`Warnings (${report.warnings.length}):`); + for (const w of report.warnings) { + console.log(` [${w.code}] ${w.message}`); + } + } + return 0; +} diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 230e8be..2d74179 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -4,6 +4,7 @@ import { runValidateCommand } from "./commands/validate.js"; import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; import { runCompileCommand } from "./commands/compile.js"; +import { runGenerateContractCommand } from "./commands/generate-contract.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); program @@ -181,6 +182,24 @@ program }, pkg.version ?? "0.0.0"); process.exitCode = exitCode; }); +program + .command("generate-contract") + .description("Extract a contract from a Next.js app (Phase 0: extraction only)") + .requiredOption("--app-root ", "Path to the Next.js app root (directory containing app/)") + .requiredOption("--surface ", "Surface identifier (e.g. surfaces-web)") + .option("--out ", `Output path for contract JSON (default: contracts/generated/.contract.json)`) + .option("--report-out ", `Output path for extraction report (default: contracts/generated/.extraction.json)`) + .option("--schema ", "Optional path to the contract schema JSON file") + .action(async (options) => { + const exitCode = await runGenerateContractCommand({ + appRoot: options.appRoot, + surfaceId: options.surface, + outPath: options.out, + reportOutPath: options.reportOut, + schemaPath: options.schema, + }); + process.exitCode = exitCode; +}); program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/interfacectl-cli/package.json b/packages/interfacectl-cli/package.json index 60c7a95..1b5fcb0 100644 --- a/packages/interfacectl-cli/package.json +++ b/packages/interfacectl-cli/package.json @@ -11,13 +11,14 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build:deps": "pnpm --filter @surfaces/interfacectl-validator run build", + "build:deps": "pnpm --filter @surfaces/interfacectl-validator run build && pnpm --filter @surfaces/interfacectl-extractor run build", "build": "pnpm run build:deps && pnpm run clean && tsc -p tsconfig.json", "clean": "rm -rf dist", "validate": "node dist/index.js validate --root test/fixtures/minimal-project --contract test/fixtures/minimal-project/contracts/ui.contract.json", "test": "node --test test/index.mjs" }, "dependencies": { + "@surfaces/interfacectl-extractor": "^0.1.0", "@surfaces/interfacectl-validator": "^0.1.0", "commander": "^12.1.0", "globby": "^14.0.2", diff --git a/packages/interfacectl-cli/src/commands/generate-contract.ts b/packages/interfacectl-cli/src/commands/generate-contract.ts new file mode 100644 index 0000000..e90a594 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/generate-contract.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { + extractContractFromNextApp, + stableStringify, +} from "@surfaces/interfacectl-extractor"; +import { validateContractStructure, getBundledContractSchema } from "@surfaces/interfacectl-validator"; + +export interface GenerateContractOptions { + appRoot: string; + surfaceId: string; + outPath?: string; + reportOutPath?: string; + schemaPath?: string; +} + +const DEFAULT_OUT_DIR = "contracts/generated"; + +export async function runGenerateContractCommand( + options: GenerateContractOptions, +): Promise { + const cwd = process.cwd(); + const appRoot = path.resolve(cwd, options.appRoot); + const surfaceId = options.surfaceId; + + const outDir = options.outPath + ? path.dirname(path.resolve(cwd, options.outPath)) + : path.resolve(cwd, DEFAULT_OUT_DIR); + const contractPath = + options.outPath ? + path.resolve(cwd, options.outPath) + : path.join(outDir, `${surfaceId}.contract.json`); + const reportPath = + options.reportOutPath ? + path.resolve(cwd, options.reportOutPath) + : path.join(outDir, `${surfaceId}.extraction.json`); + + const { contract, report } = await extractContractFromNextApp({ + appRoot, + surfaceId, + }); + + let schema: object; + if (options.schemaPath) { + const resolved = path.resolve(cwd, options.schemaPath); + const raw = await readFile(resolved, "utf-8"); + schema = JSON.parse(raw) as object; + } else { + schema = getBundledContractSchema() as object; + } + const structureResult = validateContractStructure( + contract as unknown, + schema, + ); + if (!structureResult.ok) { + console.error("Generated contract failed schema validation:"); + for (const err of structureResult.errors) { + console.error(` ${err}`); + } + return 1; + } + + await mkdir(path.dirname(contractPath), { recursive: true }); + await mkdir(path.dirname(reportPath), { recursive: true }); + + const contractJson = stableStringify(contract); + const reportJson = stableStringify(report); + await writeFile(contractPath, `${contractJson}\n`, "utf-8"); + await writeFile(reportPath, `${reportJson}\n`, "utf-8"); + + console.log(`Wrote contract: ${contractPath}`); + console.log(`Wrote report: ${reportPath}`); + if (report.warnings.length > 0) { + console.log(`Warnings (${report.warnings.length}):`); + for (const w of report.warnings) { + console.log(` [${w.code}] ${w.message}`); + } + } + return 0; +} diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 2c83e28..890f13c 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -5,6 +5,7 @@ import { runValidateCommand } from "./commands/validate.js"; import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; import { runCompileCommand } from "./commands/compile.js"; +import { runGenerateContractCommand } from "./commands/generate-contract.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); @@ -272,6 +273,31 @@ program process.exitCode = exitCode; }); +program + .command("generate-contract") + .description("Extract a contract from a Next.js app (Phase 0: extraction only)") + .requiredOption("--app-root ", "Path to the Next.js app root (directory containing app/)") + .requiredOption("--surface ", "Surface identifier (e.g. surfaces-web)") + .option( + "--out ", + `Output path for contract JSON (default: contracts/generated/.contract.json)`, + ) + .option( + "--report-out ", + `Output path for extraction report (default: contracts/generated/.extraction.json)`, + ) + .option("--schema ", "Optional path to the contract schema JSON file") + .action(async (options) => { + const exitCode = await runGenerateContractCommand({ + appRoot: options.appRoot, + surfaceId: options.surface, + outPath: options.out, + reportOutPath: options.reportOut, + schemaPath: options.schema, + }); + process.exitCode = exitCode; + }); + program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/interfacectl-extractor/dist/auth.d.ts b/packages/interfacectl-extractor/dist/auth.d.ts new file mode 100644 index 0000000..713d812 --- /dev/null +++ b/packages/interfacectl-extractor/dist/auth.d.ts @@ -0,0 +1,5 @@ +/** + * Detect presence of /auth routes (app/auth/ directory with any route). + */ +export declare function detectAuthAware(appRoot: string): Promise; +//# sourceMappingURL=auth.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/auth.d.ts.map b/packages/interfacectl-extractor/dist/auth.d.ts.map new file mode 100644 index 0000000..b54cda4 --- /dev/null +++ b/packages/interfacectl-extractor/dist/auth.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQvE"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/auth.js b/packages/interfacectl-extractor/dist/auth.js new file mode 100644 index 0000000..5dc3a68 --- /dev/null +++ b/packages/interfacectl-extractor/dist/auth.js @@ -0,0 +1,15 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; +/** + * Detect presence of /auth routes (app/auth/ directory with any route). + */ +export async function detectAuthAware(appRoot) { + const authDir = path.join(appRoot, "app", "auth"); + try { + await access(authDir); + } + catch { + return false; + } + return true; +} diff --git a/packages/interfacectl-extractor/dist/design-system.d.ts b/packages/interfacectl-extractor/dist/design-system.d.ts new file mode 100644 index 0000000..f5c5a2c --- /dev/null +++ b/packages/interfacectl-extractor/dist/design-system.d.ts @@ -0,0 +1,6 @@ +/** + * Scan directory recursively for .tsx, .ts, .jsx, .js and collect + * component names from @surfaces/ui (components/ subpath only). + */ +export declare function extractDesignSystemComponents(appRoot: string, extensions?: string[]): Promise; +//# sourceMappingURL=design-system.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/design-system.d.ts.map b/packages/interfacectl-extractor/dist/design-system.d.ts.map new file mode 100644 index 0000000..8e23971 --- /dev/null +++ b/packages/interfacectl-extractor/dist/design-system.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"design-system.d.ts","sourceRoot":"","sources":["../src/design-system.ts"],"names":[],"mappings":"AAOA;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,MAAM,EAAmC,GACpD,OAAO,CAAC,MAAM,EAAE,CAAC,CASnB"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/design-system.js b/packages/interfacectl-extractor/dist/design-system.js new file mode 100644 index 0000000..9805081 --- /dev/null +++ b/packages/interfacectl-extractor/dist/design-system.js @@ -0,0 +1,50 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +const SURFACES_UI_IMPORT = /from\s+['"]@surfaces\/ui(?:\/([^'"]*))?['"]/g; +const COMPONENT_PATH = /^components\/([A-Za-z0-9]+)/; +/** + * Scan directory recursively for .tsx, .ts, .jsx, .js and collect + * component names from @surfaces/ui (components/ subpath only). + */ +export async function extractDesignSystemComponents(appRoot, extensions = [".tsx", ".ts", ".jsx", ".js"]) { + const seen = new Set(); + await scanDir(path.join(appRoot, "app"), seen, extensions); + await scanDir(path.join(appRoot, "components"), seen, extensions); + await scanDir(path.join(appRoot, "lib"), seen, extensions); + await scanDir(path.join(appRoot, "src"), seen, extensions); + const list = Array.from(seen); + list.sort(); + return list; +} +async function scanDir(dir, seen, extensions) { + let entries; + try { + const e = await readdir(dir, { withFileTypes: true }); + entries = e.map((x) => ({ name: x.name, isFile: x.isFile() })); + } + catch { + return; + } + for (const { name, isFile } of entries) { + const full = path.join(dir, name); + if (isFile) { + const ext = path.extname(name); + if (extensions.includes(ext)) { + const content = await readFile(full, "utf-8").catch(() => ""); + for (const match of content.matchAll(SURFACES_UI_IMPORT)) { + const subpath = match[1]?.trim() ?? ""; + if (subpath.startsWith("components/")) { + const m = subpath.match(COMPONENT_PATH); + if (m) { + seen.add(m[1]); + } + } + } + } + continue; + } + if (name !== "node_modules" && !name.startsWith(".")) { + await scanDir(full, seen, extensions); + } + } +} diff --git a/packages/interfacectl-extractor/dist/extract.d.ts b/packages/interfacectl-extractor/dist/extract.d.ts new file mode 100644 index 0000000..0b3bffc --- /dev/null +++ b/packages/interfacectl-extractor/dist/extract.d.ts @@ -0,0 +1,3 @@ +import type { ExtractContractFromNextAppOptions, ExtractContractResult } from "./types.js"; +export declare function extractContractFromNextApp(options: ExtractContractFromNextAppOptions): Promise; +//# sourceMappingURL=extract.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/extract.d.ts.map b/packages/interfacectl-extractor/dist/extract.d.ts.map new file mode 100644 index 0000000..f61f1ce --- /dev/null +++ b/packages/interfacectl-extractor/dist/extract.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../src/extract.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAIV,iCAAiC,EACjC,qBAAqB,EACtB,MAAM,YAAY,CAAC;AAIpB,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,iCAAiC,GACzC,OAAO,CAAC,qBAAqB,CAAC,CA0EhC"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/extract.js b/packages/interfacectl-extractor/dist/extract.js new file mode 100644 index 0000000..c04ecca --- /dev/null +++ b/packages/interfacectl-extractor/dist/extract.js @@ -0,0 +1,115 @@ +import path from "node:path"; +import { extractRoutes } from "./routes.js"; +import { detectHasShell } from "./shell.js"; +import { extractDesignSystemComponents } from "./design-system.js"; +import { detectAuthAware } from "./auth.js"; +const PLACEHOLDER_SECTION_ID = "extracted.placeholder"; +export async function extractContractFromNextApp(options) { + const { appRoot, surfaceId } = options; + const resolvedRoot = path.resolve(appRoot); + const warnings = []; + const appDir = path.join(resolvedRoot, "app"); + let routes = []; + let hasShell = false; + let designSystemComponents = []; + let authAware = false; + try { + routes = await extractRoutes(appDir); + } + catch (err) { + warnings.push({ + code: "routes.extract-failed", + message: `Could not extract routes: ${err.message}`, + }); + } + try { + hasShell = await detectHasShell(resolvedRoot); + } + catch (err) { + warnings.push({ + code: "shell.detect-failed", + message: `Could not detect layout shell: ${err.message}`, + }); + } + try { + designSystemComponents = await extractDesignSystemComponents(resolvedRoot); + } + catch (err) { + warnings.push({ + code: "designSystem.extract-failed", + message: `Could not extract @surfaces/ui usage: ${err.message}`, + }); + } + try { + authAware = await detectAuthAware(resolvedRoot); + } + catch { + // no auth dir is not an error + } + const extracted = { + routes: [...routes].sort(), + hasShell, + designSystemComponents: [...designSystemComponents].sort(), + authAware, + }; + warnings.push({ + code: "requiredSections.omitted", + message: "requiredSections cannot be extracted from code in Phase 0; using placeholder.", + }); + warnings.push({ + code: "allowedFonts.default", + message: "allowedFonts cannot be extracted from code in Phase 0; using default.", + }); + warnings.push({ + code: "layout.default", + message: "layout.maxContentWidth cannot be extracted from code in Phase 0; using default.", + }); + const report = { + surfaceId, + appRoot: resolvedRoot, + warnings, + extracted, + }; + const contract = buildContract(surfaceId, extracted); + return { contract, report }; +} +function buildContract(surfaceId, extracted) { + const displayName = surfaceId === "surfaces-web" + ? "Surfaces Web" + : surfaceId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + return { + $schema: "https://contracts.surfaces.local/surfaces.web.contract.schema.json", + contractId: `${surfaceId}.generated`, + version: "0.1.0", + description: "Extracted from Next.js app (Phase 0).", + surfaces: [ + { + id: surfaceId, + displayName, + type: "web", + requiredSections: [PLACEHOLDER_SECTION_ID], + allowedFonts: ["sans-serif"], + layout: { maxContentWidth: 1120 }, + }, + ], + sections: [ + { + id: PLACEHOLDER_SECTION_ID, + intent: "extracted", + description: "Placeholder; sections not extracted in Phase 0.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + x_extracted: { + routes: extracted.routes, + hasShell: extracted.hasShell, + designSystemComponents: extracted.designSystemComponents, + authAware: extracted.authAware, + }, + }; +} diff --git a/packages/interfacectl-extractor/dist/index.d.ts b/packages/interfacectl-extractor/dist/index.d.ts new file mode 100644 index 0000000..6081efc --- /dev/null +++ b/packages/interfacectl-extractor/dist/index.d.ts @@ -0,0 +1,4 @@ +export { extractContractFromNextApp, } from "./extract.js"; +export { stableStringify } from "./serialize.js"; +export type { ExtractedContractFields, ExtractionReport, ExtractionWarning, ExtractContractFromNextAppOptions, ExtractContractResult, } from "./types.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/index.d.ts.map b/packages/interfacectl-extractor/dist/index.d.ts.map new file mode 100644 index 0000000..405807c --- /dev/null +++ b/packages/interfacectl-extractor/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,GAC3B,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EACV,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,EACjB,iCAAiC,EACjC,qBAAqB,GACtB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/index.js b/packages/interfacectl-extractor/dist/index.js new file mode 100644 index 0000000..0123a1f --- /dev/null +++ b/packages/interfacectl-extractor/dist/index.js @@ -0,0 +1,2 @@ +export { extractContractFromNextApp, } from "./extract.js"; +export { stableStringify } from "./serialize.js"; diff --git a/packages/interfacectl-extractor/dist/routes.d.ts b/packages/interfacectl-extractor/dist/routes.d.ts new file mode 100644 index 0000000..46acba9 --- /dev/null +++ b/packages/interfacectl-extractor/dist/routes.d.ts @@ -0,0 +1,7 @@ +/** + * Recursively collect public route paths from app/ directory. + * Converts file system paths to URL path segments; dynamic segments + * are preserved as [param], [...rest], [[...optional]]. + */ +export declare function extractRoutes(appDir: string): Promise; +//# sourceMappingURL=routes.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/routes.d.ts.map b/packages/interfacectl-extractor/dist/routes.d.ts.map new file mode 100644 index 0000000..55919a7 --- /dev/null +++ b/packages/interfacectl-extractor/dist/routes.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAKrE"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/routes.js b/packages/interfacectl-extractor/dist/routes.js new file mode 100644 index 0000000..00f70f2 --- /dev/null +++ b/packages/interfacectl-extractor/dist/routes.js @@ -0,0 +1,55 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +const PAGE_FILES = ["page.tsx", "page.js", "page.ts", "page.jsx"]; +const ROUTE_FILES = ["route.ts", "route.js", "route.tsx", "route.jsx"]; +/** + * Recursively collect public route paths from app/ directory. + * Converts file system paths to URL path segments; dynamic segments + * are preserved as [param], [...rest], [[...optional]]. + */ +export async function extractRoutes(appDir) { + const routes = []; + await walk(appDir, "", routes); + routes.sort(); + return routes; +} +async function walk(dir, segment, out) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } + catch { + return; + } + for (const e of entries) { + const name = e.name; + const fullPath = path.join(dir, name); + const urlSegment = segment ? `${segment}/${name}` : `/${name}`; + if (e.isDirectory()) { + if (name.startsWith("(") && name.endsWith(")")) { + // Route group: (shell) -> no segment added + const innerSegment = segment; + await walk(fullPath, innerSegment, out); + } + else { + await walk(fullPath, urlSegment, out); + } + continue; + } + if (e.isFile()) { + const base = path.basename(name, path.extname(name)); + if (PAGE_FILES.includes(name)) { + const route = segment || "/"; + if (!out.includes(route)) { + out.push(route); + } + } + else if (ROUTE_FILES.includes(name)) { + const route = segment || "/"; + if (!out.includes(route)) { + out.push(route); + } + } + } + } +} diff --git a/packages/interfacectl-extractor/dist/serialize.d.ts b/packages/interfacectl-extractor/dist/serialize.d.ts new file mode 100644 index 0000000..7b0ff72 --- /dev/null +++ b/packages/interfacectl-extractor/dist/serialize.d.ts @@ -0,0 +1,6 @@ +/** + * Serialize to JSON with stable key order (deterministic output). + * No timestamps or non-deterministic values. + */ +export declare function stableStringify(value: unknown): string; +//# sourceMappingURL=serialize.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/serialize.d.ts.map b/packages/interfacectl-extractor/dist/serialize.d.ts.map new file mode 100644 index 0000000..ecbac6a --- /dev/null +++ b/packages/interfacectl-extractor/dist/serialize.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEtD"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/serialize.js b/packages/interfacectl-extractor/dist/serialize.js new file mode 100644 index 0000000..c99f663 --- /dev/null +++ b/packages/interfacectl-extractor/dist/serialize.js @@ -0,0 +1,22 @@ +/** + * Serialize to JSON with stable key order (deterministic output). + * No timestamps or non-deterministic values. + */ +export function stableStringify(value) { + return JSON.stringify(sortKeys(value), null, 2); +} +function sortKeys(value) { + if (value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(sortKeys); + } + const obj = value; + const keys = Object.keys(obj).sort(); + const out = {}; + for (const k of keys) { + out[k] = sortKeys(obj[k]); + } + return out; +} diff --git a/packages/interfacectl-extractor/dist/shell.d.ts b/packages/interfacectl-extractor/dist/shell.d.ts new file mode 100644 index 0000000..7e8c95d --- /dev/null +++ b/packages/interfacectl-extractor/dist/shell.d.ts @@ -0,0 +1,5 @@ +/** + * Detect presence of a layout shell: app/layout.tsx or app/(shell)/layout.tsx. + */ +export declare function detectHasShell(appRoot: string): Promise; +//# sourceMappingURL=shell.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/shell.d.ts.map b/packages/interfacectl-extractor/dist/shell.d.ts.map new file mode 100644 index 0000000..a0e1937 --- /dev/null +++ b/packages/interfacectl-extractor/dist/shell.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAetE"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/shell.js b/packages/interfacectl-extractor/dist/shell.js new file mode 100644 index 0000000..72709f8 --- /dev/null +++ b/packages/interfacectl-extractor/dist/shell.js @@ -0,0 +1,22 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; +/** + * Detect presence of a layout shell: app/layout.tsx or app/(shell)/layout.tsx. + */ +export async function detectHasShell(appRoot) { + const appDir = path.join(appRoot, "app"); + const rootLayout = path.join(appDir, "layout.tsx"); + const rootLayoutJs = path.join(appDir, "layout.js"); + const shellLayout = path.join(appDir, "(shell)", "layout.tsx"); + const shellLayoutJs = path.join(appDir, "(shell)", "layout.js"); + for (const p of [rootLayout, rootLayoutJs, shellLayout, shellLayoutJs]) { + try { + await access(p); + return true; + } + catch { + // continue + } + } + return false; +} diff --git a/packages/interfacectl-extractor/dist/types.d.ts b/packages/interfacectl-extractor/dist/types.d.ts new file mode 100644 index 0000000..e075c2b --- /dev/null +++ b/packages/interfacectl-extractor/dist/types.d.ts @@ -0,0 +1,37 @@ +/** + * Phase 0 extraction: machine-readable fields derived from Next.js app code. + * Validator does not use these; they are for tooling and debugging. + */ +export interface ExtractedContractFields { + routes: string[]; + hasShell: boolean; + designSystemComponents: string[]; + authAware: boolean; +} +export interface ExtractionWarning { + code: string; + message: string; +} +export interface ExtractionReport { + surfaceId: string; + appRoot: string; + warnings: ExtractionWarning[]; + extracted: ExtractedContractFields; +} +export interface ExtractContractFromNextAppOptions { + /** Path to the Next.js app root (directory containing app/) */ + appRoot: string; + /** Surface identifier (e.g. surfaces-web) */ + surfaceId: string; + /** Optional base URL; not used in v0 (no network) */ + baseUrl?: string; + /** Optional config overlay; not used in v0 */ + config?: Record; +} +export interface ExtractContractResult { + /** Contract JSON-serializable object (deterministic key order) */ + contract: Record; + /** Extraction report for debugging */ + report: ExtractionReport; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/types.d.ts.map b/packages/interfacectl-extractor/dist/types.d.ts.map new file mode 100644 index 0000000..abbca39 --- /dev/null +++ b/packages/interfacectl-extractor/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,SAAS,EAAE,uBAAuB,CAAC;CACpC;AAED,MAAM,WAAW,iCAAiC;IAChD,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,sCAAsC;IACtC,MAAM,EAAE,gBAAgB,CAAC;CAC1B"} \ No newline at end of file diff --git a/packages/interfacectl-extractor/dist/types.js b/packages/interfacectl-extractor/dist/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/interfacectl-extractor/dist/types.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/interfacectl-extractor/package.json b/packages/interfacectl-extractor/package.json new file mode 100644 index 0000000..f69c97e --- /dev/null +++ b/packages/interfacectl-extractor/package.json @@ -0,0 +1,29 @@ +{ + "name": "@surfaces/interfacectl-extractor", + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=18.20.0 || >=20.10.0" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run clean && tsc -p tsconfig.json", + "clean": "rm -rf dist", + "test": "node --test test/index.mjs" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "typescript": "^5.6.3" + } +} diff --git a/packages/interfacectl-extractor/src/auth.ts b/packages/interfacectl-extractor/src/auth.ts new file mode 100644 index 0000000..0f33fc5 --- /dev/null +++ b/packages/interfacectl-extractor/src/auth.ts @@ -0,0 +1,15 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +/** + * Detect presence of /auth routes (app/auth/ directory with any route). + */ +export async function detectAuthAware(appRoot: string): Promise { + const authDir = path.join(appRoot, "app", "auth"); + try { + await access(authDir); + } catch { + return false; + } + return true; +} diff --git a/packages/interfacectl-extractor/src/design-system.ts b/packages/interfacectl-extractor/src/design-system.ts new file mode 100644 index 0000000..90e8e19 --- /dev/null +++ b/packages/interfacectl-extractor/src/design-system.ts @@ -0,0 +1,60 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const SURFACES_UI_IMPORT = + /from\s+['"]@surfaces\/ui(?:\/([^'"]*))?['"]/g; +const COMPONENT_PATH = /^components\/([A-Za-z0-9]+)/; + +/** + * Scan directory recursively for .tsx, .ts, .jsx, .js and collect + * component names from @surfaces/ui (components/ subpath only). + */ +export async function extractDesignSystemComponents( + appRoot: string, + extensions: string[] = [".tsx", ".ts", ".jsx", ".js"], +): Promise { + const seen = new Set(); + await scanDir(path.join(appRoot, "app"), seen, extensions); + await scanDir(path.join(appRoot, "components"), seen, extensions); + await scanDir(path.join(appRoot, "lib"), seen, extensions); + await scanDir(path.join(appRoot, "src"), seen, extensions); + const list = Array.from(seen); + list.sort(); + return list; +} + +async function scanDir( + dir: string, + seen: Set, + extensions: string[], +): Promise { + let entries: { name: string; isFile: boolean }[]; + try { + const e = await readdir(dir, { withFileTypes: true }); + entries = e.map((x) => ({ name: x.name, isFile: x.isFile() })); + } catch { + return; + } + for (const { name, isFile } of entries) { + const full = path.join(dir, name); + if (isFile) { + const ext = path.extname(name); + if (extensions.includes(ext)) { + const content = await readFile(full, "utf-8").catch(() => ""); + for (const match of content.matchAll(SURFACES_UI_IMPORT)) { + const subpath = match[1]?.trim() ?? ""; + if (subpath.startsWith("components/")) { + const m = subpath.match(COMPONENT_PATH); + if (m) { + seen.add(m[1]); + } + } + } + } + continue; + } + if (name !== "node_modules" && !name.startsWith(".")) { + await scanDir(full, seen, extensions); + } + } +} diff --git a/packages/interfacectl-extractor/src/extract.ts b/packages/interfacectl-extractor/src/extract.ts new file mode 100644 index 0000000..fb4565e --- /dev/null +++ b/packages/interfacectl-extractor/src/extract.ts @@ -0,0 +1,139 @@ +import path from "node:path"; +import { extractRoutes } from "./routes.js"; +import { detectHasShell } from "./shell.js"; +import { extractDesignSystemComponents } from "./design-system.js"; +import { detectAuthAware } from "./auth.js"; +import type { + ExtractedContractFields, + ExtractionReport, + ExtractionWarning, + ExtractContractFromNextAppOptions, + ExtractContractResult, +} from "./types.js"; + +const PLACEHOLDER_SECTION_ID = "extracted.placeholder"; + +export async function extractContractFromNextApp( + options: ExtractContractFromNextAppOptions, +): Promise { + const { appRoot, surfaceId } = options; + const resolvedRoot = path.resolve(appRoot); + const warnings: ExtractionWarning[] = []; + const appDir = path.join(resolvedRoot, "app"); + + let routes: string[] = []; + let hasShell = false; + let designSystemComponents: string[] = []; + let authAware = false; + + try { + routes = await extractRoutes(appDir); + } catch (err) { + warnings.push({ + code: "routes.extract-failed", + message: `Could not extract routes: ${(err as Error).message}`, + }); + } + + try { + hasShell = await detectHasShell(resolvedRoot); + } catch (err) { + warnings.push({ + code: "shell.detect-failed", + message: `Could not detect layout shell: ${(err as Error).message}`, + }); + } + + try { + designSystemComponents = await extractDesignSystemComponents(resolvedRoot); + } catch (err) { + warnings.push({ + code: "designSystem.extract-failed", + message: `Could not extract @surfaces/ui usage: ${(err as Error).message}`, + }); + } + + try { + authAware = await detectAuthAware(resolvedRoot); + } catch { + // no auth dir is not an error + } + + const extracted: ExtractedContractFields = { + routes: [...routes].sort(), + hasShell, + designSystemComponents: [...designSystemComponents].sort(), + authAware, + }; + + warnings.push({ + code: "requiredSections.omitted", + message: + "requiredSections cannot be extracted from code in Phase 0; using placeholder.", + }); + warnings.push({ + code: "allowedFonts.default", + message: "allowedFonts cannot be extracted from code in Phase 0; using default.", + }); + warnings.push({ + code: "layout.default", + message: "layout.maxContentWidth cannot be extracted from code in Phase 0; using default.", + }); + + const report: ExtractionReport = { + surfaceId, + appRoot: resolvedRoot, + warnings, + extracted, + }; + + const contract = buildContract(surfaceId, extracted); + return { contract, report }; +} + +function buildContract( + surfaceId: string, + extracted: ExtractedContractFields, +): Record { + const displayName = + surfaceId === "surfaces-web" + ? "Surfaces Web" + : surfaceId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + return { + $schema: + "https://contracts.surfaces.local/surfaces.web.contract.schema.json", + contractId: `${surfaceId}.generated`, + version: "0.1.0", + description: "Extracted from Next.js app (Phase 0).", + surfaces: [ + { + id: surfaceId, + displayName, + type: "web", + requiredSections: [PLACEHOLDER_SECTION_ID], + allowedFonts: ["sans-serif"], + layout: { maxContentWidth: 1120 }, + }, + ], + sections: [ + { + id: PLACEHOLDER_SECTION_ID, + intent: "extracted", + description: "Placeholder; sections not extracted in Phase 0.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + x_extracted: { + routes: extracted.routes, + hasShell: extracted.hasShell, + designSystemComponents: extracted.designSystemComponents, + authAware: extracted.authAware, + }, + }; +} diff --git a/packages/interfacectl-extractor/src/index.ts b/packages/interfacectl-extractor/src/index.ts new file mode 100644 index 0000000..b2aa46e --- /dev/null +++ b/packages/interfacectl-extractor/src/index.ts @@ -0,0 +1,11 @@ +export { + extractContractFromNextApp, +} from "./extract.js"; +export { stableStringify } from "./serialize.js"; +export type { + ExtractedContractFields, + ExtractionReport, + ExtractionWarning, + ExtractContractFromNextAppOptions, + ExtractContractResult, +} from "./types.js"; diff --git a/packages/interfacectl-extractor/src/routes.ts b/packages/interfacectl-extractor/src/routes.ts new file mode 100644 index 0000000..7efb387 --- /dev/null +++ b/packages/interfacectl-extractor/src/routes.ts @@ -0,0 +1,60 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import type { Dirent } from "node:fs"; + +const PAGE_FILES = ["page.tsx", "page.js", "page.ts", "page.jsx"]; +const ROUTE_FILES = ["route.ts", "route.js", "route.tsx", "route.jsx"]; + +/** + * Recursively collect public route paths from app/ directory. + * Converts file system paths to URL path segments; dynamic segments + * are preserved as [param], [...rest], [[...optional]]. + */ +export async function extractRoutes(appDir: string): Promise { + const routes: string[] = []; + await walk(appDir, "", routes); + routes.sort(); + return routes; +} + +async function walk( + dir: string, + segment: string, + out: string[], +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const name = e.name; + const fullPath = path.join(dir, name); + const urlSegment = segment ? `${segment}/${name}` : `/${name}`; + if (e.isDirectory()) { + if (name.startsWith("(") && name.endsWith(")")) { + // Route group: (shell) -> no segment added + const innerSegment = segment; + await walk(fullPath, innerSegment, out); + } else { + await walk(fullPath, urlSegment, out); + } + continue; + } + if (e.isFile()) { + const base = path.basename(name, path.extname(name)); + if (PAGE_FILES.includes(name)) { + const route = segment || "/"; + if (!out.includes(route)) { + out.push(route); + } + } else if (ROUTE_FILES.includes(name)) { + const route = segment || "/"; + if (!out.includes(route)) { + out.push(route); + } + } + } + } +} diff --git a/packages/interfacectl-extractor/src/serialize.ts b/packages/interfacectl-extractor/src/serialize.ts new file mode 100644 index 0000000..bede7c7 --- /dev/null +++ b/packages/interfacectl-extractor/src/serialize.ts @@ -0,0 +1,23 @@ +/** + * Serialize to JSON with stable key order (deterministic output). + * No timestamps or non-deterministic values. + */ +export function stableStringify(value: unknown): string { + return JSON.stringify(sortKeys(value), null, 2); +} + +function sortKeys(value: unknown): unknown { + if (value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(sortKeys); + } + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const out: Record = {}; + for (const k of keys) { + out[k] = sortKeys(obj[k]); + } + return out; +} diff --git a/packages/interfacectl-extractor/src/shell.ts b/packages/interfacectl-extractor/src/shell.ts new file mode 100644 index 0000000..f339745 --- /dev/null +++ b/packages/interfacectl-extractor/src/shell.ts @@ -0,0 +1,22 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +/** + * Detect presence of a layout shell: app/layout.tsx or app/(shell)/layout.tsx. + */ +export async function detectHasShell(appRoot: string): Promise { + const appDir = path.join(appRoot, "app"); + const rootLayout = path.join(appDir, "layout.tsx"); + const rootLayoutJs = path.join(appDir, "layout.js"); + const shellLayout = path.join(appDir, "(shell)", "layout.tsx"); + const shellLayoutJs = path.join(appDir, "(shell)", "layout.js"); + for (const p of [rootLayout, rootLayoutJs, shellLayout, shellLayoutJs]) { + try { + await access(p); + return true; + } catch { + // continue + } + } + return false; +} diff --git a/packages/interfacectl-extractor/src/types.ts b/packages/interfacectl-extractor/src/types.ts new file mode 100644 index 0000000..f45b300 --- /dev/null +++ b/packages/interfacectl-extractor/src/types.ts @@ -0,0 +1,40 @@ +/** + * Phase 0 extraction: machine-readable fields derived from Next.js app code. + * Validator does not use these; they are for tooling and debugging. + */ +export interface ExtractedContractFields { + routes: string[]; + hasShell: boolean; + designSystemComponents: string[]; + authAware: boolean; +} + +export interface ExtractionWarning { + code: string; + message: string; +} + +export interface ExtractionReport { + surfaceId: string; + appRoot: string; + warnings: ExtractionWarning[]; + extracted: ExtractedContractFields; +} + +export interface ExtractContractFromNextAppOptions { + /** Path to the Next.js app root (directory containing app/) */ + appRoot: string; + /** Surface identifier (e.g. surfaces-web) */ + surfaceId: string; + /** Optional base URL; not used in v0 (no network) */ + baseUrl?: string; + /** Optional config overlay; not used in v0 */ + config?: Record; +} + +export interface ExtractContractResult { + /** Contract JSON-serializable object (deterministic key order) */ + contract: Record; + /** Extraction report for debugging */ + report: ExtractionReport; +} diff --git a/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/auth/login/route.ts b/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/auth/login/route.ts new file mode 100644 index 0000000..3b7a507 --- /dev/null +++ b/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/auth/login/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return new Response("OK"); +} diff --git a/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/layout.tsx b/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/layout.tsx new file mode 100644 index 0000000..db8dfec --- /dev/null +++ b/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/page.tsx b/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/page.tsx new file mode 100644 index 0000000..5b9ae9b --- /dev/null +++ b/packages/interfacectl-extractor/test/fixtures/next-app-minimal/app/page.tsx @@ -0,0 +1,9 @@ +import Navigation from "@surfaces/ui/components/Navigation"; + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/packages/interfacectl-extractor/test/index.mjs b/packages/interfacectl-extractor/test/index.mjs new file mode 100644 index 0000000..b8aee26 --- /dev/null +++ b/packages/interfacectl-extractor/test/index.mjs @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + extractContractFromNextApp, + stableStringify, +} from "../dist/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(__dirname, "fixtures", "next-app-minimal"); + +describe("extractContractFromNextApp", () => { + it("returns contract and report with extracted fields", async () => { + const { contract, report } = await extractContractFromNextApp({ + appRoot: FIXTURE, + surfaceId: "test-surface", + }); + assert.ok(contract.contractId === "test-surface.generated"); + assert.ok(contract.version === "0.1.0"); + assert.ok(Array.isArray(contract.surfaces) && contract.surfaces.length === 1); + assert.strictEqual(contract.surfaces[0].id, "test-surface"); + assert.ok(Array.isArray(contract.sections) && contract.sections.length === 1); + assert.ok(contract.x_extracted); + assert.strictEqual(contract.x_extracted.authAware, true); + assert.strictEqual(contract.x_extracted.hasShell, true); + assert.ok(Array.isArray(contract.x_extracted.routes)); + assert.ok(contract.x_extracted.routes.includes("/")); + assert.ok(contract.x_extracted.routes.includes("/auth/login")); + assert.ok(Array.isArray(contract.x_extracted.designSystemComponents)); + assert.ok(contract.x_extracted.designSystemComponents.includes("Navigation")); + assert.strictEqual(report.surfaceId, "test-surface"); + assert.ok(Array.isArray(report.warnings) && report.warnings.length > 0); + assert.ok(report.extracted.routes.includes("/")); + assert.strictEqual(report.extracted.authAware, true); + assert.strictEqual(report.extracted.hasShell, true); + }); + + it("produces deterministic contract and report (stable key order)", async () => { + const run1 = await extractContractFromNextApp({ + appRoot: FIXTURE, + surfaceId: "test-surface", + }); + const run2 = await extractContractFromNextApp({ + appRoot: FIXTURE, + surfaceId: "test-surface", + }); + const json1 = stableStringify({ contract: run1.contract, report: run1.report }); + const json2 = stableStringify({ contract: run2.contract, report: run2.report }); + assert.strictEqual(json1, json2, "Two runs must produce identical JSON"); + }); + + it("report lists warnings for omitted extraction", async () => { + const { report } = await extractContractFromNextApp({ + appRoot: FIXTURE, + surfaceId: "test-surface", + }); + const codes = report.warnings.map((w) => w.code); + assert.ok( + codes.includes("requiredSections.omitted"), + "expected requiredSections.omitted warning", + ); + assert.ok( + codes.includes("allowedFonts.default"), + "expected allowedFonts.default warning", + ); + }); +}); + +describe("stableStringify", () => { + it("sorts object keys", () => { + const obj = { z: 1, a: 2, m: 3 }; + const out = stableStringify(obj); + assert.ok(out.startsWith('{\n "a":')); + }); +}); diff --git a/packages/interfacectl-extractor/tsconfig.json b/packages/interfacectl-extractor/tsconfig.json new file mode 100644 index 0000000..d8cb31c --- /dev/null +++ b/packages/interfacectl-extractor/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json index 65c36ba..0242f14 100644 --- a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json @@ -46,6 +46,17 @@ }, "color": { "$ref": "#/$defs/color" + }, + "x_extracted": { + "type": "object", + "description": "Phase 0: fields extracted from app code; not used by validator semantics.", + "additionalProperties": true, + "properties": { + "routes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "hasShell": { "type": "boolean" }, + "designSystemComponents": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "authAware": { "type": "boolean" } + } } }, "$defs": { diff --git a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json index 65c36ba..0242f14 100644 --- a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json @@ -46,6 +46,17 @@ }, "color": { "$ref": "#/$defs/color" + }, + "x_extracted": { + "type": "object", + "description": "Phase 0: fields extracted from app code; not used by validator semantics.", + "additionalProperties": true, + "properties": { + "routes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "hasShell": { "type": "boolean" }, + "designSystemComponents": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "authAware": { "type": "boolean" } + } } }, "$defs": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92851a1..63d035a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@surfaces/interfacectl-extractor': link:packages/interfacectl-extractor '@surfaces/interfacectl-validator': link:packages/interfacectl-validator importers: @@ -23,6 +24,9 @@ importers: packages/interfacectl-cli: dependencies: + '@surfaces/interfacectl-extractor': + specifier: link:../interfacectl-extractor + version: link:../interfacectl-extractor '@surfaces/interfacectl-validator': specifier: link:../interfacectl-validator version: link:../interfacectl-validator @@ -43,6 +47,15 @@ importers: specifier: ^5.6.3 version: 5.9.3 + packages/interfacectl-extractor: + devDependencies: + '@types/node': + specifier: ^20.14.9 + version: 20.19.27 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + packages/interfacectl-validator: dependencies: ajv: From 7526cc0d2bbc024322eeb69b927caba4e9d5ef90 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 4 Feb 2026 16:51:23 -0800 Subject: [PATCH 3/3] docs: add Phase 0 extraction guardrails (no heavy AST) - Add docs/plans/phase-0-extraction-guardrails.md: why we avoid Babel/AST, what we extract vs omit, trigger conditions for adding AST later - Reference guardrails in README and API.md generate-contract sections - Phase-scoped, revisitable; does not block later phases Co-authored-by: Cursor --- API.md | 2 + README.md | 2 + docs/plans/phase-0-extraction-guardrails.md | 47 +++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 docs/plans/phase-0-extraction-guardrails.md diff --git a/API.md b/API.md index 6da4605..c4cc5fd 100644 --- a/API.md +++ b/API.md @@ -440,6 +440,8 @@ interfacectl generate-contract --app-root --surface [--out ] [ **Phase 0 scope:** Routes, hasShell, designSystemComponents (from `@surfaces/ui`), authAware. Other fields use placeholders or defaults; the report lists warnings for any omitted extraction. +**Phase 0 guardrails:** No Babel or heavy AST. Uses filesystem + regex for determinism, debuggability, and minimal deps. See [docs/plans/phase-0-extraction-guardrails.md](docs/plans/phase-0-extraction-guardrails.md) for extraction limits and when AST tooling may be added. + --- ## Traceability fields (Phase 2) diff --git a/README.md b/README.md index a9fc476..48be51f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ Extracts a **deterministic contract artifact** from a Next.js app by analyzing a **Phase 0 scope:** Routes (app router), layout shell presence (`app/layout.tsx` or `app/(shell)/layout.tsx`), design system usage (`@surfaces/ui` component imports), and auth posture (`/auth` routes). Values that cannot be extracted safely are omitted and reported as warnings in the extraction report. +**Phase 0 guardrails:** No Babel or heavy AST frameworks. Extraction uses filesystem walks and regex for determinism, debuggability, and minimal dependency surface. See [docs/plans/phase-0-extraction-guardrails.md](docs/plans/phase-0-extraction-guardrails.md) for what we extract, what we omit, and when AST tooling may be added in a later phase. + Outputs: - **Contract:** `contracts/generated/.contract.json` — schema-valid contract with extracted data under `x_extracted`. diff --git a/docs/plans/phase-0-extraction-guardrails.md b/docs/plans/phase-0-extraction-guardrails.md new file mode 100644 index 0000000..cdc975a --- /dev/null +++ b/docs/plans/phase-0-extraction-guardrails.md @@ -0,0 +1,47 @@ +# Phase 0 Extraction Guardrails + +**Scope:** Contract extraction from Next.js app code (`interfacectl generate-contract`). Phase-scoped and revisitable. + +--- + +## No heavy AST in Phase 0 + +We avoid Babel, TypeScript compiler API, or other full AST frameworks in Phase 0 for: + +- **Determinism** — Filesystem walks and regex are predictable; AST tooling can vary by version and config. +- **Debuggability** — Extraction logic is easy to trace; failures map directly to file paths and patterns. +- **Minimal dependency surface** — No parser or transpiler deps; faster installs and fewer version skew risks. + +--- + +## What we extract (Phase 0) + +| Signal | Method | +|-------|--------| +| Routes | `readdir` over `app/`; page.tsx, route.ts patterns | +| Layout shell | `access()` for `app/layout.tsx`, `app/(shell)/layout.tsx` | +| Design system usage | Regex scan for `@surfaces/ui` imports in .tsx/.ts/.jsx/.js | +| Auth posture | `access()` for `app/auth/` directory | + +--- + +## What we do not extract (Phase 0) + +- JSX structure, component trees, or props +- Section IDs from `data-contract-section` or similar +- Fonts, colors, or layout values from CSS/JS +- Dynamic route params or middleware + +These require full parsing. Omit and emit a warning in the extraction report. + +--- + +## When to add AST tooling + +Add Babel, ts-morph, or similar **only when**: + +1. A concrete extraction requirement is approved (e.g. "extract section IDs from data attributes"). +2. Regex or filesystem-based extraction demonstrably fails for that requirement. +3. A follow-up plan documents the new dependency and determinism guarantees. + +This is a default, not a block. Later phases may introduce AST-based extraction under a separate plan.