agentic-sdd-framework 1.4.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/.agents/AGENTS.template.md +59 -0
- package/.agents/CONTEXT.template.md +41 -0
- package/.agents/ENTRYPOINT.template.md +31 -0
- package/.agents/skills/ast-navigator/SKILL.md +31 -0
- package/.agents/skills/ast-navigator/adapters/ast-grep.md +18 -0
- package/.agents/skills/ast-navigator/adapters/graphify.md +19 -0
- package/.agents/skills/ast-navigator/adapters/lsp.md +16 -0
- package/.agents/skills/ast-navigator/adapters/ripgrep.md +19 -0
- package/.agents/skills/auditor-executor-protocol/SKILL.md +410 -0
- package/.agents/skills/auditor-executor-protocol/references/autonomous-mode.md +144 -0
- package/.agents/skills/auditor-executor-protocol/references/failure-modes-and-example.md +103 -0
- package/.agents/skills/auditor-executor-protocol/references/handoffs.md +133 -0
- package/.agents/skills/auditor-executor-protocol/references/tasks-and-gates.md +81 -0
- package/.agents/skills/no-ai-slop/LICENSE +21 -0
- package/.agents/skills/no-ai-slop/SKILL.md +52 -0
- package/.agents/skills/strategic-cto/SKILL.md +54 -0
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/docs/SPEC_TEMPLATE.md +78 -0
- package/docs/decisions/ADR_TEMPLATE.md +49 -0
- package/docs/guidelines/AST_NAVIGATION.md +51 -0
- package/docs/guides/AGENT_CREDENTIALS.md +75 -0
- package/docs/guides/GITHUB_CLI_SETUP.md +74 -0
- package/docs/incidents/0000-00-00-incident-template.md +35 -0
- package/docs/roadmap/templates/compliance-log.md +37 -0
- package/docs/roadmap/templates/execution-guide.md +75 -0
- package/docs/roadmap/templates/plan-of-record.md +49 -0
- package/package.json +49 -0
- package/scripts/check-copy-slop.js +120 -0
- package/scripts/check-file-size.js +66 -0
- package/scripts/check-spec.js +201 -0
- package/scripts/check-system-prerequisites.js +133 -0
- package/scripts/check-versions.js +50 -0
- package/scripts/dev/fuzz-spec-markup.js +123 -0
- package/scripts/dev/set-npm-publish-token.sh +40 -0
- package/scripts/dev/sync-vendored.js +94 -0
- package/scripts/install-git-hooks.js +103 -0
- package/scripts/lib/cli.js +60 -0
- package/scripts/lib/config.js +111 -0
- package/scripts/lib/git.js +211 -0
- package/scripts/lib/markdown.js +46 -0
- package/scripts/lib/provision.js +323 -0
- package/scripts/lib/runner.js +70 -0
- package/scripts/lib/sdd.config.schema.json +213 -0
- package/scripts/lib/slop-patterns.js +57 -0
- package/scripts/lib/spec-markup.js +346 -0
- package/scripts/lib/spec.js +226 -0
- package/scripts/lib/state.js +107 -0
- package/scripts/lib/vendor/README.md +11 -0
- package/scripts/lib/vendor/markdown-it.LICENSE +22 -0
- package/scripts/lib/vendor/markdown-it.min.js +3 -0
- package/scripts/quality-gate.js +151 -0
- package/scripts/sdd-init.js +245 -0
- package/scripts/sdd-verify.js +176 -0
- package/scripts/verify-no-secrets.js +216 -0
- package/sdd.config.json +33 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/install-git-hooks.js
|
|
5
|
+
*
|
|
6
|
+
* Installs a pre-push hook that runs the quality gate on the commits being pushed
|
|
7
|
+
* (`quality-gate.js --push`), not on the working tree.
|
|
8
|
+
*
|
|
9
|
+
* The hook goes into the repository's own hooks directory (`git rev-parse --git-path
|
|
10
|
+
* hooks`, shared by linked worktrees). When core.hooksPath is set, the hooks directory is
|
|
11
|
+
* owned by a hook manager or shared across repositories, so nothing is installed and the
|
|
12
|
+
* command to add to that manager is printed instead. An existing unmanaged pre-push hook
|
|
13
|
+
* is preserved as pre-push.local and runs first.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { git, repoRoot } = require('./lib/git');
|
|
19
|
+
|
|
20
|
+
const MARKER = 'sdd:managed';
|
|
21
|
+
// Signature of the hook written by framework versions before the marker existed.
|
|
22
|
+
const LEGACY_SIGNATURE = '# Pre-push hook: Agentic SDD Quality Gate';
|
|
23
|
+
|
|
24
|
+
const toPosix = p => p.split(path.sep).join('/');
|
|
25
|
+
|
|
26
|
+
function renderHook(gateScript, nodePath) {
|
|
27
|
+
return `#!/bin/sh
|
|
28
|
+
# ${MARKER} pre-push hook installed by the Agentic SDD Framework.
|
|
29
|
+
# A pre-existing hook, if any, was moved to pre-push.local and runs first.
|
|
30
|
+
HOOK_DIR=$(dirname "$0")
|
|
31
|
+
INPUT=$(cat)
|
|
32
|
+
if [ -x "$HOOK_DIR/pre-push.local" ]; then
|
|
33
|
+
printf '%s\\n' "$INPUT" | "$HOOK_DIR/pre-push.local" "$@" || exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# GUI git clients may not have node on PATH; fall back to the node that installed the hook.
|
|
37
|
+
NODE_BIN=node
|
|
38
|
+
command -v node >/dev/null 2>&1 || NODE_BIN="${nodePath}"
|
|
39
|
+
|
|
40
|
+
if ! printf '%s\\n' "$INPUT" | "$NODE_BIN" "${gateScript}" --push "$1"; then
|
|
41
|
+
echo "Quality gate failed. Push aborted." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function configuredHooksPath(root) {
|
|
48
|
+
try {
|
|
49
|
+
return git(['config', '--get', 'core.hooksPath'], root).trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function install({ root = repoRoot(), gateScript = path.join(__dirname, 'quality-gate.js'), nodePath = process.execPath } = {}) {
|
|
56
|
+
// Git runs the hook from the real (symlink-resolved) worktree path, so the relative
|
|
57
|
+
// path must be computed between real paths too.
|
|
58
|
+
const relativeGate = toPosix(path.relative(fs.realpathSync(root), fs.realpathSync(gateScript)));
|
|
59
|
+
const hooksPath = configuredHooksPath(root);
|
|
60
|
+
if (hooksPath) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`core.hooksPath is set (${hooksPath}); that directory belongs to a hook manager or is shared by other repositories, so no hook was installed. ` +
|
|
63
|
+
`Add this to its pre-push hook, passing the hook's stdin: node ${relativeGate} --push "$1"`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
let hooksDir;
|
|
67
|
+
try {
|
|
68
|
+
hooksDir = path.resolve(root, git(['rev-parse', '--git-path', 'hooks'], root).trim());
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`${root} is not a git repository.`);
|
|
71
|
+
}
|
|
72
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const hookPath = path.join(hooksDir, 'pre-push');
|
|
75
|
+
const localPath = path.join(hooksDir, 'pre-push.local');
|
|
76
|
+
let preserved = false;
|
|
77
|
+
const current = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : null;
|
|
78
|
+
if (current !== null && !current.includes(MARKER) && !current.includes(LEGACY_SIGNATURE)) {
|
|
79
|
+
if (fs.existsSync(localPath)) {
|
|
80
|
+
throw new Error(`Both ${hookPath} and pre-push.local exist. Merge them manually, then rerun.`);
|
|
81
|
+
}
|
|
82
|
+
fs.renameSync(hookPath, localPath);
|
|
83
|
+
preserved = true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Git runs hooks from the worktree root, so a root-relative path works in every worktree.
|
|
87
|
+
fs.writeFileSync(hookPath, renderHook(relativeGate, toPosix(nodePath)), { mode: 0o755 });
|
|
88
|
+
fs.chmodSync(hookPath, 0o755);
|
|
89
|
+
return { hookPath, preserved };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (require.main === module) {
|
|
93
|
+
try {
|
|
94
|
+
const { hookPath, preserved } = install();
|
|
95
|
+
if (preserved) console.log('ℹ️ Existing pre-push hook preserved as pre-push.local (runs first).');
|
|
96
|
+
console.log(`✅ Pre-push quality gate hook installed at ${hookPath}\n`);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`❌ ${error.message}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { MARKER, install, renderHook };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/cli.js
|
|
3
|
+
*
|
|
4
|
+
* Shared entry point for the check scripts: parses the source flags, prints the banner,
|
|
5
|
+
* and turns any thrown error into a failing exit code instead of a stack trace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { repoRoot } = require('./git');
|
|
9
|
+
|
|
10
|
+
const SOURCE_USAGE = '[--staged | --ref=<commit>]';
|
|
11
|
+
|
|
12
|
+
// Strict flag parsing: --staged -> index, --ref=<commit> or --ref <commit> -> that commit,
|
|
13
|
+
// no flag -> the working tree. With allowPush, --push [remote] selects pre-push mode.
|
|
14
|
+
// Unknown flags are errors, so a typo never silently checks the working tree instead.
|
|
15
|
+
function parseCheckArgs(argv, { allowPush = false } = {}) {
|
|
16
|
+
const usage = `Usage: ${SOURCE_USAGE}${allowPush ? ' | --push [remote]' : ''}`;
|
|
17
|
+
const options = { source: { kind: 'worktree' }, push: false, remoteName: '' };
|
|
18
|
+
const chosen = [];
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (arg === '--staged') {
|
|
22
|
+
options.source = { kind: 'index' };
|
|
23
|
+
chosen.push(arg);
|
|
24
|
+
} else if (arg === '--ref' || arg.startsWith('--ref=')) {
|
|
25
|
+
const ref = arg === '--ref' ? argv[++i] : arg.slice('--ref='.length);
|
|
26
|
+
if (!ref || ref.startsWith('-')) throw new Error(`--ref needs a commit. ${usage}`);
|
|
27
|
+
options.source = { kind: 'ref', ref };
|
|
28
|
+
chosen.push('--ref');
|
|
29
|
+
} else if (allowPush && arg === '--push') {
|
|
30
|
+
options.push = true;
|
|
31
|
+
chosen.push(arg);
|
|
32
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) options.remoteName = argv[++i];
|
|
33
|
+
// git passes the remote URL as a second argument to pre-push hooks.
|
|
34
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) i++;
|
|
35
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
36
|
+
throw new Error(usage);
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(`Unknown argument "${arg}". ${usage}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (chosen.length > 1) throw new Error(`${chosen.join(' and ')} are separate modes. ${usage}`);
|
|
42
|
+
return options;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runCheckCli(title, run, argv = process.argv.slice(2)) {
|
|
46
|
+
console.log('\n======================================================');
|
|
47
|
+
console.log(` ${title}`);
|
|
48
|
+
console.log('======================================================\n');
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
const { source } = parseCheckArgs(argv);
|
|
52
|
+
result = run({ root: repoRoot(), source });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
result = { ok: false, report: `❌ ${error.message}` };
|
|
55
|
+
}
|
|
56
|
+
(result.ok ? console.log : console.error)(result.report + '\n');
|
|
57
|
+
process.exit(result.ok ? 0 : 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { parseCheckArgs, runCheckCli };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/config.js
|
|
3
|
+
*
|
|
4
|
+
* Loads and validates sdd.config.json against sdd.config.schema.json (same directory).
|
|
5
|
+
* A missing file yields an empty object so every check uses its documented defaults.
|
|
6
|
+
* An invalid file is an error: a typo must not silently switch a check off.
|
|
7
|
+
* Keys starting with "x-" are free-form extensions and are not validated.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { readFile, WORKTREE } = require('./git');
|
|
12
|
+
|
|
13
|
+
const SCHEMA = require('./sdd.config.schema.json');
|
|
14
|
+
|
|
15
|
+
const canonical = value => value.replace(/\\/g, '/').split('/').filter(s => s && s !== '.').join('/');
|
|
16
|
+
|
|
17
|
+
function typeOf(value) {
|
|
18
|
+
if (Array.isArray(value)) return 'array';
|
|
19
|
+
if (value === null) return 'null';
|
|
20
|
+
if (Number.isInteger(value)) return 'integer';
|
|
21
|
+
return typeof value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Validates the subset of JSON Schema used by sdd.config.schema.json.
|
|
25
|
+
function validate(value, schema, at, errors) {
|
|
26
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
27
|
+
errors.push(`${at}: ${JSON.stringify(value)} is not one of ${schema.enum.map(v => JSON.stringify(v)).join(', ')}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (schema.type) {
|
|
31
|
+
const actual = typeOf(value);
|
|
32
|
+
const ok = actual === schema.type || (schema.type === 'number' && actual === 'integer');
|
|
33
|
+
if (!ok) {
|
|
34
|
+
errors.push(`${at}: expected ${schema.type}, got ${actual}`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (typeof schema.minLength === 'number' && typeof value === 'string' && value.length < schema.minLength) {
|
|
39
|
+
errors.push(`${at}: must not be empty`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof schema.minimum === 'number' && typeof value === 'number' && value < schema.minimum) {
|
|
42
|
+
errors.push(`${at}: must be >= ${schema.minimum}`);
|
|
43
|
+
}
|
|
44
|
+
if (schema['x-relative-path'] && typeof value === 'string' && value !== '') {
|
|
45
|
+
// Paths are compared as written with Git's paths, so only the canonical form works:
|
|
46
|
+
// forward slashes, no ".", "..", or empty segments (a trailing "/" marks a directory).
|
|
47
|
+
const segments = value.replace(/\/$/, '').split('/');
|
|
48
|
+
if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || segments.includes('..')) {
|
|
49
|
+
errors.push(`${at}: must be a path relative to the repository root, without ".."`);
|
|
50
|
+
} else if (value.includes('\\') || segments.some(s => s === '' || s === '.')) {
|
|
51
|
+
errors.push(`${at}: write the path in canonical form (forward slashes, no "./" or "//"), e.g. "${canonical(value)}"`);
|
|
52
|
+
} else if (schema['x-file-path'] && value.endsWith('/')) {
|
|
53
|
+
errors.push(`${at}: must not end with "/"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (schema.items && Array.isArray(value)) {
|
|
57
|
+
value.forEach((item, i) => validate(item, schema.items, `${at}[${i}]`, errors));
|
|
58
|
+
}
|
|
59
|
+
if (schema.properties && typeOf(value) === 'object') {
|
|
60
|
+
for (const [key, child] of Object.entries(value)) {
|
|
61
|
+
const where = `${at}.${key}`;
|
|
62
|
+
// Own properties only: "toString" or "__proto__" must not resolve to Object.prototype.
|
|
63
|
+
if (Object.prototype.hasOwnProperty.call(schema.properties, key)) {
|
|
64
|
+
validate(child, schema.properties[key], where, errors);
|
|
65
|
+
} else if (schema.patternProperties && Object.keys(schema.patternProperties).some(p => new RegExp(p).test(key))) {
|
|
66
|
+
continue;
|
|
67
|
+
} else if (schema.additionalProperties === false) {
|
|
68
|
+
const known = Object.keys(schema.properties).join(', ');
|
|
69
|
+
errors.push(`${where}: unknown key (valid keys: ${known}; use an "x-" prefix for custom keys)`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateConfig(config) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
validate(config, SCHEMA, 'sdd.config.json', errors);
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseConfig(text) {
|
|
82
|
+
let config;
|
|
83
|
+
try {
|
|
84
|
+
config = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(`sdd.config.json is not valid JSON: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
const errors = validateConfig(config);
|
|
89
|
+
if (errors.length > 0) {
|
|
90
|
+
throw new Error(`sdd.config.json is invalid:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
91
|
+
}
|
|
92
|
+
return config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reads the configuration from the same source the checks read (working tree, index, commit).
|
|
96
|
+
function loadConfig(root, source = WORKTREE) {
|
|
97
|
+
const buffer = readFile(root, 'sdd.config.json', source);
|
|
98
|
+
return buffer ? parseConfig(buffer.toString('utf8')) : {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// getIn(config, 'capabilities.noAiSlop.enabled', true)
|
|
102
|
+
function getIn(object, keyPath, fallback) {
|
|
103
|
+
let current = object;
|
|
104
|
+
for (const key of keyPath.split('.')) {
|
|
105
|
+
if (current === null || typeof current !== 'object' || !(key in current)) return fallback;
|
|
106
|
+
current = current[key];
|
|
107
|
+
}
|
|
108
|
+
return current === undefined ? fallback : current;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { SCHEMA, validateConfig, parseConfig, loadConfig, getIn };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/git.js
|
|
3
|
+
*
|
|
4
|
+
* Git access for the quality gate checks. Every check reads files from a *source*:
|
|
5
|
+
*
|
|
6
|
+
* { kind: 'worktree' } tracked files as they are on disk (default)
|
|
7
|
+
* { kind: 'index' } the staged content (what the next commit will contain)
|
|
8
|
+
* { kind: 'ref', ref: '<sha>' } the content of a commit (used by the pre-push hook)
|
|
9
|
+
*
|
|
10
|
+
* Failures are errors, never empty results: a check that cannot read the repository
|
|
11
|
+
* must fail instead of reporting "0 files, all clean".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
19
|
+
const WORKTREE = Object.freeze({ kind: 'worktree' });
|
|
20
|
+
const GITLINK_MODE = '160000';
|
|
21
|
+
|
|
22
|
+
function git(args, cwd, options = {}) {
|
|
23
|
+
return execFileSync('git', args, {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
maxBuffer: MAX_BUFFER,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
...options
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function describeGitError(error) {
|
|
33
|
+
const stderr = error.stderr ? error.stderr.toString().trim() : '';
|
|
34
|
+
return stderr || error.message;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function repoRoot(cwd = process.cwd()) {
|
|
38
|
+
try {
|
|
39
|
+
return git(['rev-parse', '--show-toplevel'], cwd).trim();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`Not inside a usable git repository (${cwd}): ${describeGitError(error)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function describeSource(source = WORKTREE) {
|
|
46
|
+
if (source.kind === 'index') return 'staged changes';
|
|
47
|
+
if (source.kind === 'ref') return `commit ${source.ref.slice(0, 12)}`;
|
|
48
|
+
return 'working tree';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parses `ls-files -s -z` ("<mode> <oid> <stage>\t<path>") or `ls-tree -r -z`
|
|
52
|
+
// ("<mode> <type> <oid>\t<path>") output. Paths may contain any byte except NUL.
|
|
53
|
+
function parseEntries(output, format) {
|
|
54
|
+
const entries = [];
|
|
55
|
+
for (const record of output.split('\0')) {
|
|
56
|
+
if (!record) continue;
|
|
57
|
+
const tab = record.indexOf('\t');
|
|
58
|
+
const fields = record.slice(0, tab).split(' ');
|
|
59
|
+
const entry = format === 'ls-tree'
|
|
60
|
+
? { mode: fields[0], oid: fields[2], stage: '0', path: record.slice(tab + 1) }
|
|
61
|
+
: { mode: fields[0], oid: fields[1], stage: fields[2], path: record.slice(tab + 1) };
|
|
62
|
+
if (entry.mode !== GITLINK_MODE) entries.push(entry);
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Path -> blob id for every tracked file in an index or commit source. During a merge
|
|
68
|
+
// conflict the index holds several stages; stage 0 wins, otherwise "ours" (stage 2).
|
|
69
|
+
function entriesOf(root, source) {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = source.kind === 'ref'
|
|
73
|
+
? parseEntries(git(['ls-tree', '-r', '-z', source.ref], root), 'ls-tree')
|
|
74
|
+
: parseEntries(git(['ls-files', '-s', '-z'], root), 'ls-files');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error(`Cannot list files from the ${describeSource(source)}: ${describeGitError(error)}`);
|
|
77
|
+
}
|
|
78
|
+
const byPath = new Map();
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const current = byPath.get(entry.path);
|
|
81
|
+
if (!current || entry.stage === '0' || (current.stage !== '0' && entry.stage === '2')) byPath.set(entry.path, entry);
|
|
82
|
+
}
|
|
83
|
+
return byPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Files a check should look at. Submodule entries (gitlinks) are not files and are skipped.
|
|
87
|
+
// For the index, only files the next commit changes; use listTrackedFiles for all of them.
|
|
88
|
+
function listFiles(root, source = WORKTREE, { changedOnly = true } = {}) {
|
|
89
|
+
const paths = [...entriesOf(root, source).keys()];
|
|
90
|
+
if (source.kind !== 'index' || !changedOnly) return paths;
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
// T: a symlink or submodule replaced by a regular file is new content too.
|
|
94
|
+
names = git(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMRT'], root);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error(`Cannot list staged changes: ${describeGitError(error)}`);
|
|
97
|
+
}
|
|
98
|
+
const staged = new Set(names.split('\0').filter(Boolean));
|
|
99
|
+
return paths.filter(p => staged.has(p));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every tracked file in the source (for the index: the full staged tree).
|
|
103
|
+
function listTrackedFiles(root, source = WORKTREE) {
|
|
104
|
+
return listFiles(root, source, { changedOnly: false });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readWorktreeFile(root, file) {
|
|
108
|
+
const full = path.join(root, file);
|
|
109
|
+
let stat;
|
|
110
|
+
try {
|
|
111
|
+
stat = fs.lstatSync(full);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT') return null; // tracked but deleted in the working tree
|
|
114
|
+
throw new Error(`Cannot read ${file}: ${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
// Git stores a symlink as its target path; read that, never the file it points to.
|
|
117
|
+
if (stat.isSymbolicLink()) return Buffer.from(fs.readlinkSync(full));
|
|
118
|
+
if (!stat.isFile()) return null;
|
|
119
|
+
try {
|
|
120
|
+
return fs.readFileSync(full);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Cannot read ${file}: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function catFile(root, mode, oids, maxBuffer) {
|
|
127
|
+
const proc = spawnSync('git', ['cat-file', mode], { cwd: root, input: oids.join('\n') + '\n', maxBuffer });
|
|
128
|
+
if (proc.error || proc.status !== 0) {
|
|
129
|
+
throw new Error(`git cat-file failed: ${proc.error ? proc.error.message : proc.stderr.toString().trim()}`);
|
|
130
|
+
}
|
|
131
|
+
return proc.stdout;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Object sizes from `cat-file --batch-check`, so blobs can be read in bounded batches.
|
|
135
|
+
function blobSizes(root, oids, maxBuffer) {
|
|
136
|
+
const lines = catFile(root, '--batch-check', oids, maxBuffer).toString().split('\n');
|
|
137
|
+
return oids.map((oid, i) => {
|
|
138
|
+
const [gotOid, type, size] = (lines[i] || '').split(' ');
|
|
139
|
+
if (gotOid !== oid || type !== 'blob') {
|
|
140
|
+
throw new Error(`git cat-file returned "${lines[i]}" for object ${oid}; the repository may be corrupt.`);
|
|
141
|
+
}
|
|
142
|
+
return Number(size);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Reads blobs by object id with `git cat-file --batch`, in batches of about BATCH_BYTES so
|
|
147
|
+
// the output always fits the buffer (a larger blob gets a batch of its own). Object ids
|
|
148
|
+
// never contain newlines, so arbitrary file names cannot desynchronize the batch; every
|
|
149
|
+
// response is checked against the id that was requested.
|
|
150
|
+
const BATCH_BYTES = 32 * 1024 * 1024;
|
|
151
|
+
|
|
152
|
+
function readBlobs(root, oids, { batchBytes = BATCH_BYTES, maxBuffer = MAX_BUFFER } = {}) {
|
|
153
|
+
const result = new Map();
|
|
154
|
+
const unique = [...new Set(oids)];
|
|
155
|
+
if (unique.length === 0) return result;
|
|
156
|
+
const sizes = blobSizes(root, unique, maxBuffer);
|
|
157
|
+
let batch = [];
|
|
158
|
+
let bytes = 0;
|
|
159
|
+
const flush = () => {
|
|
160
|
+
if (batch.length === 0) return;
|
|
161
|
+
readBatch(root, batch, Math.max(maxBuffer, bytes + 1024 * 1024), result);
|
|
162
|
+
batch = [];
|
|
163
|
+
bytes = 0;
|
|
164
|
+
};
|
|
165
|
+
unique.forEach((oid, i) => {
|
|
166
|
+
if (batch.length > 0 && bytes + sizes[i] > batchBytes) flush();
|
|
167
|
+
batch.push(oid);
|
|
168
|
+
bytes += sizes[i] + 128; // header and trailing newline
|
|
169
|
+
});
|
|
170
|
+
flush();
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readBatch(root, oids, maxBuffer, result) {
|
|
175
|
+
const out = catFile(root, '--batch', oids, maxBuffer);
|
|
176
|
+
let offset = 0;
|
|
177
|
+
for (const oid of oids) {
|
|
178
|
+
const newline = out.indexOf(10, offset);
|
|
179
|
+
const header = newline === -1 ? '' : out.subarray(offset, newline).toString();
|
|
180
|
+
const [gotOid, type, sizeText] = header.split(' ');
|
|
181
|
+
if (gotOid !== oid || type !== 'blob') {
|
|
182
|
+
throw new Error(`git cat-file returned "${header}" for object ${oid}; the repository may be corrupt.`);
|
|
183
|
+
}
|
|
184
|
+
const size = Number(sizeText);
|
|
185
|
+
offset = newline + 1;
|
|
186
|
+
result.set(oid, out.subarray(offset, offset + size));
|
|
187
|
+
offset += size + 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Map<file, Buffer|null>; null means the file does not exist in that source.
|
|
192
|
+
function readFiles(root, files, source = WORKTREE) {
|
|
193
|
+
if (source.kind === 'worktree') {
|
|
194
|
+
return new Map(files.map(f => [f, readWorktreeFile(root, f)]));
|
|
195
|
+
}
|
|
196
|
+
const entries = entriesOf(root, source);
|
|
197
|
+
const blobs = readBlobs(root, files.filter(f => entries.has(f)).map(f => entries.get(f).oid));
|
|
198
|
+
return new Map(files.map(f => [f, entries.has(f) ? blobs.get(entries.get(f).oid) : null]));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readFile(root, file, source = WORKTREE) {
|
|
202
|
+
return readFiles(root, [file], source).get(file);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isBinary(buffer) {
|
|
206
|
+
return buffer.subarray(0, 8000).includes(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
WORKTREE, git, describeGitError, repoRoot, describeSource, listFiles, listTrackedFiles, readFiles, readFile, readBlobs, isBinary
|
|
211
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/markdown.js
|
|
3
|
+
*
|
|
4
|
+
* Minimal Markdown helpers shared by the prose linter and the spec parser.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Normalizes CRLF/CR line endings so line-anchored regexes behave the same on Windows.
|
|
8
|
+
function normalizeEol(text) {
|
|
9
|
+
return text.replace(/\r\n?/g, '\n');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function detectEol(text) {
|
|
13
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Tracks fenced code blocks. A fence closes only with the same character repeated at
|
|
17
|
+
// least as many times as the opening fence, and nothing else on the line (CommonMark).
|
|
18
|
+
// Any number of spaces is accepted because fences nested in list items are indented.
|
|
19
|
+
function createFenceTracker() {
|
|
20
|
+
let open = null;
|
|
21
|
+
return {
|
|
22
|
+
get inside() {
|
|
23
|
+
return open !== null;
|
|
24
|
+
},
|
|
25
|
+
// Returns true when the line is a fence delimiter (opening or closing).
|
|
26
|
+
update(line) {
|
|
27
|
+
// Only spaces may precede a fence (CommonMark); a tab or other whitespace makes
|
|
28
|
+
// it something else, which the spec parser reports separately.
|
|
29
|
+
const match = line.match(/^ *(`{3,}|~{3,})(.*)$/);
|
|
30
|
+
if (!match) return false;
|
|
31
|
+
const [, marker, rest] = match;
|
|
32
|
+
if (open === null) {
|
|
33
|
+
if (marker[0] === '`' && rest.includes('`')) return false;
|
|
34
|
+
open = marker;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (marker[0] === open[0] && marker.length >= open.length && rest.trim() === '') {
|
|
38
|
+
open = null;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { normalizeEol, detectEol, createFenceTracker };
|