@iceinvein/agent-skills 0.1.39 → 0.2.0
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/README.md +18 -2
- package/dist/cli/index.js +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/skill.json +32 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { balanceOf, boundsOf, censusKey, validateCensus } from './census.ts'
|
|
2
|
+
import { loadConfig } from './config.ts'
|
|
3
|
+
import { LockError, withStoreLock } from './lock.ts'
|
|
4
|
+
import { assertNotUnderSource, storePaths } from './paths.ts'
|
|
5
|
+
import { isPhase, PHASES, recordBatch } from './phases.ts'
|
|
6
|
+
import { readJsonFile, readRows, writeRows } from './store.ts'
|
|
7
|
+
import type { Census } from './types.ts'
|
|
8
|
+
|
|
9
|
+
export async function runCensus(opts: {
|
|
10
|
+
root: string
|
|
11
|
+
file: string
|
|
12
|
+
forceUnlock?: boolean
|
|
13
|
+
}): Promise<number> {
|
|
14
|
+
const cfg = await loadConfig(opts.root)
|
|
15
|
+
let parsed: unknown
|
|
16
|
+
try {
|
|
17
|
+
parsed = await readJsonFile(opts.file)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Missing or unparseable file: a usage error, same class as a malformed
|
|
20
|
+
// batch file in import-cmd.ts, not something the balance check can see.
|
|
21
|
+
process.stderr.write(`census: ${(e as Error).message}\n`)
|
|
22
|
+
return 2
|
|
23
|
+
}
|
|
24
|
+
const result = validateCensus(parsed)
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
// Not shaped like a census record at all: missing/mistyped fields, an
|
|
27
|
+
// unknown kind, or a duplicate queued id/skipped element. This is the
|
|
28
|
+
// analogue of import-cmd.ts's envelope check, not its row check, because
|
|
29
|
+
// a census file has no envelope -- the file is the one row -- so a usage
|
|
30
|
+
// error (2) is the right class, distinct from a balance failure below.
|
|
31
|
+
for (const e of result.errors) process.stderr.write(`census: ${e}\n`)
|
|
32
|
+
return 2
|
|
33
|
+
}
|
|
34
|
+
// The record is structurally sound; balance is the one substantive domain
|
|
35
|
+
// claim this task exists to check, so it gets its own exit-code class (1,
|
|
36
|
+
// an operation failure) instead of being folded into shape validation.
|
|
37
|
+
const imbalance = balanceOf(result.value) ?? boundsOf(result.value)
|
|
38
|
+
if (imbalance) {
|
|
39
|
+
process.stderr.write(`census: ${imbalance}\n`)
|
|
40
|
+
return 1
|
|
41
|
+
}
|
|
42
|
+
const path = storePaths(opts.root).census
|
|
43
|
+
try {
|
|
44
|
+
await assertNotUnderSource(path, cfg.source.path)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// A store whose configured source.path resolves to include its own
|
|
47
|
+
// target path can never be written to, regardless of this record's
|
|
48
|
+
// content: an environment/config problem, not a balance-check-visible
|
|
49
|
+
// property of the data, so it gets the same usage-error class as the
|
|
50
|
+
// shape check above, not the balance-check's operation failure (1).
|
|
51
|
+
process.stderr.write(`census: ${(e as Error).message}\n`)
|
|
52
|
+
return 2
|
|
53
|
+
}
|
|
54
|
+
const key = censusKey(result.value)
|
|
55
|
+
if (!isPhase(result.value.phase)) {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
`census: unknown phase ${result.value.phase}; want one of ${PHASES.join(', ')}\n`,
|
|
58
|
+
)
|
|
59
|
+
return 2
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await withStoreLock(
|
|
63
|
+
opts.root,
|
|
64
|
+
async () => {
|
|
65
|
+
const existing = await readRows<Census>(path)
|
|
66
|
+
// A re-run of the same census subject replaces its record rather than
|
|
67
|
+
// stacking a second one, so the gate never sees two answers for one
|
|
68
|
+
// subject.
|
|
69
|
+
const rows = existing.filter((r) => censusKey(r) !== key)
|
|
70
|
+
rows.push(result.value)
|
|
71
|
+
// The batch is committed before the census row is written, not after.
|
|
72
|
+
// A census record is the evidence that a lens closed, so a census row
|
|
73
|
+
// on disk with no batch behind it is exactly the defect this
|
|
74
|
+
// mechanism exists to close. The other order of failure is the safe
|
|
75
|
+
// one: a batch entry with no census row behind it is inert, since
|
|
76
|
+
// nothing treats phases.json's batch list as meaningful except as
|
|
77
|
+
// corroboration for a census row that is also expected to exist. If
|
|
78
|
+
// one of the two writes below has to be missing when this throws
|
|
79
|
+
// partway through, it must be the row, not the batch.
|
|
80
|
+
// isPhase is checked again here, not only above before the lock: the
|
|
81
|
+
// guard above already proved this true, but TypeScript cannot carry
|
|
82
|
+
// that narrowing of result.value.phase across the closure boundary,
|
|
83
|
+
// so the runtime-redundant check satisfies the type checker.
|
|
84
|
+
if (isPhase(result.value.phase)) {
|
|
85
|
+
await recordBatch(
|
|
86
|
+
opts.root,
|
|
87
|
+
result.value.phase,
|
|
88
|
+
{ id: result.value.batch, count: 1 },
|
|
89
|
+
cfg.source.path,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
await writeRows(path, rows, cfg.source.path)
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
cmd: 'census',
|
|
96
|
+
...(opts.forceUnlock ? { force: true } : {}),
|
|
97
|
+
onWait: (m) => process.stderr.write(`census: ${m}\n`),
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e instanceof LockError) {
|
|
102
|
+
process.stderr.write(`census: ${e.message}\n`)
|
|
103
|
+
return 3
|
|
104
|
+
}
|
|
105
|
+
throw e
|
|
106
|
+
}
|
|
107
|
+
process.stdout.write(`census: recorded ${key}\n`)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { isValidSlug } from './ids.ts'
|
|
2
|
+
import type { Census, Direction, Skipped } from './types.ts'
|
|
3
|
+
import type { Validated } from './validate.ts'
|
|
4
|
+
import { isRecord } from './validate.ts'
|
|
5
|
+
|
|
6
|
+
const QUEUE_PREFIX = 'q-'
|
|
7
|
+
|
|
8
|
+
// A queue id is q- followed by a lowercase kebab-case slug (ids.ts's own
|
|
9
|
+
// shape, reused rather than a second regex). This is deliberately a format
|
|
10
|
+
// constraint, not a blocklist of specific cosmetic defects: an uppercase
|
|
11
|
+
// letter, a stray space, or trailing punctuation are all rejected because
|
|
12
|
+
// none of them produces a well-formed slug, not because each was chased
|
|
13
|
+
// down individually.
|
|
14
|
+
function isValidQueueId(s: string): boolean {
|
|
15
|
+
return s.startsWith(QUEUE_PREFIX) && isValidSlug(s.slice(QUEUE_PREFIX.length))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Trim and case-fold, and stop there: this is the comparison form used for
|
|
19
|
+
// duplicate and cross-list checks, never the stored form. Element names are
|
|
20
|
+
// free text naming real things in a legacy source, so nothing past
|
|
21
|
+
// whitespace and case is safe to normalize away; 'orders.' and 'orders' can
|
|
22
|
+
// legitimately be two different elements, and collapsing punctuation or
|
|
23
|
+
// internal whitespace would start rejecting valid input.
|
|
24
|
+
function normalizeForComparison(s: string): string {
|
|
25
|
+
return s.trim().toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function censusKey(record: Census): string {
|
|
29
|
+
if (record.kind === 'lens') return `lens:${record.surface}`
|
|
30
|
+
if (record.kind === 'attribute') return `attribute:${record.subject}`
|
|
31
|
+
if (record.kind === 'rule-sweep') return `rule-sweep:${record.subject}`
|
|
32
|
+
return `closer:${record.closer}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function balanceOf(record: Census): string | null {
|
|
36
|
+
if (record.kind === 'lens') {
|
|
37
|
+
const sum = record.in_ledger + record.added + record.skipped.length + record.queued.length
|
|
38
|
+
if (sum !== record.total) {
|
|
39
|
+
return `lens census for ${record.surface} does not balance: total ${record.total} but in_ledger ${record.in_ledger} + added ${record.added} + skipped ${record.skipped.length} + queued ${record.queued.length} = ${sum}`
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
if (record.kind === 'attribute') {
|
|
44
|
+
const sum = record.explained + record.queued.length
|
|
45
|
+
if (sum !== record.behavioral) {
|
|
46
|
+
return `attribute census for ${record.subject} does not balance: behavioral ${record.behavioral} but explained ${record.explained} + queued ${record.queued.length} = ${sum}`
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
if (record.kind === 'rule-sweep') {
|
|
51
|
+
const sum = record.as_requirements + record.queued.length
|
|
52
|
+
if (sum !== record.found) {
|
|
53
|
+
return `rule-sweep census for ${record.subject} does not balance: found ${record.found} but as_requirements ${record.as_requirements} + queued ${record.queued.length} = ${sum}`
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
const sum = record.fixed + record.queued.length
|
|
58
|
+
if (sum !== record.findings) {
|
|
59
|
+
return `closer census for ${record.closer} does not balance: findings ${record.findings} but fixed ${record.fixed} + queued ${record.queued.length} = ${sum}`
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// total is the deduped union of what the directions found. It cannot be
|
|
65
|
+
// smaller than the largest single direction (dedup removes duplicates, never
|
|
66
|
+
// originals), and it cannot exceed their concatenation (dedup can only
|
|
67
|
+
// shrink). Neither bound needs the source, and together they close the two
|
|
68
|
+
// ways a total can be padded arithmetically. total remains unverifiable in
|
|
69
|
+
// principle: nothing on this side of the source can know how many tables the
|
|
70
|
+
// legacy system really has.
|
|
71
|
+
export function boundsOf(record: Census): string | null {
|
|
72
|
+
if (record.kind !== 'lens' && record.kind !== 'attribute') return null
|
|
73
|
+
const entries = Object.entries(record.directions)
|
|
74
|
+
if (entries.length === 0) return null
|
|
75
|
+
const label =
|
|
76
|
+
record.kind === 'lens'
|
|
77
|
+
? `lens census for ${record.surface}`
|
|
78
|
+
: `attribute census for ${record.subject}`
|
|
79
|
+
// This function's own type signature promises record.directions is a
|
|
80
|
+
// Record<string, Direction>, but a hand-edited census.jsonl can still put
|
|
81
|
+
// a bare number or any other malformed value there without ever going
|
|
82
|
+
// through validateCensus. Object.values(...).map(d => d.count) on such a
|
|
83
|
+
// value silently produces undefined, which turns both max and sum into
|
|
84
|
+
// NaN, and total < NaN / total > NaN are both false in JavaScript, so the
|
|
85
|
+
// bound check would otherwise agree with a record it never actually read.
|
|
86
|
+
// check.ts's gate validates every row before calling this function, so in
|
|
87
|
+
// practice a malformed direction should never reach here, but this stays
|
|
88
|
+
// belt and braces so a future caller cannot reintroduce the silent pass by
|
|
89
|
+
// skipping that validation.
|
|
90
|
+
const counts: number[] = []
|
|
91
|
+
for (const [name, direction] of entries) {
|
|
92
|
+
const count = isRecord(direction) ? direction.count : undefined
|
|
93
|
+
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
|
94
|
+
return `${label}: direction ${name} has no usable count (found ${JSON.stringify(count)})`
|
|
95
|
+
}
|
|
96
|
+
counts.push(count)
|
|
97
|
+
}
|
|
98
|
+
const max = Math.max(...counts)
|
|
99
|
+
const sum = counts.reduce((a, b) => a + b, 0)
|
|
100
|
+
if (record.total < max) {
|
|
101
|
+
return `${label}: total ${record.total} is below the largest direction count ${max}; a deduped union cannot be smaller than one of its inputs`
|
|
102
|
+
}
|
|
103
|
+
if (record.total > sum) {
|
|
104
|
+
return `${label}: total ${record.total} exceeds the ${sum} findings its directions reported; a deduped union cannot exceed their concatenation`
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validateCensus(row: unknown): Validated<Census> {
|
|
110
|
+
// A row must be a JSON object with a recognized kind before any field is
|
|
111
|
+
// dereferenced. A null, a string, a number or an array must be rejected
|
|
112
|
+
// cleanly here rather than throwing on the first property read. Messages
|
|
113
|
+
// here carry no 'census: ' prefix of their own: the call site owns that
|
|
114
|
+
// prefix, so callers never have to strip or double it.
|
|
115
|
+
if (!isRecord(row)) return { ok: false, errors: ['row must be a JSON object'] }
|
|
116
|
+
const errors: string[] = []
|
|
117
|
+
const r = row
|
|
118
|
+
const kind = r.kind
|
|
119
|
+
const num = (k: string): number => {
|
|
120
|
+
const v = r[k]
|
|
121
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
|
|
122
|
+
errors.push(`${k} must be a non-negative integer`)
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
return v
|
|
126
|
+
}
|
|
127
|
+
const text = (k: string): string => {
|
|
128
|
+
const v = r[k]
|
|
129
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
130
|
+
errors.push(`${k} is required`)
|
|
131
|
+
return ''
|
|
132
|
+
}
|
|
133
|
+
return v
|
|
134
|
+
}
|
|
135
|
+
// The lens contract requires at least two independent directions, and the
|
|
136
|
+
// count each one produced is only auditable if the record also says how it
|
|
137
|
+
// was produced. Both are checked here rather than left to the phase manual,
|
|
138
|
+
// because a discipline the tool can enforce should not be a discipline the
|
|
139
|
+
// agent maintains.
|
|
140
|
+
const directionsMap = (k: string): Record<string, Direction> => {
|
|
141
|
+
const v = r[k]
|
|
142
|
+
if (!isRecord(v)) {
|
|
143
|
+
errors.push(`${k} must be an object mapping each direction name to {count, evidence}`)
|
|
144
|
+
return {}
|
|
145
|
+
}
|
|
146
|
+
const out: Record<string, Direction> = {}
|
|
147
|
+
for (const [name, raw] of Object.entries(v)) {
|
|
148
|
+
if (typeof raw === 'number') {
|
|
149
|
+
errors.push(
|
|
150
|
+
`${k}.${name} uses the old bare-count shape; write {"count": ${raw}, "evidence": "<the command or method that produced this count>"}`,
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (!isRecord(raw)) {
|
|
155
|
+
errors.push(`${k}.${name} must be an object with count and evidence`)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
const count = raw.count
|
|
159
|
+
const evidence = raw.evidence
|
|
160
|
+
let valid = true
|
|
161
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) {
|
|
162
|
+
errors.push(`${k}.${name}.count must be a non-negative integer`)
|
|
163
|
+
valid = false
|
|
164
|
+
}
|
|
165
|
+
if (typeof evidence !== 'string' || evidence.trim().length === 0) {
|
|
166
|
+
errors.push(
|
|
167
|
+
`${k}.${name}.evidence is required: name the command or method that produced this count`,
|
|
168
|
+
)
|
|
169
|
+
valid = false
|
|
170
|
+
}
|
|
171
|
+
if (valid) out[name] = { count: count as number, evidence: evidence as string }
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(v).length < 2) {
|
|
174
|
+
errors.push(
|
|
175
|
+
`${k} needs at least two independent directions; the lens contract does not admit a single-direction enumeration`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
// Every queued entry must be a well-formed queue id, not merely a
|
|
181
|
+
// non-empty string: a bare id-shaped check let a duplicate survive under a
|
|
182
|
+
// cosmetic disguise (different case, a trailing space, trailing
|
|
183
|
+
// punctuation), each one still counted toward .length by balanceOf. The
|
|
184
|
+
// format constraint closes that as a class rather than chasing variants.
|
|
185
|
+
// Trimmed before validating, and an entry that only becomes valid after
|
|
186
|
+
// trimming is rejected rather than silently accepted with the whitespace
|
|
187
|
+
// dropped: the point is that a stray space fails cleanly, not that it gets
|
|
188
|
+
// normalized away. The uniqueness check below then compares the one
|
|
189
|
+
// canonical (already trim-equal) value every accepted entry has.
|
|
190
|
+
const queuedList = (k: string): string[] => {
|
|
191
|
+
const v = r[k] ?? []
|
|
192
|
+
if (!Array.isArray(v)) {
|
|
193
|
+
errors.push(`${k} must be an array`)
|
|
194
|
+
return []
|
|
195
|
+
}
|
|
196
|
+
const out: string[] = []
|
|
197
|
+
v.forEach((item, i) => {
|
|
198
|
+
const trimmed = typeof item === 'string' ? item.trim() : ''
|
|
199
|
+
if (typeof item !== 'string' || trimmed !== item || !isValidQueueId(trimmed)) {
|
|
200
|
+
errors.push(`${k}[${i}] must be a valid queue id: q- followed by a lowercase slug`)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
out.push(trimmed)
|
|
204
|
+
})
|
|
205
|
+
const seen = new Map<string, number>()
|
|
206
|
+
for (const id of out) seen.set(id, (seen.get(id) ?? 0) + 1)
|
|
207
|
+
for (const [id, count] of seen) {
|
|
208
|
+
if (count > 1) errors.push(`${k} id ${id} appears ${count} times`)
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
// Every skipped entry must be an object naming a non-empty element and a
|
|
213
|
+
// non-empty reason. A repeated element is the same padding risk as a
|
|
214
|
+
// repeated queued id, and is rejected on the element alone (not full
|
|
215
|
+
// struct equality) so varying the reason text cannot evade the check.
|
|
216
|
+
const skippedList = (k: string): Skipped[] => {
|
|
217
|
+
const v = r[k] ?? []
|
|
218
|
+
if (!Array.isArray(v)) {
|
|
219
|
+
errors.push(`${k} must be an array`)
|
|
220
|
+
return []
|
|
221
|
+
}
|
|
222
|
+
const out: Skipped[] = []
|
|
223
|
+
v.forEach((item, i) => {
|
|
224
|
+
const element = isRecord(item) ? item.element : undefined
|
|
225
|
+
const reason = isRecord(item) ? item.reason : undefined
|
|
226
|
+
const valid =
|
|
227
|
+
typeof element === 'string' &&
|
|
228
|
+
element.length > 0 &&
|
|
229
|
+
typeof reason === 'string' &&
|
|
230
|
+
reason.length > 0
|
|
231
|
+
if (!valid) {
|
|
232
|
+
errors.push(`${k}[${i}] must be an object with a non-empty element and reason`)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
out.push({ element, reason })
|
|
236
|
+
})
|
|
237
|
+
// Uniqueness is enforced on trimmed, case-folded element names, not the
|
|
238
|
+
// raw text: 'orders' and ' ORDERS' name the same entry and must not pad
|
|
239
|
+
// .length as if they were two. The stored value is always the author's
|
|
240
|
+
// original text (evidence should survive verbatim); only the comparison
|
|
241
|
+
// is normalized. This is a documented limit, not a guarantee: unlike
|
|
242
|
+
// queued ids, element names are free text with no format to constrain,
|
|
243
|
+
// so two genuinely distinct strings a person would recognize as the same
|
|
244
|
+
// real thing ('orders' and 'order', or two different names for one
|
|
245
|
+
// table) are not detectable this way, and no check can make free text
|
|
246
|
+
// padding-proof in general.
|
|
247
|
+
const seen = new Map<string, string[]>()
|
|
248
|
+
for (const s of out) {
|
|
249
|
+
const key = normalizeForComparison(s.element)
|
|
250
|
+
const variants = seen.get(key) ?? []
|
|
251
|
+
variants.push(s.element)
|
|
252
|
+
seen.set(key, variants)
|
|
253
|
+
}
|
|
254
|
+
for (const [key, variants] of seen) {
|
|
255
|
+
if (variants.length > 1) {
|
|
256
|
+
errors.push(`${k} element ${key} appears ${variants.length} times (${variants.join(', ')})`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// check.ts's run-state gate looks for a lens census's batch in exactly one
|
|
263
|
+
// place, phases.enumerate.batches, and a closer census's batch in exactly
|
|
264
|
+
// one other place, phases.extract.batches, both hardcoded, not read off the
|
|
265
|
+
// record's own `phase` field. Before this check, `phase` was free text on
|
|
266
|
+
// every kind, so a lens record could declare `phase: "extract"`, have
|
|
267
|
+
// census-cmd.ts commit its batch under `extract` (it commits under
|
|
268
|
+
// whatever phase the record itself names), and the gate would then search
|
|
269
|
+
// `enumerate.batches`, find nothing, and report the batch as never
|
|
270
|
+
// committed even though it plainly was. Constraining `phase` here, at write
|
|
271
|
+
// time, to the one phase the gate will actually look in makes the writer
|
|
272
|
+
// and the gate agree by construction instead of leaving the gate to detect
|
|
273
|
+
// a mismatch after the fact. Declared but not called for a kind the gate
|
|
274
|
+
// never cross-checks against a phase (`attribute`, `rule-sweep`); the
|
|
275
|
+
// string is required there, same as always, but its value is free.
|
|
276
|
+
const requirePhase = (kindLabel: string, required: string, declared: string): void => {
|
|
277
|
+
if (declared && declared !== required) {
|
|
278
|
+
errors.push(
|
|
279
|
+
`${kindLabel} census must declare phase "${required}", the only phase gate 10 (run-state) checks its batch against; found "${declared}"`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (kind === 'lens') {
|
|
285
|
+
text('surface')
|
|
286
|
+
requirePhase('lens', 'enumerate', text('phase'))
|
|
287
|
+
text('batch')
|
|
288
|
+
num('total')
|
|
289
|
+
num('in_ledger')
|
|
290
|
+
num('added')
|
|
291
|
+
directionsMap('directions')
|
|
292
|
+
const skipped = skippedList('skipped')
|
|
293
|
+
const queued = queuedList('queued')
|
|
294
|
+
// queued ids and skipped element names are different namespaces (a
|
|
295
|
+
// queue id must be q-<slug>; an element name is unconstrained free text
|
|
296
|
+
// drawn from the legacy system), so nothing stops a skipped element from
|
|
297
|
+
// coincidentally, or deliberately, matching a queued id's exact text.
|
|
298
|
+
// Checked explicitly: see the report for why this earns its keep rather
|
|
299
|
+
// than being left to the format constraint alone. Compared on the same
|
|
300
|
+
// normalized (trimmed, case-folded) form used for the skipped-side
|
|
301
|
+
// duplicate check above, so a case or whitespace variant on the skipped
|
|
302
|
+
// side cannot dodge this the way raw comparison let it.
|
|
303
|
+
const queuedSet = new Set(queued.map(normalizeForComparison))
|
|
304
|
+
for (const s of skipped) {
|
|
305
|
+
if (queuedSet.has(normalizeForComparison(s.element))) {
|
|
306
|
+
errors.push(`${s.element} appears in both skipped and queued`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} else if (kind === 'attribute') {
|
|
310
|
+
text('surface')
|
|
311
|
+
text('subject')
|
|
312
|
+
text('phase')
|
|
313
|
+
text('batch')
|
|
314
|
+
num('total')
|
|
315
|
+
num('behavioral')
|
|
316
|
+
num('explained')
|
|
317
|
+
directionsMap('directions')
|
|
318
|
+
queuedList('queued')
|
|
319
|
+
} else if (kind === 'rule-sweep') {
|
|
320
|
+
text('subject')
|
|
321
|
+
text('phase')
|
|
322
|
+
text('batch')
|
|
323
|
+
num('probes')
|
|
324
|
+
num('found')
|
|
325
|
+
num('as_requirements')
|
|
326
|
+
queuedList('queued')
|
|
327
|
+
} else if (kind === 'closer') {
|
|
328
|
+
text('closer')
|
|
329
|
+
requirePhase('closer', 'extract', text('phase'))
|
|
330
|
+
text('batch')
|
|
331
|
+
num('checked')
|
|
332
|
+
num('findings')
|
|
333
|
+
num('fixed')
|
|
334
|
+
queuedList('queued')
|
|
335
|
+
} else {
|
|
336
|
+
errors.push(`unknown kind ${String(kind)}`)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
340
|
+
const record = row as Census
|
|
341
|
+
return { ok: true, value: record }
|
|
342
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { runCheck } from './check.ts'
|
|
2
|
+
import type { Phase } from './phases.ts'
|
|
3
|
+
|
|
4
|
+
export async function runCheckCmd(opts: {
|
|
5
|
+
root: string
|
|
6
|
+
citations?: boolean
|
|
7
|
+
leaks?: boolean
|
|
8
|
+
phase?: Phase
|
|
9
|
+
}): Promise<number> {
|
|
10
|
+
const result = await runCheck(opts)
|
|
11
|
+
process.stdout.write(`${result.summary}\n`)
|
|
12
|
+
if (result.violations.length === 0) return 0
|
|
13
|
+
|
|
14
|
+
process.stdout.write(`\nViolations (${result.violations.length}):\n`)
|
|
15
|
+
let lastGate = ''
|
|
16
|
+
for (const v of result.violations) {
|
|
17
|
+
if (v.gate !== lastGate) {
|
|
18
|
+
process.stdout.write(` ${v.gate}:\n`)
|
|
19
|
+
lastGate = v.gate
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write(` ${v.message}\n`)
|
|
22
|
+
}
|
|
23
|
+
return 1
|
|
24
|
+
}
|