Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions openspec/changes/add-corpus-cross-implementation-join/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Change: Corpus cross-implementation suite join keys

## Why

The tests-renderer matrix page consumes the cross-impl suite repo's published
results JSON keyed by suite scenario id. Corpus entry ids are file/line-derived
(`scripts/build_tests_corpus.mjs`) and are unusable as cross-repo join keys, so
a renderer cannot place per-test-page "Other implementations" rows next to a
safe-docx scenario. The corpus contract needs an explicit, optional place to
carry the suite scenario ids a given test corresponds to. (Ref: #391, #283.)

## What Changes

- Add an optional `crossImplementation: { suiteScenarioIds: string[] }` field to
each corpus entry in `tests-corpus.schema.json`. Additive and optional — entries
without the field stay valid.
- Add a `@suiteScenarioIds` narrative JSDoc tag (a comma/space-separated list of
suite scenario ids), parsed statically by the AST extractor. It is a list of
join keys, not prose, so it lives outside the word-count `tagDefinitions` and
outside the entry `narrative` object.
- Populate the entry's `crossImplementation` from the parsed tag in
`scripts/build_tests_corpus.mjs`, emitting the field only when the tag is present.

## Impact

- Affected specs: test-corpus-narrative
- Affected code: `packages/test-narrative/src/tagSchema.ts`,
`packages/test-narrative/src/astExtractor.ts`,
`scripts/generate_tests_corpus_schema.mjs`,
`scripts/build_tests_corpus.mjs`, `tests-corpus.schema.json`
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# test-corpus-narrative Specification (delta)

## ADDED Requirements

### Requirement: Corpus entries carry cross-implementation suite join keys

The corpus schema SHALL permit each corpus entry to carry an optional
`crossImplementation` object whose `suiteScenarioIds` array lists the
cross-implementation suite scenario ids the test corresponds to. The field is
the renderer-facing join key between a
safe-docx corpus entry and the cross-impl suite repo's published results JSON,
because corpus entry ids are file/line-derived and unusable as cross-repo keys.
The field is optional and additive: entries without it remain valid against
`tests-corpus.schema.json`, and when present the array MUST contain at least one
non-empty id with no duplicates.

The ids SHALL be authored as a `@suiteScenarioIds` JSDoc tag above the
`test.openspec(...)(...)` call, holding a comma- or whitespace-separated list of
ids. The AST extractor SHALL parse the tag statically into a string array. The
join keys are not prose, so they MUST NOT be subject to the narrative word-count
tag rules and MUST NOT appear inside the entry's `narrative` object.
`scripts/build_tests_corpus.mjs` SHALL emit `crossImplementation` only when the
tag is present.

#### Scenario: suite scenario ids are extracted from the tag

- **GIVEN** a test with a `@suiteScenarioIds docx/track-changes/a, docx/track-changes/b` JSDoc tag
- **WHEN** the AST extractor processes the test
- **THEN** the scenario evidence SHALL include a `suiteScenarioIds` array equal to
`["docx/track-changes/a", "docx/track-changes/b"]`
- **AND** the parsed `narrative` object SHALL NOT contain a `suiteScenarioIds` key

#### Scenario: corpus omits the field when the tag is absent

- **GIVEN** a test with no `@suiteScenarioIds` tag
- **WHEN** `scripts/build_tests_corpus.mjs` emits the corpus entry
- **THEN** the entry SHALL NOT include a `crossImplementation` field
- **AND** the entry SHALL remain valid against `tests-corpus.schema.json`

#### Scenario: schema accepts the optional field

- **GIVEN** a corpus entry that includes `crossImplementation` with a non-empty
`suiteScenarioIds` array
- **WHEN** the entry is validated against the generated `tests-corpus.schema.json`
- **THEN** validation SHALL pass
- **AND** an entry that omits `crossImplementation` SHALL also pass
13 changes: 13 additions & 0 deletions openspec/changes/add-corpus-cross-implementation-join/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## 1. Implementation

- [x] 1.1 Add `@suiteScenarioIds` tag name + Zod validator to `tagSchema.ts`
- [x] 1.2 Parse `@suiteScenarioIds` into `ScenarioEvidence.suiteScenarioIds` in `astExtractor.ts`
- [x] 1.3 Add optional `crossImplementation.suiteScenarioIds` to the generated schema
- [x] 1.4 Populate `crossImplementation` in `build_tests_corpus.mjs` when present
- [x] 1.5 Regenerate and commit `tests-corpus.schema.json`

## 2. Tests

- [x] 2.1 AST extractor test: `@suiteScenarioIds` parses to a string array
- [x] 2.2 tagSchema test: suite-scenario-ids validator accepts/rejects ids
- [x] 2.3 Generator test: emitted schema carries optional `crossImplementation`
46 changes: 46 additions & 0 deletions packages/test-narrative/src/astExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,52 @@ describe("extractScenarios", () => {
expect(Object.keys(scenario!.narrative).sort()).toEqual(["motivatingProblem"]);
});

it("extracts @suiteScenarioIds as a string array outside the narrative object", () => {
const filePath = writeFixture(`
const test = testAllure.epic("DOCX Primitives").withLabels({ feature: "Track Changes", visibility: "public" });

/**
* @suiteScenarioIds docx/track-changes/a, docx/track-changes/b
* @motivatingProblem ${words(60)}
*/
test.openspec("join keys")("Scenario: cross-impl join", async () => {});
`);

const [scenario] = extractScenarios(filePath);

expect(scenario?.suiteScenarioIds).toEqual(["docx/track-changes/a", "docx/track-changes/b"]);
expect(scenario?.narrative as Record<string, unknown>).not.toHaveProperty("suiteScenarioIds");
expect(scenario?.narrative).toEqual({ motivatingProblem: words(60) });
});

it("splits @suiteScenarioIds on commas and whitespace across multiple lines", () => {
const filePath = writeFixture(`
const test = testAllure.epic("DOCX Primitives").withLabels({ feature: "Track Changes" });

/**
* @suiteScenarioIds docx/one
* docx/two,docx/three
*/
test.openspec("multiline")("Scenario: multiline ids", async () => {});
`);

const [scenario] = extractScenarios(filePath);

expect(scenario?.suiteScenarioIds).toEqual(["docx/one", "docx/two", "docx/three"]);
});

it("omits suiteScenarioIds when no @suiteScenarioIds tag is present", () => {
const filePath = writeFixture(`
const test = testAllure.epic("DOCX Primitives").withLabels({ feature: "Track Changes" });

test.openspec("none")("Scenario: no join keys", async () => {});
`);

const [scenario] = extractScenarios(filePath);

expect(scenario?.suiteScenarioIds).toBeUndefined();
});

it("preserves rejected aliases in the narrative so the validator can report them explicitly", () => {
// The extractor distinguishes "unknown JSDoc tag" (drop) from
// "rejected alias the schema knows about" (keep, so the validator can
Expand Down
86 changes: 63 additions & 23 deletions packages/test-narrative/src/astExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import fs from "node:fs";
import { parse } from "@typescript-eslint/parser";
import type { TSESTree } from "@typescript-eslint/types";

import { rejectedAliases, tagDefinitions, type NarrativeVisibility, type TagName } from "./tagSchema.js";
import {
rejectedAliases,
SUITE_SCENARIO_IDS_TAG,
tagDefinitions,
type NarrativeVisibility,
type TagName
} from "./tagSchema.js";

const KNOWN_NARRATIVE_KEYS = new Set<string>([
...Object.keys(tagDefinitions),
Expand Down Expand Up @@ -51,6 +57,7 @@ export type ScenarioEvidence = {
sourceRef: SourceRef;
visibility?: NarrativeVisibility;
narrative: Partial<Record<TagName, string>>;
suiteScenarioIds?: string[];
bddSteps: BddStepEvidence[];
fixtures: FixtureEvidence[];
expectArgs: ExpectArgEvidence[];
Expand Down Expand Up @@ -204,40 +211,71 @@ function evidenceForExpression(
};
}

type JsDocTag = { tag: string; lines: string[] };

/**
* Canonical JSDoc-tag parser shared by every narrative derivation.
*
* Splits a block comment into an ordered list of `{ tag, lines }` entries:
* the opening line's post-tag text plus any continuation lines up to the next
* tag. Repeated tags yield repeated entries (callers decide whether to
* accumulate or last-win). Lines before the first tag are ignored. Keeping
* this loop in one place means `extractNarrative` and `extractSuiteScenarioIds`
* cannot drift in how they recognize tags or strip the leading `* ` gutter.
*/
function parseJsDocTags(commentValue: string | undefined): JsDocTag[] {
const tags: JsDocTag[] = [];
if (!commentValue) return tags;

let current: JsDocTag | undefined;
for (const rawLine of commentValue.split("\n")) {
const line = rawLine.replace(/^\s*\* ?/, "").trimEnd();
const tagMatch = line.match(/^@([A-Za-z][\w-]*)\s*(.*)$/);
if (tagMatch) {
current = { tag: tagMatch[1] ?? "", lines: [tagMatch[2] ?? ""] };
tags.push(current);
continue;
}
if (current) current.lines.push(line.trim());
}

return tags;
}

function extractNarrative(commentValue: string | undefined): Partial<Record<TagName, string>> {
const narrative: Record<string, string> = {};
if (!commentValue) return narrative;

let currentTag: string | undefined;
let currentLines: string[] = [];
const flush = () => {
if (!currentTag) return;
for (const { tag, lines } of parseJsDocTags(commentValue)) {
// Only emit tags that the schema cares about. Unknown JSDoc tags
// (@see, @example, @deprecated, etc.) are part of normal TS convention
// and must not poison validation. Known-but-rejected aliases stay so the
// downstream validator can produce an explicit "this alias is forbidden"
// error rather than silently dropping it.
if (KNOWN_NARRATIVE_KEYS.has(currentTag)) {
narrative[currentTag] = currentLines.join(" ").replace(/\s+/g, " ").trim();
// error rather than silently dropping it. A repeated tag last-wins.
if (KNOWN_NARRATIVE_KEYS.has(tag)) {
narrative[tag] = lines.join(" ").replace(/\s+/g, " ").trim();
}
};

for (const rawLine of commentValue.split("\n")) {
const line = rawLine.replace(/^\s*\* ?/, "").trimEnd();
const tagMatch = line.match(/^@([A-Za-z][\w-]*)\s*(.*)$/);
if (tagMatch) {
flush();
currentTag = tagMatch[1];
currentLines = [tagMatch[2] ?? ""];
continue;
}
if (currentTag) currentLines.push(line.trim());
}
flush();

return narrative;
}

function extractSuiteScenarioIds(commentValue: string | undefined): string[] | undefined {
let seen = false;
const parts: string[] = [];
for (const { tag, lines } of parseJsDocTags(commentValue)) {
if (tag !== SUITE_SCENARIO_IDS_TAG) continue;
seen = true;
parts.push(...lines);
}
if (!seen) return undefined;

const ids = parts
.join(" ")
.split(/[\s,]+/)
.map((id) => id.trim())
.filter(Boolean);
return ids.length > 0 ? ids : undefined;
}

function findLeadingJsDoc(
ast: TSESTree.Program,
source: string,
Expand Down Expand Up @@ -426,11 +464,13 @@ export function extractScenarios(filePath: string): ScenarioEvidence[] {
const comment = findLeadingJsDoc(ast, source, node);
const body = collectScenarioBody(node);
const evidence = extractBodyEvidence(body, filePath, source);
const suiteScenarioIds = extractSuiteScenarioIds(comment?.value);
scenarios.push({
scenarioName: extractScenarioName(node, source),
sourceRef: sourceRefFor(filePath, node),
visibility: visibilityForScenarioCall(node, openspecCall, fileBindings),
narrative: extractNarrative(comment?.value),
...(suiteScenarioIds ? { suiteScenarioIds } : {}),
...evidence
});
});
Expand Down
3 changes: 3 additions & 0 deletions packages/test-narrative/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export {
CANONICAL_SECTION_ORDER,
rejectedAliases,
SUITE_SCENARIO_IDS_TAG,
suiteScenarioIdsSchema,
tagDefinitions,
tagSchema,
validateTags,
type SuiteScenarioIds,
type TagName,
type NarrativeTags,
type NarrativeVisibility,
Expand Down
28 changes: 28 additions & 0 deletions packages/test-narrative/src/tagSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { describe, expect } from "vitest";
import {
CANONICAL_SECTION_ORDER,
rejectedAliases,
SUITE_SCENARIO_IDS_TAG,
suiteScenarioIdsSchema,
tagDefinitions,
tagSchema,
validateTags,
Expand Down Expand Up @@ -143,4 +145,30 @@ describe("tagSchema", () => {
expect(CANONICAL_SECTION_ORDER).toContain(tagName);
}
});

it("keeps the suite-scenario-id tag outside the prose tag definitions", () => {
expect(SUITE_SCENARIO_IDS_TAG).toBe("suiteScenarioIds");
expect(Object.keys(tagDefinitions)).not.toContain(SUITE_SCENARIO_IDS_TAG);
});

it("accepts a non-empty list of unique suite scenario ids", () => {
expect(suiteScenarioIdsSchema.safeParse(["docx/a", "docx/b"]).success).toBe(true);
});

it("rejects an empty suite-scenario-id list", () => {
expect(suiteScenarioIdsSchema.safeParse([]).success).toBe(false);
});

it("rejects blank suite scenario ids", () => {
expect(suiteScenarioIdsSchema.safeParse(["docx/a", " "]).success).toBe(false);
});

it("rejects duplicate suite scenario ids", () => {
const result = suiteScenarioIdsSchema.safeParse(["docx/a", "docx/a"]);

expect(result.success).toBe(false);
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain("duplicates");
}
});
});
16 changes: 16 additions & 0 deletions packages/test-narrative/src/tagSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ export const tagDefinitions = {

export type TagName = keyof typeof tagDefinitions;

// Cross-implementation suite join keys. Authored as a `@suiteScenarioIds`
// JSDoc tag (comma/whitespace-separated list), these are renderer-facing join
// keys between a corpus entry and the cross-impl suite repo's results JSON.
// They are NOT prose, so they live outside `tagDefinitions` (no word counts)
// and outside the entry `narrative` object.
export const SUITE_SCENARIO_IDS_TAG = "suiteScenarioIds";

export const suiteScenarioIdsSchema = z
.array(z.string().trim().min(1).max(200))
.min(1)
.refine((ids) => new Set(ids).size === ids.length, {
message: "suiteScenarioIds must not contain duplicates"
});

export type SuiteScenarioIds = z.infer<typeof suiteScenarioIdsSchema>;

export const rejectedAliases = [
"limitation",
"aiContext",
Expand Down
20 changes: 20 additions & 0 deletions scripts/build_tests_corpus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Ajv from 'ajv';
import {
CANONICAL_SECTION_ORDER,
extractScenarios,
suiteScenarioIdsSchema,
validateTags,
} from '../packages/test-narrative/dist/index.js';
import { loadRegistry } from './lib/conformance-registry.mjs';
Expand Down Expand Up @@ -241,6 +242,23 @@ function sectionsForEntry(scenario, result, conformanceClaims) {
return CANONICAL_SECTION_ORDER.filter((section) => present.has(section));
}

function buildCrossImplementation(fileRel, scenario) {
// Renderer-facing join keys between this corpus entry and the cross-impl
// suite repo. Authored as a `@suiteScenarioIds` JSDoc tag; emitted only when
// present so entries without it stay clean and schema-valid.
if (!scenario.suiteScenarioIds) return undefined;
const parsed = suiteScenarioIdsSchema.safeParse(scenario.suiteScenarioIds);
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
.join('; ');
throw new Error(
`${fileRel}:${scenario.sourceRef.line}: invalid @suiteScenarioIds tag: ${issues}`,
);
}
return { suiteScenarioIds: [...parsed.data] };
}

function buildCorpusEntries() {
const registry = loadRegistry();
if (registry.errors.length > 0) {
Expand Down Expand Up @@ -306,12 +324,14 @@ function buildCorpusEntries() {

const conformanceClaims = resolveConformanceClaims(matchedResult.result, registry);
const results = serializeResult(matchedResult.result);
const crossImplementation = buildCrossImplementation(file.rel, scenario);
entries.push({
id: stableEntryId(packageName, scenario),
package: packageName,
scenarioName: normalizeScenarioName(scenario.scenarioName),
sourceRef: serializeSourceRef(scenario.sourceRef),
sections: sectionsForEntry(scenario, results, conformanceClaims),
...(crossImplementation ? { crossImplementation } : {}),
narrative: { ...scenario.narrative },
scenario: {
bddSteps: scenario.bddSteps.map(serializeBddStep),
Expand Down
Loading