@agentskit/doc-bridge 1.10.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +8 -4
  3. package/action.yml +1 -1
  4. package/dist/cli/program.js +111 -6
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/config/index.d.ts +1 -1
  7. package/dist/config/index.js +2 -0
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/{index-7wYGbllW.d.ts → index-CimDq6_e.d.ts} +2 -0
  10. package/dist/index.d.ts +51 -5
  11. package/dist/index.js +98 -4
  12. package/dist/index.js.map +1 -1
  13. package/docs/agent-corpus/audit.md +25 -0
  14. package/docs/agent-corpus/bin.md +26 -0
  15. package/docs/agent-corpus/budget.md +26 -0
  16. package/docs/agent-corpus/config.md +33 -0
  17. package/docs/agent-corpus/federation.md +25 -0
  18. package/docs/agent-corpus/findings.md +25 -0
  19. package/docs/agent-corpus/fixes.md +25 -0
  20. package/docs/agent-corpus/lib.md +29 -0
  21. package/docs/agent-corpus/metrics.md +26 -0
  22. package/docs/agent-corpus/parity.md +33 -0
  23. package/docs/agent-corpus/playbook.md +22 -0
  24. package/docs/agent-corpus/plugins.md +23 -0
  25. package/docs/agent-corpus/report.md +27 -0
  26. package/docs/agent-corpus/retrieval.md +33 -0
  27. package/docs/agent-corpus/rules.md +30 -0
  28. package/docs/agent-corpus/safety.md +23 -0
  29. package/docs/agent-corpus/schemas.md +34 -0
  30. package/docs/agent-corpus/scripts.md +29 -0
  31. package/docs/agent-corpus/shims.md +26 -0
  32. package/docs/parity/public-claims-v1.json +18 -1
  33. package/docs/spec/config-v1.md +23 -1
  34. package/mcpb/manifest.json +1 -1
  35. package/package.json +2 -2
  36. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  37. package/src/cli/program.ts +23 -2
  38. package/src/config/schema.ts +2 -0
  39. package/src/discovery/reproducibility.ts +118 -0
  40. package/src/doctor/run-doctor.ts +24 -0
  41. package/src/gates/run-gates.ts +44 -0
  42. package/src/index.ts +5 -0
  43. package/src/version.ts +1 -1
@@ -99,6 +99,7 @@ import { independentlyAdjudicateStudyLedger, persistIndependentlyAdjudicatedLedg
99
99
  import { formatStudyProviderCliText, parseStudyProviderCliConfig } from '../study/provider-cli.js'
100
100
  import { calculateStudyMetrics, formatStudyMetricsText } from '../study/metrics.js'
101
101
  import { formatStudyVerificationText, parseStudyVerificationBinding } from '../study/verification.js'
102
+ import { checkIndexReproducibility } from '../discovery/reproducibility.js'
102
103
 
103
104
  type Command =
104
105
  | 'help'
@@ -469,7 +470,11 @@ const loadProject = (configPath?: string) => {
469
470
  return { config, configPath: path, root }
470
471
  }
471
472
 
472
- const indexDiagnostics = (config: DocBridgeConfigV1, result: ReturnType<typeof buildDocBridgeIndex>): string[] => {
473
+ const indexDiagnostics = (
474
+ root: string,
475
+ config: DocBridgeConfigV1,
476
+ result: ReturnType<typeof buildDocBridgeIndex>,
477
+ ): string[] => {
473
478
  const diagnostics: string[] = []
474
479
  const onlyDoc = result.index.knowledge.length === 1 ? result.index.knowledge[0] : undefined
475
480
  if (onlyDoc?.path === config.corpus.agent.index) {
@@ -484,6 +489,22 @@ const indexDiagnostics = (config: DocBridgeConfigV1, result: ReturnType<typeof b
484
489
  'No ownership handoffs yet. Add routing.options.ownership, package frontmatter (package + editRoot), or a monorepo plugin.',
485
490
  )
486
491
  }
492
+ /*
493
+ * Said at write time, because this is the moment the unreproducible artifact is created and the
494
+ * only moment the operator can see what their machine had that another will not.
495
+ */
496
+ const reproducibility = checkIndexReproducibility(
497
+ root,
498
+ config.index?.outFile ?? '.doc-bridge/index.json',
499
+ result.index.knowledge.map((entry) => entry.path),
500
+ )
501
+ if (reproducibility.ignored.length > 0) {
502
+ const sample = reproducibility.ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`)
503
+ diagnostics.push(
504
+ `${reproducibility.ignored.length} indexed path(s) are ignored by Git: ${sample.join(', ')}${reproducibility.ignored.length > sample.length ? `, and ${reproducibility.ignored.length - sample.length} more` : ''}.`,
505
+ 'A clean checkout will not have them, so it builds a different index. Add them to safety.exclude in doc-bridge.config.json.',
506
+ )
507
+ }
487
508
  return diagnostics
488
509
  }
489
510
 
@@ -1749,7 +1770,7 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
1749
1770
  })
1750
1771
  }
1751
1772
  const result = buildDocBridgeIndex({ root, config })
1752
- const diagnostics = indexDiagnostics(config, result)
1773
+ const diagnostics = indexDiagnostics(root, config, result)
1753
1774
  const handoffCount = Object.keys(result.index.handoffs ?? {}).length
1754
1775
  writeJson({
1755
1776
  ok: true,
@@ -122,6 +122,7 @@ export const GatesConfigSchema = z
122
122
  .array(
123
123
  z.enum([
124
124
  'index-freshness',
125
+ 'index-reproducible',
125
126
  'human-guide-links',
126
127
  'link-rot',
127
128
  'okf-type',
@@ -137,6 +138,7 @@ export const GatesConfigSchema = z
137
138
  .array(
138
139
  z.enum([
139
140
  'index-freshness',
141
+ 'index-reproducible',
140
142
  'human-guide-links',
141
143
  'link-rot',
142
144
  'okf-type',
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Whether a committed index could have been produced by a clean checkout.
3
+ *
4
+ * The recommended setup commits `.doc-bridge/index.json` so a gate can verify it, and that only
5
+ * works while the index is a function of committed content. A scan has no reason to know about
6
+ * `.gitignore` — it walks what is on disk — so a generated module or document silently joins the
7
+ * corpus on any machine that has built, and silently leaves it on one that has not. The index then
8
+ * disagrees with itself between two checkouts of the same commit, `index-freshness` reports an
9
+ * artifact that nothing invalidated, and regenerating cannot fix it because the next machine
10
+ * disagrees in the other direction.
11
+ *
12
+ * Dogfooding found this the expensive way: a 25-package monorepo whose gate passed locally and
13
+ * failed in CI because `pnpm lint` had generated one `.ts` file into the corpus before the index
14
+ * was written. The scan cannot guess which files are generated, but Git already knows, so the
15
+ * check is: of the paths this index carries, which ones does Git ignore?
16
+ *
17
+ * It only applies when the index is itself tracked. A consumer who regenerates on every run has
18
+ * nothing to reproduce, and reporting generated files to them would be noise.
19
+ */
20
+
21
+ import { execFileSync } from 'node:child_process'
22
+
23
+ export type IgnoredIndexEntry = {
24
+ readonly path: string
25
+ /** The ignore rule that matched, as `.gitignore:12:pattern`, so the fix is one lookup away. */
26
+ readonly rule: string
27
+ }
28
+
29
+ export type IndexReproducibility = {
30
+ /**
31
+ * Whether the question was answerable. False outside a Git checkout, when Git is unavailable, or
32
+ * when the index is not tracked — in all three cases `ignored` is empty and means nothing.
33
+ */
34
+ readonly checked: boolean
35
+ /** Why the check did not run, for a caller that wants to say so. */
36
+ readonly skipped?: 'no-git' | 'index-untracked'
37
+ /** Indexed paths that Git ignores, in path order. */
38
+ readonly ignored: readonly IgnoredIndexEntry[]
39
+ }
40
+
41
+ const NOT_CHECKED = (skipped: 'no-git' | 'index-untracked'): IndexReproducibility => ({
42
+ checked: false,
43
+ skipped,
44
+ ignored: [],
45
+ })
46
+
47
+ /**
48
+ * `undefined` means the command did not answer.
49
+ *
50
+ * `allowExit1` is for `check-ignore` alone, which exits 1 when nothing matched — the common answer
51
+ * rather than a failure, and it still writes whatever did match to stdout. The probes must not
52
+ * share that tolerance: `ls-files --error-unmatch` exits 1 precisely to say "not tracked", and
53
+ * reading that as success made the untracked case look checked.
54
+ */
55
+ const git = (
56
+ root: string,
57
+ args: readonly string[],
58
+ options: { readonly input?: string; readonly allowExit1?: boolean } = {},
59
+ ): string | undefined => {
60
+ try {
61
+ return execFileSync('git', args, {
62
+ cwd: root,
63
+ encoding: 'utf8',
64
+ stdio: ['pipe', 'pipe', 'ignore'],
65
+ maxBuffer: 64 * 1024 * 1024,
66
+ ...(options.input === undefined ? {} : { input: options.input }),
67
+ })
68
+ } catch (error) {
69
+ const status = (error as { status?: number }).status
70
+ if (options.allowExit1 && status === 1) return (error as { stdout?: string }).stdout ?? ''
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The paths a committed index carries that Git ignores.
77
+ *
78
+ * `git check-ignore` consults the index by default, so a tracked file that also matches an ignore
79
+ * pattern is correctly reported as not ignored — being committed is the whole point. A test pins
80
+ * that behaviour rather than trusting it.
81
+ */
82
+ export const checkIndexReproducibility = (
83
+ root: string,
84
+ indexPath: string,
85
+ paths: readonly string[],
86
+ ): IndexReproducibility => {
87
+ if (git(root, ['rev-parse', '--is-inside-work-tree']) === undefined) return NOT_CHECKED('no-git')
88
+ if (git(root, ['ls-files', '--error-unmatch', '--', indexPath]) === undefined) {
89
+ return NOT_CHECKED('index-untracked')
90
+ }
91
+ if (paths.length === 0) return { checked: true, ignored: [] }
92
+
93
+ const unique = [...new Set(paths)].sort()
94
+ const output = git(root, ['check-ignore', '--verbose', '-z', '--stdin'], {
95
+ input: `${unique.join('\0')}\0`,
96
+ allowExit1: true,
97
+ })
98
+ if (output === undefined) return NOT_CHECKED('no-git')
99
+
100
+ /*
101
+ * `-z --verbose` emits four NUL-terminated fields per match: source, line, pattern, path.
102
+ *
103
+ * A record is not the same as an answer. `--verbose` also emits the path a `!` rule rescues,
104
+ * with that negation as the pattern — and such a path is precisely one Git does *not* ignore.
105
+ * Reading the record without reading the pattern reported it as unreproducible, which is the
106
+ * opposite of what the rule says. It only shows up while the rescued path is untracked, since
107
+ * `check-ignore` stops reporting it once it is committed, so the window is narrow and the wrong
108
+ * answer inside it was confident.
109
+ */
110
+ const fields = output.split('\0')
111
+ const ignored: IgnoredIndexEntry[] = []
112
+ for (let index = 0; index + 3 < fields.length; index += 4) {
113
+ const [source, line, pattern, path] = [fields[index], fields[index + 1], fields[index + 2], fields[index + 3]]
114
+ if (!path || pattern?.startsWith('!')) continue
115
+ ignored.push({ path, rule: `${source ?? '?'}:${line ?? '?'}:${pattern ?? '?'}` })
116
+ }
117
+ return { checked: true, ignored: ignored.sort((left, right) => left.path.localeCompare(right.path)) }
118
+ }
@@ -8,6 +8,7 @@ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
8
8
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
9
9
  import { runGates, type GateRunResult } from '../gates/run-gates.js'
10
10
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
11
+ import { checkIndexReproducibility, type IndexReproducibility } from '../discovery/reproducibility.js'
11
12
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
12
13
  import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
13
14
  import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
@@ -104,6 +105,8 @@ export type DoctorCoverage = {
104
105
  readonly hasIndex: boolean
105
106
  }
106
107
  readonly gates: GateRunResult
108
+ /** Whether a clean checkout would rebuild this index, or whether generated files leak into it. */
109
+ readonly reproducibility: IndexReproducibility
107
110
  }
108
111
 
109
112
  export type DoctorReport = {
@@ -265,6 +268,22 @@ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
265
268
  })
266
269
  }
267
270
 
271
+ /*
272
+ * A warning, not an error: the index is not wrong, it is unreproducible, and every reader of it
273
+ * on another machine is the one who finds out. The fix belongs to the repository's
274
+ * `safety.exclude`, so the message names the rule that matched and lets the operator decide.
275
+ */
276
+ const { ignored } = coverage.reproducibility
277
+ if (ignored.length > 0) {
278
+ const sample = ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`)
279
+ issues.push({
280
+ severity: 'warn',
281
+ code: 'index-not-reproducible',
282
+ message: `${ignored.length} indexed path(s) are ignored by Git, so a clean checkout builds a different index: ${sample.join(', ')}${ignored.length > sample.length ? `, and ${ignored.length - sample.length} more` : ''}.`,
283
+ action: 'edit doc-bridge.config.json # add the generated paths to safety.exclude',
284
+ })
285
+ }
286
+
268
287
  for (const id of coverage.packages.missingAgentDoc) {
269
288
  issues.push({
270
289
  severity: 'warn',
@@ -423,6 +442,11 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
423
442
  hasIndex,
424
443
  },
425
444
  gates,
445
+ reproducibility: checkIndexReproducibility(
446
+ root,
447
+ config.index?.outFile ?? '.doc-bridge/index.json',
448
+ index.knowledge.map((entry) => entry.path),
449
+ ),
426
450
  }
427
451
 
428
452
  const issues = buildIssues(coverage)
@@ -9,9 +9,11 @@ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
9
9
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
10
10
  import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js'
11
11
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
12
+ import { checkIndexReproducibility } from '../discovery/reproducibility.js'
12
13
 
13
14
  export type GateId =
14
15
  | 'index-freshness'
16
+ | 'index-reproducible'
15
17
  | 'human-guide-links'
16
18
  | 'okf-type'
17
19
  | 'docs-style'
@@ -54,6 +56,48 @@ export const runGate = (
54
56
  if (id === 'human-guide-links') return runHumanGuideLinksGate(root, config)
55
57
  if (id === 'okf-type') return runOkfTypeGate(root, config)
56
58
  if (id === 'docs-style') return runDocsStyleGate(root, config)
59
+ if (id === 'index-reproducible') {
60
+ /*
61
+ * Opt-in, and in no preset: a repository that regenerates the index on every run has nothing
62
+ * to enforce, and turning this on for everyone would fail gates that were passing for good
63
+ * reasons. `gates.include: ['index-reproducible']` is how a repository that commits the index
64
+ * says it wants the guarantee enforced rather than merely reported.
65
+ */
66
+ let index
67
+ try {
68
+ index = loadDocBridgeIndex(root, config)
69
+ } catch (error) {
70
+ if (error instanceof IndexNotFoundError) return { id, ok: false, message: error.message }
71
+ throw error
72
+ }
73
+ const result = checkIndexReproducibility(
74
+ root,
75
+ config.index?.outFile ?? '.doc-bridge/index.json',
76
+ index.knowledge.map((entry) => entry.path),
77
+ )
78
+ if (!result.checked) {
79
+ return {
80
+ id,
81
+ ok: true,
82
+ message:
83
+ result.skipped === 'index-untracked'
84
+ ? 'Index is not committed, so nothing has to reproduce it'
85
+ : 'Not a Git checkout; reproducibility was not checked',
86
+ }
87
+ }
88
+ if (result.ignored.length === 0) {
89
+ return { id, ok: true, message: 'Every indexed path is committed' }
90
+ }
91
+ const sample = result.ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`)
92
+ return {
93
+ id,
94
+ ok: false,
95
+ message: `${result.ignored.length} indexed path(s) are ignored by Git, so a clean checkout builds a different index. Add them to safety.exclude.`,
96
+ expected: 'every indexed path is committed',
97
+ actual: sample.join(', '),
98
+ }
99
+ }
100
+
57
101
  if (id !== 'index-freshness') throw new Error(`Unsupported gate "${id}"`)
58
102
 
59
103
  let current: string
package/src/index.ts CHANGED
@@ -98,6 +98,11 @@ export {
98
98
  repositoryInputs,
99
99
  type RepositoryInputsV1,
100
100
  } from './index-builder/project-corpus.js'
101
+ export {
102
+ checkIndexReproducibility,
103
+ type IgnoredIndexEntry,
104
+ type IndexReproducibility,
105
+ } from './discovery/reproducibility.js'
101
106
  export {
102
107
  DOCUMENT_BODY_LIMIT,
103
108
  EMPTY_OVERLAY_HASH,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '1.10.1'
1
+ export const PACKAGE_VERSION = '1.11.1'