mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
* Add failing tests for repo-declared docs-only paths (GST-1974) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Let a repository name its own docs-only paths in its base ship policy (GST-1974) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Catch relative directory readers, missing docs tests and readers the base gained in the docs-only class Review-Finding: review/specialist-batched#1 reader=security Review-Finding: review/red-team#1 reader=red-team Review-Finding: review/red-team#2 reader=red-team Review-Finding: voices/adversarial-native#1 reader=adversarial-native Review-Finding: voices/adversarial-native#3 reader=adversarial-native Review-Finding: voices/adversarial-native#5 reader=adversarial-native Review-Finding: voices/outside-voice#1 reader=outside-voice Review-Finding: voices/outside-voice#2 reader=outside-voice Review-Finding: voices/outside-voice#3 reader=outside-voice Review-Finding: voices/outside-voice#4 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Say in the changelog that a missing docs test or a new base reader keeps the full checks Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Describe the new directory-reader forms, the missing docs test refusal and land's base-reader check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
83 lines
5.6 KiB
TypeScript
83 lines
5.6 KiB
TypeScript
/**
|
|
* docs_only — the repository's own human-docs paths in .gstack/ship-policy.json (GST-1974; docs/pregate.md#repo-docs-paths),
|
|
* and the built-in deny list lib/docs-only.ts applies to every path, built-in or repo-allowed. Its own module so the
|
|
* policy reader (lib/ship-policy.ts) and the classifier share one deny list without the reader importing the classifier.
|
|
*
|
|
* The block is `{ allow: [globs], deny?: [globs], tests?: [test files] }`. Allow entries widen the docs allowlist, so they are narrow by
|
|
* construction: each ends in `.md`, starts with a literal path segment, names no hidden, code or agent directory and no
|
|
* built-in deny entry. Deny entries only remove paths, so any contained glob is accepted. Tests are the repository's own
|
|
* checks for its docs directories: the docs lane runs them, and a non-docs file that reads an allowed directory must be
|
|
* tied to one of them (lib/docs-only.ts directory readers). Unknown keys are warnings to the
|
|
* reader; the classifier then ignores the allow list, so a gstack that does not understand a key never widens on it.
|
|
*/
|
|
import type { PolicyError } from './ship-policy';
|
|
import { globError } from './ship-policy-bookkeeping';
|
|
|
|
export interface DocsOnlyPolicy { allow: string[]; deny?: string[]; tests?: string[] }
|
|
|
|
export const DOCS_ONLY_KEYS: readonly string[] = ['allow', 'deny', 'tests'];
|
|
const MAX_ALLOW = 16, MAX_DENY = 64, MAX_TESTS = 16;
|
|
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
|
|
/** Agent-loaded Markdown, tool configuration, pinned results, preregistrations and fragment instructions: never docs, whatever a repo allows. */
|
|
export const DOCS_ONLY_DENY: readonly [RegExp, string][] = [
|
|
[/(?:^|\/)(?:skill|agents|claude|gemini|ethos|crew|captain|factory|driver|checklist|unattended|pregate|lane-card|notes|digest|soul)\.md$/i, 'agent instructions'],
|
|
[/-policy\.md$/i, 'policy doc'],
|
|
[/(?:^|\/)preferences[^/]*$/i, 'preferences'],
|
|
[/(?:^|\/)(?:sections|templates|specialists|skills|prompts?)\//i, 'agent instructions'],
|
|
[/(?:^|\/|[-_.])prompts?\.md$/i, 'prompt file'],
|
|
[/\.tmpl$/i, 'template'],
|
|
[/(?:^|\/)\.[^/]+\//, 'tool configuration directory'],
|
|
[/(?:^|\/)(?:evals|inventory)\//, 'pinned results'],
|
|
[/prereg/i, 'preregistration'],
|
|
[/^changelog\.d\/README\.md$/i, 'fragment instructions'],
|
|
];
|
|
|
|
/** Directory names that hold code, tests or agent instructions; neither an allow pattern nor a path it admits may pass through one. */
|
|
export const CODE_DIRS = new Set(['skills', 'templates', 'sections', 'specialists', 'prompts', 'prompt', 'test', 'tests', 'spec', '__tests__', 'fixtures', 'lib', 'bin', 'scripts', 'src', 'node_modules', 'vendor', 'dist', 'build']);
|
|
const NARROW = 'allow takes narrow Markdown globs under one literal directory, such as "wiki/board/**.md"';
|
|
|
|
function allowError(p: string): string | null {
|
|
const contained = globError(p);
|
|
if (contained) return contained;
|
|
if (!p.endsWith('.md') || p.endsWith('/.md')) return 'must end in .md: only Markdown prose can skip the test suites';
|
|
const segs = p.split('/');
|
|
if (/[?[\]{}]/.test(p)) return 'must use only * and ** as wildcards';
|
|
if (segs[0]!.includes('*')) return 'must start with a literal directory or file name, never a wildcard';
|
|
if (segs[0]!.startsWith('.')) return 'must not name a hidden directory (.github, .gstack and the like hold configuration)';
|
|
const dir = segs.find(s => CODE_DIRS.has(s.toLowerCase()));
|
|
if (dir) return `must not pass through ${dir}/, which holds code, tests or agent instructions`;
|
|
const deny = DOCS_ONLY_DENY.find(([r]) => r.test(p));
|
|
if (deny) return `names ${deny[1]}, which the built-in deny list always refuses`;
|
|
return null;
|
|
}
|
|
|
|
/** Shape and pattern errors in a `docs_only` value (not null). Unknown keys are not errors (unknownKeyWarnings). */
|
|
export function docsOnlyPolicyErrors(raw: unknown): PolicyError[] {
|
|
const d = raw as Record<string, unknown> | null;
|
|
if (!d || typeof d !== 'object' || Array.isArray(d)) return [{ code: 'POLICY_INVALID', path: 'docs_only', message: 'must be { allow, deny?, tests? } or null' }];
|
|
const out: PolicyError[] = [];
|
|
const list = (key: 'allow' | 'deny' | 'tests', min: number, max: number, check: (p: string) => string | null, hint: string) => {
|
|
const v = d[key];
|
|
if (key !== 'allow' && v === undefined) return;
|
|
if (!Array.isArray(v) || v.length < min || v.length > max) { out.push({ code: 'POLICY_INVALID', path: `docs_only.${key}`, message: `must list ${min} to ${max} entries${hint}` }); return; }
|
|
v.forEach((p, i) => {
|
|
const why = typeof p === 'string' ? check(p) : 'must be a string';
|
|
if (why) out.push({ code: 'POLICY_INVALID', path: `docs_only.${key}[${i}]`, message: `${JSON.stringify(p)} ${why}${hint}` });
|
|
});
|
|
};
|
|
list('allow', 1, MAX_ALLOW, allowError, `; ${NARROW}`);
|
|
list('deny', 0, MAX_DENY, globError, '');
|
|
list('tests', 0, MAX_TESTS, t => globError(t) ?? (/[*?[\]{}]/.test(t) || !TEST_FILE.test(t) || /^\.(?:gstack|github)\//.test(t) ? 'must be one test file path (*.test.ts and the like), not a glob or a path under .gstack/ or .github/' : null), '');
|
|
return out;
|
|
}
|
|
|
|
export interface PolicyWarning { path: string; message: string }
|
|
|
|
/** Keys a newer gstack may add under `at`: ignored here with a warning instead of refusing the whole policy. */
|
|
export function unknownKeyWarnings(at: string, raw: unknown, known: readonly string[]): PolicyWarning[] {
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
|
|
return Object.keys(raw).filter(k => !known.includes(k)).map(k => ({ path: `${at}.${k}`, message: `unknown key, ignored (a newer gstack may read it; check the spelling)${at === 'docs_only' ? '; the docs-only classifier ignores this block\'s allow list while it is present' : ''}` }));
|
|
}
|
|
|