Files
gstack/test/catalog-trim.test.ts
T
Garry Tan 3bef43bc5a v1.55.0.0 fix wave: gbrain data-loss guards + browser crash-loop + 6 more (#1808)
* fix(jsonl-merge): make equal-ts resolution converge across machines

The JSONL append merge driver sorted timestamped entries by (0, ts) with no
further tiebreaker. Equal-ts entries then fell back to stable-sort insertion
order (base, ours, theirs), but git assigns the local side to "ours", so two
machines resolving the same conflict emitted equal-ts lines in opposite order.
The merged files diverged and never converged. gstack-telemetry-log uses
second-granularity timestamps, so same-ts collisions are routine.

Add the line content as the final sort tiebreaker so the order is total and
side-independent. Add a regression test that runs the driver with the two
sides swapped and asserts identical output.

* fix(gen-skill-docs): quote frontmatter descriptions with interior colons (#1778)

Generated SKILL.md frontmatter emitted the catalog-trimmed description: as a
plain YAML scalar. A description with an interior ": " (e.g. "Ship workflow:
detect...") parses as a nested mapping under strict YAML loaders, so Codex/OpenAI
skill loading rejected those skills.

applyCatalogTrim now routes the value through toYamlInlineScalar, which quotes
(via JSON.stringify) only when a plain scalar would be invalid — interior ": ",
inline " #", leading indicator char, or surrounding whitespace. Strings that are
already valid plain scalars pass through unchanged to keep regen diffs small.

The frontmatter test now parses every generated block (Claude + Codex hosts) with
Bun.YAML.parse instead of string-checking that name:/description: substrings exist,
so the regression can't reappear. Runs under `bun test` (already in CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(skills): regenerate SKILL.md after frontmatter quoting fix (#1778)

9 catalog-trimmed descriptions whose values contain an interior colon or inline-
comment marker are now quoted. Generated output only; rerun of bun run gen:skill-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(gbrain-sources): centralize sources-list shape handling in parseSourcesList (#1576)

#1576's crash in sourceLocalPath was already fixed in v1.42.0.0 (dual-shape
handling). But the readers disagreed: sourceLocalPath accepted both the wrapped
{sources:[...]} object (v0.20+) and a bare array, while probeSource and
sourcePageCount accepted only the wrapped shape. Extract one parseSourcesList()
normalizer and route all three through it, so the shape assumption lives in a
single place. This is also the base the #1734 remote_url audit builds on.

parseSourcesList returns [] for null/garbage rather than throwing; callers treat
'no rows' as absent. New test/gbrain-sources-parse.test.ts pins both shapes plus
the garbage paths and confirms config.remote_url survives for the audit.

#1576 is closeable as already-fixed in v1.42.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain): spawn gbrain + brain-sync through a shell on Windows (#1731)

On Windows, bun/npm install gbrain as a gbrain.cmd/.ps1 shim and gstack-brain-sync
is a bash shebang script. spawnSync/spawn/execFileSync resolve neither without a
shell, so the child spawn failed ENOENT — on the sync orchestrator this surfaced
as 'brain-sync exited undefined' (#1731).

Add NEEDS_SHELL_ON_WINDOWS (process.platform === 'win32') in gbrain-exec and pass
it as shell: to every gbrain/brain-sync child spawn: spawnGbrain, spawnGbrainAsync,
execGbrainText (gbrain-exec), the two sources-list/remove/add spawns (gbrain-sources),
the version + probe spawns (gbrain-local-status), and the two brain-sync spawns in
the orchestrator. POSIX keeps the cheaper no-shell path.

macOS/Linux CI can't exercise the Windows path, so test/gbrain-spawn-windows-shell.ts
is a static-grep tripwire: it fails CI if a gbrain/brain-sync spawn is added without
the shell flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(catalog-trim): expect YAML-quoted descriptions with interior colons (#1778)

The quoting fix wraps colon-bearing catalog descriptions in double quotes;
two catalog-trim assertions still pinned the old unquoted form. Tolerate the
optional quotes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain-sync): defensive guards against destructive gbrain ops (#1734)

The orchestrator shelled out to gbrain's destructive subcommands as if they were
safe. gbrain can rm-rf a user's working tree during an autopilot race (its own
bug, upstream gbrain #1526); gstack now defends itself. New lib/gbrain-guards.ts
gates the two destructive reach points, all checked immediately before the op:

- Autopilot refuse (multi-signal, affirmative-only): refuse a destructive op when
  a live 'gbrain autopilot' process (primary) or a known autopilot lock file
  (secondary; checked under both GBRAIN_HOME and ~/.gbrain since gbrain #1226
  ignores GBRAIN_HOME) is present. No signal → proceed; inability to introspect
  never bricks a normal sync.
- sources remove: routed through safeSourcesRemove → decideSourceRemove. Fail
  CLOSED — refuse to remove a user-managed source (remote_url set, local_path
  outside gbrain's clones) when gbrain has no --keep-storage to protect the files
  (it doesn't in 0.41.x). Also fail closed when the source list can't be read.
  Path containment uses realpath so a symlink can't smuggle a delete out of clones.
- sync --strategy code: decideCodeSync refuses URL-managed sources (remote_url
  set) unless --allow-reclone is passed, since the walk can auto-reclone (rm-rf).

Capability detection memoizes per process keyed to gbrain's identity (no stale
persistent cache); --keep-storage can't be probed (generic help) so it defaults
unsupported → fail closed. Every guard surfaces a visible reason; autopilot/reclone
refusals fail the code stage (verdict ERR) rather than silently skipping protection.

test/gbrain-guards.test.ts covers all branches hermetically (injected rows + probe
overrides): autopilot signals, fail-closed remove, keep-storage path, reclone gate,
realpath/symlink containment. Supersedes #1736 (which guarded a nonexistent path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sync-gbrain): warn against running during autopilot; prefer --path sources (#1734)

Adds a Safety note to the /sync-gbrain guidance (template + regenerated SKILL.md +
this repo's CLAUDE.md): don't run while autopilot is active, and prefer
`gbrain sources add --path` over URL-managed sources, which can auto-reclone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(memory-ingest): configurable import timeout + resume-on-timeout messaging (#1611)

The gbrain import (the long pole on big brains) had a hardcoded 30-min timeout,
so large memory corpora got SIGTERM'd mid-import on /sync-gbrain --full. Make it
configurable via GSTACK_INGEST_TIMEOUT_MS (default 30 min, validated 1min–24h).

gstack can't drive gbrain's internal resume, but the existing SIGTERM forwarder
already preserves gbrain's import-checkpoint.json, so the next run resumes. On a
timeout we now say so explicitly ('checkpoint preserved — re-run /sync-gbrain to
resume, raise GSTACK_INGEST_TIMEOUT_MS for big brains') instead of surfacing a
bare 'exited null'. True gstack-driven ingest-resume is deferred to gbrain
(.context/gbrain-asks.md).

Also guards the module's main() behind import.meta.main so resolveImportTimeoutMs
is unit-testable; the orchestrator runs it as a subprocess where main still fires.
New test/memory-ingest-timeout.test.ts pins default/override/invalid resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(browse): stop the headed daemon crash-loop + silent headless downgrade (#1781)

A headed session against a beacon-heavy page (analytics/extension load) could tip
the single-threaded daemon into a self-inflicted crash-loop: a brief HTTP stall
was read as a crash, the restart didn't clear the dead Chromium's SingletonLock,
the relaunch failed, and the session silently came back headless. Four fixes:

1. Busy-vs-dead (sendCommand): on a connection error, if the process is alive give
   /health a bounded probe (3x/250ms) and just retry the command — never kill+restart
   a live-but-busy server. A 30s timeout now reports 'busy, not restarting' when the
   process is alive instead of exiting into a kill cycle.
2. Profile-lock cleanup on (re)start: startServer reaps the orphaned Chromium holding
   the SingletonLock and clears Singleton{Lock,Socket,Cookie} before relaunch, so the
   auto-restart path gets the same clean profile the manual connect preamble did.
3. Headed persistence: the restart env reapplies BROWSE_HEADED from this invocation OR
   the persisted server state (mode==='headed'), so a restart from a plain command
   never downgrades a headed window to invisible headless. Extracted to buildRestartEnv.
4. Force-clean disconnect reaps the Chromium child tree (via the SingletonLock PID) so
   the next connect starts clean instead of fighting an orphan.

Plus macOS window surfacing: connect + focus raise 'Google Chrome for Testing' to the
active Space (best-effort osascript) with a Mission Control hint — the first thing
users read as 'I can't see the browser'.

Shared lock helpers (chromiumProfileDir / cleanChromiumProfileLocks / killOrphanChromium)
dedupe the connect, disconnect, and restart paths. browse/test/restart-env.test.ts pins
the headed-persistence decision; the full crash-loop repro is an E2E (periodic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(gbrain-install): remove the v0.18.2 pin, install latest + version floor + doctor self-test (#1744)

The installer pinned gbrain at v0.18.2 while gbrain shipped v0.41.x — ~23 versions
behind. Remove the hard pin: a fresh clone now stays on the latest default-branch
HEAD. --pinned-commit <sha> still pins for reproducibility.

Unpinning removes the version gate the pin provided, so add two install-time gates
that fail closed (exit 3, matching the existing PATH-shadow/version-mismatch posture):
- MIN_GBRAIN_VERSION floor (0.20.0, the sources-list/federated surface gstack needs):
  refuse an install below it.
- gbrain doctor --fast self-test when a brain config already exists (re-install /
  detected clone): refuse to leave a broken gbrain in place. Pre-init installs skip
  it; the full /sync-gbrain --dry-run self-test runs from /setup-gbrain after init.

Docs updated (USING_GBRAIN_WITH_GSTACK.md no longer says 'edit PINNED_COMMIT').
Detect-install tests bump the success-path fixtures above the floor and add a
below-floor exit-3 test. The gbrain-side asks (root #1526 fix, --keep-storage,
remove-lease, capability command, ingest-resume, integration CI) are written to
.context/gbrain-asks.md for filing against garrytan/gbrain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#1778): update claude-ship golden + catalog-mode assertions for quoted descriptions

ship's catalog description ('Ship workflow: detect...') has an interior colon, so
the #1778 fix now YAML-quotes it. Refresh the claude-ship golden baseline to the
quoted output and make the catalog-mode-full trim/restore assertions quote-tolerant.
codex/factory ship goldens are unaffected (they use block-scalar descriptions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gen-skill-docs): use function replacer so a $ in a description can't corrupt frontmatter (#1778)

String.prototype.replace treats $&/$1/$` in the replacement as patterns. A future
skill description containing $ (e.g. referencing $B/$D) would silently corrupt the
generated frontmatter. Use a function replacer. Behavior-preserving for all current
descriptions (regen produces no diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.55.0.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(gbrain): document configurable memory-ingest timeout for v1.55.0.0

USING_GBRAIN_WITH_GSTACK.md: note GSTACK_INGEST_TIMEOUT_MS (default 30 min,
1 min-24h range) on the /sync-gbrain memory stage, plus checkpoint-resume on
timeout. Fills the reference gap left by the configurable-import-timeout fix
(#1611) shipped in v1.55.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:57:07 -07:00

317 lines
13 KiB
TypeScript

/**
* Unit tests for catalog-trim helpers (gen-skill-docs.ts T4 functions).
*
* splitCatalogDescription, buildTrimmedDescription, buildWhenToInvokeSection,
* applyCatalogTrim — these handle every skill's frontmatter rewrite at gen
* time. Two bugs already shipped here:
*
* v1.45.0.0 design-consultation: when the first sentence exceeded 200 chars,
* the routing-prose extraction lost the entire tail. design-consultation's
* "Use when asked to..." silently disappeared from the body section.
*
* v1.45.0.0 CI freshness: the root-skill key leaked the checkout directory
* name ("seville-v3" vs "gstack") and aggregate order was filesystem-
* iteration order. Two machines produced two different JSON files.
*
* Both are regression-tested here. Future bugs in these functions surface as
* unit-test failures before they hit CI or production.
*/
import { describe, test, expect } from 'bun:test';
import {
splitCatalogDescription,
buildTrimmedDescription,
buildWhenToInvokeSection,
applyCatalogTrim,
} from '../scripts/gen-skill-docs';
describe('splitCatalogDescription', () => {
test('extracts lead sentence + routing prose from simple multi-line description', () => {
const desc =
'Pre-landing PR review. Analyzes diff against the base branch for SQL safety, LLM trust\n' +
'boundary violations, conditional side effects, and other structural issues. Use when\n' +
'asked to "review this PR", "code review", "pre-landing review", or "check my diff".\n' +
'Proactively suggest when the user is about to merge or land code changes. (gstack)';
const parts = splitCatalogDescription(desc);
expect(parts.lead).toBe('Pre-landing PR review.');
expect(parts.hasGstackTag).toBe(true);
expect(parts.voiceLine).toBeNull();
expect(parts.routingProse).toContain('Use when');
expect(parts.routingProse).toContain('Proactively suggest');
expect(parts.routingProse).toContain('Analyzes diff');
// (gstack) tag stripped from routingProse
expect(parts.routingProse).not.toContain('(gstack)');
});
test('REGRESSION (design-consultation v1.45.0.0): >200 char first sentence keeps routing', () => {
// This is the exact shape that broke. First sentence (with embedded periods)
// is 207 chars. Original bug: routing extraction ran AFTER lead truncation,
// so collapsed.indexOf(lead) returned -1 (lead ended in "...") and the
// entire "Use when..." + "Proactively..." tail dropped to empty string.
const desc =
'Design consultation: understands your product, researches the landscape, ' +
'proposes a complete design system (aesthetic, typography, color, layout, ' +
'spacing, motion), and generates font+color preview pages. ' +
'Creates DESIGN.md as your project\'s design source of truth. ' +
'For existing sites, use /plan-design-review to infer the system instead. ' +
'Use when asked to "design system", "brand guidelines", or "create DESIGN.md". ' +
'Proactively suggest when starting a new project\'s UI with no existing ' +
'design system or DESIGN.md. (gstack)';
const parts = splitCatalogDescription(desc);
// Lead may be truncated with "..." since it exceeds 200 chars
expect(parts.lead.length).toBeLessThanOrEqual(205);
// Critical: routing MUST contain the "Use when..." and "Proactively..." prose
expect(parts.routingProse).toContain('Use when asked to');
expect(parts.routingProse).toContain('design system');
expect(parts.routingProse).toContain('Proactively suggest');
expect(parts.routingProse).toContain('Creates DESIGN.md');
});
test('extracts voice-triggers line when present', () => {
const desc =
'Quick fix. Use when asked to fix the bug. ' +
'Voice triggers (speech-to-text aliases): "fix it", "patch this", "make it work". ' +
'(gstack)';
const parts = splitCatalogDescription(desc);
expect(parts.lead).toBe('Quick fix.');
expect(parts.voiceLine).toContain('Voice triggers');
expect(parts.voiceLine).toContain('"fix it"');
expect(parts.routingProse).toContain('Use when asked to fix');
// Voice line should NOT leak into routing
expect(parts.routingProse).not.toContain('speech-to-text');
});
test('handles description without (gstack) tag', () => {
const desc = 'Single sentence description. With routing prose afterward.';
const parts = splitCatalogDescription(desc);
expect(parts.lead).toBe('Single sentence description.');
expect(parts.hasGstackTag).toBe(false);
expect(parts.routingProse).toBe('With routing prose afterward.');
});
test('embedded-period descriptions: known limitation falls back to first-20-words', () => {
// KNOWN LIMITATION: the sentence regex `^([^.!?]*[.!?])(?:\\s|$)` stops
// at the FIRST `.`-then-non-whitespace because [^.!?]* is greedy and
// can't backtrack past a non-period char. For "DESIGN.md and v1.45.0.0
// in the lead. Use when..." the regex fails entirely and the lead falls
// back to the first 20 words (~the whole short input).
//
// The real-world impact is small: descriptions like "DESIGN.md" or "v1.45"
// appearing in the middle of the FIRST sentence are rare. When they do
// occur, the lead simply becomes the full description (no body section
// generated) — same as a description without a period. The trim CI gate
// still keeps the per-skill size budget honest.
//
// If this gap matters later, replace the regex with a sentence tokenizer
// (compromise.js / Intl.Segmenter) — until then we accept the fallback.
const desc =
'Skill that mentions DESIGN.md and v1.45.0.0 in the lead. ' +
'Use when asked to do something.';
const parts = splitCatalogDescription(desc);
// Actual behavior: lead absorbs the whole input via the word-count fallback.
expect(parts.lead.length).toBeGreaterThan(0);
// routingProse may be empty when the fallback consumes everything.
// The test exists to detect REGRESSIONS (lead becoming oddly short like
// "Skill that mentions DESIGN.") not to assert ideal behavior.
expect(parts.lead).toContain('Skill that mentions');
});
test('description without a period uses first ~20 words as lead', () => {
const desc = 'A long fragment with no sentence terminator drifting on and on across many words for an unusual frontmatter shape';
const parts = splitCatalogDescription(desc);
expect(parts.lead.length).toBeGreaterThan(0);
expect(parts.lead.split(/\s+/).length).toBeLessThanOrEqual(21);
});
test('idempotent: calling on already-trimmed output returns the same parts', () => {
const desc = 'Already trimmed. (gstack)';
const parts1 = splitCatalogDescription(desc);
const parts2 = splitCatalogDescription(buildTrimmedDescription(parts1));
// Re-split of a one-line trimmed result keeps lead identical, routing empty.
expect(parts2.lead).toBe(parts1.lead);
expect(parts2.hasGstackTag).toBe(true);
expect(parts2.routingProse).toBe('');
});
});
describe('buildTrimmedDescription', () => {
test('appends (gstack) when hasGstackTag is true', () => {
const out = buildTrimmedDescription({
lead: 'Some lead.',
routingProse: 'routing',
voiceLine: null,
hasGstackTag: true,
});
expect(out).toBe('Some lead. (gstack)');
});
test('omits (gstack) when hasGstackTag is false', () => {
const out = buildTrimmedDescription({
lead: 'No tag.',
routingProse: '',
voiceLine: null,
hasGstackTag: false,
});
expect(out).toBe('No tag.');
});
test('trims whitespace from lead', () => {
const out = buildTrimmedDescription({
lead: ' Lead with whitespace. ',
routingProse: '',
voiceLine: null,
hasGstackTag: true,
});
expect(out).toBe('Lead with whitespace. (gstack)');
});
});
describe('buildWhenToInvokeSection', () => {
test('produces markdown H2 with routing prose and voice line', () => {
const out = buildWhenToInvokeSection({
lead: 'Lead.',
routingProse: 'Use when asked to ship.',
voiceLine: 'Voice triggers (speech-to-text aliases): "ship it".',
hasGstackTag: true,
});
expect(out).toContain('## When to invoke this skill');
expect(out).toContain('Use when asked to ship.');
expect(out).toContain('Voice triggers');
});
test('omits routing block when routingProse is empty', () => {
const out = buildWhenToInvokeSection({
lead: 'Lead.',
routingProse: '',
voiceLine: null,
hasGstackTag: true,
});
expect(out).toContain('## When to invoke this skill');
expect(out).not.toContain('Use when');
});
test('emits even when only voice line is present', () => {
const out = buildWhenToInvokeSection({
lead: 'Lead.',
routingProse: '',
voiceLine: 'Voice triggers: x.',
hasGstackTag: true,
});
expect(out).toContain('Voice triggers: x.');
});
});
describe('applyCatalogTrim', () => {
const minimalSkill = `---
name: example
description: |
Example skill: this is the first sentence of the description, intended to be
the lead displayed in the catalog. Use when asked to do an example task.
Proactively suggest when the user mentions examples. (gstack)
preamble-tier: 2
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
# Example body
Original body content here.
`;
test('rewrites multi-line description into one-line + body section', () => {
const result = applyCatalogTrim(minimalSkill, 'example');
expect(result).not.toBeNull();
const { content, parts } = result!;
// Frontmatter description is now ONE line ending with (gstack). #1778: a
// description with an interior colon ("Example skill:") is YAML-quoted, so
// the value is wrapped in double quotes — tolerate the optional quotes.
expect(content).toMatch(/^description: "?Example skill:[^\n]*\(gstack\)"?\n/m);
// Body has the When to invoke section
expect(content).toContain('## When to invoke this skill');
expect(content).toContain('Use when asked to do an example task.');
expect(content).toContain('Proactively suggest when');
// Original body still present
expect(content).toContain('# Example body');
expect(content).toContain('Original body content here.');
// parts is populated for the aggregator
expect(parts.lead).toContain('Example skill');
expect(parts.hasGstackTag).toBe(true);
});
test('returns null for already-short descriptions (no-op)', () => {
const shortSkill = minimalSkill.replace(
/description: \|[\s\S]*?(?=preamble-tier:)/,
'description: Already short. (gstack)\n',
);
const result = applyCatalogTrim(shortSkill, 'example');
expect(result).toBeNull();
});
test('keeps the newline between description and next YAML field (no field collision)', () => {
// Bug shape from v1.45.0.0 first attempt: produced
// `description: ... (gstack)preamble-tier:` with no newline.
const result = applyCatalogTrim(minimalSkill, 'example');
expect(result).not.toBeNull();
expect(result!.content).not.toMatch(/\(gstack\)preamble-tier/);
expect(result!.content).not.toMatch(/\(gstack\)allowed-tools/);
// #1778: optional closing quote when the description was YAML-quoted.
expect(result!.content).toMatch(/\(gstack\)"?\n[a-z-]+:/);
});
test('returns null on content without proper frontmatter', () => {
expect(applyCatalogTrim('no frontmatter here', 'whatever')).toBeNull();
expect(applyCatalogTrim('---\nincomplete frontmatter', 'whatever')).toBeNull();
});
});
describe('proactive-suggestions.json determinism (regression for v1.45.0.0 CI freshness fail)', () => {
test('committed JSON keys are alphabetically sorted', () => {
// Reads the actual committed file at scripts/proactive-suggestions.json
// and verifies sort order. Catches regressions to non-sorted output.
const fs = require('fs');
const path = require('path');
const json = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'scripts', 'proactive-suggestions.json'), 'utf-8'),
);
const keys = Object.keys(json.skills);
const sorted = [...keys].sort();
expect(keys).toEqual(sorted);
});
test('root skill is keyed as "gstack" (not the checkout directory name)', () => {
// Catches the bug where the root SKILL.md.tmpl's catalog parts get
// registered under the directory basename ("seville-v3" in a Conductor
// worktree, "gstack" on CI).
const fs = require('fs');
const path = require('path');
const json = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'scripts', 'proactive-suggestions.json'), 'utf-8'),
);
expect(json.skills).toHaveProperty('gstack');
// The directory the test runs in must NOT appear as a key.
const repoDir = path.basename(path.resolve(__dirname, '..'));
if (repoDir !== 'gstack') {
expect(json.skills).not.toHaveProperty(repoDir);
}
});
test('schema + catalog_mode + note fields are stable', () => {
const fs = require('fs');
const path = require('path');
const json = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'scripts', 'proactive-suggestions.json'), 'utf-8'),
);
expect(json).toHaveProperty('$schema');
expect(json.catalog_mode).toBe('trim');
expect(typeof json.note).toBe('string');
// No timestamp field — those cause flapping CI freshness checks.
expect(json).not.toHaveProperty('generated_at');
expect(json).not.toHaveProperty('timestamp');
});
});