Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/contributing/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,43 @@ Before propagating a new operator / endpoint / pattern from VibeCode docs into a
| Is X deprecated? | `packages/jssdk/README-AI.md` "Deprecation notice" + JSDoc `@deprecated` markers on classes |

If the SDK source disagrees with the VibeCode docs, the SDK source wins.

## 8. The fence typecheck

`pnpm run skills:typecheck-blocks` compiles every ` ```ts ` fence in
`skills/*/SKILL.md` against the built SDK types, the way `docs:typecheck-blocks`
has guarded the documentation site since #109. It runs inside `pnpm run
typecheck`, so the commit protocol in §5 already covers it — but know what it is
for, because it changes how a skill edit should be written.

**It is the reason the table in §7 is a fallback rather than the first move.** A
claim you can express as code belongs in a fence, where the compiler checks it
against the real signatures on every run. The table is for what a compiler
cannot see: whether a portal supports a method, what a REST field means.

The first run of this gate, on skills that had been reviewed by hand many times,
found: a class the documentation gives its own page to and the package did not
export (`B24HelperManager`); `helper.license` / `helper.payment`, which do not
exist — the properties are `licenseInfo` / `paymentInfo`; `destroyB24Helper`
imported from the package root when it is a member of `useB24Helper()`;
`Text.toB24Format(...)` silently resolving to the DOM `Text` because the fence
never imported the SDK's; `selectAccess({})` where the parameter is a `string[]`;
and CRM ids written as `'D_42'` where the type is `number[]`.

### Writing a fence that the gate can check

- **Import what you use.** An SDK export is never ambient — see the header of
`.skills-typecheck/globals.d.ts` for why. An agent copying the fence needs the
import, so the fence must carry it.
- **Fragments are declarations too.** `filter: { … }` alone is not TypeScript;
`const params = { filter: { … } }` is, and it also shows the reader where
`filter` actually lives.
- **Placeholders belong in `globals.d.ts`** — a domain type, a handler the
reader writes, an id list they already have.
- **`// @check-ignore` on the line before the fence** skips it. Use it when
making the snippet compile would misrepresent it, and say why in the marker.
It is the last resort: every ignored fence is a fence the gate stops checking.

The gate proves a fence type-checks. It does not prove the method exists on a
portal, or that the field names are right — that is what `skills:verify` (#113)
is for.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,9 @@ coverage

# docs-typecheck generated temp files (scripts/docs-typecheck.mjs)
.docs-typecheck/tmp/
.skills-typecheck/tmp/
# Scratch file written by the docs-lint freshness test. It is removed in a
# `finally`, but a killed runner would otherwise leave it as a stray untracked
# file — ignoring it keeps `git status` honest either way.
packages/jssdk/src/.docs-lint-freshness-probe.ts
__pycache__/
111 changes: 111 additions & 0 deletions .skills-typecheck/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Ambient declarations for the TypeScript fences in `skills/*\/SKILL.md` (#402).
*
* Skill files are reference prose, so many fences are deliberately fragments:
* they show the call under discussion, not a runnable program. Without ambients
* every such fence would fail on a name the surrounding paragraph established,
* and the gate would report noise instead of defects.
*
* The line drawn here, which is the whole design of this file:
*
* - A REAL export of `@bitrix24/b24jssdk` is NEVER declared here. If a fence
* uses `Text`, `AjaxResult` or `EnumCrmEntityTypeId`, the fence must import
* it — an agent copying that snippet needs the import, and hiding it behind
* an ambient would make the gate certify code that does not run. The first
* run of this gate found `Text.toB24Format(...)` resolving to the DOM `Text`
* for exactly that reason.
* - A PLACEHOLDER belonging to the reader's own application — a domain type, a
* handler they write, an id list they already have — is declared here.
* - A framework global that really is auto-imported at the callsite (Nuxt's
* `useNuxtApp`, `navigateTo`) is declared here.
*
* This mirrors `.docs-typecheck/globals.d.ts`, including its known limitation:
* an ambient cannot tell whether the reader actually has that binding, so the
* gate proves a fence type-checks, not that it is complete. Keep the list short
* for that reason — every entry is a check the gate stops performing.
*/

// region The reader's own application ////

// Domain types used as `<T>` arguments in examples. Deliberately loose: the
// examples illustrate the call shape, not any particular portal's schema.
type CrmItem = Record<string, any>
type Contact = Record<string, any>
type ChatInfo = Record<string, any>
type EventLogItem = Record<string, any>
type Deal = Record<string, any>
type TaskItem = Record<string, any>
type EventItem = Record<string, any>

// Values and functions the surrounding prose establishes the reader already has.
declare const ids: number[]
declare const arrayOfCalls: any[]
declare const b24OAuthParams: any
declare const clientId: string
declare const clientSecret: string
declare const db: any
declare const sixMonthsAgo: Date
declare const rootEl: HTMLElement
declare function processDeal(item: any): void
declare function handleCrmEvent(message: any): void
declare function handleImEvent(message: any): void
declare function handleAppEvent(message: any): void
declare function routeForPlace(place: string): string
declare function isSamePath(a: string, b: string): boolean
declare function risky(): Promise<void>
declare function main(): Promise<void>

// endregion ////

// region Instances a previous fence constructed ////

// The live SDK client, as in `.docs-typecheck/globals.d.ts`. `let`, not `const`:
// frame snippets assign to it via `$b24 = await initializeB24Frame()`, which a
// `declare const` would reject with TS2588.
declare let $b24: import('@bitrix24/b24jssdk').B24Frame

// `helper` and `logger` are built in an earlier fence on the same page and then
// used across several later ones. Same concession `.docs-typecheck` makes for
// `$b24`: the alternative is repeating the constructor in every fragment, which
// buries the call being explained.
declare const helper: ReturnType<typeof import('@bitrix24/b24jssdk').useB24Helper> extends infer H
? H extends { getB24Helper: () => infer M } ? M : any
: any
declare const logger: import('@bitrix24/b24jssdk').LoggerInterface

// Destructured from `useB24Helper()` in the page's first fence.
declare const usePullClient: () => void
declare const useSubscribePullClient: (callback: (message: any) => void, moduleId?: string) => () => void
declare const startPullClient: () => void

// endregion ////

// region Framework globals ////

// Nuxt auto-imports these in an app; a fence showing app code does not import them.
declare function useNuxtApp(): any
declare function useRouter(): any
declare function navigateTo(to: any, options?: any): any
declare function onNuxtReady(callback: () => void): void
declare function defineNuxtRouteMiddleware(handler: (to: any, from: any) => any): any

// endregion ////

// Single-file components are out of scope for a type gate over Markdown; a
// fence importing from 'vue' only needs the names to resolve.
declare module 'vue' {
export function ref<T>(value: T): { value: T }
export function computed<T>(getter: () => T): { value: T }
export function onMounted(callback: () => void): void
export function onUnmounted(callback: () => void): void
export function onBeforeUnmount(callback: () => void): void
export function watch(source: any, callback: any, options?: any): () => void
}

// Vite/Nuxt extend ImportMeta; the DOM lib does not know about these.
interface ImportMeta {
dev?: boolean
server?: boolean
client?: boolean
env?: Record<string, any>
}
27 changes: 27 additions & 0 deletions .skills-typecheck/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ESNext",
"DOM"
],
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true,
"noEmit": true,
"moduleDetection": "force",
"allowJs": false,
"allowImportingTsExtensions": false,
"isolatedModules": false,
"types": [
"node"
]
},
"include": [
"globals.d.ts",
"tmp/**/*.ts"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Types overview
description: 'Navigable map of every types/* module in the SDK — which have a dedicated page, which are surfaced through another page, and which are follow-ups.'
category: 'Types'
navigation.title: Types overview
audited: 2026-07-02
audited: 2026-08-27
links:
- label: public barrel (index.ts)
iconName: GitHubIcon
Expand Down
3 changes: 2 additions & 1 deletion docs/content/docs/3.api-reference/1.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: API Reference
description: 'Index of the public @bitrix24/b24jssdk surface, grouped by domain — every value export, what it is, and where its guide and source live.'
navigation.title: API Reference
audited: 2026-08-24
audited: 2026-08-27
links:
- label: index.ts (the public surface)
iconName: GitHubIcon
Expand Down Expand Up @@ -99,6 +99,7 @@ Aggregated portal state — profile, app, licence, currency.

| Export | What it is | Guide |
| --- | --- | --- |
| [`B24HelperManager`](https://github.com/bitrix24/b24jssdk/blob/main/packages/jssdk/src/helper/helper-manager.ts) | Loads and caches portal-wide data (profile, app, currencies, options, license, payment) and owns the Pull-client glue. Construct it directly for backend code; in an app prefer the composable below. | [guide](/docs/working-with-the-rest-api/helper/) |
| [`useB24Helper`](https://github.com/bitrix24/b24jssdk/blob/main/packages/jssdk/src/helper/use-b24-helper.ts) | Composable that initialises and exposes the helper. | [guide](/docs/working-with-the-rest-api/helper-use-b24-helper/) |
| [`LoadDataType`](https://github.com/bitrix24/b24jssdk/blob/main/packages/jssdk/src/types/b24-helper.ts) | Which data sets the helper loads. | [guide](/docs/working-with-the-rest-api/helper/) |
| [`EnumAppStatus`](https://github.com/bitrix24/b24jssdk/blob/main/packages/jssdk/src/types/b24-helper.ts) | Application status values. | [guide](/docs/working-with-the-rest-api/helper-app-manager/) |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"docs:preview": "nuxt preview docs",
"docs:lint": "pnpm run docs:lint-pages && pnpm run docs:lint-links && pnpm run docs:lint-api-index",
"docs:typecheck": "pnpm --filter ./docs typecheck",
"skills:typecheck-blocks": "node scripts/skills-typecheck-blocks.mjs",
"docs:typecheck-blocks": "node scripts/docs-typecheck.mjs",
"skills:install": "pnpm --dir skills/b24jssdk-recipes install --frozen-lockfile",
"skills:typecheck": "pnpm run skills:install && tsc -p skills/b24jssdk-recipes/tsconfig.json",
Expand Down Expand Up @@ -75,7 +76,7 @@
"docs:lint-links": "node scripts/docs-link-check.mjs",
"docs:lint-api-index": "node scripts/check-api-reference-index.mjs",
"docs:lint:test": "node --test \"scripts/__tests__/**/*.test.mjs\"",
"typecheck": "pnpm run package-jssdk:typecheck && pnpm run package-jssdk-nuxt:typecheck && pnpm run docs:typecheck && pnpm run playground-nuxt:typecheck && pnpm run playground-cli:typecheck && pnpm run docs:typecheck-blocks && pnpm run skills:typecheck && pnpm run contributing:typecheck",
"typecheck": "pnpm run package-jssdk:typecheck && pnpm run package-jssdk-nuxt:typecheck && pnpm run docs:typecheck && pnpm run playground-nuxt:typecheck && pnpm run playground-cli:typecheck && pnpm run docs:typecheck-blocks && pnpm run skills:typecheck && pnpm run skills:typecheck-blocks && pnpm run contributing:typecheck",
"release:bump": "node scripts/bump-version.mjs"
},
"devDependencies": {
Expand Down
6 changes: 6 additions & 0 deletions packages/jssdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export * from './tools/batch-ref-v3'
export * from './hook/index'
export * from './frame/index'
export * from './oauth/index'
// `B24HelperManager` has its own documentation page and the `b24jssdk-helpers`
// skill teaches constructing it directly for backend code — but it was never
// exported, so `import { B24HelperManager } from '@bitrix24/b24jssdk'` resolved
// to nothing. Found by the skills fence typecheck (#402); additive, so no
// existing code changes meaning.
export * from './helper/helper-manager'
export * from './helper/use-b24-helper'
export * from './pullClient/index'
export * from './loader-b24frame'
110 changes: 109 additions & 1 deletion scripts/__tests__/docs-lint.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { spawnSync } from 'node:child_process'
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseFrontmatter, walkMarkdownFiles, isFreshnessTrackedSource } from '../_docs-utils.mjs'
import { checkAuditFreshness, checkFrontmatterLinkTargets } from '../docs-lint.mjs'
import { checkAuditFreshness, checkFrontmatterLinkTargets, parseDirtyPaths, gitLastCommitDate } from '../docs-lint.mjs'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '..', '..')
Expand Down Expand Up @@ -154,6 +154,114 @@ test('checkAuditFreshness: a .md source never ages a page; a .ts source does', (
assert.doesNotMatch(warns[0], /SKILL\.md/)
})

test('gitLastCommitDate: an uncommitted edit to a cited source ages the page', () => {
// The trap this closes, twice hit in practice: the freshness check read only
// committed history, so running it with a cited source modified-but-not-yet-
// committed reported on a state that no longer existed — clean locally, red in
// CI the moment the commit landed.
//
// Driven through the real `gitLastCommitDate` (no `getCommitDate` seam) so the
// git plumbing itself is exercised: dirty the file, expect a warning; restore
// it, expect none.
// A throwaway file inside the repo, never a tracked source. An earlier draft
// appended a probe line to `packages/jssdk/src/types/payloads.ts` and restored
// it in a `finally` — which loses the race if the runner is killed or times
// out, leaving a real source file corrupted. `git status` needs the path to be
// inside the work tree, but it does not need it to be one that matters.
const cited = 'packages/jssdk/src/.docs-lint-freshness-probe.ts'
const abs = join(REPO_ROOT, cited)
const frontmatter = {
audited: '2020-01-01',
links: [`label: Code\nto: https://github.com/bitrix24/b24jssdk/blob/main/${cited}`]
}

try {
writeFileSync(abs, '// probe\n', 'utf8')
const warns = []
checkAuditFreshness('page.md', frontmatter, { warn: (_f, m) => warns.push(m) })

assert.equal(warns.length, 1, 'an untracked/dirty cited source must age the page')
// Today's date, not a commit's — that is the whole point. The file has no
// commit at all, so reading history alone would have skipped it silently.
assert.match(warns[0], new RegExp(`modified on ${new Date().toISOString().slice(0, 10)}`))
} finally {
rmSync(abs, { force: true })
}
})

test('parseDirtyPaths: reads every shape git status --porcelain emits', () => {
const paths = parseDirtyPaths([
' M packages/jssdk/src/types/http.ts', // modified, unstaged
'M packages/jssdk/src/index.ts', // staged
'MM scripts/docs-lint.mjs', // staged and modified again
'?? scripts/new-thing.mjs', // untracked
'A docs/content/docs/new-page.md', // added
'R old/name.ts -> packages/jssdk/src/renamed.ts', // rename: destination wins
'?? "docs/content/a file with spaces.md"' // quoted
].join('\n'))

assert.ok(paths.has('packages/jssdk/src/types/http.ts'))
assert.ok(paths.has('packages/jssdk/src/index.ts'))
assert.ok(paths.has('scripts/docs-lint.mjs'))
assert.ok(paths.has('scripts/new-thing.mjs'))
assert.ok(paths.has('docs/content/docs/new-page.md'))
// The rename records where the file IS, not where it was.
assert.ok(paths.has('packages/jssdk/src/renamed.ts'))
assert.ok(!paths.has('old/name.ts'))
assert.ok(paths.has('docs/content/a file with spaces.md'))
assert.equal(paths.size, 7)
})

test('gitLastCommitDate: a dirty TRACKED source reports now, not its commit date', () => {
// The primary case, and the one neither probe-file test reaches: both exit
// through the untracked/ignored fallback, so disabling the dirty branch left
// them green. Tested at the function that owns the branch, with `isDirty`
// injected — the alternative, dirtying a real tracked source, is the exact
// hazard the probe tests were rewritten to avoid.
const cited = 'packages/jssdk/src/core/result.ts'
const today = new Date().toISOString().slice(0, 10)

const clean = gitLastCommitDate(cited, () => false)
assert.ok(clean, 'a tracked file must have a commit date')
assert.notEqual(clean.slice(0, 10), today, 'fixture assumption: result.ts was not committed today')

const dirty = gitLastCommitDate(cited, () => true)
assert.equal(dirty.slice(0, 10), today, 'a dirty source must report as modified now')
})

test('gitLastCommitDate: a gitignored cited source ages the page too', () => {
// The narrower half of the same trap, and the one the test above does NOT
// reach: an untracked file shows up in `git status` as `??`, so it exits
// through the dirty branch. A GITIGNORED file appears in neither `git status`
// nor `git log` — so before this it returned null and the page was skipped in
// silence, which is the worst of the three outcomes.
//
// `.docs-typecheck/tmp/` is ignored by .gitignore:112, which is what makes it
// usable here.
const dir = join(REPO_ROOT, '.docs-typecheck', 'tmp')
const cited = '.docs-typecheck/tmp/freshness-probe.ts'
const abs = join(REPO_ROOT, cited)
mkdirSync(dir, { recursive: true })

try {
writeFileSync(abs, '// probe\n', 'utf8')
// Precondition, asserted rather than assumed: git must be blind to it both ways.
assert.equal(spawnSync('git', ['status', '--porcelain', '--', cited], { cwd: REPO_ROOT, encoding: 'utf8' }).stdout.trim(), '')
assert.equal(spawnSync('git', ['log', '-1', '--format=%cI', '--', cited], { cwd: REPO_ROOT, encoding: 'utf8' }).stdout.trim(), '')

const warns = []
checkAuditFreshness('page.md', {
audited: '2020-01-01',
links: [`label: Code\nto: https://github.com/bitrix24/b24jssdk/blob/main/${cited}`]
}, { warn: (_f, m) => warns.push(m) })

assert.equal(warns.length, 1, 'a cited source git cannot vouch for must age the page')
assert.match(warns[0], new RegExp(`modified on ${new Date().toISOString().slice(0, 10)}`))
} finally {
rmSync(abs, { force: true })
}
})

// ── checkFrontmatterLinkTargets (#117) ───────────────────────────────────

test('checkFrontmatterLinkTargets: errors on a blob/main link whose file is gone', () => {
Expand Down
Loading
Loading