From a6a9d99a93a3b1ec035e281025db32b826f25831 Mon Sep 17 00:00:00 2001 From: ThePlenkov Date: Thu, 22 Oct 2026 14:54:07 +0000 Subject: [PATCH] feat(adt-plugin-gcts): spec file-name grammar + full source-extension coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseGctsFilename: .properties files get their spec grammar — {name}.{type}.{lang}.properties and {name}.{type}.{content_type}.{lang}.properties (PROG selections/ headings/texts). Previously the 5-part shape misparsed the type segment into the name; every upstream example filename now parses. - TYPE_TO_SOURCE_EXT: full spec surface — .acds for all CDS-family stubs (ddla ddlx dtsc dtix dteb dtdc dras drty dsfd), .csn (csnm), .xdp (sfpf); .ddic + .properties recognized in parse. - i18n.propertiesFilename: optional content-type segment. - deserializer SOURCE_EXTENSIONS: acds/abdl/csn/xdp/ddic/properties so companion files are collected, not silently skipped. tests/aff/filename-grammar.test.ts: 10/10 incl. whole-corpus sweep; existing format tests 28/28 unchanged. Divergence noted for W5: gCTS .asddls/.asdcls kept for DDLS/DCLS (test-pinned) though upstream AFF examples uniformly use .acds. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../changes/add-aff-full-coverage/tasks.md | 3 +- .../adt-plugin-gcts/src/lib/deserializer.ts | 107 ++++++++- .../src/lib/format/filename.ts | 122 +++++++--- .../adt-plugin-gcts/src/lib/format/i18n.ts | 38 +++- .../tests/aff/filename-grammar.test.ts | 208 ++++++++++++++++++ 5 files changed, 433 insertions(+), 45 deletions(-) create mode 100644 packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts diff --git a/openspec/changes/add-aff-full-coverage/tasks.md b/openspec/changes/add-aff-full-coverage/tasks.md index 8cab39155..8d294e4c6 100644 --- a/openspec/changes/add-aff-full-coverage/tasks.md +++ b/openspec/changes/add-aff-full-coverage/tasks.md @@ -5,7 +5,8 @@ - [x] 0.1 Vendored schemas — already done via `git_modules/abap-file-formats` submodule (pinned `5ed949a`); document the bump-to-sync workflow in the package AGENTS.md. - [x] 0.2 Wire existing `scripts/generate-aff-schemas.ts` into `nx codegen adt-plugin-gcts` → `src/schemas/generated/{type}-v{n}.ts` typed models, one file per schema version. - [ ] 0.3 AFF handler contract — gcts `createHandler` `toMetadata`/`fromMetadata` already is the contract; extend with `sourceExt`/`companions` declaration for source-bearing types. abapGit `toAffJson` stays a Wave-4 parity concern inside that plugin only. -- [x] 0.4 ajv-based schema-validation harness — shared `affValidate(type, json, version)` reading schemas from `git_modules/` + golden-fixture runner over the submodule's `examples/` (match example→schema by `formatVersion` field; `.docu.`/`.reps.` files use their own companion schemas). Emit-side round-trip tests that pass handler-emitted metadata through `affValidate` land with the generic emitters in Waves 1–3 (`tests/aff/generic-roundtrip.test.ts`). +- [x] 0.4 ajv-based schema-validation harness — shared `affValidate(type, json, version)` reading schemas from `git_modules/` + golden-fixture runner over the submodule's `examples/` (match example→schema by `formatVersion` field; `.docu.` files route to the docu companion schema). Emit-side round-trip tests that pass handler-emitted metadata through `affValidate` land with the generic emitters in Waves 1–3 (`tests/aff/generic-roundtrip.test.ts`). +- [ ] 0.4a Route `.reps.`/`.func.` LIMU sub-object examples to their nested schemas (`fugr/func-v1.json` etc.) — currently counted but not schema-validated. - [x] 0.5 `aff-coverage` nx target on `adt-plugin-gcts` — emits `type × [handler-kind, versions, body-fields, examples, schema-validated]` matrix; fails under threshold. ## 1. Enrich stubs — AFF families (Wave 1) diff --git a/packages/adt-plugin-gcts/src/lib/deserializer.ts b/packages/adt-plugin-gcts/src/lib/deserializer.ts index fd74383d5..083b0649e 100644 --- a/packages/adt-plugin-gcts/src/lib/deserializer.ts +++ b/packages/adt-plugin-gcts/src/lib/deserializer.ts @@ -29,8 +29,19 @@ import { createAdk, type AdkObject, type AdtClient } from '@abapify/adk'; import { getHandler, getSupportedTypes } from './handlers/base'; import { parseGctsFilename, PACKAGE_FILENAME } from './format/filename'; -/** Source-file extensions emitted by gCTS handlers. */ -const SOURCE_EXTENSIONS = new Set(['abap', 'asddls', 'asdcls']); +/** Source-file extensions emitted by gCTS handlers. `.properties` + * translation files are deliberately excluded — they are companions + * routed into `data.properties`, never ABAP source staging. */ +const SOURCE_EXTENSIONS = new Set([ + 'abap', + 'asddls', + 'asdcls', + 'acds', + 'abdl', + 'csn', + 'xdp', + 'ddic', +]); /** * Map numeric abapLanguageVersion codes (`'5'` etc.) to the descriptive @@ -53,6 +64,10 @@ interface ObjectFiles { type: string; metadataFile?: string; sourceFiles: Array<{ path: string; suffix?: string }>; + /** `{name}.{type}.{docu}.json` companion — not the metadata anchor. */ + docuFile?: string; + /** `.properties` translations keyed by suffix (`en`, `texts.en`, `''`). */ + propertyFiles: Array<{ path: string; suffix?: string }>; } /** @@ -98,9 +113,27 @@ export async function* deserialize( name: parsed.name.toUpperCase(), type: parsed.type.toUpperCase(), sourceFiles: [], + propertyFiles: [], }); } - objectMap.get(key)!.metadataFile = jsonPath; + // `{name}.{type}.docu.json` is a DOCU companion, not a second + // metadata anchor — without this it can overwrite metadataFile + // depending on glob order. + if (parsed.suffix === 'docu') { + objectMap.get(key)!.docuFile = jsonPath; + } else { + objectMap.get(key)!.metadataFile = jsonPath; + } + } + + // `.properties` translation companions — routed into `data.properties` + // so they round-trip through metadata, not source staging. + for (const path of await fileTree.glob('**/*.properties')) { + const filename = path.split('/').pop()!; + const parsed = parseGctsFilename(filename); + if (!parsed) continue; + const key = `${parsed.name.toUpperCase()}:${parsed.type.toUpperCase()}`; + objectMap.get(key)?.propertyFiles.push({ path, suffix: parsed.suffix }); } // Collect every source file (.abap/.asddls/.asdcls) and group by name:type. @@ -119,6 +152,38 @@ export async function* deserialize( } } + // Pre-read all companion contents in bounded batches — sequential + // `await`s inside the object loop both serialize I/O and trip static + // analysis, while an unbounded Promise.all over thousands of files can + // exhaust file descriptors (EMFILE). Per-path failures are recorded + // (not thrown) so one unreadable file fails only its owning object + // inside the per-object try/catch below. + const companionContents = new Map(); + const readErrors = new Map(); + const companionPaths = [...objectMap.values()].flatMap((o) => [ + ...(o.docuFile ? [o.docuFile] : []), + ...o.propertyFiles.map((f) => f.path), + ...o.sourceFiles.map((f) => f.path), + ]); + const READ_BATCH = 32; + let cursor = 0; + const readNext = async (): Promise => { + const path = companionPaths[cursor++]; + if (path === undefined) return; + try { + companionContents.set(path, await fileTree.read(path)); + } catch (error) { + readErrors.set(path, error); + } + return readNext(); + }; + await Promise.all( + Array.from( + { length: Math.min(READ_BATCH, companionPaths.length) }, + readNext, + ), + ); + for (const [, objFiles] of objectMap) { if (!objFiles.metadataFile) continue; @@ -131,10 +196,21 @@ export async function* deserialize( // metadata passed to fromMetadata. const metadata = handler.schema.parse(raw); + // Surface any companion read failure here, inside the per-object + // try — one bad file fails only this object, not the generator. + for (const path of [ + ...(objFiles.docuFile ? [objFiles.docuFile] : []), + ...objFiles.propertyFiles.map((f) => f.path), + ...objFiles.sourceFiles.map((f) => f.path), + ]) { + if (readErrors.has(path)) throw readErrors.get(path); + } + // Read sources, mapping suffix → source key via handler map. const sources: Record = {}; for (const { path, suffix } of objFiles.sourceFiles) { - const content = await fileTree.read(path); + const content = companionContents.get(path); + if (content === undefined) continue; const sourceKey = suffix ? (handler.suffixToSourceKey?.[suffix] ?? suffix) : 'main'; @@ -149,6 +225,29 @@ export async function* deserialize( ? handler.fromAbapGit(metadata) : { name: objFiles.name }; + // DOCU companion → `data.docu` (genericGetExtraFiles re-emits it). + const docuRaw = objFiles.docuFile + ? companionContents.get(objFiles.docuFile) + : undefined; + if (docuRaw !== undefined) { + try { + payload.docu = JSON.parse(docuRaw) as unknown; + } catch { + payload.docu = docuRaw; + } + } + + // `.properties` translations → `data.properties` keyed by suffix + // ('' = master file, 'en', 'texts.en', …). + if (objFiles.propertyFiles.length > 0) { + const props: Record = {}; + for (const { path, suffix } of objFiles.propertyFiles) { + const content = companionContents.get(path); + if (content !== undefined) props[suffix ?? ''] = content; + } + payload.properties = props; + } + // AFF metadata frequently omits the object name (it's encoded in the // filename). Fall back to the filename-derived name. const objectName = payload.name || objFiles.name; diff --git a/packages/adt-plugin-gcts/src/lib/format/filename.ts b/packages/adt-plugin-gcts/src/lib/format/filename.ts index a63cd2737..c4e11f927 100644 --- a/packages/adt-plugin-gcts/src/lib/format/filename.ts +++ b/packages/adt-plugin-gcts/src/lib/format/filename.ts @@ -31,6 +31,19 @@ const TYPE_TO_SOURCE_EXT: Record = { DCLS: 'asdcls', BDEF: 'abdl', SRVD: 'acds', + // AFF-`.acds` CDS-family sources (upstream examples are uniform: + // `..acds` for every CDS-bearing type). + DDLA: 'acds', + DDLX: 'acds', + DTSC: 'acds', + DTIX: 'acds', + DTEB: 'acds', + DTDC: 'acds', + DRAS: 'acds', + DRTY: 'acds', + DSFD: 'acds', + CSNM: 'csn', + SFPF: 'xdp', }; /** Metadata extension for all AFF/gCTS objects. */ @@ -64,6 +77,19 @@ export function gctsFilename( : `${base}.${typeLower}.${ext}`; } +/** Source/companion extensions recognised by the spec file-name grammar. */ +const KNOWN_SOURCE_EXTS = new Set([ + 'abap', + 'asddls', + 'asdcls', + 'abdl', + 'acds', + 'csn', + 'xdp', + 'ddic', + 'properties', +]); + /** * Parse an AFF/gCTS filename back into its components. * @@ -75,53 +101,80 @@ export function parseGctsFilename( ): ParsedFormatFilename | undefined { // Fixed package filename. if (filename === PACKAGE_FILENAME) { - return { - name: '', - type: 'DEVC', - extension: METADATA_EXTENSION, - }; + return { name: '', type: 'DEVC', extension: METADATA_EXTENSION }; } const parts = filename.split('.'); - if (parts.length < 3) { - return undefined; - } + if (parts.length < 3) return undefined; const extension = parts.at(-1)!; - const maybeSuffix = parts.length >= 4 ? parts.at(-2) : undefined; - const nameParts = maybeSuffix ? parts.slice(0, -3) : parts.slice(0, -2); + if (extension === METADATA_EXTENSION) return parseMetadataName(parts); + if (extension === 'properties') return parsePropertiesName(parts); + return parseSourceName(parts, extension); +} - if (nameParts.length === 0) { - return undefined; +/** + * `{name}.{type}.json` metadata files, plus the DOCU companion form + * `{name}.{type}.docu.json` which belongs to the parent object. + */ +function parseMetadataName(parts: string[]): ParsedFormatFilename { + if (parts.at(-2) === 'docu' && parts.length >= 4) { + return { + name: parts.slice(0, -3).join('.'), + type: parts.at(-3)!.toUpperCase(), + suffix: 'docu', + extension: METADATA_EXTENSION, + }; } + return { + name: parts.slice(0, -2).join('.'), + type: parts.at(-2)!.toUpperCase(), + extension: METADATA_EXTENSION, + }; +} - // For 3-part filenames (name.type.ext) the "maybeSuffix" branch would - // misclassify a 4-part filename vs a 3-part filename. Use a recognised - // source extension to decide: if ext is 'json' there can't be a suffix. - if (extension === METADATA_EXTENSION) { +/** + * `.properties` text files have their own grammar: + * {name}.{type}.properties — master (3 parts) + * {name}.{type}.{lang}.properties — language (4 parts) + * {name}.{type}.{content_type}.{lang}.properties — e.g. PROG + * selections/headings/texts (5 parts) + * The composite suffix (`de`, `texts.en`) stays opaque — handlers map + * suffixes to source keys via `suffixToSourceKey`. + */ +function parsePropertiesName(parts: string[]): ParsedFormatFilename { + if (parts.length >= 5) { return { - name: nameParts.length === 0 ? '' : parts.slice(0, -2).join('.'), - type: parts.at(-2)!.toUpperCase(), - extension, + name: parts.slice(0, -4).join('.'), + type: parts.at(-4)!.toUpperCase(), + suffix: `${parts.at(-3)}.${parts.at(-2)}`, + extension: 'properties', }; } - - // Source file — suffix is optional. - const knownSourceExts = new Set([ - 'abap', - 'asddls', - 'asdcls', - 'abdl', - 'acds', - 'properties', - ]); - if (!knownSourceExts.has(extension)) { - return undefined; + if (parts.length === 4) { + return { + name: parts.slice(0, -3).join('.'), + type: parts.at(-3)!.toUpperCase(), + suffix: parts.at(-2), + extension: 'properties', + }; } + return { + name: parts.slice(0, -2).join('.'), + type: parts.at(-2)!.toUpperCase(), + extension: 'properties', + }; +} - // Detect suffix: the part before the extension is a suffix only if the part - // before THAT is a valid-looking lowercase type code. Use a narrow heuristic: - // suffix present iff parts.length >= 4. +/** + * `{name}.{type}.{ext}` and `{name}.{type}.{suffix}.{ext}` source files — + * a suffix is present iff there are ≥4 dot-segments. + */ +function parseSourceName( + parts: string[], + extension: string, +): ParsedFormatFilename | undefined { + if (!KNOWN_SOURCE_EXTS.has(extension)) return undefined; if (parts.length >= 4) { return { name: parts.slice(0, -3).join('.'), @@ -130,7 +183,6 @@ export function parseGctsFilename( extension, }; } - return { name: parts.slice(0, -2).join('.'), type: parts.at(-2)!.toUpperCase(), diff --git a/packages/adt-plugin-gcts/src/lib/format/i18n.ts b/packages/adt-plugin-gcts/src/lib/format/i18n.ts index 2c8385122..7c1eac54a 100644 --- a/packages/adt-plugin-gcts/src/lib/format/i18n.ts +++ b/packages/adt-plugin-gcts/src/lib/format/i18n.ts @@ -43,11 +43,34 @@ export function propertiesFilename( name: string, type: string, language?: string, + contentType?: string, ): string { + // A content-type segment without a language would emit the ambiguous + // 4-part `{name}.{type}.{ct}.properties`, which reparses as a language + // file — the AFF grammar only defines 3-, 4- (lang) and 5-part forms. + if (contentType && !language) { + throw new Error( + 'propertiesFilename: contentType requires a language — ' + + 'the 4-part `{name}.{type}.{content_type}.properties` form is ambiguous', + ); + } + // Filename segments are dot-separated — a dotted value would parse back + // with a different suffix/language than it was emitted with. + for (const [label, seg] of [ + ['contentType', contentType], + ['language', language], + ] as const) { + if (seg?.includes('.')) { + throw new Error( + `propertiesFilename: ${label} must be a single filename segment, got "${seg}"`, + ); + } + } const base = gctsFilename(name, type, 'metadata').replace(/\.json$/, ''); - return language - ? `${base}.${language.toLowerCase()}.properties` - : `${base}.properties`; + const segments = [base]; + if (contentType) segments.push(contentType.toLowerCase()); + if (language) segments.push(language.toLowerCase()); + return `${segments.join('.')}.properties`; } /** @@ -57,18 +80,23 @@ export function propertiesFilename( * @param type ABAP object type code * @param translations Map of language code → entries. The key '' (empty * string) represents the master / default language. + * @param contentType Optional content-type segment (e.g. `texts`, + * `selections`, `headings`) — emits the 5-part + * `{name}.{type}.{content_type}.{lang}.properties` + * form and therefore requires a non-empty language key. */ export function createPropertiesFiles( name: string, type: string, translations: Record, + contentType?: string, ): PropertiesFile[] { const files: PropertiesFile[] = []; for (const [lang, entries] of Object.entries(translations)) { if (!entries || entries.length === 0) continue; const path = lang - ? propertiesFilename(name, type, lang) - : propertiesFilename(name, type); + ? propertiesFilename(name, type, lang, contentType) + : propertiesFilename(name, type, undefined, contentType); files.push({ path, content: buildProperties(entries) }); } return files; diff --git a/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts b/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts new file mode 100644 index 000000000..feccaa403 --- /dev/null +++ b/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts @@ -0,0 +1,208 @@ +/** + * AFF file-name grammar conformance — driven by real filenames found in + * the upstream examples, not invented shapes. `.properties` text files + * carry an optional content-type + BCP47 language segment; source + * extensions cover the full spec surface (.abap .abdl .acds .csn .ddic + * .xdp .properties). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + parseGctsFilename, + gctsFilename, +} from '../../src/lib/format/filename.ts'; + +const AFF_EXAMPLES = join( + dirname(fileURLToPath(import.meta.url)), + '../../../../git_modules/abap-file-formats/file-formats', +); + +describe('parseGctsFilename — upstream example shapes', () => { + it('name.type.texts.en.properties (5-part content-type+lang)', () => { + const p = parseGctsFilename('z_aff_example_prog.prog.texts.en.properties'); + assert.equal(p?.name, 'z_aff_example_prog'); + assert.equal(p?.type, 'PROG'); + assert.equal(p?.suffix, 'texts.en'); + assert.equal(p?.extension, 'properties'); + }); + + it('name.clas.texts.en.properties', () => { + const p = parseGctsFilename('z_aff_example_clas.clas.texts.en.properties'); + assert.equal(p?.name, 'z_aff_example_clas'); + assert.equal(p?.type, 'CLAS'); + assert.equal(p?.suffix, 'texts.en'); + }); + + it('name.fugr.texts.en.properties', () => { + const p = parseGctsFilename('z_aff_example_fugr.fugr.texts.en.properties'); + assert.equal(p?.name, 'z_aff_example_fugr'); + assert.equal(p?.type, 'FUGR'); + }); + + it('prog selections/headings content types', () => { + for (const ct of ['selections', 'headings', 'texts']) { + const p = parseGctsFilename( + `z_aff_example_prog.prog.${ct}.en.properties`, + ); + assert.equal(p?.type, 'PROG'); + assert.equal(p?.suffix, `${ct}.en`); + } + }); + + it('abapcatalog.ddla.acds (ddla CDS source)', () => { + const p = parseGctsFilename('abapcatalog.ddla.acds'); + assert.equal(p?.name, 'abapcatalog'); + assert.equal(p?.type, 'DDLA'); + assert.equal(p?.extension, 'acds'); + }); + + it('z_aff_example_csnm.csnm.model_inferred.csn', () => { + const p = parseGctsFilename('z_aff_example_csnm.csnm.model_inferred.csn'); + assert.equal(p?.type, 'CSNM'); + assert.equal(p?.suffix, 'model_inferred'); + assert.equal(p?.extension, 'csn'); + }); + + it('z_aff_example_sfpf.sfpf.xdp (Adobe form layout)', () => { + const p = parseGctsFilename('z_aff_example_sfpf.sfpf.xdp'); + assert.equal(p?.type, 'SFPF'); + assert.equal(p?.extension, 'xdp'); + }); + + it('LIMU sub-object: fugr.reps.json parses as metadata', () => { + const p = parseGctsFilename('z_aff_example_fugr.fugr.reps.json'); + assert.equal(p?.extension, 'json'); + }); +}); + +describe('gctsFilename — spec extension coverage', () => { + it('maps every example-evidenced source extension', () => { + const cases: Array<[string, string]> = [ + ['ddla', 'acds'], + ['ddlx', 'acds'], + ['dtsc', 'acds'], + ['dtix', 'acds'], + ['dteb', 'acds'], + ['dtdc', 'acds'], + ['dras', 'acds'], + ['drty', 'acds'], + ['dsfd', 'acds'], + ['csnm', 'csn'], + ['sfpf', 'xdp'], + ['bdef', 'abdl'], + ['clas', 'abap'], + ]; + for (const [type, ext] of cases) { + assert.equal( + gctsFilename('z_obj', type, 'source'), + `z_obj.${type}.${ext}`, + `${type} source ext`, + ); + } + }); +}); + +/** + * Independent spec-based partition — the assertions below compare the + * parser against THIS reference, so a wrong-but-self-consistent parse + * (e.g. `x.doma.docu.json` split as name=`x.doma`+type=`docu`) still fails. + */ +function referenceParse(filename: string): { + name: string; + type: string; + suffix?: string; + extension: string; +} { + const parts = filename.split('.'); + const extension = parts.at(-1)!; + if (extension === 'json') { + if (parts.at(-2) === 'docu' && parts.length >= 4) { + return { + name: parts.slice(0, -3).join('.'), + type: parts.at(-3)!.toUpperCase(), + suffix: 'docu', + extension, + }; + } + return { + name: parts.slice(0, -2).join('.'), + type: parts.at(-2)!.toUpperCase(), + extension, + }; + } + if (extension === 'properties') { + if (parts.length >= 5) { + return { + name: parts.slice(0, -4).join('.'), + type: parts.at(-4)!.toUpperCase(), + suffix: `${parts.at(-3)}.${parts.at(-2)}`, + extension, + }; + } + if (parts.length === 4) { + return { + name: parts.slice(0, -3).join('.'), + type: parts.at(-3)!.toUpperCase(), + suffix: parts.at(-2), + extension, + }; + } + return { + name: parts.slice(0, -2).join('.'), + type: parts.at(-2)!.toUpperCase(), + extension, + }; + } + if (parts.length >= 4) { + return { + name: parts.slice(0, -3).join('.'), + type: parts.at(-3)!.toUpperCase(), + suffix: parts.at(-2), + extension, + }; + } + return { + name: parts.slice(0, -2).join('.'), + type: parts.at(-2)!.toUpperCase(), + extension, + }; +} + +describe('upstream corpus — every example filename parses', () => { + it('no example filename is unparsable', (t) => { + if (!existsSync(AFF_EXAMPLES)) { + t.diagnostic('AFF submodule not checked out — skipping corpus sweep'); + return; + } + const wrong: string[] = []; + const walk = (dir: string): void => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (dir.includes('examples') && e.name.includes('.')) { + const p = parseGctsFilename(e.name); + const expected = referenceParse(e.name); + // LIMU sub-object files ({parent}.{ptype}.{sub}.{stype}.{ext}) + // get a full partition only in the wave-4 grammar — here they + // must at least classify, not be checked field-for-field. + const limu = /\.(reps|func)\./.test(e.name); + const ok = limu + ? !!p && p.extension === expected.extension + : !!p && + p.name === expected.name && + p.type === expected.type && + p.suffix === expected.suffix && + p.extension === expected.extension; + if (!ok) wrong.push(`${e.name} → ${JSON.stringify(p)}`); + } + } + }; + walk(AFF_EXAMPLES); + // README/other non-example files are excluded by the examples-dir check + assert.deepEqual(wrong, []); + }); +});