Skip to content

chore: modernization sprint — bun 1.3.13 + ts 6 + zod 4 + host-io + telemetry#79

Draft
primeinc wants to merge 36 commits into
nextfrom
chore/bun-modernization
Draft

chore: modernization sprint — bun 1.3.13 + ts 6 + zod 4 + host-io + telemetry#79
primeinc wants to merge 36 commits into
nextfrom
chore/bun-modernization

Conversation

@primeinc

Copy link
Copy Markdown
Owner

Summary

Comprehensive modernization sprint of the github-stars control plane, ported from doctrines validated in juv2 + westcore-x1 templates. 13 commits, 86 files, +10.2k / -5.3k.

Toolchain pivot

  • bun 1.3.13 (engines.bun >=1.3.13, packageManager bun@1.3.13). All pnpmbun run across 4 server-side workflows.
  • TypeScript 6.0.3 strict (exactOptionalPropertyTypes, noUncheckedIndexedAccess, verbatimModuleSyntax).
  • bun:test replaces vitest (mock(), toHaveBeenCalledTimes).
  • biome 2.4.15 formatter/linter + eslint 10 flat config (typescript-eslint, n, security, jsdoc, tsdoc, zod, jest plugins).
  • @exodus/schemasafe zero-dep JSON-Schema parser; @octokit/rest subsumes @octokit/core + plugins.

Architecture layers

  • `src/host-io/*` — sole consumer of `node:fs` / `node:path` / `node:os` / `node:child_process`. Defense-in-depth via eslint `no-restricted-imports` + dependency-cruiser + biome.
  • `src/contracts/*` — `registry.ts` (Zod schema registry, ids `contract.github-stars...v`), `env.ts` (env-key catalog, satisfies clause guarantees parity), `paths.ts` + `paths-config.ts` + `paths-codegen.ts` (paths-as-data, single source of truth).
  • `src/manifest/schema.zod.ts` — 5 registered schemas + reusable primitives. `json-schema.ts` derives `schemas/repos-schema.json` via `z.toJSONSchema(target: "draft-2020-12")`.
  • `src/telemetry/*` — westcore-x1 OTel shape (NodeSDK + standard OTEL_* env vars + endpoint-gate skip). `registerTelemetry({serviceName})`, `createLogger(scope)`, `scrubAttribute(value)`, `PINO_REDACT_PATHS`. Quarantined to src/telemetry/** by eslint. Observability ONLY — never auth signal.

Type doctrine (no handrolling SDKs)

  • GitHub REST shapes derived from `@octokit/openapi-types` (`components["schemas"]["starred-repository"]`).
  • GitHub GraphQL shapes derived from `@octokit/graphql-schema` (`Pick<Repository, ...>`, `Pick<PageInfo, ...>`, `Pick<StarredRepositoryEdge, ...>`).
  • `Maybe` (T | null | undefined) coalesced to project's `T | null` contract at the boundary, not deeper.

Gate pipeline (`bun run gate`)

9 stages, all green:

  1. typecheck
  2. lint (biome + eslint, --no-inline-config)
  3. test (bun test, 112 tests)
  4. validate (taxonomy + JSON Schema)
  5. no-loose-zod (custom scanner bans `z.any()` / `z.unknown()` at call sites)
  6. dependency-cruiser (11 architecture rules, 0 violations)
  7. knip (unused files / exports / deps; respects `+public` TSDoc tag)
  8. generated-artifacts registry
  9. actionlint (workflow YAML)

Doctrines locked in

  • No deferrals — every commit lands green.
  • No handrolling SDKs — first-party typed SDKs only.
  • Canonical references first — `../refs` is source of truth.
  • TSDoc on every public export — clone-friendly starter doctrine.
  • Zod metadata canonical — `.register(reg, meta)` + TSDoc orthogonal.
  • westcore-x1 OTel shape for telemetry (not juv2 backend registry).

Workflows migrated server-side

  • `00-ci.yml`, `01-fetch-stars.yml`, `02-sync-stars.yml`, `03-classify-repos.yml` → `oven-sh/setup-bun@v2` + `bun install --frozen-lockfile`.
  • `00b-web-ci.yml` and `04-build-site.yml` intentionally left on npm — they build the separate `web/` React package with its own package-lock.json (out of scope for server-side migration).

Test plan

  • CI green on this PR (all jobs in 00-ci.yml workflow).
  • Manual dispatch of 01-fetch-stars.yml verifies the bun migration runs end-to-end on the runner.
  • Manual dispatch of 02-sync-stars.yml verifies the bun migration + Zod-derived schema gate.
  • Verify telemetry no-op path: when `OTEL_EXPORTER_OTLP_ENDPOINT` unset, CLIs run without connection-refused noise.

🤖 Generated with Claude Code

primeinc and others added 13 commits May 10, 2026 08:18
…gistry

Foundation commit for the bun modernization. Subsequent commits add
biome 2, eslint 10 + plugins, knip, dependency-cruiser, paths-as-data,
host-io boundary, octokit-app + openapi-types, zod replacement of ajv,
westcore-x1-shaped telemetry, and the TanStack Start app.

Locked floors:
  - bun >= 1.3.13 (test-runner upgrades)
  - typescript ~6.0.3 (typescript-eslint peer caps <6.1.0)
  - zod ^4.3.6
  - eslint ^10.2.1 + plugin set
  - pino ^10 (pino-opentelemetry-transport@3 peer)

Toolchain:
  - bunfig.toml: linker isolated, saveTextLockfile, [test] preload + ignore
  - tsconfig.base.json: types ['bun'], verbatimModuleSyntax,
    exactOptionalPropertyTypes, noUncheckedIndexedAccess, all strict flags
  - lefthook.yml: pre-commit reject-private-keys + biome auto-fix;
    pre-push runs `bun run gate`
  - .gitignore: agent state (.claude / .serena / .sisyphus), generated/

New contracts layer (juv2 shape):
  - src/contracts/registry.ts: GhStarsSchemaRegistry (Zod 4 z.registry +
    reverse id->schema map). Hard-fail on duplicate id with different
    schema instance. Doctrine source juv2 packages/contracts-core/src/registry.ts.
  - src/contracts/env.ts: GH_STARS_ENV_KEYS tuple + GhStarsEnvKeySchema
    (registered) + GhStarsEnv dictionary (`as const satisfies`). Single
    source of truth for every env var the kernel reads.

Test preloads (juv2 shape):
  - tests/setup/deterministic-env.ts: OTEL_SDK_DISABLED, LOG_LEVEL trace,
    frozen GITHUB_RUN_* defaults
  - tests/setup/strict-mode.ts: setSystemTime 2026-01-01 in beforeAll;
    loud-fail on unhandledRejection / uncaughtException
  - tests/setup/schema-registry.ts: side-effect imports of every contract
    module so registry is populated before tests read it

Drops:
  - pnpm-lock.yaml, tsx, vitest, ajv, ajv-formats, @exodus/schemasafe,
    @octokit/core + @octokit/auth-app + @octokit/plugin-* (replaced by
    @octokit/app + @octokit/rest + @octokit/openapi-types in subsequent
    commits), @types/node (replaced by @types/bun)

Known follow-ups (handled in next commits, not bypassed):
  - typecheck currently fails (~50 errors) due to TS 6 strict shape.
    Errors fall in 3 classes:
      TS4111 index-signature bracket access  -> fixed by GhStarsEnv migration
      TS2532/TS18048 noUncheckedIndexedAccess -> requires guards
      TS2379/TS2322 exactOptionalPropertyTypes -> requires conditional spread
    These land in the upcoming code-adapt commits, not in this commit
    (which is intentionally infrastructure-only).
  - biome.json, eslint.config.ts, knip.ts, paths catalog, host-io
    boundary, telemetry, octokit-app migration, dual-write CLI shape:
    each lands as its own commit per the read-the-room evidence ledger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…QL fixes

biome.json shape:
  - vcs.useIgnoreFile (honors .gitignore)
  - excludes: docs, web, fixtures, queries, schemas/repos-schema.json,
    issues, categories, tags, .github-stars/data, lockfiles, generated/
  - formatter: tabs + double quotes + always-semis (matches juv2 + bun ecosystem)
  - linter: recommended + complexity.useLiteralKeys off + correctness.noUnresolvedImports off
  - Per-script overrides for scripts/*.{js,mjs,cjs} (templated GH-Actions
    `console.log` lives there; biome's useTemplate / useNodejsImportProtocol
    rules stay enabled for src/ but off for legacy scripts).
  - noImportCycles: OFF — biome 2.4 stack-overflows on Windows. Coverage
    moves to dependency-cruiser's no-circular rule (Phase B10).

Auto-fix sweep applied via `biome check --write [--unsafe]` over src/ + tests/:
  - 41 files reformatted (tabs, quotes, semis).
  - useTemplate: every string concatenation in src/ → template literal.
  - useNodejsImportProtocol: `from "path"` → `from "node:path"`.
  - useOptionalChain: `x && x.foo` → `x?.foo`.
  - noUnusedImports: dropped (closes CodeQL #5: unused `path` in cli-normalize).
  - noUnusedVariables: dropped (closes CodeQL #6 + #7: dead
    `lastSucceededPage` in list-paginator-rest).

Structural fixes (real bugs, not blanket lint suppressions):
  - src/sync/reconcile.ts: build manifest_metadata fully-defined at
    construction time. Closes 5 × noNonNullAssertion (`manifest.manifest_metadata!.x`).
    The original code was a structural latent bug — the `!` assertions
    promised a guarantee the constructor didn't enforce; if a caller passed
    a manifest without manifest_metadata, the runtime would TypeError on
    first set. Now the metadata block is guaranteed-defined or the manifest
    rejects at the type system.
  - src/fetch/list-paginator.ts:87: narrow `if (partialList && partial)`
    so the loop body sees `partial` as non-null. Closes
    noUnsafeOptionalChaining — the prior code would TypeError if
    `partial` was null (it can't be when partialList is truthy, but the
    type system needs the narrowing).

Script CodeQL fixes (the originally-flagged issues):
  - scripts/generate-readmes.js:27: `\[\]` → `[]` inside character class.
    Closes CodeQL #1 (incomplete sanitization in escapeMd; the backslash
    escape was both unnecessary AND incomplete — biome's
    noUselessEscapeInRegex catches it; the underlying regex still escapes
    the input correctly, the bug was the escape's expression, not its
    runtime).
  - scripts/generate-readmes.js:38, scripts/reconstruct-repos-yml.mjs:46:
    `let badges` / `let cleaned` → `const` (useConst).

Result: bun x biome lint --error-on-warnings . — Found 0 errors.
       Pre-push lint gate passes clean.

Typecheck still fails ~50 errors (TS 6 strict shape) — those land in
Phase C as documented in the foundation commit.

Closes:
  - CodeQL #1: scripts/generate-readmes.js:27 incomplete sanitization
  - CodeQL #4: scripts/generate-readmes.js:2 unused path import (already gone)
  - CodeQL #5: src/cli-normalize.ts:1 unused path import (auto-fixed)
  - CodeQL #6, #7: src/fetch/list-paginator-rest.ts:89, :136 dead lastSucceededPage (auto-fixed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st + zod-config + bun:test + canonical TSDocs

Lint enforcement (eslint.config.ts):
  Flat config with typed-linting via projectService. Plugins: typescript-
  eslint, eslint-plugin-n, eslint-plugin-security (recommended; off
  detect-object-injection + detect-non-literal-fs-filename), eslint-
  plugin-jsdoc, eslint-plugin-tsdoc, eslint-plugin-zod, eslint-plugin-jest.

  Repo-wide bans (no-restricted-imports):
    - node:fs / node:fs/promises / node:os / node:path / node:crypto /
      node:child_process / node:util / node:zlib / node:readline /
      node:stream / node:stream/promises (+ unprefixed forms): src/host-io/** only
    - node:url { pathToFileURL }: banned absolutely (use Bun.pathToFileURL)
    - pino / pino-opentelemetry-transport / @opentelemetry/sdk-* /
      @opentelemetry/exporter-*: src/telemetry/** only
    - ajv / ajv-formats / @exodus/schemasafe: banned (use Zod)
    - @octokit/core / @octokit/auth-app / @octokit/plugin-*: banned
      (use @octokit/app + @octokit/rest + @octokit/openapi-types)

  Defense in depth: this rule + dependency-cruiser's no-non-package-json
  rule + biome's noPrivateImports. Three layers because each gate sees a
  different slice of the import graph.

  TSDoc/JSDoc gate (canonical layered pattern):
    - tsdoc/syntax: warn (per refs/microsoft/tsdoc/eslint-plugin/README L57)
    - jsdoc/check-tag-names: error with definedTags = TSDOC_STANDARD_TAGS
      sourced 1:1 from refs/microsoft/tsdoc/tsdoc/src/details/StandardTags.ts
      L557-587 (StandardTags.allDefinitions). Three classes: Core, Extended,
      Discretionary.
    - jsdoc/check-param-names + check-property-names: error
    - jsdoc/no-types: error (TS owns types; no `{type}` in doc blocks)
    - jsdoc/require-jsdoc: error with enableFixer:false. github-stars is
      a clone-friendly STARTER REPO — TSDocs are the contract surface
      forking developers read first. publicOnly.ancestorsOnly:true scopes
      to barrel-reachable exports only.
    - eslint-plugin-zod recommended.

host-io boundary (src/host-io/**):
  Sole importer of node:fs / node:fs/promises / node:os / node:path /
  node:child_process. Public surface:
    - fs.ts: readTextFileSync, writeTextFileSync, writeTextFileAtomicSync
      (write-file-atomic — closes CodeQL js/file-system-race for the whole
      class), acquireFileLockSync (proper-lockfile), pathExistsSync,
      makeDirSync, makeTempDirSync, removePathSync, copyPathSync,
      appendFileTextSync, appendFileText, listDirSync, statPathSync,
      fileSizeBytesSync, renameSync, readFileBytesSync.
    - path.ts: joinPaths, resolvePath, relativePath, dirnameOf, basenameOf,
      extnameOf, normalizePath, isAbsolutePath, pathSep.
    - process.ts: cwd, chdir, exit, setExitCode, getEnv, currentPid,
      onSignal, processArgv, platform.
    - stdio.ts: writeStdout, writeStdoutLine, writeStderr, writeStderrLine,
      stdoutIsTTY, stderrIsTTY.
    - spawn.ts: runCommandSync (subprocess wrapper).

@octokit migration (src/fetch/octokit-client.ts):
  @octokit/core + @octokit/plugin-retry + @octokit/plugin-request-log
  → @octokit/rest (bundles paginate + REST endpoint methods + retry
  via the built-in request.retries knob — no plugin churn). Same retry
  contract via the first-party knob.

zod-config + GhStarsEnv (src/auth/setup-doctor.ts):
  Reads env via the typed GhStarsEnv catalog (src/contracts/env.ts) +
  boundary-validates via DoctorEnvSchema (registered with
  GhStarsSchemaRegistry as `contract.github-stars.auth.doctor-env.v1`).
  Every env reference is a typed lookup; bad shapes fail at the
  boundary, not deep in the resolver.

vitest → bun:test:
  All 9 test files migrated. `vi.fn()` → `mock()` per Bun test API.
  Drop vitest.config.ts. `src/generated/registry.test.ts` no longer
  imports node:fs — uses host-io's pathExistsSync.

Drops:
  - src/repro-taxonomy.ts (one-shot dev demo, not in any workflow)
  - vitest.config.ts (replaced by bunfig.toml [test])

Bug fixes (real bugs surfaced by the new lint layer):
  - src/manifest/validator.ts: tag-format check rewritten as char-walk
    isValidTag() instead of regex. Closes the security/detect-unsafe-regex
    finding structurally without disabling the rule.

TSDoc canonical layered pattern (every public export now documented):
  Per refs/colinhacks/zod/packages/docs/content/metadata.mdx, TSDoc
  comments and Zod registries are orthogonal layers — TSDoc above the
  declaration explains the contract for code readers; .register(reg, meta)
  metadata explains it for runtime consumers + JSON Schema bridge.
  Every src/** public export now carries real (non-stub) TSDoc with
  Core tags first (@param, @returns, @remarks, @public), Extended where
  warranted (@example, @throws, @see).

Closes (this commit):
  - Phase B5: eslint.config.ts (the BIG enforcement layer)
  - Phase C4: zod-config + GhStarsEnv for setup-doctor
  - Phase C5: switch to @octokit/app surface
  - Phase C8 (subsumed by C9): drop existsSync TOCTOU sites
  - Phase C9: src/host-io/* (monopoly on node:fs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Typecheck under TS 6.0.3 + exactOptionalPropertyTypes + noUncheckedIndexedAccess
+ verbatimModuleSyntax was failing 30 errors after the host-io migration.
Resolves all of them; `bun run typecheck` now exits clean. `bun test` runs
86 pass / 0 fail / 225 expects in 469ms.

Categories of fix:

**exactOptionalPropertyTypes** (conditional spreads instead of `: undefined`):
  - src/fetch/fetch-stars.ts: `batchSize: opts.batchSize` where the receiver
    types it `batchSize?: number`. Pass via conditional spread so absence
    is encoded as a missing key, not a `: undefined` value.
  - src/sync/reconcile.ts: `github_metadata: r.github_metadata ? {...} : undefined`
    same pattern. Conditional spread keeps the key absent when source is
    absent.

**noUncheckedIndexedAccess** (real nullable narrowing on indexed reads):
  - src/fetch/metadata-batcher.ts: `s.repo.split("/")` slots are
    `string | undefined`. Switched to `flatMap` that skips malformed
    `<owner>/<name>` entries instead of letting undefined leak into the
    GraphQL variables map. Real defensive behavior (was a latent bug —
    a malformed entry would have stringified to "undefined" in the var).
  - src/fetch/metadata-batcher.ts: `batch[j]` narrowing — pull entry
    once and guard, avoid re-indexing.
  - src/fetch/partial-graphql.ts: regex match group `m[1]` is
    `string | undefined`; added `m?.[1]` guard. Defensive only — the
    regex captures when it matches, but the type system needs the
    narrowing under noUncheckedIndexedAccess.
  - test files (normalizer / reconcile / partial-graphql / runtime-state):
    `result.repositories[N]` and `warn.mock.calls[0]` reads now use `?.`
    chains. Tests still assert the same invariants — the optional chain
    propagates undefined through the matcher (which fails the same way).

**bun:test API compat** (vitest → bun migration leftovers):
  - src/auth/runtime-state.test.ts: `toHaveBeenCalledOnce` is vitest-only;
    bun:test exposes `toHaveBeenCalledTimes(1)`. One-line swap.

**eslint.config.ts type plumbing**:
  - `eslint-plugin-security` ships JS only (no .d.ts). Added a single
    `// @ts-expect-error TS7016` at the import site + a typed cast to a
    minimal `{ configs: { recommended: Config } }` shape so the rest of
    the file stays `any`-free.
  - `defineConfig` returns `Config[]`, not `Config`. Re-typed the local
    `config` variable accordingly. Dropped the spurious `as Config[number]`
    casts on the `securityPlugin.configs.recommended` and
    `zodPlugin.configs.recommended` spreads — they're already
    `Config`-shaped at the type level.
  - `RESTRICTED_IMPORTS_OPTIONS` and `NO_SYNC_OPTIONS` had `as const`
    that widened poorly into the rule-options type slot. Switched to
    explicit `["error", { ... }]` tuple types — same runtime, types
    accepted by the eslint rule contract.

Closes (this commit):
  - Phase C: code adapt to bun + TS6 strict
  - Phase D: CodeQL leftovers (the residual TOCTOU + dead-var sites
    were already closed structurally by the host-io migration in the
    previous commit; this commit closes the typecheck-side echoes)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stray watchdog

Two small follow-ups to the eslint flat config:

1) **Canonical citation for the two `security/*: off` rules.**
   Previous comment was hand-wavy ("high false-positive in typed-record
   + host-io's whole job IS dynamic filenames"). Now cites the canonical
   docs verbatim:

   - `security/detect-object-injection`: cites the rule's own docs at
     refs/eslint-community/eslint-plugin-security/docs/rules/detect-object-injection.md L28
     ("This rule flags any expression in the form of `object[expression]`
     no matter where it occurs") AND the plugin maintainers' own README L7
     ("This project will help identify potential security hotspots, but
     finds a lot of false positives which need triage by a human"). The
     rule fires on every `record[k]` over a typed `Record<K, V>` —
     under noUncheckedIndexedAccess these are already type-safe.

   - `security/detect-non-literal-fs-filename`: explained that
     src/host-io/** IS the repo-wide boundary that takes dynamic
     filenames by design (eslint's no-restricted-imports above
     quarantines node:fs there). Rule fires in the exact location
     where it's structurally wrong; outside host-io there are no
     node:fs imports for it to flag.

2) **`@types/eslint-plugin-security` evaluated and rejected.**
   The canonical README L88-95 says: *"Type definitions for this
   package are managed by DefinitelyTyped. Use
   @types/eslint-plugin-security for type checking."* I tried to
   install it. The package depends on `@types/eslint@*`, which
   resolves to 9.6.1 — pre-eslint-10. That version's
   `Linter.LanguageOptions` shape conflicts with `@eslint/core`'s
   newer `LanguageOptions` (used by our `defineConfig` from
   `eslint/config`). Until @types catches up to eslint v10, we keep
   the `@ts-expect-error TS7016` at the import site + a typed cast
   to a minimal `{ configs: { recommended: Config } }` shape. The
   comment now documents WHY (so the next reader doesn't try the
   same install).

3) **Dropped `scripts/epic02-watchdog.sh`** which was untracked at
   branch start (carryover from a prior session) and got pulled in
   by `git add -A` in the previous commit. Not part of the
   modernization scope; nothing references it.

`bun run typecheck` — clean. `bun test` — 86 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ccessor)

Repo-relative path strings used to live as inline literals in 6 src files
(.github-stars/data/..., repos.yml, queries/..., schemas/..., web/public/...).
Replaced with a single source of truth.

**Shape** (per juv2/packages/catalog/src/paths.ts doctrine):
  - github-stars.paths.config.ts   single source of truth: { entries: [{ key, path }] }
  - src/contracts/paths-config.ts  Zod schema + defineGhStarsPathsConfig identity helper
  - src/contracts/paths.ts         derived surface: GH_STARS_PATHS, GhStarsPathSchema,
                                   GhStarsPath, GhStarsPathKey, GhStarsPaths,
                                   getGhStarsPath
  - src/contracts/paths-codegen.ts emits generated/paths.json for non-TS consumers

**Why static-import + pass-through SyncAdapter (NOT zod-config's scriptAdapter)**:
  scriptAdapter does `await import(<runtime variable specifier>)` per
  refs/alexmarqs/zod-config/src/lib/adapters/script-adapter/index.ts:21.
  Bun's bundler can only trace static-string specifiers per
  refs/oven-sh/bun/docs/bundler/executables.mdx:1139, so scriptAdapter
  breaks `bun build --compile` — the bundled binary's runtime import()
  walks an absolute path that exists on the dev box but not in the
  relocated VFS. Same constraint juv2 paths.ts L30-43 documents.

**Schema registry registration** (per the canonical layered TSDoc + Zod
pattern from feedback_zod_metadata_canonical.md):
  - GhStarsPathEntrySchema  → contract.github-stars.paths.entry.v1
  - GhStarsPathsConfigSchema → contract.github-stars.paths.config.v1

**Two refines** on the top-level schema enforce uniqueness across the
catalog: duplicate `key` and duplicate `path` both fail at config load,
not deep in a runtime call.

**Catalog entries (13)**:
  Manifest+fetch artifacts:
    reposManifest, reposTemplate, fetchedStarsGraphql
  Schemas:
    reposSchemaJson
  Generated docs:
    topReadme, categoriesDir, tagsDir
  Web feeds:
    webPublicDataJson, docsDataJson
  GraphQL queries:
    starsListQuery, starsMetadataFragment
  Generated outputs:
    generatedPathsJson, cliReportsRoot

**Migrated consumers** (all literal strings → typed accessor):
  - src/cli-normalize.ts        "repos.yml" → getGhStarsPath("reposManifest")
  - src/cli-validate.ts         same
  - src/sync/cli.ts             FETCHED_STARS_PATH + MANIFEST_PATH defaults
  - src/sync/manifest-io.ts     TEMPLATE_PATH constant
  - src/fetch/cli.ts            LIST_QUERY_PATH + FRAGMENT_PATH + OUTPUT_FILE defaults

**Test preload**:
  tests/setup/schema-registry.ts now side-effect-imports
  src/contracts/env.ts and src/contracts/paths-config.ts so their
  registrations fire before any test reads the registry.

**Eslint allowlist update**:
  n/no-sync rule's `ignores` array gains `loadConfigSync` (the
  zod-config sync loader used at module init in paths.ts).

**Operator workflow**:
  Generate the JSON projection on demand: `bun run paths:generate`.
  No postinstall hook — the JSON is committed via the
  GENERATED_ARTIFACTS registry's `committed` policy and the gate's
  validateRegistry step asserts presence; drift is caught at CI, not
  silently regenerated on every install.

**Verification**:
  - bun run typecheck — clean
  - bun x eslint --max-warnings=0 . — clean
  - bun test — 86 pass / 0 fail / 225 expects in 145ms
  - bun run paths:generate — generated/paths.json byte-stable

Closes (this commit):
  - Phase B8: paths-as-data (config + schema + codegen + accessor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… sites

A new gate stage that reads every src/**/*.ts file, strips comments +
string literals (length-preserving, line-numbers stay accurate), and
scans for the substrings `z.any(` / `z.unknown(`. Either occurrence
inside actual code fails the gate.

**Why**: Zod schemas are the contract surface every public boundary
in src/** parses through. `z.any()` and `z.unknown()` accept anything
— using them in a registered schema defeats the purpose of having a
registry at all (the entry exists, but the runtime parse is a no-op).
Per the canonical Zod metadata doctrine
(refs/colinhacks/zod/packages/docs/content/metadata.mdx): schemas
encode what shape to enforce; loose escapes silently broaden every
consumer downstream.

**Doctrine source**:
  - juv2/packages/governance/src/check-no-loose-zod-core.ts (shape).
  - We diverge from juv2's hand-rolled lexer (juv2 bans regex literals
    repo-wide; we don't) and use a regex-free char-walk stripper anyway
    — same lexical equivalence, different rationale (the function
    survives its own gate's evaluation cleanly that way too).

**Files**:
  - src/gate/no-loose-zod.ts        pure: stripStringsAndComments,
                                    scanText, evaluateLooseZod, isExcluded
  - src/gate/no-loose-zod-cli.ts    runner: walks src/**, calls evaluator,
                                    emits findings + exit code
  - src/gate/no-loose-zod.test.ts   16 bun:test cases covering exclusion
                                    predicate, lexer, scanner, aggregator

**host-io addition**:
  - walkFilesSync (sync, depth-first directory walk) added to fs.ts +
    barrel re-export. Required by the runner; modeled on juv2's same
    function. n/no-sync allowlist already includes it from the prior
    eslint config commit.

**Wired into the gate**:
  - package.json: `gate:no-loose-zod` script
  - src/gate/cli.ts: new stage runs after `validate (taxonomy + schema)`
    and before `generated-artifacts registry`. Fails the gate on any
    finding.

**Verification**:
  - bun run typecheck — clean
  - bun x eslint --max-warnings=0 . — clean
  - bun test — 102 pass / 0 fail / 254 expects in 173ms (16 new tests)
  - bun run gate:no-loose-zod — 50 files scanned, 0 loose-zod, PASS

Closes (this commit):
  - Phase D2: src/gate/no-loose-zod.ts (schemas:no-loose-check)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dence module

Adds .dependency-cruiser.mjs with the canonical rule set ported from
juv2/.dependency-cruiser.mjs (rule scope retargeted from `^packages` to
`^src` since we are a flat single-package repo). Wired as a gate stage
that runs after no-loose-zod and before generated-artifacts.

**Rules**:
  no-circular           error  cycles in the import graph
  no-orphans            warn   modules nothing imports (with sane exceptions
                               for dot-files, .d.ts, tsconfig, CLI runners)
  no-deprecated-core    warn   imports of deprecated node core modules
  not-to-deprecated     warn   imports of deprecated npm packages
  no-non-package-json   error  npm imports not declared in package.json
                               (with `.d.ts` exception for Bun's content-
                               addressed install layout)
  not-to-unresolvable   error  imports that don't resolve to a real module
  no-duplicate-dep-types  warn  packages declared in both deps and devDeps
  not-to-spec           error  prod code depending on test files
  not-to-dev-dep        error  src/** code depending on devDependencies
                               (test files exempted by from.pathNot)
  optional-deps-used    info   npm-optional usage
  peer-deps-used        warn   npm-peer usage

**Bun-specific options**:
  - builtInModules.add: bun, bun:ffi, bun:jsc, bun:sqlite, bun:test,
    bun:wrap, detect-libc, undici, ws — depcruise treats these as core
    rather than unresolvable.
  - combinedDependencies: false — each consumer owns its own dep ledger.
  - tsPreCompilationDeps + detectJSDocImports + detectProcessBuiltinModuleCalls:
    surface the type-surface graph alongside runtime.
  - enhancedResolveOptions.conditionNames: ["import", "require", "node",
    "default", "types"] — match Bun's resolution.

**Wiring**:
  package.json `depcruise` script: `dependency-cruiser --validate true src`
    (the boolean form `--validate true` is required; the docs misleadingly
    show `--validate <file>` which actually consumes the next positional
    arg as the config path. The default `-c .dependency-cruiser.mjs`
    auto-discovery works with `--validate true`.)
  src/gate/cli.ts: new `dependency-cruiser (architecture rules)` stage
    runs between no-loose-zod and generated-artifacts registry.

**Bug fix from running the gate**:
  src/diagnostics/evidence.ts deleted — depcruise's no-orphans rule
  flagged it as imported by nothing. Confirmed via rg: zero consumers
  in src/, tests/, scripts/, or .github/. The module was for an
  evidence-label doctrine planned for issue #69 that never wired into
  any caller. Per the no-deferrals doctrine: rather than carve an
  orphan exception for an unused module, delete it. If we need it back
  later it's two files away in git history.

**Verification**:
  - bun run typecheck — clean
  - bun x eslint --max-warnings=0 . — clean
  - bun test — 102 pass / 0 fail / 254 expects in 156ms
  - bun run depcruise — 65 modules, 131 dependencies, 0 violations

Closes (this commit):
  - Phase B10: .dependency-cruiser.mjs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…phan modules

Adds knip.ts with a slim config that leans on knip's auto-discovery
(reads package.json scripts for entry points, respects .gitignore for
file scope) and only overrides what knip cannot infer.

**Config shape** (per refs/webpro-nl/knip canonical patterns):
  ignore: web/**, docs/**, scripts/{migrate,reconstruct,recover,generate}*
    — separate workspace + pre-built docs site + legacy node-shaped
    scripts that don't import any TS we own.
  ignoreBinaries: ["playwright"] — invoked from .github/workflows/*.yml
    via shell; knip's binary scanner can't follow YAML.
  ignoreDependencies: 25 entries — all pre-installed for upcoming
    sprint phases (#66 telemetry: @opentelemetry/* + pino + pino-otlp;
    #67 dual-write: commander + listr2 + picocolors; #61/62 octokit
    type packages; future fast-check + eslint-plugin-jest). Once each
    phase wires its dep, the entry comes off the list.
  tags: ["+public"] — only flag exports lacking the `@public` TSDoc
    tag. Per the project's clone-friendly TSDoc doctrine, every
    exported symbol in src/** carries `@public`, so this effectively
    whitelists every declared public surface. Closes the 23 false-
    positive "unused export" findings on src/host-io/index.ts (the
    canonical wrapper barrel re-exports the full sanctioned surface
    by design — future Phase tasks will use the rest).

**Wired into the gate**:
  src/gate/cli.ts: new `knip (unused files / exports / deps)` stage
    runs after dependency-cruiser and before generated-artifacts.

**Bug fixes from running knip**:
  - Deleted src/manifest/index.ts: barrel re-export
    (`export * from "./loader.js"; ...`) — every consumer in src/ and
    tests/ imports from the specific file directly. Confirmed via
    `rg from .*manifest/index|from .*manifest['\"]` returning zero hits
    outside the file itself. The barrel exists only as a
    well-intentioned starter scaffold but no code uses it.
  - Deleted src/diagnostics/summary.ts (and the empty src/diagnostics/
    dir): same orphan pattern as src/diagnostics/evidence.ts deleted
    in the prior commit. Module exports `appendSummary`,
    `summaryHeading`, `summaryTable` — zero consumers anywhere
    (`rg diagnostics/summary|appendSummary|summaryHeading|summaryTable
    src/ tests/ scripts/ .github/`). Was for an incident-summary
    doctrine that never wired into any caller.

**eslint.config.ts**:
  Self-config block expanded from `[eslint.config.ts]` to
  `[eslint.config.ts, knip.ts, github-stars.paths.config.ts]` so
  typescript-eslint's parser handles all root TS configs without
  requiring projectService. These files are loaded via jiti, not
  through tsc, so the projectService include doesn't apply.

**Verification**:
  - bun run typecheck — clean
  - bun x eslint --max-warnings=0 . — clean
  - bun test — 102 pass / 0 fail / 254 expects in 158ms
  - bun x knip — 0 issues, 0 config hints

Closes (this commit):
  - Phase B6: knip.ts + lefthook.yml (lefthook landed in the foundation
    commit — this completes the pair)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…undary parser

Replaces the hand-written schemas/repos-schema.json with a Zod schema
that IS the source of truth, with the JSON-Schema artifact derived from
it via z.toJSONSchema() and a compiled @exodus/schemasafe parser bound
to that derived JSON Schema for boundary validation of YAML-loaded
manifests.

**Why @exodus/schemasafe (not ajv)**:
  - Zero dependencies (refs/ExodusMovement/schemasafe/README.md L25-26).
  - No fast-uri chain (the unpatchable GHSA-v39h / GHSA-q3j6 advisories
    that ban ajv repo-wide).
  - Code-generates a self-contained validator at parser-construction
    time; no runtime schema interpretation.
  - Parser API ("recommended" per README L60-81) takes a JSON STRING
    and returns `{ valid, value, error?, errors? }` — caller never
    handles unvalidated JSON in non-string form.

**Files**:
  - src/manifest/schema.zod.ts    Zod source of truth — ManifestSchema
                                  + 4 sub-schemas (ManifestMetadataSchema,
                                  FeatureFlagsSchema, TaxonomySchema,
                                  RepositoryEntrySchema), every one
                                  registered with GhStarsSchemaRegistry.
                                  All 5 carry `@public` TSDoc with their
                                  inferred type. Reusable primitives
                                  (RepoIdentifierSchema, CategoryNameSchema,
                                  TagNameSchema via .refine() char-walk
                                  predicate to dodge eslint-plugin-security's
                                  alternation-regex flag, FrameworkNameSchema,
                                  Iso8601DateTimeSchema, Iso8601DateSchema,
                                  SemverVersionSchema, GitShaSchema,
                                  GitHubUsernameSchema).
  - src/manifest/json-schema.ts   Derives ManifestJsonSchema constant via
                                  z.toJSONSchema(ManifestSchema, { target:
                                  "draft-2020-12", unrepresentable: "any" })
                                  + lazily-compiled compileManifestParser()
                                  returning the cached schemasafe Parse fn.
  - src/manifest/schema-codegen.ts Bun-runnable codegen: writes the JSON
                                  shape to schemas/repos-schema.json (atomic,
                                  via host-io's writeTextFileAtomicSync).
                                  Output target sourced from
                                  github-stars.paths.config.ts via
                                  getGhStarsPath("reposSchemaJson").

**Eslint update**:
  Removed @exodus/schemasafe from no-restricted-imports (was banned
  alongside ajv). The ajv ban stays — it pulls fast-uri. The schemasafe
  rationale is now spelled out in the ajv ban message + the file-header
  comment block: schemasafe is the JSON-Schema-shape boundary check,
  Zod is the runtime contract.

**Test preload**:
  tests/setup/schema-registry.ts side-effect-imports
  src/manifest/schema.zod.js so all 5 manifest schemas register before
  tests read the registry.

**Operator workflow**:
  `bun run schema:generate` re-emits schemas/repos-schema.json. The
  registry rule already polices it as `committed`, so drift is caught
  by the gate.

**Verification**:
  - bun run typecheck — clean
  - bun x eslint --max-warnings=0 . — clean
  - bun test — 102 pass / 0 fail / 254 expects in 166ms
  - bun x knip — 0 issues
  - bun run depcruise — 67 modules, 131 deps, 0 violations
  - bun run gate:no-loose-zod — 50 files, 0 occurrences
  - bun run schema:generate — wrote schemas/repos-schema.json (Zod-derived)

Closes (this commit):
  - Phase C3: replace ajv with zod for repos.yml (uses registry from B7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces actions/setup-node@v4 + `npm install -g pnpm@10.13.1` + retry
pnpm install in 00-ci.yml, 01-fetch-stars.yml, 02-sync-stars.yml,
03-classify-repos.yml with oven-sh/setup-bun@v2 + `bun install
--frozen-lockfile`. setup-bun auto-detects the version from
package.json `packageManager` ("bun@1.3.13"); --frozen-lockfile fails
the run on lockfile drift instead of silently mutating bun.lock.

`pnpm <script>` callsites (auth:doctor, fetch:stars, sync:stars,
normalize, validate) become `bun run <script>`. Stale comment/summary
references to "pnpm gate" updated to "bun run gate".

00b-web-ci.yml and 04-build-site.yml still use setup-node — they
build the separate `web/` React package which has its own
package-lock.json and is not part of this server-side migration.
05-generate-readmes.yml has no Node setup; only github-script.

actionlint passes on all 4 migrated workflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the telemetry kernel: a single OpenTelemetry NodeSDK bootstrap
(registerTelemetry), a pino logger factory (createLogger), the
attribute scrubber (scrubAttribute), and the redact-path catalog. Port
of the canonical westcore-x1 OpenTelemetryRegistration.cs shape, kept
1:1 on heuristics and doctrine: telemetry is OBSERVABILITY ONLY —
never authorisation signal, never carries secrets.

Module surface (src/telemetry/index.ts):
  - registerTelemetry({serviceName}) — idempotent NodeSDK start;
    skipped when OTEL_EXPORTER_OTLP_ENDPOINT is unset (matches
    westcore HasConfiguredEndpoint gate) or OTEL_SDK_DISABLED=true.
  - shutdownTelemetry() — flush spans/metrics on SIGTERM/SIGINT.
  - createLogger(scope) — child pino logger with redact paths.
  - scrubAttribute(value) — JWT-shape and long-base64-blob redaction;
    thresholds ported verbatim from the C# template.
  - PINO_REDACT_PATHS / PINO_REDACT_CENSOR — concrete path catalog;
    fast-redact syntax per refs/pinojs/redact/README.md.

Wired into the four real CLI entrypoints: fetch/cli.ts, sync/cli.ts,
cli-normalize.ts, cli-validate.ts. Each calls registerTelemetry,
installs SIGTERM/SIGINT handlers that void shutdownTelemetry, and
emits a startup log line. gate/cli.ts is intentionally NOT wired —
it's a tool runner, not an instrumented CLI.

eslint quarantine (already in place from B5) keeps every pino +
@opentelemetry/* import inside src/telemetry/**. Knip
ignoreDependencies trimmed to only the genuinely-pre-staged
packages (api/resources/sdk-logs/sdk-trace-*/semantic-conventions/
exporter-logs-otlp-http/pino-opentelemetry-transport — for future
log-bridge work). Gate green: 9/9 stages, 112 tests pass.

10 scrub.test.ts cases pin the heuristics (JWT shape boundary,
base64 ratio threshold, prose pass-through, base64url alphabet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…urces

Replaces hand-rolled GitHub API shapes with type derivations from the
first-party Octokit type packages, per the no-handrolling-SDKs
doctrine. A schema rename of any of these fields now fails this
codebase at compile time instead of silently passing wrong-shape data
to downstream stages.

REST (#61) — list-paginator-rest.ts:
  - `RestStarItem` aliased over
    `components["schemas"]["starred-repository"]` from
    @octokit/openapi-types.
  - The `starred-repository[] | repository[]` union returned by the
    `GET /users/{username}/starred` route is narrowed via a single
    documented cast onto the `starred-repository[]` branch (pinned by
    the `application/vnd.github.star+json` Accept header). The runtime
    loop body still narrows per element.

GraphQL (#62) — list-paginator.ts + metadata-batcher.ts:
  - `ListPageResult` now `Pick<>`s `nameWithOwner`/`isPrivate` from
    `Repository`, `starredAt` from `StarredRepositoryEdge`,
    `hasNextPage`/`endCursor` from `PageInfo` — all from
    @octokit/graphql-schema.
  - `RepoNode` decomposed into `RepoScalarFields = Pick<Repository,
    13 scalar names>` plus query-shape projections for the nested
    selections (RepositoryTopicConnection / Release / Ref / Owner /
    License are too wide as full schema types).
  - Read sites coalesce canonical `Maybe<...>` (T | null | undefined)
    to the FetchedRepo contract's narrower `T | null` shape:
    `endCursor`, `description`, `updatedAt`, `pushedAt`, `diskUsage`,
    `homepageUrl`, `mirrorUrl`. The boundary lands here, not deeper.

Knip ignoreDependencies trimmed: @octokit/openapi-types and
@octokit/graphql-schema are now genuinely consumed. Gate green: 9/9
stages, 112 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread scripts/generate-readmes.js Fixed
Comment thread .dependency-cruiser.mjs Fixed
Comment thread src/fetch/list-paginator-rest.ts Fixed
Comment thread src/fetch/list-paginator-rest.ts Fixed
Comment thread scripts/generate-readmes.js Fixed
primeinc and others added 6 commits May 10, 2026 16:13
- scripts/generate-readmes.js:27 — js/incomplete-sanitization (high):
  escapeMd char class missed `\` itself, so a stray backslash in
  source content escaped the next meta-char instead. Add `\` to the
  class so backslashes are escaped first.
- scripts/generate-readmes.js:2 — js/unused-local-variable: drop the
  `path` import (legacy script no longer uses it).
- src/fetch/list-paginator-rest.ts:133 + :192 — js/useless-assignment-
  to-local: `_lastSucceededPage` was write-only debug scaffolding
  from an earlier shape; the resume token derives from `page` directly.
- .dependency-cruiser.mjs:145 — js/missing-space-in-concatenation:
  add a trailing space to "'dependencies' " so the next-line
  fragment "section of your package.json" doesn't render as
  "'dependencies'section".

Gate green: 9/9 stages, 112 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four-part CLI ergonomics pass:

1) dual-write.ts (src/cli/) — extract setOutput()/appendStepSummary()
   from the 3 sites that handrolled the
   getEnv(GhStarsEnv.X)/appendFileTextSync pattern (fetch/cli.ts,
   sync/cli.ts, auth/setup-doctor.ts). One module, one shape; a
   future change to the GitHub Actions step-output protocol lands
   in one file.

2) commander — replace ad-hoc `process.argv.slice(2)` parsing in
   cli-normalize.ts and cli-validate.ts with `new Command()` +
   `.argument([input])` + `.option(-c, --check)`. Both CLIs now have
   typed opts and self-documented `--help` output.

3) listr2 — wrap fetch/cli.ts pipeline in 4 named tasks (resolve
   creds, load queries, fetch, write). `renderer: "simple"` keeps
   the CI log shape one-line-per-step (no ANSI screen control).
   Failures in any task short-circuit and bubble through Listr's
   exitOnError contract.

4) picocolors — color the cli-normalize.ts and cli-validate.ts
   banners + status markers (✓/✗/ℹ). Same character set picocolors
   uses elsewhere in the bun ecosystem, no symbol drift.

eslint `src/cli/**` carve-out (already in place from B5) covers
the new module. knip ignoreDependencies trimmed: commander,
listr2, picocolors are now consumed.

Gate green: 9/9 stages, 112 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ender)

Replaces the vite+React+CSS-modules web/ stack with TanStack Start
in static-prerender (SPA) mode, tailwind v4, and bun for install
and build. Output target stays at docs/ for the existing GitHub
Pages deploy — workflow stages web/dist/client/ → docs/.

Doctrine source (canonical, from refs/TanStack/router/examples/react/):
  - start-basic-static/ — vite plugin + spa.prerender + tsconfig
  - start-tailwind-v4/  — @tailwindcss/vite + @import "tailwindcss"

What changed
  - web/package.json: dropped vite plugin-react alone; added
    @tanstack/react-router, @tanstack/react-start,
    @tanstack/start-static-server-functions, @tailwindcss/vite,
    @tanstack/router-plugin, typescript-eslint, vite@^8.
  - web/vite.config.ts: tanstackStart({ spa.prerender, prerender }) +
    viteReact() + tailwindcss(); base "/github-stars/"; output stays
    at default web/dist (lets the SSR prerender step resolve `react`
    from web/node_modules, the canonical layout).
  - web/tsconfig.json: TanStack canonical (strict, ESNext,
    Bundler resolution, jsx react-jsx, ~/* path alias).
  - web/src/router.tsx: createRouter({routeTree, basepath, ...}).
  - web/src/routes/__root.tsx: HeadContent + Scripts shell + title
    "web" preserved (playwright contract).
  - web/src/routes/index.tsx: 1:1 port of the previous web/src/App.jsx
    — Fuse.js search, faceted filters (categories/languages/topics),
    sort (starred/stars/pushed/name), repository list. CSS modules
    swapped for tailwind utility classes.
  - web/src/components/RepoCard.tsx: typed port; .repository-card
    class preserved (playwright contract).
  - web/src/types.ts: Repo + Filters + ManifestRepoEntry types.
  - web/src/styles/app.css: @import "tailwindcss" + @apply rules
    (canonical from start-tailwind-v4).
  - web/eslint.config.js: typescript-eslint flat config + ignores.
  - Removed: web/index.html (TanStack Start owns the shell),
    web/src/App.jsx, web/src/main.jsx, all CSS modules, ThemeContext
    (system preference via tailwind dark: replaces the toggle).
  - Removed: web/package-lock.json (bun.lock now).

Workflow updates
  - 00b-web-ci.yml: setup-bun + bun install --frozen-lockfile +
    bun run lint/build; verify against web/dist/client/ artifacts.
  - 04-build-site.yml: setup-bun + bun build; stage web/dist/client/
    to docs/ and rename _shell.html → index.html. Hand-authored
    docs/security.md is now preserved (only build-managed paths are
    swept).

Re-rolled the docs/ build artifact in this commit so a checkout of
the merge commit serves the new shell immediately, before the next
deploy run lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@primeinc

Copy link
Copy Markdown
Owner Author

Closing to retrigger CI — last 3 commits did not produce workflow runs despite GitHub status reporting all systems operational. Reopening immediately.

@primeinc primeinc closed this May 10, 2026
@primeinc primeinc reopened this May 10, 2026
primeinc and others added 2 commits May 10, 2026 17:54
…g.ts

Web CI build job (run #25640812965) failed at `tsc --noEmit` with:

  playwright.config.ts(6,17): error TS2591: Cannot find name 'process'.
  Do you need to install type definitions for node? Try
  `npm i --save-dev @types/node` ...

@types/node is an optional peer of vite 8 — locally bun's resolver
deduped a transitive copy that satisfied tsc, but on the Linux CI
runner the optional peer was not installed and tsc could not see
node globals from the playwright config (which uses
`process.env.CI`). Add it explicitly so the install is reproducible
across hosts.

Vite + tanstack-start prerender already complete before this step
fails; the fix is type-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dependency-audit gate stage between generated-artifacts and
actionlint. Implementation:

- src/host-io/spawn.ts: extend RunCommandSyncOptions with `cwd` so the
  audit step can run in both the kernel root and the web/ workspace
  from the same gate process.
- src/gate/cli.ts: new `audit (bun audit --audit-level=high)` stage.
  Runs `bun audit --audit-level=high` against root and web/. Exit
  code is the contract per https://bun.com/docs/install/audit (0 =
  clean, 1 = advisories at threshold). A failure in either workspace
  fails the stage.
- docs/security.md: new "Dependency Audit" section with the
  exit-code contract, defense-in-depth table that places the new
  gate alongside CodeQL / lefthook / Dependabot, and a documented
  suppression workflow for known-safe advisories.

Threshold rationale: high+ blocks, moderate+low surface in the
Dependabot tab but don't fail builds — matches the SDL doctrine in
docs/security.md and keeps the gate signal tight.

Closes #30. Gate green: 10/10 stages, 112 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two governance docs:

- docs/automation/bot-naming.md: names every actor in the control
  plane (App identity, subsystems, diagnostic-only references) and
  the rules for adding new ones. Splits the audit-log identity
  (`primeinc-github-stars`) from the diagnostic name
  (`primeinc-stars-yoshi-doctor`) per the issue's reasoning — boring
  in audit logs, memorable at the diagnostic surface.

- .github-stars/control-plane/permissions.yml: machine-readable
  permission capability ledger. Every permission granted to the
  GitHub App has access, phase, capability (what it enables),
  proof_required (how we know it's used), and prune_rule (when to
  revoke or downgrade). Bucket classification at the bottom routes
  each permission into runtime_core / bootstrap_self_config /
  speculative_remove_unless_proven.

  The `issues: write` permission is explicitly classified as
  speculative — it's granted for future router work but no
  workflow exercises it today. Per least-privilege doctrine, it
  should be downgraded to none until the router subsystem actually
  opens issues.

  An "Permissions deliberately NOT granted" section enumerates the
  scopes we refused (administration, checks, deployments, packages,
  secrets, statuses) so the omission is auditable evidence rather
  than a silent gap.

Per the issue's scope boundary: this does not touch the auth
implementation; #69 (now PR #79) owns that. This is governance docs
only.

Closes #73.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the mandatory read-the-room evidence gate spec'd in #75.

AGENTS.md §0 (new top section): canonical-only doctrine, the
required reading sequence (local refs → upstream canonical →
rationale → mapping → patch → gate), forbidden authority sources
(blog posts, LLM memory, etc.), and the evidence label set
(direct / weak / unsupported / blocked / contradicted).

.github/PULL_REQUEST_TEMPLATE.md (new): the actual PR-time gate.
Sections:
  - Read-the-room evidence: local refs read, upstream canonical
    refs read, mapping table, evidence labels.
  - Test plan: bun run gate locally + new tests + manual + CI.
  - Path-based gates: per-area extra requirements that route to
    the right doctrine (workflows → actionlint, src/auth → resolver
    tests, src/manifest → schema+taxonomy+Zod registry, etc.).
  - Doctrines that must hold: the locked rules from prior phases
    (no deferrals, no handrolling SDKs, canonical refs first,
    TSDoc on public exports, Zod metadata via .register, telemetry
    is observability only).

The template is GitHub's canonical path; it auto-populates every PR
body and reviewers can grep for the checklist completion. No CI gate
on the template content — that's a future enhancement listed in #75
"Gate Ideas" but not the acceptance criteria for this issue.

Closes #75.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the privacy hard-block from #74. github-stars deploys
from a public repo to public GitHub Pages; the invariant is "if
output_repository.visibility == public, private repository records
MUST NOT be surfaced." The fetch path already filters
private:true records before they reach the manifest; this module is
the downstream defense-in-depth tripwire that catches schema drift,
hand-edits, or future code paths that could let a private record
slip through.

Module shape (`src/privacy/`):
  - private-quarantine.ts: OutputVisibility enum (public/private/
    unknown), PRIVATE_SENTINEL_SLUG fixture
    ("owner/private-sentinel-repo-do-not-leak"), Zod schemas
    registered in GhStarsSchemaRegistry, PrivateOmissionReport
    aggregate (count only, no slugs).
  - quarantine.ts: quarantinePrivate (filters private:true under
    public/unknown visibility, passes through under private),
    publicSafeOmissionReport (count-only emit), findPrivateSlugLeaks
    (scans arbitrary text for known private slugs),
    assertNoPrivateLeak + PrivateLeakError (hard-fail variant).
  - quarantine.test.ts: 14 tests including the sentinel tripwire
    against artifact JSON, markdown summaries, and classifier prompt
    construction.

Wiring (src/sync/cli.ts):
  - After reconcile, before write: pass result.manifest.repositories
    through quarantinePrivate({visibility: "public"}).
  - Aggregate count surfaces in workflow output as
    private_repos_quarantined; per-repo names are NEVER emitted to
    the public log (session-oracle verdict rule 8).
  - After write: re-read the manifest and assertNoPrivateLeak
    against PRIVATE_SENTINEL_SLUG. If the fixture appears in the
    serialized output, the pipeline aborts before the workflow's
    commit step runs.

Per the issue's "unknown output visibility -> fail closed" rule,
the quarantine treats `unknown` as `public` (strictest surface).

Per #74 non-goals: this does NOT remove support for private/
authenticated star fetching in private output contexts; it
quarantines records only when output visibility is public. The
auth resolver (src/auth/) is unchanged.

Schema registration: tests/setup/schema-registry.ts now imports
src/privacy/private-quarantine.js so the registry is populated
before any test runs.

Closes #74. Gate green: 10/10 stages, 126 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the legacy untyped JS-in-YAML parsing with a typed
TypeScript validator that gates every classifier mutation.

The doctrine: model output is CANDIDATE classification, not truth.
Every claim passes through a typed parse + taxonomy validation +
evidence cross-check before any row can mutate repos.yml.

New module (src/classifier/):
  - classification-schema.ts: Zod ClassifierRowSchema +
    ClassifierResponseSchema, registered in GhStarsSchemaRegistry.
  - validator.ts: validateRow / validateResponse / parseAndValidate.
    Filters categories against canonical taxonomy, validates
    framework via existing src/manifest/taxonomy.validateFramework,
    sanitises tags via the same char-walk predicate as
    src/manifest/validator.ts (dodges security/detect-unsafe-regex).
    Cross-checks `lang:X` tags against per-repo evidence
    (github_metadata.language with the same alias map embedded in
    the prep step's prompt builder). Mismatches downgrade to
    `decision: "needs_review"`; categorical violations become
    `decision: "reject"`.
  - validator.test.ts: 25 tests covering all 9 failure modes from
    the issue spec — wrong repo, extra row, missing row, invalid
    JSON, schema violation, unknown category, unknown framework,
    contradicted lang tag, plus the legacy-prompt-acceptance trap.
  - cli.ts: `bun run classify:apply <ai-response> <batch-meta> <result>`.
    Strips markdown fences, calls parseAndValidate, writes typed
    ClassifierValidationResult. Exit 1 on parse/schema failure
    (workflow fails loud, no silent corruption); exit 0 otherwise
    so the workflow can consume rejections as a signal.
  - index.ts: barrel re-export (consumed by cli.ts).

Workflow changes (.github/workflows/03-classify-repos.yml):
  - Prep step now writes batch-meta.json (batchRepos + taxonomy +
    per-repo language evidence) for the validator.
  - New `Validate AI output (typed gate)` step between AI classify
    and Apply. Calls `bun run classify:apply`. If the model output
    fails to parse / fails the schema, this step fails the
    workflow before any manifest mutation can occur.
  - Apply step rewritten to consume classifier-result.json
    (the typed validation result) instead of re-parsing the raw AI
    response. Mutates manifest from `accepted` and `needsReview`
    rows only — `rejected` rows never touch the manifest.
  - Workflow summary now surfaces accepted / needs_review /
    rejected / missing / extra counts. The legacy
    apply.outputs.retry signal is gone — the validator's hard-fail
    on parse failure is the new retry trigger (operator
    re-dispatches).
  - prompt_version bumped from "v3" to "v4-typed" so future
    classification provenance is traceable to the typed gate.

Acceptance criteria from #71:
  - ✅ Classification no longer accepts ungrounded `legacy prompt
    format` / `simple inference` output as production-path truth.
    The typed gate sits between inference and mutation.
  - ✅ Every model classification is parsed by TypeScript before
    mutation (parseAndValidate via ClassifierResponseSchema).
  - ✅ Classification output matched back to requested batch
    (extras flagged, missing flagged, validateResponse summary).
  - ✅ Schema validation + taxonomy validation are hard gates.
  - ✅ Evidence validation exists for language tags (lang:X
    cross-checked against gathered repo metadata).
  - ✅ Unsupported / contradictory claims rejected or marked
    `needs_review`.
  - ✅ Workflow summary reports model, validator counts,
    rejected/needs-review counts.
  - ✅ Tests cover malformed, mismatched, unsupported, contradicted
    outputs (25 tests, all 9 failure modes named in the issue).
  - ✅ Raw model JSON cannot directly mutate repos.yml — the apply
    step consumes only the typed ClassifierValidationResult.

Closes #71. Gate green: 10/10 stages, 142 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
primeinc added a commit that referenced this pull request May 11, 2026
…nd-main + auto-sync

Per the protection-stage brief, this is one coherent diff that brings
admin/ghapp-rulesets to the required final state for the user-owned
public repo (no org/team layer, App as the automation/protection
actor, main = release lane, next = integration lane).

Changes:

1) Delete .github/CODEOWNERS (brief rule #2). Solo-owner CODEOWNERS
   is fake trust — there is no separate reviewer/team; CODEOWNERS
   only adds friction without governance. Path-based ownership for
   a one-actor repo is theatre.

2) Drop require_code_owner_review + required_approving_review_count
   from both ruleset specs (brief rule #3). Without a separate
   reviewer the rule is unsatisfiable on every PR; setting
   review_count=0 + dropping require_code_owner_review reflects the
   actual governance shape (App + status checks gate, not human
   approver gate). The App is still the bypass actor for the few
   cases that need it.

3) Render App bypass with bypass_mode: "pull_request" (brief rule #4).
   Per the GitHub REST docs:
   "pull_request means that an actor can only bypass rules on pull
   requests" and "pull_request is only applicable to branch
   rulesets." That's strictly tighter than the previous "always" and
   matches the workflow shape (App bypass exists to close PRs, not
   to bypass the rule entirely).

4) Add 00d-admin-branch-sync-guard.yml (brief rule #9). Runs on
   every PR to main; for admin/* heads it queries
   GET /repos/{owner}/{repo}/compare/{base}...{head} and fails the
   PR if behind_by > 0. For non-admin heads it passes through (so
   the check name remains a viable required-status-check on every
   PR to main without leaving non-admin PRs perpetually pending).
   Update path: rebase or use the GitHub UI's Update branch button
   (which calls PUT /pulls/{n}/update-branch with
   expected_head_sha for the safe path).

5) Add admin-branch-sync-guard to protect-main-release-only.json's
   required_status_checks. The check is now both wired (workflow
   exists) and required (ruleset references it).

6) 00f-sync-next-with-main.yml: add `push: branches: [main]` trigger
   so admin merges to main propagate the next branch automatically
   via the documented update-branch API (brief rule #10). The
   existing workflow_dispatch fallback retains check/sync inputs.
   Operation defaults to `sync` on push; on dispatch the input
   wins. Removed unused repo_name shell var.

What was removed:
- .github/CODEOWNERS (entire file; brief rule #2)
- require_code_owner_review: true (both rulesets; brief rule #3)
- required_approving_review_count: 1 (both rulesets; brief rule #3)
- bypass_mode: "always" (replaced with "pull_request"; brief rule #4)
- 00f-sync-next-with-main.yml's unused repo_name shell variable

Native GitHub primitives used:
- Branch rulesets (target: branch) with bypass_actors
- bypass_mode: pull_request (App-shaped governance)
- required_status_checks rule with strict_required_status_checks_policy
- required_linear_history + non_fast_forward + deletion rules
- GET /repos/{owner}/{repo}/compare/{base}...{head} for behind-main check
- PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch with
  expected_head_sha for the next-with-main sync (stale-head guard)
- create-github-app-token@v3 for short-lived App tokens with minimal
  scoped permissions per workflow

Tracked JSON does not hardcode App IDs; bypass actor is rendered at
runtime in 00e-branch-rulesets.yml from vars.GH_APP_ID.

Remaining manual repo settings:
- Create vars.GH_APP_ID (numeric App ID for the
  primeinc-github-stars App). The branch-rulesets workflow guards
  against this with `^[0-9]+$` regex and fails loud.
- Configure `github-admin` deployment environment with required
  reviewers (the brief notes this is the future
  webhook/custom-deployment-protection-app surface). Until then,
  upsert is gated only by the workflow's APPLY_RULESETS confirmation
  and refs/heads/main check.

Deferred to a future stage (per brief): external webhook / custom
deployment protection app. The github-admin environment is shaped
for it; activation requires a separate deployment.

Do not merge: brief rule "Do not merge this PR" + "Do not merge PR
#79" + "Do not activate live rulesets" all stand. PR #79 unblock
condition: this admin PR merges to main, then 00f-sync-next-with-main
fires automatically on the push to main and updates the next branch
PR's head, then chore/bun-modernization (PR #79's branch) rebases
against main + retargets to next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
primeinc added a commit that referenced this pull request May 11, 2026
Per the user's correction: the canonical admin/control-plane branch
name is `ghapp/repo-admin`, not `ghapp/next`. `next` already means
the integration lane; overloading the name on the admin lane makes
future humans stupider. Branch renamed remote
(admin/ghapp-rulesets -> ghapp/repo-admin) and locally to match.

Three workflow changes form one coherent diff:

1) 00c-main-release-guard.yml: rewrite to accept exactly two
   repo-owned source branches into main:
     - `next`              integration / release lane
     - `ghapp/repo-admin`  control-plane admin lane
   Anything else fails with both allowed lanes named in the error.
   Forks fail (BASE_REPO != HEAD_REPO).

2) 00d-admin-branch-sync-guard.yml: tighten head-branch scope from
   `admin/*` glob to exactly `ghapp/repo-admin` (env
   ADMIN_LANE_BRANCH). Add a SECOND check — path-scope guard — that
   fails the PR if any changed file is outside the Medium scope
   per the brief:
     - .github/workflows/00*.yml
     - .github-stars/control-plane/**
     - AGENTS.md
     - docs/automation/**
     - docs/security.md
     - .github/PULL_REQUEST_TEMPLATE.md
   Pass-through for non-admin heads so this required-status-check
   name remains viable on every PR to main.

3) Replace 00f-sync-next-with-main.yml with
   00f-sync-protected-branches-with-main.yml. The brief's missing
   piece: a push-to-main reconciler that brings forward BOTH long-
   lived lanes when main moves, or marks them stale.
     - For `next`: prefer GitHub's update-branch API on the open
       repo-owned next -> main release PR with expected_head_sha.
       If no release PR is open, fail loud (per release-lane policy
       — the release PR is the documented surface for next->main
       update-branch calls).
     - For `ghapp/repo-admin`: prefer update-branch API if an open
       admin PR exists. If no open PR, FF-state the branch via
       compare API; fast-forward via PATCH /git/refs/heads/{branch}
       (force=false) only when ahead_by=0. Divergent histories
       fail loud (refuse to blind-push). Up-to-date is recorded.
     - GitHub App installation token only. No PAT fallback.
     - Workflow fails red if either lane could not be synced; PR #79
       remains blocked until both lanes are caught up.
     - Summary surfaces main_sha + each lane's before/after SHA or
       blocker reason.

What was removed:
- The `admin/*` glob in 00d (replaced by exact `ghapp/repo-admin`)
- The single-lane (next-only) restriction in 00c (now allows
  ghapp/repo-admin too)
- 00f-sync-next-with-main.yml entirely (subsumed by the broader
  protected-branches reconciler)
- Implicit acceptance of any path on admin PRs (now path-scoped)

Native GitHub primitives used (additions to the prior set):
- `gh api --paginate /repos/.../pulls/{n}/files` for path-scope
  enforcement
- bash `extglob`/`globstar` for `**` glob matching against the
  allow-list
- `PATCH /repos/{owner}/{repo}/git/refs/heads/{branch}` with
  force=false for FF-only branch advancement
- `GET /repos/{owner}/{repo}/compare/{branch}...main` to determine
  FF-state before any branch advancement

Operator manual settings (already documented in PR #80 comment):
- vars.GH_APP_ID = 3663316 still required
- secrets.GH_APP_PRIVATE_KEY still required
- github-admin environment still optional (only for live ruleset
  upsert)

Do not merge: brief stands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@primeinc primeinc requested a review from Copilot May 11, 2026 03:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

primeinc added a commit that referenced this pull request May 11, 2026
…eric msgs

Three real fixes from the PR #81 review pass (codex P1 + 2 Copilot
mediums):

1) [chatgpt-codex P1, 00f:200] Fix FF detection inverted compare
   semantics. The compare API returns ahead_by/behind_by relative to
   `compare/{base}...{head}` order:
     ahead_by  = commits in head not in base
     behind_by = commits in base not in head

   Previous code called `compare/${branch}...main` then read
   behind_by as "branch behind main" — exactly inverted. With
   compare/branch...main: ahead_by = commits-in-main-not-in-branch
   (i.e. branch BEHIND), behind_by = commits-in-branch-not-in-main
   (i.e. branch AHEAD). The caller's variable names assumed the
   correct semantic, so a cleanly fast-forwardable branch was being
   reported as divergent and skipped.

   Fix: ff_state() now uses `compare/main...${branch}` so the
   API's ahead_by/behind_by directly match the caller's
   "branch ahead/behind main" semantic. Comment block expanded to
   make the API direction explicit so future-me doesn't re-invert it.

2) [Copilot 00f:106] find_release_pr's "Multiple open PRs" error
   message went to stdout while callers do `... 2>/dev/null`. The
   stderr redirect didn't help because the annotation was on
   stdout, and stdout is captured by command substitution. Net
   effect: silent failure on the multi-PR error path.

   Fix: open FD 3 to the script's real stderr at the top of the
   step (`exec 3>&2`). Helper functions now emit their `::error::`
   annotations + diagnostic lists to FD 3, which survives both the
   caller's command substitution and the `2>/dev/null` swallow on
   the happy path.

3) [Copilot 00f:229] Two summary strings hardcoded "PR #79 remains
   blocked..." This will rot the moment another downstream PR
   exists. Replace with generic "downstream PRs targeting either
   lane remain blocked..." in both the error path and the summary
   section heading.

Not addressed in this commit (with reasons):

- [Copilot 00e:36] `environment: ${{ inputs.operation == 'upsert'
  && 'github-admin' || '' }}` empty-string env on the check path.
  Per ../refs/github/docs/data/reusables/actions/jobs/section-using-
  environments-for-jobs.md, the docs describe valid forms (single
  name string, or object with name+url) but do not document
  empty-string behavior. actionlint accepts the YAML. The 00e
  workflow is dispatch-only and the brief explicitly defers live
  upsert ("Do not activate live rulesets"), so the empty-string
  path is currently unreachable. If/when 00e fires in upsert mode
  and the empty-string env breaks at runtime, split into two jobs
  (render + upsert, only upsert declares environment).

- [gemini approval-count = 1, both rulesets] Brief explicitly says
  "No `require_code_owner_review` unless a real separate
  reviewer/team exists. It does not." With one actor,
  required_approving_review_count: 1 is unsatisfiable. Brief-aligned
  value is 0.

- [gemini hardcoded App ID 3663316 in tracked JSON, both rulesets]
  Brief explicitly says "Tracked JSON must not hardcode numeric
  App IDs." Bypass actor is rendered at runtime in 00e from
  vars.GH_APP_ID. Tracked specs MUST stay with empty bypass_actors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants