mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-01 15:51:41 +02:00
3bef43bc5a
* 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>
317 lines
13 KiB
TypeScript
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');
|
|
});
|
|
});
|