import { describe, expect, it } from 'vitest'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname } from 'node:path'; import { REQUEST_REQUIREMENTS, SUPPORT_EMAIL, UNSUPPORTED_CONNECTOR_SHORT, } from '@beehexa/hexasync-template-model'; /** * AD-31 over the source this repository STILL HAS — the half that stayed behind when the guard left. * * ⛔ Added by Epic 2 batch 2b (Story 2.2), 2026-08-19, and it is not a new invariant. * * `packages/hexasync-template-validate/src/__tests__/unsupportedConnectorRequestGuard.spec.ts` asserted that the * unsupported-connector wording has exactly ONE author (`connectorSupport.ts`) and is hand-written nowhere else. Its * sweep walked every `packages//src` **and `apps/cli/src`**, which is 205 of the files it read. Batch 2b moved * `@beehexa/hexasync-template-validate` to `hexasync-templates-vscode-ext`, and the guard travelled with it — so * without this file the claim over `apps/cli/src` would have been enforced NOWHERE, silently, from that commit on. * * That is the same accounting batch 2a did in the other direction: the two AD-21 flow suites left * `packageLayer.spec.ts` here and were re-homed there, because their subject moved. Here the subject SPLIT — the * authoring module is in the other repository, the CLI app that must not re-type it is in this one — so the guard * exists on both sides, each over the roots its own repository holds. Neither is a copy for tidiness: delete either * and a real surface stops being checked. * * ### What it asserts, and what it deliberately does not * * The same narrow claim as its counterpart: the support address and the distinctive words of the four requirements * appear only in the ONE module AD-31 names. It does NOT assert that every surface renders the long form — a * diagnostic legitimately wants the one-line form, and the policy provides both. The invariant is single * AUTHORSHIP, not uniform length. * * The violation this guard family was written for was a PARAPHRASE: `ruleCatalog.ts`'s `CONN-1` entry hand-wrote * *"up-to-date API docs and a sandbox"* against the source's *"API documentation — a link, or an attachment … It * must be up to date"*, and the hand-written copy was the one the compose-report appendix actually rendered. * Whole-string matching would have missed it, which is why the tells below are phrases rather than sentences. */ /** The one module allowed to author the wording. Everything else must import from it. */ const THE_ONE_SOURCE = 'connectorSupport.ts'; /** Files that quote the wording as test DATA — a spec asserting the words must be able to contain them. */ const MAY_QUOTE = new Set(['unsupportedConnectorWording.spec.ts']); /** * ⚠️ Both forms count, and `require.resolve` is how the module is found rather than a written path. * * `@beehexa/hexasync-template-model` left this repository in Epic 2 batch 1, and the manifests here declare the * PUBLISHED version (local-first is a tooling overlay, not a manifest field — `scripts/localPackages.mjs`). A * published tarball ships `dist` only, so the COMPILED `connectorSupport.js` is usually what is on disk here; a * developer running the local overlay gets the `.ts`. The claim is single AUTHORSHIP and the compiled module has the * same author, so either satisfies it. * * No sibling path is written here on purpose: a spec naming one is a spec `corpusCheckout.spec.ts` requires to be * registered out of `test:ci`, and this spec needs no checkout — it needs an installed dependency. * * ⚠️ ONE FILE, never a walk of the package. The rest of a published `dist` is outside this repository's control, and * a guard that can go red because of somebody else's build output is a guard that gets switched off rather than * obeyed. */ const AUTHOR_BASENAMES = new Set([ 'connectorSupport.ts', 'connectorSupport.js', ]); const authoringModule = (): string[] => { try { const root = dirname( dirname( createRequire(import.meta.url).resolve( '@beehexa/hexasync-template-model', ), ), ); for (const candidate of [ `${root}/src/connectorSupport.ts`, `${root}/dist/connectorSupport.js`, ]) { if (existsSync(candidate)) return [candidate]; } return []; } catch { return []; } }; function sourceFilesUnder(root: string): string[] { const out: string[] = []; const queue = [root]; while (queue.length > 0) { const dir = queue.pop()!; for (const name of readdirSync(dir)) { if (name === 'node_modules' || name === 'dist') continue; const full = `${dir}/${name}`; if (statSync(full).isDirectory()) queue.push(full); else if (name.endsWith('.ts')) out.push(full); } } return out; } /** * `apps/cli/src` plus whatever `packages/` still holds, walked from the repo root — derived, never listed. * * ⚠️ Existence-filtered, because Epic 2 is emptying `packages/` batch by batch and Story 2.4 empties it entirely. A * hardcoded root is exactly the shape that does not survive that, and the vacuity floor below is what stops the * filter from turning this file green by looking at less. */ const isDirectory = (path: string): boolean => { try { return statSync(path).isDirectory(); } catch { return false; } }; const files = (() => { const packages = isDirectory('packages') ? readdirSync('packages') .map((name) => `packages/${name}/src`) .filter(isDirectory) : []; return [ ...['apps/cli/src', ...packages] .filter(isDirectory) .flatMap(sourceFilesUnder), ...authoringModule(), ]; })(); describe('the unsupported-connector wording has exactly one author, here too', () => { it('walks a real, non-trivial set of source files', () => { // Vacuity guard. A root that silently matched nothing would make every assertion below pass. `apps/cli/src` // alone is over 200 files, so this floor does not depend on how much of `packages/` Epic 2 has moved yet. expect(files.length).toBeGreaterThan(100); expect( files.some((f) => AUTHOR_BASENAMES.has(f.split('/').pop()!)), `${THE_ONE_SOURCE} is not in the resolved @beehexa/hexasync-template-model package (as source or as built ` + `output), so every assertion below would pass by not reading the one module allowed to author the wording ` + `(AD-31).`, ).toBe(true); }); it('writes the support address in one module and nowhere else', () => { const offenders = files.filter((file) => { const name = file.split('/').pop()!; if (AUTHOR_BASENAMES.has(name) || MAY_QUOTE.has(name)) return false; return readFileSync(file, 'utf8').includes(SUPPORT_EMAIL); }); expect( offenders, `${SUPPORT_EMAIL} is hand-written outside ${THE_ONE_SOURCE}. Import the wording instead — AD-31.`, ).toEqual([]); }); it('does not let a second file re-type the four requirements', () => { const TELLS = [ 'up-to-date API doc', 'up to date API doc', 'sandbox / test environment', ]; const offenders: string[] = []; for (const file of files) { const name = file.split('/').pop()!; if (AUTHOR_BASENAMES.has(name) || MAY_QUOTE.has(name)) continue; const text = readFileSync(file, 'utf8'); if (TELLS.some((tell) => text.includes(tell))) offenders.push(file); } expect( offenders, `the request requirements are re-typed outside ${THE_ONE_SOURCE}. Import them — AD-31.`, ).toEqual([]); }); it('keeps the one source reachable, so importing it is actually possible', () => { // The other half of AD-31: a single author is no use if consumers cannot reach it. Both forms must be exported // values, not internal constants. expect(REQUEST_REQUIREMENTS).toHaveLength(4); expect(UNSUPPORTED_CONNECTOR_SHORT).toContain(SUPPORT_EMAIL); expect(UNSUPPORTED_CONNECTOR_SHORT.toLowerCase()).toContain('sandbox'); }); });