Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
476 lines
20 KiB
JavaScript
476 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Anchors BOTH halves of the inventory-drift rule (`CLAUDE.md` → Inventory Drift:
|
|
* "requires updating `docs/INVENTORY.md` AND running
|
|
* `node scripts/gen-inventory-manifest.cjs --write`"):
|
|
*
|
|
* 1. `docs/INVENTORY-MANIFEST.json` matches the filesystem — the machine half.
|
|
* Fix by running: node scripts/gen-inventory-manifest.cjs --write
|
|
* 2. `docs/INVENTORY.md` carries a roster row for every manifest entry — the human
|
|
* half (#3762). Fix by hand-writing the row.
|
|
*
|
|
* Until #3762 only half 1 existed, so a PR could ship a surface, regenerate the
|
|
* manifest, omit the roster row, and pass CI green — while `docs/INVENTORY.md:3`
|
|
* called itself "Authoritative roster of every shipped MSD surface" and
|
|
* `docs/INVENTORY.md:681` promised "a new file without a matching row here will fail
|
|
* CI". PR #3758 is the live instance: `msd-core/references/planner-coupling.md`,
|
|
* manifest entry present, roster row absent, CI 30 success / 0 failing.
|
|
*
|
|
* The roster comparison itself lives in `tests/helpers/inventory-roster.cjs` — read
|
|
* its header for exactly which families are enforced, which two are deliberately
|
|
* exempt, and what shapes a row may legally take. The fixture rows at the bottom of
|
|
* this file drive that matcher directly, because the live assertion passes whenever
|
|
* the repo is clean and therefore proves nothing about the comparison on its own.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
const {
|
|
ROSTER_SECTIONS,
|
|
findMissingRosterRows,
|
|
formatRosterFailure,
|
|
} = require('./helpers/inventory-roster.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
|
|
const INVENTORY_PATH = path.join(ROOT, 'docs', 'INVENTORY.md');
|
|
|
|
// #2996: FAMILIES and NESTED_FAMILIES are IMPORTED, never redeclared. This file used to
|
|
// carry its own copy of the family table — the `DEFECT.GENERATIVE-FIX` divergence class:
|
|
// a family added to the generator but not here left this test silently verifying a
|
|
// subset while still reporting green. Importing makes divergence impossible rather than
|
|
// merely detectable.
|
|
const { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs');
|
|
|
|
test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
|
|
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
const additions = [];
|
|
const removals = [];
|
|
|
|
for (const { name, dir, filter, toName } of FAMILIES) {
|
|
const flat = fs.readdirSync(dir)
|
|
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
|
|
.map(toName);
|
|
// `cli_modules` also ships one level of subdirectory modules (#3309); mirror
|
|
// buildManifest's special-case merge exactly, or this test would report every
|
|
// subdirectory file as a phantom removal.
|
|
const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : [];
|
|
const live = new Set([...flat, ...nested]);
|
|
const recorded = new Set((committed.families || {})[name] || []);
|
|
|
|
for (const entry of live) {
|
|
if (!recorded.has(entry)) additions.push(name + '/' + entry);
|
|
}
|
|
for (const entry of recorded) {
|
|
if (!live.has(entry)) removals.push(name + '/' + entry);
|
|
}
|
|
}
|
|
|
|
for (const family of NESTED_FAMILIES) {
|
|
const live = new Set(collectNested(family));
|
|
const recorded = new Set((committed.families || {})[family.name] || []);
|
|
for (const entry of live) {
|
|
if (!recorded.has(entry)) additions.push(family.name + '/' + entry);
|
|
}
|
|
for (const entry of recorded) {
|
|
if (!live.has(entry)) removals.push(family.name + '/' + entry);
|
|
}
|
|
}
|
|
|
|
const msg = [
|
|
additions.length ? 'New surfaces not in manifest (run node scripts/gen-inventory-manifest.cjs --write):\n' + additions.map((e) => ' + ' + e).join('\n') : '',
|
|
removals.length ? 'Manifest entries with no matching file:\n' + removals.map((e) => ' - ' + e).join('\n') : '',
|
|
].filter(Boolean).join('\n');
|
|
|
|
assert.ok(additions.length === 0 && removals.length === 0, msg);
|
|
});
|
|
|
|
// ─── #3762: the roster half ──────────────────────────────────────────────────
|
|
|
|
test('docs/INVENTORY.md carries a roster row for every manifest entry', () => {
|
|
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
const inventory = fs.readFileSync(INVENTORY_PATH, 'utf8');
|
|
const findings = findMissingRosterRows(inventory, committed.families);
|
|
|
|
assert.ok(
|
|
findings.missingSections.length === 0 && findings.missingRows.length === 0,
|
|
formatRosterFailure(findings),
|
|
);
|
|
});
|
|
|
|
// ─── Fixture rows: the gate must be able to FAIL, and only for the right reason ──
|
|
|
|
const FIXTURE_HEAD = '# Fixture\n\n';
|
|
|
|
/** Assemble a fixture document from `{heading: bodyText}`, optionally with CRLF endings. */
|
|
function doc(sectionsBySpec, { crlf = false, indent = '' } = {}) {
|
|
let out = FIXTURE_HEAD;
|
|
for (const [heading, body] of Object.entries(sectionsBySpec)) {
|
|
out += indent + '## ' + heading + '\n\n' + body + '\n\n';
|
|
}
|
|
return crlf ? out.replace(/\n/g, '\r\n') : out;
|
|
}
|
|
|
|
/** Every in-scope section present and empty, so a row supplies only the table it cares about. */
|
|
function emptySections(overrides = {}, opts) {
|
|
const spec = {};
|
|
for (const heading of Object.values(ROSTER_SECTIONS)) spec[heading] = '(no rows)';
|
|
return doc({ ...spec, ...overrides }, opts);
|
|
}
|
|
|
|
test('row 2 — the PR #3758 shape: a manifest reference with no roster row is reported', () => {
|
|
// msd-core/references/planner-coupling.md shipped in PR #3758 with a manifest entry,
|
|
// all 19 tests/fixtures/install-tree/*.json updated, and no row in
|
|
// §"Modular Planner Decomposition". That PR's CI was 30 success / 3 skipped / 0
|
|
// failing. This row is the reason it would not be.
|
|
const text = emptySections({
|
|
References: '| Reference | Role |\n|---|---|\n| `checkpoints.md` | Checkpoint types. |',
|
|
});
|
|
const { missingSections, missingRows } = findMissingRosterRows(text, {
|
|
references: ['checkpoints.md', 'planner-coupling.md'],
|
|
});
|
|
|
|
assert.deepStrictEqual(missingSections, []);
|
|
assert.deepStrictEqual(missingRows, ['references/planner-coupling.md']);
|
|
});
|
|
|
|
test('row 3 — adding the row clears the report', () => {
|
|
const text = emptySections({
|
|
References:
|
|
'| Reference | Role |\n|---|---|\n| `checkpoints.md` | Checkpoint types. |\n' +
|
|
'| `planner-coupling.md` | Planner coupling rules. |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['checkpoints.md', 'planner-coupling.md'] }).missingRows,
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('row 4 — a substring of a longer rostered cell is not a row', () => {
|
|
// The real docs/INVENTORY.md rosters `host-integration-adapters/imperative-hook-bus.cjs`.
|
|
// A substring match would report the separate, genuinely-unrostered top-level
|
|
// `hook-bus.cjs` as satisfied — a false pass on a shipped module.
|
|
const text = emptySections({
|
|
'CLI Modules':
|
|
'| Module | Responsibility |\n|---|---|\n' +
|
|
'| `host-integration-adapters/imperative-hook-bus.cjs` | Imperative hook-bus adapter. |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, {
|
|
cli_modules: ['host-integration-adapters/imperative-hook-bus.cjs', 'hook-bus.cjs'],
|
|
}).missingRows,
|
|
['cli_modules/hook-bus.cjs'],
|
|
);
|
|
});
|
|
|
|
test('row 5 — a row in another family section does not satisfy this family', () => {
|
|
// `smart-entry.md` (a workflow) and `smart-entry.cjs` (a CLI module) are different
|
|
// surfaces sharing a stem; an unscoped document-wide search conflates two families
|
|
// the moment they ever do share a full name.
|
|
const text = emptySections({
|
|
'CLI Modules': '| Module | Responsibility |\n|---|---|\n| `smart-entry.md` | Wrong section. |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { workflows: ['smart-entry.md'] }).missingRows,
|
|
['workflows/smart-entry.md'],
|
|
);
|
|
});
|
|
|
|
test('row 6 — a command row is matched on its Source link, not its display name', () => {
|
|
const text = emptySections({
|
|
Commands:
|
|
'| Command | Role | Source |\n|---|---|---|\n' +
|
|
'| `/msd-workflow` | Phase pipeline router. | [commands/msd/ns-workflow.md](../commands/msd/ns-workflow.md) |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { commands: ['/msd-ns-workflow'] }).missingRows,
|
|
[],
|
|
'the six namespace routers deliberately render a name that is not their file stem',
|
|
);
|
|
});
|
|
|
|
test('row 7 — a command row without a Source link is not a row', () => {
|
|
const text = emptySections({
|
|
Commands: '| Command | Role | Source |\n|---|---|---|\n| `/msd-quick` | Quick task. | (todo) |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { commands: ['/msd-quick'] }).missingRows,
|
|
['commands//msd-quick'],
|
|
);
|
|
});
|
|
|
|
test('row 8 — an unbackticked agent cell is a row', () => {
|
|
// Agents is the one family whose identity column carries a bare, unbackticked name.
|
|
const text = emptySections({
|
|
Agents:
|
|
'| Agent | Role | Spawned by | Primary doc |\n|---|---|---|---|\n' +
|
|
'| msd-planner | Creates executable phase plans. | `/msd-plan-phase` | primary |',
|
|
});
|
|
|
|
assert.deepStrictEqual(findMissingRosterRows(text, { agents: ['msd-planner'] }).missingRows, []);
|
|
});
|
|
|
|
test('row 9 — trailing HTML comments and extra columns are tolerated', () => {
|
|
const text = emptySections({
|
|
'CLI Modules':
|
|
'| Module | Responsibility | Extra |\n|---|---|---|\n' +
|
|
'| `installer-migrations/003-rename.cjs` | Migration. | n/a |<!-- msd-allow-legacy-name -->',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { cli_modules: ['installer-migrations/003-rename.cjs'] }).missingRows,
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('row 10 — CRLF line endings are tolerated', () => {
|
|
const spec = {};
|
|
for (const heading of Object.values(ROSTER_SECTIONS)) spec[heading] = '(no rows)';
|
|
spec.Hooks = '| Hook | Event | Purpose |\n|---|---|---|\n| `msd-statusline.js` | `statusLine` | Statusline. |';
|
|
const text = doc(spec, { crlf: true });
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { hooks: ['msd-statusline.js'] }).missingRows,
|
|
[],
|
|
'a \\n-only split leaves a trailing \\r on every cell and reds every Windows checkout',
|
|
);
|
|
});
|
|
|
|
test('row 10b — a fenced code block does not split a section or contribute cells', () => {
|
|
// docs/INVENTORY.md carries no fence today. The day someone documents a `## `
|
|
// example inside one, an unfenced scanner truncates the family section at that
|
|
// line and reds a document that is perfectly correct — and a pipe-delimited line
|
|
// inside the fence would count as a row it is not.
|
|
const text = emptySections({
|
|
References:
|
|
'```\n## References\n| `imposter.md` | not a row |\n```\n\n' +
|
|
'| Reference | Role |\n|---|---|\n| `real.md` | Real. |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['real.md', 'imposter.md'] }).missingRows,
|
|
['references/imposter.md'],
|
|
);
|
|
});
|
|
|
|
// ─── Fence-length boundary: the matcher's only numeric limit is `{3,}` ──────────
|
|
//
|
|
// RULESET.TESTS.boundary-coverage — exercise limit-1 / limit / limit+1 on the fence
|
|
// marker, for both delimiter characters. Two backticks is a plain inline code span
|
|
// and must NOT swallow the rows beneath it; three and four must.
|
|
|
|
test('row 10f — a TWO-character run is not a fence, and swallows nothing (limit-1)', () => {
|
|
for (const mark of ['``', '~~']) {
|
|
const text = emptySections({
|
|
References: mark + '\n| `kept.md` | Role. |\n' + mark,
|
|
});
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['kept.md'] }).missingRows,
|
|
[],
|
|
'a two-' + mark[0] + ' run is inline markup, not a fence — treating it as one hides a real row',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('row 10g — a THREE-character run opens and closes a fence (limit)', () => {
|
|
for (const mark of ['```', '~~~']) {
|
|
const text = emptySections({
|
|
References: mark + '\n| `swallowed.md` | Role. |\n' + mark + '\n\n| `kept.md` | Role. |',
|
|
});
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['kept.md', 'swallowed.md'] }).missingRows,
|
|
['references/swallowed.md'],
|
|
);
|
|
}
|
|
});
|
|
|
|
test('row 10h — a FOUR-character run opens and closes a fence (limit+1)', () => {
|
|
for (const mark of ['````', '~~~~']) {
|
|
const text = emptySections({
|
|
References: mark + '\n| `swallowed.md` | Role. |\n' + mark + '\n\n| `kept.md` | Role. |',
|
|
});
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['kept.md', 'swallowed.md'] }).missingRows,
|
|
['references/swallowed.md'],
|
|
'a longer run is still a fence; only `{3,}` matters, not an exact count',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('row 10c — a heading indented up to 3 spaces is still a heading (CommonMark)', (t) => {
|
|
// Anchoring hard at /^##/ looks harmless and is not. CommonMark permits an ATX
|
|
// heading to carry 1-3 leading spaces, so an author who indents one writes a
|
|
// perfectly valid document that a `^##`-anchored scanner reads as having NO family
|
|
// sections at all — all six reported missing, a structural red for zero real drift.
|
|
const text = emptySections(
|
|
{ References: '| Reference | Role |\n|---|---|\n| `only.md` | Only. |' },
|
|
{ indent: ' ' },
|
|
);
|
|
const { missingSections, missingRows } = findMissingRosterRows(text, { references: ['only.md'] });
|
|
|
|
assert.deepStrictEqual(missingSections, [], 'a 3-space indent must not erase every family section');
|
|
assert.deepStrictEqual(missingRows, [], 'rows under an indented heading are still rows');
|
|
t.diagnostic('CommonMark 4.2: an ATX heading may be indented 0-3 spaces');
|
|
});
|
|
|
|
test('row 10d — a cell that is a link wrapping a code span is a row', () => {
|
|
// docs/INVENTORY.md already writes file references as [`docs/AGENTS.md`](AGENTS.md)
|
|
// in prose. The first contributor who writes a FAMILY ROW that way gets an
|
|
// inexplicable red on a row that plainly documents the file.
|
|
const text = emptySections({
|
|
References: '| Reference | Role |\n|---|---|\n| [`linked.md`](../references/linked.md) | Linked. |',
|
|
});
|
|
|
|
assert.deepStrictEqual(findMissingRosterRows(text, { references: ['linked.md'] }).missingRows, []);
|
|
});
|
|
|
|
test('row 10e — a file merely MENTIONED in a role cell is still not a row', () => {
|
|
// The guard on row 10d. Unwrapping presentation must peel only layers that wrap the
|
|
// cell ENTIRELY; the moment a code span mentioned mid-prose counts, the gate has
|
|
// traded a false red for a false pass, which is the strictly worse failure.
|
|
const text = emptySections({
|
|
References: '| Reference | Role |\n|---|---|\n| `real.md` | Superseded by `ghost.md` in the role prose. |',
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['real.md', 'ghost.md'] }).missingRows,
|
|
['references/ghost.md'],
|
|
);
|
|
});
|
|
|
|
test('row 11 — a missing family section is reported as a section failure', () => {
|
|
const text = '# Fixture\n\n## Agents\n\n| Agent |\n|---|\n| msd-planner |\n';
|
|
const { missingSections, missingRows } = findMissingRosterRows(text, {
|
|
agents: ['msd-planner'],
|
|
cli_modules: ['a.cjs', 'b.cjs', 'c.cjs'],
|
|
});
|
|
|
|
assert.deepStrictEqual(missingSections, ['Commands', 'Workflows', 'References', 'CLI Modules', 'Hooks']);
|
|
assert.deepStrictEqual(
|
|
missingRows,
|
|
[],
|
|
'a missing section is ONE structural failure, not one phantom row per entry inside it',
|
|
);
|
|
});
|
|
|
|
test('row 12 — an empty family contributes nothing (limit-1)', () => {
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(emptySections(), { references: [] }),
|
|
{ missingSections: [], missingRows: [] },
|
|
);
|
|
});
|
|
|
|
test('row 13 — exactly one entry, rostered (limit)', () => {
|
|
const text = emptySections({ References: '| Reference | Role |\n|---|---|\n| `only.md` | Only. |' });
|
|
assert.deepStrictEqual(findMissingRosterRows(text, { references: ['only.md'] }).missingRows, []);
|
|
});
|
|
|
|
test('row 14 — one more entry than the roster carries is the one reported (limit+1)', () => {
|
|
const text = emptySections({ References: '| Reference | Role |\n|---|---|\n| `only.md` | Only. |' });
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(text, { references: ['only.md', 'extra.md'] }).missingRows,
|
|
['references/extra.md'],
|
|
);
|
|
});
|
|
|
|
test('row 16 — nested step/mode families are deliberately out of scope', () => {
|
|
// docs/INVENTORY.md §"Workflow Sub-Files": "Adding a step or mode file requires no
|
|
// hand-written row here". If that decision is ever reversed, this row is what has to
|
|
// change first — deliberately and in the open, rather than by widening
|
|
// ROSTER_SECTIONS and discovering 62 red rows.
|
|
assert.deepStrictEqual(
|
|
findMissingRosterRows(emptySections(), {
|
|
workflow_steps: ['quick/steps/a.md', 'quick/steps/b.md'],
|
|
workflow_modes: ['discuss-phase/modes/default.md'],
|
|
}),
|
|
{ missingSections: [], missingRows: [] },
|
|
);
|
|
});
|
|
|
|
test('the failure message names every missing path and the remedy that satisfies it', () => {
|
|
const rendered = formatRosterFailure({
|
|
missingSections: [],
|
|
missingRows: ['references/planner-coupling.md', 'cli_modules/hook-bus.cjs'],
|
|
});
|
|
|
|
assert.match(rendered, /references\/planner-coupling\.md/);
|
|
assert.match(rendered, /cli_modules\/hook-bus\.cjs/);
|
|
assert.match(
|
|
rendered,
|
|
/Regenerating the manifest does NOT satisfy this/,
|
|
'the predictable wrong guess is "run the generator"; the message has to close that door',
|
|
);
|
|
});
|
|
|
|
test('property — a command is reported missing exactly when no Source link names its file', () => {
|
|
// The cell-equality property below covers the five families that share one rule.
|
|
// `commands` is the family with its OWN rule, and therefore the one where an
|
|
// untested edge is most likely — so it gets its own property rather than riding on
|
|
// the five hand-written command fixtures.
|
|
const stemArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,10}$/);
|
|
|
|
fc.assert(
|
|
fc.property(
|
|
fc.uniqueArray(stemArb, { minLength: 1, maxLength: 8 }),
|
|
fc.uniqueArray(stemArb, { maxLength: 8 }),
|
|
(linked, candidates) => {
|
|
const absent = candidates.filter((c) => !linked.includes(c));
|
|
// Render each row with a DISPLAY NAME that is deliberately not the file stem,
|
|
// mirroring the six real namespace routers: if the matcher ever falls back to
|
|
// the rendered name, this property fails.
|
|
const rows = linked
|
|
.map((s) => '| `/msd-alias-' + s + '` | role | [src](../commands/msd/' + s + '.md) |')
|
|
.join('\n');
|
|
const text = emptySections({ Commands: '| Command | Role | Source |\n|---|---|---|\n' + rows });
|
|
|
|
const { missingRows } = findMissingRosterRows(text, {
|
|
commands: [...linked, ...absent].map((s) => '/msd-' + s),
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
missingRows.slice().sort(),
|
|
absent.map((s) => 'commands//msd-' + s).sort(),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
});
|
|
|
|
test('property — an entry is reported missing exactly when no cell in its section equals it', () => {
|
|
const nameArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,10}\.md$/);
|
|
|
|
fc.assert(
|
|
fc.property(
|
|
fc.uniqueArray(nameArb, { minLength: 1, maxLength: 8 }),
|
|
fc.uniqueArray(nameArb, { maxLength: 8 }),
|
|
(rostered, candidates) => {
|
|
// `candidates` may overlap `rostered`; the entries genuinely absent from the
|
|
// table are exactly the set difference, and that is what the matcher must
|
|
// return — no more (false red) and no fewer (false pass).
|
|
const absent = candidates.filter((c) => !rostered.includes(c));
|
|
const rows = rostered.map((n) => '| `' + n + '` | role |').join('\n');
|
|
const text = emptySections({ References: '| Reference | Role |\n|---|---|\n' + rows });
|
|
|
|
const { missingRows } = findMissingRosterRows(text, {
|
|
references: [...rostered, ...absent],
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
missingRows.slice().sort(),
|
|
absent.map((e) => 'references/' + e).sort(),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
});
|