@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.
- package/CHANGELOG.md +52 -0
- package/README.md +8 -4
- package/action.yml +1 -1
- package/dist/cli/program.js +111 -6
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -1
- package/dist/{index-7wYGbllW.d.ts → index-CimDq6_e.d.ts} +2 -0
- package/dist/index.d.ts +51 -5
- package/dist/index.js +98 -4
- package/dist/index.js.map +1 -1
- package/docs/agent-corpus/audit.md +25 -0
- package/docs/agent-corpus/bin.md +26 -0
- package/docs/agent-corpus/budget.md +26 -0
- package/docs/agent-corpus/config.md +33 -0
- package/docs/agent-corpus/federation.md +25 -0
- package/docs/agent-corpus/findings.md +25 -0
- package/docs/agent-corpus/fixes.md +25 -0
- package/docs/agent-corpus/lib.md +29 -0
- package/docs/agent-corpus/metrics.md +26 -0
- package/docs/agent-corpus/parity.md +33 -0
- package/docs/agent-corpus/playbook.md +22 -0
- package/docs/agent-corpus/plugins.md +23 -0
- package/docs/agent-corpus/report.md +27 -0
- package/docs/agent-corpus/retrieval.md +33 -0
- package/docs/agent-corpus/rules.md +30 -0
- package/docs/agent-corpus/safety.md +23 -0
- package/docs/agent-corpus/schemas.md +34 -0
- package/docs/agent-corpus/scripts.md +29 -0
- package/docs/agent-corpus/shims.md +26 -0
- package/docs/parity/public-claims-v1.json +18 -1
- package/docs/spec/config-v1.md +23 -1
- package/mcpb/manifest.json +1 -1
- package/package.json +2 -2
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/cli/program.ts +23 -2
- package/src/config/schema.ts +2 -0
- package/src/discovery/reproducibility.ts +118 -0
- package/src/doctor/run-doctor.ts +24 -0
- package/src/gates/run-gates.ts +44 -0
- package/src/index.ts +5 -0
- package/src/version.ts +1 -1
package/src/cli/program.ts
CHANGED
|
@@ -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 = (
|
|
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,
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/doctor/run-doctor.ts
CHANGED
|
@@ -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)
|
package/src/gates/run-gates.ts
CHANGED
|
@@ -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.
|
|
1
|
+
export const PACKAGE_VERSION = '1.11.1'
|